【JVM】整体结构(二):运行时数据区

it2025-04-08  17

在上一篇文章我们介绍了类加载子系统,在类的加载阶段的第(2),(3)步可以发现有运行时数据,堆,方法区等名词

(2):将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构(3):在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

说白了运行时数据区就是类文件被类装载器装载进来之后,类中的内容(比如变量,常量,方法,对象等这些数据)得要有个去处,也就是要存储起来,存储的位置肯定是在JVM中有对应的空间

1.Method Area(方法区)

方法区是各个线程共享的内存区域,在虚拟机启动时创建。

1.1 方法区存什么?

方法区是用于存放 class 文件信息和运行时常量池。那 class 文件信息有什么呢?运行时常量池又是什么呢?

1)class 文件的信息

对于 class 简单概括一下就是类信息和静态常量池(Class Constant Pool)

类信息:包括字段表和方法表,以及一些特殊方法如构造函数,接口代码等。更多内容可以参考关于字节码的这篇文章。

class 常量池:用于存放编译时生成的各种字面量和符号引用。

字面量就是我们所说的常量概念。一般包括:文本字符串(“abc")、基本数据类型的值(1)符号引用是一组符号来描述所引用的目标,因为只有在运行时才会知道每个对象的具体地址,比如 A a = new A() 并不知道A对象在运行时的内存地址。一般包括:类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。(描述符是描述字段或方法的类型的字符串)

可以理解成类信息是框架,常量池负责存储具体内容。另外,符号引用和字符串字面量的区别是,符号引用不是直接存储字符串,而是存储字符串在常量池里的索引(#~)

2)运行时常量池

运行时常量池也叫动态常量池,在JVM的类解析阶段,会将class常量池里的符号引用转为直接引用(内存地址)。即动态常量池是静态常量池加载到内存后的版本。

与class常量池相同点:运行时常量池也是每个类都有一个的

与class常量池的区别:

class常量池中存放的是字面量和符号引用,而运行时常量池存放的是直接引用(内存地址)

class常量池在编译就确定了,但运行时常量池可以动态添加内容。例如调用 String 的 intern 方法就能将 string 的值添加到 String 常量池中,这里 String 常量池是包含在动态常量池里的,但在 jdk1.8 后,将 String 常量池放到了堆中

注:final只是将当前变量的访问标识ACC_FINAL置为1,表示当前变量值不可变(线程安全),并不能代表存储位置,更不能说有final它就进入常量池了。示例代码可以参考这篇

PS:这里再说一下字符串常量池。在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享,所以也叫全局常量池。字符串常量由一个一个字符组成,放在了StringTable上。这三个常量池的对比详细见这篇文章。

最后,一定要注意一点,静态变量现在并不是存在于方法区。在JDK7以上版本,静态变量(线程不安全)是存储于定义类型的Class对象中,而Class对象同堆中的其他对象一样都存在与GC堆中。

1.2 永久代和元空间是什么?

《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,,并没有规定如何去实现它。那么,在不同的JVM上方法区的实现肯定是不同的了。而我们最常听见的实现就是永久代和元空间,它们有什么关系呢?

永久代在虚拟机当中,永久代逻辑结构上也属于堆,但是物理上不属于;而元数据区不在虚拟机当中,而是用的本地内存。JDK1.8之后,元空间(Meta Data)替代了永久代,

jdk1.6及以前:有永久代,常量池在方法区

jdk1.7:有永久代,逐步“去永久代”,常量池在堆内存

jdk1.8及以后:无永久代,常量池在元空间(HotSpot JVM --> JRockit JVM)

为什么移除了永久代?参考官方解释。大概意思是移除永久代是为融合HotSpot与 JRockit而做出的努力,因为JRockit没有永久代,不需要配置永久代。

2.Heap(堆)

Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。 用于存放对象的实例和数组,几乎所有对象(包括常量池)都在堆上分配内存,当对象无法在该空间申请到内存是将抛出OutOfMemoryError异常。同时也是垃圾收集器管理的主要区域。

2.1 堆的组成

堆(heap)由两部分组成,新生代(Young)和老年代(Old),而新生代又分为伊甸区(Eden)和幸存者区(Survivor),幸存者区又分为From区(S0)和To区(S1):

新生代(1/3):新生对象 -> MinorGC 伊甸区(Eden space 8/10):新对象一般都创建在伊甸区,经过MinorGC且存活的对象会进入幸存者区幸存者区(Survior space): From(1/10) <–GC15–> To(1/10),同一时间 From 和 To 只能一个有数据一个空 老年代(2/3):大对象 + 多次GC后仍存在的对象 -> FullGC

下面的图直观的展示了堆的组成及各部分的大小关系:

问题一:为什么需要Survivor区?只有Eden不行吗?

如果没有 Survivor,并且没有年龄限制的话,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代。 这样一来,老年代很快被填满,从而触发 Major GC(因为Major GC一般伴随着Minor GC,随意也可以看做触发了Full GC)。

而老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。执行时间长有什么坏处?频发的 Full GC消耗的时间很长,会影响大型程序的执行和响应速度。

可能你会说,那就对老年代的空间进行增加或者较少咯。

假如增加老年代空间,更多存活对象才能填满老年代。虽然降低 Full GC 频率,但是随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长假如减少老年代空间,虽然 Full GC 所需时间减少,但是老年代很快被存活对象填满,Full GC频率增加

所以,Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生。Survivor的预筛选保证只有经历16 次Minor GC后,还能在新生代中存活的对象,才会被送到老年代。

问题二:为什么需要两个Survivor区?

我们现在知道了必须设置Survivor区,也就是说问题是为什么一个Survivor区不行?

假设现在只有一个Survivor区,我们来模拟一下流程: 刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候问题来了。

此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。

所以,两个Survivor区最大的好处就是解决了碎片化,永远有一个Survivor space是空的,另一个非空的Survivor space无碎片。

2.2 对象应该在哪里创建?

既然堆中有这么多区域,那一个对象的创建到底在哪个区域呢?一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。

比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了 100M或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect), 这样的GC我们称之为Minor GC,Minor GC指得是Young区的GC。

