♻️ 垃圾回收
对象存活判定 · 四大引用 · 三大回收算法 · CMS/G1/ZGC · GC 调优
1. 如何判断对象是否存活?
- 引用计数法:每个对象维护计数器,被引用 +1,解除 -1,为 0 即回收。缺点:无法解决循环引用(A↔B 互指但外部无引用,计数器不为 0 却已死)。JVM 不使用。
- 可达性分析(根搜索):从 GC Roots 出发向下搜索,不可达的对象判定为可回收。
- GC Roots 包括:虚拟机栈中引用的对象(局部变量)、静态变量引用、常量引用、本地方法栈(JNI)引用、被 synchronized 持有的对象等
- 标记后不立即回收:对象有一次 finalize() 自救机会(第一次标记 → 判断是否需执行 finalize,重写并在其中重新引用自己可自救)。但这个方法JDK 9 已标记废弃、JDK 18(JEP 421)默认禁用,执行时机不确定且有性能问题——面试提到它应当说"已废弃,用 try-with-resources / Cleaner 替代"
四种引用(由强到弱):
- 强引用:new 出来的默认引用。只要可达,永不回收
- 软引用 SoftReference:内存不足时才回收。适合做缓存(如 ImageCache)
- 弱引用 WeakReference:下一次 GC 必回收。典型应用:ThreadLocal 的 key、WeakHashMap
- 虚引用 PhantomReference:无法通过它拿到对象,仅用于跟踪对象回收(配合 ReferenceQueue 做资源清理,如 DirectByteBuffer 的 Cleaner)
🎯 面试要点
- 循环引用对象会被可达性分析正确回收(JVM 不采用引用计数)
- 软引用 OOM 前被回收:-XX:SoftRefLRUPolicyMSPerMB 控制存活时长
- 延伸:ThreadLocal 内存泄漏 = 弱引用 key 被回收 + value 强引用链不断 → 用完必须 remove()
2. 分代收集理论?三大回收算法?
分代假说:绝大多数对象朝生夕死(弱分代);熬过多次 GC 的对象越难死(强分代)。据此把堆分为新生代(Eden + 2 个 Survivor,8:1:1)和老年代。
- 标记-清除(Mark-Sweep):标记存活对象,清除未标记的。缺点:内存碎片化,分配大对象慢。适用老年代。
- 标记-复制(Mark-Copy):把存活对象复制到另一块区域,原区域整体清空。优点:无碎片、效率高;缺点:浪费空间(复制 8:1:1 设计只浪费 10%)。适用新生代。
- 标记-整理(Mark-Compact):标记后把存活对象向一端移动,再清理边界外。优点:无碎片;缺点:移动对象需更新引用(Stop The World 更长)。适用老年代。
🎯 面试要点
- 新生代回收(Minor GC)用复制算法:Eden 满触发;存活对象从 Eden+Survivor 复制到另一个 Survivor,年龄 +1,够 15 进老年代
- Survivor 放不下直接进老年代(分配担保)
- CMS 用标记清除 → 碎片 → 触发 Full GC 时退化为标记整理(Serial Old)
3. CMS 收集器的工作过程?优缺点?
CMS(Concurrent Mark Sweep):以"最短停顿"为目标的老年代收集器,四大步骤:
- 初始标记(STW):只标记 GC Roots 直接引用的对象,很快
- 并发标记:与用户线程并发,从初始标记对象追踪全部存活对象(时间长但不暂停)
- 重新标记(STW):修正并发期间用户线程产生的变动(增量更新)
- 并发清除:与用户线程并发清理死亡对象
缺点:
- CPU 敏感:并发阶段占用 CPU,吞吐量下降
- 浮动垃圾:并发清除时新产生的垃圾只能等下次,需预留空间 → CMS 默认老年代 92% 就触发(-XX:CMSInitiatingOccupancyFraction)
- 标记清除 → 碎片化 → 大对象分配失败 → 退化为 Serial Old 串行 Full GC(停顿巨长)
🎯 面试要点
- CMS 三色标记问题:并发标记时引用变化 → 用增量更新(CMS)或原始快照 SATB(G1)解决
- JDK9 标记弃用,JDK14 移除,被 G1 替代
- CMS 的两个 STW 都很短,适合低延迟应用(旧场景)
4. G1 收集器的设计?为什么 JDK9 起成为默认?
Region 化整为零:堆被划分为大小相等的 Region(默认 2048 个,1~32M),逻辑上仍分代(Region 标记为 Eden/Survivor/Old/Humongous 大对象区)。回收以 Region 为单位,不再全堆扫描。
核心机制:
- RSet(Remembered Set):记录"谁引用了本 Region 的对象",跨 Region 引用扫描只需查 RSet,避免全堆扫描
- 可预测停顿:维护各 Region 的回收价值和成本,优先回收回收价值最高的 Region(Garbage First 名字来源),可通过 -XX:MaxGCPauseMillis 设置停顿目标
- 并发标记:用 SATB(原始快照)解决并发引用变化;最后做筛选回收(可并发可 STW)
- Mixed GC:回收新生代 + 部分老年代 Region
🎯 面试要点
- G1 目标:在不牺牲太多吞吐的前提下,把停顿控制在可预测范围内(200ms 级别)
- 大对象(超过 Region 一半)放 Humongous Region
- G1 的弱点是:分配过猛/并发标记赶不上时,退化为 Full GC(JDK 10 起 Full GC 已并行化,不再是 JDK 8 那种 Serial 式全停顿)
- 对比 CMS:G1 无碎片、停顿可预测、能回收整个堆(新生代+老年代)
5. ZGC 为什么能做到毫秒级停顿?
- 染色指针(Colored Pointers):对象引用指针的 64 位中,用高 4 位存颜色标记(Finalizable/Remapped/Marked0/Marked1),标记信息存在指针上而不是对象头,避免访问对象
- 读屏障(Load Barrier):每次引用读取时检查颜色,不一致就"自愈"修正(把引用更新到新地址)。回收与用户线程并发,只有初始标记和最终标记需要短暂 STW(毫秒级,与堆大小无关)
- 指针自愈:被修正过的引用下次读无需再查 → 性能逐步收敛
- 支持 Region 动态大小(2MB~16MB 内小、中、大三类)
ZGC 的停顿不随堆大小增长(JDK15 起可线上使用,JDK17 增强),适合大堆低延迟场景;G1 的停顿会随堆增长而变长。
🎯 面试要点
- ZGC 只支持部分平台(Linux x86_64/aarch64、Windows 等)
- 染色指针的代价:寻址范围受限(JDK 15 上限 4TB;JDK 21 起支持 16TB 堆)
- Shenandoah 类似思路(不用染色指针,用转发指针)——可提一句显深度
6. GC 调优参数与调优思路?
常用 JVM 参数
-Xms2g -Xmx2g # 堆大小,生产建议 -Xms = -Xmx 避免动态伸缩
-Xmn512m # 新生代大小
-Xss256k # 线程栈大小(调小可支撑更多线程)
-XX:MetaspaceSize=256m # 元空间
-XX:+UseG1GC # JDK9+ 默认,无需显式
-XX:MaxGCPauseMillis=200 # G1 停顿目标
-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/ # OOM 自动 dump,必配!
# JDK 9+ 统一日志(旧参数 PrintGCDetails/-Xloggc 在 JDK 14 已彻底移除)
-Xlog:gc*,gc+heap=info:file=/data/logs/gc.log:time,uptime,level,tags
-XX:+DisableExplicitGC # 禁用 System.gc() 防止频繁 Full GC
调优流程:
- 明确目标:低延迟(缩短 STW)还是高吞吐(减少 GC 总耗时)?二者矛盾
- 观察现状:GC 日志看频率/时长,jstat 看各区占用
- 调整方向:Minor GC 频繁 → 增大新生代;Full GC 频繁 → 老年代不足/大对象过多/内存泄漏;对象过早晋升 → 检查大对象和 Survivor 容量
- 预防性策略:避免大对象、避免频繁创建对象(复用)、合理设置线程池和连接池、排除泄漏
🎯 面试要点
- 调优不是调参数,先排代码问题(泄漏/大对象),参数只是辅助
- Full GC 频繁先查:堆太小?大对象直接进老年代?System.gc 被调?类加载器泄漏(Metaspace)?
- GC 日志分析重点:GC (Allocation Failure)、Full GC、耗时异常长、GC 后空间没降下来