【JVM】(二)运行时数据区及内存模型

本文详细介绍了JVM的运行时数据区,包括方法区、堆、虚拟机栈、本地方法栈和程序计数器。重点讲解了虚拟机栈中的栈帧结构,以及对象在堆中的内存布局。同时,阐述了新生代和老年代的内存模型,包括MinorGC和MajorGC的工作原理,以及空间分配担保机制,确保内存的有效管理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.运行时数据区的组成

JDK版本:1.8
在上一篇《【JVM】(一)类的加载过程》中谈到了一个类文件通过类的加载机制,加载到JVM的运行时数据区的过程。其中类元信息被装载到了方法区,而类的对象信息,生成了一个对应的Class对象放入了堆中。
除此之外,运行时数据区还包含了另外的几个部分,下面是一个运行时数据区的模型:
JVM运行时数据区

上面黄色部分每个JVM中只有一份,是线程共享的,生命周期与JVM的生命周期一致。绿色的部分是线程私有的。

①方法区

  • 方法区在JDK6或7中就是Perm Space(永久代),JDK 8中就是Metaspace(元空间)。
  • 在方法区中存储的有:类信息、常量、静态变量,即时编译器编译后的代码等。
  • 方法区内存不足,会报OutOfMemoryError。

②堆

  • 堆是JVM所管理内存的最大一部分,在虚拟机启动时启动。
  • Java中的对象实例和数组都在堆上分配。
  • 堆内存不足,也会报OutOfMemoryError。

③虚拟机栈
根据之前的类加载过程可以知道类是如何装载到JVM中的,那么被装载到JVM中的类是如何被使用起来的呢?这里就得提到虚拟机栈。

Java服务的运行就是一个个方法的调用,调用方法的单位是线程,每个线程都是属于自己的虚拟机栈。线程每调用一次方法就会在虚拟机栈中压入一个栈帧,方法结束后就会将这个栈帧弹出。
如果压入的栈帧过多,超过了栈的深度,就会报StackOverflowError。
在这里插入图片描述

④本地方法栈
与虚拟机栈结构一致,区别在于本地方法栈执行的是Java底层由C++编写的native方法。

⑤程序计数器
在多线程的环境下,每一个线程在运行的过程中都可能遇到被其它的线程抢占了CPU的资源,当它重新抢回CPU资源时,肯定是需要从它中断的位置继续往下执行的。
程序计数器中记录的是正在执行的虚拟机字节码指令的地址,也就是记录当前线程执行到哪儿了。

2.虚拟机栈

我们已经知道了,每个线程都有自己私有的栈,每调用一次方法就会往自己私有的栈中压入一个栈帧,调用完成后栈帧弹出。显然,方法中的代码逻辑的执行与栈帧息息相关。
那么栈帧里面究竟有什么东西,又是如何运作的呢?

2.1.栈帧的组成

栈帧实际上就是一个方法的运行空间,里面主要包括局部变量表、操作数栈、动态链接、方法返回地址这几个组成部分。
在解释这几个组成部分之前,先了解一下Java的字节码指令。

2.1.1.反编译和字节码指令

准备一个简单的Java类,做一个简单的加法运算。

public class Test {
    public static int add(int a, int b) {
        int c = a + b;
        return c;
    }
}

先使用我们在学习Java的第一个Hello World时的操作,使用javac指令编译,获取一个Test.class文件。再使用反编译指令查看这个类中的字节码指令。

javap -c Test
// 也可以把信息输出到指定的文件中
javap -c Test > Test.txt

完成后就可以在Test.txt中查看Test类的字节码指令了。add()的字节码指令如下:

