JVM-深入内存模型与GC

224 阅读17分钟

一、JVM的组成

JVM = 类加载系统+运行时数据区+字节码执行引擎

如下图:

image.png

运行时数据区(JVM内存模型)

1. 方法区:

JDK8之前方法区又叫永久代,8之后又改叫元空间,其中主要存放常量,静态变量,类元信息

2. 栈:

image.png

  • 栈属于线程私有,一个线程一个栈
  • 栈中又有栈帧,一个方法对应一个栈帧
  • 栈帧中又包含局部变量表、操作数栈、动态链接、方法出口

3. 程序计数器:

程序计数器也是属于线程私有,在程序的运行过程中由字节码执行引擎来动态记录下一次要执行的机器码行数

4. 堆:

大部分对象都存放在堆中,少部分情况下存放在栈中(即时编译期间发生了逃逸分析)

栈--举例说明

看下面的代码在JVM内部是如何运行的:

public class MyTest {

    public static void main(String[] args) {
        int result = math();
        System.err.println(result);
    }

    public static int math(){
        int a=1;
        int b=2;
        int c = (a+b)*10;
        return c;
    }
}

我们通过javap -c MyTest.class查看字节码反汇编之后的样子:

public class com.example.spring.jvmTest.MyTest {
  public com.example.spring.jvmTest.MyTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #2                  // Method math:()I
       3: istore_1
       4: getstatic     #3                  // Field java/lang/System.err:Ljava/io/PrintStream;
       7: iload_1
       8: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
      11: return

  public static int math();
    Code:
       0: iconst_1
       1: istore_0
       2: iconst_2
       3: istore_1
       4: iload_0
       5: iload_1
       6: iadd
       7: bipush        10
       9: imul
      10: istore_2
      11: iload_2
      12: ireturn
}

按照上面说的:一个方法一个栈帧,同时遵从栈帧的FILO原则(先进后出),我们来分析一下math()方法

注:JVM字节码指令代表什么意思,百度搜(JVM 虚拟机字节码指令表)

public static int math();
    Code:
       0: iconst_1     // 将int型1推送至操作数栈
       1: istore_0     // 将操作数栈int型数值存入第一个本地变量
       2: iconst_2     // 将int型2推送至操作数栈
       3: istore_1     // 将操作数栈int型数值存入第二个本地变量
       4: iload_0      // 将第一个int型本地变量推送至操作数栈
       5: iload_1      // 将第二个int型本地变量推送至操作数栈
       6: iadd         // 将操作数栈两int型数值相加,并将结果压入操作数栈
       7: bipush        10     //将10推送至操作数栈
       9: imul                 //将操作数栈两int型数值相乘,并将结果压入操作数栈
      10: istore_2             // 将操作数栈int型数值存入第三个本地变量
      11: iload_2              // 将第三个int型本地变量推送至操作数栈
      12: ireturn              // 从当前方法返回int
}

所以得出结论:操作数栈主要用于数据到局部变量表的中转,并用于运算

逃逸分析

JVM的运行模式有三种:

  1. 解释模式:执行一行JVM字节码就编译一行为机器码
  2. 编译模式:先将所有JVM字节码一次编译为机器码,然后一次性执行所有机器码
  3. 混合模式:依然使用解释模式执行代码,但是对于一些 "热点" 代码采用编译模式执行,JVM一般采用混合模式执行代码

解释模式启动快,对于只需要执行部分代码,并且大多数代码只会执行一次的情况比较适合;

编译模式启动慢,但是后期执行速度快,而且比较占用内存,因为机器码的数量至少是JVM字节码的十倍以上,这种模式适合代码可能会被反复执行的场景;

混合模式是JVM默认采用的执行代码方式,一开始还是解释执行,但是对于少部分 “热点 ”代码会采用编译模式执行,这些热点代码对应的机器码会被缓存起来,下次再执行无需再编译,这就是我们常见的JIT(Just In Time Compiler)即时编译技术。

在即时编译过程中JVM可能会对我们的代码做一些优化,比如对象逃逸分析等

看一段代码:

public class MyTest {

    public static void main(String[] args) {
        math();
    }

    public static void math(){
        A a = new A();
    }
}

math()方法中,虽然实例化了一个对象,可这个对象没有返回,没有任何引用,即该对象发生了逃逸分析,放于栈中。

二、GC算法

我们都知道GC主要发生在堆区(方法区也会发生,不过很少),而堆又分为新生代和老年代,新生代又分为Eden区+Survivor0区(From区)+Survivor1区(To 区),如下图:

image.png

  • Minor GC: 发生在年轻代,相对Full GC,Minor GC发生比较频繁,垃圾回收速度也快
  • Full GC: 发生在老年代,会回收老年代,年轻代,方法区的垃圾,一般会比Minor GC的慢10倍以上

如何判断对象可以被回收?

1. 引用计数法:

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

2. 可达性分析算法

将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象

GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

如图:

image.png

3. 判断对象的引用类型

java的引用类型一般分为四种:强引用软引用弱引用虚引用

强引用:普通的变量引用

public static User user = new User();

软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来做缓存

public static SoftReference<User> user = new SoftReference<User>(new User());

至于弱引用和虚引用,和没引用差不多,在GC阶段会直接回收

回收之前最后的救命稻草

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

1. 第一次标记并进行一次筛选。

判断该对象是否覆盖了finalize方法,没有则直接被回收。

