一、一个让我困惑的 618ms
今天排查一个分页查询性能问题,接口返回明明只有 10 条数据,SQL 执行也就 600 多毫秒,可 IDEA 自带的 Profiler 却给出了一组整齐划一的数字:
CollectionUtils.isEmpty— 618 msBeanCopyUtil.map— 618 mscollect(Collectors.toList())— 618 msobjectMapper.writeValueAsString— 还是 618 ms
起初我以为类型转换和 JSON 序列化真的这么慢,下意识地就开始查反射、查 Jackson 配置。根据我的排查我逐渐开始换衣IDEA 的Profiler的运行结果,直到我换成 Arthas,用 trace 命令一看,所有这些Profiler认为慢的步骤加起来不到 2 毫秒。这才意识到,IDEA Profiler 的 618ms 不过是一串“幽灵耗时”,完全把我带偏了方向。
为什么同样的程序,两个工具给出的结果差出两个数量级?后来我梳理清楚了背后的技术原理,根源在于 采样型 Profiler 与插桩型 Profiler 在 JVM 运行时面前的表现完全不同。下面把整个推导过程记录下来,并留出实际运行结果。
二、两大阵营:采样器 vs 插桩器
在 JVM 生态中,性能分析工具主要分为两个流派:
| 流派 | 代表工具 | 核心原理 |
|---|---|---|
| 采样型(Sampling) | async-profiler, IDEA Profiler, JProfiler(采样模式) | 定期收集线程调用栈,统计栈帧出现频率,推算方法耗时 |
| 插桩型(Instrumentation) | Arthas, BTrace, SkyWalking(增强后) | 修改目标方法字节码,在方法入口和出口插入计时器,直接记录耗时 |
IDEA 集成的 Profiler 底层正是 async-profiler(或 Java Flight Recorder),属于采样型;而 Arthas 是通过 Java Agent 动态附加,利用 ASM 字节码增强 实现插桩。两种原理的差异,直接决定了它们在面对 JIT 编译、GC 停顿时表现出的“准度”鸿沟。
三、采样型 Profiler 的致命伤
3.1 工作原理

采样器像一位摄影师,每隔 10ms 给 JVM 拍一张“栈快照”。方法出现在栈顶的次数越多,我们就认为它的 CPU 占用越高。这种方法本质是统计学推断,而非精确测量。
3.2 内联:让方法“隐身”的魔术
JVM 在运行时会对热点代码进行 即时编译(JIT),其中最重要的优化之一是 方法内联(Inlining)。当 BeanCopyUtil.map 被频繁调用时,JIT 会直接把它的字节码嵌入到调用者 convert2OrderStatisticsEntityList 的编译代码中,消除方法调用开销。

一旦方法被内联,线程的调用栈中就不再出现 BeanCopyUtil.map 这个帧。采样器只能看到一个巨大的“编译后方法”,所有子方法耗时都被错误地归算到外层方法上,甚至完全消失。这就是为什么很多时候你会发现 IDEA Profiler 第二次录制时“监控不到方法”——它们已经被内联“吞噬”了。
3.3 GC 停顿:偷时间的隐身贼
采样器还有另一个致命问题:无法区分 CPU 执行时间和线程暂停时间。当 JVM 发生 GC 时,所有应用线程都会被暂停(Stop-The-World)。如果采样信号恰好落在 GC 停顿期间,它会把这段暂停时间错误地归算到当时正在执行的方法上。
[业务线程] ----执行业务A----|---GC停顿---|---执行业务A----
[采样信号] ↑ ↑正确样本 错误样本(A被记入大量耗时)
这就解释了开头提到的“618ms 魔咒”——很可能是一次 Young GC 或 Full GC 恰好与采样点重合,导致多个方法都分到了相同的 618ms。而 Arthas 的插桩计时器在 GC 暂停期间根本不会走动,因为它统计的是实际执行时间或准确的墙钟时间差,不会把暂停时间混入方法耗时。
四、Arthas:用手术刀代替照相机
4.1 字节码增强原理
Arthas 通过 Java Agent 动态 attach 到目标 JVM,利用 Instrumentation API 和 ASM 库,在你指定的方法前后直接织入计时代码。

