JVM

GC总结

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

Posted by Timer on October 2, 2021

判断对象是否已死

GC作为垃圾回收器,回收是死掉的对象。所以在记录GC之前要先知道,什么样的对象叫死掉的对象。

  • 引用计数法

    给对象添加一个计数器,有一个引用就+1,引用失效就-1,为0就死。

    缺点:有很多例外情况需要考虑,比如循环引用,属于致命伤。

  • 可达性分析法

    堆中的对象和其他对象都存在引用关系,成员变量、数组或者隐藏的引用,比如对象自己的类型引用,Class对象对其类加载器的引用。以GC Roots为根,各个对象之间形成图状的引用链。如果某个对象没有根,那么就死了。

GC Roots

GC Roots的定义:A garbage collection root is an object that is accessible from outside the heap。

借用知乎R大的定义,GC Roots是:

一组必须活跃的引用

GC Roots固定的有:

  • 所以JAVA当前活跃的栈帧里指向堆里对象的引用。说人话就是当前调用方法的参数、局部变量、临时值。
  • JVM内部的引用
  • JNI
  • 所有synchronized锁住的对象引用
  • 方法区里静态变量、常量池里的引用。

GC Roots不固定的有:

  • 跨代引用。年轻代中有对象A,其唯一引用它的是老年代中的某个对象,那么在Minor GC时,如果不考虑跨代引用,就会把A直接清除。

所有GC Roots详情见

https://help.eclipse.org/latest/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Fconcepts%2Fgcroots.html&cp=37_2_3

引用

​ 可达性分析靠的就是图算法+引用链,在JAVA1.2之后,对引用的概念进一步的扩充,衍生出了四种引用。

​ 个人觉得,JAVA比C虽然少了指针直接处理内存。但是有GC免于四处free,有了四大引用用来方便各种使用场景,从某种程度上来说也算解放劳动力了。

  • 强引用

  • 弱引用

    ​ 只能活到下一次GC。 经典例子ThreadLocalMap中的Key值使用了弱引用,在无外部强引用的情况下,自动清除,防止内存泄漏。

  • 软引用

    ​ 如果内存不够了,内存快不够的时候,GC直接杀掉软引用对象。适用于内存紧张的情况,典型的缓存设计

    但是个人觉得:软引用作为缓存是一种很牵强的方式,因为软引用会在内存使用量超过一定阈值的时候杀掉所有软引用,意味着当某一个值加入缓存池的时候刚好约过这个阈值的话,所有的缓存池都会被清空,这是一种效率很低的方式。同时,如果缓存使用的是map结构的话,value值使用了软引用,value被GC过后会造成空间泄漏,这个时候还要ReferenceQueue去检测软引用的情况,一一去除“脏”的键值对。可能某些极端场景需要,但总体有点感觉是为了用软引用而去用软引用。

    ​ 附带一个自己加深理解写的软引用缓存Demo

  • 虚引用

    最弱的引用,无法通过虚引用获得实例,对其对象的生存时间也毫无影响。唯一作用在于能在这个对象被回收时收到一个系统通知。

垃圾回收算法

  • 标记-清除

    • 缺点1:执行效率不稳定,需要回收的对象越多,效率越低。
    • 缺点2:容易产生内存碎片,致使大对象找不到连续内存继续触发GC。
  • 标记-复制

    新生代对象,死亡率很高,所以采用复制算法,成本低,效率高。

    • 方法1:半区复制

      可用内存分为两半,每次只用其中的一半,如果一块用完了,就把活着的对象复制到另一半,再把刚刚那一半给清除了。这种方法实现方便,效率也高,最大的缺点在于可用空间变为二分之一。

    • 方法2:Apple式回收(Apple是作者名)

      IBM有一项研究表明新生代的对象98%都熬不过第一轮回收。

      基于这个前提,将内存分为一块eden区和两块survivor区,空间比例为8:1:1。每次只使用eden区和一块survivor区,当GC时,把活着的对象放到另一块survivor区中,和半区复制如出一辙。如果活着的对象数量超过了survivor区的容量时,就会把这个对象放到老年代里。

  • 标记-整理

    老年代的对象一般比较大,用复制算法效率低,空间也浪费,所以用整理算法,将其全部挪到一边。

