原文作者:James Bloom 地址: http://blog.jamesdbloom.com/JVMInternals.html
本文解释了 Java 虚拟机(JVM)的内部体系结构。下图展示符合 Java虚拟机规范(JavaSE7版)的典型 JVM 关键内部组件。
这个图中展示的的组件每个在下面分为两部分进行解释。第一部分介绍为每个线程创建的组件,第二部分介绍线程共享的组件。
线程指程序执行过程中的一个线程实体。JVM 允许应用程序同时运行多个线程。在 Hotspot JVM 中 Java 线程与原生操作系统线程之间有直接映射。原生操作系统线程将在 Java 线程所有状态都准备好了后创建,比如线程本地存储、分配缓冲区、同步对象、堆栈和程序计数器等。一旦 Java 线程终止,原生线程就会回收。因此,操作系统负责调度所有线程并将其分派给任何可用的 CPU。一旦原生线程初始化后,它将调用 Java 线程中的 run()
方法。当 run()
方法返回时,出现未捕获的异常,原生线程将确认是否由于线程终止(即它是最后一个非守护线程)而需要终止 JVM。当线程终止所有资源时,本地线程和 Java 线程都被释放。
如果你使用 jconsole 或者任何 debugger,可以看到有很多线程在后台运行。这些后台线程由主线程(调用 public static void main(String[])
创建),以及由主线程创建的子线程组成。Hotspot JVM 中的主要后台系统线程是:
线程 | 作用 |
---|---|
虚拟机线程 | 此线程等待 JVM 到达安全点操作出现。这些操作必须发生在单独的线程上的原因是因为它们都需要 JVM 处于无法修改堆的安全点。这个线程执行的操作类型是“stop-the-world”垃圾收集,线程栈 dump,线程挂起和线程偏向锁(biased locking)解除。 |
周期性任务线程 | 此线程负责计时器事件(即中断),用于计划周期性操作的执行 |
GC 线程 | 这些线程支持 JVM 中发生的不同类型的垃圾收集活动 |
编译器线程 | 这些线程在运行时将字节码编译为本地代码 |
信号调度程序线程 | 该线程接收发送给 JVM 进程的信号,并通过调用适当的 JVM 方法在 JVM 内处理它们。 |
每个执行线程都有以下组件:
程序计数器(PC,Program Counter)是指非本地方法当前指令(或操作码)的地址。如果当前是本地方法,那么 PC 是未定义的。所有的 CPU 都有一个 PC,通常 PC 在执行每条指令后递增,因此保存了下一条要执行的指令的地址。JVM 使用 PC 跟踪执行指令的位置,PC 实际上将指向方法区域中的内存地址。
每一个线程都有一个自己的栈(Stack),为每个在该线程上执行的方法保存一个栈帧(Frame)。栈是后进先出(LIFO)数据结构,因此当前正在执行的方法位于栈的顶部。每当方法调用时创建一个新的栈帧并将其添加(push)到帧顶。当方法正常返回或在调用期间抛出未捕获的异常时,栈被移除(pop)。除了 push
和 pop
之外,栈不是直接操作的,因此栈帧对象可以在堆(Heap)中分配,并且在内存中不需要连续。
不是所有的 JVM 都支持本地方法,但是,通常每个线程都会创建一个本地方法栈。如果 JVM 用 C-linkage 模型实现了 Java Native Invocation(JNI),那么本地栈将是 C 栈。在这种情况下,参数与返回值的顺序在本地栈中与典型的 C 程序相同。一个本地方法通常(取决于 JVM 实现)回调到 JVM 并调用 Java 方法,这种 Java 调用的本地方法将出现在栈上(普通的 Java 栈)。该线程将离开本地栈并在栈上创建一个新的栈(普通 Java 栈)。
栈的大小是可以动态或固定分配的。如果线程所需的栈大小超过允许大小则抛出 StackOverflowError
。如果线程需要一个新的栈帧但没有足够的内存来分配它,那么会抛出 OutOfMemoryError
。
每当方法调用时创建一个新的栈帧并将其添加(push)到栈顶。当方法正常返回或在调用期间抛出未捕获的异常时,帧被移除(pop)。更多明细请看第二部分。
每个栈帧都包括:
局部变量数组包含执行该方法期间使用的所有变量,包括对 this
引用,所有方法参数和其他本地定义的变量。对于类方法(即静态方法),方法参数从下标从 0 开始,但是,位置 0 保留给 this
。
局部变量类型:
所有类型都在局部变量数组中占用一个插槽,除了 long
和 double
,它们都占用两个连续的插槽,因为这些类型是双精度( 64 位而不是 32 位)。
操作数栈在执行字节码指令过程中被用到,与原生 CPU 中使用通用寄存器类似。大多数 JVM 字节码花费时间操作操作数栈,通过推动、弹出、复制、交换或执行或使用。因此,在字节代码中,在局部变量数组和操作数栈之间移动值的指令非常频繁。例如,一个简单的变量初始化会产生与操作数堆栈交互的两个字节代码:
int i;
下面是编译后的字节码:
0: iconst_0 // Push 0 to top of the operand stack 1: istore_1 // Pop value from top of operand stack and store as local variable 1
有关本地变量数组,操作数栈和运行时常量池之间交互的更多详细信息,请参阅下面的“类文件结构”部分。
每一个栈帧都包含对运行时常量池的引用。引用指向当前栈帧执行的方法的类的常量池。此引用有助于动态支持动态链接。
通常将 C/C++ 代码编译为一个对象文件,然后将多个对象文件链接在一起以产生可用的文件,如可执行文件或 DLL。在链接阶段,每个对象文件中的符号引用被替换为相对于最终可执行文件的实际内存地址。在 Java 中,这个链接阶段是在运行时动态完成的。
编译 Java 类时,所有对变量和方法的引用都作为符号引用存储在类的常量池中。符号引用是逻辑引用,而不是实际指向物理内存位置的引用。JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式。无论如何 ,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次。一旦绑定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联的)偏移量。
堆用于在运行时分配类实例和数组。数组和对象永远不能存储在栈上,因为一个帧在创建之后就无法修改其大小。栈帧只存储指向堆上的对象或数组的引用。与局部变量数组中的原始变量和引用不同(在每个栈帧中),对象始终存储在堆中,因此在方法结束时不会删除它们。对象只能被垃圾收集器删除。
为了支持垃圾收集器,堆分为三部分:
对象和数组永远不会显式地回收,而是垃圾收集器自动回收它们。
通常这个工作如下:
非堆内存指的是那些逻辑上属于 JVM 一部分对象,但实际上不在堆上创建。
非堆内存包括:
Java 字节码被解释执行,但是这并不像在 CPU 上直接执行本地代码那么快。为了提高性能,Oracle Hotspot VM 会找到执行最频繁的字节码片段并把它们编译成原生机器码。然后将原生代码存储在代码缓存中的非堆内存中。通过这种方法,Hotspot 虚拟机将权衡下面两种时间消耗:将字节码编译成本地代码需要的额外时间和解释执行字节码消耗更多的时间。
方法区域存储每个类的信息,例如:
所有线程共享相同的方法区,因此访问方法区数据和动态链接过程必须是线程安全的。如果两个线程试图访问尚未加载的类的字段或方法,则只能加载一次,并且两个线程都必须先加载才能继续执行。
编译后的类文件由以下结构组成:
ClassFile { u4 magic; u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info contant_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]; }
名称 | 描述 |
---|---|
magic,minor_version,major_version | 指定关于该类的版本和编译该类的 JDK 版本的信息。 |
contant_pool | 类似于符号表,尽管它包含更多的数据 |
access_flags | 提供了这个类的修饰符列表 |
this_class | 指向常量池中的索引,此类的完全限定名称,例如 org/jamesdbloom/foo/Bar |
super_class | 指向常量池中的索引,表示父类的符号引用,例如 java/lang/Object |
interfaces | 指向常量池中的一组索引,表示所有已实现的接口符号引用 |
fields | 指向常量池中的一组索引,表示每个字段的完整描述 |
methods | 指向常量池中的一组索引,表示每个方法签名的完整描述,如果方法不是抽象的或本地的,那么会显示这个函数的字节码。 |
attributes | 提供有关该类的附加信息,包括使用 RetentionPolicy.CLASS 或 RetentionPolicy.RUNTIME 的任何注释 |
可以使用 javap
命令查看已编译的 Java 类中的字节码。
如果您编译以下简单的类:
package org.jvminternals; public class SimpleClass { public void sayHello() { System.out.println("Hello"); } }
然后,如果运行以下输出: javap -v -p -s -sysinfo -constants classes/org/jvminternals/SimpleClass.class
public class org.jvminternals.SimpleClass SourceFile: "SimpleClass.java" minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #6.#17 // java/lang/Object."<init>":()V #2 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #20 // "Hello" #4 = Methodref #21.#22 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #23 // org/jvminternals/SimpleClass #6 = Class #24 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lorg/jvminternals/SimpleClass; #14 = Utf8 sayHello #15 = Utf8 SourceFile #16 = Utf8 SimpleClass.java #17 = NameAndType #7:#8 // "<init>":()V #18 = Class #25 // java/lang/System #19 = NameAndType #26:#27 // out:Ljava/io/PrintStream; #20 = Utf8 Hello #21 = Class #28 // java/io/PrintStream #22 = NameAndType #29:#30 // println:(Ljava/lang/String;)V #23 = Utf8 org/jvminternals/SimpleClass #24 = Utf8 java/lang/Object #25 = Utf8 java/lang/System #26 = Utf8 out #27 = Utf8 Ljava/io/PrintStream; #28 = Utf8 java/io/PrintStream #29 = Utf8 println #30 = Utf8 (Ljava/lang/String;)V { public org.jvminternals.SimpleClass(); Signature: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lorg/jvminternals/SimpleClass; public void sayHello(); Signature: ()V flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String "Hello" 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 6: 0 line 7: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this Lorg/jvminternals/SimpleClass; }
这个类文件显示了常量池,构造函数和 sayHello
方法的三个主要部分。
sayHello
方法中的字节码 0 ,第 7 行对应于 sayHello
方法中的字节码 8. this
这个 class 文件用到下面这些字节码操作符:
名称 | 说明 |
---|---|
aload_0 | 该操作码是格式为 aload_<n> 的一组操作码之一。它们都是将一个对象引用加载到操作数栈中。 <n> 表示正在访问的局部变量数组中的位置,但只能是 0, 1, 2 或 3。还有其他类似的操作码用于加载不是对象的引用, iload_<n> , lload_<n> , fload_<n> 和 dload_<n> ,其中 i 是 int , l 是 long , f 是 float , d 是 double 。索引大于 3 的局部变量可以使用 iload , lload , fload , dload 和 aload 加载。这些操作码都采用单个操作数来指定要加载的局部变量的索引。 |
ldc | 该操作码用于将常量从运行时常量池中推送到操作数栈中 |
getstatic | 该操作码用来把一个静态变量从运行时常量池的静态变量列表中压栈到操作数栈 |
invokespecial, invokevirtual | 这些操作码属于一组函数调用的操作码,包括 invokedynamic , invokeinterface , invokespecial , invokestatic , invokevirtual 。在这个 class 文件中, invokespecial 和 invokevirtual 都用到了,它们两者之间的区别在于, invokevirtual 指令用于调用对象的实例方法,而 invokespecial 指令用于调用超类方法、私有方法和实例初始化方法。 |
return | 该操作码与 ireturn , lreturn , freturn , dreturn , areturn 属于一组。每个操作码都表示不同的返回值,其中 i 表示返回 int , l 表示返回 long , f 表示返回 float , d 表示返回 double , a 表示返回对象的引用,没有前缀表示返回 void |
与任何典型的字节码一样,大部分操作数与局部变量,操作数堆栈和运行时常量池交互如下。
构造器函数包含两个指令。首先 this
变量被压栈到操作数栈,然后父类的构造器函数被调用,而这个构造器会消费 this
,之后 this
被弹出操作数栈。
sayHello()
方法更复杂,因为它必须使用运行时常量池来解析实际引用的符号引用。第一个操作数 getstatic
用于将 System
类中静态变量 out
压到操作数栈。下一个操作数 ldc
将字符串“Hello” 推送到操作数栈中。最后操作数 invokevirtual
调用 System.out
的 println
方法,将操作数栈中的 “Hello” 作为参数弹出,并为当前线程创建一个新帧。
JVM 启动时会用 Bootstrap
类加载器加载一个初始化类。然后这个类会在 public static void main(String[])
调用之前完成链接和初始化。执行这个方法会执行加载、链接、初始化需要的额外类和接口。
加载(Loading)是找到代表具有特定名称的类或接口类型的 class 文件,并将其读入字节数组的过程。接下来,解析字节以确保是一个正确的 Class
对象并具有正确的 major
和 minor
版本信息。任何直接父类的类或接口也会被加载。一旦完成,就从二进制表示中创建一个类或接口对象。
链接(Linking)是校验一个类或接口并准备该类型及其直接父类和父接口的过程。链接过程包含三步:校验(verifying)、准备(preparing)、部分解析(optionally resolving)。
校验(Verifying)是确认类或接口表示在结构上是否正确并遵循 Java 编程语言规范和 JVM 语义要求的过程。例如,执行以下检查:
变量的值类型正确
在验证阶段执行这些检查意味着这些检查不需要在运行时执行。链接期间的验证会降低类加载的速度,但它避免了在执行字节码时需要执行多次这些检查。
准备(Preparing)是为静态存储和 JVM 使用的任何数据结构(例如方法表)分配内存。创建静态字段并将其初始化为其默认值,但是,在此阶段不会执行初始化程序或代码,因为这是初始化的一部分。
解析(Resolving)是一个可选的阶段,它涉及通过加载引用的类或接口来检查符号引用,并检查引用是否正确。如果不是发生在这个阶段,那么符号引用的解析可以推迟到它们被字节码指令使用之前。
初始化(Initialization)类或者接口初始化由类或接口初始化方法 <clinit>
的执行组成。
在 JVM 中有多个具有不同角色的类加载器。每个类加载器委托给它的父类加载器(加载它),除了最高类加载器 Bootstrap
类加载器。
Bootstrap 类加载器通常使用本地代码实现,因为它在 JVM 加载时很早实例化。 Bootstrap
类加载器负责加载基本的 Java API,例如 rt.jar
。它只加载拥有较高信任级别的启动路径下找到的类;因此它跳过了大部分为普通类所做的验证。
Extension 类加载器加载了标准 Java 扩展 API 中的类,比如 security 的扩展函数。
System 类加载器是默认的应用程序类加载器,它从 classpath
加载应用程序类。
用户自定义 类加载器也可以用来加载应用程序类。用户定义的类加载器用于许多特殊的原因,包括类的运行时重新加载或 Web服务器(例如Tomcat)通常需要的不同加载类组之间的分离。
共享类数据(Class Data Sharing,CDS)是Hotspot JVM 5.0 的时候引入的新特性。在 JVM 安装过程中,安装进程会加载一系列核心 JVM 类(比如 rt.jar)到一个共享的内存映射区域。CDS 减少了加载这些类所需的时间,从而提高了 JVM 的启动速度,并允许在 JVM 的不同实例之间共享这些类,从而减少内存占用。
在 Java虚拟机规范(JavaSE7版)中清楚地写道:“虽然方法区域在逻辑上是堆的一部分,但简单的实现可以选择不对它进行回收和压缩。”。Oracle JVM 的 jconsole 显示方法区和 code cache 区被当做为非堆内存,而 OpenJDK 则显示 CodeCache 被当做 VM 中对象堆(ObjectHeap)的一个独立的域。
所有加载的类都包含对加载它们的类加载器的引用。反过来,类加载器还包含对其已加载的所有类的引用。
JVM 维护了一个按类型区分的常量池,一个类似于符号表的运行时数据结构。尽管它包含更多数据。Java 中的字节码需要数据,通常这些数据太大而不能直接存储在字节码中,而是存储在常量池中,字节码包含对常量池的引用。如上所述,运行时间常量池用于动态链接
常数池中包含几种类型的数据
示例代码如下:
Object foo = new Object();
将按照以下字节代码写入:
0: new #2 // Class java/lang/Object 1: dup 2: invokespecial #3 // Method java/ lang/Object "<init>"( ) V
new
操作码的后面紧跟着操作数 #2 。这个操作数是常量池的一个索引,表示它指向常量池的第二条数据。第二条数据是一个类引用,这条数据反过来引用常量池中中包含 UTF8 编码的字符串类名的数据( // Class java/lang/Object
)。这个符号链接可以用来查找 java.lang.Object
的类。 new
操作码创建一个类实例并初始化它的变量。然后将新类实例的引用添加到操作数栈中。 dup
操作码创建一个操作数栈顶元素引用的额外拷贝。最后用 invokespecial
来调用第 2 行的实例初始化方法。该操作数还包含对常量池的引用。初始化方法将从操作数栈中 pop
顶部引用作为该方法的参数。最后,有一个对已经创建和初始化的新对象的引用。
如果您编译以下简单的类:
package org.jvminternals; public class SimpleClass { public void sayHello() { System.out.println("Hello"); } }
生成的类文件中的常量池将如下所示:
Constant pool: #1 = Methodref #6.#17 // java/lang/Object."<init>":()V #2 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #20 // "Hello" #4 = Methodref #21.#22 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #23 // org/jvminternals/SimpleClass #6 = Class #24 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lorg/jvminternals/SimpleClass; #14 = Utf8 sayHello #15 = Utf8 SourceFile #16 = Utf8 SimpleClass.java #17 = NameAndType #7:#8 // "<init>":()V #18 = Class #25 // java/lang/System #19 = NameAndType #26:#27 // out:Ljava/io/PrintStream; #20 = Utf8 Hello #21 = Class #28 // java/io/PrintStream #22 = NameAndType #29:#30 // println:(Ljava/lang/String;)V #23 = Utf8 org/jvminternals/SimpleClass #24 = Utf8 java/lang/Object #25 = Utf8 java/lang/System #26 = Utf8 out #27 = Utf8 Ljava/io/PrintStream; #28 = Utf8 java/io/PrintStream #29 = Utf8 println #30 = Utf8 (Ljava/lang/String;)V
常量池包含以下类型:
名称 | 描述 |
---|---|
Integer | 4字节常量 |
Long | 8 个字节常量 |
Float | 4 字节常量 |
Double | 8 字节常量 |
String | 字符串常量,指向包含实际字节的常量池中的另一个 Utf8 数据 |
Utf8 | Utf8 编码的字符序列字节流 |
Class | Class 常量指向常量池中另一个 Utf8 数据,该数据包含内部 JVM 格式的全限定类名(动态链接过程使用该常量) |
NameAndType | 冒号(:)分隔的一组值,这些值都指向常量池中的其它数据。第一个值(“:”之前的)指向一个 Utf8 字符串数据,它是一个方法名或者字段名。第二个值指向表示类型的 Utf8 数据。对于字段类型,这个值是类的全名,对于方法类型,这个值是每个参数类型类的类全名的列表。 |
Fieldref,Methodref,InterfaceMethodref | 点号(.)分隔的一组值,每个值都指向常量池中的其它的数据。第一个值(“.”号之前的)指向 Class 数据,第二个值指向 NameAndType 数据。 |
异常表存储每个异常处理程序的信息,例如:
捕获异常类的常量池索引
如果一个方法定义了 try-catch
或 try-finally
异常处理程序,那么就会创建一个异常表。这包含每个异常处理程序或 finally 代码块的信息,包括处理程序应用的范围,处理的异常类型以及处理程序代码的位置。
当方法抛出异常时,JVM 会在当前方法中查找匹配的处理程序,如果没有找到该方法,那么方法会立即结束并弹出当前栈帧,这个异常会被重新抛到调用这个方法的方法中(在新的栈帧中)。如果在弹出所有栈帧之前没有找到异常处理程序,则线程终止。如果在最后一个非守护进程线程中抛出异常,例如如果线程是主线程,这也会导致 JVM 本身终止。
Finally
异常处理器匹配所有的异常类型,且不管什么异常抛出 finally
代码块都会执行。在没有抛出异常的情况下, finally
代码块还是会在方法最后执行。这种靠在代码 return
之前跳转到 finally
代码块来实现。
除了按类型来分的运行时常量池,Hotspot JVM 在永久代还包含一个符号表。这个符号表是一个哈希表,保存了符号指针到符号的映射关系(也就是 Hashtable<Symbol*, Symbol>
),它拥有指向所有符号(包括在每个类运行时常量池中的符号)的指针。
引用计数用于控制符号何时从符号表中删除。例如,当一个类被卸载时,它在运行时间常量池中保存的所有符号的引用计数递减。当符号表中符号的引用计数变为零时,符号表知道该符号不再被引用,并将符号从符号表中被卸载。符号表和后面介绍的字符串表都被保存在一个规范化的结构中,以便提高效率并保证每个实例只出现一次。
Java 语言规范要求相同的(即包含相同序列的 Unicode 指针序列)字符串字面量必须指向相同的 String 实例。另外,在一个字符串实例上调用 String.intern()
方法的返回引用必须与字符串是字面量时的一样。因此,下面的代码返回 true:
("j" + "v" + "m").intern() == "jvm"
Hotspot JVM 中 interned 字符串保存在字符串表中。字符串表是一个哈希表,保存着对象指针到符号的映射关系(也就是 Hashtable<oop, Symbol>
),它被保存到永久代中。符号表和字符串表的实体都以规范的格式保存,保证每个实体都只出现一次。
当类加载时,字符串字面量被编译器自动 intern
并加入到符号表。除此之外,String 类的实例可以调用 String.intern()
显式地 intern。当调用 String.intern()
方法时,如果符号表已经包含了这个字符串,那么就会返回符号表里的这个引用,如果不是,那么这个字符串就被加入到字符串表中同时返回这个引用。
本人英语水平有限,可以阅读由 ImportNew.com - 挖坑的张师傅 翻译的 JVM内幕:Java虚拟机详解