JVM垃圾收集器
一、垃圾收集算法
复制算法
将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完
后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回
收。
标记-清除算法
首先标记阶段将需要清理的对象进行标记,然后在清除阶段就进行清除,缺点就是会存在内存碎片。
标记-整理算法
前面的步骤和标记-清除算法一样,在标记清除完成之后,会对对象进行移动整理,是的
对象的内存占用变得紧凑,减少内存碎片。
分代收集理论
由于新生代和老年代的生命周期不同,所以对于不同的分代,使用不同的垃圾收集算法收集。
比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次
垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以必须选择"标记-清除"或"标
记-整理"算法进行垃圾收集。注意,"标记-清除"或"标记-整理"算法会比复制算法慢10倍以上。
二、垃圾收集器
垃圾回收算法是对垃圾进行回收的,垃圾回收器是垃圾回收算法的实现者。
2.1 Serial收集器
单线程垃圾回收器,新生代使用复制算法,老年代使用标记-整理算法。
所有回收过程都是STW的,简单高效但是用户体验不好。

2.2 Parallel收集器
Serial收集器的多线程版本,在垃圾回收的时候会使用多线程进行垃圾回收,但是
整个垃圾回收的过程还是STW的。

2.3 CMS收集器
使用标记-清除算法 ,主要是对老年代的清理。

- 初始标记:STW,记录gc root直接引用的对象(三色标记法初始化),速度很快
- 并发标记:使用三色标记法,并发进行标记
- 再标记:STW,使用三色标记法,对在并发标记的时候标记过但是之后又发生变化的对象进行重新标记。
- 并发清理:使用标记清除算法,这也是为什么可以并发清理而不需要STW
- 重置:将标记数据进行重置。
注:
- CMS主要是对老年代的清理,通常搭配parnew清理新生代
- CMS使用标记清除算法,会产生内存碎片,当内存碎片过多的时候会触发full gc,这个时候是STW的。
- CMS的并发重置阶段,不是对整个堆内存的对象进行遍历修改其对象头,而是自身维护了一个bitmap,
将该bitmap的位数进行重置即可,速度很快。
三、垃圾收集底层实现原理
3.1 三色标记法
三色标记算法是把Gc roots可达性分析遍历对象过程中遇到的对象, 按照"是否访问过"这个条件标记成以下三种颜色:
- 黑色:表示该对象已经扫描过,且表示它是一个存活的对象。
- 灰色:表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
- 白色:表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段,
仍然是白色的对象, 即代表不可达。
算法执行流程
- 初始状态:所有对象都是白色。
- 初始标记:从GC Roots(如栈变量、静态变量)出发,将直接关联的对象标记为灰色。
- 并发标记(循环):
- 从灰色集合中取出一个对象。
- 将该对象引用的所有白色子对象标记为灰色。
- 将该对象自己标记为黑色。
- 结束条件:当灰色集合清空时,标记结束。
- 筛选回收:此时所有黑色对象是存活的,所有白色对象是垃圾,进行回收。
多标-浮动垃圾
"多标" 指的是:本来应该被回收的垃圾,被错误地标记为存活对象,导致本次 GC 没有回收它。
-
场景重现
假设对象关系为 A -> B。
- GC 扫描:GC扫描到了A,将A标为黑色,将B标为灰色(或者把B加入待处理队列)。
- 用户修改:在GC继续深度扫描之前,用户线程执行了 A.B = null;
- 结果:此时B其实已经是垃圾了。但因为B已经被标记为灰色(或被灰色引用),GC 依然会认为它是存活的,并继续扫描 B 的子对象。最终 B 会变成黑色,逃过本次回收。
-
后果
- 这部分本该回收但没回收的内存,称为**"浮动垃圾"**。
- 它们虽然占用了空间,但不会影响程序正确性。等到下一次 GC 时,因为没有引用指向 B,它自然会变回白色被回收掉。
漏标-读写屏障
"漏标" 指的是:本来是活着的对象,因为引用关系的改变,被 GC 误判为白色(垃圾),导致被回收掉了。这是严重的BUG(会导致 NullPointerException)。
- 场景重现
假设当前关系:A (黑色) 已经扫描完,B (灰色) 正在扫描,B -> C (白色)。- 用户线程执行 B.c = null; 此时,C 失去了唯一的灰色保护伞。GC 扫描 B 时,发现 B 没引用了,B 变黑,C 还是白的。
- 用户线程执行 A.c = C;
用户把 C 挂到了 A 下面。但因为 A 已经是黑色的(代表"我已扫描完毕,不用再看我"),GC 不会再回头去检查 A 引用的对象。 - 结果: GC 认为所有灰色对象都处理完了,开始清理。此时 C 依然是白色,于是 C 被当做垃圾清理掉了。 但实际上 A 还引用着 C,当代码访问 A.c 时,程序崩溃
如何处理漏标现象:
- 增量更新(CMS使用):当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些
记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就
变回灰色对象了。 - 原始快照(SATB)(G1使用):当灰色对象指向的白色对象被断开的时候,JVM就将这个白色对象放到一个栈中,并标记为灰色,标记完成之后再次扫描这个栈中的灰色对象(就是默认这个断开的对象及其引用是存活的)。
增量更新和原始快照的实现都是通过==写屏障(类似AOP,在写内存之前先执行一个函数操作)==的方式。
对于增量更新,最后的再次扫描是STW的
CMS:写屏障+增量更新
G1: 写屏障+原始快照
ZGC: 读屏障
为什么CMS使用增量更新而G1使用原始快照?
- CMS 使用增量更新,是因为它更看重**"准确性"**(尽量少产生浮动垃圾),哪怕要在最后的重新标记阶段多花点时间去"深度扫描"。
- G1 使用 SATB,是因为它更看重**"停顿时间的不可预测性"**(Stop The World 的时间必须可控),它宁愿产生浮动垃圾,也不愿在最后阶段做不可控的深度扫描
四、记忆集和卡表
记忆集的简单理解就是使用一个表,记录老年代中哪些内存块引用了新代中的对象。
在gc扫描的时候就需要额外扫描老年代中的这几个内存块。卡表就是记忆集的具体实现。
在CMS中,由于新生代是一块连续的空间,所以只需要维护一个全局map,记录每个老年代块是否引用了新生代对象即可。
而在G1中,由于内存空间是以region为单位,所以每一个新生代region都需要维护一个map,记录老年代是否引用了本新生代region(因为G1需要有在任何时候都能独立清理某个region的能力)。
五、G1回收器
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。
一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数
"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。

