← 返回博客
2026-10-08 08:00:02

三色标记为什么会漏标,javap 又该怎么看

三色标记为什么会漏标,javap 又该怎么看

线上一次 Full GC 从 200ms 涨到 2s,堆不大、对象不多,日志里也没看到明显的内存泄漏。这种问题往下查,绕不开三色标记的漏标,也绕不开用 javap 把字节码摊开看。这篇就把这两块和四类 OOM 的区分讲透。

手把手实操:用 javap 拆开一段拼接代码

先写一个最小类,观察字符串拼接到底生成了什么。

public class ConcatDemo {
    public String concat(String a, String b) {
        return a + b;
    }
    public String loop(int n) {
        String s = "";
        for (int i = 0; i < n; i++) {
            s += i;
        }
        return s;
    }
}

编译后反编译:

javac ConcatDemo.java
javap -c -p -v ConcatDemo.class

-c 输出字节码指令,-p 带上私有成员,-v 额外打印常量池、方法描述符等。你要重点看 concat 方法里有没有出现 invokedynamic。

问题在于:JDK 9 起,a + b 这种拼接在编译期会被改写成 invokedynamic #x, 0 // InvokeDynamic #0:makeConcatWithConstants,而不是老版本的 new StringBuilder().append()。这是 JSR 292 引入的 StringConcatFactory 在起作用,运行时由它生成拼接逻辑。JDK 9 起这个策略成为默认,JDK 8 上你看到的还是 StringBuilder 那一套。

关键点在两点。一是 loop 方法里,s += i 在循环体内,每次迭代都会新建一个 StringBuilder(或走一次 makeConcat),复杂度是 O(n²),这是面试官最爱追问的"为什么循环里拼接要用 StringBuilder"。二是 -v 输出里会有一段 Constant pool,那是 Class 文件常量池,别和运行时常量池搞混。

看源码及解析:常量池、三色标记、漏标解法

Class 文件常量池 vs 运行时常量池。 Class 文件常量池是 .class 文件里的一张表,javap -v 能直接看到,存的是字面量和符号引用(类名、方法名、描述符)。JVM 加载类时,会把这张表的内容搬进方法区的运行时常量池,符号引用在解析阶段被替换成直接引用。运行时常量池是方法区的一部分,JDK 8 起方法区的实现是 Metaspace,所以运行时常量池在 Metaspace 里。字符串常量池是另一回事,它在堆里,第 1 篇已经讲过,这里不重复。

三色标记。 并发标记阶段,GC 线程和应用线程同时跑,对象被分成三种颜色。白色是还没被访问到的,灰色是自己被标记了但成员还没扫完,黑色是自己和成员都扫完了。标记结束时,白色对象就是垃圾。

漏标怎么发生的?两个条件同时成立才会漏。一是黑色对象新增了一条指向白色对象的引用,二是这个白色对象到灰色对象的引用链断了。黑色对象不会再被扫描,白色对象又失去了被扫到的路径,它就被当成垃圾回收了,但它其实还被黑色对象引用着。这是并发标记的经典问题。

两种解法。 增量更新(Incremental Update)破坏第一个条件:黑色对象新增引用时,把这条记录记下来,重新标记阶段以这些黑色对象为根再扫一遍。CMS 用的是这个。原始快照(Snapshot At The Beginning, SATB)破坏第二个条件:灰色对象删除指向白色的引用时,把旧引用记下来,重新标记时按这个快照扫。G1 用的是 SATB。

为什么 G1 选 SATB 而不是增量更新?G1 的回收单位是 Region,SATB 只需要在引用被删除时记录,配合写屏障实现,重新标记阶段要扫的对象相对可控;增量更新在引用新增时记录,重新标记时要重新扫的黑色对象可能很多。具体哪个更省,取决于你的对象图形态,需要自己压测验证。

写屏障是这一切的落点。赋值动作 obj.field = ref 会被 JVM 插入一段屏障代码,把旧值或新值记进卡表或 SATB 队列。卡表(card table)是记忆集的一种实现,把堆按固定大小分卡,卡脏了就标记,Minor GC 时只扫脏卡对应的老年代区域,避免全堆扫描。

验证方法:四类 OOM 怎么区分

跑下面几段,观察异常信息里的关键词,这是区分 OOM 类型的直接手段。

# 堆 OOM
java -Xmx20m -XX:+HeapDumpOnOutOfMemoryError HeapOom
# 输出:java.lang.OutOfMemoryError: Java heap space

# 元空间 OOM(JDK 8+)
java -XX:MaxMetaspaceSize=20m MetaspaceOom
# 输出:java.lang.OutOfMemoryError: Metaspace

# 栈溢出
java -Xss256k StackOom
# 输出:java.lang.StackOverflowError

# 直接内存
java -XX:MaxDirectMemorySize=10m DirectOom
# 输出:java.lang.OutOfMemoryError: Direct buffer memory

关键点在异常信息本身。Java heap space 是堆,Metaspace 是元空间,Direct buffer memory 是直接内存,栈溢出抛的是 StackOverflowError 而不是 OOM。堆 OOM 加 -XX:+HeapDumpOnOutOfMemoryError 能拿到 dump,用 MAT 看支配树找泄漏点。元空间 OOM 常见于动态生成类,比如 CGLIB 代理、反射大量生成类。直接内存 OOM 常见于 NIO 的 ByteBuffer.allocateDirect 没释放。

面试场景:面试官问"线上 OOM 了你怎么定位",你要能说出先看异常信息区分类型,再看是不是内存泄漏还是单纯不够用,泄漏就 dump 分析,不够用就调参数或改代码。答不出四类的区分,基本就卡在这了。

面试速答

核心收获:漏标是并发标记的必然产物,解法是破坏两个条件之一。下一步,拿你自己的项目跑一次 javap -v,把循环拼接那段字节码对着看一遍。

本文关键词:三色标记、漏标、javap、invokedynamic、StringConcatFactory、CMS、G1、SATB、增量更新、写屏障、OOM