以下是JVM的整体架构,包括源文件通过javac编译成字节码文件,类加载系统通过加载、链接、初始化的过程把源文件加载到JVM运行时数据区,在运行时数据区域不同部分协调工作,执行方法、创建对象、分配和回收内存。最后执行引擎通过解释或编译字节码指令确保高效执行。
2.1 类加载器系统(Class Loader System)
源代码通过javac编译生成.class文件,之后JVM会使用类加载器读取.class文件,并将其加载到JVM内存中。
2.1.1 类加载器的结构
一个类加载器系统(Class Loader System)包括三个加载器:
- Bootstrap ClassLoader(启动类加载器):用于加载Java核心类库(如:
java.lang)。 - Extension ClassLoader(扩展类加载器):用于加载扩展类库。
- Application ClassLoader(应用类加载器):加载应用程序的类。
2.1.2 类加载过程
- Loading(加载):将
.class文件载入内存。 - Linking(链接)
- Verify(验证):确保字节码的正确性。
- Prepare(准备):为类分配静态存储空间并初始化默认值。
- Resolve(解析):将符号引用替换为直接引用。
- Initialization(初始化):初始化静态字段,执行静态代码块。
2.2 运行时数据区(Runtime Data Access)
当类加载过程结束后,Java程序进入运行阶段。
2.2.1 方法区(Method Area)
概述
- 存储元信息:
- 类的名称、父类、方法、字段等。
- 静态变量的定义和初始化值。
- 常量池(如字符串字面值和符号引用)。
- 方法区中的元信息拱JVM使用,不会直接参与对象实例化
- 方法区的数据所有线程共享。
用途
- 存储类信息,包含类的完整结构:
- 类的完全限定名:
java.lang.String。 - 父类名:
java.lang.Object。 - 修饰符:
public、abstract。 - 接口列表
- 字段、方法定义
- 存储方法的字节码
- JVM执行方法时所需字节码
- 方法入口地址、局部变量表、操作数栈等
- 静态变量
- 类中所有
static修饰的字段即静态变量存储在方法区中 - 静态变量属于类本身,与类的实例无关
- 常量池
- 存储编译期生成的常量,如字符串字面量(从Java 7开始,字符串常量池被转移到了堆区)、数字常量
- 运行时常量池用于动态生成常量(如调用
String.intern()时)
- 类加载器引用:方法区保留对这些类加载器的引用
实现 JVM规范并未明确方法区的实现方式,不同JVM实现方式可能不同。
- 在HotSpot JVM中
- 基于永久代(Permanent Generation,简称PermGen)(Java 8之前)
- 基于元空间(MetaSpace)实现
- PermGen(Java 7 及之前)
- 存储:类元数据、静态变量、常量池等
- 大小:通过JVM参数配置:
-XX:PermSize=<InitialSize> -XX:MaxPermSize=<MaxSize>
但是固定大小容易导致OutOfMemoryError(特别是在使用大量动态代理、反射或类加载时),并且性能开销较大,因为需要管理固定内存
3. Metaspace(Java 8及之后)
Metaspace将方法区转移本地内存(Native Memory)
- 特点:不再受JVM堆内存的限制;动态扩展大小,减少
OutOfMemoryError问题 - 使用以下参数调整大小:
-XX:MetaspaceSize=<InitialSize> -XX:MaxMetaspaceSize=<MaxSize>
2.2.2 堆区(Heap Area)
概述
- 对象分配:
- 所有通过
new关键字创建的对象(如对象实例、数组)都存储在堆区。 - JVM会动态分配堆内存以满足对象创建需求。
- 垃圾回收
- 不再被引用的对象会被垃圾回收器(Gabage Collector)回收,释放内存。
- 垃圾回收机制避免内存泄漏,优化内存使用。
- 堆区中的数据所有线程共享。
内存划分 Java堆是垃圾收集器管理的内存区域,因此也被称为GC堆,从回收内存的角度来看,由于现代垃圾回收器绝大部分都是基于分带收集理论设计的,所以Java堆中经常会出现新生代、老年代、永久代、元空间等名词。
分代优化了GC性能,为了进行高效的垃圾回收,虚拟机把堆内存从逻辑上划分成三块区域:
- 新生代
- 存储新创建的对象,大多数对象在生命初期会在新生代中分配内存
- 新生代通常分为Eden区和两个Survivor区(S0和S1)
- 新生代中的对象存活时间较短,一般经过几次垃圾回收后,如果对象仍然存活,就会被晋升到老年代
- 老年代
- 老年代存储的是长时间存活的对象,即哪些经过多次垃圾回收仍然没有被回收的对象。
- 老年代垃圾回收通常发生得较少,因为其垃圾回收代价较大。
- 永久代/元空间
- 永久代:Java 7 之前版本的堆区的部分,存储类的元数据(如类的结构信息、方法字节码、静态变量等)
- 元空间:从Java 8开始,永久代被移除,取而代之的是元空间,存储类元数据,元空间存储在本地内存而不是JVM堆内存中。
内存管理
- 内存分配
- 对象创建:当程序创建对象时,JVM在堆区中分配内存,存储对象的数据,Java对象的内存分配通常由对象分配算法来处理
- 数组分配:数组是对象,因此它也在堆区分配,数组长度在运行时决定,需要动态分配内存。
2. 垃圾回收 JVM使用垃圾回收器(GC)自动管理堆区的内存回收,堆区的内存回收主要分为以下几类
- Minor GC: 发生在新生代,主要回收新创建的短命对象,快速频繁。
- Major GC/Full GC: 发生在老年代,回收长时间存活的对象,通常较慢,频率较低,但耗时长。 堆区采用以下垃圾回收算法:
- 标记清除算法(Mark-Sweep):遍历堆区对象,标记所有可达对象,然后清除不可达对象。
- 标记复制算法(Mark-Copy):将对象从一个区域复制到另一个区域,以此清理垃圾。
- 标记整理算法(Mark-Compact):在标记过程中将存活的对象压缩到堆区的一端,避免内存碎片。
- 分片收集:基于对象的生命周期不同,将堆区分为新生代和老年代,采用不同的垃圾回收策略 GC的触发条件:
- 堆内存空间不足:当堆区的内存使用达到一定阈值时,垃圾回收就会触发
- System.gc():开发者可以手动调用
System.gc()来建议JVM执行垃圾回收 常见的垃圾回收器: - Serial GC:单线程垃圾回收器,用于单核机器或小型应用
- Parallel GC:多线程垃圾回收器,用于多核机器,可以并行处理垃圾回收任务。
- CMS(Concurrent Mark-Sweep) GC:尽量减少应用时间的垃圾回收器
- G1(Garbage First) GC:JVM提供的最新垃圾回收器,适用于大堆内存,能平衡GC的停顿时间和吞吐量。
3. 堆区调优
- 堆的初始大小:
-Xms<size> - 堆的最大大小:
-Xmx<size> - 新生代大小:
-XX:NewSize=<size> //新生代大小
-XX:MaxNewSize=<size> //新生代最大大小
- 垃圾回收器选择
-XX:+UseParallelGC //使用Parallel GC
-XX:+UseG1GC //使用G1 GC
2.2.3 栈区(Stack Area)
概述
栈区也称为虚拟机栈(Virtual Machine Stacks),每个线程在创建的时候都会创建一个虚拟机栈,其内部保存了一个个的栈帧,用来存储方法调用。虚拟机栈是线程私有的,随着线程的创建而创建,线程的终止而销毁。
- 线程独立栈:
- 每个线程都会分配自己的栈区域,用来存储方法调用的栈帧(Stack Frame)。
- 栈帧结构:
- 局部变量表:存储局部变量(如方法参数、临时变量)。
- 操作数栈:存储方法执行过程中的中间结果。
- 方法返回地址:记录方法执行后的跳转地址
- 每次方法调用都会生成一个栈帧,方法执行完毕后,栈帧会被销毁(出栈)。
栈是一种快速的分配存储方式,访问速度仅次于程序计数器。在JVM对虚拟机栈执行操作时,每执行一个方法就会伴随压栈,方法执行结束后出栈。
栈可能出现的异常:
- 如果是固定大小的Java虚拟机栈:如果线程请求分配的栈容量大于Java虚拟机栈允许的最大容量,则会抛出JVM会抛出
StackOverflowError异常。 - 如果Java虚拟机栈可以动态扩展,并且在尝试扩展时没有申请到足够内存,或者在创建新的线程时没有足够的内存分配给新的虚拟机栈,则JVM会抛出
OutOfMemoryError异常。 - 可以通过
-Xss设置栈的最大空间。
什么是栈帧?
- 每个线程都有自己的栈,栈帧是存储栈中数据的基本单位。
- 一个线程上执行的每个方法都有各自对应的一个栈帧。
- 栈帧是一个内存区域,用于存储方法执行过程中的数据。
栈运行原理
- 在一个活动线程中,同一时间只会有一个活动的栈帧,称为当前栈帧。当前栈帧对应当前执行的方法,称为当前方法,当前方法所属的类称为当前类。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 方法中调用了其他方法,新的栈帧会被创建出来,放在栈顶,成为新的当前栈帧。在方法返回时,当前栈帧会传回此方法的执行结果给前一个栈帧,然后JVM销毁当前栈帧,前一个栈帧重新称为当前栈帧。
- 不同线程所包含的栈帧不能互相引用
- Java方法返回函数的方式一种是return指令返回,另一种是抛出异常,不管哪种方式都会导致栈帧被弹出。
IDEA Debug以下代码,可以在Debug窗口中看到各种方法对应的栈帧入栈和出栈情况:
package base;
public class Stack {
public void method1(){
System.out.println("执行了method1方法。");
method2();
}
public void method2(){
System.out.println("执行了method2方法。");
method3();
}
public void method3(){
System.out.println("执行了method3方法。");
method4();
}
public void method4(){
System.out.println("执行了method4方法。");
method5();
}
public void method5(){
System.out.println("执行了method5方法。");
method6();
}
public void method6(){
System.out.println("执行了method6方法。");
}
public static void main(String[] args) {
Stack stack = new Stack();
stack.method1();
}
}
栈帧的内部结构
每个栈帧包含了以下部分:
- 局部变量表
- 用于存储方法的局部变量,包括:方法参数、方法内的局部变量。
- 局部变量表通过索引下标访问,索引从0开始。
- 不同数据类型占用不同槽位
int、float:占用一个槽位。long、double:占用两个槽位。- 引用类型(如对象引用、字符串):占一个槽位。
- 操作数栈
- 用于操作数的临时存储,方法执行的过程中会频繁使用。
- 栈式计算模型:JVM指令执行时,操作数会被压入或弹出操作数栈
- 动态链接
- 每个栈顶包含一个指向方法运行时常量池的引用,用于支持动态链接。
- 动态链接是为了解决方法调用时的符号引用解析为实际的内存地址。
- 方法返回地址
- 存储调用方法返回时的指令地址。
- 如果方法通过异常完成,返回地址可能为空。
- 附加信息:JVM实现可能需要存储其他额外信息,如调试信息
2.2.4 程序计数器(PC Register)
程序计数器用于跟踪指令执行:
- 每个线程都有自己的程序计数器,存储当前线程正在执行的字节码指令地址。
- 在方法调用时,程序计数器会更新以跳转到新方法的指令地址
2.2.5 本地方法栈(Native Method Stack)
概述 调用本地方法:如果代码中调用JNI(Java Native Interface)方法或底层操作系统相关的功能(如文件操作、设备操作),就会通过本地方法栈实现。
为什么要用本地方法?
- Java应用有时需要与Java外面的环境交互
- JVM的一些指令需要操作系统支持。通过本地方法,可以实现Java与实现了jre的底层系统交互。
特点
- 本地方法栈也是线程私有的。
- 如果线程请求的栈容量超过本地方法栈运行的最大容量,会抛出
StackOverflowError异常;如果本地方法栈可动态扩展,并且在尝试扩展时没有申请到足够内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,会抛出OutOfMemoryError异常。 - 基于C或C++实现。
- 本地方法通常用于操作底层硬件或操作系统资源、提高性能(关键部分用C/C++实现)、使用现成的非Java库
- 定义本地方法:本地方法通过
native关键字声明,没有具体实现体,方法的实现由外部的本地库提供:
public class NativeExample {
public native void nativeMethod();
}
- 本地方法栈包括:局部变量、操作数站、动态链接信息、返回地址
运行原理
- 调用本地方法
- 加载本地库:通过
System.loadLibrary();加载本地动态链接库。
static {
//nativeLib是动态链接库名称,如nativeLib.dll
System.loadLibrary("nativeLib");
}
- 通过JNI接口调用本地方法,JVM将控制权交给本地方法栈
- 执行本地代码:本地方法在本地方法栈执行,本地方法可以通过JNI调用JVM其他功能。
- 返回到JVM
- 本地方法执行完毕后,将结果返回到调用者(可能是JVM或另一个本地方法)
- 如果是本地方法抛出异常,则会返回到JVM, 由Java程序处理。
2.2.6 执行引擎(Execution Engine)活动
- 字节码解释和执行:执行引擎负责将加载到内存的字节码指令。
- 即时编译(JIT): 对热点代码进行优化,编译成高效的本地机器代码以提升性能。
- 垃圾回收:执行引擎触发垃圾回收器(GC)回收无用对象,减少内存使用。
在Java程序运行阶段,运行时数据区域不同部分协调工作,执行方法、创建对象、分配和回收内存。方法和对象的元信息存储在方法区和堆区中,栈区位每个线程管理方法调用和局部变量,程序计数器和本地房发展保证指令的正确执行和外部调用。执行引擎通过解释或编译字节码指令确保高效执行。
2. 代码执行的流程
2.1 源代码文件编译成字节码文件
使用IDEA创建了一个JVM类,在JVM类中有一个main函数调用了一个私有的静态方法add():
运行这个类,会在
target文件夹中生成对应的字节码文件JVM.class:
JVM.class文件实际上是二进制文件,包含了Java字节码。只不过打开后看到的内容被解释为了可见的文本字符。字节码是一种中间语言,提供了JVM执行的指令集。
在Windows操作系统中,可以通过以下指令查看文件的二进制数据:
Get-Content JVM.class -Encoding Byte | % { [Convert]::ToString($_, 2).PadLeft(8, '0') }
得到部分输出结果:
我们通过java提供的字节码反汇编工具
javap将Java编译后的字节码指令返回变成可读的字节码指令。
使用以下命令查看字节码:
javap -c JVM.class
得到输出内容:
字节码解释:
aload_0
- 将局部变量表中索引为
0的值加载到操作数栈中。 - 索引
0通常是this引用(对于非静态方法)。
invokespecial #1:
- 调用父类
java/lang/Object的构造方法<init>。 #1是常量池中的引用,指向方法Object.<init>()。
return:方法执行完毕,返回给调用者。iconst_1:将整数1压入操作数栈。istore_1:将栈顶元素压入局部变量表索引1(局部变量是按照索引存储的)。iconst_2:将整数2压入操作数栈。istore_2:将栈顶元素压入局部变量表索引2。iload_1:将局部变量表索引为1的值加载到操作数栈。iload_2:将局部变量表索引为2的值加载到操作数栈。invokestatic #7
- 调用静态方法
add(int,int)。 #7是常量池中的引用,指向add:(II)I,即add方法的描述符。- 将操作数栈的顶部两个值作为参数传递给
add方法
istore_3
- 将静态方法
add返回的值3存入局部变量表索引为3的位置。 - 索引
3对应int sum = JVM.add(a,b);
return:方法执行完毕,程序正常退出。
2.2 JVM执行字节码文件
当源代码被编译成字节码文件后,通过类加载系统将字节码加载到内存,并传递给运行时数据区。当类加载完成后,字节码由JVM执行引擎执行,同时运行时数据存储在运行时数据区中。
对字节码文件进行解释和编译: 执行引擎将字节码翻译成底层机器代码,通过解释器或即时编译之一的方式运行:
- 解释器:逐条解释并执行字节码指令
- JIT编译器:将热点字节码段编译成本地机器码,编译后直接运行在物理CPU上,以提高性能。 同时,执行引擎需要配合运行时数据区,完成字节码的运行。
参考:
- 周志明. 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)[M]. 北京: 机械工业出版社, 2021.
- JVM 基础 - JVM 内存结构 | Java 全栈知识体系