Humongous:如果一个对象的大小大于一个region的一半,则默认将其放到大对象区。
垃圾回收流程

- 初始标记:STW,和CMS初始标记一样
- 并发标记:同CMS
- 再标记:STW,同CMS
- 筛选回收:主要使用复制算法,将一个region中的对象复制到另外一个空闲的region中。根据用户设定的
最长停顿时间,进行某些region的评估筛选(不一定要清理所有region)(G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region)。
垃圾回收分类
- YoungGC:对于某个新生代region满了,不会触发YoungGC,而是所有新生代region所占空间总和达到某个阈值时,才会触发YoungGC。
- MixedGC:当老年代占用的空间达到某个阈值时,就会触发MixedGC。
- FullGC:停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这
个过程是非常耗时的。(例如有一个大对象,而没有足够的连续空间的region可以容纳,就会触发FullGC)
六、ZGC
内存布局
ZGC收集器是一款基于Region内存布局的,jdk11引入,jdk11-jdk20不分代,jdk21引用分代, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整
理算法的, 以低延迟为首要目标的一款垃圾收集器。
ZGC的Region可以分为大、中、小三类(分代版本只有小、大):
- 小型region:容量固定为2MB, 用于放置小于256KB的小对象。
- 中型region:容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
- 大型region:容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或
以上的大对象。 每个大型Region中
只会存放一个大对象, 这也预示着虽然名字叫作"大型Region", 但它的实际容量完全有可能小于中型
Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配。
分代版本:
- 小型region:小对象 (< 256KB)
- 大型region:大对象 (≥ 256KB)(通常直接进入老年代,或者在年轻代但由于体积大不进行拷贝移动,仅逻辑晋升)
着色器
不同于其他垃圾收集器,ZGC的着色器对象地址来平替三色标记法,并将对象的引用信息存在对象地址中而不是对象头中。
-
0 \~ 43 位\]: 对象地址(Object Address)共 44 位,这意味着 ZGC 能管理的内存最大为 2442\^{44}244 Bytes = 16 TB。
-
48 \~ 63 位\]: 未使用/保留受限于硬件(如 Intel/AMD 的 48-bit 虚拟地址空间),这部分通常全为 0 或不做处理。
- Marked0 (第 44 位)
- Marked1 (第 45 位)
- Remapped (第 46 位)
- Finalizable (第 47 位)
- Marked0 / Marked1:
- 这两个位用于标记阶段,标识对象是否存活。
- 为什么有两个? 类似于"红黑"交替。ZGC 在一次 GC 周期使用 Marked0,下一次就使用 Marked1。这样可以在不重置整个堆的标记位的情况下,区分是"上一轮存活"还是"这一轮存活"的对象。
- Remapped: 表示该引用指向的对象已经被移动(Relocated),且该引用**已经修正(Heal)**指向了新的内存地址。
如果指针是 Remapped 状态,说明它是安全的,直接访问即可。 - Finalizable: 用于表示该对象只能通过 Finalizer 访问(即对象不可达,但需要执行 finalize() 方法)。
读屏障
在GC的过程中,可能会存在对象的读取,那么此时会优先读取着色器中的Remapped位判断是否可读(对象是否被GC使用复制算法迁移),
如果不可读则进行重定向,并更新地址。这就是为什么ZGC可以在使用复制算法的同时实现并发清理。
七、如何选择垃圾收集器
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
- 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC