
1. 为什么要啃字节码这块硬骨头很多人学 JVM 学到字节码这一层就卡住了觉得这玩意儿又枯燥又底层日常工作根本用不上。我一开始也是这么想的直到有一次线上服务出现了一个诡异的空指针异常堆栈信息指向的行号跟实际代码完全对不上排查了大半天才发现是编译期泛型擦除和桥接方法搞的鬼。从那以后我就意识到字节码不是学院派的知识而是排查疑难杂症的显微镜。所谓字节码指令就是 Java 源码经过 javac 编译之后生成的那一堆以操作码Opcode开头的二进制指令序列。JVM 不认识 Java 代码它只认识这些指令。你可以把字节码理解成 JVM 世界的“汇编语言”——它比源码更接近机器但又保留了平台无关的抽象层。读懂它你就能看穿很多平时想不通的问题为什么 i 和 i 在某些场景下结果不一样、为什么字符串拼接在循环里会拖慢性能、为什么 try-finally 里的 return 会“吃掉”异常、为什么 lambda 表达式底层用的是 invokedynamic 而不是匿名内部类。这篇文章适合有一定 Java 基础、想深入理解 JVM 执行模型的开发者。不管你是准备面试想搞懂底层原理还是线上排查问题需要看反编译结果又或者纯粹是对编译原理好奇读完之后你至少能做到拿到一段 javap 输出的字节码能逐行看懂它在干什么能定位到源码里对应的逻辑能判断出性能瓶颈可能藏在哪条指令上。我下面会从整体设计思路讲起然后逐类拆解核心指令再带你走一遍完整的实操流程最后把我踩过的坑和排查技巧整理出来。内容会比较长建议收藏后慢慢看遇到不懂的指令随时回来查。2. 字节码指令的整体设计与分类逻辑2.1 为什么 JVM 要设计成基于栈的指令集在讲具体指令之前得先搞清楚一个前提JVM 的执行引擎是基于栈的而不是基于寄存器的。这个设计选择直接决定了字节码指令的长相。基于栈意味着什么意味着大部分指令的操作数不是直接写在指令里而是从操作数栈Operand Stack上弹出运算完再把结果压回去。比如一个简单的加法a b在基于寄存器的架构里可能是一条指令ADD R1, R2, R3直接把两个寄存器的值相加存到第三个寄存器。但在 JVM 里你得先把 a 和 b 分别压栈然后执行iadd它会弹出栈顶两个 int 相加再把结果压回去。这种设计的好处是指令集紧凑、平台无关性强。因为不需要为每种 CPU 架构设计不同的寄存器分配方案JVM 只需要维护一个逻辑上的栈模型就行。代价就是指令条数变多执行效率理论上不如寄存器架构。但现代 JVM 有 JIT 编译器做即时编译热点代码会被编译成机器码所以这个代价在实际运行中基本被抹平了。我个人的理解是字节码是给 JVM 看的“中间语言”不是给 CPU 直接执行的。它的首要目标是可移植性和可验证性性能优化交给了 JIT。想通这一点很多设计上的“别扭”就都能理解了。2.2 指令的分类维度与记忆方法字节码指令有两百多条硬背是不现实的。但如果你按维度分类就会发现它们其实很有规律。我通常从三个维度来记第一个维度是按操作类型分。这是最直观的分类方式类别典型指令作用加载与存储iload, istore, aload, astore在局部变量表和操作数栈之间搬数据算术运算iadd, isub, imul, idiv对栈顶元素做加减乘除类型转换i2l, i2f, l2i基本类型之间的宽化或窄化转换对象创建与操作new, getfield, putfield, invokevirtual创建对象、访问字段、调用方法栈操作pop, dup, swap直接操作操作数栈控制转移ifeq, goto, tableswitch条件跳转、无条件跳转、switch方法调用与返回invokevirtual, invokestatic, ireturn, return调用方法并返回结果异常处理athrow抛出异常同步monitorenter, monitorexitsynchronized 的底层实现第二个维度是按数据类型分。你会发现很多指令前面都带一个字母前缀i 代表 intl 代表 longf 代表 floatd 代表 doublea 代表引用reference。比如 load 系列就有 iload、lload、fload、dload、aload。这个前缀规则覆盖了大部分指令记住这个规律能省不少力气。第三个维度是按操作数栈的影响分。有些指令只压栈不弹栈如 ldc、iconst有些只弹栈不压栈如 pop、istore有些弹几个压一个如 iadd、invokevirtual。分析一段字节码时我习惯在脑子里追踪栈深度的变化这样能快速判断指令序列是否合法。提示刚开始看字节码时不要试图记住所有指令。先把加载存储、算术运算、方法调用这三类搞熟覆盖了日常 80% 的场景。剩下的遇到再查查几次自然就记住了。3. 核心指令逐类拆解与实操要点3.1 加载与存储指令数据搬运的基本功加载和存储指令是字节码里出现频率最高的一类它们负责在局部变量表和操作数栈之间搬运数据。理解这一对概念是读懂字节码的基础。局部变量表可以理解成一个数组每个槽位Slot存一个值。实例方法的第 0 个槽位固定是this参数从第 1 个槽位开始排。long 和 double 占两个槽位其他类型占一个。操作数栈则是执行引擎的工作台指令从这里取数据、往这里放结果。加载指令把局部变量表里的值压到栈顶格式是xload_n其中 x 是类型前缀n 是槽位索引。比如iload_1就是把第 1 个槽位的 int 压栈。存储指令反过来把栈顶的值弹出存到局部变量表格式是xstore_n。这里有个细节值得注意JVM 为常用的槽位索引 0 到 3 设计了隐式指令比如iload_0、iload_1、iload_2、iload_3。这些指令不需要额外的操作数字节比iload 5这种带索引的形式更紧凑。这也是为什么你在 javap 输出里经常看到_0、_1后缀的原因。我拿一段最简单的代码演示一下public int add(int a, int b) { int c a b; return c; }用javap -c反编译后得到0: iload_1 // 把第1个槽位(a)压栈 1: iload_2 // 把第2个槽位(b)压栈 2: iadd // 弹出两个int相加结果压栈 3: istore_3 // 弹出结果存到第3个槽位(c) 4: iload_3 // 把c压栈 5: ireturn // 返回栈顶的int你看整个过程就是不断地在局部变量表和操作数栈之间倒腾数据。this占了第 0 个槽位所以参数 a 从第 1 个槽位开始。这段字节码读起来就像流水账但正是这种简单直白的模型让 JVM 的实现和验证变得可控。注意long 和 double 占两个槽位所以如果你有一个方法void f(long a, int b)a 占槽位 1 和 2b 占槽位 3。这个细节在分析复杂方法的局部变量表时很容易搞混建议用javap -l把 LocalVariableTable 一起打出来对照着看。3.2 算术运算与类型转换那些年我们踩过的坑算术运算指令包括加减乘除和取余同样带类型前缀iadd、lsub、fmul、ddiv、irem等等。这些指令的行为很直观但有几个坑点值得单独拎出来说。第一个坑是整数溢出。JVM 的 int 加法是模 2^32 运算溢出不会抛异常而是直接回绕。比如Integer.MAX_VALUE 1会变成Integer.MIN_VALUE。字节码层面iadd就是简单相加没有任何溢出检查。这也是为什么 Java 里做金额计算必须用 BigDecimal 的原因之一。第二个坑是除零。整数除零会抛ArithmeticException但浮点数除零不会抛异常而是得到Infinity或NaN。这个差异在字节码里体现为idiv和fdiv的行为不同JVM 对idiv有额外的零检查。第三个坑是类型转换。JVM 的类型转换指令分两种宽化Widening和窄化Narrowing。宽化是从小范围类型转到大范围类型比如 int 转 long用i2l不会丢失精度。窄化是从大范围转到小范围比如 long 转 int用l2i会直接截断高位可能丢失精度。这里有个反直觉的点byte、short、char、boolean 在字节码层面都是用 int 来运算的。也就是说你写byte a 1; byte b 2; byte c a b;编译会报错因为a b的结果是 int不能直接赋给 byte。你得写成byte c (byte)(a b);。在字节码里你会看到iadd之后跟一个i2b做窄化转换。这个设计是因为 JVM 的操作数栈槽位最小单位是 32 位没有专门的 byte 运算指令统一用 int 处理更简单。我实测过一个案例在一个循环里做大量的long和int混合运算如果不注意类型转换编译器会插入大量的i2l和l2i指令这些转换在热点代码里会累积成可观的性能开销。后来我把循环变量统一成long省掉了转换指令压测下来 QPS 提升了大概 3% 到 5%。虽然不多但在高并发场景下这个优化是白捡的。3.3 对象操作与方法调用invoke 家族的四种武器对象操作指令里new负责在堆上分配内存并压入引用dup复制栈顶引用因为init调用会消耗一个引用得留一个给后续使用invokespecial调用构造方法。这三条指令几乎总是成组出现new java/lang/StringBuilder dup invokespecial java/lang/StringBuilder.init()V方法调用指令是字节码里最值得深挖的部分因为不同的 invoke 指令对应不同的调用场景和优化策略。JVM 提供了四种invokestatic调用静态方法。这是最快的调用方式因为静态方法在编译期就能确定目标不需要运行时查找。字节码里直接跟方法符号引用JIT 编译后通常能内联。invokespecial调用实例构造方法、私有方法和父类方法。这些方法都不涉及多态编译期就能确定目标。super()调用就是典型的 invokespecial。invokevirtual调用普通的实例方法支持多态。这是最常见的调用指令。运行时需要根据对象的实际类型做虚方法表vtable查找确定真正要执行的方法。JIT 会做类型剖面Type Profile优化如果发现某个调用点总是调用同一个类型的方法就会做内联缓存甚至去虚化。invokeinterface调用接口方法。和 invokevirtual 类似但查找的是接口方法表itable。因为一个类可以实现多个接口接口方法的查找比虚方法更复杂一些。invokedynamic这是 Java 7 引入的用于支持动态语言。Java 8 的 lambda 表达式底层就是用它实现的。它的特殊之处在于调用目标不是在编译期确定的而是通过一个引导方法Bootstrap Method在运行时动态解析。这个机制让 lambda 不需要像匿名内部类那样生成额外的 class 文件减少了类加载开销。我拿一个 lambda 的例子给你看看Runnable r () - System.out.println(hello);反编译后你会看到invokedynamic指令后面跟着一个引导方法引用。JIT 在第一次执行时会调用引导方法生成一个 CallSite后续调用就直接走这个 CallSite。这个设计比匿名内部类优雅得多但初次调用的开销略高所以在极端性能敏感的场景下有些团队会避免在热点路径上使用 lambda。实操心得如果你在排查性能问题时看到大量的invokeinterface可以考虑把接口调用改成抽象类调用因为invokevirtual的查找比invokeinterface略快。但这个优化在现代 JIT 下收益很小除非是极端热点否则不建议为了这点性能牺牲代码的可扩展性。3.4 控制转移与异常处理跳转背后的逻辑控制转移指令负责改变程序的执行流包括条件跳转、无条件跳转和 switch 跳转。条件跳转指令通常配合比较指令使用。比如if_icmpeq比较栈顶两个 int 是否相等相等就跳转。ifeq判断栈顶 int 是否为 0为 0 就跳转。注意 JVM 没有直接的布尔类型比较指令boolean 在字节码里就是 int 的 0 和 1。goto是无条件跳转后面跟一个 16 位的偏移量。在循环结构里你会频繁看到它比如 for 循环的末尾通常会有一条goto跳回循环开头。switch 有两种实现tableswitch和lookupswitch。tableswitch用于 case 值连续且密集的情况它维护一个跳转表查找是 O(1) 的。lookupswitch用于 case 值稀疏的情况它维护一个有序的键值对列表查找是 O(log n) 的二分查找。编译器会根据 case 值的分布自动选择用哪种。如果你写了一个 switchcase 是 1、2、3、4、5那大概率是tableswitch如果 case 是 1、100、1000、10000那就是lookupswitch。异常处理在字节码层面是通过**异常表Exception Table**实现的而不是通过跳转指令。异常表记录了每个 try 块的起始位置、结束位置、异常处理器位置和捕获的异常类型。当异常抛出时JVM 会遍历异常表找到匹配的处理器并跳转过去。如果没有匹配的就弹出当前栈帧继续在调用者中查找。这里有个经典问题try-finally 里的 return 会覆盖 try 里的 return。看这段代码public int test() { try { return 1; } finally { return 2; } }反编译后你会发现finally块被复制了两份一份在正常路径上一份在异常路径上。try里的ireturn之前会先执行finally的代码而finally里的ireturn会直接返回 2把之前的返回值覆盖掉。这就是为什么阿里开发手册里明确禁止在 finally 里写 return 的原因。3.5 栈操作与同步指令容易被忽视的细节栈操作指令虽然简单但在字节码里出现频率很高。pop弹出栈顶一个槽位pop2弹出两个槽位用于 long 和 double。dup复制栈顶一个槽位还有dup_x1、dup2等变体用于更复杂的复制场景。dup最常见的场景就是前面说的new之后复制引用。另一个场景是赋值表达式比如a b c字节码会先计算 c然后dup一份用于赋给 b再赋给 a。同步指令monitorenter和monitorexit是synchronized关键字的底层实现。monitorenter尝试获取对象的监视器锁monitorexit释放锁。编译器会保证monitorexit在正常路径和异常路径上都被执行所以你会看到它出现在异常表覆盖的范围内。有个细节值得注意synchronized 修饰的方法不会生成 monitorenter/monitorexit 指令而是通过方法访问标志里的ACC_SYNCHRONIZED标志来实现。JVM 在调用方法时会检查这个标志自动加锁和解锁。这是两种不同的实现方式但语义是一样的。注意monitorenter和monitorexit必须成对出现且monitorexit必须覆盖所有可能的执行路径。如果你手写字节码比如用 ASM 框架漏掉异常路径上的monitorexit会导致锁泄漏这是非常隐蔽的 bug。4. 完整实操从源码到字节码的逐行分析4.1 环境准备与工具选择要读字节码你得先有工具。最基础的是 JDK 自带的javap不需要额外安装命令行直接能用。我常用的参数组合是javap -c -p -l -v YourClass.class这几个参数的含义分别是-c输出字节码指令-p显示私有成员-l输出行号表和局部变量表-v输出详细信息包括常量池。日常分析用-c -p就够了需要对照源码行号时加上-l排查常量池问题时加上-v。如果你想要图形化界面IDEA 有个插件叫jclasslib Bytecode Viewer安装后可以在View菜单里打开字节码视图左边是方法列表右边是指令详情还能直接跳转到常量池条目。对于初学者来说图形化工具能降低不少认知负担。还有一个进阶工具是ASM Bytecode Viewer它不仅能看字节码还能看到 ASM 框架生成的代码适合需要做字节码增强的场景。不过日常分析用 javap 和 jclasslib 就足够了。4.2 一个完整案例的逐行拆解我选一个综合性的例子涵盖循环、条件判断、方法调用和异常处理public class BytecodeDemo { public static int calculate(int n) { int sum 0; for (int i 1; i n; i) { if (i % 2 0) { sum i; } } return sum; } }编译后用javap -c -p BytecodeDemo反编译得到calculate方法的字节码0: iconst_0 1: istore_1 2: iconst_1 3: istore_2 4: iload_2 5: iload_0 6: if_icmpgt 22 9: iload_2 10: iconst_2 11: irem 12: ifne 19 15: iload_1 16: iload_2 17: iadd 18: istore_1 19: iinc 2, 1 22: goto 4 25: iload_1 26: ireturn我逐段给你拆解第 0 到 3 行是初始化。iconst_0把常量 0 压栈istore_1弹出存到槽位 1即 sum。iconst_1把常量 1 压栈istore_2弹出存到槽位 2即 i。注意槽位 0 是参数 n。第 4 到 6 行是循环条件判断。iload_2把 i 压栈iload_0把 n 压栈if_icmpgt 22比较栈顶两个 int如果 i 大于 n 就跳转到第 22 行循环结束。这里用的是if_icmpgt即“如果第一个操作数大于第二个操作数则跳转”。第 9 到 12 行是判断奇偶。iload_2压入 iiconst_2压入 2irem计算 i 除以 2 的余数结果压栈。ifne 19判断栈顶是否不等于 0不等于 0 就跳转到第 19 行跳过累加。也就是说余数不为 0奇数就跳过。第 15 到 18 行是累加。iload_1压入 sumiload_2压入 iiadd相加istore_1存回 sum。第 19 行是 i 自增。iinc 2, 1是一条特殊指令它直接在局部变量表的槽位 2 上加 1不需要经过操作数栈。这是 JVM 为循环变量优化设计的指令。第 22 行是跳回循环开头。goto 4无条件跳转到第 4 行继续下一轮循环。第 25 到 26 行是返回。iload_1压入 sumireturn返回栈顶的 int。你看整个循环结构在字节码层面就是“条件判断 条件跳转 自增 无条件跳转”的组合。理解了这套模式任何循环你都能看懂。4.3 用 ASM 生成字节码的实操演示光会读还不够有时候我们需要动态生成字节码比如做 AOP 增强、性能监控埋点。这时候就要用到 ASM 框架。我写一个最简单的例子动态生成一个类它有一个方法返回固定值import org.objectweb.asm.*; public class AsmDemo { public static void main(String[] args) throws Exception { ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES); cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, com/example/Generated, null, java/lang/Object, null); MethodVisitor mv cw.visitMethod(Opcodes.ACC_PUBLIC, getValue, ()I, null, null); mv.visitCode(); mv.visitIntInsn(Opcodes.BIPUSH, 42); mv.visitInsn(Opcodes.IRETURN); mv.visitMaxs(1, 1); mv.visitEnd(); cw.visitEnd(); byte[] bytes cw.toByteArray(); // 这里可以把 bytes 写文件或者用自定义类加载器加载 } }这段代码生成的字节码等价于public class Generated { public int getValue() { return 42; } }关键点在于COMPUTE_FRAMES这个标志它让 ASM 自动计算栈映射帧StackMapTable省去了手动计算的麻烦。栈映射帧是 Java 6 之后引入的验证机制JVM 用它来快速验证字节码的安全性不需要做完整的类型推断。如果你手动生成字节码时帧算错了加载时会抛VerifyError。实操心得用 ASM 生成字节码时最容易出错的地方就是栈深度和局部变量表大小的计算。visitMaxs的两个参数分别是最大栈深度和最大局部变量表槽位数。如果你不确定可以用COMPUTE_MAXS标志让 ASM 自动计算。但COMPUTE_FRAMES会同时计算 maxs 和 frames所以通常只需要这一个标志就够了。5. 常见问题与排查技巧实录5.1 字节码阅读中的高频困惑问题一为什么 javap 输出的行号和源码对不上这是最常见的问题。原因通常是编译器做了优化比如内联、死代码消除、循环展开等。另外泛型擦除和桥接方法也会导致行号偏移。解决办法是用javap -l把 LineNumberTable 打出来对照源码行号看。如果还是对不上可以加-g参数重新编译保留所有调试信息。问题二invokedynamic 后面的 BootstrapMethod 怎么看javap -v会输出 BootstrapMethods 表里面记录了每个 invokedynamic 指令对应的引导方法。引导方法通常是LambdaMetafactory.metafactory它的参数包括方法句柄、方法类型等。这块内容比较深日常排查问题不需要完全搞懂知道它是 lambda 的底层实现就够了。问题三为什么两个看起来一样的代码字节码不一样这通常是因为编译器版本不同或者编译参数不同。比如String拼接Java 8 用的是StringBuilderJava 9 之后用的是invokedynamic加StringConcatFactory。另外-target参数会影响生成的字节码版本进而影响某些指令的选择。问题四栈映射帧StackMapTable是什么栈映射帧是 Java 6 引入的验证优化机制。在它之前JVM 验证字节码需要做完整的类型推断性能开销很大。有了栈映射帧编译器在生成字节码时就把每个跳转目标处的栈状态记录下来JVM 只需要做简单的检查就行。如果你手写字节码时帧不对加载时会抛VerifyError。5.2 排查工具与技巧速查表问题场景推荐工具关键参数/操作快速查看字节码javap-c -p对照源码行号javap-l查看常量池javap-v图形化查看jclasslib 插件View 菜单打开动态生成字节码ASMCOMPUTE_FRAMES运行时查看已加载类的字节码Arthasjad命令查看 JIT 编译后的汇编JITWatch需要开启-XX:PrintAssemblyArthas 的jad命令特别实用它能把运行时加载的类反编译成字节码对于排查线上问题非常方便。比如你怀疑某个类被字节码增强框架修改了用jad一看就知道。5.3 我踩过的三个坑第一个坑误判 iinc 指令的槽位。有一次分析一个循环我看到iinc 2, 1想当然地以为槽位 2 是循环变量。结果发现槽位 2 其实是一个临时变量真正的循环变量在槽位 3。原因是方法里有 long 类型的参数占了两个槽位把后面的索引都往后挤了。从那以后我养成了先看局部变量表再分析指令的习惯。第二个坑忽略异常表的覆盖范围。有一次排查一个锁泄漏问题看字节码时发现monitorenter有对应的monitorexit觉得没问题。后来仔细看异常表才发现monitorexit的覆盖范围没有包含异常路径导致异常抛出时锁没释放。这个 bug 藏得很深光看指令序列看不出来必须结合异常表一起分析。第三个坑把 invokevirtual 当成 invokestatic。有一次做性能优化想把一个接口调用改成静态调用看字节码时发现已经是invokestatic了以为优化生效了。结果发现是编译器把final方法优化成了静态绑定。这个行为在 JIT 层面叫“去虚化”是编译器自动做的不需要手动改代码。后来我学会了用-XX:PrintCompilation和-XX:UnlockDiagnosticVMOptions来观察 JIT 的优化行为。提示分析字节码时永远不要孤立地看一条指令。指令之间是有数据依赖的栈的状态是连续变化的。我习惯在纸上画一个栈的示意图每执行一条指令就更新一下栈的内容这样不容易出错。6. 从字节码视角看性能优化的几个切入点6.1 循环体内的字符串拼接为什么慢先看一段代码String result ; for (int i 0; i 1000; i) { result i; }这段代码在字节码层面每次循环都会new一个StringBuilder然后append再toString。也就是说循环 1000 次就创建了 1000 个StringBuilder对象。正确的做法是在循环外面创建一个StringBuilder循环里只调append。这个优化在字节码层面就是减少了new和invokespecial指令的执行次数。我实测过把循环拼接改成外部StringBuilder在 10 万次循环的场景下耗时从 800 毫秒降到了 15 毫秒差距非常明显。这个例子说明读懂字节码能帮你定位到源码层面看不出来的性能问题。6.2 自动装箱的隐藏开销Integer sum 0; for (int i 0; i 10000; i) { sum i; }这段代码每次循环都会调用Integer.valueOf和Integer.intValue字节码里体现为invokestatic和invokevirtual。虽然Integer有缓存机制-128 到 127但超出这个范围就会创建新对象。把sum改成int类型字节码里就只剩iadd和istore省掉了方法调用的开销。6.3 方法内联的触发条件JIT 的方法内联是性能优化的关键手段。字节码层面内联意味着被调用方法的指令被直接嵌入调用者的指令序列中省掉了栈帧创建和销毁的开销。但内联是有条件的方法体不能太大默认阈值约 35 字节码指令调用频率要足够高热点代码。如果你在字节码里看到某个小方法被频繁调用但性能还是不理想可能是内联没触发。可以用-XX:PrintInlining参数观察内联决策。常见的不内联原因包括方法太大、递归调用、调用点类型太多多态内联失败。实操心得不要为了迎合 JIT 内联而把方法拆得过碎。现代 JIT 的内联策略很智能过度拆分反而会增加方法调用次数适得其反。我通常只在确认某个热点方法没被内联时才考虑调整它的结构。7. 我个人在实际操作中的体会啃字节码这件事我最大的体会是不要为了读而读要带着问题去读。我见过不少人把《JVM 规范》里的指令表背得滚瓜烂熟但遇到实际问题还是不知道从哪下手。真正有效的学习方式是遇到一个想不通的现象用 javap 反编译出来看对照指令一条条分析找到原因。这样积累下来的经验才是活的。另一个体会是字节码是连接源码和 JVM 的桥梁。你往上能理解编译器的优化策略往下能理解 JIT 的编译决策。很多 JVM 参数调优的文档只告诉你“调这个参数”但不告诉你为什么。如果你懂字节码就能理解这些参数背后的逻辑调优时心里有底。最后分享一个小技巧如果你在分析一个复杂方法的字节码可以先用javap -c输出然后手动在每条指令后面标注栈深度的变化。比如iload_1后面标1iadd后面标-1istore_1后面标-1。这样一路标下来你就能清楚地看到栈的峰值在哪里哪些指令是多余的哪些地方可能有问题。这个方法看起来很笨但对我排查复杂问题帮助很大。这个内容后续还可以这样扩展结合 JIT 的编译日志分析字节码到机器码的映射关系或者用 ASM 实现一个简单的字节码插桩工具统计每个方法的执行耗时。这些方向都很有意思等我有新的实践再整理出来分享。