这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 Java 既有解释执行的启动优势,也有 JIT 编译到本地代码后的峰值性能。分层编译、热点探测、去优化和代码缓存共同决定了 Java 服务的预热、抖动和压测结果。
7.1 执行方式
Bytecode
-> Interpreter
-> C1 编译代码
-> C2 编译代码
| 方式 | 特点 |
|---|---|
| Interpreter | 启动快,无需等待编译,峰值慢 |
| C1 | 编译快,优化相对保守 |
| C2 | 编译慢,优化激进,峰值高 |
| Graal | 可选编译器,架构不同 |
HotSpot 常见默认是分层编译:
java -XX:+PrintFlagsFinal -version | grep TieredCompilation
7.2 热点探测
方法触发编译的常见依据:
- 方法调用计数;
- 回边计数;
- 分层阈值。
相关参数:
-XX:CompileThreshold
-XX:-TieredCompilation
-XX:+PrintCompilation
查看:
jstat -printcompilation <pid> 1000
jcmd <pid> Compiler.codecache
这就是 Java 服务需要预热的原因:
启动初期
解释执行 + C1
-> 热点累积
-> C2 编译
-> 峰值性能稳定
7.3 JIT 优化
| 优化 | 说明 |
|---|---|
| 方法内联 | 把被调方法复制进调用点,减少调用开销 |
| 逃逸分析 | 判断对象是否逃逸 |
| 锁消除 | 去掉不可能竞争的锁 |
| 锁粗化 | 合并相邻锁操作 |
| 公共子表达式消除 | 避免重复计算 |
| 循环展开 | 减少循环控制开销 |
| 去虚拟化 | 根据类型 profile 内联虚方法 |
优化基于运行时 profile,因此同一段代码在不同流量和输入分布下性能可能不同。
7.4 去优化
JIT 根据 profile 做乐观优化。假设被打破时会去优化,回退到较低层执行。
常见触发:
- 新类型出现导致去虚拟化失败;
- 类加载改变继承关系;
- 异常路径进入;
- 优化假设失效;
- 监控点失效。
观察:
java -XX:+PrintCompilation -jar app.jar
输出中出现 made not entrant 或 deoptimized 常见于去优化。诊断参数不要在高峰生产随意开启。
7.5 Code Cache
查看:
jcmd <pid> Compiler.codecache
分段:
| 段 | 用途 |
|---|---|
| non-method | 生命周期长的 JVM stub |
| profiled | 带 profile 的 C1 代码 |
| non-profiled | C2 或优化后代码 |
满了可能:
- 停止 JIT;
- 退回解释执行;
- 延迟升高;
- CPU 上升。
参数:
-XX:ReservedCodeCacheSize=256m
7.6 JIT 与容器
容器中要关注:
- CPU limit 与编译线程数;
- 启动阶段 CPU 不足导致预热慢;
- readiness 过早放流量;
- 弹性扩容后新实例冷启动;
- CPU throttling 与长尾延迟。
JVM 会根据可用 CPU 调整编译线程和 GC 线程。容器感知能力随 JDK 版本改进,建议使用现代 LTS。
7.7 预热策略
发布后:
1. readiness 通过后再接流量
2. 少量灰度流量
3. 观察延迟和错误率
4. 逐步增加
5. 达到稳定后接全量
压测:
低流量
-> 中流量
-> 目标流量
-> 峰值流量
如果启动后立即全量压测,测到的往往是预热成本和资源竞争,不是稳定容量。
7.8 基准测试
JMH 示例:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class StringBenchmark {
@Benchmark
public String concat() {
return "a" + "b" + "c";
}
}
JMH 要点:
- 防止死代码消除;
- fork 隔离进程;
- 预热;
- 多轮测量;
- 报告置信区间;
- 记录 JDK 和参数。
7.9 火焰图
async-profiler:
./asprof -d 30 -f cpu.html <pid>
Arthas:
profiler start
profiler stop --format html
解读:
| 特征 | 可能含义 |
|---|---|
| 解释器栈占比高 | 预热或去优化 |
| JIT 编译线程高 | 启动或编译压力大 |
| 某业务方法很宽 | CPU 热点 |
| GC 栈宽 | GC 占用高 |
| lock / park 高 | 阻塞等待 |
7.10 生产案例
案例一:发布后 P99 高
现象:新实例启动后前几分钟延迟高。
排查:
jstat -printcompilation <pid> 1000
cat /sys/fs/cgroup/<path>/cpu.stat
结论:实例仍预热且触发 CPU throttling。处理:
- 更新 readiness;
- 灰度流量;
- 提高 CPU limit;
- 调整扩容阈值;
- 评估 AppCDS 或 AOT。
案例二:CodeCache 满
jcmd <pid> Compiler.codecache
处理:
- 增大 Code Cache;
- 减少动态类生成;
- 升级 JDK;
- 修复反射生成类问题;
- 观察去优化。
本章小结
解释器负责快速启动,JIT 负责峰值性能,分层编译结合两者。JIT 依赖 profile,因此服务需要预热,异常输入和类型分布可能导致去优化。生产性能评估必须区分冷启动、预热完成和稳定阶段。
思考题
- 为什么 Java 服务启动初期性能较低?
- C1 和 C2 有什么差异?
- 什么是去优化?
- Code Cache 满会造成什么影响?
- 如何设计发布后的预热策略?