java垃圾回收的一些思考

垃圾回收器怎么解决一个对象被标记为不可达,然后清理完成后,用户代码又引用这个对象

首先,一旦对象被垃圾回收器物理回收,任何语言都无法“解决”后续再引用它的问题,因为那块内存已经不再属于原来的对象了。
在java里操作的都是引用,单线程时代码执行到安全点之后,引用关系相对固定,不应该出现把对象内存块捞出来又复用一下的问题;而在多线程时,如果没有使用volatile,可能会存在一个线程设置null,一个线程用引用对象来做事这种情况,这是代码的线程安全问题。
java提供GC机制,但不保证程序员不会通过并发错误(如数据竞争)创造出悬挂引用。
go则是通过 “语言和运行时提供强保证:只要指针存在,对象就一定存活。”,不再像CMS那样去保护黑色对象(防止它引用白色),而是直接保护白色对象,确保它不会被错误回收。同时完全消除了在重新标记阶段去重新扫描堆栈的需要,从而极大地缩短了STW时间。

CMS的第二次重新标记阶段都做了些什么

1.处理增量更新(Incremental Update):
这是解决我们之前讨论的“对象消失”问题的关键。
在并发标记期间,写屏障捕获了所有“将引用写入黑色对象”的操作(即 黑色对象 -> 新白色对象)。
这些被修改过的黑色对象会被记录下來,并在这个阶段被重新标记为灰色。
重新标记阶段会以这些灰色对象为根,重新扫描一遍,从而确保所有新被引用的对象(那些险些被误删的白色对象)都被正确地标记为存活(黑色)。
2.遍历脏页(Dirty Pages):
现代JVM会通过卡表(Card Table)来高效地实现写屏障。当发生引用写入时,JVM并不会记录具体哪个对象被改了,而是会标记该内存区域对应的“卡表”项为“脏”(Dirty)。
重新标记阶段会遍历所有这些被标记为“脏”的内存区域,检查其中的引用变化,并更新标记状态。
3.处理其他引用:
例如,还需要处理类卸载、弱引用、软引用、虚引用、finalizer引用等。

CMS的浮动垃圾是如何产生的

浮动垃圾指的是那些在本次GC周期中已经失效(成为垃圾)但却没有被回收掉的内存。
为什么会产生?
这同样是由CMS的“并发”特性决定的。想象一下这个时间线:
时刻 T1:并发标记阶段开始。GC线程开始扫描。
时刻 T2:对象A被标记线程访问,并被标记为存活(黑色)。此时对象A正引用着对象B。
时刻 T3:应用程序线程运行,断开了对象A到对象B的引用(例如执行了 A.b = null)。现在对象B已经无人引用,成为了垃圾。
时刻 T4:并发标记阶段结束。标记线程认为所有黑色对象都是存活的,而对象B因为曾经被存活对象A引用过,所以也被标记为了黑色(存活)。
时刻 T5:并发清理阶段开始。清理线程看到对象B是黑色的(存活的),所以不会去回收它。
结果: 对象B实际上已经是垃圾了,但它却“漂浮”在这次GC过程中,没有被清理掉。这就是“浮动垃圾”。
浮动垃圾的本质:
CMS的标记过程本质上是基于 “标记开始时” 的对象图快照。它标记的是在T1时刻存活的对象,以及在T1到T4期间新分配的对象(这些对象会被直接标记为黑色,默认存活)。它无法感知到在标记过程中新产生的垃圾。

为了获得并发的低停顿,我宁愿一次少清理一点(产生浮动垃圾),也绝不能清理错了(需要重新标记来保证),并且要提前行动(降低触发阈值)以防万一(避免Concurrent Mode Failure)。

CMS并发标记阶段增量更新是怎么做的

目标:解决“对象消失”问题。即防止一个在并发标记期间被新引用的白色对象(应该是存活的)被错误回收。
核心思想:破坏“对象消失”的第一个条件(赋值器插入了一条从黑色对象到白色对象的新引用)。它选择跟踪引用关系的变化。
具体操作:
在并发标记期间,如果应用程序线程执行了一个写操作,将一个引用存储到某个字段(例如:objA.field = objC)。
并且,如果写入者(objA)是黑色的(已被扫描过),而被写入的引用(objC)是白色的(尚未被标记)。
那么,写屏障(Write Barrier) 会被触发。
写屏障不会直接处理 objC,而是将黑色对象 objA 重新推回标记栈,将其标记为灰色。
后续处理:
这个“由黑变灰”的操作意味着:“这个对象可能引用了新的东西,需要重新检查”。
在接下来的重新标记阶段(STW),GC线程会从这些被记录的灰色对象(如 objA)出发,重新扫描它们的引用。
这样,新被引用的白色对象 objC 就会被发现并被标记为黑色,从而被“拯救”出来,避免在本轮被回收。

