Skip to main content

JVM字节码理解

·1261 words·6 mins

JVM 概览 #

上图为 HotSpot JVM 中 Java 代码的执行流程:.javajavac 编译为 .class 字节码,类加载器载入后进入运行时数据区,由解释器或 JIT 编译后的本地代码执行。

运行时数据区 #

JVM 在运行时会划分以下内存区域(逻辑概念,具体实现因 VM 而异):

区域线程共享说明
程序计数器(PC Register)当前线程执行的字节码行号指示器
Java 虚拟机栈栈帧:局部变量表、操作数栈、动态链接、方法出口
本地方法栈为 Native 方法服务
堆(Heap)对象实例、数组的分配区域,GC 主要工作区
方法区(Method Area)类信息、常量、静态变量;Java 8 起元数据在 Metaspace
运行时常量池方法区的一部分,存放编译期生成的字面量与符号引用

Java 虚拟机解释器(Interpreter)的执行流程如下面的伪代码:

do {
    自动计算 pc 寄存器以及从 pc 寄存器的位置取出操作码;
    if (存在操作码) 取出操作数;
    执行操作码所定义的操作;
} while (处理下一次循环);

热点代码会由 JIT 编译器编译为本地机器码,存入 Code Cache,后续直接执行机器码而非逐条解释字节码。

字节码指令集简介 #

了解 class 文件 #

Java 源代码在执行之前要经历一系列变换。首先就是使用 Java 编译器 javac 进行的编译阶段,将 Java 代码转换为包含字节码的 .class 文件。字节码是一种中间表示,没有与特定的机器架构绑定。

public class Test {
    public static void main(String[] args) {
        System.out.println("Hello Bytecode!");
    }
}

使用 JDK 中自带的 javac 命令将 Test.java 源文件编译成 class 文件,就可以生成 Test.class 文件。

javac Test.java    # output: Test.class

使用十六进制查看工具查看 Test.class 文件内容:

其具体的结构如下面代码所示。

ClassFile {
    u4             magic;                                   // 魔数 0xCAFEBABE
    u2             minor_version;                           // 该类次版本号
    u2             major_version;                           // 该类主版本号
    u2             constant_pool_count;                     // 常量数
    cp_info        constant_pool[constant_pool_count-1];    // 该类的常量池
    u2             access_flags;                            // 访问标志
    u2             this_class;                              // 该类的类名
    u2             super_class;                             // 超类名
    u2             interfaces_count;                        // 该类的实现的接口数量
    u2             interfaces[interfaces_count];            // 该类的实现的接口
    u2             fields_count;                            // 字段数
    field_info     fields[fields_count];                    // 该类的所有字段
    u2             methods_count;                           // 方法数
    method_info    methods[methods_count];                  // 该类的所有方法
    u2             attributes_count;                        // 属性数
    attribute_info attributes[attributes_count];            // 该类的所有属性(比如,源文件的名字,等等)
}

u1、u2、u4 三种数据结构表示 1、2、4 字节无符号整数

《Optimizing Java》一书作者帮我们编了一句顺口溜:My Very Cute Animal Turns Savage In Full Moon Areas

魔数 #

每个类文件都以魔数(magic number)0xCAFEBABE 开始,前面的 4 个以十六进制表示的字节表示当前文件符合 class 文件格式。

可以通过此链接 GCK’S FILE SIGNATURES TABLE 查看其他文件的魔数值。

版本号 #

随后的 4 个字节表示用于编译该类文件的主版本号次版本号Test.class 文件中的主版本号为 52(0x34),表示是使用 Java 8 编译的。

每次大版本发布,主版本号加 1。Java 8 为 52,Java 21 为 65

常量池 #

常量池的作用类似于 C 语言中的符号表(Symbol Table),也是 class 文件中第一个出现的变长结构。

常量池结构由两部分组成:

  1. 常量池大小u2 两字节

  2. 常量池项:由 1 字节 tag 和具体内容组成

    cp_info {
        u1 tag;
        u1 info[];
    }
    

常量池真正有效的索引是 1 ~ n-1,0 属于保留索引。

下表列出 JVM 规范中常见的常量池 tag 类型(Java 8 使用 tag 1~18,Java 9 起增加 Module / Package):

常量池类型

类型tag 值Java SE
CONSTANT_Utf811.0
CONSTANT_Integer31.0
CONSTANT_Float41.0
CONSTANT_Long51.0
CONSTANT_Double61.0
CONSTANT_Class71.0
CONSTANT_String81.0
CONSTANT_Fieldref91.0
CONSTANT_Methodref101.0
CONSTANT_InterfaceMethodref111.0
CONSTANT_NameAndType121.0
CONSTANT_MethodHandle157
CONSTANT_MethodType167
CONSTANT_InvokeDynamic187
CONSTANT_Dynamic1711
CONSTANT_Module199
CONSTANT_Package209

