JVM和字节码基础
第一部分:JVM概述
1. 为什么需要JVM?
C语言:不能跨平台运行!
- 跨平台特性:一次编写,到处运行(Write Once, Run Anywhere)
- Java源代码(.java) → 字节码(.class) → 各平台JVM执行
- class文件本质是一个二进制字节流
- 内存管理:自动垃圾回收机制
- 安全沙箱:限制恶意代码的执行
2. JVM家族
| 虚拟机类型 | 特点 |
|---|---|
| HotSpot VM | Oracle/OpenJDK默认,使用最广泛 |
| JRockit VM | BEA开发,专注于服务器端性能 |
| J9 VM | IBM开发,模块化设计 |
| Dalvik VM | Android使用,基于寄存器架构 |
第二部分:JVM架构
1. 核心组件
JVM 大致可以划分为三个部分,分别是
类加载器(Class Loader)
JVM 需要将编译后的字节码文件加载到其内部的运行时数据区域中进行执行。这个过程涉及到了Java的类加载机制(双亲委派机制)。
什么是类加载?
类加载:加载、验证、准备、解析、初始化、使用和卸载。
1)Loading(载入):将字节码从不同的数据源转化为二进制字节流加载到内存中,并生成一个代表该类的java.lang.Class 对象。
2)Verification(验证):对二进制字节流进行校验
3)Preparation(准备):对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化。
4)Resolution(解析):该阶段将常量池中的符号引用转化为直接引用。
5)Initialization(初始化):类变量将被赋值为代码期望赋的值。换句话说,执行类构造器方法的过程。
运行时数据区(Runtime Data Areas)
JVM 定义了Java 程序运行期间需要使用到的内存区域,简单来说,这块内存区域存放了字节码信息以及程序执行过程的数据,垃圾收集器也会针对运行时数据区进行对象回收的工作
方法区、堆、虚拟机栈、本地方法栈以及程序计数器(PC寄存器)
• PC寄存器:当前运行方法的字节码指令地址。(线程私有)
• 本地方法栈:运行本地方法(java代码调用其它语言)。Native方法参数、局部变量。(线程私有)
• 虚拟机栈(栈):存储方法调用和局部变量。栈帧:包括方法参数、返回地址、操作数栈等。
• 堆:存储Java运行时所有的对象实例。
• 方法区:存储类信息、常量、静态变量等。
JDK 1.6、JDK 1.7、JDK 1.8 的内存划分都会有所不同。
• JDK 7 之前,只有常量池的概念,都在方法区中。
• JDK 7 的时候,字符串常量池从方法区中拿出来放到了堆中,运行时常量池还在方法区中(也就是永久代中)。
• JDK 8 的时候,HotSpot 移除了永久代,取而代之的是元空间。字符串常量池还在堆中,而运行时常量池跑到了元空间。
执行引擎(Excution Engine)
将字节码指令解释/编译为对应平台上的本地机器指令。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
• 解释器:读取字节码,然后执行指令。因为它是一行一行地解释和执行指令,所以它可以很快地解释字节码,是执行起来会比较慢(毕竟要一行执行完再执行下一行)。
• 即时编译器:执行引擎首先按照解释执行的方式来执行,随着时间推移,即时编译器会选择性的把一些热点代编译成本地代码。执行本地代码比一条一条进行解释执行的速度快很多,因为本地代码是保存在缓存里的。
• 垃圾回收器,用来回收堆内存中的垃圾对象。
graph TD
A[JVM] --> B[类加载子系统]
A --> C[运行时数据区]
A --> D[执行引擎]
C --> E[方法区]
C --> F[堆]
C --> G[虚拟机栈]
C --> H[本地方法栈]
C --> I[程序计数器]
D --> J[解释器]
D --> K[JIT编译器]
D --> L[垃圾回收器]
2. 运行时数据区
| 区域 | 存储内容 | 线程共享 | 异常类型 | GC管理 |
|---|---|---|---|---|
| 方法区 | 类信息、常量、静态变量 | 是 | OutOfMemoryError | 是 |
| 堆 | 对象实例、数组 | 是 | OutOfMemoryError | 是 |
| 虚拟机栈 | 栈帧(局部变量、操作数栈等) | 否 | StackOverflowError | 否 |
| 本地方法栈 | Native方法调用 | 否 | StackOverflowError | 否 |
| 程序计数器 | 字节码指令地址 | 否 | 无 | 否 |
1.程序计数器
• 程序计数器(Program Counter Register)所占的内存空间不大,很小很小一块,可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器会在工作的时候改变这个计数器的值来选取下一条需要执行的字节码指令,像分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
• 在JVM 中,多线程是通过线程轮流切换来获得CPU 执行时间的,因此,在任一具体时刻,一个CPU 的内核只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,并且不能互相干扰,否则就会影响到程序的正常执行次序。即程序计数器是线程私有的。
• 如果线程执行的是非本地方法,则程序计数器中保存的是当前需要执行的指令地址;如果线程执行的是本地方法,则程序计数器中的值是undefined。因为本地方法大多是通过C/C++ 实现的,并未编译成需要执行的字节码指令。
2.虚拟机栈
1、JVM 完成.class 文件加载之后,会创建一个名为”main”的线程,该线程会自动调用名为”main”的静态方法,这是Java 程序的入口点;
2、main 线程在执行main 方法时,JVM 会在虚拟机栈中压入main 方法对应的栈帧;
3、栈帧的操作数栈中存储了操作的数据,JVM 执行字节码指令的时候会从操作数栈中获取数据,执行计算操作后会将结果再次压入操作数栈中;
4、当进行calculate 方法调用的时候,虚拟机栈继续压入calculate 方法对应的栈帧
5、PC 寄存器中存储了下一条需要执行的字节码指令地址。
6、当calculate 方法执行完成后,对应的栈帧将从虚拟机栈中弹出,方法执行的结果会被压入main 栈帧中的操作数栈中,而方法返回地址被重置到main 线程的PC 寄存器中,以便于后续字节码执行引擎从PC 寄存器中获取下一条命令的地址。
3.栈帧
虚拟机栈操作的基本元素就是栈帧,栈帧主要包含了局部变量表、操作数栈、动态连接以及方法返回地址。栈帧是一个先进后出的数据结构,每个方法从调用到执行完成都会对应一个栈帧在虚拟机栈中入栈和出栈。
4.动态链接
每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。
动态链接是实现Java多态特性的基础机制:
•在运行时确定实际要调用的方法版本
1.方法引用存储:每个栈帧保存一个指向运行时常量池中方法符号引用的指针
2.符号引用解析:第一次使用时,将符号引用解析为直接引用(方法实际入口地址)
3.方法调用:通过这个连接找到并执行目标方法
符号引用是一组描述性的字符串,用来唯一标识一个类、方法、字段等目标,与虚拟机的内存布局无关。
直接引用是能够直接定位到目标的指针、偏移量或句柄,与虚拟机的内存布局相关。
5.常量池、运行时常量池、字符串常量池
常量池:存放于字节码里,字节码文件的资源仓库。
运行时常量池:运行时期间,JVM 会将字节码文件中的常量池加载到内存中,存放在运行时常量池中。
存放编译器生成的
• 各种字面量
• numeric literals
• string literals
• 符号引用
• class references
• field references
• method references
字符串常量池:字符串常量池,用于存放字符串常量的运行时的对象的引用。JDK 1.6及之前字符串常量池位于方法区,JDK 1.7之后字符串常量池被移至堆中。
• String中==和equals()的区别
• 直接赋值和new String的区别
String s1 = new String("hello"); // 在堆上创建新对象 String s2 = s1.intern(); // 返回常量池中的引用 String s3 = "hello"; // 直接使用常量池中的引用 System.out.println(s1 == s2); // false System.out.println(s2 == s3); // true
String.intern()
•当调用intern() 方法时,JVM 会检查字符串常量池中是否已经存在内容相同的字符串
•如果存在,则直接返回常量池中的引用
•如果不存在,则将该字符串对象添加到常量池,并返回其引用
6.方法区
第三部分:类文件结构
1. Class文件格式
ClassFile {
u4 magic; // 魔数CAFEBABE
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]; // 属性表
}
魔数:第一行中有一串特殊的字符cafebabe,它就是一个魔数,是JVM 识别class 文件的标志,JVM 会在验证阶段检查class 文件是否以该魔数开头,如果不是则会抛出ClassFormatError。
版本号:魔数后面的四个字节0000 0041 分别表示副版本号和主版本号。也就是说,主版本号为65(0x41 的十进制),也就是Java 21 对应的版本号,副版本号为0。
决定了该类文件可以被哪个版本的Java虚拟机(JVM)加载和执行。每个版本的Java平台都会引入新的特性或优化,而这些新特性或优化通常需要相应的字节码版本号来支持。
常量池:版本号之后的是常量池,它包含了类、接口、字段和方法的符号引用,以及字符串字面量和数值常量。这些信息在编译时被创建,并在运行时被Java虚拟机(JVM)使用。
相当于一个资源仓库,主要存放量大类型常量:
•字面量(Literals):字面量是不变的数据,主要包括数值(如整数、浮点数)和字符串字面量。例如,一个整数100或一个字符串”Hello World”,在源代码中直接赋值,编译后存储在常量池中。
•符号引用(Symbolic References):符号引用是对类、接口、字段、方法等的引用,它们不是由字面量值给出的,而是通过符号名称(如类名、方法名)和其他额外信息(如类型、签名)来表示。这些引用在类文件中以一种抽象的方式存在,它们在类加载时被虚拟机解析为具体的内存地址。
前两个字节是常量的个数,十六进制的22,十进制的34,但一共只有33个常量,因为从1开始计数。每一个常量都是个常量表。在JDK1.7之前共有11种不同结构的常量表,在JDK 1.7中又额外增加了3种常量表。每个常量表都是以u1类型的标志位开始,来区分常量表的类型。
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
访问标记:常量池之后的区域就是访问标记(Access flags),这个标记用于识别类或接口的访问信息,比如说:
•到底是class 类还是interface 接口?
•是public 吗?
•是abstract 抽象类吗?
•是final 类吗?
类索引、父类索引和接口索引:这三部分用来确定类的继承关系,this_class 为当前类的索引,super_class 为父类的索引,interfaces 为接口。
字段表:一个类中定义的字段会被存储在字段表。
方法表:方法表和字段表类似,区别是用来存储方法的信息,包括方法名,方法的参数,方法的签名。
2. 常量池类型
| 类型 | 标志 | 描述 |
|---|---|---|
| CONSTANT_Class | 7 | 类或接口符号引用 |
| CONSTANT_Fieldref | 9 | 字段符号引用 |
| CONSTANT_Methodref | 10 | 方法符号引用 |
| CONSTANT_String | 8 | 字符串字面量 |
| CONSTANT_Utf8 | 1 | UTF-8编码字符串 |
总结
•class 文件是一串连续的二进制,由0 和1 组成,但我们仍然可以借助一些工具来看清楚它的真面目。
•class 文件的内容通常可以分为下面这几部分,魔数、版本号、常量池、访问标记、类索引、父类索引、接口索引、字段表、方法表、属性表。
•常量池包含了类、接口、字段和方法的符号引用,以及字符串字面量和数值常量。
| •访问标记用于识别类或接口的访问信息,比如说是不是public | private | protected,是不是static,是不是final 等。 |
•类索引、父类索引和接口索引用来确定类的继承关系。
•字段表用来存储字段的信息,包括字段名,字段的参数,字段的签名。
•方法表用来存储方法的信息,包括方法名,方法的参数,方法的签名。
•属性表用来存储属性的信息,包括字段的初始值,方法的字节码指令等。
第四部分:字节码指令集
1. 指令分类
- 加载/存储:
iload,istore,aload,astore - 运算指令:
iadd,isub,imul,idiv - 类型转换:
i2l,f2d,d2i - 对象操作:
new,getfield,putfield - 控制转移:
ifeq,goto,tableswitch - 方法调用:
invokevirtual,invokestatic,invokespecial
2. 方法调用示例
// 源代码
String s = "Hello";
System.out.println(s);
// 对应字节码
0: ldc #2 // String Hello
2: astore_1
3: getstatic #3 // Field java/lang/System.out
6: aload_1
7: invokevirtual #4 // Method java/io/PrintStream.println
第五部分:字节码执行过程
1. 方法执行示例
public static int add(int a, int b) {
return a + b;
}
对应字节码:
0: iload_0 // 加载参数a
1: iload_1 // 加载参数b
2: iadd // 执行加法
3: ireturn // 返回结果
执行过程:
- 创建栈帧并入栈
- 局部变量表存储参数a和b
- 操作数栈进行加法运算
- 返回结果并弹出栈帧
2. 栈帧结构
┌─────────────────┐
│ 栈帧 Stack Frame │
├─────────────────┤
│ 局部变量表 Local Vars │
├─────────────────┤
│ 操作数栈 Operand Stack│
├─────────────────┤
│ 动态链接 Dynamic Linking│
├─────────────────┤
│ 方法返回地址 Return Address│
└─────────────────┘
第六部分:类加载机制
1. 加载过程
- 加载:查找并加载字节码
- 验证:确保字节码符合规范
- 准备:为静态变量分配内存
- 解析:将符号引用转为直接引用
- 初始化:执行静态代码块
加载、验证、准备、解析、初始化、使用和卸载。
1)Loading(载入):将字节码从不同的数据源转化为二进制字节流加载到内存中,并生成一个代表该类的java.lang.Class 对象。
2)Verification(验证):对二进制字节流进行校验
3)Preparation(准备):对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化。
4)Resolution(解析):该阶段将常量池中的符号引用转化为直接引用。
5)Initialization(初始化):类变量将被赋值为代码期望赋的值。换句话说,执行类构造器方法的过程。
2. 双亲委派模型
• 双亲委派机制的基本思想是:当一个类加载器试图加载某个类时,它会先委托给其父类加载器,如果父类加载器无法加载,再由当前类加载器自己进行加载。
• 这种层层委派的方式有助于保障类的唯一性,避免类的重复加载,并提高系统的安全性和稳定性。
- 启动类加载器(Bootstrap Class Loader):负责加载%JAVA_HOME%/jre/lib 目录下的核心Java类库,比如:rt.jar、charsets.jar等。它是最顶层的类加载器,通常由C++编写。
- 扩展类加载器(Extension Class Loader):负责加载Java的扩展库,一般位于
/lib/ext目录下。 - 应用程序类加载器(Application Class Loader):也称为系统类加载器,负责加载用户类路径(ClassPath)下的应用程序类。
- 自定义类加载器:用户没法控制,加载自己想要的一些类。
启动类加载器(Bootstrap)
↑
扩展类加载器(Extension)
↑
应用程序类加载器(Application)
↑
自定义类加载器(Custom)
工作原理:自底向上检查类是否已加载,自顶向下尝试加载类
第七部分:内存模型
1. 堆内存结构
┌───────────────────────┐
│ 堆 Heap │
├───────────┬───────────┤
│ 年轻代 Young Generation │ 老年代 Old Generation │
├─────┬─────┤ │
│ Eden │ Survivor │ │
└─────┴─────┴───────────┘
2. 字符串常量池
- JDK7前位于方法区(PermGen)
- JDK7+移至堆内存
intern()方法示例:String s1 = new String("Hello"); // 堆中创建对象 String s2 = s1.intern(); // 返回常量池引用 System.out.println(s1 == s2); // false
第八部分:关键案例分析
1. 条件语句编译
if (n > 0) {
return 1;
} else {
return 0;
}
对应字节码:
0: iload_1
1: ifle 6 // 如果n<=0跳转到6
4: iconst_1
5: ireturn
6: iconst_0
7: ireturn
2. 循环语句编译
for (int i=0; i<10; i++) { /*...*/ }
对应字节码:
0: iconst_0 // i=0
1: istore_1
2: iload_1 // 循环开始
3: bipush 10
5: if_icmpge 18 // 比较i和10
8: iinc 1, 1 // i++
11: goto 2 // 跳回循环开始
18: return
本讲义系统性地介绍了JVM的核心原理和字节码执行机制,通过理论结合实例的方式帮助理解Java程序的底层运行原理。建议配合实际代码反编译(javap -v)进行实践学习。