ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java字节码查看实战:从javap到Arthas的完整指南

Java字节码查看实战:从javap到Arthas的完整指南 刚学会写 Java 的那几年我对字节码的态度是不主动看也不觉得需要看。直到有一次线上排查一个诡异的 ClassCastException同事随手拉了个 javap 指令把我写的那段泛型代码的底裤扒得一干二净我才意识到字节码这东西是每个 Java 开发迟早要补的一课。你平时写的语法糖、泛型、Lambda、字符串拼接编译器在背后做了多少小动作看一遍字节码全清楚了。这篇文章就把“怎么看字节码”和“看的时候重点盯哪些输出”这两件事一次讲透。为了让后面的例子不空对空先准备一段贯穿全文的演示代码import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; public class Demo { private ListString names Arrays.asList(alice, bob, carol); private static final int MAX 9; public ListString filterNames(int minLen) { return names .stream() .filter(s - s.length() minLen) .collect(Collectors.toList()); } public T extends ComparableT T max(T left, T right) { return left.compareTo(right) 0 ? left : right; } public static void main(String[] args) { Demo demo new Demo(); System.out.println(demo.filterNames(4)); } }编译并看字节码javac Demo.java javap -v Demo.class下面所有讨论都围绕这段代码展开。1. 为什么非学会看字节码不可先说一个常被误解的点字节码不是“反编译”它比反编译更接近真相。反编译工具比如 IDEA 里反编译依赖 Jar 的那种功能是把字节码还原成 Java 源码给人看的但还原过程中会丢失很多信息比如哪一行对应哪个字节码指令、哪些表达式被编译器改写了、哪些方法是自动生成的。而你直接看字节码看到的是真正在 JVM 里跑的东西是编译器给虚拟机下达的“最终指令”。很多场景光看源码是解不出来的。举几个我实际碰到过的例子。第一个是性能排查。你用Integer做循环累加源码看起来平平无奇但字节码里全是Integer.valueOf和intValue的调用少则装箱多则分配对象GC 压力就是这样悄悄涨上去的。第二个是框架原理。Spring AOP 的动态代理、MyBatis 的 Mapper 代理最终都会生成一个你工程里根本不存在的类只有把运行期字节码 dump 出来看你才能理解“代理对象到底长什么样”。第三个是“语法糖后遗症”。比如泛型方法明明返回T运行时却可能冒出桥接方法导致你反射拿到一堆“多余”的同名方法不查字节码你会以为反射 API 出 bug 了。字节码的本质可以理解成 Java 源码和机器码之间的一层“中间契约”。它由 JVM 规范定义不依赖具体操作系统所以 Java 才能到处跑。我们平时说的“class 文件里存的东西”就是这个契约的二进制形态。掌握了读字节码你就同时获得了“编译器视角”和“虚拟机视角”很多问题一眼就能看到本质。另外补充一个基础认知字节码是一套面向栈的指令集。绝大多数指令都是从操作数栈取数据、算完再放回栈里这跟 x86 那种寄存器机器码完全不是一个思维模型。看的时候别用“寄存器”那套思路去套不然每个指令都看不懂。提示如果你完全不熟悉 JVM 规范里的“描述符”概念建议先搞懂I是 int、J是 long、V是 void、Ljava/lang/String;是 String后面所有字节码输出都离不开这些缩写。2. 字节码查看方法全梳理从 javap 到 Arthas字节码查看手段很多但不同场景对应不同工具。我的经验是静态分析用 javap 和 IDE 插件运行期分析靠 Arthas要动手改字节码才轮到 ASM 那套工业级库。下面一个个说。2.1 javap命令行第一选择javap 是 JDK 自带的类文件反汇编工具所有 Java 开发者的基本功。最常用的几个参数参数作用我的使用习惯javap -c Demo.class只输出方法体的字节码指令快速看某个方法内部逻辑时用它最常用javap -v Demo.class输出完整信息含常量池、属性表、异常表等想系统研究整个类时用信息爆炸但最全javap -p Demo.class包含 private 成员与合成方法排查 Lambda、桥接方法时必须加不加会被“藏起来”javap -s Demo.class显示内部类型描述符确认泛型擦除后的真实签名时用javap -l Demo.class显示行号表和局部变量表需要定位行号或变量槽位时用举个例子想看filterNames方法的字节码一句javap -c Demo.class就能看到类似输出public java.util.Listjava.lang.String filterNames(int); Code: 0: aload_0 1: getfield #7 // Field names:Ljava/util/List; 4: invokeinterface #8, 2 // InterfaceMethod java/util/List.stream:()Ljava/util/stream/Stream; 9: invokedynamic #9, 36 // InvokeDynamic #0:test:(I)Ljava/util/function/Predicate; ...-c和-v的区别一句话说清楚-c是“只看方法里怎么跑”-v是“连类怎么组织、常量池里有什么、异常表怎么跳”全给你铺开。刚开始学习建议直接-v先从整体框架建立认知再慢慢学着挑重点看。2.2 IDE 插件jclasslib 与 ASM Bytecode Viewer命令行看字节码有个痛点常量池里的#7、#8要自己跟着编号跳来跳去看复杂类非常费眼。IDEA 里装一个jclasslib插件class 文件会多出一个可视化页签左侧是常量池、字段、方法、属性这些分组点击任意一条引用都能直接跳转体验比命令行好一个量级。还有一个插件叫ASM Bytecode Viewer它的特色是能直接生成“用 ASM 框架访问这个类”的 Java 代码。如果你想在代码里动态生成/修改字节码用它做参考非常直观相当于把你手写 ASM 的流程省了一半。不过我要提醒一句IDE 插件读的也是磁盘上的 class 文件它解决的是“可读性”问题不是“获取字节码”问题。如果类只在 JVM 运行期动态生成磁盘上根本没有对应文件插件和 javap 都无能为力这时候要请出下一位选手。2.3 运行期字节码怎么拿Arthas dump 实战线上排查时我们经常遇到一种情况某个类已经被性能监控、APM 或业务框架增强过了你本地编译出来的 class 和 JVM 里实际跑的完全不是一回事。或者某个类来自一个自定义类加载器只有运行期才存在。这种场景用javap拷磁盘文件是没用的正确姿势是用 Arthas 把 JVM 里的真实字节码 dump 出来。操作很简单java -jar arthas-boot.jar # 选择目标 Java 进程 dump com.example.DemoArthas 会把该类的字节码文件导出到arthas-output目录之后再配合javap分析即可。原理上Arthas 走了 JVM 的 Instrumentation 机制拿到的是类加载器真正加载过的字节数组所以连运行时增强后的类也能看到。这个方法我在排查 CGLIB 代理类问题时用过不止一次价值极高。2.4 工具选型建议你的目的首选工具理由快速确认某个类的字节码javap零依赖JDK 自带命令一敲就出来系统学习 class 结构jclasslib 插件可视化跳转对新手友好写 ASM 代码改字节码ASM Bytecode Viewer直接给出访问者模式模板运行期查看真实类Arthas dump能看到 JVM 里实际加载和增强后的版本研究二进制原始布局010 Editor 等十六进制工具看魔数、版本号时偶尔会用工具组合没有标准答案但我的习惯是先用 javap 搞快速预览再用 jclasslib 深挖结构最后在需要写字节码生成代码时打开 ASM Viewer 抄模板。3. 站在 class 文件门口先看结构再看细节拿到一份 javap -v 的输出面对一屏又一屏的信息许多人第一反应是“这都什么玩意儿”。别慌class 文件结构是高度规范化的按顺序扫就行。3.1 魔数、版本号第一眼永远先看这里任何 class 文件的前四个字节都是固定魔数CAFEBABE这是 JVM 识别 class 文件的硬性标记。紧跟其后的是次版本号和主版本号它直接决定了这个类能在哪个版本的 JDK 上跑。javap -v 输出在最前面会显示Classfile /path/to/Demo.class minor version: 0 major version: 52这里的major version 52对应 Java 855对应 Java 1161对应 Java 17。如果 JVM 的主版本号小于 class 文件的主版本号启动时会报UnsupportedClassVersionError。我见过不少同事把同事用 JDK 17 写的代码拿到 JDK 8 环境上跑报错后一脸懵其实看一眼版本号段就全明白了。注意javap 本身的版本最好不低于 class 文件的版本否则解析会报错。跨版本解析时优先用高版本 JDK 里的 javap兼容性更好。3.2 常量池整份文件的符号字典常量池是整个 class 文件的“通讯录”后面所有指令里的#7、#8都是在这本通讯录里查号。 javap 输出以Constant pool:开头数量标注在紧接着的那一行例如Constant pool: #1 Methodref #12.#35 // java/lang/Object.init:()V #2 Class #36 // Demo #3 Methodref #37.#38 // java/util/Arrays.asList:...看常量池要把握三个关键点。第一编号从 1 开始没有 #0。0 被 JVM 内部当成“无引用”的哨兵值编译器给常量池分配编号时也刻意避开它。新手最容易在这上面犯迷糊明明看到#0出现在某些指令里别慌那不是常量池项而是“没有”的意思。第二各种类型的常量之间有关联关系。Methodref会指向Class和NameAndTypeNameAndType又指向两个Utf8一层一层套下去。你在 javap 里看到#12.#35这种写法意思是“第 12 号常量和第 35 号常量共同描述了这个方法”顺着编号跳转就能还原出完整引用。第三字符串字面量在常量池里会出现两次一次是CONSTANT_String_info一次是CONSTANT_Utf8_info。前者表示这个字符串是什么后者存实际内容。有人统计过为什么hello在常量池里占两条因为 JVM 需要区分“String 对象引用”和“原始字符数据”这是规范设计不是重复浪费。3.3 访问标志、类信息与字段表访问标志区就在常量池之后javap 里显示为一行flagsflags: (0x0021) ACC_PUBLIC, ACC_SUPER这个十六进制值由一组位掩码组合而成0x0021就是ACC_PUBLIC0x0001和ACC_SUPER0x0020相加。值得一说的是ACC_SUPER它在现代 JVM 里已经不是字面意思了而是所有新编译类的固定常客主要用于配合invokespecial语义。你要是看到ACC_SUPER也不用想得太玄就把它当成“这个类遵守新语义”的标志即可。紧接着是this_class、super_class、interfaces然后是字段表和方法表。字段表里最值得关注的是ConstantValue属性例如我们 Demo 里的MAXprivate static final int MAX; descriptor: I flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL ConstantValue: int 9static final的基本类型/字符串常量会由编译器直接内联这就是为什么很多常量在字节码里不通过getstatic读取而是直接出现在指令里。读字段表时我习惯对照源码想一遍哪些常量被内联了、哪些字段只是声明没初始化一眼就能看出编译器优化痕迹。4. 方法体的字节码操作数栈、局部变量表与指令流class 文件的骨架是常量池、字段表、方法表但真正决定程序行为的是每个方法方法体里的那串指令。这才是字节码的“内核”。4.1 操作数栈和局部变量表理解一切的地基javap -v 输出里每个方法都会有一套Code属性开头几行固定长这样Code: stack4, locals3, args_size1这三个数字的含意是方法执行时操作数栈最多需要 4 个槽位局部变量表需要 3 个槽位方法参数占用 1 个槽位。注意locals算上了隐藏的this——对实例方法this永远占据局部变量表的第 0 号槽位。所以filterNames(int minLen)的args_size明明是 1但 locals 是 2因为有一个隐式的 this 参数。看字符串拼接时为什么总能看到一个StringBuilder本质就是编译器在操作数栈上分配了一块“工作台”先把拼接内容一个一个压上去再统一调用toString取结果。局部变量表相当于“寄存柜”操作数栈相当于“流水线”把代码想象成一个工人在操作台上搬东西字节码就没那么抽象了。几个与槽位相关的细节long和double占两个槽位这是 JVM 规范里的硬性规则。局部变量先放参数再放局部变量顺序可以复用方法里局部变量作用域结束后的位置可能被新的变量顶替。指令中的aload_0是“加载第 0 号槽位的引用”在实例方法里就是 this所以你会看到每个实例方法的指令流开头基本都是aload_0。4.2 指令流里的高频套路看多了字节码后你会发现方法体里的指令就那几个固定组合反复出现。最常见的是对象创建。Java 里new Foo()在字节码里不是一行而是三件套new #20 // class Foo dup invokespecial #21 // Method Foo.init:()V为什么new之后要来个dup因为new把刚创建对象的引用压入操作数栈dup复制一份一份留给init构造器用一份保留给后续的astore存到局部变量。如果你在字节码里看到某个对象只有newastore没有invokespecial那大概率是编译器在搞半初始化对象这也常是“安全发布”问题的一个排查突破口。其次是字段读写。读实例字段用getfield写用putfield静态字段则是getstatic/putstatic。javap 输出会在指令后面用注释标出对应的字段符号引用比如getfield #7 // Field names:Ljava/util/List;这条注释读起来就是“取出当前对象的 names 字段类型是 List”。扫方法体时优先看这类注释能快速确认方法到底碰了哪些字段。再就是类型转换。字节码里有i2l、l2d、checkcast这些指令分别对应 int 转 long、long 转 double、引用类型强转。checkcast尤其要留意——它对应 Java 里的(Foo) obj强转而 JVM 强制转型失败时抛出的ClassCastException就发生在这个指令上。看栈轨迹时行号明明停在源码那一行但你们真正挂掉的是 checkcast 指令这个对应关系要心里有数。4.3 五个方法调用指令的差异方法调用是字节码里信息量最大的指令之一。JVM 一共有五种调用指令相互之间不能混用。指令用途示例invokestatic调用静态方法Integer.valueOf、工具类的静态方法invokespecial调用构造器、私有方法、super 方法init、private方法invokevirtual调用实例方法按实际类型分派重写方法、普通 public 方法invokeinterface通过接口引用调用方法List.stream、Runnable.runinvokedynamic动态方法调用由引导方法决定目标Lambda、字符串拼接JDK 9最容易被忽略的细节是invokevirtual和invokeinterface在执行层面并不一样。前者在类继承体系中查找目标方法后者要在接口方法表里再走一层早期 JVM 上接口调用性能略逊一筹。虽然现代 JVM 优化得已经很好但面试里问“为什么实现接口调用可能比继承调用慢”答案的根源就在这两个指令的设计差异上。invokedynamic则是个异类。它的真正目标方法是运行时才决定的依靠常量池里的InvokeDynamic条目和BootstrapMethods属性。Java 8 的 Lambda、Java 9 之后的字符串拼接都复用了这条指令。看到它别慌张结合引导方法BootstrapMethod去理解就行。4.4 异常表不是“异常”的意思方法体里除了指令还有一张Exception table。名字听起来像是“抛出异常列表”实际作用却是“异常处理器跳转表”。javap 输出长这样Exception table: from to target type 0 16 19 Class java/lang/Exception 0 16 19 anyfrom和to是指令偏移区间表示哪些指令可能触发异常target是异常处理器的指令偏移type是捕获的异常类型。一旦在[from, to)区间内抛出匹配异常JVM 就跳转到target继续执行。finally块在异常表里会特别有意思。编译器会把finally代码复制出多份一份放在正常路径末尾一份作为异常处理器所以你在异常表里会看到好几条any类型的条目——any表示捕获所有异常然后再执行 finally 块逻辑。看异常表时看到any条目基本可以断定这里有 try-finally 或 try-with-resources。4.5 同步方法在字节码里长什么样同步有两种实现方式在字节码层面差别很大。用synchronized修饰的方法方法 flags 里会出现ACC_SYNCHRONIZED调用线程持锁/释放锁完全由虚拟机根据方法属性完成方法体内没有任何锁指令。而synchronized代码块则是显式的monitorenter ... 临界区逻辑 ... monitorexit值得警惕的是正常路径上会有一条monitorexit释放锁异常路径上还有一条monitorexit保证锁能释放。所以你在同步块字节码里经常能看到两处monitorexit一个对应 try 之后的 finally一个对应异常处理器。手动改字节码时漏掉异常路径的monitorexit会导致死锁这是动态字节码生成里最容易踩的坑之一。4.6 方法签名泛型擦除与 Signature 属性回到 Demo 里的max方法javap -v 中它的描述符是descriptor: (Ljava/lang/Comparable;Ljava/lang/Comparable;)Ljava/lang/Comparable;但源码里明明是T extends ComparableT返回值是 T 而不是Comparable。这就是泛型擦除的直接证据字节码的运行期描述符里类型变量 T 被替换成了它的上界Comparable。不过磨平的信息并没有完全消失。JVM 规范在方法或字段的属性表里留了Signature属性专门存储源码里的泛型签名Signature: #31 // T:Ljava/lang/ComparableTT;;(TT;TT;)TT;这就是为什么你在运行时用method.getGenericReturnType()还能拿到T而用method.getReturnType()只能拿到Comparable。看字节码时如果发现描述符和印象不一致一定往下看一眼 Signature 属性很多“反射结果跟源码对不上”的诡异问题答案都在这里。5. 语法糖在字节码面前的真实模样看字节码最上头的部分就是看编译器怎么把你的“随手一写”翻译成繁琐的指令序列。读完下面几个例子你会对现代编译器的优化方向有一个非常直观的感受。5.1 字符串拼接不同 JDK 版本写法完全不一样写个最简单的拼接方法public class ConcatDemo { public String build(int num, String suffix) { return value: num suffix; } }在 JDK 8 上反编译方法体里能看到一长串StringBuildernew StringBuilder dup invokespecial StringBuilder.init ldc value: invokevirtual StringBuilder.append iload_1 invokevirtual StringBuilder.append aload_2 invokevirtual StringBuilder.append invokevirtual StringBuilder.toString这就是网上流传的“字符串拼接别用 底层是 StringBuilder”一说的来源。但在 JDK 9 之后同一个方法编译出来只有一行核心指令invokedynamic // InvokeDynamic #0:makeConcatWithConstants真正拼接逻辑跑到常量池的BootstrapMethods属性里由StringConcatFactory决定怎么高效拼接。所以现在面试官如果说“用 拼接会创建一堆 StringBuilder”那是在拿 JDK 8 之前的旧经验说话了。新版 JDK 下字符串拼接死活用不上 String 的一些性能优化比如常量折叠编译出来直接就是一次动态调用。看字节码最大的收获之一就是能纠正这种“版本过时”的认知。拿到任何一份字节码先看它是哪个 JDK 版本编译的再判断某一项优化是否存在不然你容易拿旧印象套新代码。5.2 Lambda被 invokedynamic 包装得干干净净回过头看我们 Demo 里的filter(s - s.length() minLen)。它在字节码里是invokedynamic #9, 36 // InvokeDynamic #0:test:(I)Ljava/util/function/Predicate;这条指令的描述符是(I)意思是 lambda 表达式捕获了外部变量minLen。你写的是“一个只有参数 s 的 lambda”但编译器闭包捕获了外部的 int所以调用点就得额外携带这个变量。这就是 Kotlin/Scala 那些函数式写法在 JVM 上跑起来并不免费的原因之一——捕获变量意味着每次调用点都要传参甚至可能创建新的 lambda 实例。完全不捕获外部变量的 lambda 则不同调用点描述符是空参数列表底层可以复用同一个“单例” lambda生成成本低很多。写代码时如果你能减少 lambda 对外部变量的依赖往往能顺带省掉一部分分配开销。想知道自己的 lambda 属于哪种直接把编译后的 class 拖进 jclasslib看invokedynamic调用点的描述符就一目了然。5.3 try-with-resources 的隐藏处理JDK 7 引入的 try-with-resources 堪称“资源关闭语法糖”但它在字节码里一点也不糖。编译后每个资源对象的close()调用会被放进 finally 逻辑同时编译器还会生成一段“抑制异常”的处理若 try 块本身抛出了异常而在关闭资源时又发生异常编译器用addSuppressed把后一个异常附加到前一个异常上。在字节码层面你会看到异常表里出现 extra 条目以及成堆的checkcast和invokevirtual Throwable.addSuppressed。看这种代码的字节码我的建议是不要试图逐指令读懂先看异常表有几个区间再看有没有addSuppressed调用就能确认资源关闭的“主从异常”链路是否按预期生成。这也解释了为什么 try-with-resources 代码被反编译后看起来很啰嗦——那不是反编译工具的锅是编译器为了正确性必须这样做。5.4 自动拆装箱真正的性能刺客自动装箱在源码里毫无存在感在字节码里却是明明白白的方法调用。Integer i 42;会变成bipush 42 invokestatic Integer.valueOf:(I)Ljava/lang/Integer;int n i;则变成invokevirtual Integer.intValue:()I你写的是“一句赋值”编译器实际做了方法调用。在循环里频繁拆装箱光看源码很容易低估开销但扫一眼字节码valueOf、intValue满天飞任何性能问题都藏不住。看字节码时盯着这些非业务语义的调用往往比盯着业务逻辑更有价值。6. 字节码查看避坑实录常见问题与经验读字节码本身不难难的是在实战里遇到各种“看起来不该出现”的现象时能稳住。这里把我踩过和见过的问题整理成速查希望能帮你省掉一些弯路。6.1 javap 看不到行号和局部变量表如果你执行javap -v发现动态生成类中完全没有LineNumberTable和LocalVariableTable先别怀疑工具。这两个属性受javac -g参数控制。默认情况下 javac 只生成部分调试信息一些构建工具为减小体积还会主动关闭。想拿到完整的局部变量表编译时加-g某些场景你还会遇到只有行号没有局部变量表的情况那通常是-g:lines的配置。排查线上堆栈需要行号信息时这个参数比代码本身更关键。6.2 桥接方法同名方法到底从哪里来给一个简单子类public class StringList extends ArrayListString { Override public boolean add(String e) { return super.add(e); } }用javap -p看你会吃惊地发现两个add方法public boolean add(java.lang.String) public boolean add(java.lang.Object)第二个方法就是编译器生成的桥接方法打了ACC_BRIDGE和ACC_SYNTHETIC两个标志。原因在于 JVM 方法调用是按描述符精确匹配的ArrayList 里的方法是add(Object)而子类想重写它却又声明了add(String)为了保持多态一致性编译器生成一个add(Object)来代理add(String)。这类桥接方法在反射、Spring 动态代理中很常见如果你在做切面匹配时看到“方法明明匹配了但参数类型对不上”多半是桥接方法在捣乱。解决办法一句话匹配方法时优先看method.isBridge()或者把ACC_BRIDGE标志过滤掉。6.3 生成字节码时的 VerifyError 和 StackMapTable做字节码增强比如 ASM、CGLIB时你可能会遇到java.lang.VerifyError: Expecting a stackmap frame at branch target这个错误和类文件里的StackMapTable属性强相关。从 Java 7 开始JVM 加载 class 时会用类型检查验证器核对栈帧类型而二进制的StackMapTable就是编译器为验证器准备的地图。动态生成的字节码如果只拼了指令漏了异常分支、跳转目标的栈帧信息就会触发上面的错误。看字节码时StackMapTable虽然看起来面目狰狞但它关系到类能不能被顺利加载尤其做 APM、全链路监控之类的字节码工程时几乎天天跟它打交道。6.4 版本不匹配与工具配套工作中常有这种场景从服务器拉回来一个 class拿本地 JDK 8 的 javap 去解析提示版本不支持。这不代表文件损坏而是 class 主版本号比你本地的 javap 高。最简单的是用高版本 JDK 的 javap 或直接将相关 jar 反编译成源码看。另一种常见情况是服务器的运行时用了动态代理生成类磁盘上的类文件与 JVM 里的类实现不一致。遇到这种别纠结于本地文件回到 2.3 节用 Arthas dump 真实类。6.5 我的日常排查清单最后分享一份我读字节码时的固定检查顺序供你参考看 major version确定编译与运行环境兼容性。扫一遍常量池确认关键类引用和字符串是否合理。打开目标方法先看 stack/locals 参数评估方法复杂度。找方法调用指令区分它走的是虚分派、接口分派还是动态调用。看异常表有几段推测是否存在 finally 或 try-with-resources。查有无桥接方法与合成方法确认泛型重写和 Lambda 产物。最后对照源码逐步还原每条指令的用户意图。按照这个顺序我基本能在几分钟内把任何陌生 class 的核心行为摸清楚。注意读字节码时务必把 javap 输出里的行号注释和真实源码行号区分开。行号表记录的是“字节码指令对应源码第几行”但编译器可能把一行代码拆成多条指令或多行代码合并所以不要用“字节码行号多”去反推“源码一定复杂”。回到开头那个 Demo你现在再拿 javap 去看应该不会再两眼一黑。它是 List 字段、Lambda 捕获、泛型擦除、字符串拼接等知识点的综合练习很适合作为第一个“手动反编译”的学习素材。我个人实操中的体会是字节码这门功夫不需要背指令表只要动手多看几个类把常见套路记熟后面再遇到性能问题、代理问题、诡异反射问题你会比别人多一个“直接看真相”的排查维度。强烈建议你今天就拿自己项目里最常用的那个类试一遍javap 跑一把很多困惑会瞬间清晰。
返回列表