如何判断对象是否可回收

@[toc] --- 判断对象是否可回收的两种方法: - 引用分析法 - 可达性分析法(目前主流的 JVM 用的都是可达性分析算法) ## 引用分析法 所谓引用计数法就是在对象中添加一个字段作为引用计数器,每当有一个地方引用这个对象时,计数器的值就加一;当引用失效时,计数器值就减一;计数器为零的对象就是可以被回收的对象。 目前主流的 JVM 里面都没有选用引用计数算法来管理内存。主要原因是,这个看似简单的算法需要考虑很多例外情况,比如,**单纯的引用计数就很难解决对象之间相互【循环引用】的问题**。 所谓对象之间的相互循环引用,举个简单的例子,如下代码:对象 objA 和objB 都有字段 instance,赋值令 `objA.instance=objB` 及 `objB.instance=objA`,除此之外,这两个对象再无任何引用 ![](https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220227204354.png) 当我们令 objA 和 objB 为 null 的时候,意思就是不想再让他俩指向的对象被其他人访问了,虚拟机可以回收掉了,但是它们因为互相引用着对方,导致它们的引用计数都不为零,所以引用计数算法也就无法回收它们 ![](https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220227204809.png) ## 可达性分析法 这个算法的基本思路可以理解为一棵**多叉树**:就是通过一系列称为 “**GC Roots**” 的根节点作为**起始节点集**,从这些节点开始,根据引用关系向下搜索,如果从 GC Roots 到某个对象不可达时,则说明此对象是可以被回收的。 所以,可达性分析可以分成两个阶段 - **根节点枚举**:必须 STW(暂停全部用户进程) 的 - **从根节点开始遍历对象图**:如果不进行 STW 的话,会导致**浮动垃圾**(把原本死亡的对象错误标记为已存活,无需解决,等待下一次 GC 即可)和**对象消失**(把原本存活的对象错误标记为已消亡,必须解决!)问题,由于第二阶段时间比较长,长时间的 STW 很影响性能,所以 JVM 设计们设计了解决方案来解决对象消失问题,从而使得这个第二阶段可以不用 STW,大幅减少时间 ### 哪些对象的引用可以作为 GC Roots? A garbage collection root is an object that is accessible from outside the heap. (**GC roots 是可以从堆外部访问到的对象**): - 在**虚拟机栈 / 本地方法栈**中引用的对象 - 在方法区中**类的静态变量(static)或者 常量(final)**引用的对象(JDK 1.7 开始静态变量和字符串常量池从方法区移动到了堆中) - **JVM 内部的引用**,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器等。这个很好理解,毕竟如果这些核心的系统类对象被回收了,程序就没办法运行了 - 所有被**同步锁**(synchronized 关键字)持有的对象 - ...... > 简单来说,GC 管理的主要区域是 Java 堆,一般情况下只针对堆进行垃圾回收。方法区、虚拟机栈和本地方法栈等不被 GC 所管理,因而选择这些区域内的对象作为 GC roots,被 GC roots 引用的对象不被 GC 回收。 ### 什么是安全点(Safe Point)和安全区域(Safe Region)? 对于第一阶段根节点枚举来说,是必须暂停用户线程的,也即 STW,不然**如果分析过程中用户进程还在运行,就可能会导致根节点集合的对象引用关系不断变化,这样可达性分析结果的准确性显然也就无法保证了**。 作为 GC Roots 的对象引用就那么几个,主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如虚拟机栈中引用的对象)中,**尽管目标很明确,但要是把这些区域全都扫描检查一遍显然太过于费时了**。 那有没有办法减少耗时呢? 一个很自然的想法,就是**空间换时间** 把引用类型和它对应的位置信息用**哈希表**(映射表)记录下来,这样到 GC 的时候就可以直接读取这个哈希表,而不用一个区域一个区域地进行扫描了。Hotspot 就是这么实现的,这个用于存储引用类型的数据结构叫 **OopMap**。 在 OopMap 的协助下,HotSpot 可以快速完成根节点枚举了,但一个很现实的问题随之而来:**由于引用关系可能会发生变化,这就会导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂**。 所以实际上 HotSpot 也确实没有为每条指令都生成 OopMap,**只是在 “特定的位置” 生成 OopMap**,换句话说,只有在某些 ”特定的位置“ 上才会把对象引用的相关信息给记录下来,这些位置也被称为【**安全点(Safepoint)**】。 有了安全点的设定,也就决定了用户程序执行时并不是随便哪个时候都能够停顿下来开始 GC 的,而是**强制要求程序必须执行到达安全点后才能够进行 GC(或者说开始根节点枚举)**。 ![](https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220518185944.png) 因此,安全点的设定既不能太少以至于让垃圾收集器等待时间过长,也不能太多以至于频繁进行垃圾收集从而导致运行时的内存负荷大幅增大。所以,安全点的选定基本上是以 “**是否具有让程序长时间执行的特征**” 为标准进行选定的,最典型的就是指令序列的复用:例如方法调用、循环跳转、异常跳转等,所以只有具有这些功能的指令才会产生安全点。 对于安全点,另外一个需要考虑的问题是,**如何在 GC 发生时让所有用户线程都执行到最近的安全点,然后停顿下来呢**?。这里有两种方案可供选择: - **抢先式中断(Preemptive Suspension)**:这种思路很简单,就是在 GC 发生时,系统先把所有用户线程全部中断掉。然后如果发现有用户线程中断的位置不在安全点上,就恢复这条线程执行,直到跑到安全点上再重新中断。 抢先式中断的最大问题是<u>时间成本的不可控</u>,进而导致性能不稳定和吞吐量的波动,特别是在高并发场景下这是非常致命的,所以现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件 - **主动式中断(Voluntary Suspension)**:主动式中断不会直接中断线程,而是全局设置一个标志位,用户线程会不断的轮询这个标志位(GC 是否开始),当发现标志位为真时,线程会在最近的一个安全点主动中断挂起。现在的虚拟机基本都是用这种方式 <br> 对于主动式中断来说,用户线程需要不断地去轮询标志位,那对于那些处于 waiting 或者 blocked 状态的线程(不在活跃状态的线程)来说怎么办? **这些不活跃的线程,我们没法掌控它们醒过来的时间。很可能其他线程都已经通过轮询标志位到达安全点被中断了,然后虚拟机开始根节点枚举了(根节点枚举需要暂停所有用户线程),但是这时候那些本不活跃的用户线程又醒过来了开始执行,破坏了对象之间的引用关系**,那显然是不行的。 对于这种情况,就必须引入【**安全区域(Safe Region)**】来解决。 安全区域的定义是这样的:确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中的任意地方开始 GC 都是安全的。 **可以简单地把安全区域看作被拉长了的安全点**。 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。**当安全区域中的线程被唤醒并离开安全区域时,它需要检查下主动式中断策略的标志位是否为真(GC 是否开始),如果为真则继续挂起等待(防止根节点枚举过程中这些被唤醒线程的执行破坏了对象之间的引用关系),如果为假那么线程就可以被唤醒然后继续执行**。 ### 三色标记法(浮动垃圾和对象消失问题) 可达性分析的第二阶段从根节点开始遍历对象图,如果不进行 STW 的话,会导致**浮动垃圾**(把原本死亡的对象错误标记为已存活,无需解决,等待下一次 GC 即可)和**对象消失**(把原本存活的对象错误标记为已消亡,必须解决!)问题,由于第二阶段时间比较长,长时间的 STW 很影响性能(停顿时间与 Java 堆容量直接成正比例关系),所以 JVM 设计们设计了解决方案来解决对象消失问题,从而使得这个第二阶段可以不用 STW,大幅减少时间 三色标记法就是帮助我们搞清楚在这个阶段如果用户线程和垃圾收集线程同时进行(不 STW),会出现什么问题(浮动垃圾和对象消失),的这么一个**工具方法** 所谓三色标记法,就是**把遍历对象图过程中遇到的对象,按照 “是否访问过” 这个条件标记成以下三种颜色**: - **白色**:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,<u>若在分析结束的阶段,仍然是白色的对象,即代表不可达(可达性分析到不了的对象,就是死亡对象,需要被回收)</u> - **黑色**:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。 - **灰色**:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过 所以对象图遍历的过程,其实就是由灰色从黑向白推进的过程,灰色是黑和白的分界线。 借助三色标记法,我们可以分析出,在 “对象图遍历” 这个阶段如果用户线程与收集器并发工作会出现两个问题(问题的根源都在于黑色对象不会被重新扫描): 1)【**浮动垃圾**】:把原本死亡的对象错误标记为已存活(原本应该是白色的对象被误标为黑色) 举个例子,a 引用了 b,此时 b 被扫描为可达,但是用户线程随后又执行了 a.b = null,这个时候其实 b 已经是死亡的垃圾对象了,但是由于<u>黑色对象不会被重新扫描</u>,所以在垃圾收集里 b 依然作为存活对象被标记成黑色,因此就成了浮动垃圾: ![](https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/20220529143726.png) 浮动垃圾不是一件好事,但其实是可以容忍的,因为这只不过产生了一点逃过本次收集的浮动垃圾而已,反正还会有下一次垃圾收集,到时候就会被标记为垃圾被清理掉了 2)【**对象消失**】:把原本存活的对象错误标记为已消亡(原本应该是黑色的对象被误标为白色),产生对象消失问题需要满足两个条件: - 插入了一条或多条从黑色对象到白色对象的新引用 - 删除了全部从灰色对象到该白色对象的直接或间接引用 举个例子,b(灰色) -> c 的引用被切断,但同时用户线程建立了一个新的从 a(黑色) -> c 的引用,由于已经遍历到了 b,不可能再回去遍历 a(黑色对象不会被重新扫描),再遍历 c,所以这个 c 实际是存活的对象,但由于没有被垃圾收集器扫描到,被错误地标记成了白色。 ![](https://cs-wiki.oss-cn-shanghai.aliyuncs.com/img/image-20220529144429502.png) 对象消失是一个很致命的问题,程序肯定会因此发生错误,所以 “对象图遍历” 这个阶段最好是进行 STW 的,但是这个阶段的时间又很长,所以我们需要想出一些办法来解决对象消失问题,使得在遍历对象图的过程中不用进行 STW(也就是用户线程和对象线程可以同时工作),从而提升可达性分析的效率 上面总结了对象消失问题的两个条件,所以说,**如果我们想要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可**。由此分别产生了两种解决方案: - **增量更新(Incremental Update)**:增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时(就是上图中的 a -> c 引用关系),<u>就将这个新插入的引用记录下来</u>,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象(a)为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了(CMS 是基于增量更新来做并发标记的) - **原始快照(Snapshot At The Beginning,SATB)**:原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时(上图中的 b -> c 引用关系),<u>就将这个要删除的引用记录下来</u>,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象(b)为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。(G1、Shenandoah 是基于原始快照来做并发标记的)