2. 第二次标记

如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可。如果对象这时候还没逃脱,那就真的被回收了。

Minor GC过程:

  1. 程序运行后,不断有对象进去Eden区,当Eden区被填满后,会发生Minor GC,一般来说,Minor GC会回收掉90%的垃圾对象,剩余存活的对象会挪到S0区,对象年龄+1
  2. 当Eden区再次被填满后发生Minor GC,存活下来的对象和S0区的对象,会会被挪到S1区,对象年龄+1
  3. 当Eden区再次被填满后发生Minor GC,存活下来的对象和S1区的对象,会会被挪到S0区,对象年龄+1 ...反复循环

什么样的对象会进入到老年代呢?

1. 大对象直接进入老年代:

大对象即占用大量连续内存空间的对象(比如:字符串、数组),我们可以配置JVM参数( -XX:PretenureSizeThreshold)指定大对象的大小,超过这大小即进入老年代。

2. 长期存活的对象将进入老年代

当对象的年龄增加到一定岁数(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值不能>15岁,因为对象的年龄在对象的组成中只占4Bit(即最大为1111=15),可以通过参数 -XX:MaxTenuringThreshold 来设置。

3. 对象动态年龄判断

例如S0区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了S0区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制在minor gc之后触发的。

4. Minor gc后存活的对象Survivor区放不下

这种情况会把存活的对象部分挪到老年代,部分可能还会放在Survivor区

5. 老年代空间分配担保机制

年轻代每次Minor gc之前JVM都会计算老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了, 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"

如图:

image.png

我们画个图总结一下: image.png

JVM常用的GC算法有:

1. 标记-清除算法:

算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后回收所有被标记的对象。

缺点:

  • 效率问题(年轻代90%对象都是需要回收的,一个一个标记效率较低)
  • 空间问题(标记清除后会产生大量不连续的碎片) 如图:

image.png

2. 复制算法

为了解决效率问题,“复制”算法出现了。它将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

问题:耗内存,需要时刻保留一块同样大小的空间

如图:

image.png

3. 标记-整理算法

根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一段移动,然后直接清理掉边界以外的内存

如图:

image.png

4. 分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法

java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

注意:“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上

我们画个图总结一下:

image.png

三、垃圾收集器

1. Serial收集器:

Serial(串行)是一个单线程收集器。它在进行垃圾收集的时候必须暂停其他的工作线程("Stop The World"),直到它收集结束。

Serial收集器新生代采用复制算法,老年代采用标记-整理算法。

如图:

image.png

2. ParNew收集器

他是Serial收集器的多线程版本,默认的收集线程数跟cpu核数相同

如图:

image.png

3. Parallel收集器

Parallel收集器类似于ParNew收集器,是JVM默认的收集器

Parallel收集器关注点是吞吐量。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。

Parallel收集器新生代采用复制算法,老年代采用标记-整理算法。

4. CMS收集器

CMS(Concurrent Mark Sweep)收集器追求的是回收过程最短停顿时间。它非常注重用户体验,它是HotSpot虚拟机第一款真正意义上的并发收集器,实现了让垃圾收集线程与用户线程同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种“标记-清除”算法实现的,它的运作过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快
  • 并发标记: 同时开启GC和用户线程,根据上一步中找到的对象去记录它们引用链条上的对象。并标记发生引用更新的对象。
  • 重新标记: 修正并发标记期间引用更新的对象,这个阶段的停顿时间比初始标记的时间长,比并发标记阶段时间短
  • 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。

如图:

image.png

优点:并发收集、低停顿。

缺点

  1. 对CPU资源敏感(会和服务抢资源);
  2. 无法处理浮动垃圾(在并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
  3. 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理
  4. 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,改用serial old收集器来回收

5. G1收集器

G1是一款适用于服务器的垃圾收集器,主要针对大容量内存的机器. 在满足GC停顿时间要求的同时,还具备高吞吐量性能特征

G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。G1将堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,

如图:

image.png

默认年轻代对堆内存的占比是5%,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整

Region的区域功能会动态变化,一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代。

G1有专门分配大对象的Region叫Humongous区(专门存放短期巨型对象),而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,一个大对象如果太大,可能会横跨多个Region来存放。

G1收集器一次GC的运作过程大致分为以下几个步骤:

  • 初始标记:暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
  • 并发标记:同CMS的并发标记
  • 最终标记:同CMS的重新标记
  • 筛选回收:根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定),G1收集器在后台维护了一个优先列表,在用户允许的时间范围内,优先选择回收价值最大的Region,使得G1在有限时间内尽可能的提高收集效率。不管是年轻代或是老年代,回收算法用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。

如图: image.png

它具备以下特点

  • 并行与并发:G1充分利用多核CPU的硬件优势来缩短Stop-The-World停顿时间
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者指定在M毫秒内完成垃圾收集。(通过参数"-XX:MaxGCPauseMillis"指定)

G1垃圾收集分类:

1. YoungGC

YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC

2. MixedGC

不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercen)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

3. Full GC

停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。

G1垃圾收集器优化建议:

假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。 那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。 或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。 所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.

什么场景适合使用G1

  1. 50%以上的堆被存活对象占用
  2. 对象分配和晋升的速度变化非常大
  3. 垃圾回收时间特别长,超过1秒
  4. 8GB以上的堆内存(建议值)
  5. 停顿时间是500ms以内