《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法

对象存活判断

在对堆进行回收之前虚拟机需要判断这些对象中那些是“存活”的。

引用计数算法(Reference Counting)

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

优点:实现简单,判定效率高。

缺点:很难解决对象之间相互循环引用的问题。

JVM并没有使用这种判断方法,而是被广泛使用在FlashPlayer、Python等语言上。

可达性分析算法(Reachability Analysis)

在主流的商用程序语言(Java、C#)的主流实现中,都是使用可达性分析(Reachability Analysis)来判断对象是否存活的。

原理:通过一系列的称为“GC Root”的**引用(Reference)**作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Root 没有任何引用链相连(即从 GC Root 到这个对象不可达)时,则证明此对象是不可用的。

注:这里在书中成称 GC Root 为对象,个人看了知乎上R大的回答后,感觉引用更适合。引用是存放在栈上的变量,对象则是存放在堆中的数据,而我们平时都通过引用(即引用变量)来操作对象。

我们来理解一下,假设垃圾回收是一条河,而堆里面的对象则是这条河上的船,那么 GC Roots 就是河中的锚点。锚点肯定是不会被河水冲走的,与锚点有联系的船(也就是可达的对象)同样也不会被冲走;而与锚点断开联系的船(即不可达的对象),则会慢慢被河水冲走(即被垃圾回收)。

《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法_第1张图片
如上图,object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以他们会被判定为是可回收的对象。

哪些对象可以被作为 GC Roots 呢?
在Java语言中,可以作为 GC Roots的对象包括下面几种:

  1. 虚拟机栈(栈帧中的局部变量表)中所引用的对象;
  2. 方法区中类静态属性引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。

从垃圾回收谈 GC Root
在young GC中不收集 old gen,所以 old gen 里的对象此时全部都会被认为是活的,那么从这些对象出发指向 young gen 的引用就必须被认为是活的。因而把这些引用看作 root set 的一部份,所以对于 young GC 比 full GC 的 GC roots 还要更多一些。

生存还是死亡

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

《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法_第2张图片

第一次标记

如果对象在进行可达性分析后发现没有 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有重写(Override)finalize()方法或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

第二次标记

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列里面,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不会承诺会等待它们运行结束,原因是,如果一个对象在 finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己赋值给某个类变量或者对象的成员变量,那在第二次标记中它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

在一下次 GC 中该逃脱的对象只会再进行一次标记即会被回收。

finalize()方法的缺点

finalize()方法并不推荐大家去使用,书上介绍它说“运行代价高昂、不确定性大,无法保证各个对象的调用顺序”。
其实更直观的因素还有它使用不当的话很容易导致 OOM(内存溢出),如下:

/**
 * @author 小关同学
 * @create 2021/10/4
 */
public class FinalizeTest {
     

    public static class Test{
     
        private byte[] content = new byte[1024*1024];

        protected void finalize(){
     
            System.out.println("finalize方法被执行");
        }
    }

    public static void main(String[] args) throws Exception{
     
        for (int i = 0;i<1000;i++){
     
            Test test = new Test();
        }
    }
}
# 虚拟机参数设置:
# 最大堆内存
-Xmx5m
# 最小堆内存
-Xms5m

执行结果:
《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法_第3张图片
如上图,发生了内存溢出,原因前面也讲到,第二次标记过程是由一个低优先级的 Finalizer 线程执行的,当主线程在疯狂创建新的Test对象占用空间时,Finalizer 线程还在慢悠悠地执行,这样就导致了清除内存空间的速度跟不上内存空间被占用的速度,进而发生了内存溢出。

垃圾收集算法

标记-清除算法(Mark-Sweep)

“标记-清除”(Mark-Sweep)算法是最基础的收集算法,后续的收集算法都是基于它进行改进而得到的,它分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象(即前面的两次标记)。

《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法_第4张图片
上图来自https://www.jianshu.com/p/74727c856da4

缺点:

  1. 效率问题,标记和清除这两个过程的效率都并不高;
  2. 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多会导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法(Copying)

“复制”(Copying)算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉,解决了内存碎片的问题。对象存活率越高,效率越低

现在的商业虚拟机都是采用复制算法来回收新生代,它们将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中的一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚刚使用过的 Survivor 空间。

HotSpot JVM 把年轻代分为了三部分:1个 Eden 区和2个 Survivor 区(分别叫 From 和 To),默认比例为 8:1:1,并且默认 Eden 和 From Survivor 的大小比例是 8:1 ,也就是说每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存(To Survivor区域)会被“浪费”。

注意:当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法_第5张图片

收集过程描述:

  1. 在 GC 开始的时候,对象只会存在于 Eden 区和 From Survivor 区,To Survivor 区是空的。

  2. 紧接着进行 GC ,Eden 区中所有存活的对象都会被复制到 To Survivor 区

  3. 而在 From Survivor 区中,仍存活的对象会根据他们的年龄值来决定去向(对象在 From Survivor 区中每熬过一次 Minor GC,年龄就会增加1岁)。年龄达到一定值(年龄阈值,可以通过 -XX:MaxTenuringThreshold 来设置)的对象会被移动到老年代中,没有达到阈值的对象会被复制到 To Survivor 区域。

  4. 经过这次 GC 后,Eden 区和 From Survivor 区已经被清空。这个时候,From Survivor 和 To Survivor 会交换他们的角色,也就是新的 To Survivor 就是上次 GC 前的 From Survivor,新的 From Survivor 就是上次 GC 前的 To Survivor。不管怎样,都会都会保证名为 To Survivor 区域是空的,即永远有一块 Survivor Space 是空的。Minor GC 会一直重复这样的过程,直到 To Survivor 区被填满(即被复制到 To Survivor 区域时,To Survivor 区域一下子装不下了),To Survivor 区被填满之后,会将所有对象直接移动到年老代中

标记-整理算法(Mark-Compact)

“标记-整理”(Mark-Compact)算法是针对老年代进行回收的算法,它的标记过程和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。

《深入理解Java虚拟机》(第二版)学习2:垃圾收集算法_第6张图片

上图来自:https://blog.51cto.com/u_4837471/2324575

分代收集算法(Generational Collection)

“分代收集”(Generational Collection)算法根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据个人年代的特点采用最适当的收集算法。

新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,那就采用复制算法,只需要付出少量存活对象的复制成本就可完成收集;
而老年代中因为对象存活率过高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

算法实现原理

枚举根节点

枚举根节点也就是查找 GC Roots,我们前面说过,在HotSpot虚拟机中,是通过可达性分析来判断此对象是否需要回收的。那可达性分析就需要找到“源头”,也就是根节点(GC Roots)。

问题

  1. 消耗大量时间。
    从前面可达性分析知道,GC Roots主要在全局性的引用(常量或静态属性)和执行上下文中(栈帧中的本地变量表);
    要在这些大量的数据中,逐个检查引用,会消耗很多时间;
  2. GC停顿。
    可达性分析期间需要保证整个执行系统的一致性,对象的引用关系不能发生变化;
    导致GC进行时必须停顿所有Java执行线程 (称为 “Stop The World”,STW );
    (几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的)
    STW 是 JVM 在后台自动发起和自动完成的;在用户不可见的情况下,把用户正常的工作线程全部停掉;

总结:要可达性分析耗费的时间,让 SWT 的时间尽可能缩短,从而减少虚拟机停顿的时间。

解决方法

为了减少可达性分析所耗费的时间,Hotspot 虚拟机使用了一组称为 OopMap 的数据结构,使虚拟机可以直接得知哪些地方存放着对象引用 ,不用每次直接遍历整个引用链,所以执行系统停顿下来后,并不需要全部、逐个检查完全局性的和执行上下文中的引用位置。

在 Hotspot 中,对象的类型信息都有自己的 OopMap ,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的,而这些数据是在类加载过程中计算得到的。

OopMap 的作用:

  1. 避免全栈扫描,加快枚举根节点的速度
  2. 帮助 Hotspot 实现准确式 GC

保守式 GC

保守式 GC 是不能识别指针(引用)和非指针的 GC

缺点:

  1. 不能识别引用和非引用会导致部分对象本来应该是要被回收的,但有疑似指针指向它们,使它们逃过 GC 的收集;
  2. 由于不知道疑似指针是否就是真的指针,所以它们的值都不能改写;移动对象就意味着修改指针,因此只能使用不移动对象的 GC 算法,如:Mark-Sweep

半保守式 GC

半保守式 GC 在栈上不记录引用信息,在对象上记录引用信息

缺点:

  1. 运行时,需要在对象上带有足够多的元数据

准确式 GC

虚拟机要知道内存中所有位置上的数据具体是什么类型,是不是指向 GC 堆里面的引用。

几种准确式 GC 的实现方法:

  1. 让数据自身带上标记(tag)(在半保守式 GC 中较为常见);
  2. 让编译器为每个方法生成特别扫描码;
  3. 从外部记录下类型信息存成映射表,如:Hotspot 中的 OopMap。

安全点(Safe Point)

有了 OopMap,HotSpot 可以快速准确完成 GC Roots 枚举。但是另一个问题来了,我们要在什么地方创建 OopMap?
程序运行期间,引用的变化在不断发生,如果每一条指令都生成 OopMap,那占用空间就太大了,所以有了安全点(Safe Point)。
只在安全点进行 GC 停顿,只要保证引用变化的记录完成于 GC 停顿之前就可以。

如何选定安全点

安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,“长时间执行”的最明显特征就是指令序列复用,例如在方法调用、循环跳转、异常跳转等,所以具备这些功能的指令才会产生 Safe Point。

安全点如何让线程中断

有两种方案可以选择:

  1. 抢占式中断(Preemptive Suspension)
    在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。
  2. 主动式中断(Voluntary Suspension)
    当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

HotSpot 使用主动式中断。

安全区(Safe Region)

安全点保证了程序执行时,在不长的时间里就会遇到可进入 GC 的安全点,但如果线程没有分配 CPU 时间,必须线程处于 睡眠(Sleep) 或阻塞(Blocked)状态,就无法响应 JVM 的中断请求,走到安全点去挂起。安全区(Safe Region)解决了这一问题。

安全区是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。

当代码执行到安全区时,首先标识自己已经进入了安全区,那样如果在这段时间里 JVM 发起 GC,就不用管标识自己在安全区的那些线程了,在线程离开安全区域时,会检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待,直到收到可以安全离开安全区的信号为止。

PS:也可以到我的个人博客查看更多内容
个人博客地址:小关同学的博客

你可能感兴趣的