Skip to content

JVM 深入 ​

Java 高级 · 底层原理

JVM 是 Java 工程师从"会用"走向"懂原理"的分水岭。真正吃透 JVM,不是背几个 -Xmx 参数,而是要回答三个问题:并发语义在硬件层面如何保证(JMM)?对象怎么从新生到消亡(GC)?字节码到底长什么样、框架如何在不改源码的情况下织入逻辑(Class 文件与字节码增强)? 本文把这三块讲透,让你在调优、排障和阅读框架源码时不再靠猜。

一、运行时数据区:对象与线程的家 ​

JVM 在进程启动后划分出若干内存区域,理解它们的归属(线程私有 / 共享)是理解一切并发与 GC 的基础。

区域线程归属存什么溢出形态
程序计数器私有当前线程执行字节码的行号不会 OOM
虚拟机栈私有栈帧(局部变量表、操作数栈、动态链接、返回地址)StackOverflowError / OOM
本地方法栈私有Native 方法同上
堆(Heap)共享对象实例、数组OutOfMemoryError: Java heap space
方法区 / 元空间共享类元信息、常量、静态变量、JIT 代码缓存Metaspace OOM
直接内存堆外NIO 的 DirectByteBuffer、Netty 池化内存Direct buffer memory OOM

WARNING

元空间不是"不会 OOM"。Spring/CGLIB 动态生成类、Groovy/JSP 动态编译,都会往元空间塞类元信息;一旦类加载器泄漏(Web 应用热部署未卸载旧类加载器),Metaspace 会持续上涨直至溢出。监控 Metaspace 使用率是生产必做项。

二、JMM:并发语义的硬件真相 ​

2.1 三大问题从哪来 ​

为什么需要 JMM(Java Memory Model)?因为编译器和 CPU 为了性能会做指令重排序,且每个线程有自己的工作内存(本质是 CPU 缓存),导致:

  • 可见性:线程 A 改了变量,线程 B 可能永远看不到(缓存未回写主存)。
  • 原子性:i++ 在字节码层面是 getstatic → iadd → putstatic 三条指令,多线程穿插会丢失更新。
  • 有序性:单线程看不出问题,但多线程下"先写 flag 再写数据"可能被重排成"先写数据再写 flag"。

JMM 的解决办法不是禁止重排,而是用 happens-before 规则定义"哪些操作之间必须保证可见与有序"。它是一组偏序关系,常见规则:

  1. 程序次序:单线程内前面的操作 happens-before 后面的。
  2. volatile 规则:对 volatile 变量的写 happens-before 后续对该变量的读。
  3. 锁规则:unlock happens-before 后续对同一锁的 lock。
  4. 线程启动/终止:thread.start() happens-before 线程内任何动作;线程内动作 happens-before thread.join() 返回。
  5. 传递性:A hb B 且 B hb C,则 A hb C。

2.2 volatile 的底层实现:内存屏障 ​

volatile 不只是一句"可见"的承诺。编译后,HotSpot 会在 volatile 写操作后插入一个 StoreLoad 屏障,在 x86 上它通常被实现为带 lock 前缀的指令(如 lock addl $0x0,(%rsp))。这个 lock 指令有两个作用:

  1. 把当前 CPU 写缓冲区和缓存行强制刷回主存(并触发 MESI 协议让其他核对应缓存行失效)——解决可见性。
  2. 该指令本身是全屏障(mfence 级),禁止其前后的读写重排序——解决有序性。
java
// volatile 写生成的典型屏障序列(逻辑示意,非真实字节码)
class VolatileDemo {
    int a = 0;
    volatile boolean flag = false;
    void writer() {
        a = 1;              // 普通写
        // [StoreStore] 屏障:保证 a 的写不会重排到 flag 写之后
        flag = true;        // volatile 写
        // [StoreLoad] 屏障:lock 前缀指令,强制刷盘 + 禁止后续读重排到此处之前
    }
    void reader() {
        // [LoadLoad] 屏障
        if (flag) {         // volatile 读
            // [LoadStore] 屏障
            System.out.println(a); // 一定能看到 a==1
        }
    }
}

TIP

volatile 只解决可见性和有序性,不解决原子性。i++ 该加锁还是得加锁(或 AtomicInteger)。另外,64 位 long/double 的非 volatile 读写在 32 位 JVM 上可能被拆成两次 32 位操作,理论上有撕裂风险——用 volatile long 可避免。

三、垃圾回收:对象的新生与消亡 ​

