1. 字节码文件到底是个什么东西
1.1 名字里藏着的两个概念
“字节码文件”这个叫法,其实把两个层面的东西揉在了一起。第一个层面是“字节码”本身,指的是 JVM 能够识别的、介于源代码和机器码之间的一种中间指令格式;第二个层面是“文件”,也就是这些指令以二进制形式固化到磁盘后的产物,典型的就是 .class 文件。准确一点说,字节码是文件的“内容语义”,文件是字节码的“物理载体”。
很多人第一次接触这个概念时会愣一下:既然代码最终要跑在操作系统上,为什么不直接编译成机器码,非要中间多出这么一层?这个问题我后面会专门展开。
先说个最直观的印象。写一句 Java 代码:
int a = 1; int b = 2; int c = a + b;编译之后,这一小段逻辑在 .class 文件里是被拆成一条条助记符指令的:
iconst_1 istore_1 iconst_2 istore_2 iload_1 iload_2 iadd istore_3看见了吗,源代码里的赋值和加法,在这里变成了像“iconst”“istore”“iload”“iadd”这样的短单词。这就是字节码指令的助记符。每一个助记符在真实文件里对应一个数字,JVM 靠这些数字去识别该执行什么操作。你如果在终端里用 hexdump 之类的工具去看这个文件,看到的会是一堆十六进制数字串。
字节码文件最典型的代表是 Java 编译产出的 .class 文件。不过这个概念不只属于 Java,任何跑在 JVM 上的语言,比如 Kotlin、Groovy、Scala,最后都会被编译成同一种字节码格式,也就是说它们最终都能被同一个 JVM 执行。这也是 JVM 语言生态能繁荣起来的底层原因之一。
1.2 为什么不是直接编译成机器码
这是理解字节码价值的关键问题,也是我在很多场合被问得最多的问题。
机器码是与 CPU 架构绑定的。x86 有 x86 的指令集,ARM 有 ARM 的指令集,同样一条“把寄存器里的两个数相加”的操作,在不同架构下的二进制编码完全不同。如果 Java 像 C/C++ 那样直接编译成机器码,那么同一个 .class 文件就只能在一种 CPU 上运行,跨平台就成了一句空话。
字节码走的是另一条路:先编译成与架构无关的中间表示,再靠运行时的解释器或 JIT 编译器把它翻译成当前平台的机器码。一句话概括就是“一次编译,到处运行”的本质是把“和平台相关的部分”推迟到了运行时去解决。
这么说可能还不够具体。我给你打个比方。一份会议纪要,如果你用中文写好直接用,那只有懂中文的人能看;但如果你把它先整理成一套规范的结构化条目,比如“时间、地点、人物、决议事项”四栏,那么无论谁来翻译成中文、英文、日文,都只需照着这套条目转换就行。字节码就是这套结构化条目,JVM 就是那个负责翻译成当地语言的人。
代价当然也有。多一层中间表示,就多一次翻译开销,指令密度也不如原生机器码。所以 JVM 才会引入 JIT 编译和各类优化技术,把那些热点方法在运行时编译成优化的机器码,抵消解释执行的性能损失。理解了这一点,你就理解了整个 JVM 性能调优领域为什么存在。
1.3 什么样的人应该认真学一遍
如果你只是写写业务 CRUD,可能几年都不需要直接面对字节码文件。但如果你属于下面这几类人,字节码知识迟早会找上门。
第一类是做框架和中间件的人。很多 Java 生态的增强功能,比如 AOP 动态代理、MyBatis 的接口绑定、Spring 的注解解析,底层都涉及字节码生成或操纵。你不一定写字节码,但你要看得懂生成的字节码长什么样,否则出了问题只能瞎猜。
第二类是搞性能分析和 JVM 调优的人。当你做 CPU 热点分析、火焰图分析时,真正要看清的是方法栈和 JIT 编译后的行为。如果你不懂字节码层面的语义,很多分析结论会是空中楼阁。
第三类是写基础工具的人,比如代码覆盖率工具、链路追踪探针、热部署组件。这些工具的核心思路几乎都是“在字节码层面做埋点或改造”,比如用 ASM 在方法入口插入一段计时逻辑。这个领域的活儿,纯粹是在字节码文件上做文章。
还有一类是想要加深语言理解的人。字节码文件里藏着类加载机制、多态分派、异常表、泛型擦除这些概念的实物证据。学一遍字节码,等于把 JVM 相关的知识从“背概念”升级成了“看实物”,那种认知转变很难用别的方式替代。
2. 字节码文件结构,从魔数到方法体逐段拆解
2.1 前4个字节的魔数与版本号里有什么信息
每个 .class 文件的开头是一串固定不变的十六进制:CA FE BA BE。这四个字节被称为“魔数”。它不是给用户看的,而是给 JVM 做识别用的:当类加载器读入一个文件时,第一件事就是验证前四个字节是不是这个魔数,不是就直接拒绝加载并抛出错误。这么设计的直接原因是,防止有人把一张图片、一个文本文件或者格式错乱的数据当成 class 文件丢给 JVM,造成不可预期的行为。
魔数之后紧接着是次版本号和主版本号,各占两个字节。比如主版本号 52 对应 Java 8,61 对应 Java 17,65 对应 Java 21。这个数字的含义很直接:每个版本的 JVM 只能加载“不高于自己主版本号”的 class 文件。你在 JDK 8 环境里跑一个用 JDK 17 编译的 class 文件,就会遇到那个很经典的报错:
UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime这一段的实际读取逻辑并不复杂。我平时排查这类问题,第一眼不会看代码,而是先看文件的头部,用xxd或者 IDE 自带的 hex 插件就能快速确认主版本号,省得猜来猜去。版本号这一块,信息量不大,但它是很多“为什么我的程序跑不起来”问题的物理根源。
2.2 常量池:整个文件的字典和索引中心
常量池是字节码文件里体量最大、结构最复杂的一块,也是我建议你花最多时间去理解的部分。
为什么要叫“常量池”?因为它就是一张集中存放各类常量的表。类名、方法名、字段名、字符串字面量、类型描述符、方法签名、各种直接引用的符号信息,全部以条目的形式收录在这里。文件里其他地方如果需要引用某个类或某个方法,不会傻乎乎地内嵌一整段名字,而是只保存一个指向常量池的索引编号。
这个设计很像写论文时的“参考文献列表”和正文里的“引用编号”。正文只需要写 [1]、[2],具体是哪个文献去文末查表。
常量池条目的种类很多,常见的有这些:
CONSTANT_Utf8:保存字符串的实际内容,比如类名、方法名、描述符CONSTANT_Class:引用一个类或接口,内部指向一个 Utf8 名称CONSTANT_Fieldref:字段的符号引用,指向类和字段名CONSTANT_Methodref:方法的符号引用CONSTANT_NameAndType:方法或字段的名字和类型描述符
这些条目之间经常是互相引用的状态。一个CONSTANT_Methodref会指向一个CONSTANT_Class和一个CONSTANT_NameAndType,而CONSTANT_NameAndType又指向两个CONSTANT_Utf8。整张表本质上是一个有向引用网络,索引就是网络里的地址。
描述符这个概念需要单独解释一下。它在字节码文件里承担“类型签名”的职责。比如:
I代表 intLjava/lang/String;代表 String()V代表无参数、返回 void(IDLjava/lang/String;)V代表参数分别是 int、double、String,返回 void
JVM 判断方法是否重载、是否能被正确调用,主要靠类名、方法名和描述符三者做匹配,不只是看名字。这也是为什么 Java 里两个方法可以同名但不同参数,因为它们在字节码层面的“全名”实际上是包含描述符的。
2.3 类信息、字段表和方法表是如何组织的
常量池之后,是类文件的骨架信息,也就是访问标志、类索引、父类索引、接口索引集合。
访问标志是个 16 位整数,每一位代表一个开关,比如ACC_PUBLIC表示类是 public,ACC_ABSTRACT表示类是抽象的,ACC_INTERFACE表示这是个接口,ACC_ENUM表示是枚举。一眼看去,定义类时写的修饰符,在这里变成了几个二进制位。
然后是字段表集合和方法表集合。每个字段由三部分组成:访问标志、字段名索引、描述符索引。拿一个常见的字段举例:
private String name;它对应的结构是:ACC_PRIVATE这个访问标志,指向常量池里“name”字符串的索引入口,再指向描述符Ljava/lang/String;。同样地,每个方法也由访问标志、方法名索引、描述符索引,以及后面紧跟的若干属性组成。属性里最核心的就是Code属性。
这里有两点值得注意。第一,字段的初始值不是直接写在字段结构里的,而是放在<clinit>方法(即类初始化方法)的字节码中,由静态初始化块赋值。第二,接口和抽象类的方法是可以只有声明、没有Code属性的,这很正常;但普通类的具体方法如果缺了Code属性,类加载时就会出问题。
2.4 Code属性才是方法真正的灵魂
一个方法真正执行的指令,全部存放在Code属性里。这个属性包含的东西比常人想象的多,不只是指令本身。
首先是max_stack和max_locals两个计数。max_stack表示这个方法运行时操作数栈的最大深度,max_locals表示局部变量表需要多少个槽位。这两个数字是类加载验证阶段的重要参数:运行时分配的栈帧大小由它们决定,深度超标会被直接拒绝。JVM 不是每次执行指令时临时去检查“这里够不够放”,而是在加载阶段就按这两个数把栈帧内存块划定好,运行时完全靠约定去读取,速度才上得去。
然后是code_length和code本体,也就是那条真正存储字节码指令的字节数组。每条指令前面是一个操作码(opcode),后面根据指令不同跟着零到多个操作数。比如bipush 100,操作码是 0x10,操作数是 100;而iconst_1这类指令没有任何操作数,因为数值 1 已经被编码进助记符本身了。
再往下是异常表。异常表以一种结构化方式记录了 try-catch 的范围和跳转目标,每条记录包含 start_pc、end_pc、handler_pc、catch_type 四个部分。start_pc 和 end_pc 表示的是字节码指令的起始和结束偏移量,handler_pc 是异常处理代码的起始位置,catch_type 指向被捕获的异常类型。这个表的意义在于,异常捕获不需要在指令流里做“跳转包裹”,而是一张紧凑的分发表,JVM 抛出异常时去查这张表即可找到对应处理逻辑。
最后是附加属性,比如LineNumberTable记录了字节码指令与源代码行号的关系,StackMapTable在 Java 7 之后用于字节码验证。调试器里能显示第几行出错,靠的就是前者。
3. 字节码指令系统:看懂程序是怎么跑起来的
3.1 用一条iadd看懂操作数栈的运转方式
JVM 的字节码指令系统是基于“操作数栈”设计的,这一点和 x86 芯片那种基于寄存器的指令风格有本质区别。x86 的加法指令通常长这样:两个操作数来自寄存器,结果写回寄存器。但 JVM 的加法指令,比如iadd,做的事情非常朴素:从操作数栈顶弹出一个整数,再弹出一个整数,相加后把结果压回栈顶。
只看文字可能不够直观,我带你完整走一遍前面那个 int a+b+c 的例子。
字节码:
iconst_1 // 把整数常量1压入操作数栈 istore_1 // 从栈顶弹出一个int,保存到局部变量表下标1的位置 iconst_2 istore_2 iload_1 // 把局部变量表下标1的int值压入栈 iload_2 iadd // 弹出两个int,相加,结果压回栈 istore_3 // 把结果保存到局部变量表下标3这个过程中,局部变量表承担的是“变量存储”的角色,操作数栈承担的是“计算临时空间”的角色。所有数据都要先被加载到栈上,经过计算后再次保存回去。这种模型指令数量比寄存器机器码多,但胜在结构统一、易于跨平台实现。
理解操作数栈,建议记住一条:栈有深度上限,这个上限就是Code属性里的max_stack。栈上元素类型必须严格匹配指令预期,比如iadd就只能对 int 生效,不可能把一个 long 和一个 int 直接相加,必须先用l2i之类的转换指令把 long 转成 int。
我实际读字节码时的套路是:先把局部变量表想象成一张贴了编号的表格,把操作数栈想象成一个纸箱子,然后一行一行照着指令往表格里填数、往箱子里放数。整个过程完全机械化,但恰恰因为机械化,才让人看得很清楚。
3.2 方法调用那条多态分派链
方法调用是字节码指令里最核心的一类,因为它们直接和 Java 的多态、继承、接口特性挂钩。
JVM 一共提供了五条方法调用指令:
invokestatic:调用静态方法。解析方式最直接,根据符号引用定位到具体方法,不涉及对象分派。invokespecial:调用私有方法、构造器、super调用。这些方法不能被覆盖,因此也不需要动态分派。invokevirtual:调用实例方法。这是实现多态分派的核心指令,实际调用哪个方法取决于对象的真实类型。invokeinterface:通过接口引用调用方法。由于接口方法的分派需要查询接口方法表,性能略逊于 invokevirtual。invokedynamic:Java 7 引入、Java 8 后大量使用的动态调用指令,Lambda 表达式的实现依赖它。
拿invokevirtual举例。字节码里写的是:
invokevirtual #18这里的 #18 指向常量池里的一个方法符号引用,包含类名、方法名和描述符。但真正执行时,JVM 不能直接根据这个符号引用调用,因为它代表的可能是父类的方法,也可能是某个子类重写后的方法。JVM 会先把符号引用解析成实际方法,然后在运行期依据对象的实际类型去方法表里查找最终应该调用的那个版本。
还有个很容易被忽略的点:编译期的方法重载判定发生在生成字节码的时候。同一个void doWork(Animal a)和void doWork(Cat c),调用点编译出的 invokevirtual 符号引用已经明确指向了哪个重载版本,运行时只做“多态分派”,不做“重载选择”。这个和 C++ 的虚函数表逻辑有某些相似之处,但 JVM 的方法表机制更灵活。
3.3 执行引擎怎么把字节码变成CPU动作
字节码不是直接在 CPU 上跑的,它得经过执行引擎这一关。执行引擎内部有两种主要执行方式:解释执行和编译执行。
最早期的 JVM 是纯解释执行,也就是每条字节码指令都通过一个大的循环去匹配和模拟执行。这种方式启动快,但性能不高。后来引入了 JIT 编译器,特别是 C1、C2 编译器,将热点方法的字节码在运行时编译成当前 CPU 平台的机器码,后续调用直接执行机器码。
从工程角度看,JVM 会为每个方法维护调用计数,计数达到阈值后进入编译队列。编译完成后,方法入口的指令会被替换为跳转到编译后机器码的地址,这个替换发生在方法头部的“入口补丁”上,细节非常精巧。用户感知层面,最明显的是“代码冷热”带来的性能差异:冷启动时大量方法还在被解释执行,热点跑起来后性能才真正释放。
这里有个概念容易绕晕:字节码是“与平台无关”的中间表示,但 JIT 编译后的机器码是“与平台相关”的。同一个 class 文件在不同平台上经过 JIT 编译,生成的机器码完全不同。这也是为什么我强调“平台无关”只是前半程的事,后半程的翻译工作交给了 JVM 和 CPU。
关于执行引擎你只需要抓住一个主线:字节码指令是 JVM 的“程序”,执行引擎是 JVM 的“CPU”,操作数栈和局部变量表是 JVM 的“寄存器与内存”。其他一切细节,都是围绕这条主线展开的。
4. 为什么字节码需要验证,验证到底在防什么
4.1 加载阶段验证的四道关卡
.class 文件不是只能由 Java 编译器生成。任何人只要能按格式手工构造一个文件,它看起来完全可能就是合法的字节码。如果 JVM 不做任何安全检查,恶意构造的字节码就可以绕过 Java 的类型安全约束,比如说谎字段类型、伪造方法访问权限、直接篡改内存。所以 JVM 在类加载阶段安排了一整套验证流程,业内通常称为“验证四连”。
第一关是文件格式验证。魔数、版本号、常量池长度是否符合要求,各种索引是否越界,这部分基本上是把文件名结构和二进制语义对照起来检查。
第二关是元数据验证。检查类与类之间的关系是否合法,比如父类是否被 final 修饰、当前类是否实现了所有抽象方法、继承链路是否有循环。
第三关是字节码验证。这一关最复杂,也是和本文主题最相关的部分。JVM 会通过数据流分析检查每条指令执行前后的栈状态是否一致,局部变量表里的类型是否符合指令预期,跳转目标地址是否有效,最终确认方法不会因为类型混乱导致运行时错误。
第四关是符号引用验证。检查常量池里的类名、字段名、方法名是否能被解析到实际存在的实体,访问权限是否足够。
这四关严格程度与启用的选项有关。某些场景下可以通过-Xverify:none开关跳过第三关,启动速度会提升,但代价是放弃一部分安全保护。正式生产环境我从不建议这么干,只有极少数内部工具在确保输入绝对可信时才会这么做。
4.2 字节码验证为什么会给出VerifyError
VerifyError是平时排查问题很常遇到的一个错误,它意味着字节码验证阶段发现了不合法的指令序列。这里的“不合法”不是语法错误,而是数据流或控制流层面的类型问题。
举个例子,如果有一段指令在某个分支中往操作数栈里压入了 int,而在另一个分支中压入了引用类型,后续用一个期望待定的指令去消费栈顶元素,验证器就会认为这个方法的栈状态不一致,直接抛出VerifyError。
这类错误最常出现在手动修改字节码或者用字节码操纵框架生成代码的场景中。比如用 ASM 往方法里插桩时,如果插入的代码没有仔细设计好栈帧变化,尤其是 try-catch 块之间的栈状态同步,就很容易触发这个错误。
排查这种问题时,没有捷径可走,必须把报错方法反汇编出来,逐条核对操作数栈的使用情况。尤其是要关注是否存在分支合并处栈高度和类型不一致的情况。许多框架作者在发布新版本时都要跑一遍全量插桩测试,就是为了提前发现这类栈帧不一致问题。
4.3 符号引用与直接引用,运行时常量池的转变
字节码里的方法调用、字段访问,在初始状态下存的都是“符号引用”。所谓符号引用,其实就是把目标描述成“类名+方法名+描述符”这样的文本形式,它并不对应任何真实内存地址。
当类成功加载、准备、解析之后,符号引用会被替换为“直接引用”。直接引用就是真正的目标地址,比如某个字段在内存中的偏移量、某个方法的实际入口位置。这一步在解析阶段完成。
这里有个常见误解:以为所有符号引用都会在加载时一次性替换成直接引用。实际上,JVM 的规范允许“懒解析”,也就是等到某条指令第一次使用某个符号时才触发解析。这样设计的目的是缩短启动时间,因为很多方法可能永远都不会被调用到。
运行时常量池就是保存这些符号引用和已解析结果的地方。它是常量池的运行时版本,不过常量池里那些字符串常量本身不会进入到方法区的运行时常量池,因为它们的用途完全不同。如果符号引用解析失败,比如依赖的类没有被打进包,就会抛出类的系列异常,也就是后面要讲到的经典报错。
5. 实战:看懂class文件与排查常见异常
5.1 javap反汇编是怎么工作的
读字节码文件最趁手的工具不是 IDEA,也不是各种在线反编译网站,而是 JDK 自带的javap。它的作用是把 .class 文件里的字节码“翻译”成人类能读懂的助记符形式。
我日常用的命令是:
javap -c -v -p ClassName几个参数的含义说一下:
-c:输出方法体对应的字节码指令-v:输出常量池、版本号、访问标志等详细结构-p:显示 private 成员
如果只想快速看一个方法的主要执行逻辑,只带-c就够了;想彻底研究类文件的每一个字节,必须带-v。输出会非常长,但信息很全。
举一个实际感受。有一个很简单的类文件,javap -c 的输出大致是这样的:
public static void main(java.lang.String[]); Code: 0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #9 // String Hello 5: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return右侧的数字 0、3、5、8 是字节码指令的偏移量,而不是行号。异常表、LineNumberTable 里记录的 start_pc 等,指的都是这类偏移量。阅读 javap 输出时,一定要分清楚“偏移量”“操作码”“操作数”三者的区别。
另外一个容易被忽略的用法是,javap 可以不带-c直接查看类的签名和常量池。这在分析泛型擦除时特别有用。你写一个List<String>,javap 会告诉你签名里确实写着Ljava/util/List<Ljava/lang/String;>;,但方法体内实际访问的全部是 java.util.List 这个裸类型。这个对照是理解泛型擦除最直观的证明。
5.2 UnsupportedClassVersionError不匹配时怎么定位
这是实战里最常见的启动报错之一,完整格式通常是:
java.lang.UnsupportedClassVersionError: SomeClass has been compiled by a more recent version of the Java Runtime它的根本原因是系统启动 JVM 的版本,低于编译这个 class 文件所用的版本。比如用 JDK 17 编译出的 class 文件,在一个基于 JDK 8 的服务里直接启动,就会遇到这个错。
定位思路分三步。
第一步,确定当前进程使用的是哪个 JVM 版本,执行:
java -version第二步,确定 class 文件的主版本号。可以看编译时用的target参数,也可以用十六进制查看工具检查文件头。主版本号和 Java 版本的对应关系是:49 对应 Java 5,50 对应 6,51 对应 7,52 对应 8,53 对应 9,54 对应 10,55 对应 11,56 对应 12,57 对应 13,58 对应 14,59 对应 15,60 对应 16,61 对应 17,62 对应 18,63 对应 19,64 对应 20,65 对应 21。
第三步,看编译侧配置。如果用的是构建工具,检查源代码编译时指定的 release 或 source/target 级别是否低于目标运行环境的版本。
这个错误之所以容易在多人协作项目里出现,是因为每个人的本机 JDK 版本不一致,编译产物天然不同。解决方向是从构建流程统一 JDK 版本,而不是靠开发者在本地手动编译完再上传。现在主流的构建工具已经默认处理了 source/target 的向下兼容,但仍要留意编译时与运行时版本的匹配。
5.3 NoSuchMethodError与静态修改字节码的隐患
NoSuchMethodError和前面的UnsupportedClassVersionError不同,它不是启动时立刻爆出来的,而是在运行到某个方法调用的时刻才蹦出来。完整提示一般是:
java.lang.NoSuchMethodError: com.example.SomeService.doWork(Ljava/lang/String;)V注意这一整段描述,它其实是一条含完整描述符的方法签名。出现这个错误通常意味着:调用方在编译期看到的类版本,和运行时实际加载到的类版本不一致,前者拥有的某个方法,在后者当中不存在。常见的原因是依赖升级后,某方法被改名或被删除了,但调用方模块还在用旧版本编译出的 class 文件。
也有一种情况比较隐蔽:有人手动用字节码工具修改了某个类的 class 文件,把方法名改了或者把描述符改了,但其他模块仍然引用旧版本对应的符号引用。这个场景在大型项目里偶尔会出现,排查时需要把两边的 class 文件都反汇编出来,对照签名逐个核实。
谈论NoSuchMethodError很容易让人联想起字节码层面的“方法删除”问题。实际上,这对使用字节码操纵框架做热替换或插桩的团队是个经典考验:如果只改了父子类中的某一个方法的结构,而不注意同步维护符号引用的一致性,运行时的错误就会以这类形式出现。
还有个常见坑是开发者以为NoSuchMethodError和NoClassDefFoundError是一回事,其实后者是因为某个类在运行时找不到,本质是类路径问题,而前者是类存在但方法不存在,本质是依赖版本不一致或字节码被篡改。不同错误,排查路径完全不同,别混在一起处理。
6. 写在后面的一些经验
聊到这里,字节码文件的结构、指令、验证机制和排查套路基本都过了一遍。回想我自己从“看到 javap 输出就头疼”到“能靠反汇编信息定位线上问题”的过程,有几个体会值得单独说。
第一个体会是,读字节码不需要把每一条指令都背下来。常用的指令就那几类:加载存储、算术、对象访问、方法调用、控制转移。先把那几十条高频指令的助记符混个脸熟,剩下的大不了每次翻阅指令表。真正难的不是记,而是建立“局部变量表加操作数栈等于执行状态”的思维模型。这个模型一旦建立,读多少字节码都不怵。
第二个体会是,工具链比想象中重要。除了 javap,你应该关注一下字节码操纵库的运行原理,比如 ASM 的 ClassReader/ClassWriter 模型、核心 API 的访问者模式。不见得马上要用,但理解了它们,你就知道很多框架是怎么在编译期和运行期“变身”的了。
第三个体会是,字节码文件不仅是性能分析的对象,更是排查问题的宝库。很多“玄学异常”其实都能在字节码层面找到实锤证据。遇到诡异问题时,试着把相关类反汇编一遍,往往比盯着源码猜更快。我近几年排查疑难杂症,几乎都绕不开一个步骤:看字节码。
最后说个建议:初学者可以自己写一个程序,编译后逐段对照标准文档读一遍 .class 文件,再对比 javap 的输出,感受一下“二进制字节”和“助记符文本”之间的关系。这个过程花不了多少时间,带来的理解深度却远超看一百篇博客的效果。技术这东西,光看总会觉得懂了,上手一读才发现自己能看懂的边界在哪里。