一、一个让我狐疑的 618ms
本日排查一个分页查询性能标题,接口返回显着只有 10 条数据,SQL 实行也就 600 多毫秒,可 IDEA 自带的 Profiler 却给出了一组整齐划一的数字:
- CollectionUtils.isEmpty — 618 ms
- BeanCopyUtil.map — 618 ms
- collect(Collectors.toList()) — 618 ms
- objectMapper.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 万次,每次调用 inner
- for (int i = 0; i < 100_000; i++) {
- String s = inner(i);
- // 产生一些对象,诱发 Young GC
- List<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 默认用表明模式实行字节码,每一条指令都老诚实实翻译成呆板码,方法调用也会在栈上压一个帧。此时调用链是:采样器每 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 并非全能:
<ul>字节码加强会带来微小的性能开销(通常 |