标记相关细节:

  • 根节点枚举

    ​ 垃圾回收首先得找到所有的GC Roots。

    准确式GC的“准确”体现在:当给定某个区域的某块数据的时候,这块数据到底是不是指针。准确式GC要求jvm必须清楚的知道内存中哪些位置存放了对象引用。

    ​ 因此HotSpot在外部用一数据结构来记录对象内各个偏移量上的数据是什么类型,这个数据结构也就是OopMap,Ordinary Object Pointer。

    • 安全点

      ​ 程序运行期间,很多指令都是有可能修改引用关系的,即要修改OopMap。如果碰到就修改,那代价也太大了,故而引入了 SafePoint,只在 SafePoint 才会对 OopMap 做一个统一的更新。这也使得,只有 SafePoint 处 OopMap 是一定准确的,因此只能在 SafePoint 处进行 GC 行为。

      由此也可见,GC不是任意时刻都可以进行的,程序中调用System.gc(),需要跑到最近的安全点再执行,不是立刻就能执行

      ​ 安全点的选取也是有讲究的,如果安全点太少,那么gc等待的时间就会长,如果安全点太多,又会降低性能。

      ​ 一般选取在:循环的末尾、调用方法之后、方法返回之前、抛异常的位置 。

    • 如何让当前所有线程都达到安全点

      ​ 目前主要使用的是主动式中断,不直接让各个线程中断,给它一个标志位,线程执行的过程中会不断轮询这个标志位。如果标志位为真,则在附近最近的安全点主动挂起。

    • 安全区域

      ​ 安全区域就是指某一段代码中,引用关系不会发生变化。比如Sleep语句,Wait阻塞等等,这里可以看成全部都是安全点,可以随意进行GC。

  • 并发标记(三色标记法)

    ​ 在对GC Roots进行图遍历的时候, 对节点的定义有三种:白色,未扫描。灰色,有子节点未扫描。黑色,子节点全扫描完。

    ​ 整体运行的时候灰色像波纹一样推动着黑色的蔓延。

    这里解释一下灰色存在的意义(个人理解):

    ​ 正常来说遍历结束一个图只需要黑白即可,但是纯黑白的话,想要判断什么时候遍历终止,就需要对每个末尾的黑节点一一判断,效率较低。这里引入灰色节点的话,只需要判断灰色节点集合是否为空即可,如果为空,说明可以开始清除白色对象了, 避免了在每次GC循环中对工作集的接触。贴一下维基百科里几句关键的话:

    Since all objects not immediately reachable from the roots are added to the white set, and objects can only move from white to gray and from gray to black, the algorithm preserves an important invariant – no black objects reference white objects. This ensures that the white objects can be freed once the gray set is empty.

    By monitoring the size of the sets, the system can perform garbage collection periodically, rather than as needed. Also, the need to touch the entire working set on each cycle is avoided.

    ​ 但是并发必然会带来修改的问题,也就是节点的多标漏标

    关于多标,比如在扫描到某灰色节点的时候,灰色节点和之前黑色节点的引用被断掉了,那么这个灰色节点就成为了“浮动垃圾”,它不会影响程序的正确性,只是要等到下一轮才能回收。

    关于漏标,Wilson在1994年证明了,以下两个条件同时满足时,才会使节点漏标,从而被误杀。

    1.赋值器插入了从黑色节点到白色节点的引用

    2.赋值器删除了所有灰色节点到白色节点的引用

    ​ 漏标也就是灰色节点不要的引用,黑色节点要了。所以这个推论不难理解,所以只要破坏两者其一即可。对应也有两个方案。

    1.增量更新(Increment Update):破坏了第一个条件,当黑色节点插入新的指向白色节点的引用时,把这个操作记录下来,当并发扫描结束,把这个黑色的对象置为灰色,再扫描一次。所谓增量指的就是增加新的引用。CMS就是用的增量更新。

    2.原始快照(SnapShot At The Begining, SATB):破坏了第二个条件,当灰色对象要删除指向白色对象的引用时,把这个删除的引用记下来,并发扫描完之后,以刚刚的灰色对象为根,继续扫描SATB里的信息。G1就是用的SATB。

    为什么G1会选择原始快照呢?

    ​ 我觉得一个比较合理的推测是,SATB是针对灰删白做文章,灰色必是白色的邻接节点,而增量更新是黑插白,然后把黑当灰,但黑色不一定是邻接节点,可能离灰色很远。所以增量更新做的搜索更深度,代价更大。同时,G1的扫描范围是全堆,而CMS只是老年代,老年代对象是相对较少的,大多都是大对象,所以CMS用增量更新也问题不大。但G1如果用增量更新的话,代价太大。

  • 跨代引用问题

    记忆集:记录从非收集区到收集区的指针集合,这是一种抽象的数据结构。

    卡表:记忆集的一种实现,这东西本质就是为了减少扫描old区域的范围。

    在这里插入图片描述