这种方法有几个关键优势:
- 不受 JIT 内联影响:计时逻辑直接嵌在原始方法内部,即使该方法被内联到其他方法,计时器仍然跟随执行。你可以精准测量某个子方法的耗时,无需关心它是否被编译优化。
- 精确区分 GC 影响:如果测量基于
System.nanoTime(),GC 停顿期间线程暂停,计时器自然停滞,不会将 GC 时间算入业务方法。 - 可下钻到任意层次:Arthas 允许你
trace任何类的任何方法,甚至可以追踪第三方库(如ObjectMapper.writeValueAsString)的内部调用,这是采样器无法做到的(因为第三方类可能没有 debug 信息,采样器无法复原被内联的栈帧)。
4.2 Arthas 的工作流程详解

核心要点:
- Arthas 的计时发生在字节码层面,与 JIT 编译后的机器码无缝融合。
- 对于内联的方法,如果你单独 trace 它,Arthas 会直接增强那个被内联的方法体,你依然能看到它的耗时。
- 整个增强过程运行时动态完成,无需重启应用,退出时自动还原。
五、实证对比:同一段代码,两种画像
5.1 测试运行
为了直观体会差异,下面用一段反复执行的测试代码来对比两个工具的结果。
测试代码:
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;public class PerfTest {public static void main(String[] args) throws InterruptedException, IOException {System.out.println("程序已启动,PID: " + Thread.currentThread().getId() + ",请 attach 后按回车继续...");System.in.read();PerfTest test = new PerfTest();// 预热for (int i = 0; i < 10; i++) {test.outer();}System.out.println("预热完成,开始录制。请现在启动 Profiler...");System.in.read(); // 此时暂停,手动启动 Profiler 录制// 正式执行一次,供 Profiler 采集test.outer();System.out.println("执行完毕,按任意键退出...");System.in.read();}public void outer() {// 循环 10 万次,每次调用 innerfor (int i = 0; i < 100_000; i++) {String s = inner(i);// 产生一些对象,诱发 Young GCList<String> list = new ArrayList<>(10);list.add(s);list.add("padding_" + i);}}public String inner(int seed) {// 模拟业务计算:随机数 + 字符串拼接 + 数组拷贝double d = Math.random() * seed;String base = "value_" + (int)(d * 1000);char[] chars = base.toCharArray();// 反转字符数组for (int j = 0; j < chars.length / 2; j++) {char tmp = chars[j];chars[j] = chars[chars.length - 1 - j];chars[chars.length - 1 - j] = tmp;}return new String(chars);}
}
IDEA Profiler(async-profiler 模式)运行结果:
1.预热前就开启IDEA Profiler的录制,结果是能看到inner的具体耗时

2.预热后开启IDEA Profiler的录制,结果完全看不到inner耗时,有时候连outer都没采集到

在预热后的录制中,inner() 方法完全消失,火焰图上只看到有时候连 outer() 消耗也看不到,看起来就好像 inner() 的消耗为零。
Arthas trace outer -n 15 运行结果:

这里可以清晰看到 inner() 在每次 outer() 调用中被执行 次数,以及它的平均耗时与总耗时。单独 trace inner 也能拿到该方法的独立耗时数据。
这种差异落到实际业务里就变得非常关键:当瓶颈恰好是一个小工具方法(比如我的 BeanCopyUtil.map),IDEA Profiler 会因为内联和 GC 污染给出误导信息,让我误以为类型转换和 JSON 序列化很慢;而 Arthas 则直接指出真正消耗时间的那一行。
你可以直接把下面的内容插在第五章末尾(Arthas 运行结果截图之后)作为一个“补充”小节,排版保持与全文一致。
5.2 为什么预热前后,IDEA Profiler 判若两人?
看完上面的执行结果,自然会冒出一个疑问:为什么预热前 Profiler 还能看到 inner,预热后就彻底不见了?是不是因为预热后执行太快,没来得及采样?
答案是:不是“快不快”的问题,而是“有没有”的问题。真正让 inner 消失的,是 JIT 的方法内联优化,而预热只是触发这种优化的条件。
预热前:解释执行,栈帧还在
程序刚启动时,JVM 默认用解释模式执行字节码,每一条指令都老老实实翻译成机器码,方法调用也会在栈上压一个帧。此时调用链是:
outer → inner → 业务代码
采样器每 10ms 拍一张快照,能看到 outer 下面稳稳挂着 inner,所以火焰图上 inner 一目了然。这时的 Java 世界还没被优化,一切都很“诚实”。
预热后:分层编译 + 内联,方法帧被“吃”掉了
当 outer 中的循环让 inner 被调用了上百万次后,JVM 的 分层编译 开始发力:
- C1 编译器:在数千次调用后介入,做轻量优化,此时方法帧通常还在。
- C2 编译器:在调用过万次后,进行深度优化,核心手段就是方法内联。
内联的意思是:编译器直接把 inner 的代码“粘贴”到 outer 内部,变成一个巨大的方法再编译成机器码。这样一来,inner 作为独立方法彻底消失,调用栈上再也找不到它。
采样器仍然每 10ms 拍快照,但栈上只有那个编译后的 outer。这就是为什么你的截图里,预热后 inner 直接“蒸发”,严重时连 outer 都因为样本太少而显示不全。
预热后变快,是“果”不是“因”
你可能观察到预热后执行时间缩短了,这确实是 JIT 优化的功劳,但采样器“看不到”不是因为时间短到采不足,而是因为方法帧被物理抹除了。如果只是跑得快但栈帧还在,采样器仍能抓到 inner——就像 Arthas 每次都能测到一样。
Arthas 为何能“无视”预热?
Arthas 不靠“拍快照”找方法,而是直接在 inner 的字节码里插入计时代码。JIT 编译这个增强后的方法时,计时代码会一并被内联、一并执行。无论栈帧在不在,耗时都被精准记录了。
简而言之:采样器看的是“运行时找不找得到方法帧”,Arthas 做的是“直接让方法自己报时间”。这个根本差异,在预热前后表现得淋漓尽致。
六、Arthas 的局限与补充
当然,Arthas 并非万能:
- 字节码增强会带来微小的性能开销(通常 <5%),不适合长期挂载,适合诊断时临时使用。
- 只关注用户指定的方法,不适合全局热点发现。
- 对 lambda 表达式、Stream 内部方法的追踪需要额外技巧(因为编译后类名不可预测)。
因此,生产环境的最佳实践是组合使用:
- 全局监控:JDK 自带 JFR 或 Prometheus JMX Exporter,观察整体趋势。
- 精准诊断:Arthas 做手术刀式定位。
- 离线分析:jstack、JMC 做补充。
七、总结
IDEA Profiler 的不准确,根源于采样 + JIT 内联 + GC 污染这套组合拳,它更适合宏观热点发现,但无法胜任方法级精密计时。Arthas 通过直接修改字节码,将计时代码与目标方法融为一体,规避了 JVM 运行时的所有优化副作用,做到了真正意义上的“所见即所得”。
下次当 Profiler 再给你一个整齐的耗时,不用怀疑代码,先怀疑 Profiler。然后,打开 Arthas,输入 trace,让真相浮出水面。如果 方法 真的慢,Arthas 也会真实地告诉你它耗在了哪里,而不会再让其他方法背锅。

本文通过一个618ms的性能排查案例,揭示了采样型Profiler(如IDEA Profiler)与插桩型Profiler(如Arthas)的本质差异。采样型工具通过定期捕获调用栈推算耗时,容易受到JIT内联优化和GC停顿的干扰,导致方法耗时被错误归集或完全消失;而插桩型工具通过字节码增强直接植入计时逻辑,能精准测量实际执行时间,不受运行时优化影响。文章通过原理分析和实测对比,建议在性能优化时优先使用Arthas等插桩工具获取准确数据,采样工具更适合宏观性能分析。