说实话,很多Java开发者在写了好几年代码之后,仍然会有一个感觉:Java源码是看得懂的,但.class文件里的东西就像是另一个世界。我曾经在一个同事面前聊某个框架的实现原理,他随口说了一句“你去看下字节码不就知道了”,我当时愣了半天——因为我是真的没系统看过字节码。后来花了点时间把javap、jclasslib这些工具用熟,才发现原来很多源码层面模棱两可的结论,在字节码面前全都一目了然。
这篇内容适合下面这几类人:准备Java面试、被“String拼接到底怎么编译”这类问题困扰的人;读框架源码想确认编译器到底做了什么的人;在排查线上问题时需要确认某个类是不是真的按预期编译的人。我会把查看Java字节码的完整套路拆开讲清楚,包括命令怎么用、工具怎么选、字节码怎么读,以及几个我实际踩过坑的案例。
1. 我为什么劝你把字节码当成“第二份源码”来看
1.1 编译后的源码有时会“骗人”
先说一个反直觉的结论:你在IDE里看到的Java源码,并不能代表程序真正执行的样子。Java语言为了保证开发效率,提供了泛型、lambda、自动资源管理、字符串重载等语法糖,但JVM并不认识这些语法。javac编译时会把它们“翻译”成更原始的形态,而这个翻译结果才是JVM真正执行的东西。
举几个典型的例子:
List<String>在源码里写得很明确,但字节码里只是一个List,泛型参数在编译后被擦除了。String a = s1 + s2,在JDK 8下会先new一个StringBuilder,再逐个append,最后toString。如果不看字节码,你很难想象一次小小的拼接背后有这么多动作。try (InputStream in = ...)在源码里是一个简洁的代码块,但字节码里会多出异常表和多次close()调用,甚至还有Throwable.addSuppressed的调用。- lambda表达式在源码里是一个箭头,但字节码里全是
invokedynamic和一个编译器生成的lambda$xxx$0静态方法。
这些差异靠肉眼读源码是看不出来的,只有把.class文件翻出来看,才能知道javac到底做了什么。所以我一直跟团队的人说:阅读源码的同时,要把字节码当成第二份源码一起看。源码告诉你设计意图,字节码告诉你实际执行方式。
1.2 字节码是连接Java语言和JVM的桥梁
Java程序的编译链路是:.java文件经过javac编译生成.class文件,.class文件里装的不是机器码,而是JVM的指令集——也就是字节码。JVM加载.class文件后,再通过解释器或JIT编译器把字节码转成当前平台能执行的机器码。
我经常用一个类比来解释这件事:Java语言是产品经理的需求文档,字节码是研发的详细设计文档,而JVM执行字节码就像研发照着详细设计文档去编码实现。只看需求文档,你会漏掉很多实现细节;虽然详细设计文档读起来枯燥,但是最接近事实的那个版本。
对普通开发来说,字节码至少有四个实际价值:
- 面试时不再背结论。比如“字符串拼接底层用的是StringBuilder”“lambda是通过invokedynamic实现的”,与其背下来,不如自己看一眼字节码,一辈子忘不掉。
- 排查问题更精准。如果怀疑某个类没编译干净或者被代理增强过,用
javap打开看一眼,一切真相大白。 - 理解JVM和Java语言之间的边界。知道哪些是编译器做的,哪些是运行时做的,对学习JVM参数、GC、类加载都会有帮助。
- 为写字节码增强工具打基础。像一些APM组件、AOP框架都会直接操作字节码,这块绕不开。
1.3 什么阶段最适合开始看字节码
我的建议是:Java核心语法熟练、理解类和对象、懂异常处理之后,就可以开始接触字节码了。你不需要把《Java虚拟机规范》背下来,只需要了解Class文件的大致结构和常用指令,就足够应付绝大多数场景。
对于纯新手,第一次打开javap -v的输出一定会被吓到,海量的常量池、描述符和指令让人头大。这很正常,你不需要一次性全部看懂。先从最简单的类开始,看构造方法、字段访问、方法调用这三类指令,慢慢扩充。本文后面会给你一条循序渐进的路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种查看字节码的工具,按场景选型
2.1 JDK自带javap:零依赖,最快上手
如果你从来没有看过字节码,我建议从javap开始。javap是JDK自带的类文件反汇编工具,只要装了JDK就能用,不需要安装任何插件。它的特点是稳定、输出格式统一、可以在服务器上直接执行。
javap本质上不是反编译器,它不会把.class变回Java源码,而是把Class文件中保存的字段、方法、属性、字节码指令等内容“翻译”成可读的形式。这反而是它的优势:信息更接近原始Class文件,没有被二次加工。
2.2 IDEA插件jclasslib:图形化看常量池和Code属性
如果你觉得javap的输出太扁平、太单一,推荐安装IDEA插件jclasslib Bytecode Viewer。这个插件用表格和树形结构展示Class文件的各个部分,常量池、接口、字段、方法、属性分门别类,点进去就能看。
jclasslib最大的优势是把“常量池引用”和“Code属性”关联得很清楚。比如说你在字节码里看到getfield #7,在jclasslib里可以直接点#7跳到常量池看具体的字段名和类型描述符。对于学习Class文件格式来说,这个交互方式比命令行直观太多。
2.3 ASM Bytecode Viewer:给写字节码增强的人用
IDEA里还有一个插件叫ASM Bytecode Viewer(以前叫ASM Bytecode Outline)。它的功能比jclasslib更进一步:除了显示字节码,还能直接生成对应的ASM API代码。如果你将来要做字节码增强、写Java Agent、研究AOP底层,这个插件是效率神器。
它会把:
java复制int addAndReturn(int delta) {
count += delta;
return count;
}
编译后的字节码翻译成ASM代码模板,比如mv.visitVarInsn(ILOAD, 1)、mv.visitInsn(IADD)这样的形式。你不需要手动去查指令常量,它已经帮你填好了。
2.4 其他辅助方式:javap -v与线上dump配合
除了上面三种常用工具,还有一个场景需要提一下:线上环境的类可能和本地不一致,或者运行时动态生成了一些类。这时可以配合使用jcmd、HSDB等工具把运行中的类dump出来,再用javap分析。我遇到过本地构造半天说不出问题,最后把线上类dump下来一看,发现是构建产物里有脏数据导致字节码版本不对。这个思路后面会展开讲。
下面用一个表格把常见工具的特点列一下,方便你按场景选择:
| 工具 | 形式 | 最大优点 | 最适合场景 |
|---|---|---|---|
| javap | 命令行 | 零依赖、可脚本化 | 快速验证、服务器排查、批量分析 |
| jclasslib | IDEA插件 | 图形化展示常量池和属性 | 初学Class文件格式、逐项阅读 |
| ASM Bytecode Viewer | IDEA插件 | 生成ASM代码模板 | 字节码增强、写Agent |
| jcmd/HSDB | 运行时工具 | 获取JVM中的真实类信息 | 线上分析、动态类检查 |
3. javap命令拆解:从-classpath到-v的完整套路
3.1 javap的常用参数及注意事项
javap的命令格式是:
bash复制javap [options] <classname>
注意classname要写类的全限定名,或者直接给.class文件的路径。最常用的参数是这几个:
-p:显示所有类和成员,包括private成员。不加-p时,javap默认只显示public、protected和包内可见的成员。很多新人第一次跑javap ClassName发现字段都没了,就是没加-p。-c:反汇编方法体,输出字节码指令。这是最常用的参数。-v(或-verbose):输出完整信息,包括常量池、行号表、局部变量表、异常表等。信息量很大,但排查问题时最有用。-s:打印内部类型签名(Signature),比如泛型签名,这个和字段描述符是两回事。-constants:显示static final常量值。-classpath:指定类搜索路径。如果类依赖了其他jar包,可以通过这个参数保证javap能加载到依赖。
实际工作中,我一般只看两类输出:小问题用javap -c -p,需要分析完整结构或者看常量池时用javap -v -p。javap -v的输出非常长,建议先用javap -v -p重定向到文件再看:
bash复制javap -v -p BytecodeDemo > bytecode.txt
然后用编辑器打开慢慢翻阅。直接在终端上看,很容易被刷屏刷得找不到重点。
3.2 一个真实栗子:编译并反汇编Demo类
下面用一个最简单的类演示完整流程。新建BytecodeDemo.java:
java复制public class BytecodeDemo {
private int count = 0;
public int addAndReturn(int delta) {
count += delta;
return count;
}
}
编译并查看字节码:
bash复制javac BytecodeDemo.java
javap -c -p BytecodeDemo
输出大概是这样的(不同JDK版本注释略有差异):
text复制public class BytecodeDemo {
private int count;
public BytecodeDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #7 // Field count:I
9: return
public int addAndReturn(int);
Code:
0: aload_0
1: dup
2: getfield #7 // Field count:I
5: iload_1
6: iadd
7: putfield #7 // Field count:I
10: aload_0
11: getfield #7 // Field count:I
14: ireturn
}
这里能看到很多有意思的细节:
aload_0表示把局部变量表中第0个slot(在非静态方法里就是this)压入操作数栈。getfield #7中的#7是常量池索引,指向count字段。(count + delta)这个复合操作被拆成了dup、getfield、iload_1、iadd、putfield五步。ireturn表示返回一个int值。注意:addAndReturn里return count又执行了一次getfield,字节码里对count字段一共读取了两次。如果某个并发场景下同一个字段被改了,你在字节码层面能看到第二次读取可能拿到不同的值,这对理解线程安全问题是很有帮助的。
如果只写javap BytecodeDemo,你会看到简洁的成员签名列表,没有字节码指令。很多人误以为这就是javap的全部能力,所以一定要记住-c才是反汇编核心参数。
3.3 两个容易踩的坑:默认不看私有成员、本地代码块被优化
第一个坑是-p参数。假设BytecodeDemo里有一个private void secret()方法,不加-p时javap不显示它。我在排查一个框架问题时曾经只看public方法,差点以为编译器把某个私有方法删掉了,后来才发现是javap默认行为。
第二个坑和编译参数有关。如果不带-g参数编译,生成的Class文件里就不会保存局部变量表(LocalVariableTable)里的变量名信息。这样用javap -v查看时,只能看到arg0、arg1这种占位符,或者干脆看不到某些调试信息。这不是字节码丢了,而是编译时没有生成调试信息。用IDE构建工具时通常在Debug模式下会带调试信息,而写脚本手动javac时容易忽略。
还有一个我在初学时常犯的认知错误:以为javap会加载外部依赖。实际上javap只是静态解析Class文件,它不需要把引用的类加载进来。哪怕类里引用了一个不存在的类型,javap依然可以正常输出,因为它只是把常量池里的符号引用打印出来,不会去做类解析。这一点和Java运行时的类加载机制不一样,理解这个区别对排查ClassNotFoundException的问题有帮助。
4. 字节码阅读基本功:方法描述符与常用操作码
4.1 常量池和描述符:Class文件的自描述部分
javap -v输出的第一大部分是常量池(Constant Pool),这是Class文件的“字典”。所有的类名、方法名、字段名、字符串字面量都存放在这里,字节码指令通过索引引用它们。
要读懂常量池和指令,必须先认识类型描述符。JVM用一套固定的缩写表示Java类型:
| 描述符 | 含义 |
|---|---|
I |
int |
J |
long |
F |
float |
D |
double |
Z |
boolean |
B |
byte |
C |
char |
S |
short |
V |
void |
[I |
int数组 |
[Ljava/lang/String; |
String数组 |
Ljava/lang/String; |
String类 |
方法描述符用小括号表示参数列表,返回值放在括号后。举个例子:
()V:无参数,返回void。(ILjava/lang/String;)Ljava/lang/String;:参数为int和String,返回String。([Ljava/lang/String;)V:参数为String数组,返回void,这正是main方法的形状。
在javap -v输出的方法信息中,你会看到descriptor: (I)V这样的字段。在字段信息中则直接看到descriptor: I或descriptor: Ljava/lang/String;。
字段还有一个容易混淆的东西叫Signature(类型签名),它包含泛型信息。字段描述符Ljava/util/List;在泛型版本里对应Signature: Ljava/util/List<Ljava/lang/String;>;。描述符是给JVM用的,签名是给编译器做泛型检查用的。所以你知道泛型擦除了,但擦除后泛型信息并没有完全消失,而是保存在Signature属性里,通过反射依然可以拿到。
4.2 JVM指令分类:加载、存储、调用、返回
字节码指令有几百个操作码,但实际常用的核心指令是有限的。按功能可以分成几组:
- 常量入栈:
iconst_0、bipush、ldc。作用是把一个常量压到操作数栈上。ldc通常用来加载字符串或Class常量池项。 - 局部变量加载与存储:
iload_0、aload_1、istore_2、astore_3。i前缀表示int类型,a前缀表示引用类型。后面跟的数字是局部变量表中的slot索引。索引0在实例方法中通常是this。 - 操作数栈操作:
dup(复制栈顶值)、pop(弹出栈顶值)、swap。new对象后经常跟着dup,因为构造方法需要消耗一个引用,而new的结果还要作为后续调用结果保留一份。 - 对象和字段:
new、getfield、putfield、getstatic、putstatic。 - 方法调用:
invokevirtual用于普通实例方法调用(支持多态);invokespecial用于构造方法、私有方法和super调用;invokestatic用于静态方法;invokeinterface用于接口方法;invokedynamic是JDK 7引入的动态调用指令,lambda和字符串拼接(JDK 9之后)都走它。 - 返回:
ireturn返回int、lreturn返回long、freturn返回float、dreturn返回double、areturn返回引用、return返回void。
操作数栈可以理解为JVM执行方法时的“临时工作台”。JVM是一个基于栈的虚拟机,绝大多数指令都是把数据压入栈、从栈取出、或者对栈顶数据做运算。例如iadd就是弹出两个int压入栈顶,相加后再把结果压回栈顶。读字节码时,脑子里始终有“当前操作数栈里有几个值”这根弦,就不会觉得指令是乱序的。
我至今记得第一次用生活类比解释操作数栈的场景:它就像自助餐的取餐台,你把菜(数据)放到托盘上,厨师(JVM)从托盘取用料加工,再把成品放回托盘。后面的指令再从这个托盘取东西。
4.3 LineNumberTable与LocalVariableTable:调试信息和字节码的对应
javap -v中每个方法除了Code属性,还有LineNumberTable和LocalVariableTable,它们分别保存代码行号与局部变量的映射。
LineNumberTable的常见形式:
text复制LineNumberTable:
line 5: 0
line 6: 4
line 7: 10
这表示字节码偏移为0的指令对应Java源码第5行,偏移为4的指令对应第6行,依此类推。Java在抛出异常时输出的堆栈行号,就是根据这个表推算出来的。如果编译时被刻意去掉了调试信息,异常堆栈就只能显示“Unknown Source”,这对排查线上问题非常不友好。
LocalVariableTable的常见形式:
text复制LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this LBytecodeDemo;
0 9 1 delta I
可以看到:变量delta在slot 1中,类型是int,作用域从字节码偏移0开始持续9个长度单位。你在源码里为参数起什么名字,编译后会记录在这个表里。结合Slot推断,也能看出为什么静态方法里没有this(slot 0是参数),为什么内部类构造时会多一个外部类引用参数。
5. 三个必看的实战案例:String拼接、try-with-resources、lambda
5.1 案例一:字符串拼接在JDK 8和JDK 17下的字节码差异
几乎所有Java面试题都会聊String拼接,但很多人是背答案,没有亲眼看字节码。先写一个简单的拼接方法:
java复制public class ConcatDemo {
public String concat(String a, String b) {
return a + b;
}
}
在JDK 8下编译,javap -c输出:
text复制public java.lang.String concat(java.lang.String, java.lang.String);
Code:
0: new #7 // class java/lang/StringBuilder
3: dup
4: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
7: aload_0
8: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
11: aload_1
12: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
18: areturn
可以看到,JDK 8的javac会把a + b编译成:创建StringBuilder、连续两次append、最后toString。new后面接dup是因为invokespecial <init>会从栈顶取走一个引用用于初始化,同时后续的invokevirtual需要一个引用,所以new的引用要先复制一份。
如果换到JDK 17下编译,同样的源码出来的字节码完全不同:
text复制public java.lang.String concat(java.lang.String, java.lang.String);
Code:
0: aload_0
1: aload_1
2: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
7: areturn
JDK 9开始,字符串拼接改成了invokedynamic,运行时由StringConcatFactory选择拼接策略。这样做的用意是:编译期不再生成固定的StringBuilder代码,而是把拼接逻辑推迟到运行时,JVM可以根据目标平台和实际使用情况动态优化,比如直接计算长度、避免中间对象。这也是为什么不要在新JDK上根据源码猜测性能,直接看字节码才是最准的。
不过要注意:如果拼接的所有字符串都是编译期常量,那javac会在编译期直接折叠成一个字符串,字节码里只有一行ldc,根本不调用StringBuilder。比如String s = "a" + "b" + "c",编译后是String s = "abc"。
5.2 案例二:try-with-resources的异常抑制是怎么用代码实现的
再看一个经典场景。用一段简单的try-with-resources代码:
java复制import java.io.FileInputStream;
import java.io.InputStream;
public class TwrDemo {
public void read(String path) throws Exception {
try (InputStream in = new FileInputStream(path)) {
in.read();
}
}
}
如果你用javap -v查看read方法,会发现它比源码里看到的复杂得多。编译后的代码大致做了这些事:
- 创建
FileInputStream并保存到局部变量。 - 调用
in.read(),这是try块主体。 - 无论try块是否正常结束,都会执行
in.close()。 - 如果try块抛出了异常,在关闭资源时又发生了异常,编译器会调用
Throwable.addSuppressed(Throwable)把关闭异常附加到主异常上。
异常表(Exception table)里会显示多条记录,大致形式如下(不同JDK版本生成的条目略有不同):
text复制Exception table:
from to target type
11 16 9 Class java/lang/Throwable
11 16 39 any
9 39 39 any
第一次看到这个表可能会懵,但结合语义就明白了:第一条记录表示in.read()如果在偏移11到16之间抛出异常,跳到偏移9处把异常保存下来;第二条记录表示不管抛什么异常,都要走偏移39的清理逻辑;第三条记录表示清理逻辑本身如果失败,直接处理新异常。
这就是“try-with-resources为什么能抑制close异常”的底层答案。源码只是薄薄一层语法糖,真正实现异常抑制的是编译器生成的异常处理和addSuppressed调用。面试时如果能把字节码层面这个表解释清楚,和普通背答案的人立刻就拉开了差距。
这里顺便提一个容易踩的坑:不同javac版本生成的try-with-resources字节码细节可能不一样。JDK 8、JDK 11、JDK 17都有自己的优化策略。你拿高版本编译器生成的字节码去对照老版本的面试题答案,可能会发现对不上,这不代表谁错了,而是编译器在演进。遇到这种情况,以实测为准。
5.3 案例三:lambda表达式在字节码层面变成了什么
lambda是另一个字节码和源码差异巨大的语法糖。写一个最简单的方法:
java复制public class LambdaDemo {
public void run() {
Runnable r = () -> System.out.println("hi");
r.run();
}
}
用javap -c -p LambdaDemo看,会发现几个重要信息。
先看run方法的字节码:
text复制public void run();
Code:
0: invokedynamic #7, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #8, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
再看类的底部,编译器自动生成了一个额外方法:
text复制private static void lambda$run$0();
Code:
0: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #12 // String hi
5: invokevirtual #13 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
这揭示了lambda的底层实现:lambda表达式体被编译器提取成一个私有的静态方法,方法名类似lambda$run$0;run方法里通过invokedynamic调用LambdaMetafactory.metafactory,在运行时动态生成一个实现了Runnable接口的对象,这个对象内部持有对lambda$run$0的引用。
所以,lambda不是在编译期生成一个匿名内部类,而是在运行时通过invokedynamic延迟生成。这也是为什么lambda捕获取值时会要求变量是effectively final——invokedynamic引导方法在生成实现对象时,需要把捕获的参数作为常量或参数传递,编译器对局部变量的生命周期做了严格约束。
顺带说一句:每次执行到invokedynamic时,JVM会缓存CallSite,所以lambda对象的生成开销不会像想象的那么高。如果你想验证,可以在字节码里看到LambdaMetafactory的调用,也可以在运行时通过-Djdk.invoke.LambdaMetafactory.dumpProxyClassFiles把动态生成的类dump出来看。
6. 进阶方向:泛型桥方法、版本号与Class文件格式
6.1 泛型擦除后的桥方法,藏得很深的编译器生成物
泛型擦除是一个老生常谈的话题,但桥方法很多人没见过。来看一个经典的继承场景:
java复制public class Parent {
public Comparable get() {
return "parent";
}
}
public class Child extends Parent {
@Override
public String get() {
return "child";
}
}
Child的get()返回String,而Parent的get()返回Comparable,因为String实现了Comparable<String>,所以这是合法的协变返回覆盖。但是,编译后的字节码里JVM并不知道泛型关系,它只知道Parent.get()的返回类型是Comparable。为了保证多态,编译器在Child里生成了两个get()方法。
用javap -p Child查看:
text复制public java.lang.String get();
descriptor: ()Ljava/lang/String;
public java.lang.Comparable get();
descriptor: ()Ljava/lang/Comparable;
flags: (0x0001) ACC_PUBLIC, (0x1000) ACC_BRIDGE, (0x1000) ACC_SYNTHETIC
Code:
0: aload_0
1: invokevirtual #7 // Method get:()Ljava/lang/String;
4: areturn
返回Comparable的get()方法同时带有ACC_BRIDGE和ACC_SYNTHETIC标志,说明这是编译器生成的桥方法。它的逻辑很简单:把调用转发到真正返回String的get()方法。这个桥方法的作用,就是让Parent类型的引用调用get()时,能够正确走到Child.getString()的实现。
很多Java面试题会问“桥方法的原理”,直接把这个字节码结果贴出来,比背任何结论都有说服力。它也是理解“泛型擦除不破坏多态”的关键。
6.2 字节码版本号:为什么会有UnsupportedClassVersionError
Class文件开头有一组固定字节:CA FE BA BE,也就是十六进制的魔数,表示这是一个Class文件。魔数之后是minor version和major version两个两字节的版本号。javap -v输出开头会直接显示:
text复制Classfile /path/to/BytecodeDemo.class
Last modified ...
SHA-256 checksum ...
Compiled from "BytecodeDemo.java"
public class BytecodeDemo
minor version: 0
major version: 61
major version和Java版本的对应关系是:52对应Java 8,55对应Java 11,61对应Java 17,65对应Java 21。高版本编译出来的Class文件,放在低版本JVM上运行,会抛出UnsupportedClassVersionError,本质就是JVM发现major version超出了自己支持的版本范围。
排查这个问题最快的方式就是用javap -v看版本号,或者用十六进制工具直接看文件头。我遇到过几次线上启动报错,本地开发用的都是JDK 17,生产环境却还是JDK 8,javap一查major version: 61,立刻定位到构建和运行环境不一致的问题。这类问题花在猜上面的时间,远多于实际解决的时间。
6.3 后续学习路径建议:往Class文件规范和ASM方向深入
如果看完这些案例,你对字节码产生了兴趣,我建议按这个顺序继续学习:
- 先把
javap -v -p的完整输出对着《Java虚拟机规范》第4章“The class File Format”读一遍,重点看常量池、字段表、方法表、Code属性这些部分。看不懂没关系,只要把结构和javap输出对起来,就建立了基础。 - 接着看第6章的指令集描述,不需要背操作码,只需要知道哪里有查,能根据操作码找到含义就行。
- 再做一个小练习:写一个Java类,用各种语法特性(泛型、lambda、内部类、try-with-resources、switch表达式等),然后逐一用
javap -v观察javac生成的字节码。 - 如果想继续做字节码增强,可以学ASM和Byte Buddy。ASM是操作字节码的库,Byte Buddy是在ASM之上封装的更高层API。很多APM工具和AOP框架底层都使用它们。
我个人的学习体会是:字节码并不难,难的是第一次面对海量信息时的心理压力。它就像一张城市地图,一开始觉得每条路都一样,但当你把几个地标(描述符、操作数栈、异常表)记住之后再上路,整个城市就变得有秩序了。从javap开始,别急着追求速度,每次只看一个方法,把指令一行行读明白,积累几个案例之后,你会发现自己分析类的节奏完全不一样了。
最后分享一个小技巧:在IDEA的Settings里,把javap -v -p配置成一个External Tool,快捷键一键对当前编译后的Class执行,省去切终端的麻烦。配好之后,每写一个类都可以顺手看一眼字节码,这种“随手体检”的习惯,比刻意专门学效率要高得多。
