Skip to content

GC可达性、引用处理与回收算法完整原理

垃圾收集不是“内存满了以后删除没有变量名的对象”。HotSpot必须在多线程、JIT编译、对象互相引用和业务持续修改对象图的条件下完成:

  1. 找到可信的GC Roots。
  2. 判断哪些对象仍可从Roots到达。
  3. 在并发修改引用时避免漏标活对象。
  4. 按强、软、弱、虚引用规则处理对象。
  5. 回收或移动对象并修正所有受影响引用。
  6. 在暂停、吞吐、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堆主要不用

引用计数为每个对象维护引用数量:新增引用加一,断开引用减一,计数为零即可回收。

优点:

  • 对象失去最后引用后可以较快识别。
  • 回收工作可分散到引用更新路径。
  • 某些资源管理和语言运行时使用引用计数或混合方案。

主要代价:

  • 每次引用写入都要维护计数,多线程还要处理并发正确性。
  • 计数本身占空间。
  • 批量释放对象图可能造成释放抖动。
  • 单纯引用计数不能识别循环垃圾。
mermaid
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从一组可信根开始沿引用边遍历:

mermaid
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和执行需要全局一致性的操作。

mermaid
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扫描。
  • 对象复制/整理。
  • 引用处理。
  • 类卸载、字符串去重等附加工作。

六、三色标记模型

三色是理解并发标记的抽象,不表示对象头真的涂成三种颜色:

  • 白色:尚未被发现,周期结束仍白色则可能回收。
  • 灰色:对象已发现,但它引用的子对象尚未全部扫描。
  • 黑色:对象已发现,并且其出边已经扫描完成。
mermaid
flowchart TD
    A["Roots先变灰"] --> B["取一个灰对象"]
    B --> C["把其白色子对象变灰"]
    C --> D["当前对象变黑"]
    D --> E{"还有灰对象?"}
    E -- "是" --> B
    E -- "否" --> F["剩余白对象为回收候选"]

在完全STW下,对象图不变,过程直观;并发标记时业务线程称为Mutator,会同时新增、删除和替换引用。

七、并发标记怎样发生漏标

考虑:

text
黑对象A已经扫描完成
灰对象B尚未扫描完
白对象C当前由B引用

业务线程并发执行:

text
A新增引用C
B删除引用C

如果GC不知道这两次变化:

  • A已经黑,不会再扫描新增的C。
  • B扫描时已经看不到C。
  • C仍白,但实际上已被A引用。

回收C就会破坏程序。

漏标通常需要两个条件同时成立:

  1. 黑对象获得指向白对象的新引用。
  2. 灰对象删除了通向该白对象的旧引用,破坏原可达路径。

并发算法通过写屏障破坏其中一个条件。

八、写屏障不是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)尝试保留并发标记开始时对象图的逻辑快照。业务覆盖旧引用前,写屏障记录旧引用:

text
old = object.field
记录old
object.field = newValue

这样即使并发期间删除了原路径,起始快照中可达的对象仍被本轮保守保留。

mermaid
flowchart TD
    A["并发标记开始"] --> B["建立SATB逻辑快照"]
    B --> C["Mutator覆盖引用"]
    C --> D["写屏障记录旧引用"]
    D --> E["并发线程处理标记队列"]
    E --> F["Remark处理剩余队列"]

SATB可能产生浮动垃圾:快照开始时可达、之后变垃圾的对象本轮仍保留到后续周期。这是正确性和并发性的取舍。

十、四种引用必须结合可达性和队列理解

Java通过java.lang.ref提供软、弱、虚引用。Reference对象本身也是普通堆对象;referent是它关联的目标。

10.1 强引用

java
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:

text
WeakHashMap
→ 强引用Entry/value
→ value强引用key

key仍可能从Map Root通过value到达,无法按预期回收。弱容器不是自动防泄漏工具。

10.5 虚引用PhantomReference

JDK7/8构造PhantomReference必须关联ReferenceQueue:

java
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完整链路

mermaid
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:

java
try (InputStream input = new FileInputStream(path)) {
    // use input
}

