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 规则定义"哪些操作之间必须保证可见与有序"。它是一组偏序关系,常见规则:
- 程序次序:单线程内前面的操作 happens-before 后面的。
- volatile 规则:对 volatile 变量的写 happens-before 后续对该变量的读。
- 锁规则:
unlockhappens-before 后续对同一锁的lock。 - 线程启动/终止:
thread.start()happens-before 线程内任何动作;线程内动作 happens-beforethread.join()返回。 - 传递性:A hb B 且 B hb C,则 A hb C。
2.2 volatile 的底层实现:内存屏障
volatile 不只是一句"可见"的承诺。编译后,HotSpot 会在 volatile 写操作后插入一个 StoreLoad 屏障,在 x86 上它通常被实现为带 lock 前缀的指令(如 lock addl $0x0,(%rsp))。这个 lock 指令有两个作用:
- 把当前 CPU 写缓冲区和缓存行强制刷回主存(并触发 MESI 协议让其他核对应缓存行失效)——解决可见性。
- 该指令本身是全屏障(mfence 级),禁止其前后的读写重排序——解决有序性。
// 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:
[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):
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)大都在类加载期或编译期改字节码。两种主流手段:
// 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// 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)、线程栈预留空间,别把内存吃满。
📚 官方文档与参考资料
- OpenJDK 官网:https://openjdk.org/
- Oracle Java SE 文档:https://docs.oracle.com/en/java/
- JSR-133(JMM)规范说明:https://openjdk.org/projects/jmm/
- G1 垃圾回收器白皮书(Oracle):https://www.oracle.com/technical-resources/articles/java/g1gc.html
✏️ 练习与自测
- 写一个多线程可见性 bug(
flag不加volatile导致线程不退出),用jstack或打印观察,再给flag加volatile解释内存屏障如何修复。 - 用
jstat -gcutil观察一个服务的 GC 频率,判断是否存在"过早晋升"或频繁 Full GC,并给出调优假设。 - 用 Javassist 或 ASM 给指定方法插桩,统计方法调用耗时并在 Arthas 中验证增强生效。
- 简述 G1 的 RSet 与 ZGC 的染色指针分别解决了什么问题,二者在"并发"思路上的根本差异是什么。
本节小结
抓住三条主线:JMM 靠 happens-before + 内存屏障(volatile 的 lock 前缀 / StoreLoad)保证并发语义;回收器按"吞吐 vs 停顿"权衡,G1 用 Region+RSet+SATB 做可预测停顿,ZGC 用染色指针+读屏障把停顿压到亚毫秒且与堆大小无关;字节码是框架织入能力的舞台,ASM/Javassist 让你不改源码也能插逻辑。调优的精髓是"开日志、看数据、建基线",而非背参数。