字节码基础
这是第九阶段——JVM 深度的最后一章。前面四章我们讲了内存模型、GC、类加载、性能调优——都是”宏观”层面的 JVM。这一章我们钻进 .class 文件,看 字节码(Bytecode)——JVM 真正执行的指令。
字节码是 Java “一次编写,到处运行”的根基——.java 编译成 .class(字节码),字节码跨平台,由各平台的 JVM 解释/JIT 编译执行。理解字节码,能让你看清 Java 语法糖背后的真相——i++ 和 ++i 真的一样吗?泛型真的擦除了吗?try-with-resources 编译后加了什么?这一章把这些问题一次说清。
一、javap:字节码查看工具
javap(Java Class File Disassembler)是 JDK 自带的反汇编工具——把 .class 文件反汇编成可读的字节码。
1.1 基本用法
# 反汇编一个类 (-c 显示字节码, -p 显示私有成员)
$ javap -c -p MyClass
# 更完整: -v 显示常量池、行号等
$ javap -v -p MyClass > bytecode.txt
# 常用组合
$ javap -c -p -l MyClass # -l 显示行号和局部变量表
$ javap -s MyClass # -s 显示内部类型签名 (泛型擦除后)
1.2 输出结构
javap -v 输出包含:
- 类基本信息——版本号、访问标志、父类、接口。
- 常量池(Constant Pool)——字符串、类名、方法名、字段名的常量表。
- 字段表——类有哪些字段。
- 方法表——每个方法的字节码。
- 属性表——行号、局部变量表、注解等。
我们重点看方法表的字节码部分。
1.3 一个简单例子
public class Hello {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
}
javap -c -p Hello:
public static void main(java.lang.String[]);
Code:
0: iconst_1 # 把常量 1 压入操作数栈
1: istore_1 # 弹出栈顶, 存到局部变量 1 (即 a)
2: iconst_2 # 把常量 2 压入操作数栈
3: istore_2 # 弹出, 存到局部变量 2 (即 b)
4: iload_1 # 加载局部变量 1 (a) 到栈
5: iload_2 # 加载局部变量 2 (b) 到栈
6: iadd # 弹出两个 int, 相加, 压回结果
7: istore_3 # 存到局部变量 3 (即 c)
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_3 # 加载 c
12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
15: return
每行是一条字节码指令——操作码(助记符)+ 操作数。左边的数字是偏移量(字节码地址)。#2、#3 是常量池索引。
二、字节码指令概览
JVM 指令集约 200 条,按功能分几类:
2.1 加载/存储指令
在局部变量表和操作数栈之间搬运数据:
| 指令 | 作用 |
|---|---|
iconst_<n> / lconst_<n> / fconst_<n> / dconst_<n> | 把常量 0/1/2/3/4/5 压栈 |
bipush / sipush | 把 byte/short 压栈 |
ldc / ldc_w / ldc2_w | 从常量池加载常量压栈 |
iload_<n> / lload_<n> / fload_<n> / dload_<n> / aload_<n> | 加载局部变量到栈 |
istore_<n> / lstore_<n> / fstore_<n> / dstore_<n> / astore_<n> | 弹栈存到局部变量 |
iaload / aaload 等 | 数组元素加载 |
iastore / aastore 等 | 数组元素存储 |
前缀 i/l/f/d/a 表示类型——i int、l long、f float、d double、a reference。
<n> 是常见的简写——iload_0 等价于 iload 0,省一个字节。
2.2 运算指令
| 指令 | 作用 |
|---|---|
iadd / ladd / fadd / dadd | 加 |
isub / lsub / fsub / dsub | 减 |
imul / lmul / fmul / dmul | 乘 |
idiv / ldiv / fdiv / ddiv | 除 |
irem / lrem / frem / drem | 取模 |
ineg / lneg / fneg / dneg | 取负 |
iand / ior / ixor | 位运算 |
ishl / ishr / iushr | 移位 |
运算指令从操作数栈弹出操作数,计算后压回结果。
2.3 类型转换
| 指令 | 作用 |
|---|---|
i2l / i2f / i2d | int → long/float/double |
l2i / l2f / l2d | long → … |
f2i / f2l / f2d | float → … |
d2i / d2l / d2f | double → … |
i2b / i2c / i2s | int → byte/char/short |
宽类型(long/double)转窄类型(int/float)可能丢精度。
2.4 对象/数组操作
| 指令 | 作用 |
|---|---|
new | 新建对象 |
newarray / anewarray / multianewarray | 新建数组 |
arraylength | 数组长度 |
getfield / putfield | 读/写实例字段 |
getstatic / putstatic | 读/写静态字段 |
checkcast | 类型检查(强转) |
instanceof | instanceof 判断 |
2.5 控制流
| 指令 | 作用 |
|---|---|
if_icmpeq / if_icmpne / if_icmplt / if_icmpge 等 | int 比较+跳转 |
ifeq / ifne / iflt / ifge 等 | 与 0 比较+跳转 |
ifnull / ifnonnull | null 检查+跳转 |
if_acmpeq / if_acmpne | 引用相等+跳转 |
goto / goto_w | 无条件跳转 |
tableswitch / lookupswitch | switch 语句 |
iinc | 局部变量自增(重要,i++ 的关键) |
2.6 方法调用
| 指令 | 作用 |
|---|---|
invokevirtual | 实例方法,动态分派(基于实际类型) |
invokestatic | 静态方法 |
invokeinterface | 接口方法 |
invokespecial | 构造器、private 方法、super.method() |
invokedynamic | 动态方法(lambda、字符串拼接) |
return / ireturn / lreturn / freturn / dreturn / areturn | 方法返回 |
invokevirtual vs invokespecial 是多态的关键——invokevirtual 在运行时按对象的实际类型找方法(虚方法分派),invokespecial 在编译时就确定调用哪个(构造器、私有方法)。
2.7 异常处理
| 指令 | 作用 |
|---|---|
athrow | 抛异常 |
| 异常表(Exception Table) | try-catch 的实现机制 |
字节码里没有 try/catch 关键字——它们编译成异常表:一段字节码范围(start_pc ~ end_pc),异常类型(catch_type),处理跳转地址(handler_pc)。发生异常时 JVM 查异常表,匹配上就跳到 handler。
三、i++ vs ++i:字节码的真相
这是面试常考题——i++ 和 ++i 在表达式里有什么不同?字节码能看清。
3.1 单独语句:完全一样
int i = 0;
i++; // 单独语句
++i; // 单独语句
i++ 字节码:
iinc 1 1 # 局部变量 1 自增 1
++i 字节码:
iinc 1 1 # 一模一样!
单独使用时,i++ 和 ++i 字节码完全相同——iinc 指令直接在局部变量上加 1,不需要进栈出栈。所以性能也完全一样。
3.2 在表达式里:不同
int i = 0;
int a = i++; // 后置: a = 0, i = 1
int b = ++i; // 前置: i = 2, b = 2
i++ 字节码(a = i++):
0: iconst_0
1: istore_1 # i = 0
2: iload_1 # 加载 i 到栈 (此时栈上是 0)
3: iinc 1 1 # i 自增 (i 变成 1)
6: istore_2 # 把栈上的 0 存到 a (a = 0)
++i 字节码(b = ++i):
7: iinc 1 1 # i 自增 (i 变成 2)
10: iload_1 # 加载 i 到栈 (栈上是 2)
11: istore_3 # 把栈上的 2 存到 b (b = 2)
区别——i++ 先 iload(取值)后 iinc(自增),++i 先 iinc(自增)后 iload(取值)。所以 i++ 拿到的是自增前的值,++i 拿到的是自增后的值。
在字节码层面,i++ 多了一次”先取值”——但 JIT 优化后单独语句两者一致。在表达式里,两者语义不同,不是性能差异。
3.3 经典陷阱:循环里的 i++
for (int i = 0; i < 10; i++) {
// ...
}
for (int i = 0; i < 10; ++i) {
// ...
}
这两个循环编译后字节码完全一样——因为 i++/++i 是单独语句,iinc 自增。循环里用 i++ 还是 ++i 性能完全相同(C++ 也一样,现代编译器都优化)。这个面试题的”标准答案 i++ 更慢”是过时的迷思。
四、泛型擦除:字节码的真相
Java 泛型是编译期类型检查,运行时擦除。字节码能看清——List<String> 和 List<Integer> 运行时是同一个 List。
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
strings.add("hello");
ints.add(42);
// 反射看 class
System.out.println(strings.getClass() == ints.getClass()); // true!
字节码里——List<String> 编译成 List,String 的类型参数被擦除。
4.1 字段签名字段
字节码的字段表/方法表有 Signature 属性,保留泛型签名信息(用于反射)。但方法体的字节码完全擦除。
public List<String> getList() { ... }
字节码:
public getList() :Ljava/util/List; // 返回类型是 List (擦除)
Signature: ()Ljava/util/List<String>; // Signature 属性保留泛型
方法返回类型字节码层面是 List,但 Signature 属性记录了 List<String>——反射 Method.getGenericReturnType() 能读到。
4.2 擦除的代价
- 不能
new T()——T 擦除成 Object,无法 new。 - 不能
new T[]——同上。 - 不能
instanceof T——运行时没 T 的信息。 - 基本类型不能当泛型参数——
List<int>不行,必须List<Integer>(装箱)。 - 重载冲突——
void m(List<String>)和void m(List<Integer>)擦除后签名相同,编译错。
// 字节码看 add 方法
List<Integer> ints = new ArrayList<>();
ints.add(42);
// 编译后:
// 9: aload_1
// 10: bipush 42
// 12: invokestatic Integer.valueOf // int 装箱成 Integer
// 15: invokeinterface List.add // 调 List.add(Object)
// 20: pop
add(42) 编译成 Integer.valueOf(42) + List.add(Object)——泛型擦除后参数是 Object,基本类型要装箱。这是泛型不能直接用基本类型的根本原因。
五、try-with-resources:自动 close 的实现
try-with-resources(Java 7+)自动调用 close()——编译器在背后做了什么?字节码能看清。
try (FileInputStream fis = new FileInputStream("a.txt")) {
int b = fis.read();
} catch (IOException e) {
e.printStackTrace();
}
编译后等价于(伪代码):
FileInputStream fis = new FileInputStream("a.txt");
Throwable primaryExc = null;
try {
int b = fis.read();
} catch (Throwable t) {
primaryExc = t;
throw t;
} finally {
if (fis != null) {
if (primaryExc != null) {
try {
fis.close();
} catch (Throwable closeT) {
primaryExc.addSuppressed(closeT); // 抑制异常
}
} else {
fis.close();
}
}
}
字节码层面的关键点:
addSuppressed——try块的异常和close的异常都抛时,close的异常被”抑制”(attached as suppressed)到主异常上。这是 Java 7 引入的Throwable.addSuppressed。- null 检查——
close前检查资源不为 null。 - 双 try 嵌套——外层捕获 + 内层 finally close。
实际字节码(简化):
0: new FileInputStream
...
8: invokespecial FileInputStream.<init>
11: astore_2 # 存 fis
12: aload_2
13: invokevirtual read
...
// finally 块
aload_2 # 加载 fis
ifnull 退出 # null 检查
invokevirtual close # 调 close
...
// 异常处理: addSuppressed
理解这个机制,能解释为什么 try-with-resources 比手动 close 更好——它能正确处理”两处异常”的情况,不会丢失任何异常信息。
5.1 Java 9+ 改进
Java 9 起 try-with-resources 支持外部变量(只要 effectively final):
FileInputStream fis = new FileInputStream("a.txt");
try (fis) { // Java 9+ 可以
int b = fis.read();
}
字节码等价。
六、字符串拼接:invokedynamic 的进化
+ 拼接字符串在 Java 8 和 Java 9+ 的字节码完全不同。
6.1 Java 8:StringBuilder
String s = "a" + "b" + c;
Java 8 编译成:
String s = new StringBuilder().append("a").append("b").append(c).toString();
字节码:
new StringBuilder
dup
invokespecial StringBuilder.<init>
ldc "a"
invokevirtual StringBuilder.append
ldc "b"
invokevirtual StringBuilder.append
iload_1 // c
invokevirtual StringBuilder.append
invokevirtual StringBuilder.toString
astore_2
6.2 Java 9+:invokedynamic
Java 9 起改用 invokedynamic + StringConcatFactory——JVM 在运行时决定最优拼接策略(可能直接 makeConcatWithConstants,可能预编译字节码):
ldc "a"
ldc "b"
iload_1
invokedynamic makeConcatWithConstants // 动态调用
astore_2
invokedynamic 让 JVM 可以根据运行时情况选择策略,性能比 Java 8 的固定 StringBuilder 更好——尤其是循环外的拼接,避免了重复创建 StringBuilder。
七、实战:演示字节码
下面的例子用 Java 代码演示 i++ vs ++i、泛型擦除、try-with-resources 的行为,配合反射看类型擦除后的效果。
观察重点:
a = i++后 a=0、i=1,b = ++i后 b=1、j=1——前置后置语义不同。 > -strings.getClass() == ints.getClass()返回 true——泛型擦除后 List<String> 和 List<Integer> 是同一个 Class。getGenericReturnType()返回List<String>——Signature 属性保留了泛型信息,反射能读到。e.getSuppressed()返回 close 的异常——try-with-resources 自动把 close 异常”挂”到主异常上。- lambda 的类名带
$$Lambda$——invokedynamic 运行时生成,不是匿名内部类。- switch(String) 能工作——编译成 hashCode + equals 的双重检查。
八、如何学习字节码
8.1 实践工具
javap -c -p—— 最简单,看字节码。javap -v -p—— 看常量池、Signature、行号。- ASM Bytecode Outline(Eclipse 插件)—— 编辑 Java 代码时实时显示字节码。
- jclasslib Bytecode Viewer(IDEA 插件)—— GUI 浏览字节码,比 javap 直观。
class文件结构图 —— JVM 规范 Chapter 4 有完整定义。
8.2 推荐练习
- 写简单的方法(加法、循环、if-else),用 javap 看字节码。
- 对比
i++/++i、for/while、switch/if-else的字节码。 - 看 lambda、泛型、自动装箱的字节码——理解语法糖。
- 看 try-with-resources、try-catch-finally 的异常表。
- 看 synchronized 编译后的
monitorenter/monitorexit指令。
8.3 字节码的应用场景
- 性能优化——看哪些操作字节码层面有开销(装箱、字符串拼接)。
- 框架开发——ASM/ByteBuddy 操作字节码做 AOP、动态代理。
- 逆向工程——看闭源库的实现。
- 理解语法糖——lambda、泛型、try-with-resources 背后的真相。
- 面试硬实力——字节码是”内功”的体现。
九、本章小结
| 概念 | 核心要点 |
|---|---|
javap -c -p | 反汇编字节码 |
javap -v -p | 看常量池、Signature、行号 |
| 加载/存储指令 | iload/istore/iconst/ldc |
| 运算指令 | iadd/isub/imul/idiv/irem |
| 控制流 | if_icmpeq/goto/tableswitch/iinc |
| 方法调用 | invokevirtual/invokestatic/invokespecial/invokeinterface/invokedynamic |
iinc | 局部变量自增,i++/++i 单独语句的字节码相同 |
| 泛型擦除 | 字节码无泛型,Signature 属性保留泛型信息 |
| try-with-resources | 编译成 finally + addSuppressed |
| 异常表 | try-catch 的实现机制 |
| 字符串拼接 | Java 8 StringBuilder,Java 9+ invokedynamic |
| lambda | invokedynamic 运行时生成,不创建匿名类 |
记忆口诀:
javap -c -p看字节码——基础命令。iinc自增不进栈——i++单独语句和++i字节码相同。- 泛型擦除只留 Signature——字节码里没
<String>,反射才能读到。 - try-with-resources = finally + addSuppressed——双异常不丢。
- 5 种 invoke 指令——virtual/static/special/interface/dynamic。
- lambda 是 invokedynamic——运行时生成,不是匿名类。
- switch(String) = hashCode + equals——字符串 switch 的实现。
- JVM 是栈式机器——所有操作都通过操作数栈。
结语:第九阶段完结——从语法到字节码
第九阶段到这里就结束了。回顾这 5 章,我们从”使用 Java”钻到了”理解 JVM”:
- 第 51 章 JVM 内存模型 —— 五大运行时数据区、堆分代、对象布局。
- 第 52 章 垃圾回收 —— 可达性分析、四种引用、GC 算法、收集器演进。
- 第 53 章 类加载机制 —— 加载五阶段、双亲委派、打破双亲委派。
- 第 54 章 性能监控与调优 —— jstack/jmap/jfr/MAT,OOM 和 CPU 飙高排查。
- 第 55 章 字节码基础(本章) —— javap、指令集、i++/泛型/try-with-resources 的真相。
这一套知识构成了 Java 工程师的”内功”——会写代码是基础,懂 JVM 才是高级。生产环境出问题,会 JVM 的人能 5 分钟定位,不会的人 5 小时还摸不着头脑——这就是差距。
整个 Java 教程的总收尾
到这里,9 个阶段、55 章的 Java 教程全部完成。回望这条路:
- 第一阶段(第 1-5 章) —— 入门语法:变量、运算、控制流、数组。
- 第二阶段(第 6-12 章) —— 面向对象:类、封装、继承、多态、接口、内部类、枚举注解。
- 第三阶段(第 13-17 章) —— 核心类库:String、包装类、日期时间、异常、泛型。
- 第四阶段(第 18-24 章) —— 集合框架:List/Set/Map/Queue、工具类、Stream。
- 第五阶段(第 25-27 章) —— 函数式:Lambda、函数式接口、方法引用。
- 第六阶段(第 28-33 章) —— IO 与 NIO:字节流、字符流、序列化、NIO、Files。
- 第七阶段(第 34-45 章) —— 并发编程:线程、同步、JMM、锁、CAS、并发集合、线程池、虚拟线程。
- 第八阶段(第 46-50 章) —— 现代 Java:模块系统、Records、Sealed、Pattern Matching、HttpClient 等。
- 第九阶段(第 51-55 章) —— JVM 深度:内存、GC、类加载、调优、字节码。
从一行 System.out.println("Hello") 到看懂 javap -v 的字节码,从 Thread.sleep 到 ZGC 的染色指针——这是一段漫长的旅程,也是 Java 工程师成长的必经之路。
学完这 55 章,你已经具备了:
- 写 Java 的能力——从语法到并发,从 IO 到现代特性。
- 调 Java 的能力——用工具监控、定位 OOM 和 CPU 问题。
- 懂 Java 的能力——理解 JVM 内存、GC、类加载、字节码。
接下来怎么走?几个方向:
- 框架——Spring、Spring Boot、MyBatis,把 Java 用到生产规模。
- 架构——分布式、微服务、消息队列、缓存。
- 数据库——MySQL、Redis、ES,性能优化、索引、事务。
- 中间件——Kafka、Netty、Zookeeper。
- JVM 进阶——JIT 编译、内存屏障、GC 调优实战。
- Kotlin/Scala——JVM 生态的现代语言。
Java 的世界很大,55 章只是开了门。希望这趟旅程让你从”会写 Java”走到”懂 Java”,从”用工具”走到”造工具”。技术在变,但底层原理不变——内存、并发、JVM,这些是 Java 工程师永远的内功。
祝你在这条路上越走越远,越走越稳。Java 的旅程,永无止境。