新生代回收器:

Serial

单线程,收集的时候必STW。标记-复制算法。

ParalLel scavenge

Serial的多线程版本。最主要的特点在于他的关注点是尽可能的缩短STW时间,提高吞吐量,吞吐量为 代码运行时间 / 处理器总耗时。主要适合后台计算而不需要太多用户交互的情况。

ParNew

Serial的多线程版本。其最大的意义在于,只有它能和CMS配合工作。ParalLel scavenge包括G1当初设计的时候都没有用HotSpot的分代框架,而是独立的一部分。

老年代回收器

Serial Old

单线程,收集的时候必STW。标记-整理算法。

与Serial配合运行示意图:

Parallel Old

是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。在此之前,Parallel Scavenge没有与之配合的老年代收集器,只能勉强用Serial Old。Parallel Old出现后,对于吞吐量优先或者处理器资源稀缺的情况,就有了一对很好的组合。

与Parallel Scavenge配合运行示意图:

CMS(Concurrent Mark Sweep)

主要目标是获取最短回收停顿的时间,也就是让STW尽可能短的GC。适用于对响应速度要求很高的WEB服务。

CMS是老年代收集器中少见的以标记-清除算法作为理论基础的GC。主要有以下四个步骤:

  1. 初始标记(CMS initial mark) (STW) :标记一下GC Roots能直接关联到的对象。
  2. 并发标记(CMS concurrent mark) : 遍历整个对象图,和用户线程同时执行。
  3. 重新标记(CMS remark) (STW) : 修正并发标记时,用户程序导致标记发生变动的对象。这里涉及到增量更新,需要重点理解。
  4. 并发清除(CMS concurrent sweep) : 清除标记好的对象。

可见,CMS的优点在于:并发收集、低停顿

但是缺点也很明显:

  • 并发收集,资源敏感。默认回收线程数是(core + 3) / 4,对于低于4核的情况不友好。
  • 无法处理浮动垃圾。
  • 标记-清除带来的内存碎片。可以调整JVM参数整理碎片。

全能选手- G1

JDK9的默认GC,同时CMS已经被警告未来会被抛弃。

作为CMS的继承者,G1的目的是Pause Prediction Model,也就是尽力达到在预期时间内GC完。

最显著的,它的垃圾回收范围有了变化。G1之前的所有GC目标范围要么是Minor GC,要么是Major GC,要么是Full GC。

而G1则面向全堆进行回收,衡量的标准从分代变成了哪块内存垃圾多,收益大,就回收哪个。这就是G1的Mixed GC模式。

和虚拟内存从分段到分页类似,GC从G1开始,不再大规模划分空间,而是以Region为基本单位,一个Region可以代表E S O,如果一个对象的大小超过1/2的Region Size的话,就会放入Humongous区域。

G1之所以能够Pause Prediction,是因为它以Region为回收单位,这样可以避免全堆一起收集。同时,给所有的Region以其价值进行排序,在期望时间内优先处理高价值的Region。

G1的跨Region引用问题:

一个Region存一个记忆集,这种记忆集的实现本质上是一种哈希表,Key是其他Region的地址,Value是卡页的索引号。这也意味着G1要占用更多的空间。

G1的垃圾回收阶段:

  • Young Collection(STW) : 当E区达到阈值了,Young Collection 触发,把幸存对象复制到S区。当S区也达到阈值,再次触发,将够年龄的复制到O区,不够年龄的继续复制到新的S区。

  • Young Collection + CM : 当O区达到阈值,会进行并发标记,决定哪些老年代要回收。

    1. 初始标记(STW):标记GC Roots
    2. 并发标记:并发的可达性分析,图扫描完还得重新处理SATB变动的部分。
    3. 最终标记(STW):清空SATB缓冲区。
    4. 筛选回收(STW):排序各个Region的价值和成本,选择要回收的Region。这里涉及到对象的移动,必须STW。
  • Mixed Collection : 把选择的老年代区域和E、S 区一起作为集合回收。