Java虚拟机架构与原理
Java虚拟机概述
Java虚拟机(JVM,Java Virtual Machine)是Java平台的核心组件,它使得Java程序能够实现“一次编写,到处运行”的特性。JVM在不同的操作系统上提供了一个统一的运行环境,屏蔽了底层操作系统和硬件的差异。从本质上讲,JVM是一个虚拟的计算机,它有自己的指令集、寄存器、内存管理等机制。
在Java开发过程中,我们编写的Java源文件(.java
)首先经过Java编译器(javac
)编译成字节码文件(.class
)。字节码是一种中间表示形式,它不是针对特定硬件平台的机器码,而是可以被JVM识别和执行的指令集。JVM加载并执行这些字节码,从而实现Java程序的运行。
JVM的架构
JVM主要由以下几个部分组成:类加载子系统、运行时数据区、执行引擎和本地方法接口。
类加载子系统
类加载子系统负责将字节码文件加载到JVM中,并生成对应的Class对象。它包括三个主要的类加载器:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用程序类加载器(Application ClassLoader),它们构成了一个父子层级关系的类加载器树。
- 启动类加载器:它是最顶层的类加载器,由C++实现,负责加载Java核心类库,如
java.lang
包下的类。这些类存放在$JAVA_HOME/jre/lib
目录下,或者由-Xbootclasspath
参数指定的路径中。由于启动类加载器是用C++实现的,在Java代码中无法直接获取到它的引用。 - 扩展类加载器:由Java语言实现,继承自
ClassLoader
类,负责加载$JAVA_HOME/jre/lib/ext
目录下的类库,或者由系统属性java.ext.dirs
指定的路径中的类库。这些类库通常是对Java核心类库的扩展。 - 应用程序类加载器:也由Java语言实现,同样继承自
ClassLoader
类,它是ClassLoader
类中的getSystemClassLoader()
方法的返回值,所以也被称为系统类加载器。它负责加载应用程序的类路径(classpath
)下的所有类,也就是我们自己编写的Java类以及通过Maven、Gradle等构建工具引入的第三方类库。
类加载器在加载类时遵循双亲委派模型。当一个类加载器收到类加载请求时,它首先不会自己尝试去加载这个类,而是把请求委派给父类加载器去完成,每一层的类加载器都是如此,只有当父类加载器反馈自己无法完成这个加载请求(在它的搜索范围中没有找到所需的类)时,子类加载器才会尝试自己去加载。这种模型保证了Java核心类库的安全性和一致性,避免了用户自定义的类覆盖Java核心类库中的类。
以下是一个简单的代码示例,用于展示如何获取不同的类加载器:
public class ClassLoaderExample {
public static void main(String[] args) {
// 获取系统类加载器(应用程序类加载器)
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println("系统类加载器: " + systemClassLoader);
// 获取系统类加载器的父类加载器(扩展类加载器)
ClassLoader extensionClassLoader = systemClassLoader.getParent();
System.out.println("扩展类加载器: " + extensionClassLoader);
// 获取扩展类加载器的父类加载器(启动类加载器)
ClassLoader bootstrapClassLoader = extensionClassLoader.getParent();
System.out.println("启动类加载器: " + bootstrapClassLoader);
// 获取自定义类的类加载器
ClassLoaderExample example = new ClassLoaderExample();
ClassLoader classLoader = example.getClass().getClassLoader();
System.out.println("自定义类的类加载器: " + classLoader);
}
}
在上述代码中,我们通过ClassLoader.getSystemClassLoader()
获取应用程序类加载器,然后通过getParent()
方法获取其父类加载器,即扩展类加载器。由于启动类加载器是由C++实现的,在Java代码中获取其引用会返回null
。
运行时数据区
运行时数据区是JVM在运行Java程序时管理内存的区域,它主要分为以下几个部分:
-
程序计数器(Program Counter Register):每个线程都有一个独立的程序计数器,它是一块较小的内存空间,用于记录当前线程执行的字节码指令的地址。如果当前线程正在执行一个Java方法,那么程序计数器记录的是正在执行的字节码指令的地址;如果当前线程正在执行一个本地(Native)方法,那么程序计数器的值为空(Undefined)。程序计数器是JVM执行多线程的基础,通过它可以保证每个线程都能独立地执行自己的任务,互不干扰。
-
Java虚拟机栈(Java Virtual Machine Stack):同样每个线程都有一个独立的Java虚拟机栈,它与线程的生命周期相同。虚拟机栈用于存储栈帧(Stack Frame),每个方法的调用都会创建一个栈帧并压入栈中,方法执行完毕后,栈帧从栈中弹出。栈帧包含了局部变量表、操作数栈、动态链接和方法返回地址等信息。
-
局部变量表:用于存储方法中的局部变量,包括方法参数和方法内部定义的局部变量。局部变量表的大小在编译时期就已经确定,它的单位是变量槽(Slot),每个变量槽可以存放一个基本数据类型(如
int
、short
、boolean
等)或者一个引用类型(如对象引用、数组引用等)。对于64位的基本数据类型(如long
和double
),需要占用两个变量槽。 -
操作数栈:也称为表达式栈,它是一个后进先出(LIFO)的栈结构。在方法执行过程中,字节码指令会将操作数压入操作数栈,然后从操作数栈中弹出操作数进行运算,并将运算结果压回操作数栈。例如,对于
i = j + k
这样的表达式,首先会将j
和k
的值压入操作数栈,然后执行加法指令,从操作数栈中弹出j
和k
,计算它们的和,再将结果压入操作数栈,最后将操作数栈中的结果存储到局部变量表中的i
变量槽中。 -
动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,这个引用用于支持方法调用过程中的动态链接。在Java中,方法的调用分为静态链接和动态链接。静态链接是在编译时期就确定了方法的调用版本,而动态链接是在运行时期根据实际对象的类型来确定方法的调用版本,也就是多态的实现原理。
-
方法返回地址:当一个方法执行完毕后,需要返回到调用它的方法继续执行。方法返回地址就是记录方法执行完毕后应该返回的字节码指令地址。方法返回有两种方式,一种是正常返回,通过
return
指令;另一种是异常返回,通过抛出异常。无论是哪种方式,都会根据方法返回地址返回到调用方法的适当位置继续执行。
-
-
本地方法栈(Native Method Stack):与Java虚拟机栈类似,本地方法栈也是每个线程独有的,它用于支持Java程序对本地方法(使用C、C++等语言编写的方法)的调用。当Java程序调用一个本地方法时,JVM会在本地方法栈中为该本地方法创建一个栈帧,用于存储本地方法的局部变量、操作数栈等信息。本地方法栈的实现方式和所使用的语言与具体的JVM实现相关,有些JVM实现可能会将本地方法栈和Java虚拟机栈合二为一。
-
堆(Heap):堆是JVM中最大的一块内存区域,它被所有线程共享,用于存储对象实例以及数组。Java堆是垃圾回收(Garbage Collection,GC)的主要区域,因此也被称为GC堆。堆在逻辑上可以分为新生代(Young Generation)和老年代(Old Generation),新生代又可以进一步细分为伊甸园区(Eden Space)和两个幸存者区(Survivor Space,通常分别称为S0和S1)。对象的内存分配首先在伊甸园区进行,当伊甸园区满时,会触发一次Minor GC(新生代垃圾回收),将伊甸园区中存活的对象移动到其中一个幸存者区(假设为S0),同时清空伊甸园区。随着对象在幸存者区之间的移动和年龄的增长(每经历一次Minor GC,对象的年龄加1),当对象的年龄达到一定阈值(默认为15)时,会被晋升到老年代。当老年代空间不足时,会触发Major GC(也称为Full GC,整堆垃圾回收),回收老年代和新生代的垃圾对象,释放内存空间。
以下是一个简单的对象创建和内存分配的代码示例:
public class HeapAllocationExample {
public static void main(String[] args) {
// 创建对象,对象会分配在堆内存中
HeapAllocationExample example = new HeapAllocationExample();
// 创建数组,数组同样分配在堆内存中
int[] array = new int[10];
}
}
在上述代码中,HeapAllocationExample
对象和int
类型的数组array
都被分配在堆内存中。
- 方法区(Method Area):方法区也是被所有线程共享的内存区域,它用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在JDK 7及之前,方法区的实现是永久代(Permanent Generation),它是堆的一个逻辑部分,但使用的是独立于Java堆的内存空间。从JDK 8开始,方法区的实现改为元空间(Metaspace),元空间不再使用堆内存,而是直接使用本地内存(Native Memory),这样可以避免永久代在内存管理上的一些问题,如内存溢出等。
方法区中存储的类信息包括类的全限定名、类的访问修饰符(如public
、private
等)、类的父类信息、实现的接口信息、字段信息、方法信息等。常量池(Constant Pool)是方法区的一部分,它用于存储编译时期生成的各种字面量(如字符串常量、整型常量等)和符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等)。在运行时期,这些符号引用会被解析为直接引用(如对象的内存地址)。
以下是一个关于常量池的代码示例:
public class ConstantPoolExample {
public static void main(String[] args) {
String str1 = "Hello";
String str2 = "Hello";
String str3 = new String("Hello");
System.out.println(str1 == str2); // true,因为字符串常量在常量池中是共享的
System.out.println(str1 == str3); // false,因为new String()创建的是新的对象
}
}
在上述代码中,str1
和str2
指向常量池中的同一个字符串常量,所以str1 == str2
返回true
。而str3
是通过new String("Hello")
创建的新对象,它在堆内存中有自己独立的存储地址,所以str1 == str3
返回false
。
执行引擎
执行引擎是JVM的核心组件之一,它负责执行字节码指令。执行引擎从字节码文件中读取字节码指令,并将其解析为具体的操作,然后在运行时数据区中进行相应的操作。执行引擎主要包括以下几个部分:
-
解释器(Interpreter):解释器是执行引擎的基础部分,它逐行解释并执行字节码指令。当JVM启动时,解释器会首先开始工作,将字节码指令解释为机器码并执行。解释器的优点是启动速度快,因为它不需要等待代码编译完成就可以开始执行,适用于启动时间敏感的应用场景。但是,由于解释器是逐行解释执行字节码,执行效率相对较低。
-
即时编译器(Just - In - Time Compiler,JIT):为了提高Java程序的执行效率,JVM引入了即时编译器。即时编译器会在运行过程中对热点代码(经常被执行的代码)进行编译,将其编译为本地机器码,然后直接执行本地机器码,这样可以大大提高执行效率。即时编译器采用了多种优化技术,如方法内联、逃逸分析、公共子表达式消除等,来生成高效的本地机器码。
-
方法内联:将被调用的方法的代码直接嵌入到调用处,避免了方法调用的开销,如栈帧的创建和销毁等。
-
逃逸分析:分析对象的作用域,如果一个对象只在方法内部使用,不会被外部访问,那么可以将该对象的内存分配在栈上,而不是堆上,这样可以减少垃圾回收的压力。
-
公共子表达式消除:如果在代码中存在重复计算的表达式,即时编译器会识别并只计算一次,将结果缓存起来,后续使用时直接从缓存中获取,避免了重复计算的开销。
-
-
垃圾回收器(Garbage Collector):垃圾回收器是执行引擎的重要组成部分,它负责自动回收堆内存中不再被使用的对象所占用的内存空间。JVM提供了多种垃圾回收器,如Serial GC、Parallel GC、CMS(Concurrent Mark - Sweep) GC、G1(Garbage - First) GC等,每种垃圾回收器都有其适用场景和特点。垃圾回收器的工作过程主要包括标记阶段和清除阶段,在标记阶段,垃圾回收器会标记出所有仍然被引用的对象,在清除阶段,会回收那些未被标记的对象所占用的内存空间。
以下是一个简单的代码示例,用于触发垃圾回收:
public class GarbageCollectionExample {
public static void main(String[] args) {
// 创建大量对象,占用堆内存
for (int i = 0; i < 1000000; i++) {
new GarbageCollectionExample();
}
// 手动触发垃圾回收
System.gc();
}
}
在上述代码中,通过循环创建大量的GarbageCollectionExample
对象,占用堆内存。然后通过System.gc()
手动触发垃圾回收,垃圾回收器会回收那些不再被引用的对象所占用的内存空间。
本地方法接口(Native Interface)
本地方法接口是JVM提供的一种机制,它允许Java程序调用本地代码(如C、C++代码)。通过本地方法接口,Java程序可以访问底层操作系统的功能,或者利用本地代码的高性能来提高程序的执行效率。
当Java程序调用一个本地方法时,JVM会通过本地方法接口找到对应的本地代码实现,并将Java方法的参数传递给本地代码。本地代码执行完毕后,将结果返回给Java程序。本地方法接口的实现与具体的JVM实现相关,不同的JVM可能有不同的实现方式。
以下是一个简单的本地方法调用的示例:
public class NativeMethodExample {
// 声明本地方法
public native void nativeMethod();
static {
// 加载本地库
System.loadLibrary("NativeMethodExample");
}
public static void main(String[] args) {
NativeMethodExample example = new NativeMethodExample();
example.nativeMethod();
}
}
在上述代码中,首先声明了一个本地方法nativeMethod()
,然后通过System.loadLibrary("NativeMethodExample")
加载本地库(本地库的命名规则通常是lib
+ 库名 + .so
(Linux)或.dll
(Windows))。在main
方法中,创建NativeMethodExample
对象并调用本地方法。本地方法的具体实现需要使用C或C++编写,并通过JNI(Java Native Interface)与Java代码进行交互。
JVM的原理
JVM的运行原理涉及到多个方面,包括字节码的执行、内存管理、垃圾回收等。
字节码的执行
当JVM加载一个字节码文件并创建对应的Class对象后,执行引擎开始工作。执行引擎从方法区中获取方法的字节码指令,然后按照顺序逐行解释或编译执行。
例如,对于以下简单的Java代码:
public class BytecodeExecutionExample {
public static void main(String[] args) {
int a = 10;
int b = 20;
int c = a + b;
System.out.println(c);
}
}
编译后的字节码指令如下(简化示例,实际字节码指令更复杂):
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #2 <java/lang/System.out>
13: iload_3
14: invokevirtual #3 <java/io/PrintStream.println : (I)V>
17: return
解释器会按照顺序执行这些字节码指令。bipush 10
将常量10压入操作数栈,istore_1
将操作数栈顶的值弹出并存储到局部变量表的第一个变量槽(对应a
变量)。bipush 20
和istore_2
类似,将常量20存储到局部变量表的第二个变量槽(对应b
变量)。iload_1
和iload_2
分别将局部变量表中的a
和b
的值压入操作数栈,iadd
从操作数栈中弹出两个值进行加法运算,并将结果压回操作数栈,istore_3
将操作数栈顶的结果存储到局部变量表的第三个变量槽(对应c
变量)。然后通过getstatic
获取System.out
对象,iload_3
将c
的值压入操作数栈,最后通过invokevirtual
调用System.out.println
方法输出c
的值。
内存管理
JVM的内存管理主要涉及到运行时数据区的管理,包括程序计数器、Java虚拟机栈、本地方法栈、堆和方法区。
在程序执行过程中,栈帧的创建和销毁由Java虚拟机栈管理,局部变量的存储和访问通过局部变量表实现。对象的创建和内存分配在堆上进行,垃圾回收器负责回收堆上不再被使用的对象所占用的内存空间。方法区用于存储类信息、常量等数据,在类加载时进行初始化,并在类卸载时释放内存。
例如,当创建一个对象时,JVM首先在堆上为对象分配内存空间,然后初始化对象的成员变量。当对象不再被引用时,垃圾回收器会在适当的时候回收该对象所占用的内存空间。
垃圾回收原理
垃圾回收的主要目的是自动回收堆内存中不再被使用的对象所占用的内存空间,以避免内存泄漏和提高内存利用率。垃圾回收器的工作过程通常包括以下几个阶段:
-
标记阶段:垃圾回收器使用可达性分析算法来标记出所有仍然被引用的对象。可达性分析算法从一组称为“GC Roots”的对象开始,如栈帧中的局部变量、方法区中的静态变量等,通过遍历对象之间的引用关系,标记出所有从“GC Roots”可达的对象。那些不可达的对象就是可以被回收的对象。
-
清除阶段:在标记阶段完成后,垃圾回收器会回收那些未被标记的对象所占用的内存空间。不同的垃圾回收器在清除阶段的实现方式有所不同,例如,标记 - 清除算法(Mark - Sweep)直接回收未标记的对象所占用的内存空间,但会产生内存碎片;标记 - 整理算法(Mark - Compact)在回收对象后会对内存空间进行整理,避免内存碎片的产生;复制算法(Copying)将存活的对象复制到另一块内存区域,然后清空原区域,适用于新生代的垃圾回收。
-
分代回收:由于对象的生命周期不同,JVM采用了分代回收的策略。将堆内存分为新生代和老年代,新生代中的对象通常生命周期较短,老年代中的对象生命周期较长。新生代采用复制算法进行垃圾回收,老年代采用标记 - 清除或标记 - 整理算法进行垃圾回收。通过分代回收,可以提高垃圾回收的效率,减少垃圾回收对应用程序性能的影响。
总结
Java虚拟机是Java平台的核心,它的架构和原理涉及到类加载、运行时数据区、执行引擎和本地方法接口等多个方面。深入理解JVM的架构和原理对于编写高效、稳定的Java程序至关重要,同时也有助于解决Java程序在运行过程中遇到的性能问题、内存问题等。通过合理使用JVM的各种特性,如选择合适的垃圾回收器、优化类加载过程等,可以提高Java应用程序的性能和可靠性。