经过GC之后,有些对象就会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor 区,然后再清空Eden区中的这些对象。 若此时只有Eden区和From中有对象,To 中是空的。 那么进行一次GC操作,From区中对象的年龄就会+1,我们知道Eden区中所有存活的对象会被复制到To 区, From区中还能存活的对象会有两个去处:

若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区如果Eden区和From区没有达到阈值的 对象会被复制到To 区

此时Eden区和From区已经被清空(被GC的对象肯定没了,没有被GC的对象都有了各自的去处)。 这时候From和To 交换角色,之前的From变成了To ,之前的To 变成了From。也就是说无论如何都要保证名为To 的Survivor区域是空的。 Minor GC会一直重复这样的过程,直到To 区被填满,然后会将所有对象复制到老年代中。

若最后老年代也满了,这时候将发生Major GC(也可以叫 Full GC), 进行老年区的内存清理。若老年区执行了Full GC之后发现依然无法进行对象的保存,就会抛出 OOM(OutOfMemoryError)异常。

下面这张图就叫做对象的一生把:

2.3 方法区和堆的关系?

方法区中包含类的信息,堆中会有对象,那么怎么知道对象时哪个类创建的呢?所以,堆上的所有对象一定要有什么方式指向方法区。具体内容本篇就不说了,在下一篇 【JVM】堆(Heap)上有什么?对象全方位解析。。。

3.Java Virtual Machine Stacks(虚拟机栈)

假如,目前的阶段是初始化完成了,后续做什么呢?肯定是Use使用咯,不用的话这样折腾来折腾去有什么意义?那怎样才能被使用到?换句话说里面内容怎样才能被执行?

比如通过主函数main调用其他方法,这种方式实际上是main线程执行之后调用的方法,即要想使用里面的各种内容,得要以线程为单位,执行相应的方法才行。

那一个线程执行的状态如何维护?一个线程可以执行多少个方法?这样的关系怎么维护呢?

虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建(一个线程对应一个栈)。

每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致。

3.1 栈帧的组成

上面说了栈帧(一个方法就一个栈帧),那栈帧的组成是什么呢?由四部分组成:

局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中 局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用所有在方法里初始化的对象,都是线程私有的,一般安全不存在垃圾回收问题,只要线程一结束就释放,生命周期与线程一致 操作数栈:以压栈和出栈的方式存储操作数的动态链接:指向运行时常量池的引用(A reference to the run-time constant pool)方法出口:方法返回地址。当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且 这个异常没有在方法体内得到处理

PS:一个栈帧需要分配多少内存在程序编译期就已确定,而不会受到程序运行期变量数据的影响

4.The pc Register(程序计数器)

我们都知道一个JVM进程中有多个线程在执行,而线程中的内容是否能够拥有执行权,是根据 CPU调度来的。

假如线程A正在执行到某个地方,突然失去了CPU的执行权,切换到线程B了,然后当线程A再获得CPU执行权的时候,怎么能继续执行呢?这就是需要在线程中维护一个变量,记录线程执行到的位置。

程序计数器占用的内存空间很小,由于Java虚拟机的多线程是通过线程轮流切换,并分配处理器执行时间的方式来实现的,在任意时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程需要有一个独立的程序计数器(线程私有)。

说白了,程序计数器就是一个指针,指向方法区中的方法字节码(用来存储指向吓一跳指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计:

如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址如果正在执行的是Native方法,则这个计数器为空

5.Native Method Stacks(本地方法栈)

和栈作用很相似,都是线程私有,区别不过是Java栈为JVM执行Java方法服务,而本地方法栈为JVM执行native方法服务。登记native 方法,在Execution Engine执行时加载本地方法库

关于执行引擎,请看下一篇文章…

最新回复(0)