3.1 可达性分析,而非引用计数 ​

存活判定用"可达性分析":从 GC Roots(虚拟机栈局部变量、静态字段、常量、JNI 引用等)出发向下搜索,能触达的对象存活,其余判定为可回收。引用计数无法处理循环引用(A↔B 互相引用但无人外部引用),所以主流 JVM 不用它。

引用分四级,强度递减:

  • 强引用 Object o = new Object():宁 OOM 不回收。
  • 软引用 SoftReference:内存不足时回收,适合内存敏感缓存。
  • 弱引用 WeakReference:下次 GC 必回收,典型如 ThreadLocalMap 的 key。
  • 虚引用 PhantomReference:仅用于跟踪对象被回收的时刻,必须配合 ReferenceQueue。

3.2 G1:可预测停顿的 Region 化回收 ​

G1(JDK 9+ 默认)把堆切成约 2048 个大小相等的 Region(1MB~32MB),每个 Region 可扮演 Eden / Survivor / Old / Humongous(大对象)。它打破物理分代,用逻辑分代 + 停顿预测模型工作:

  • RSet(Remember Set,记忆集):每个 Region 维护一个卡表(Card Table)索引集合,记录"哪些别的 Region 引用了我"。这样回收某 Region 时不必全堆扫描,只看 RSet 即可。
  • 写屏障(Write Barrier):每次引用赋值(如 obj.field = other)会触发写屏障,更新卡表。这是 G1 正确性的关键,也是其"屏障开销"的来源。
  • SATB(Snapshot-At-The-Beginning):并发标记开始时对堆做逻辑快照,标记期间新建的对象默认视为存活,只处理"快照内变脏"的对象,降低并发标记复杂度。
  • Mixed GC(混合回收):不只回收新生代,还会按"回收收益"挑选若干老年代 Region 一起回收,靠 -XX:MaxGCPauseMillis 预测停顿时间、动态决定本次回收多少 Region。

3.3 ZGC:亚毫秒停顿的染色指针 ​

ZGC(JDK 15 生产可用,JDK 21 支持分代)的核心是把 GC 状态编码进对象指针本身——"染色指针(Colored Pointer)"。在 64 位地址中借出几位(Marked0/Marked1/Remapped/Finalizable)记录对象的三色标记状态,配合 Load Barrier(读屏障),在应用线程"读取对象指针"时顺便完成指针自愈(Remap)。

  • 多重映射(Multi-Mapping):同一物理内存被映射到多个虚拟地址,靠指针染色位区分视图,省去对象头额外字段。
  • 并发三阶段:并发标记(读屏障记录)、并发重定位(把存活对象搬到新 Region,旧地址通过转发表映射)、并发重映射(自愈旧引用)。
  • 停顿与堆大小无关:因为所有重活都在并发阶段做,STW 只发生在极短的 roots 扫描,官方宣称停顿 < 1ms,TB 级堆也成立。

3.4 GC 日志怎么读 ​

调优不靠猜,靠日志。JDK 11+ 统一用 -Xlog:gc*:file=gc.log:time,level,tags:

text
[2026-09-14T10:00:01.123+0800] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 2048M->512M(4096M) 18.3ms
[2026-09-14T10:00:05.456+0800] GC(15) Pause Remark 4096M->4096M(4096M) 42.1ms
[2026-09-14T10:00:10.789+0800] GC(18) Pause Full (Allocation Failure) 4096M->3990M(4096M) 1200ms

重点看:单次 Pause 的停顿时长是否逼近 MaxGCPauseMillis;是否频繁出现 Full GC(Allocation Failure / Metadata GC Threshold 是危险信号,往往意味着内存真不够或元空间泄漏);-> 之后堆仍居高不下说明内存泄漏而非 GC 不力。

四、字节码:框架织入逻辑的舞台 ​

4.1 Class 文件结构 ​

.class 文件不是随意的二进制,而是严格结构(JVM 规范 §4):

text
ClassFile {
  u4             magic;             // 0xCAFEBABE
  u2             minor_version;
  u2             major_version;     // JDK 版本
  u2             constant_pool_count;
  cp_info        constant_pool[];   // 常量池: 类/方法/字段/字符串的符号引用
  u2             access_flags;      // ACC_PUBLIC / ACC_VOLATILE / ACC_SYNCHRONIZED...
  u2             this_class;
  u2             super_class;
  u2             interfaces_count;
  u2             fields_count;
  field_info     fields[];
  u2             methods_count;
  method_info    methods[];         // 每个方法含 code 属性(字节码指令)
  u2             attributes_count;
  attribute_info attributes[];      // 如 LineNumberTable, BootstrapMethods
}

