07.5 - JITWatch 使用手册
定位: JIT 编译日志的可视化分析工具——将晦涩的
-XX:+PrintCompilation输出转换为可交互的编译决策看板 面试高频度: ⭐⭐⭐ 考查方式: 如何生成 JIT 日志(了解)、JITWatch 主要视图功能(应用中)、如何分析内联失败原因(进阶)
一、这是什么?为什么需要它?
JIT 编译器编译了哪些方法?为什么某些方法没有被内联?逃逸分析失败了还是成功了?循环展开做了吗?
这些问题的答案藏在 JIT 编译日志中。但原始日志是纯文本格式,信息密集难以阅读:
bash
# -XX:+PrintCompilation 输出示例 (纯文本, 一行一个方法):
52 1 3 java.lang.String::hashCode (55 bytes)
52 2 3 java.lang.String::equals (50 bytes)
55 4 1 java.lang.Object::<init> (1 bytes)
57 5 3 java.lang.String::indexOf (70 bytes)
73 12 4 java.lang.String::hashCode (55 bytes)一屏几百行——很难看出哪些是自己的方法、哪些优化没生效、内联链是怎样的。
JITWatch(Chris Newland 开发的开源工具)解决了这个问题:它解析 JIT 编译日志,提供可视化界面,让你像 IDE 调试代码一样"看"JIT 编译决策。
原始日志 (Text) ─→ JITWatch 解析 ─→ 可视化界面
├─ TriView: 源码 ↔ 字节码 ↔ 汇编
├─ 内联报告: 哪些方法内联了/没内联
├─ 编译链: 方法编译时间线
└─ 热点排名: 哪些方法编译耗时最长二、原理拆解
2.1 JIT 日志生成
要让 JITWatch 工作,首先需要生成完整的 JIT 编译日志:
bash
# 标准 JIT 日志参数集
-XX:+UnlockDiagnosticVMOptions # 解锁诊断选项 (部分日志功能需要)
-XX:+TraceClassLoading # 记录类加载事件 (JITWatch 需要)
-XX:+LogCompilation # 启用编译日志记录
-XX:LogFile=jit.log # 日志输出路径 (默认 hotspot.log)
# 可选增强
-XX:+PrintAssembly # 包含反汇编输出 (需要 hsdis 库)
-XX:+PrintInlining # 详细的内联决策信息bash
# 生产环境请注意: 这些参数有性能开销!
# - 日志 I/O: 频繁写入日志文件 (特别是 PrintAssembly)
# - 内存开销: 日志缓冲区
# 建议: 仅测试/预发环境收集, 使用短暂的采样窗口
# 带时间限制的 JIT 日志收集 (可用 -XX:+UnlockDiagnosticVMOptions)
java -XX:+UnlockDiagnosticVMOptions \
-XX:+LogCompilation \
-XX:LogFile=jit.log \
-XX:+PrintInlining \
-jar myapp.jar
# 应用运行结束后, 在当前目录得到 jit.log
# jit.log 是 XML 格式, 直接打开可读性较差 → 需用 JITWatch日志内容示例(简化的 XML 结构):
xml
<hotspot_log version='...'>
<thread ...>
<!-- 方法被编译 -->
<task compile_id='1' method='java/lang/String hashCode ()I'
bytes='55' level='3' stamp='0.052'>
<phase name='parse'/>
<phase name='optimize'/>
<phase name='matcher'/>
<!-- 内联决策 -->
<inline_fail reason='too big'/>
</task>
</thread>
</hotspot_log>2.2 JITWatch 的安装与启动
bash
# 方式 1: 从 GitHub 下载发行版
# 访问 https://github.com/AdoptOpenJDK/jitwatch/releases
# 下载 jitwatch-<version>.jar
# 方式 2: 从源码构建
git clone https://github.com/AdoptOpenJDK/jitwatch.git
cd jitwatch
mvn clean install -DskipTests
# 构建后在 ui/app/target/ 下得到可执行 jar
# 启动 JITWatch
java -jar jitwatch-<version>.jar
# 或
java -cp jitwatch-<version>.jar com.chrisnewland.jitwatch.launch.JITWatchUI2.3 JITWatch 核心视图
TriView — 三栏对照视图
这是 JITWatch 最有价值的功能——同一方法同时展示源码、字节码、机器码:
┌─────────────────────────────────────────────────────────────────┐
│ TriView (三栏对照视图) │
├──────────────────┬──────────────────┬──────────────────────────┤
│ 源代码 (Java) │ 字节码 │ 汇编 (机器码) │
│ │ │ │
│ int sum(int n) { │ 0: iconst_0 │ mov $0x0,%eax │
│ int r = 0; │ 1: istore_2 │ │
│ for(int i=0; │ 2: iconst_0 │ │
│ i<n; i++){ │ 3: istore_3 │ xor %r10d,%r10d │
│ r += i; │ 4: iload_3 │ │
│ } │ 5: iload_1 │ │
│ return r; │ 6: if_icmpge 15│ cmp %rsi,%rdi │
│ } │ 9: iload_2 │ jge 0x... │
│ │ 10: iload_3 │ │
│ │ 11: iadd │ add %eax,%r10d │
│ │ 12: istore_2 │ │
│ │ 13: iinc 3, 1 │ inc %r10d │
│ │ 16: goto 4 │ jmp 0x... │
│ │ 19: iload_2 │ │
│ │ 20: ireturn │ ret │
├──────────────────┴──────────────────┴──────────────────────────┤
│ 每行对应关系清晰可见: │
│ Java 源码 for(i=0; i<n; i++) → 字节码 4-16 → 机器码循环主体 │
│ 点击任意栏 → 其他两栏自动高亮对应行 │
└─────────────────────────────────────────────────────────────────┘使用场景:
- 确认热点方法的编译质量:机器码是否符合预期?
- 检查边界检查是否被消除:
if (i < arr.length)是否已消除? - 观察循环展开效果:循环体是否被复制展开?
内联报告 (Inline Report)
展示每个方法的内联树——哪些方法被内联了,哪些没被内联以及原因:
┌─────────────────────────────────────────────────────────────────┐
│ 内联报告 — com.example.MyService::processOrder (125 字节) │
│ │
│ com.example.MyService::processOrder (125 bytes) │
│ ├── inline (hot) → com.example.Order::getPrice (15 bytes) │
│ │ └── inline (hot) → java.math.BigDecimal::multiply ... │
│ │ └── inline (intrinsic) → java.math.BigInteger::... │
│ ├── inline → com.example.Discount::calculate (45 bytes) │
│ │ └── not inline → ...::applyCoupon (180 bytes) │
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^ │
│ │ callee 太大 → 超出 FreqInlineSize │
│ ├── not inline → com.example.Audit::log (55 bytes) │
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ │
│ │ callee 不是热点 (cold) │
│ └── not inline → com.example.dao::save (80 bytes) │
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ │
│ 虚调用 — 接口有多种实现 │
│ │
│ ㅤ │
│ 红色/带❌标记 = 未内联 (鼠标悬停显示具体原因) │
│ 绿色/带✓标记 = 已内联 │
│ 内联预算剩余: 25% │
└─────────────────────────────────────────────────────────────────┘使用场景:
- 排查为什么热点方法没达到 C2 编译——是不是内联预算被耗尽?
- 发现未被内联的虚方法调用——考虑是否可以将类/方法声明为 final
- 发现过大的方法阻碍内联——考虑是否要拆分为更小的子方法
编译链 (Compilation Chain)
展示一个方法从 L0 到 L4 的完整编译演化历史:
┌─────────────────────────────────────────────────────────────────┐
│ Compilation Chain — com.example.MyService::handleRequest │
│ │
│ Timeline: │
│ ─────────────────────────────────────────────────────────── │
│ T+0.000s L0: 解释执行 │
│ T+0.500s L3: C1 完整 profiling 编译 (因为方法调用达 10000 次) │
│ T+0.510s L3 编译完成, 替换解释版本 │
│ T+1.200s L4: C2 开始编译 (使用 L3 收集的 profile) │
│ T+2.350s L4 编译完成 → 替换 L3 版本 │
│ T+3.000s L4 → L0: 去优化! (类加载触发: 新子类被加载) │
│ T+3.001s L0: 回退到解释执行 │
│ T+3.500s L3: 重新 C1 编译 (新 profile) │
│ T+4.200s L4: 重新 C2 编译 (使用包含新类型的 profile) │
│ T+5.500s L4 编译完成, 稳定执行至今 │
│ │
│ 总编译耗时: 5.5s │
│ 编译次数: 4 次 (L3→L4→L3→L4) │
│ 去优化次数: 1 次 │
└─────────────────────────────────────────────────────────────────┘使用场景:
- 查看方法是否经历了异常的去优化
- 确认方法的最终编译层级(目标是 L4)
- 分析去优化原因(类加载、类型 profile 变化等)
OSR 编译视图
专门展示 On-Stack Replacement 相关的编译:
┌─────────────────────────────────────────────────────────────────┐
│ OSR 编译视图 │
│ │
│ com.example.DataProcessor::processLargeSet │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ OSR 编译请求: 回边计数器达阈值 (14000) │ │
│ │ OSR 编译 ID: 42 │ │
│ │ OSR 编译层级: L4 │ │
│ │ OSR 入口: 循环开始处 (bci=15) │ │
│ │ OSR 触发时: 方法已执行到循环第 5000 次 │ │
│ │ OSR 编译耗时: 850ms │ │
│ └─────────────────────────────────────────────────────────┘ │
│ 正常入口编译: ID 45, 稍后完成 │
└─────────────────────────────────────────────────────────────────┘2.4 实际分析案例
案例 1:发现方法未达到 C2
问题: 一个热点 REST 接口方法始终在 L3 运行, 从未达到 L4
JITWatch 分析:
1. 打开 TriView → 方法字节码大小: 560 字节 (C2 编译预算可能不够)
2. 内联报告 → 该方法的内联链中有一个 400 字节的工具方法
未被内联 → 因为超出 FreqInlineSize (325)
3. 因此 C2 编译时需要处理的代码总量太大 → 编译时间太长 →
编译器线程放弃 → 停在 L3
解决:
1. 手动内联那个大工具方法 (重构)
2. 或增加 FreqInlineSize: -XX:FreqInlineSize=500 (视情况)
3. 或增加 CICompilerCount 允许更多编译线程案例 2:逃逸分析失败排查
问题: 分配大量临时对象的代码没有通过标量替换优化
JITWatch 分析:
1. 在内联报告中, 检查对象创建所在方法的内联链
2. 方法 A 调用了方法 B, 方法 B 调用了方法 C
3. 方法 C 中创建了临时对象 — 但方法 C 没有被内联到方法 A
4. 逃逸分析无法跨方法判断 → 对象被标记为 ArgEscape (参数逃逸)
解决:
1. 方法 C 为什么不内联? → 检查内联报告中的原因
2. 如果是因为方法太大 → 考虑拆小
3. 如果可以确保调用链简单 → 考虑使用 final 方法促进内联三、图解全景
┌──────────────────────────────────────────────────────────────────────────┐
│ JITWatch 分析工作流 │
│ │
│ ① 收集阶段 │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ java -XX:+UnlockDiagnosticVMOptions │ │
│ │ -XX:+LogCompilation -XX:LogFile=jit.log │ │
│ │ -XX:+PrintInlining -jar myapp.jar │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ② 加载阶段 │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 启动 JITWatch → 打开 jit.log │ │
│ │ 配置源码路径: 指向项目源代码 │ │
│ │ (JITWatch 需要源码路径来显示 TriView 的源代码列) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ③ 探索阶段 │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 选择分析方法: │ │
│ │ │ │
│ │ 热点方法排名 → 查看哪些方法编译耗时最长 (C2 在它们身上花时间最多) │ │
│ │ ↓ │ │
│ │ 选择感兴趣的方法 → TriView 查看编译质量 │ │
│ │ ↓ │ │
│ │ 内联报告 → 分析内联失败原因 │ │
│ │ ↓ │ │
│ │ 编译链 → 查看方法从 L0→L4 的完整演进 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ④ 优化阶段 │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 基于分析结果做针对性优化: │ │
│ │ - 过大方法 → 重构拆小 │ │
│ │ - 虚调用未内联 → 加 final │ │
│ │ - 逃逸分析失败 → 减少参数传递链 │ │
│ │ - 编译线程不足 → 增加 CICompilerCount │ │
│ │ - 去优化频繁 → 检查类加载触发条件 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ⑤ 验证阶段 │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 再次运行 + 收集 JIT 日志 → 对比优化前后的编译决策 │ │
│ │ 确认: 内联是否生效了? 方法是否达到 L4? 逃逸分析成功了? │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘四、实战验证
bash
# 完整 JIT 日志收集命令
java -XX:+UnlockDiagnosticVMOptions \
-XX:+LogCompilation \
-XX:+TraceClassLoading \
-XX:+PrintInlining \
-XX:LogFile=myapp-jit.log \
-XX:+PrintCodeCache \
-jar myapp.jar &
APP_PID=$!
# 等待应用预热 (如压测完成)
sleep 120
# 生成热点方法采样 (可选)
jcmd $APP_PID Compiler.print_statistics
# 停止应用
kill $APP_PID
# 调试用: 只收集特定方法的 JIT 日志
# -XX:CompileOnly=com.example.MyClass::myMethod
# 慎用: 只编译指定方法, 严重影响性能bash
# 查看已编译方法的统计信息 (运行时)
jcmd <PID> Compiler.print_statistics
# 输出示例:
# Compiler statistics:
# Number of compiled methods: 3842
# Number of C1 compilations: 3100
# Number of C2 compilations: 742
# Average compilation time C1: 12ms
# Average compilation time C2: 520ms
# Total compilation time: 47.8sbash
# 运行 JITWatch
java -jar jitwatch-1.4.5.jar
# 在 UI 中:
# 1. File → Open Log → 选择 myapp-jit.log
# 2. 如果看到源码, 需配置 Source Path (指向项目 src 目录)
# 3. 加载完成后 → 进入主界面JITWatch 控制台模式(无 UI 时使用):
bash
# JITWatch 也支持命令行分析 (从 1.4.0+)
# 可直接生成分析报告
java -cp jitwatch-1.4.5.jar com.chrisnewland.jitwatch.launch.Headless \
--log myapp-jit.log \
--report output.html
# 生成 HTML 报告, 包含关键编译统计五、面试视角
| 追问 | 答案要点 |
|---|---|
| JITWatch 能做什么? | 可视化分析 JIT 编译日志。主要功能:TriView(源码↔字节码↔汇编对照)、内联报告(内联决策树与失败原因)、编译链(方法编译历史)、OSR 视图(长循环编译分析)、热点排名(编译耗时排序)。 |
| 如何生成 JITWatch 需要的日志? | 添加 JVM 参数:-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:+TraceClassLoading -XX:LogFile=jit.log。生产环境慎用(有 I/O 开销)。 |
| 如何通过 JITWatch 发现内联问题? | 打开内联报告,检查热点方法的内联树。红色标记的方法就是未被内联的方法——鼠标悬停查看原因(方法太大、虚调用、不是热点等)。针对性优化:拆小方法、加 final、加参数。 |
| 什么是 TriView?解决了什么问题? | 三栏对照视图——同一方法同时展示源码、字节码、机器码。解决"Java 代码到底被编译成了什么"的问题。可以看到边界检查是否被消除、循环是否被展开、锁是否被消除。 |
| JITWatch 的"编译链"视图有什么用? | 展示一个方法从 L0(解释执行)到 L4(C2 完整优化)的完整编译过程。特别有用的是排查"为什么方法没有达到 L4"——如果看到去优化事件,可以分析触发原因。 |
| JITWatch 是生产环境工具吗? | 主要用于测试/预发环境。生产环境记录完整 JIT 日志有性能开销。生产环境推荐使用 jcmd 的 Compiler.print_statistics 做轻量级查看。 |
📚 相关链接
- **解释执行与编译执行** — JIT 编译日志中的解释 vs 编译状态对比
- **C1与C2编译器与分层编译** — JITWatch 中 L0-L4 层级对应的编译阶段
- **方法内联和逃逸分析** — JITWatch 内联报告中展示的内联决策细节
- ← 返回 **执行引擎索引**