GC可达性、引用处理与回收算法完整原理
垃圾收集不是“内存满了以后删除没有变量名的对象”。HotSpot必须在多线程、JIT编译、对象互相引用和业务持续修改对象图的条件下完成:
- 找到可信的GC Roots。
- 判断哪些对象仍可从Roots到达。
- 在并发修改引用时避免漏标活对象。
- 按强、软、弱、虚引用规则处理对象。
- 回收或移动对象并修正所有受影响引用。
- 在暂停、吞吐、CPU、内存和碎片之间取舍。
本页讲算法与正确性;具体Serial、Parallel、CMS、G1如何组合算法见HotSpot垃圾收集器,对象怎样在Eden、TLAB、Survivor和Old流转见对象分配、TLAB、晋升与GC。
学习目标
学完后应能:
- 解释引用计数与Tracing GC的根本区别。
- 画出GC Roots到对象图的可达性分析。
- 说明线程栈、静态字段、JNI、锁和JVM内部结构为什么可能成为Roots。
- 解释安全点、OopMap和STW怎样建立可靠根集合。
- 使用白、灰、黑三色说明标记状态。
- 推导并发标记漏标的两个必要条件。
- 区分CMS增量更新与G1 SATB的基本思想。
- 正确理解强、软、弱、虚引用和ReferenceQueue。
- 说明SoftReference为什么不适合做可靠生产缓存。
- 解释WeakHashMap为何仍可能因value反向引用key而泄漏。
- 说明PhantomReference为什么
get()永远返回null、为什么必须关联队列。 - 解释finalize的一次性复活、队列积压和JDK版本边界。
- 比较标记清除、复制、标记整理的工作量、碎片和暂停代价。
- 解释分代假设、Card Table和Remembered Set为什么存在。
- 区分“分配率高”“存活率高”“内存泄漏”和“容量不足”。
一、哪些区域需要GC
1.1 线程私有区域
Java虚拟机栈中的栈帧、程序计数器等通常随方法和线程生命周期管理。方法返回后栈帧弹出,线程结束后线程私有结构释放,不需要像堆对象那样从GC Roots做全局对象回收。
但栈中的引用是GC Roots的重要来源。GC不是“回收栈”,而是需要知道栈里哪些局部变量正在指向堆对象。
1.2 Java堆
对象和数组主要位于Java堆,生命周期不随创建方法简单结束。方法返回后,如果对象被静态字段、线程、缓存或其他长寿命对象持有,它仍然可达。
1.3 方法区逻辑数据与类元数据
JVM规范的方法区是逻辑概念。HotSpot中:
- JDK7常由永久代等实现承载大量类元数据。
- JDK8主要迁到本地内存Metaspace。
java.lang.Class镜像本身是堆对象。
类元数据可随定义ClassLoader卸载,但条件比普通对象回收严格。
1.4 堆外与本地内存
Direct Memory、线程栈、Code Cache和JNI内存不一定由普通Java堆Tracing GC直接释放。某些DirectByteBuffer通过Java对象可达性和Cleaner/引用处理最终释放本地内存,但释放时机不能替代显式资源生命周期设计。
二、引用计数为什么直观但HotSpot堆主要不用
引用计数为每个对象维护引用数量:新增引用加一,断开引用减一,计数为零即可回收。
优点:
- 对象失去最后引用后可以较快识别。
- 回收工作可分散到引用更新路径。
- 某些资源管理和语言运行时使用引用计数或混合方案。
主要代价:
- 每次引用写入都要维护计数,多线程还要处理并发正确性。
- 计数本身占空间。
- 批量释放对象图可能造成释放抖动。
- 单纯引用计数不能识别循环垃圾。
flowchart TD
A["Root不再引用对象A/B"] --> B["A仍引用B"]
B --> C["B仍引用A"]
C --> D["A、B计数都不为0"]
D --> E["单纯引用计数无法回收"]循环问题可以通过额外循环检测解决,因此准确说法不是“引用计数理论上绝对无法处理循环”,而是单纯计数不足,需要更复杂补充算法。HotSpot Java堆主要采用从Roots追踪的Tracing GC。
Netty ByteBuf的引用计数是显式资源/缓冲区生命周期协议,与HotSpot判断普通Java堆对象存活不是同一机制。
三、可达性分析
Tracing GC从一组可信根开始沿引用边遍历:
flowchart TD
R["GC Roots"] --> A["对象A"]
A --> B["对象B"]
B --> C["对象C"]
X["对象X"] --> Y["对象Y"]
Y --> X- A、B、C可从Roots到达,必须保留。
- X、Y即使循环引用,只要没有Root路径,就属于不可达候选。
“不可达”通常表示有资格按引用/finalization规则进一步处理,不代表这一纳秒已经释放,也不保证GC立即运行。
四、哪些对象可能是GC Roots
常见工程分类:
- 活动线程栈帧局部变量表、寄存器和JIT已知位置中的对象引用。
- 已加载类的静态字段引用。
- JNI局部/全局引用。
- 正在持有的同步锁/监视器关联对象。
- 活动线程对象和JVM内部运行结构。
- 引导类加载器、系统类和收集器实现所需的其他内部Roots。
Root集合是JVM实现细节与规范语义共同结果,不能只背“栈、静态、常量、Native”四个词。字符串常量池、ClassLoader、线程和JIT优化后的引用位置都要由JVM准确描述。
五、安全点与OopMap为什么需要
业务线程运行时,局部变量可能位于栈槽、CPU寄存器,甚至被JIT优化消除。GC不能把任意机器字都猜成引用,也不能在指令更新引用一半时随意扫描。
HotSpot会在特定位置保存对象引用位置元数据,可概括为OopMap;在安全点,线程状态满足JVM可准确枚举Roots和执行需要全局一致性的操作。
flowchart TD
A["GC请求STW"] --> B["各业务线程运行到安全点/安全状态"]
B --> C["线程暂停并暴露可靠引用位置"]
C --> D["JVM依据OopMap枚举Roots"]
D --> E["标记、转移或修正引用"]
E --> F["恢复业务线程"]5.1 为什么STW有时包含等待安全点时间
发起暂停到所有线程真正停下之间可能有Time To Safepoint。长时间运行的本地代码、历史JDK中的不可中断路径或特殊循环可能让线程较慢到达安全点。
所以“GC日志暂停长”要进一步区分:
- 等待线程到达安全点。
- Root扫描。
- 对象复制/整理。
- 引用处理。
- 类卸载、字符串去重等附加工作。
六、三色标记模型
三色是理解并发标记的抽象,不表示对象头真的涂成三种颜色:
- 白色:尚未被发现,周期结束仍白色则可能回收。
- 灰色:对象已发现,但它引用的子对象尚未全部扫描。
- 黑色:对象已发现,并且其出边已经扫描完成。
flowchart TD
A["Roots先变灰"] --> B["取一个灰对象"]
B --> C["把其白色子对象变灰"]
C --> D["当前对象变黑"]
D --> E{"还有灰对象?"}
E -- "是" --> B
E -- "否" --> F["剩余白对象为回收候选"]在完全STW下,对象图不变,过程直观;并发标记时业务线程称为Mutator,会同时新增、删除和替换引用。
七、并发标记怎样发生漏标
考虑:
黑对象A已经扫描完成
灰对象B尚未扫描完
白对象C当前由B引用业务线程并发执行:
A新增引用C
B删除引用C如果GC不知道这两次变化:
- A已经黑,不会再扫描新增的C。
- B扫描时已经看不到C。
- C仍白,但实际上已被A引用。
回收C就会破坏程序。
漏标通常需要两个条件同时成立:
- 黑对象获得指向白对象的新引用。
- 灰对象删除了通向该白对象的旧引用,破坏原可达路径。
并发算法通过写屏障破坏其中一个条件。
八、写屏障不是Java内存屏障的同义词
GC写屏障是JIT在对象引用写操作前后插入的记账逻辑,例如记录旧引用或把Card标脏。它与JMM用于可见性/有序性的CPU内存屏障有联系但不是同一个概念,不能因为都叫Barrier就混为一谈。
写屏障带来少量业务线程CPU和缓冲区开销,换来并发标记正确性和较小扫描范围。
九、CMS增量更新与G1 SATB
9.1 CMS增量更新思想
CMS重点关注并发标记期间“黑对象新指向白对象”的引用更新,把相关Card/引用变化记录下来,Remark时重新扫描受影响区域,等价于让黑对象在必要时重新变成需要检查的状态。
9.2 G1 SATB思想
SATB(Snapshot At The Beginning)尝试保留并发标记开始时对象图的逻辑快照。业务覆盖旧引用前,写屏障记录旧引用:
old = object.field
记录old
object.field = newValue这样即使并发期间删除了原路径,起始快照中可达的对象仍被本轮保守保留。
flowchart TD
A["并发标记开始"] --> B["建立SATB逻辑快照"]
B --> C["Mutator覆盖引用"]
C --> D["写屏障记录旧引用"]
D --> E["并发线程处理标记队列"]
E --> F["Remark处理剩余队列"]SATB可能产生浮动垃圾:快照开始时可达、之后变垃圾的对象本轮仍保留到后续周期。这是正确性和并发性的取舍。
十、四种引用必须结合可达性和队列理解
Java通过java.lang.ref提供软、弱、虚引用。Reference对象本身也是普通堆对象;referent是它关联的目标。
10.1 强引用
Object value = new Object();只要存在从Roots经强引用链到目标,GC不能按软弱策略清除它。常见内存泄漏本质是业务不再需要,但仍有强引用链,例如:
- static Map无界增长。
- ThreadLocal未remove且线程长期存活。
- 监听器注册后未注销。
- MQ积压对象滞留内存队列。
- ClassLoader被线程/TCCL/缓存引用。
10.2 软引用SoftReference
软可达对象在内存压力下可被清除。JDK7/8 HotSpot有历史软引用时钟/策略参数,但具体清理受实现、堆、访问时间和压力影响。
SoftReference不适合作为可靠商业缓存的主要容量治理:
- 清理时机不可精确预测。
- 内存压力时可能批量失效造成缓存击穿。
- 没有业务TTL、最大条数、权重和命中率治理。
- 可能在接近内存压力时增加延迟抖动。
生产缓存应使用显式最大容量、TTL、统计和降级;软引用最多作为特定场景的辅助。
10.3 弱引用WeakReference
当对象只剩弱可达关系,GC在处理该可达性级别时可清除相关WeakReference,并按规则把已注册的Reference入队。不能表述成“每执行任何一次GC都必然立刻清除”,因为System.gc()只是建议,收集范围和引用处理时机由JVM决定。
典型用途:不应阻止对象回收的关联元数据、规范化映射和WeakHashMap键。
10.4 WeakHashMap陷阱
WeakHashMap弱化的是key。若value强引用key:
WeakHashMap
→ 强引用Entry/value
→ value强引用keykey仍可能从Map Root通过value到达,无法按预期回收。弱容器不是自动防泄漏工具。
10.5 虚引用PhantomReference
JDK7/8构造PhantomReference必须关联ReferenceQueue:
ReferenceQueue<Object> queue = new ReferenceQueue<Object>();
PhantomReference<Object> ref =
new PhantomReference<Object>(target, queue);关键性质:
ref.get()始终返回null,不能通过虚引用重新取得对象。- 不延长目标可用生命周期。
- 目标达到相应可达性并经GC处理后,Reference可入队。
- 常用于得到“对象已不能再被正常使用”的通知,再配合独立保存的本地资源句柄做清理。
JDK9 Cleaner提供更高层机制;JDK7/8可用ReferenceQueue构建清理,但数据库连接、文件、Socket和锁仍应优先try-with-resources/显式close,不能等GC。
十一、ReferenceQueue完整链路
flowchart TD
A["创建Reference并注册queue"] --> B["保留Reference对象本身"]
B --> C["目标失去更强可达路径"]
C --> D["GC执行对应引用处理"]
D --> E["清除/处理referent"]
E --> F["Reference进入queue"]
F --> G["清理线程remove/poll取得Reference"]
G --> H["根据Reference旁存句柄清理资源"]必须保留Reference对象本身,否则Reference也可能先被回收,队列消费者收不到预期通知。清理线程还需要:
- 可停止的生命周期。
- 异常隔离。
- 队列积压监控。
- 清理幂等。
- 不在Reference中强引用回目标对象。
十二、finalize为什么不能做可靠资源释放
JDK7/8仍有Object.finalize(),但它有严重问题:
- 触发依赖GC,不保证及时。
- Finalizer处理能力有限,阻塞会导致队列积压和对象滞留。
- 对象通常要经历额外生命周期才能真正回收。
- finalizer可把
this写入静态字段实现一次“复活”。 - 复杂继承、安全和异常处理难以推理。
如果首次finalize复活对象,后续再次不可达时JVM不会自动再次调用同一对象的finalize。不要描述成“JVM会中断耗时finalize并直接清除”,实际更常见风险是Finalizer线程被阻塞,导致大量待终结对象堆积。
JDK9起finalize被标记Deprecated,后续又走向forRemoval。JDK7/8项目也应使用JDK7已有的AutoCloseable和try-with-resources:
try (InputStream input = new FileInputStream(path)) {
// use input
}十三、对象不可达后的处理不是一刀切
简化链路:
flowchart TD
A["强可达路径消失"] --> B{"是否软/弱/虚可达"}
B --> C["按Reference规则处理"]
C --> D{"是否具有尚未执行的finalization语义"}
D -- "是" --> E["进入终结处理,可能发生一次复活"]
D -- "否" --> F["成为可回收候选"]
E --> G{"重新建立强可达?"}
G -- "是" --> H["暂时存活"]
G -- "否" --> F实际收集器阶段和JDK版本更复杂,重点是:不可达、Reference清除、入队、finalization和物理空间复用不是同一个瞬间。
十四、标记—清除
步骤:
- 从Roots标记所有存活对象。
- 扫描管理区域,回收未标记对象形成的空闲块。
- 维护空闲列表/位图供后续分配。
flowchart TD
A["对象混合分布"] --> B["标记存活对象"]
B --> C["清除未标记对象"]
C --> D["形成多个不连续空闲块"]优点:不必复制所有存活对象。缺点:
- 标记和清除需要扫描。
- 外部碎片导致总空闲足够但找不到连续大块。
- 分配需要空闲列表等结构,不再只是连续指针碰撞。
CMS普通并发清除阶段体现标记—清除思想,因此需要面对碎片和并发失败。
十五、复制/Evacuation
把收集集合中的存活对象复制到目标区域,更新引用,然后整块回收源区域:
flowchart TD
A["Source区域"] --> B["识别存活对象"]
B --> C["复制到Target区域"]
C --> D["建立转发地址并修正引用"]
D --> E["整块释放Source"]成本与“存活对象数量/大小”更相关。年轻代大多数对象死亡时,复制少量存活对象非常高效。
代价:
- 需要目标空间预留。
- 存活率高时复制成本大。
- 必须更新所有指向移动对象的引用。
- Target不足会发生晋升、Evacuation Failure或退化。
教材“把整个内存固定分成相等两半”是最简单Semispace模型。HotSpot年轻代通常使用Eden+两个Survivor并允许晋升,G1则在Region间Evacuate,不能把所有复制算法都说成永久浪费一半堆。
十六、标记—整理
先标记存活对象,再把存活对象压向一侧或按整理策略移动,更新引用,最后得到连续空闲空间:
flowchart TD
A["标记存活对象"] --> B["计算新地址"]
B --> C["移动/压紧存活对象"]
C --> D["修正所有引用"]
D --> E["得到连续空闲区域"]优点:消除外部碎片,后续可用指针碰撞快速分配。代价:
- 移动大量长寿命对象成本高。
- 引用更新复杂。
- 常需要STW或更复杂并发转移屏障。
Serial Old、Parallel Old和许多Full GC路径体现标记整理思想;具体实现可能滑动整理、区域整理或并发转移,不是一个统一步骤数。
十七、三种算法怎样选
| 特征 | 标记清除 | 复制/Evacuation | 标记整理 |
|---|---|---|---|
| 移动存活对象 | 否 | 是 | 是 |
| 外部碎片 | 容易产生 | 源区域整块释放 | 整理后减少 |
| 额外目标空间 | 较少 | 需要 | 通常不要求对等半区 |
| 适合 | 不想移动/并发清除 | 存活率低区域 | 存活率高且需连续空间 |
| 主要风险 | 碎片 | 复制量和To-space不足 | 移动与引用修正停顿 |
算法名称只描述核心空间处理,真实收集器还包含并行度、并发标记、屏障、代、Region和失败退化策略。
十八、为什么要分代
两个经验假设:
- 弱分代假设:多数对象朝生夕死。
- 强分代假设:熬过越多次GC的对象往往越可能继续存活。
因此把新对象集中在年轻区域,用复制算法回收少量存活对象;长期存活对象进入老年代,减少每次Young GC扫描范围。
flowchart TD
A["新对象进入Eden"] --> B["Young GC"]
B --> C{"仍存活?"}
C -- "否" --> D["快速回收"]
C -- "是" --> E["Survivor/年龄增长"]
E --> F{"达到晋升条件?"}
F -- "否" --> B
F -- "是" --> G["Old"]分代不是JVM规范要求所有收集器物理连续划代。G1用Region动态承担Eden/Survivor/Old,但逻辑分代仍存在;ZGC等新版本的分代能力还要按具体JDK版本判断。
十九、跨代引用为什么需要Card Table
Young GC若为找“Old对象引用Young对象”而扫描整个Old,会抵消分代收益。HotSpot把堆划成Card,业务写引用时通过写屏障把对应Card标脏。
flowchart TD
A["Old对象字段写入Young引用"] --> B["写屏障定位Card"]
B --> C["Card Table标记dirty"]
C --> D["Young GC扫描Roots+脏Card"]
D --> E["无需遍历整个Old"]G1进一步按Region维护Remembered Set,记录外部哪些Card可能指向目标Region。代价是写屏障CPU、队列处理和RSet内存。
二十、分配担保与失败
Young GC后存活对象可能放不进Survivor,需要晋升Old。GC必须评估/尝试让Old承接这些对象。
正确理解不是“只要存活超过10%就全部直接进入老年代”,而是由:
- Survivor容量。
- 对象年龄和动态阈值。
- 实际存活/晋升量。
- Old连续/可用空间。
- 收集器与JDK策略。
共同决定。
Old无法承接可能出现Promotion Failed、退化Full GC或最终OOM。G1则可能出现To-space exhausted/Evacuation Failure等自己的失败形态。
二十一、Minor、Major、Mixed和Full不是统一规范术语
- Young/Minor:通常处理年轻区域,但会扫描Roots/跨代卡并发生晋升。
- Major/Old:资料和工具口径并不完全统一。
- G1 Mixed:年轻Region加部分Old Region。
- Full GC:通常覆盖更完整堆和更多元数据,停顿/退化较重,但具体实现随收集器和版本变化。
生产必须先确认收集器并读实际日志,不能背“Minor一定只看新生代”“Major一定等于Full”。
二十二、类卸载与方法区回收
一个自定义加载器定义的类要具备卸载条件,通常需要:
- 所有实例不可达。
java.lang.Class镜像不可达。- 定义ClassLoader不可达。
- 没有线程、TCCL、JNI、静态缓存等继续引用加载器/类。
- JVM执行支持类卸载的GC周期。
flowchart TD
A["插件停止"] --> B{"线程/TCCL/缓存仍引用ClassLoader?"}
B -- "是" --> C["类元数据不能卸载"]
B -- "否" --> D["实例、Class镜像、加载器不可达"]
D --> E["满足条件的GC卸载元数据"]JDK7可能表现为PermGen增长,JDK8表现为Metaspace增长。只调大MaxMetaspaceSize不会修复ClassLoader泄漏。
二十三、JDK7/8 Demo:循环引用与WeakReference队列
下面代码只使用JDK7 API。它证明目标不是“引用计数归零”,而是从Roots是否可达。System.gc()只是建议,因此Demo带循环等待和内存压力,但不同JVM仍可能在不同轮次回收。
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
public class ReachabilityDemo {
static class Node {
Node other;
final byte[] payload = new byte[256 * 1024];
}
public static void main(String[] args) throws Exception {
ReferenceQueue<Node> queue = new ReferenceQueue<Node>();
Node first = new Node();
Node second = new Node();
first.other = second;
second.other = first;
WeakReference<Node> observed =
new WeakReference<Node>(first, queue);
first = null;
second = null;
List<byte[]> pressure = new ArrayList<byte[]>();
for (int i = 0; i < 100 && observed.get() != null; i++) {
try {
pressure.add(new byte[256 * 1024]);
} catch (OutOfMemoryError error) {
pressure.clear();
}
System.gc();
Thread.sleep(20L);
}
Reference<? extends Node> queued = queue.remove(1000L);
System.out.println("referentCleared="
+ (observed.get() == null));
System.out.println("enqueued=" + (queued == observed));
}
}运行:
javac ReachabilityDemo.java
java -Xms16m -Xmx16m ReachabilityDemo通常会看到:
referentCleared=true
enqueued=true即使两个Node互相引用,只要外部Root路径消失,Tracing GC仍可回收它们。队列入队和清除的观察存在时序,因此Demo使用queue.remove(timeout)等待,仍不能把System.gc()当作生产代码的确定性协议。
二十四、JDK7/8 Demo:制造年轻代分配压力
public class YoungAllocationDemo {
public static void main(String[] args) throws Exception {
for (int round = 0; round < 80; round++) {
for (int i = 0; i < 100; i++) {
byte[] temporary = new byte[32 * 1024];
temporary[0] = 1;
}
Thread.sleep(20L);
}
System.out.println("done");
}
}JDK7/8日志:
javac YoungAllocationDemo.java
java -Xms32m -Xmx32m -Xmn16m \
-XX:+UseSerialGC \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
YoungAllocationDemoJDK9+才使用:
java -Xms32m -Xmx32m -XX:+UseSerialGC -Xlog:gc* YoungAllocationDemo不要把JDK9的-Xlog复制到JDK8。
二十五、商业场景:GC后Old基线持续上升
订单接口加入static本地缓存,key包含用户和日期却没有最大容量/TTL。对象分配率并不极端,但每次GC后Old占用基线继续上升,最终频繁Full GC和Heap OOM。
flowchart TD
A["请求创建结果对象"] --> B["放入static Map"]
B --> C["对象从类静态字段Root可达"]
C --> D["Young GC后继续存活并晋升"]
D --> E["Old基线持续抬升"]
E --> F["Full GC回收很少"]
F --> G["Java heap space"]排查:
- 对齐GC日志与业务流量,确认GC后Old基线趋势。
- 连续对象直方图看Map、Entry、业务DTO数量增长。
- Heap Dump用MAT查看Dominator Tree和Retained Heap。
- Path To GC Roots找到
static cache -> entries -> DTO。 - 修复为最大容量、TTL、按业务失效、命中率/淘汰监控。
- 压测验证基线稳定,不只调大Xmx。
分配率高与泄漏不同
分配率高 + GC后占用回落
→ 短命对象风暴/吞吐问题
分配率不高 + GC后Old基线持续上涨
→ 长寿命持有/泄漏优先
分配率和基线都高
→ 峰值容量与持有链都要查二十六、商业场景:软引用缓存雪崩
图片处理服务用SoftReference缓存大图片,平时命中率高。流量高峰和内存压力下,大量软引用被集中清除,所有请求同时回源解码,CPU、磁盘和分配率进一步升高,形成恶性循环。
治理:
- 使用显式权重/容量和TTL缓存。
- SingleFlight或请求合并避免并发回源。
- 限制单图大小和解码并发。
- 监控命中、淘汰、加载失败和回源耗时。
- 把SoftReference作为实现细节而非唯一容量策略。
二十七、生产GC排查Runbook
27.1 先确认现场
java -version
jcmd <pid> VM.flags
jcmd <pid> VM.command_line
jstat -gcutil <pid> 1000 10保存GC日志、业务P99、吞吐、CPU、RSS、容器limit和发布事件。
27.2 判断回收行为
| 证据 | 可能方向 |
|---|---|
| Young频繁但Old稳定 | 分配率高、短命对象多 |
| Young后晋升量大 | 存活率高、Survivor不足、批量峰值 |
| Full后Old基线不降 | 强引用持有、泄漏或真实工作集过大 |
| Heap正常但RSS上涨 | Direct、线程栈、Metaspace、Native |
| Remark长 | 脏卡/引用变化、对象图、CPU、引用处理 |
| Reference处理长 | Soft/Weak/Finalizer/Cleaner积压 |
| Allocation图高 | 分配热点,不等于泄漏 |
27.3 逐步升级证据
- 先看历史指标和GC日志。
- 进程存活时低风险连续jstat、线程栈。
- 对象直方图比较增长趋势。
- 有副本、磁盘和暂停预算时再Heap Dump。
- MAT看Dominator Tree、Retained Heap、Path To GC Roots。
- 修代码/容量后用相同负载验证。
二十八、常见误区
| 误区 | 正确理解 |
|---|---|
| 没有Java变量名的对象就是垃圾 | 看从GC Roots是否可达 |
| 循环引用HotSpot无法回收 | Tracing GC可回收无Root路径的环 |
| 不可达后立刻物理释放 | 还涉及GC时机、Reference和finalization处理 |
| 每次GC一定清除所有弱引用 | 取决于对象可达级别、收集范围和引用处理 |
| SoftReference能自动做可靠缓存 | 清理不可预测且可能批量回源 |
| PhantomReference能get回对象 | get始终为null,主要配合ReferenceQueue通知 |
| finalize会被JVM及时执行 | 不保证及时,阻塞会导致队列积压 |
| Class对象在Metaspace | Class镜像在堆,类元数据在永久代/Metaspace等实现区 |
| 复制算法永远浪费一半堆 | 那是简单Semispace模型,HotSpot代/Region更复杂 |
| Survivor不够只因存活超过10% | 还取决于年龄、容量、晋升量和收集器策略 |
| Allocation火焰图能证明泄漏 | 只说明谁分配,泄漏要看存活与Root链 |
| Full GC后不降说明GC失效 | 更常见是对象仍被Roots强引用 |
二十九、面试标准回答
GC怎样判断对象是否存活
HotSpot主要使用从GC Roots出发的可达性分析。Roots包括活动线程栈/JIT已知引用、静态字段、JNI引用、锁和JVM内部结构等。能沿强引用链到达的对象存活;无Root路径的循环引用也可回收。安全点和OopMap让JVM准确枚举引用位置。
三色标记为什么会漏标
并发标记中,如果黑对象新指向白对象,同时灰对象删除了通向该白对象的旧路径,GC可能不再扫描到仍存活的白对象。CMS用增量更新重点记录新引用并在Remark重扫,G1 SATB记录被覆盖的旧引用,破坏漏标条件。写屏障为此给业务引用写入增加记账。
强软弱虚引用区别
强可达对象不能按内存压力回收;软可达对象可在内存压力下清除,但时机不适合做可靠缓存;弱可达对象在相应GC引用处理时可被清除;虚引用不延长对象可用生命周期,get永远为null,必须关联ReferenceQueue,常用于不可再使用后的清理通知。资源仍应显式close。
标记清除、复制、标记整理区别
标记清除不移动存活对象但产生碎片;复制/Evacuation把存活对象移到目标区域并整块释放源区域,适合存活率低区域但需要To-space和引用更新;标记整理移动压紧存活对象得到连续空间,适合高存活区域但移动和修正成本高。真实收集器还组合并行、并发、分代和Region策略。
为什么不推荐finalize
finalize依赖GC且不保证及时,Finalizer线程阻塞会让对象队列积压,对象还可能一次复活并延长生命周期。JDK9起已Deprecated并继续走向移除。JDK7/8也应使用AutoCloseable和try-with-resources,Phantom/Cleaner只能做兜底而不是主要资源管理。
三十、关联知识点
- HotSpot垃圾收集器与JDK7/8选型
- 对象分配、TLAB、晋升与GC
- JVM组成与GC Roots
- JVM参数与诊断命令
- JVM常用排查工具
- OOM生产级排查
- 火焰图与Allocation采样
- ThreadLocal生命周期
- JavaSE面试标准回答
三十一、学习验收
- [ ] 能画出无Root路径循环对象并解释为何可回收。
- [ ] 能列出至少五类GC Roots并说明OopMap作用。
- [ ] 能用三色模型推导并发漏标两个条件。
- [ ] 能比较CMS增量更新和G1 SATB保护哪类引用变化。
- [ ] 能正确解释Soft、Weak、Phantom和ReferenceQueue。
- [ ] 能指出WeakHashMap value反向引用key的风险。
- [ ] 能解释finalize为什么会积压且只能自动调用一次。
- [ ] 能从存活率和碎片比较三种回收算法。
- [ ] 能解释Card Table如何维持分代收益。
- [ ] 能从GC后Old基线区分分配风暴、容量不足和泄漏。
本章小结
GC的核心是维护“对象图在并发变化下仍然正确”的可达性事实,再选择合适空间算法复用内存。Roots、安全点和OopMap解决从哪里开始;三色、屏障、增量更新和SATB解决并发正确性;Reference体系处理不同可达强度;标记清除、复制和整理解决空间复用。只有把这些链路连起来,才能从GC日志和Heap引用链解释线上现象,而不是把所有问题都归结为“内存不够”。