常量池是类文件的"字典",所有类名、方法名、字段名、字面量都以符号引用(UTF8 / Class / NameAndType / Methodref)存在其中,运行期通过它做动态链接解析成直接引用。

4.2 字节码增强:ASM 与 Javassist ​

框架(Spring AOP、SkyWalking、Lombok、Mockito)大都在类加载期或编译期改字节码。两种主流手段:

java
// Javassist:用字符串级 API,门槛低,适合插桩统计
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("com.demo.OrderService");
CtMethod m = cc.getDeclaredMethod("create");
m.addLocalVariable("__start", CtClass.longType);
m.insertBefore("__start = System.nanoTime();");
m.insertAfter("System.out.println(\"cost=\" + (System.nanoTime() - __start));");
// 必须在目标类被 AppClassLoader 加载【之前】调用 toClass() 或注册到 ClassFileTransformer
java
// ASM:基于访问者模式(Visitor)直接操作字节码,性能更好、控制更细
// 典型用法:实现 ClassVisitor,在 visitMethod 里插入 MethodVisitor 改写指令
// 生产框架(如 SkyWalking agent)多用 ASM 在 ClassFileTransformer 里织入埋点
public class TraceClassVisitor extends ClassVisitor {
    @Override
    public MethodVisitor visitMethod(int acc, String name, String desc, String sig, String[] ex) {
        MethodVisitor mv = super.visitMethod(acc, name, desc, sig, ex);
        return new AdviceAdapter(Opcodes.ASM9, mv, acc, name, desc) {
            @Override protected void onMethodEnter() {
                mv.visitMethodInsn(INVOKESTATIC, "com/trace/Agent", "enter", "()V", false);
            }
        };
    }
}

TIP

字节码增强的本质是"不修改 .java 源码,却在 .class 层面插入逻辑"。理解它,你就能看懂 APM 探针、Mock 框架、乃至 Spring 的 CGLIB 子类代理到底干了什么。排障时要注意:被增强的类若有问题,异常栈会出现 $$EnhancerByCGLIB$$、_$$_jvst 之类合成类,别被吓到。

五、生产工具箱与排障链路 ​

  • jstat -gcutil <pid> 1s:实时看各分区使用率与 GC 次数/时间,判断"过早晋升"(对象没熬过几次 Young GC 就进 Old)。
  • jmap -histo:live <pid>:按类统计实例数/占用,定位哪类对象暴涨(内存泄漏初筛);jmap -dump 抓堆转储用 MAT 分析。
  • jstack <pid>:抓线程栈,定位死锁与"大量线程 BLOCKED 在某把锁"。
  • Arthas:线上不停机 watch/trace/jad(反编译)/ 改 logger 级别,排障神器。
  • Async-profiler:火焰图定位 CPU/内存/锁热点,比猜强一万倍。

DANGER

容器化部署务必开启 -XX:+UseContainerSupport(JDK 8u191+ 默认开),让 JVM 读取 cgroup 的 CPU/内存上限,否则会按宿主机物理内存分配堆,极易被 OOM Kill。-Xmx == -Xms 在容器里并非永远最优:要给元空间、直接内存(Netty)、线程栈预留空间,别把内存吃满。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 写一个多线程可见性 bug(flag 不加 volatile 导致线程不退出),用 jstack 或打印观察,再给 flag 加 volatile 解释内存屏障如何修复。
  2. 用 jstat -gcutil 观察一个服务的 GC 频率,判断是否存在"过早晋升"或频繁 Full GC,并给出调优假设。
  3. 用 Javassist 或 ASM 给指定方法插桩,统计方法调用耗时并在 Arthas 中验证增强生效。
  4. 简述 G1 的 RSet 与 ZGC 的染色指针分别解决了什么问题,二者在"并发"思路上的根本差异是什么。

本节小结

抓住三条主线:JMM 靠 happens-before + 内存屏障(volatile 的 lock 前缀 / StoreLoad)保证并发语义;回收器按"吞吐 vs 停顿"权衡,G1 用 Region+RSet+SATB 做可预测停顿,ZGC 用染色指针+读屏障把停顿压到亚毫秒且与堆大小无关;字节码是框架织入能力的舞台,ASM/Javassist 让你不改源码也能插逻辑。调优的精髓是"开日志、看数据、建基线",而非背参数。