Java 虚拟机之字节码
# Java 虚拟机之字节码
# 简介
Java 字节码是 Java 源代码编译后的中间表示形式,存储在 .class 文件中。字节码是一种平台无关的指令集,由 JVM 的解释器或 JIT 编译器执行。理解字节码结构有助于深入理解 Java 的编译、加载和执行机制,也是学习字节码增强技术(ASM、Javassist、ByteBuddy)的基础。
# 字节码简介
Java 字节码是 Java 虚拟机执行的一种指令格式。之所以被称之为字节码,是因为:Java 字节码文件(.class)是一种以 8 位字节为基础单位的二进制流文件,各个数据项严格按照顺序紧凑地排列在 .class 文件中,中间没有添加任何分隔符。整个 .class 文件本质上就是一张表。
Java 能做到 “一次编译,到处运行”,一是因为 JVM 针对各种操作系统、平台都进行了定制;二是因为无论在什么平台,都可以编译生成固定格式的 Java 字节码文件(.class)。

# 类文件结构
一个 Java 类编译后生成的 .class 文件内容如下图所示,是一堆十六进制数。
Class 文件是一组以 8 个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文 件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数 据,没有空隙存在。

图来自 字节码增强技术探索 (opens new window)
字节码看似杂乱无序,实际上是由严格的格式要求组成的。