十三、对象不可达后的处理不是一刀切

简化链路:

mermaid
flowchart TD
    A["强可达路径消失"] --> B{"是否软/弱/虚可达"}
    B --> C["按Reference规则处理"]
    C --> D{"是否具有尚未执行的finalization语义"}
    D -- "是" --> E["进入终结处理,可能发生一次复活"]
    D -- "否" --> F["成为可回收候选"]
    E --> G{"重新建立强可达?"}
    G -- "是" --> H["暂时存活"]
    G -- "否" --> F

实际收集器阶段和JDK版本更复杂,重点是:不可达、Reference清除、入队、finalization和物理空间复用不是同一个瞬间。

十四、标记—清除

步骤:

  1. 从Roots标记所有存活对象。
  2. 扫描管理区域,回收未标记对象形成的空闲块。
  3. 维护空闲列表/位图供后续分配。
mermaid
flowchart TD
    A["对象混合分布"] --> B["标记存活对象"]
    B --> C["清除未标记对象"]
    C --> D["形成多个不连续空闲块"]

优点:不必复制所有存活对象。缺点:

  • 标记和清除需要扫描。
  • 外部碎片导致总空闲足够但找不到连续大块。
  • 分配需要空闲列表等结构,不再只是连续指针碰撞。

CMS普通并发清除阶段体现标记—清除思想,因此需要面对碎片和并发失败。

十五、复制/Evacuation

把收集集合中的存活对象复制到目标区域,更新引用,然后整块回收源区域:

mermaid
flowchart TD
    A["Source区域"] --> B["识别存活对象"]
    B --> C["复制到Target区域"]
    C --> D["建立转发地址并修正引用"]
    D --> E["整块释放Source"]

成本与“存活对象数量/大小”更相关。年轻代大多数对象死亡时,复制少量存活对象非常高效。

代价:

  • 需要目标空间预留。
  • 存活率高时复制成本大。
  • 必须更新所有指向移动对象的引用。
  • Target不足会发生晋升、Evacuation Failure或退化。

教材“把整个内存固定分成相等两半”是最简单Semispace模型。HotSpot年轻代通常使用Eden+两个Survivor并允许晋升,G1则在Region间Evacuate,不能把所有复制算法都说成永久浪费一半堆。

十六、标记—整理

先标记存活对象,再把存活对象压向一侧或按整理策略移动,更新引用,最后得到连续空闲空间:

mermaid
flowchart TD
    A["标记存活对象"] --> B["计算新地址"]
    B --> C["移动/压紧存活对象"]
    C --> D["修正所有引用"]
    D --> E["得到连续空闲区域"]

优点:消除外部碎片,后续可用指针碰撞快速分配。代价:

  • 移动大量长寿命对象成本高。
  • 引用更新复杂。
  • 常需要STW或更复杂并发转移屏障。

Serial Old、Parallel Old和许多Full GC路径体现标记整理思想;具体实现可能滑动整理、区域整理或并发转移,不是一个统一步骤数。

十七、三种算法怎样选

特征标记清除复制/Evacuation标记整理
移动存活对象
外部碎片容易产生源区域整块释放整理后减少
额外目标空间较少需要通常不要求对等半区
适合不想移动/并发清除存活率低区域存活率高且需连续空间
主要风险碎片复制量和To-space不足移动与引用修正停顿

算法名称只描述核心空间处理,真实收集器还包含并行度、并发标记、屏障、代、Region和失败退化策略。

十八、为什么要分代

两个经验假设:

  • 弱分代假设:多数对象朝生夕死。
  • 强分代假设:熬过越多次GC的对象往往越可能继续存活。

因此把新对象集中在年轻区域,用复制算法回收少量存活对象;长期存活对象进入老年代,减少每次Young GC扫描范围。

mermaid
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标脏。

mermaid
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”。

二十二、类卸载与方法区回收

