JVM深入浅出(4)--- 垃圾收集算法 & HotSpot 算法细节实现

自己在学习《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) (华章原创精品) - 周志明》这本书时候的一些思考和总结

1. 哪些对象需要被回收

1.1 垃圾回收时需要注意的问题

聚焦垃圾回收的三个问题

  • 哪些需要回收(对应就是 如何判断对象是否存活)
  • 什么时候回收(safepoint 相关
  • 怎么回收(三种垃圾收集算法)

1.2 对象已死?

判断对象存活的方法如下

1.2.1 引用计数法

引用计数法非常简单,就是给每个对象添加一个引用计数器,当指向对象的引用增加时,引用计数器也随之增加。当引用次数为0时,则判断该对象是要被清除的对象

优点:

  • 简单,高效
  • 引用计数器占用的内存不大,所以对内存影响不大

缺点:没办法解决循环依赖的问题,当两个应该被回收的对象互相引用时候,这种情况会产生内存泄露的问题。

1.2.2 可达性分析算法

可达性分析算法是通过选取一系列的对象,作为GCroots,通过遍历该对象的引用链,遍历到的对象则被认为是存活的,而遍历不到的则是要清除的对象。

可以作为GCroots的对象:

  • 虚拟栈中引用的对象(局部变量表中的)
  • 本地方法栈中Native方法引用对象
  • 类静态属性引用的对象
  • 方法区中常量引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized) 持有的对象
  • 反映 Java 虚拟机内部情况的 JM XBean 、 JVM TI 中注册的回调、本地代码缓存等 ;
  • 除此之外,根据用户选择的垃圾收集器,有些对象也会被“临时加入”

1.2.3 再谈引用

Java并不单单实现“引用”和“非引用”两种状态,而是根据内存实际情况,去适当保存一些引用,将引用分成了

  • 强引用:强引用是最传统的引用方式,通过直接的Object o = new object()构建,只要引用关系还在,这种引用就不会被回收
  • 软引用:软引用表示那些还有用,但非必须的对象,软引用的对象在发生内存溢出之前先进行回收,如果回收后内存仍然不够,就会报OOMerror。 软引用通过SoftReference类实现
  • 弱引用:弱引用也是用来表示那些非必须对象,弱引用比软引用更容易被回收,通常在下一次垃圾回收时就会被回收掉,用WeakReference实现
  • 虚引用:最弱的引用关系,无法通过虚引用来获取实例,设置虚引用只是为了在对象被回收的时候产生通知。用PhantomReference类实现。

1.2.4 生存还是死亡?

  1. 判断对象死亡的过程
    • 宣告一个对象死亡,至少要经历两次标记过程,第一次是判断这个对象是否在GCroots的引用链上,其次是该对象是否需要调用finalize()方法,如果对象没有重写finalize()方法,或者该对象已经调用过finalize()方法,第二次都会被标记上,经历这两次标记,才会被回收掉
  2. finalize()如何调用 & 对象的最后一次自救
    • 在判断对象要调用其finalize()方法时,虚拟机会把该对象放在一个自动建立,低调度的线程Finalizer中。该线程负责调用对象的finalize()方法。
    • 对象可以通过在finalizer方法中,重新将GCroots的引用链上任意引用关联到对象上,这样就能完成自救。

1.2.5 回收方法区

方法区的回收主要牵扯到常量的回收 和 类的卸载

  • 常量的回收,当常量池中的常量不被使用的时候,那就可以回收了
  • 类的卸载不一样,判断一个类要被卸载需要同时满足以下三个情况
    • 类的所有实例被回收
    • 加载类的类加载器被回收
    • 类在堆中的Java.lang.Class对象不再被使用,也就是没有再使用该类的反射

2. 垃圾收集算法

2.1 分代收集理论

现代的jvm基本上根据这几条假说设计:

  • 弱分代假说:对象朝生夕灭,存活周期较短
  • 强分代假说:经历越多垃圾回收的对象就更难被消除
  • 跨代引用假说:指的是产生了跨代引用,但是存在引用的对象趋向于共生或者共亡,所以这种跨代引用在经历过多次gc后也会更倾向于同代引用。
    • 记忆集: 既然出现了跨代引用的现象,我们不应该为了老年代的引用去扫描整个老年代。JVM中采用了一种数据结构 ----记忆集 ,用来记录哪些对象/内存区域(取决于记忆集的精度)发生了跨代引用的现象,当扫描新生代时候,也只会将记忆集中记录跨代引用中老年代的内存加入扫描。

