字节码基础

这是第九阶段——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 / i2dint → long/float/double
l2i / l2f / l2dlong → …
f2i / f2l / f2dfloat → …
d2i / d2l / d2fdouble → …
i2b / i2c / i2sint → byte/char/short

宽类型(long/double)转窄类型(int/float)可能丢精度。

2.4 对象/数组操作

指令作用
new新建对象
newarray / anewarray / multianewarray新建数组
arraylength数组长度
getfield / putfield读/写实例字段
getstatic / putstatic读/写静态字段
checkcast类型检查(强转)
instanceofinstanceof 判断

2.5 控制流

指令作用
if_icmpeq / if_icmpne / if_icmplt / if_icmpgeint 比较+跳转
ifeq / ifne / iflt / ifge与 0 比较+跳转
ifnull / ifnonnullnull 检查+跳转
if_acmpeq / if_acmpne引用相等+跳转
goto / goto_w无条件跳转
tableswitch / lookupswitchswitch 语句
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(自增),++iiinc(自增)后 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> 编译成 ListString 的类型参数被擦除。

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();
        }
    }
}

字节码层面的关键点:

  1. addSuppressed——try 块的异常和 close 的异常都抛时,close 的异常被”抑制”(attached as suppressed)到主异常上。这是 Java 7 引入的 Throwable.addSuppressed
  2. null 检查——close 前检查资源不为 null。
  3. 双 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 的行为,配合反射看类型擦除后的效果。

Java · 在线运行

观察重点

  • 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++/++ifor/whileswitch/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
lambdainvokedynamic 运行时生成,不创建匿名类

记忆口诀

  • 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 的旅程,永无止境。