CONSTANT_Integer 和 CONSTANT_Float #

CONSTANT_Integer/Float {
    u1 tag;
    u4 bytes;
}

两者都是使用 4 字节来表示具体的数值常量。

Java 语言中定义的 boolean、byte、short、char 类型的变量在常量池中都会被当作 int 处理。

CONSTANT_Long 和 CONSTANT_Double #

CONSTANT_Long/Double {
    u1 tag;
    u4 high_bytes;
    u4 low_bytes;
}

采用 big-endian(high byte first)存储:

((long) high_bytes << 32) + low_bytes

LongDouble 占两个常量池槽位,下一索引不可用。

CONSTANT_Utf8 #

CONSTANT_Utf8 {
    u1 tag;
    u2 length;
    u1 bytes[length];
}

length 表示 MUTF-8 编码的字节数组长度,bytes[length] 为具体字节。

MUTF-8 与 UTF-8 区别:

  1. 采用两个字节表示空字符(\0
  2. 只使用单字节、双字节、三字节;四字节字符用「代理对」(双字符)表示

CONSTANT_String #

用来表示 java.lang.String 类型的常量对象。CONSTANT_Utf8 存储字符串真正的内容,而 CONSTANT_String 并不包含字符串内容,仅包含指向 CONSTANT_Utf8 的索引。

CONSTANT_String {
    u1 tag;
    u2 string_index;
}

CONSTANT_Class #

用来表示类或者接口,结构与 CONSTANT_String 类似。

CONSTANT_NameAndType #

用来表示字段或方法的名称与描述符:

CONSTANT_NameAndType {
    u1 tag;
    u2 name_index;
    u2 descriptor_index;
}
  1. tag 值固定为 12
  2. name_index 指向 CONSTANT_Utf8,为字段或方法名
  3. descriptor_index 指向 CONSTANT_Utf8,为字段或方法的类型描述符

CONSTANT_Fieldref、CONSTANT_Methodref、CONSTANT_InterfaceMethodref #

CONSTANT_Fieldref/Methodref/InterfaceMethodref {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}
  • class_index 指向 CONSTANT_Class,表示字段或方法所在的类
  • name_and_type_index 指向 CONSTANT_NameAndType

CONSTANT_MethodHandle、CONSTANT_MethodType、CONSTANT_InvokeDynamic #

从 JDK 7 起为支持动态语言调用新增。CONSTANT_InvokeDynamicinvokedynamic 指令提供引导方法(bootstrap method)的引用。

总结 #

可以通过 javap 命令或者 jclasslib 工具查看常量池信息。

Access flags #

在常量池之后是访问标记,用来表示一个类是否为 finalabstractinterfaceannotationenum 等。两字节,16 个标记位。

this_class、super_class、interfaces #

这三个字段用来确定继承关系:

  • this_class:当前类索引
  • super_class:直接父类索引
  • interfaces:实现的直接父接口列表

均指向常量池索引。

字段表 #

字段表是变长结构,类中定义的字段存储在这个集合中:

field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

字段描述符 #

描述符类型说明
Bbyte
Cchar
Ddouble
Ffloat
Iint
Jlong
Sshort
Zboolean
L ClassName;引用类型L + 全限定名 + ;,如 Ljava/lang/String;
[一维数组[I = int[]
[[多维数组[[I = int[][]

方法表 #

方法表也是变长结构,类中定义的方法存储在这里:

method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

方法描述符格式:(参数1类型 参数2类型 ...) 返回值类型

(Ljava/lang/String;)V

V 表示 void。

属性表 #

attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

常见属性:

  • ConstantValue:出现在 field_info 中,表示静态变量初始值
  • Code:出现在 method_info 中,包含方法的字节码
  • LineNumberTableCode 的附属属性,存放源码行号与字节码偏移的对应关系

javap 使用 #

javap [options] [classes]

默认显示 public、protected 和默认级别的方法。

选项作用
-p显示 private 方法和字段
-s输出类型描述符签名
-c反编译,显示方法内字节码
-v详细信息:版本号、访问权限、常量池
-l行号表和局部变量表(需 javac -g 编译)

字节码指令 #

Java 虚拟机的指令由 1 字节的操作码(opcode)以及 0 至多个操作数构成。

opcode [<operand1>, <operand2>]

0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
3 ldc #3 <Hello world!>
5 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
8 return

虚拟机指令集 #

按用途可分为:常量、加载、存储、栈、数学、转换、比较、控制、引用等类别。

方法调用指令 #

指令用途
invokestatic调用静态方法
invokespecial调用构造器 <init>、私有方法、父类方法
invokevirtual调用虚方法(大多数实例方法)
invokeinterface调用接口方法
invokedynamic运行时动态解析引导方法,再执行目标方法

前四条指令的分派逻辑由 JVM 内置;invokedynamic 的分派逻辑由用户提供的 bootstrap method 决定(lambda、Stream 等依赖此机制)。

非虚方法(Non-Virtual Method):静态方法、私有方法、构造器、父类方法,以及 final 方法。这类方法在类加载的解析阶段即可确定唯一版本。

虚方法(Virtual Method):其余可被覆盖的实例方法,通过 invokevirtual / invokeinterface运行时按实际对象类型分派(动态分派)。

字符串 #

【推荐】循环体内,字符串的连接方式,使用 StringBuilder 的 append 方法进行扩展。

说明:反编译出的字节码显示,每次循环都会 new 一个 StringBuilder,再 append,最后 toString,造成额外分配。

String str = "start";
for (int i = 0; i < 100; i++) {
    str = str + "hello";
}

Java 8 编译上述代码的字节码:

自动装箱/拆箱 #

自动装箱、拆箱在编译期被转换为包装类方法调用。以 int / Integer 为例:

  • 装箱:Integer.valueOf(int)
  • 拆箱:Integer.intValue()

try-catch-finally 以及 try-with-resources 的字节码原理 #

阿里《Java 开发手册》中关于异常处理的规范,可以从字节码层面理解其原因。

异常处理 7.【强制】不要在 finally 块中使用 return

说明:try 块中的 return 执行成功后并不马上返回,而是继续执行 finally;若 finally 中也有 return,会直接返回,丢弃 try 中的返回点。

class TryCatchFinallyDemo {
    public static void main(String[] args) {
        try {
            int i = 1 / 0;
            System.out.println("try");
        } catch (Exception exception) {
            System.out.println("catch");
        } finally {
            System.out.println("finally");
        }
    }
}

现代 Java 编译器采用复制 finally 代码块的方式,将其插入 try 和 catch 中所有正常退出与异常退出路径之前,因此 finally 一定会执行。

异常处理 6.【强制】必须对资源对象、流对象进行关闭,有异常也要做 try-catch。

说明:JDK 7+ 可使用 try-with-resources。

public void testTryResource(FileReader fileReader) throws IOException {
    try (BufferedReader br = new BufferedReader(fileReader)) {
        System.out.println(br.readLine());
    }
}

编译器会展开为带 finally 关闭逻辑的代码(简化示意):

BufferedReader br = new BufferedReader(fileReader);
Throwable primary = null;
try {
    System.out.println(br.readLine());
} catch (Throwable t) {
    primary = t;
    throw t;
} finally {
    if (br != null) {
        if (primary != null) {
            try { br.close(); } catch (Throwable suppressed) {
                primary.addSuppressed(suppressed);
            }
        } else {
            br.close();
        }
    }
}

Java 7 为 Throwable 增加了 addSuppressed,用于记录关闭资源时被抑制的异常。

为什么不依赖 finalize 关闭资源?

  • finalize 执行时机不确定,可能在 GC 时才调用,资源可能长期占用
  • 异常被吞掉,难以排查
  • Java 9 起 Object.finalize() 已废弃,Java 18 起默认禁用

因此应使用 try-with-resources 或显式 close(),而不是指望 GC 回收时自动关闭。

lambda 表达式的原理 #

Java 8 的 lambda 不是匿名内部类的语法糖(早期编译器可能生成内部类,现代 javac 使用 invokedynamic)。

Runnable r = () -> System.out.println("hello");

编译后核心字节码类似:

invokedynamic #0:run:()Ljava/lang/Runnable;

JVM 在首次执行时调用 bootstrap method(通常是 LambdaMetafactory.metafactory),动态生成实现函数式接口的类实例。好处是:不额外生成 .class 文件、按需生成、性能更好。

反射的实现原理 #

Method.invoke() 在 HotSpot 中的典型路径:

  1. Native 调用:首次调用走 JNI,开销较大
  2. Inflation(膨胀):调用次数超过阈值(默认 15 次)后,JVM 生成字节码访问器(Generated Method Accessor),后续走普通方法调用,性能接近直接调用
  3. setAccessible(true):跳过 Java 访问检查,但仍需处理安全检查与模块限制(Java 9+)

反射打破了编译期类型约束,灵活性高,但首次调用和安全检查有额外成本。

Java Instrumentation 的原理 #

java.lang.instrument 包提供运行时修改字节码的能力,基于 JVMTI 实现。

两种挂载方式:

方式入口场景
Premainpremain(String, Instrumentation)JVM 启动时通过 -javaagent:agent.jar 加载
Agentmainagentmain(String, Instrumentation)运行时 Attach 到已有进程

常见能力:

  • addTransformer:类加载时转换字节码
  • retransformClasses / redefineClasses:对已加载类重新转换

应用:APM 探针(SkyWalking、Pinpoint)、热部署、单元测试 Mock、Arthas 等。

Just-In-Time (JIT) 编译器 #

《Java 性能权威指南》第四章介绍 JIT 编译器。

代码格式 11.【推荐】单个方法的总行数不超过 80 行。

说明:方法过长不利于 JIT 内联与优化,热点方法应保持精简。

JIT 将热点字节码编译为本地机器码,存入 Code Cache。Code Cache 大小有限,填满后新代码只能解释执行,性能下降。

常用 JVM 参数(HotSpot,默认值因版本和平台略有差异):

参数含义默认值(约)
InitialCodeCacheSizeCode Cache 初始大小255 KB(Client)/ 160 KB(Server)
ReservedCodeCacheSizeCode Cache 最大容量Java 8:240 MB;Java 11+:often 240 MB,可 -XX:ReservedCodeCacheSize=256m 调整
-XX:+UseCodeCacheFlushing允许在 Cache 满时 flush 冷代码视版本而定

字节码的应用 #

方法重载(静态分派):编译期根据参数类型选择方法,对应字节码中的 invokestatic / invokespecial 等,在类加载解析阶段即可确定目标。

方法重写(动态分派):父类引用指向子类对象时,运行时按实际类型选择方法,对应 invokevirtual / invokeinterface,是 JVM 多态的实现基础。

class Parent { void foo() { System.out.println("parent"); } }
class Child extends Parent { void foo() { System.out.println("child"); } }

Parent p = new Child();
p.foo();  // 输出 child,运行时分派

字节码增强与 Java Agent #

编译后的 .class 仍是可读写、可变换的中间产物。在类加载前(改磁盘文件)或类加载时(ClassFileTransformer),可以对字节码进行插入、替换、删除指令,从而在不改源码的情况下改变程序行为。

源码 (.java)
    ↓ javac
字节码 (.class)  ←── ASM / Javassist / Byte Buddy 读写、改写
    ↓ ClassLoader + Instrumentation
JVM 加载并执行增强后的类

常见框架对比 #

框架特点典型使用者
ASM直接操作字节码,性能高、控制细,API 偏底层Spring、CGLIB、Many agents
Javassist提供 Java 源码级 API(CtMethod 等),易上手早期 Hibernate、部分 AOP
Byte Buddy流式 API + 声明式,生成代码质量高,无需编译器Mockito、SkyWalking、Hibernate

Spring AOP 默认对 interface 用 JDK 动态代理,对 class 用 CGLIB(底层 ASM)生成子类;而 Java Agent 场景多在类加载阶段织入逻辑,两者切入点不同。

Java Agent 如何介入 #

Java Agent 通过 Instrumentation 接口注册 ClassFileTransformer

public class MyAgent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new MyTransformer(), true);
    }
}

类加载时,JVM 把原始 byte[] 交给 Transformer,返回修改后的字节码再加载。-javaagent:my-agent.jar 在进程启动时生效;Attach API 可在运行中对已存活 JVM 注入 Agent(Arthas 即此路径)。

典型应用场景 #

场景做法代表
AOP方法入口/出口插入拦截逻辑Spring AOP、AspectJ 编译织入
链路监控自动埋点,记录耗时、TraceIdSkyWalking、Pinpoint、Zipkin Agent
热修复替换有 bug 的方法体部分线上 patch 方案(需谨慎)
单元测试 Mock运行时生成子类/替身Mockito、PowerMock
诊断排查动态增强指定类观察行为Arthas watch / trace

简单示例:方法耗时统计 #

用 Javassist 在方法前后插入计时代码(示意):

CtMethod method = ctClass.getDeclaredMethod("doWork");
method.insertBefore("long _start = System.nanoTime();");
method.insertAfter("{ System.out.println(\"cost: \" + (System.nanoTime() - _start)); }");

等价的 ASM 需要手动维护操作数栈和局部变量表,代码更长,但性能与体积更优。Byte Buddy 则可以用更声明式的方式达到同样效果:

new ByteBuddy()
    .subclass(Foo.class)
    .method(named("doWork"))
    .intercept(MethodDelegation.to(TimingInterceptor.class))
    .make();

使用注意 #

  • 类加载顺序:被增强的类一旦加载,需 retransformClasses 才能再次修改
  • 性能开销:频繁插桩会增加方法入口成本,监控类 Agent 通常只增强业务包、排除 JDK 类
  • 兼容性:JDK 模块系统(Java 9+)限制对核心类的改写,Agent 需声明 Can-Redefine-Classes 等权限
  • 安全与合规:热修复、远程 Agent 注入涉及线上变更,需严格管控

更系统的实践可参考美团技术团队文章 字节码增强技术探索

参考资料 #