由此jvm设计出了

  • 新生代:产生对象的地方,经历多次垃圾回收后仍然存活的对象将会进入老年代。
  • 老年代:存放的对象不易被回收或者较大。

不同种类的GC:

  • Minor gc:针对整个新生代的垃圾收集
  • Major gc:针对整个老年代的垃圾收集 -- CMS垃圾收集器
  • mixed gc:扫描新生代和部分老年代
  • full gc:扫描整个堆和方法区

2.2 标记-清除算法

正如名字一般,标记-清除算法主要是两个阶段 --- 标记 & 清除,第一步标记内存中需要清除的对象,第二步清除掉标记的对象。

这种算法存在较大的缺点

  • 由于新生代垃圾回收需要回收的对象较多,当对象多了,效率也就变低了
  • 标记-清除会产生大量不连续的内存碎片,当这些碎片不足以分配较大内存时,就会频繁触发gc。

image-20260212020008262

2.3 标记-复制算法

标记复制的做法是将剩余的内存空间分为两个大小相等的区域,将存活的对象复制到其中的一块内存区域,清除使用过的空间内存。

image-20260212030614651
优点:

  • 当存活对象较多时,这种算法在复制阶段的消耗较高,但是新生代内存大多会被清除。
  • 标记复制算法不会产生内存碎片

缺点:

  • 将可用的内存空间一分为二,空间浪费较多。

Appel式回收:是一种更优化的半区复制分代策略。Appel将新生代分为了一块较大的Eden区,From survivor,To survivor。发生垃圾回收时,收集器将Eden区和其中一块survivor区存活的对象复制到另一块survivor区中,并清除掉eden区和先前使用的survivor区。

Hotspot中默认eden区比survivor区是1:8。

2.4 标记-整理算法

由于标记复制算法当存活对象较多时,复制的开销会变大,并且标记复制算法由于空间浪费,需要另一块空间为其担保(老年代为新生代担保),显然这种算法并不适用于老年代,于是有了标记-整理算法

标记整理算法是通过将存活的对象向着内存的一端移动,并清理掉存活对象内存边界外的对象,这样很好的避免了产生碎片化空间的问题。

分析:移动对象 vs 不移动对象

  • 移动对象时,难免会需要其他线程停止,也就是所谓的STW现象。
  • 不移动对象时候,在内存分配上,就需要格外的数据结构去记录这些不连续的空闲内存。而访问这些内存是最影响吞吐量

所以,当移动对象时,内存回收会复杂,而不移动对象时,内存的分配会复杂。 说白了,移动对象会影响收集器的效率,也就是延迟,而不移动对象,则会有更多开销去访问不连续内存,影响的就是吞吐量了。所以也就有了关注吞吐量的parallel scavenge收集器采用的是标记-整理算法,而关注延迟的CMS收集器则是采用的标记-清理算法比较特殊的是CMS收集器采用的是混合的方式,当标记-清理算法产生的碎片化可用空间过多时候,他又换成了使用标记整理算法。

3. HotSpot的算法细节实现

3.1 根节点枚举

虽然在可达性分析算法中,大部分查找引用链的情况是并发的,但是在根节点枚举这一段,是必须暂停所有用户线程的,也就是所谓的STW。

为了便利虚拟机查找对象的引用,虚拟机引入了一种叫做OopMap的数据结构,它的作用是记录该对象什么偏移量上对应着什么类型的数据,也就是该对象的对象引用放在了哪些位置。这样收集器就不需要一个一个查找GCroots,而是直接在OopMap上查找,加快了gcRoots的枚举。

3.2 安全点

虽然OopMap可以很大程度加快了GCroots的查找,但是频繁的更新OopMap(回收某些对象)是不切实际的,Hotspot也不会为每一条指令集都生成对应的OopMap,而是在“特定的位置”去记录这些信息,也就是所谓的安全点。有了安全点之后,只有线程都都跑到了安全点的时候才开始做垃圾回收。