public static int add(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: istore_2
       4: iload_2
       5: ireturn

这些字节码的含义可以通过官网查询官网字节码指令,当然也有先驱大佬整理好了各种字节码指令的含义,可以百度搜索Java字节码指令表。

其实在这个简单的方法中,结合上面的源码来看就可以知道大概的意思。

  • iload_n:从局部变量表的第n个位置取出值压入操作数栈栈顶(下面简称栈顶)。
  • iadd:将操作数栈中的数值弹出,做加法运算,再压入栈顶。
  • istore_n:将栈顶的值弹出,存入局部变量表中的第n个位置。
  • ireturn:将栈顶值弹出并返回。

2.1.2.方法运行图解

在上面的简单代码中加入一个main方法再运行,用图来说明一下栈里面的发生了一些什么事。

 public static void main(String[] args) {
        int add = add(1, 2);
 }

在这里插入图片描述

2.1.3.栈帧中各部分的含义

  • 局部变量表:存放方法中定义的局部变量及其参数值。
  • 操作数栈:与局部变量表交互,通过压栈、弹栈的方式对操作数做运算。
  • 动态链接:在运行时才能确定某些对象的类型,再将变量与对象链接起来,比如常见的多态、泛型等。
  • 方法返回地址:在方法中继续调用其他方法的时候,会压入第二个栈帧,当这个栈帧弹栈后,需要找到第一个栈帧中执行的位置。方法返回地址分为正常返回和异常返回,正常返回就是外层方法调用内层方法结束的位置,异常返回则会找到异常处理或异常抛出的位置。

3.堆

上面提到了局部变量表中保存的是方法中定义的变量及其参数值,如果定义的变量是引用类型的话,那参数值就是对象的地址,而对象实例则是存放在堆中的。

在聊堆之前,我们先了解一下,存入堆中的对象到底是一个什么样的结构。

3.1.对象内存布局

在这里插入图片描述

3.2.JVM内存模型

主要分为两块,一块是非堆区,一块是堆区。堆又分为两块,新生代和老年代。新生代也分为两块,Eden和Survivor。Survivor中也划分成两块一模一样的大小,S0和S1(From和To)。

内存模型的图解如下:
在这里插入图片描述

3.2.1.新生代

除了一些大对象会直接放入老年代外,大部分对象在创建的时候都被分配到新生代。随着对象创建的越来越多,达到了某一个临界值的时候,就会触发新生代的垃圾回收,也就是Minor GC,GC后释放出的内存空间又可以分配更多新的对象。

GC后的内存间隙
GC后的新生代内存是不连续的,因为内存之间一个个间隙都小于某个新建的对象大小,就会再次触发GC,这样会导致GC更加频繁。
在这里插入图片描述
如上图,新的object在新生代分配的时候没有一个足够大的连续内存空间供它使用,这种情况就会先GC一次,再做分配。如果此时新生代的内存空间是连续的,就可以直接进行分配。

3.2.1.1.Eden和Survivor

为了解决这个问题,将新生代划分成了Eden区和Survivor区,每次Minor GC后,将未回收的对象复制到Survivor区,然后将Eden区完全清空,这样就不存在内存间隙的问题,让Eden能够分配更多的对象。

但是这样的划分存在同样的内存间隙问题,Minor GC是针对新生代的回收,也就是说在Survivor区也可能存在回收后的内存间隙问题。

为了解决这个问题,又将Survivor划分成了两块一模一样的区域,叫做S0区和S1区,也叫From区和To区,新生代的垃圾回收就变成了下面的步骤:

  • 第一次Minor GC的时候将幸存的对象复制到S0区。
  • 下一次Minor GC时,将Eden和S0幸存的对象复制到S1,清空S0。
  • 再下一次Minor GC时,将Eden和S1的幸存对象复制到S0,清空S1。
  • ……依次类推

S0和S1可以相互替换,这样用于分配对象的内存空间就一直都是连续的。此外,由于在新生代中,大部分的对象都是“朝生夕死”的,所以Survivor区不用太大,Eden和S0、S1的比值默认为8:1:1。

3.2.1.2.对象什么时候进入老年代
  • 在新生代中,每一次Minor GC后,幸存的对象在对象头中的分代年龄就会 + 1,在达到一定的年龄之后(默认是存活15次GC,可以通过参数-XX:MaxTenuringThreshold设置)会被复制到老年代。
  • 大对象直接进入老年代(界限值可以通过-XX:PretenureSizeThreshold 配置,没有默认值)。
  • 动态年龄判断,Survivor空间中相同年龄的对象总大小大于一半,会将年龄大于等于这批对象的所有对象复制到老年代。

3.2.2.老年代

老年代是存放大部分长期存活对象的区域,当老年代内存空间快要被填满时,会触发Major GC并且会伴随着Minor GC,两者加起来就是Full GC。Full GC会导致程序短暂停顿,影响程序的执行速度和运行速度。

3.3.空间分配担保

思考这么一个问题,如果在Minor GC发生时,幸存的对象如果大于了Survivor中的To区时,幸存的对象放不下了,这个时候可能就需要做一次Full GC。但是由于Full GC的代价较大,可能导致程序短时间停顿,所以就有了空间分配担保机制。
所谓的空间分配担保机制,就是在上述情况发生之前,通过历次新生代对象晋升的情况作为参考,判断本次对象晋升时,老年代是否有足够的最大内存连续空间,如果有,就能避免一次Full GC

在Minor GC后,S去放不下的对象可以直接晋升到老年代(年龄大的优先)。

3.3.1.Minor GC前的验证

既然要实现老年代分配,那么必然需要做的就是验证老年代能不能放下待分配的对象,会按下面的步骤进行验证:

  • 验证当前老年代最大连续内存空间是否大于当前新生代所有对象大小的总和,才能够确保此次Minor GC是安全的,此时就会进行Minor GC。
  • 如果上述的条件不成立,则会查看虚拟机配置HandlePromotionFailure的配置(JDK6),如果为True,需要验证当前老年代最大连续空间是否大于历次晋升到老年代的对象平均大小,如果大于则尝试做一次Minor GC,尽管这次Minor GC是有风险的,小于则进行一次Full GC。
  • 如果HandlePromotionFailure为false(JDK6),则会直接进行Full GC。

上面提到的Minor GC存在风险的意思是,虽然验证了大于历次晋升到老年代的对象的平均大小,但是每次晋升的对象总大小基本都是不一样的,可能还是会出现本次晋升的对象大于了当前老年代最大连续内存空间的情况,这样还是会进行Full GC。

虽然绕了这么大一个圈子最后还是可能出现Full GC,但是在这样的验证机制下,只要保证大部分情况只需要使用Minor GC就可以了,避免Full GC过去频繁。


对于HandlePromotionFailure这个参数的配置,在JDK6 Update 24后,已经不再使用了。在做担保验证的时候,不管这个参数设置的值是什么,都会验证当前老年代最大连续内存空间是否大于历次晋升对象大小的平均值或大于当前新生代对象总大小。

3.4.对象申请内存空间图解

在这里插入图片描述

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

挥之以墨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值