# 魔数
每个 .class 文件的头 4 个字节称为 魔数(magic_number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 .class 文件。魔数的固定值为:0xCAFEBABE(咖啡宝贝)。
# 版本号
版本号(version)有 4 个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。
Java 的版本号是从 45 开始的,JDK 1.1 之后 的每个 JDK 大版本发布主版本号向上加 1。举例来说,如果版本号为:“00 00 00 34”。那么,次版本号转化为十进制为 0,主版本号转化为十进制为 52,在 Oracle 官网中查询序号 52 对应的主版本号为 1.8,所以编译该文件的 Java 版本号为 1.8.0。
# 常量池
紧接着主版本号之后的字节为常量池(constant_pool),常量池可以理解为 .class 文件中的资源仓库。
常量池整体上分为两部分:常量池计数器以及常量池数据区
常量池计数器(constant_pool_count) - 由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。
常量池数据区 - 数据区的每一项常量都是一个表,且结构各不相同。
常量池主要存放两类常量:
- 字面量 - 如文本字符串、声明为
final的常量值。 - 符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
# 访问标志
紧接着常量池的 2 个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口的访问信息,描述该 Class 是类还是接口;以及是否被 public、abstract、final 等修饰符修饰。
# 类索引、父类索引、接口索引集合
类索引(this_class)和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定该类型的继承关系。
# 字段表集合
字段表(field_info)用于描述类和接口中声明的变量。Java 语言中的“字段”(Field)包括类级变 量以及实例级变量,但不包括在方法内部声明的局部变量。
字段可以包括的修饰符有字段的作用域(public、private、protected 修饰 符)、是实例变量还是类变量(static 修饰符)、可变性(final)、并发可见性(volatile 修饰符,是否 强制从主内存读写)、可否被序列化(transient 修饰符)、字段数据类型(基本类型、对象、数组)、 字段名称。
# 方法表集合
Class 文件存储 格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依 次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表 集合(attributes)几项
字段表结束后为方法表,方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
# 属性表集合
属性表集合(attribute_info)存放了在该文件中类或接口所定义属性的基本信息。
# 字节码指令
字节码指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零到多个代表此操作所需参数(Operands)而构成。由于 JVM 采用面向操作数栈架构而不是寄存器架构,所以大多数的指令都不包括操作数,只有一个操作码。
JVM 操作码的长度为 1 个字节,因此指令集的操作码最多只有 256 个。
字节码操作大致分为 9 类:
- 加载和存储指令
- 运算指令
- 类型转换指令
- 对象创建与访问指令
- 操作数栈管理指令
- 控制转移指令
- 方法调用和返回指令
- 异常处理指令
- 同步指令
# 字节码增强
# Asm
对于需要手动操纵字节码的需求,可以使用 Asm,它可以直接生产 .class字节码文件,也可以在类被加载入 JVM 之前动态修改类行为(如下图 17 所示)。
Asm 的应用场景有 AOP(Cglib 就是基于 Asm)、热部署、修改其他 jar 包中的类等。当然,涉及到如此底层的步骤,实现起来也比较麻烦。
Asm 有两类 API:核心 API 和树形 API
# 核心 API
Asm Core API 可以类比解析 XML 文件中的 SAX 方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用 Core API。在 Core API 中有以下几个关键类:
- ClassReader:用于读取已经编译好的。class 文件。
- ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
- 各种 Visitor 类:如上所述,CoreAPI 根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor、用于访问类变量的 FieldVisitor、用于访问注解的 AnnotationVisitor 等。为了实现 AOP,重点要使用的是 MethodVisitor。
# 树形 API
Asm Tree API 可以类比解析 XML 文件中的 DOM 方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。TreeApi 不同于 CoreAPI,TreeAPI 通过各种 Node 类来映射字节码的各个区域,类比 DOM 节点,就可以很好地理解这种编程方式。
# Javassist
利用 Javassist 实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用 java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。
其中最重要的是 ClassPool、CtClass、CtMethod、CtField 这四个类:
CtClass(compile-time class)- 编译时类信息,它是一个 class 文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个 CtClass 对象,用来表示这个类文件。ClassPool- 从开发视角来看,ClassPool 是一张保存 CtClass 信息的 HashTable,key 为类名,value 为类名对应的 CtClass 对象。当我们需要对某个类进行修改时,就是通过 pool.getCtClass("className") 方法从 pool 中获取到相应的 CtClass。CtMethod、CtField- 这两个比较好理解,对应的是类中的方法和属性。
# 典型应用场景
# 场景一:使用 ASM 生成类
在运行时动态生成新的类:
ClassWriter cw = new ClassWriter(0);
cw.visit(V1_8, ACC_PUBLIC, "com/example/Hello", null, "java/lang/Object", null);
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "hello", "()V", null, null);
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello ASM!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitEnd();
# 场景二:使用 Javassist 实现 AOP
在方法前后添加日志:
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("com.example.UserService");
CtMethod method = cc.getDeclaredMethod("getUser");
method.insertBefore("System.out.println(\"Entering getUser\");");
method.insertAfter("System.out.println(\"Exiting getUser\");");
# 场景三:使用 ByteBuddy 实现热修复
在不重启 JVM 的情况下修改类行为:
new ByteBuddy()
.redefine(UserService.class)
.method(named("getUser"))
.intercept(FixedValue.value("mocked"))
.make()
.load(getClass().getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());
# 最佳实践
- 使用
javap -c查看字节码:理解编译器如何转换代码,发现隐藏的装箱/拆箱等开销。 - 字节码增强优先使用高级框架:Javassist 和 ByteBuddy 比 ASM 更容易使用和维护。
- 避免过度使用字节码操作:调试困难且容易出错,优先考虑反射或代理模式。
- 注意 ClassLoader 隔离:字节码增强的类必须在正确的 ClassLoader 中加载。
# 常见问题
# Q1:字节码和机器码有什么区别?
字节码是 JVM 指令集,平台无关,由 JVM 解释执行或 JIT 编译。机器码是 CPU 原生指令,平台相关,直接由 CPU 执行。字节码通过 JIT 编译可以转换为机器码。
# Q2:为什么 Java 使用字节码而不是直接编译为机器码?
字节码实现了“一次编译,到处运行”的跨平台能力。如果直接编译为机器码,每个平台都需要单独编译。字节码还便于在运行时进行优化(JIT)和动态加载。
# Q3:Kotlin/Scala 等 JVM 语言也生成字节码吗?
是的。所有 JVM 语言(Kotlin、Scala、Groovy、Clojure 等)都编译为 JVM 字节码,因此可以无缝互操作。