JVM内存模型——运行时数据区的特点和作用

2022-12-02 10:46:31 浏览数 (1)

文章目录

  • 前言
  • 1程序计数器
  • 2本地方法栈
  • 3虚拟机栈
    • 3.1局部变量表
    • 3.2操作数栈
    • 3.3动态连接
    • 3.4返回地址
  • 4方法区
  • 5堆
  • 5.1查看方法区跟堆大小
    • 5.2新生代跟老年代
    • 5.3什么时候进老年代
    • 5.4Survivor 区域
    • 5.5对象动态年龄判断
    • 5.6老年代空间分配担保机制
  • 6对象的一辈子
  • 7担保晋升机制

前言

java之所以发展到如今这个规模与生态呢很大程度上源于它的虚拟机,而内存管理又是虚拟机中的一个重要命题。可以说当JVM接手了内存管理的事宜之后呢,相对于C 手动控制管理内存,Java降低了开发者的门槛,也提高了程序的可维护性。那么JVM究竟是如何对内存进行管理的?

1程序计数器

特点:1.线程独有的,2.是JVM中唯一没有OOM的内存区域

程序用来存储字节码指令地址的,由执行引擎读取下一条指令进行执行。

这时候我们来看一下字节码文件,可以看到第一列的数字代表了字节码指令之间的偏移量,叫做bytecode index。这其实呢就是程序计数器所需要读取的数据。今天这节课,字节码指令不是重点,不用仔细看。主要看到这边的bytecode index 为11这行,他的操作指令为goto,操数为 2,代表回到了index为2的那一行指令。这里就提现了源码中的的循环逻辑,也体现了程序计数器的工作方式。

总结: 程序计数器用来存储字节码指令的地址,由执行引擎读取下一条指令执行。

2本地方法栈

特点:线程私有,调用native方法。

3虚拟机栈

特点:线程私有、内部结构是一个个的栈帧结构。

栈帧:是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行是区中虚拟机栈的栈元素。

大家应该知道程序执行的过程对应着方法的调用,而方法的调用实际上对应着栈帧的入栈与出栈。

比如我们写这样一段代码运行时呢,程序会先调用a 方法,那么a 方法将会封装成栈帧入栈。由于a 方法中调用了b 那么b 方法封装成栈帧入栈,然后先执行b 中的逻辑,等于b栈帧出栈,然后再执行a 方法。

可能有的同学在以前写递归代码的时候,稍不留神将会出现栈帧溢出的这种异常情况。原因呢就是没有编写适当的递归退出条件,导致无限量的栈帧入栈,超出了方法栈的最大深度。所以就抛出了stack overflow 的异常。

这里有三点需要注意。

第一点呢就是栈帧。栈帧这个概念呢我们下面会详细的讲到。 目前呢可以简单的将其当做方法调用了一种封装

第二点,栈帧的生成时机。在编译期间无法确定java 方法栈的深度。因为栈帧的生成是根据程序运行时的实际情况来决定的,这是动态的,比如你写了藏有StackOverFlow的递归代码,编译器是无法检查出这种异常的。

第三点,栈帧的组成。在编译期间呢,由于每一个方法的源码都是确定的,而栈帧是根据方法的调用来产生的。那么可以猜想栈帧内部的一些元素是可以确定的。比如说有多少个局部变量,局部存储局部变量所需要的空间等等。还有一些元素呢是无法确定的。比如说该方法与其他方法之间的动态连接

现在我们的关注栈帧:

3.1局部变量表

存储方法里面的参数,还有定义在方法里面的局部变量,(8大基本的数据类型,对象的引用地址,返回值地址。)

栈帧是通过方法源码来生成的。当调用该方法时呢,传入方法的参数类型,局部变量的类型。这些在源码中都是已经确定的。既然数量与类型能够确定,那么需要占用的存储空间也就能够确定。但是怎么进行存储呢?这里在局部变量表中通过四字节的slot(槽)来进行存储。 明白这些后,对局部变量表的理解也就已经够了。

3.2操作数栈

操作数栈的作用有两个:

  1. 存储操作数
  2. 操作数栈方便字节码指令读取操作数。

下面我们来写一个例子,然后使用javap反编译class 文件,得到可读性好一点的字节码。

一起来窥探一下局部变量表在LocalVariableTable这一栏中呢,我们可以看到局部变量表,其中参数args占用了index 为零的slot,并且声明了签名为string类型。

剩下的三个局部变量a b c 呢分别占用了其余的三个slot,签名呢都是int。接下来我们来看操作数栈。

在操作系统层面的操作数是计算机指令的一部分,而这里的操作数在的是JVM层面的,但作用是相似的。顾名思义,这里的操作数栈就是一个用来存储操作数的栈。这里的操作数大部分就是方法内的变量。