一个自定义加载器定义的类要具备卸载条件,通常需要:

  1. 所有实例不可达。
  2. java.lang.Class镜像不可达。
  3. 定义ClassLoader不可达。
  4. 没有线程、TCCL、JNI、静态缓存等继续引用加载器/类。
  5. JVM执行支持类卸载的GC周期。
mermaid
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仍可能在不同轮次回收。

java
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));
    }
}

运行:

bash
javac ReachabilityDemo.java
java -Xms16m -Xmx16m ReachabilityDemo

通常会看到:

text
referentCleared=true
enqueued=true

即使两个Node互相引用,只要外部Root路径消失,Tracing GC仍可回收它们。队列入队和清除的观察存在时序,因此Demo使用queue.remove(timeout)等待,仍不能把System.gc()当作生产代码的确定性协议。

二十四、JDK7/8 Demo:制造年轻代分配压力

java
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日志:

bash
javac YoungAllocationDemo.java
java -Xms32m -Xmx32m -Xmn16m \
  -XX:+UseSerialGC \
  -XX:+PrintGCDetails \
  -XX:+PrintGCDateStamps \
  YoungAllocationDemo

JDK9+才使用:

bash
java -Xms32m -Xmx32m -XX:+UseSerialGC -Xlog:gc* YoungAllocationDemo

不要把JDK9的-Xlog复制到JDK8。

二十五、商业场景:GC后Old基线持续上升

订单接口加入static本地缓存,key包含用户和日期却没有最大容量/TTL。对象分配率并不极端,但每次GC后Old占用基线继续上升,最终频繁Full GC和Heap OOM。

mermaid
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"]

排查:

  1. 对齐GC日志与业务流量,确认GC后Old基线趋势。
  2. 连续对象直方图看Map、Entry、业务DTO数量增长。
  3. Heap Dump用MAT查看Dominator Tree和Retained Heap。
  4. Path To GC Roots找到static cache -> entries -> DTO
  5. 修复为最大容量、TTL、按业务失效、命中率/淘汰监控。
  6. 压测验证基线稳定,不只调大Xmx。

分配率高与泄漏不同

text
分配率高 + GC后占用回落
→ 短命对象风暴/吞吐问题

分配率不高 + GC后Old基线持续上涨
→ 长寿命持有/泄漏优先

分配率和基线都高
→ 峰值容量与持有链都要查

二十六、商业场景:软引用缓存雪崩

图片处理服务用SoftReference缓存大图片,平时命中率高。流量高峰和内存压力下,大量软引用被集中清除,所有请求同时回源解码,CPU、磁盘和分配率进一步升高,形成恶性循环。

治理:

  • 使用显式权重/容量和TTL缓存。
  • SingleFlight或请求合并避免并发回源。
  • 限制单图大小和解码并发。
  • 监控命中、淘汰、加载失败和回源耗时。
  • 把SoftReference作为实现细节而非唯一容量策略。

二十七、生产GC排查Runbook

27.1 先确认现场

bash
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 逐步升级证据

  1. 先看历史指标和GC日志。
  2. 进程存活时低风险连续jstat、线程栈。
  3. 对象直方图比较增长趋势。
  4. 有副本、磁盘和暂停预算时再Heap Dump。
  5. MAT看Dominator Tree、Retained Heap、Path To GC Roots。
  6. 修代码/容量后用相同负载验证。

二十八、常见误区

误区正确理解
没有Java变量名的对象就是垃圾看从GC Roots是否可达
循环引用HotSpot无法回收Tracing GC可回收无Root路径的环
不可达后立刻物理释放还涉及GC时机、Reference和finalization处理
每次GC一定清除所有弱引用取决于对象可达级别、收集范围和引用处理
SoftReference能自动做可靠缓存清理不可预测且可能批量回源
PhantomReference能get回对象get始终为null,主要配合ReferenceQueue通知
finalize会被JVM及时执行不保证及时,阻塞会导致队列积压
Class对象在MetaspaceClass镜像在堆,类元数据在永久代/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只能做兜底而不是主要资源管理。

三十、关联知识点

三十一、学习验收

  • [ ] 能画出无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引用链解释线上现象,而不是把所有问题都归结为“内存不够”。