如何让所有线程都跑到安全点后再开始回收?

  • 抢断式中断:系统直接把所有的线程都停止,如果发现这个线程不在安全点上,就恢复对应的线程,等它跑到安全点后再停止
  • 主动式中断:系统不操作线程,而是简单设置一个标志位,然后不断的去轮询这个标志位。一旦这个中断标志为真,线程就需要跑到最近的安全点主动中断挂起

3.3 安全区域

当线程无法响应jvm的中断时(如进入了sleep或者blocked的状态),就需要安全区域来解决。

安全区域指的是在这一片代码片段中,任何地方的引用都不会发生改变,也就是说,在任意地方开始做垃圾回收都是安全的。安全区域可以看作是多个安全点的集合。

当线程想要离开安全区域时,必须检查虚拟机是否处于暂停所有用户线程的阶段(如GCroots根节点枚举),只有等到这个阶段完成了才能出去,否则就要一直等待。

3.4 记忆集与卡表

先前我们提到了,为了避免跨代引用中,因为存在跨代引用而扫描整个老年代,所以有了记忆集这种数据结构,去记录跨代引用关系。

具体来说,记忆集是用来记录非收集区指向收集区的指针集合的数据结构(也就是老年代 --> 新生代)

对于记忆集来说,有不同的记忆精度

  • 字长精度:精确到一个机器字长,该字长含有跨代指针
  • 对象精度:精确到一个对象,该对象中含有跨代指针
  • 卡精度:精确到一片内存区域,该区域中存在跨代指针。

其中,“卡表”是卡精度的记忆集的一种实现。卡表是一个字节数组,字节数组中的每一个元素对于着一块特定大小的内存块(类似于HashMap的结构),也就是“卡页,当某个卡页存在跨代引用,就会把这个元素值变为1,也就是这个元素变脏

image-20260212201755706

3.5 写屏障 & 伪共享问题

写屏障是为了维护卡表的一种机制,简单来说,它可以看作是对更新引用的AOP操作中的环绕通知(也就是存在写前屏障写后屏障),当某个引用类型字段发生更新时(无论是新生代对象还是老年代对象),都会去更新卡表的值。

伪共享问题指的是在高并发的场景中,缓存系统是通过缓存行为单位存储的,这就有多线程修改多个变量,且这些变量放在一个缓存行的导致效率低下的问题。解决伪共享可以通过在执行卡表变脏操作之前,先检查这个卡表有没有被修改过。

3.6 并发的可达性分析

可达性分析算法在查询引用链时,在并发的场景下,强调保持快照的一致性就变得尤为重要。

三色标记法:

  • 白色:代表那些还没有被垃圾搜集器访问到的对象。当所有对象访问结束扔标记为白色的对象将会被清除。
  • 灰色:代表那些对象被垃圾收集器访问了,但是其引用至少还有一个还没有开始访问
  • 黑色:代表对象和引用都被访问了。代表它是安全存活的

image-20260213010259728
对于并发标记,会出现两个问题

  • 把原本消亡的对象标记为存活,这种情况还好,最多就是产生一些浮动垃圾,可以在下一次的gc中回收掉
  • 把原本存活的对象标记为消亡 --- 最致命

以下两种情况同时满足时候,会把黑色对象最终标记为白色对象(也就是本该存活的对象标记为消亡)

  • 插入了一条或者多条黑色到白色的引用
  • 删除了全部灰色到白色的直接或者间接引用

为什么只有当两条同时满足时候才导致黑色对象被标记为白色对象呢

首先 只有 满足黑色到白色引用插入同时灰色到白色引用删除时会漏标

  1. 但单单只有黑色到白色引用时,还可能通过其他灰色标记着对象为黑色,所以没关系
  2. 但只是删除所有灰色到白色的直接或间接引用时,也没关系,这说明那个对象本身就是垃圾。

我们只需要解决掉其中的一个问题,就能够保证快照的一致性。有两种方法能办到。

  • 增量更新:当新增了黑色对象到白色引用的指针时,我们把这些引用记录下来,等到标记结束后,再将新增引用的黑色节点作为根,再扫描一遍。
  • 原始快照(SATB):在删除引用时(白色对象),将这些引用记录下来(证明这一刻白色对象是活着的,之后也应该是活着的),等并发扫描结束后,再将这些引用作为灰色对象扫描,这样就确保了前后的快照一致性。
posted @ 2026-04-06 13:20  不会coding的喵酱  阅读(4)  评论(0)    收藏  举报