那为什么需要使用操作数栈来对操作数(变量)进行入栈和出栈的操作呢? 主要有两个作用,第一点呢就是存储操作数。这里的操作数呢指的是变量以及中间结果。第二点呢就是操作数栈能够方便指令顺序读取操作数,虚拟机的执行引擎在执行字节码指令的时候呢,会通过当前指令类型,从操作数栈中取出栈顶的操作数进行计算,然后再将计算结果入栈,继续执行后续的指令。

可以看到 bytecode index为4和5这两行的对应的字节指令是 iload,iload的含义就是将int 类型的操作数压栈,所以4和5 其实就是将a和b这两个变量压栈,接着就是 iadd指令,它呢就是取出栈顶的两个操作数进行求和计算,并且将计算结果压栈中,接着就是istore这个指令,istore就是将暂定的操作数取出来,并存放在局部变量表。看到这里你是不是对操作数栈有了更加清晰一点的认识。 当然还有隐藏的小彩蛋,如果虚拟机栈中有多个栈帧呢?我们可以想象先执行完的方法的返回值需要被当做后执行方法的变量。这时候怎么办?

指令与含义 iload int类型变量入栈 istore 栈顶int数值存入局部变量 iadd 弹出两个栈顶操作数,并将求和的int值压入栈中

这里首先a方法先进入栈帧,然后再把b方法压入栈帧,b方法入栈之后,会将 a和b两个操作数入栈,通过求和字节码指令计算结果,并且将计算结果存入局部变量表。那么这个中间的结果又将会成为栈帧a的操作数,所以需要从栈帧b中的局部变量表中将该值复制记录到栈帧a的操作数栈。这样做是没有问题的,但是jvm觉得这里可以优化,在jvm的实践中,将两个栈帧的一部分重叠,让下面的栈帧的操作数栈和上面栈帧的部分局部变量表进行重叠。这样看来我们在方法调用的时候就可以共享一部分数据,而无需进行额外的参数复制和传递了

3.3动态连接

引用地址,可以简单理解为直接引用

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)

Java类加载过程中呢,有一个步骤叫连接。JVM将会将class对象中的部分符号引用转换为直接引用。关于符号引用和直接引用,这里不再解释了。我们上面说连接是讲部分的符号引用替换为直接引用,为什么是部分呢?因为对于有些方法JVM能够判断出这些方法所在的具体类型,所以可以大胆放心的对方法进行连接,这个叫静态解析。而对于有些方法,因为多态的存在,JVM无法在加载的阶段就确定被调用的具体类型,只能在运行时真正产生调用的时候。根据实际类型信息来进行连接,这就叫做动态连接.

我们用一个非常简单的例子来说明一下,由于A为抽象类,所以C类在加载过程中无法确定B的具体实现类。

当代码运行时,当方法methodB中调用方法methodA,首先需要查询栈帧A在运行时常量池中的符号引用,然后根据当前的具体类型信息进行动态连接。

3.4返回地址

一个内存地址,在方法执行的时候就需要告知否则程序计数器是无法知道下一步要做什么的。 当出现以下两种情况,当前方法将会返回:

第一种呢就是方法正常执行完成返。 第二种就是方法执行期间遇到了异常情况返回

正常返回的情况,当方法B正常返回就代表方法B执行完成,此时调用栈帧A由于方法B是被方法A调用的,那么在栈帧退出虚拟机的时候,需要把返回的信息压入栈帧的操作数栈,正如我们之前画的那种图,同时需要修改程序计数器的值,让程序能够继续执行下去。

4方法区

存储:比如是类的签名,属性和方法

虚拟机规范中表明:无论你用Hotspot还是JRockit等等,它们的具体实现中,必须要存在方法区这个结构,但具体的实现可以灵活发挥。

我们今天基于主流的HotSpot虚拟机去学习,在JDK8以前HotSpot的开发者将面向堆的分代设计复用在了方法区上,他们使用“永久代”来作为HotSpot上的方法区的实现。但是后来发现这种设计不太优雅。所以从JDK8开始借鉴Jrocket的设计思路,使用了元空间来替代永久代作为新的实现方式。总结来说,“方法区”是抽象“永久代” 和“元空间”是实现。

所以很多人在背八股文的时候,喜欢说JDK8以后元空间代替了方法区。这种是说法是错误的,而且显得非常不专业,如果在面试的过程中是非常败坏好感度的。

至于为什么使用了元空间来替代永久代作为实现方式呢?

下面简单说一下永久带的两个主要缺点。第一呢就是可能引起内存溢出,永久代的大小设置为多少,可以通过启动参数来指定。但其中存储的数据大小是动态变化的,若阈值设置的太小,则可能导致频繁的类卸载或者说内存溢出问题,设置的太大呢,有可能会存在空间浪费,所以将会由此出现一些调优的问题。第二点就是永久代本身设计复杂永久代本身是面向堆来设计的,所以存储在永久带内的对象的内存不是连续,需要通过额外的存储信息以及实现额外的对象查找机制来定位对象。

