大家做开发其实大部分对底层使用不多,代码只要成功返回就行,对 JVM 的理解停留在"写代码 -> 编译 -> 运行",但这远远是不够多,在高并发、低延迟多赛场上,不懂 JVM 底层就像开着法拉利却不知道如何换挡。
第一部分:对象布局 (Object Layout) 与 压缩指针
创建一个对象不仅仅是分配内存,它有着严格的二进制结构。理解这个结构对于优化内存占用至关重要。
1. 对象头 (Object Header)
| 部分 | 大小 (64-bit + Compressed Oops) | 内容 |
|---|---|---|
| Mark Word | 8 字节 | 哈希码、GC 分代年龄、锁状态标志 (偏向锁/轻量级锁/重量级锁)、线程持有锁记录 |
| Klass Pointer | 4 字节 | 类型指针,指向方法区中类的元数据 (Class 对象) |
| Array Length | 4 字节 | (仅数组对象有) 数组长度 |
| 对齐填充 | 0~7 字节 | 保证对象总大小是 8 字节的倍数 |
2. 压缩指针 (Compressed Oops)
- 问题:64 位指针占 8 字节,如果每个对象都存 8 字节指针,内存占用会增加 50%。
- 解决 :HotSpot 默认开启
-XX:+UseCompressedOops。- 将 64 位指针压缩成 32 位 (4 字节)。
- 原理:对象地址 = 基地址 + (压缩指针 * 缩放因子,通常是 8)。
- 限制:堆内存不能超过 ~32GB。超过后指针无法压缩,对象头变大,内存占用激增。
垃圾收集
在尽可能短的时间内,回收尽可能多的内存,同时让应用线程感知不到它的存在。这就有一个问题:吞吐量 (Throughput) 、延迟 (Latency) 、内存占用 (Memory Footprint) 很难兼得。JVM 的发展史,就是一部不断打破这个不可能三角的历史
核心算法基石:标记-整理 vs. 分代理论
在说任何话之前,必须理解两个支撑所有现代 GC 的理论:
1. 分代假说 (Generational Hypothesis)
- 弱分代假说:绝大多数对象都是"朝生夕死"的。
- 强分代假说:对象存活时间越长,未来存活的概率越大。
- 架构 :
- Young Gen (新生代) :存放新对象。采用 复制算法 (Copying),速度极快,但浪费一半空间。
- Old Gen (老年代) :存放长寿命对象。采用 标记-整理 (Mark-Compact) 或 标记-清除 (Mark-Sweep)。
2. 三色标记法 (Tri-color Marking)
这是解决"标记过程中对象引用变化"的核心算法(用于 CMS, G1, ZGC)。
- 白色:未访问,可能是垃圾。
- 灰色:已访问,但子节点未扫描。
- 黑色:已访问,且子节点已扫描(安全)。
- 问题:如果在标记过程中,黑色对象引用了白色对象,而灰色对象断开了对白色对象的引用,白色对象会被误删。
- 解决 :
- CMS : 使用 增量更新 (Incremental Update)。
- G1/ZGC : 使用 SATB (Snapshot At The Beginning) 或 Read Barrier。
主流收集器深度解析
1. CMS (Concurrent Mark Sweep) ------ 时代的过渡者
- 定位:低延迟,老年代收集器。
- 算法:标记 - 清除 (Mark-Sweep)。
- 特点 :
- 并发标记/清理:大部分工作与用户线程并行。
- 缺点 :
- 碎片化:标记 - 清除不整理内存,导致大量碎片,触发 Full GC 时退化为单线程 Serial Old,造成长时间 STW。
- 浮动垃圾:并发清理期间产生的新垃圾无法当次回收。
- CPU 敏感:并发阶段抢占 CPU 资源,可能导致应用变慢。
- 现状 :JDK 9 废弃,JDK 14 移除。不建议在新项目中使用。
2. G1 (Garbage First) ------ 当前的默认王者 (JDK 8u20+ / JDK 11+)
- 定位:兼顾吞吐量和延迟,大堆内存首选。
- 革命性改变 :抛弃物理分代,采用逻辑分代 + Region 分区 。
- 堆内存被划分为多个大小相等的 Region (通常 1MB - 32MB)。
- 每个 Region 动态扮演 Eden, Survivor, Old, Humongous (大对象) 的角色。
- 核心算法 :
- 可预测停顿模型 :用户设定
MaxGCPauseMillis(如 200ms),G1 根据历史数据,优先回收垃圾最多的 Region (Garbage First),确保在时间内完成。 - SATB (Snapshot At The Beginning):解决并发标记时的对象消失问题。记录标记开始时的对象引用快照。
- Remembered Set (RSet):每个 Region 维护一个 RSet,记录"谁引用了我"。避免全堆扫描,实现跨 Region 引用的高效追踪。
- 可预测停顿模型 :用户设定
- 流程 :
- Initial Mark (STW, 极短): 标记 GC Roots 直接关联的对象。
- Concurrent Mark (并发): 遍历对象图,使用 SATB 记录变化。
- Final Mark (STW, 较短): 处理 SATB 记录的剩余变动。
- Live Counting & Evacuation (STW, 可控): 筛选 Region,将存活对象复制到其他 Region (天然无碎片)。
- 适用场景:堆内存 > 4GB,对延迟有要求但不需要极致微秒级。
3. ZGC (Z Garbage Collector) ------ 颠覆者 (JDK 11 实验 / JDK 15+ 生产)
- 定位 :超低延迟 (< 1ms STW),支持 TB 级堆内存。
- 核心黑科技 :染色指针 (Colored Pointers) + 读屏障 (Read Barriers)。
- 原理深度拆解 :
- 染色指针 :利用 64 位指针中的高位 bits (约 4-5 位) 直接存储对象状态(如:是否被移动过、属于哪个代)。
- 以前:状态存在对象头里 -> 需要访问对象内存才能知道状态。
- 现在 :状态存在指针里 -> 无需访问对象内存,仅看指针就知道要不要重映射。
- 读屏障 :
- 当应用线程读取对象引用时,插入一段汇编代码(读屏障)。
- 检查指针的"颜色位"。如果对象已被 GC 移动,读屏障会立即修正指针地址,指向新位置。
- 效果:GC 移动对象时,不需要暂停所有线程 (STW),应用线程在访问时自动"自我修正"。
- 染色指针 :利用 64 位指针中的高位 bits (约 4-5 位) 直接存储对象状态(如:是否被移动过、属于哪个代)。
- 优势 :
- STW 时间恒定,与堆大小无关 (无论 1GB 还是 1TB,STW 都在亚毫秒级)。
- 无碎片 (基于 Region 复制)。
- 代价 :
- CPU 开销略高 (每次读对象都要执行屏障指令,吞吐量比 G1 低 10%-15%)。
- 需要较新的 CPU 架构支持。
- 适用场景:对延迟极度敏感 (高频交易、实时推荐),堆内存巨大 (> 32GB)。
4. Shenandoah ------ 红帽的开源方案
- 定位:与 ZGC 类似,低延迟。
- 区别 :
- 不使用染色指针 (兼容 32 位系统)。
- 使用 Brooks Pointers (在每个对象头里多放一个转发指针)。
- 写屏障 (Write Barrier) 为主,ZGC 是读屏障。
- 现状:JDK 12+ 内置。性能与 ZGC 互有胜负,取决于负载类型。
如何选择合适的 GC:建议 不具有法律效益
G1 :传统电商后台 (堆 4GB - 8GB)
理由:G1 在大堆下表现稳定,能平衡吞吐和延迟
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿 200ms
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用 45% 时启动并发标记
-XX:ConcGCThreads=2 # 并发线程数
ZGC (JDK 17+) 高频交易系统 / 实时广告竞价 (堆 16GB+, 延迟敏感)
哪怕只有 10ms 的 STW 都可能导致交易失败。ZGC 的亚毫秒级停顿是必须的。
-XX:+UseZGC
-Xms16g -Xmx16g # 固定堆大小,避免动态扩容
-XX:+ZGenerational # JDK 21+ 开启分代 ZGC (大幅提升吞吐量)
- 注 :JDK 21 引入了 Generational ZGC,解决了早期 ZGC 吞吐量低的问题,全能型选手。
**Parallel GC:**批处理 / 数据分析 (堆 32GB+, 追求吞吐量)
Parallel GC (JDK 8 默认) 或 G1 (调优吞吐量) ;离线任务不在乎单次停顿 1 秒,只在乎整体跑完要多久。Parallel GC 的多线程并行回收吞吐量最高。
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=0 # 不关心停顿,只关心总时间
-XX:GCTimeRatio=99 # 99% 时间用于业务,1% 用于 GC
调试与监控:看透 GC 的黑盒
统一日志格式 (Unified Logging)
JDK 9+ 引入了统一的日志标签系统,极其强大
- -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags
gc*: 包含所有 GC 相关标签safepoint: 记录安全点信息(为什么 STW?谁阻塞了?)- 分析技巧 :搜索
Pause Young (Normal) (G1 Evacuation Pause)查看年轻代回收时间;搜索To-space exhausted查看是否晋升失败
2. JFR (Java Flight Recorder)
咱各种工具:不要只用文本日志,用 JFR 录制运行过程。
- -jfr:filename=recording.jfr,start=manual,stop=manual
在 JDK Mission Control (JMC) 中打开:
- GC Phase 视图:直观看到哪个阶段耗时最长。
- TLAB Allocation:查看对象分配速率,判断是否分配过快导致频繁 Young GC。
- Old Object Sample:直接定位哪些对象长期存活占用了老年代。
3. 咱项目上常见 GC 问题诊断
- 频繁 Young GC :
- 原因:Eden 区太小,或对象分配速率过高。
- 对策:增大
-Xmn(G1 中自动调整,可尝试增大 Region 数),检查代码是否有大量临时对象创建。
- 频繁 Full GC / Metaspace OOM :
- 原因:动态类加载过多 (如 Groovy, CGLib),元空间不足。
- 对策:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m。
- G1 Mixed GC 耗时过长 :
- 原因:一次回收的 Region 太多,或者单个 Region 内存活对象太多(复制成本高)。
- 对策:调整
-XX:G1MixedGCLiveThresholdPercent或-XX:G1HeapWastePercent。
总结
- JDK 8 用户:如果是新系统,尽量升级到 JDK 17/21。如果必须留在 JDK 8,大堆请用 G1 (需手动开启),小堆可用 ParallelGC。慎用 CMS。
- JDK 17/21 用户 :无脑选 ZGC (特别是 JDK 21 的分代 ZGC)。它已经解决了吞吐量的短板,是目前的最优解。
- 核心心法 :
- GC 调优的本质是减少对象分配,而不是调整 GC 参数。
- 最好的 GC 是没有 GC 。通过对象池、复用、逃逸分析减少
new,比任何调参都有效。 - 监控先行:没有数据支撑的调优就是瞎猜。
编写 GC 友好的代码 (GC-Friendly Coding)
核心哲学 :最好的 GC 调优,是减少对象分配。
GC 的压力 = 对象分配速率 × 对象存活率。如果你能控制这两者,GC 参数只是锦上添花。
1. 拒绝"循环内新建" (The Loop Killer)
这是最常见的性能杀手。在高频循环中 new 对象,会瞬间填满 Eden 区,触发频繁的 Young GC。
//下面不对,咱们只说不对的
public long sumList(List<Integer> list) {
long sum = 0;
for (Integer i : list) {
// 陷阱:每次循环都创建一个新的 StringBuilder
// 如果 list 有 100 万个元素,就创建 100 万个对象!
String s = new StringBuilder().append("Value: ").append(i).toString();
sum += i;
// s 变成垃圾,等待回收
}
return sum;
}
如果连 StringBuilder 都不需要(只是计算),直接去掉字符串操作。如果必须格式化,考虑使用线程局部变量 (ThreadLocal<StringBuilder>) 在多线程环境下复用。
2.警惕自动装箱 (Auto-Boxing Trap)
Java 的集合框架 (List<Integer>, Map<String, Object>) 只能存对象。当你用 int 赋值给 Integer 时,JVM 会调用 Integer.valueOf(),产生一个新对象(除非在缓存范围内 -128~127)。
// 创建了 10,000 个 Integer 对象
long sum = 0;
for (int i = 0; i < 10000; i++) {
sum += i; // 这里没问题
// 但如果放入集合:
// list.add(i); -> 每次 add 都 new 一个 Integer (超出缓存范围)
}
//正确的姿势
//内存占用减少 5-10 倍,GC 压力几乎为零
//第三方库如 FastUtil, HPPC, 或 JDK 21+ 的 Primitive Collections (未来特性)
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.ints.IntList;
// 底层是 int[] 数组,完全无装箱开销
IntList list = new IntArrayList();
for (int i = 0; i < 10000; i++) {
list.add(i); // 直接存 int,无对象创建
}
3. 字符串拼接的真相
-
JDK 8 :
+拼接在编译期转换为StringBuilder。 -
JDK 9+ : 引入了
StringConcatFactory和invokedynamic,性能更好,但在循环中依然危险String result = "";
for (String s : largeList) {
result += s; // 每次循环都创建新的 String 对象 (String 是不可变的)
// 第一次:new String(0+1)
// 第二次:new String(1+2) ... 复制所有字符
// 复杂度 O(N^2)
}
4. 对象池 (Object Pooling) ------ 双刃剑
对于重量级对象(如数据库连接、Netty 的 ByteBuf、复杂的解析器),可以使用对象池。
- 工具:Apache Commons Pool, Netty Recycler。
- 警告 :
- 对于轻量级对象(如简单的 POJO),不要 pooling!现代 JVM 的分配速度极快(指针碰撞),而池化带来的代码复杂度和维护成本往往超过收益。
- 只有当对象构造成本极高(如涉及系统调用、大数组分配)时,才考虑池化。
5. 弱引用与软引用 (Weak/Soft Reference)
用于实现本地缓存,避免 OOM。
-
WeakReference: GC 发生时立即回收。
-
SoftReference: 内存不足时才回收。
-
场景:图片缓存、大报表缓存。
Map<String, SoftReference<BigImage>> cache = new ConcurrentHashMap<>();
public BigImage getImage(String key) {
SoftReference<BigImage> ref = cache.get(key);
if (ref != null) {
BigImage img = ref.get();
if (img != null) return img;
}
// 重新加载并放入缓存
BigImage img = loadFromDisk(key);
cache.put(key, new SoftReference<>(img));
return img;
}
字节码执行引擎 (Execution Engine)
这是 JVM 最"智能"的大脑。Java 之所以能"一次编译,到处运行"且性能接近 C++,全靠这套机制。
| 特性 | 解释器 (Interpreter) | JIT 编译器 (Just-In-Time) |
|---|---|---|
| 工作方式 | 读一行字节码,执行一行机器指令 | 将热点字节码整体编译成本地机器码 |
| 启动速度 | 极快 (无需编译) | 慢 (需要编译时间) |
| 执行速度 | 慢 (每次都要翻译) | 极快 (直接执行机器码,可做深度优化) |
| 适用场景 | 冷代码、只执行一次的代码 | 热点代码 (HotSpot) |
策略:JVM 启动时先用解释器快速运行。当某段代码被频繁执行(达到阈值),JIT 编译器介入,将其编译为本地代码。后续执行直接调用本地代码。
2. 分层编译 (Tiered Compilation)
现代 HotSpot (JDK 7+) 不再是非黑即白,而是有 4 个层级,动态切换:
- Level 0: 解释执行。
- Level 1: C1 编译器 (Client Compiler),做简单优化 (如方法内联),编译速度快。
- Level 2: C1 编译器,做更多优化 (如值编号)。
- Level 3: C2 编译器 (Server Compiler),做激进优化 (如逃逸分析、锁消除、循环展开),编译慢但生成的代码质量极高。
流程:
- 方法开始执行 -> Level 0 (解释)。
- 调用次数增加 (
invokecounter) -> 编译到 Level 1/2 (C1)。 - 成为超级热点 (
backedge counter+invokecounter) -> 提交给 C2 编译到 Level 3。 - 如果 C2 编译太慢,先继续用 C1 代码跑,编译好后替换(On-Stack Replacement, OSR)
查看分层编译状态 -XX:+PrintCompilation
3. 核心优化技术 (JIT 的黑魔法)
A. 方法内联 (Method Inlining) ------ 最重要的优化
-
原理:将小方法的代码直接复制到调用处,消除方法调用的栈帧开销。
-
连锁反应:内联后,编译器可以看到更广阔的代码视野,从而进行更深度的优化(如常量折叠、死代码消除)。
-
限制 :默认只对非常小的方法内联。可以通过
-XX:InlineSmallCode调整。 -
陷阱 :
private,static,final方法容易内联;virtual方法(多态)难内联,除非 JIT 能确定具体类型(类层次分析 CHA)。interface Shape { double area(); }
class Circle implements Shape { ... }
class Square implements Shape { ... }void test(Shape s) { s.area(); } // 虚方法调用
-
优化 :如果 JIT 通过 CHA (Class Hierarchy Analysis) 发现当前程序中
Shape只有一个实现类Circle,它会将虚调用直接转换为直接调用,甚至内联Circle.area()。 -
守护假设 :JIT 会生成一段"守护代码"。如果后来加载了新的类
Triangle实现了Shape,之前的优化假设失效,JVM 会触发 Deoptimization,回退到解释执行或重新编译。
C. 逃逸分析与标量替换 (回顾)
- 如前所述,如果对象不逃逸,JIT 直接把它拆解成寄存器或栈上的标量,堆分配指令直接消失。
4. 现场演示:观察 JIT 优化
public class JitDemo {
public static void main(String[] args) {
int sum = 0;
// 预热
for (int i = 0; i < 10000; i++) {
sum += calculate(i);
}
// 正式测试
long start = System.nanoTime();
for (int i = 0; i < 100000000L; i++) {
sum += calculate(i);
}
long end = System.nanoTime();
System.out.println("Result: " + sum + ", Time: " + (end-start)/1e6 + " ms");
}
// 这是一个容易被内联的小方法
public static int calculate(int x) {
return x * 2 + 1;
}
}
开启打印编译信息,关闭偏向锁干扰
java -XX:+PrintCompilation -XX:-UseBiasedLocking JitDemo
你会看到 calculate 方法很快被编译(例如 1% 4 make not entrant 然后 4 b 4 JitDemo::calculate)。如果你把 calculate 改得非常复杂,或者让它调用一个未知的虚方法,你会发现它长时间停留在解释执行阶段,或者编译层级较低。
5. 反优化 (Deoptimization)
JIT 是基于"猜测"优化的。如果猜测错了(比如原本以为只有一个子类,结果动态加载了新类),JVM 必须:
- 丢弃编译好的本地代码。
- 将执行栈帧转换回解释器状态(重建栈帧信息)。
- 重新解释执行。
- 代价:非常高。所以尽量避免在热路径上触发类动态加载或多态剧烈变化。
终极总结:全栈性能心法
结合我们之前聊的 无锁编程 、JVM 内存模型 、GC 收集器 和 执行引擎,这里是高性能 Java 的终极心法:
-
代码层面 (Source Code):
- 减少对象分配(复用、基本类型集合、避免循环 new)。
- 保持代码简单,方便 JIT 内联和分析(避免过度复杂的反射、动态代理)。
- 使用
volatile要谨慎,理解其内存屏障代价。
-
JVM 层面 (Runtime):
- 选对 GC:低延迟选 ZGC (JDK 21+),通用选 G1。
- 给 JIT 时间:生产环境启动后要有"预热"过程,让分层编译完成,达到 Level 3 优化后再压测。
- 固定堆大小 :
-Xms == -Xmx,避免堆动态伸缩带来的 GC 开销。
-
监控层面 (Observability):
- 不要猜!使用 JFR 录制,用 Async-Profiler 画火焰图。
- 关注 Allocation Rate (分配速率) 和 GC Pause Time。