CMS怎么解决跨代引用

CMS有一个全局的卡表,它是一个字节数组,将整个老年代空间划分为固定大小的“卡”(Card),例如512字节一块。
同样通过写屏障实现。当程序更新一个引用时(例如,一个老年代对象 OldObj 引用了一个年轻代对象 YoungObj,即 OldObj.field = YoungObj),写屏障会标记卡表为脏
当发生年轻代GC(Young GC)时,GC器不需要扫描整个老年代,只需要扫描卡表中被标记为“脏”的卡所对应的内存区域。因为这些“脏卡”意味着这里可能存在着指向年轻代的跨代引用。
优点:实现简单,开销低。
缺点:精度粗糙。只要卡内有一个引用被更新,整个512字节的卡都会被标记为脏,年轻代GC时就需要扫描这整个512字节的区域,可能其中大部分引用都是无效的。

G1中怎么解决CMS遇到的问题

1.重新标记阶段时间不可控
用户参数设置STW时间,多个region,收益最高的优先清理,频繁多次清理
2.GC线程与用户线程并发过程影响吞吐量
优化数据结构,卡表变rset 跨代引用更快,region小 避免全局扫描 快,尽量不触发full GC
3.内存碎片没有整理
region复制

带来的新问题,内存开销(region的RSet),写屏障开销(SATB和维护RSet),复杂 需要调优,仍然会Full GC

G1的SATA算法

通过“初始快照”这一简单而强大的概念,结合高效的写屏障技术,优雅地解决了并发标记中最棘手的正确性问题。其代价是会产生一定的浮动垃圾和运行时开销,但这对于实现可预测的短暂停顿这一首要目标而言,是一个非常值得的权衡。
SATB 要保证的是:所有在快照时刻存活的对象,最终都会被标记为存活,从而不会被错误回收。
“对象消失”必须同时满足两个条件:

  • 赋值器插入了一条从一个黑色对象到一个白色对象的新引用。
  • 赋值器删除了所有从灰色对象到该白色对象的直接或间接引用。

SATB 算法的核心在于 破坏第二个条件。

步骤一:它断开了对象B到对象C的引用(B.c = null)。
步骤二:它让对象A建立了一个新的指向对象C的引用(A.c = C)。(这一步其实不影响SATB)
在步骤一(断开引用 B -> C)发生时,写屏障(Write Barrier) 会被触发。
写屏障不会去管现在的引用关系,而是会将被覆盖的旧值(即指向对象C的那个引用)记录下来。它捕获的是“曾经有一个从B到C的引用”这个事实。
这个旧值(对象C的地址)会被放入一个特殊的线程本地队列,称为 SATB 队列。
标记线程会定期检查并处理全局的 SATB 队列(或者当线程本地的 SATB 队列满时,将其推送至全局队列)。
标记线程从 SATB 队列中取出这些旧的引用值(指向对象C),并将这些引用所指向的对象(对象C)标记为灰色(存活),并将其加入标记栈以供进一步扫描。
对象C被标记为灰色,因此它不会被当作垃圾回收。
即使从B到C的引用已经被断开,但因为SATB记录下了这个引用在“快照”时刻是存在的,所以对象C依然得救。

特性 G1 (SATB) CMS (Incremental Update)
哲学 保守主义:保证快照时刻的所有存活对象都被标记。宁可多标记(浮动垃圾),绝不漏标。 修正主义:跟踪并发期间的变化,在STW阶段进行修正。
关注点 删除的引用(B->C 被置null) 新建立的引用(A->C)
屏障动作 记录旧的被引用对象(C) 将黑色的源对象(A) “降级”为灰色
主要处理阶段 并发标记阶段即可处理SATB队列 主要在重新标记(STW) 阶段处理灰色对象
结果 产生浮动垃圾 需要更长的重新标记STW时间
posted @ 2025-09-14 21:33  柠檬水请加冰  阅读(7)  评论(0)    收藏  举报