所以比较麻烦。虚拟机设计团队之所以一开始会使用永久代这种方式来实现方法区呢,是为了进行一定程度的代码复用。但是后来发现存在一些问题,以上两个缺点呢对于方法区来说并不是不可回避的。

目前使用基于直接内存的元空间来代替永久带,就不会有这些问题了。理清楚概念,下面就来看看方法区内到底存储了一些什么东西。类加载的第一个阶段叫做加载。在这个阶段内,虚拟机将会读取被编译的class 文件,生成class 对象。class 对象呢存储了一些类型信息,这些信息呢就是存储在方法区内的这里所说的类型信息。

方法区存储: 如像类的签名,属性和方法。

使用“永久代”实现“方法区”的缺点:

  1. 可能会引起内存溢出
  2. 永久代本身设计就比较复杂,这种设计为了部分的代码复用可能会导致内存空间管 理出现未知异常。

5堆

大部分对象大部分都在堆上分配

是虚拟机所管理的内存中最大的一块。Java堆是被所 有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有 的对象实例以及数组都应当在堆上分配“,后面出现了逃逸分析,有可能有对象在栈上分配。

总结:将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

5.1查看方法区跟堆大小

设置大小 -Xms20M -Xmx20M 打印分配的大小 -XX: PrintGCDetails

默认堆大小 :物理电脑内存大小的 1/64 最大的内存大小:物理电脑内存大小的 1/4

5.2新生代跟老年代

那么我们堆里面的内存到底是怎么样的呢?我们一个一个看 首先,我们想到的是堆就预分配了一块内存!!所有的对象都在一块,当我们要进行GC的时候,会去找所有的对象是否需要回收!!

但是我们的对象大部分都是朝生夕死的,那么我们就想着,能不能把大部分新创建的对象单独放到某一个地方,减少我们GC的范围,那些少部分的非朝生夕死的对象,或者很大的对象放到我们的另外一个地方!!这样我们就有了2个区域,old区跟young区!!比例old占三分之二,young占三分之一。

年龄:一次垃圾回收,没被回收的对象年龄加1,年龄大于15了就移到老年代。 老年代:对象大小特别大。

这样我们就能保证一般情况下只会对我们的young进行GC,那么这个GC的过程叫做young GC或者MinorGC.

5.3什么时候进老年代

既然区分了2个代,那么老年代肯定也是要放数据的,不然就没用了,那什么时候进入老年代!!新创建的对象在新生代,那么进入老年代肯定是需要条件的!

  1. 大对象:通过-XX:PretenureSizeThreshold参数可以设置的,目的是为了减少频繁的youngGC,大对象比较占内存默认值是0,意思是不管多大都是先在eden中分配内存
  2. 年限达到15岁,默认15,可以设定,但是不能超过15,-XX:MaxTenuringThreshold=16就会报错,无法创建JVM,这个是因为java的对象头是4bit来保存分代年龄

肯定,老年代也是需要去进行内存清理的,那么老年代的GC叫做Major GC,MajorGC一般会伴随至少一次MinorGC.

那么full GC就是我的整个堆、元空间等全局范围的GC。

5.4Survivor 区域

我们现在分为老年代跟新生代,比我们之前在一起的时候,GC速率已经快不少了,但是我们又发现了一个问题!!空间碎片,我回收完了之后,有太多碎片,保存不了超过碎片的数据,又会浪费!

所以,又想了个办法,把young区再分Eden区、S0、S1三个区域!那么他们是怎么解决碎片问题的呢?

  1. 我们数据只会存在Eden区、S0或者S1个区域的一个区域,也就是我S0跟S1肯定有一个是空的!
  2. 最开始,我们的数据假如都在Eden或S0区,当Eden满了或者没有足够的连续空间保存我的对象时,会触发MinorGC,所有对象的年龄 1,这个时候会把我们Eden区的对象赋值到S1区,之前S0区的数据就会根据他们的年龄来决定去向,到了去老年代的阀值就去老年代,不然就跟着一起去S1区!
  3. 这样,我们的Eden区跟S0区又空了,就不会有碎片,如此循环!解决碎片问题,就是牺牲我一个小区的空间解决空间碎片问题!

Eden:S0:S1 =8:1:1

5.5对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块S区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1 年龄2 年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor GC之后触发

5.6老年代空间分配担保机制

年轻代每次minor GC之前JVM都会计算下老年代剩余可用空间

  1. 如果这个可用空间小于年轻代里现有的所有对象大小之后(包括垃圾对象),就会看一个“-XX:-HandlerPromotionFailure"(jdk1.8默认是设置了)的参数是否设置了。
  2. 如何有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minorGC后进入老年代的对象的平均大小。
  3. 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次FullGC,对老年代和年轻代一起回收一次垃圾。
  4. 如果回收完还是没有足够空间存放新的对象就会发生“OOM”
  5. 当然,如果minorGC之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发FullGC,FullGC完之后如果还是没有空间放minorGC之后的存活对象,则也会发生“OOM”。

6对象的一辈子

7担保晋升机制

0 人点赞