JVM的内部架构

这篇文章将解释 JVM 的内部架构。根据 JAVA7 JVM 规范,下图展现了一个典型 JVM 的的内部关键组件。

图上的这些组件将通过下面的两个章节逐一解释。章节一涵盖每一个线程独立创建的组件,章节二包含独立在线程之外的组件。

Threads

  • JVM System Threads
  • Per Thread
  • program Counter (PC)
  • Stack
  • Native Stack
  • Stack Restrictions
  • Frame
  • Local Variables Array
  • Operand Stack
  • Dynamic Linking

Shared Between Threads

  • Heap
  • Memory Management
  • Non-Heap Memory
  • Just In Time (JIT) Compilation
  • Method Area
  • Class File Structure
  • Classloader
  • Faster Class Loading
  • Where Is The Method Area
  • Classloader Reference
  • Run Time Constant Pool
  • Exception Table
  • Symbol Table
  • Interned Strings (String Table)

Thread

线程是程序中的一个执行主线。JVM 允许应用程序并行执行多个主线,也就是多线程并行执行。在 Hostspt JVM 中,存在一个 Java 线程和本地操作系统线程的一一映射。Java 线程启动时,需要分配 thread-local 存储空间、buffer、synchronization 对象、堆栈和计数器,之后本地线程被创建。当 Java 线程终止时,本地线程会被回收复用。因此,操作系统负责调度所有的线程,为这些线程分配 CPU 时间片。一旦本地线程初始化完成,Java 线程类中声明的 run() 方法将被执行。当 run() 方法返回时,JVM 会处理未捕获的异常,之后由本地线程根据情况决定,是否此线程结束后(如:最后一个非守护线程退出),JVM 也需要停止并退出。当线程结束后,会释放所有的本地和 Java 线程资源。

JVM System Threads

如果你使用 jconsole 或者其他任何 debug 工具,有可能你会发现有大量的线程在后台运行。这些后台线程随着 main 线程的启动而启动,即,在执行 public static void main(String[]) 后,或其他 main 线程创建的其他线程,被启动后台执行。

Hotspot JVM 主要的后台线程包括:

1.VM thread: 这个线程专门用于处理那些需要等待 JVM 满足 safe-point 条件的操作。safe-point 代表现在没有修改 heap 的操作发生。这种类型的操作包括:”stop-the-world” 类型的 GC,thread stack dump,线程挂起,或撤销对象偏向锁 (biased locking revocation)

2.Periodic task thread: 用于处理周期性事件(如:中断)的线程

3.GC threads: JVM 中,用于支持不同阶段的 GC 操作的线程

4.Compiler threads: 用于在运行时,将字节码编译为本地代码的线程

5.Signal dispatcher thread: 接受发送给 JVM 处理的信号,并调用对应的 JVM 方法

Per Thread

每个执行线程包含以下组件:

Program Counter (PC)

当前操作指令或 opcode 的地址指针,如果当前方法是本地方法,则 PC 值为 undefined。每个 CPU 都有一个 PC,一般来说,每一次指令之后,PC 值会增加,指向下一个操作指令的地址。JVM 使用 PC 保持操作指令的执行顺序,PC 值实际上就是指向方法区 (Method Area) 中的内存地址。

Stack

每一个线程都拥有自己的栈(Stack),用于在本线程中正在执行的方法。栈是一个先进后出(LIFO)的数据结构,所以当前的执行方法位于栈顶。每一个方法开始执行时,一个新的帧(Frame) 被创建(压栈),并添加到栈顶。当方法正常执行返回,或方法执行时抛出一个未捕获的异常,则此帧被移除(弹栈)。栈,除了压栈和弹栈操作外,不会被执行操作,因此,帧对象可以被分配在堆(Heap)内存中,并且不需要分配连续内存。

Native Stack

不是所有的 JVM 都支持本地方法,然而,基本上都会为每个线程,创建本地方法栈。如果 JVM 使用 C-Linkage 模型,实现了 JNI(Java Native Invocation),那么本地栈就会是一个 C 语言的栈。在这种情况下,本地栈中的方法参数和返回值顺序将和 C 语言程序完全一致。一个本地的方法一般可以回调 JVM 中的 Java 方法(依据具体 JVM 实现而定)。这样的本地方法调用 Java 方法一般会使用 Java 栈实现,当前线程将从本地栈中退出,在 Java 栈中创建一个新的帧。

Stack Restrictions

栈可以使一个固定大小或动态大小。如果一个线程请求超过允许的栈空间,允许抛出 StackOverflowError。如果一个线程请求创建一个帧,而没有足够内存时,则抛出 OutOfMemoryError。

Frame

每个方法开始执行时,一个帧被创建,压到栈顶。当方法正常执行返回,或方法执行时抛出一个未捕获的异常,此帧被删除,弹栈操作。更多细节,查看 Exception Table 章节。

每一帧包含以下信息:

  • 本地变量数组, Local variable array
  • 返回值
  • 操作对象栈, Operand stack
  • 当前方法所属类的运行时常量池

Local Variables Array

本地变量数组包含所有方法执行过程中的所有变量,包括 this 引用,方法参数和其他定义的本地变量。对于类方法(静态方法),方法参数从 0 开始,然后对于实例方法,参数数据的第 0 个元素是 this 引用。

本地变量包括:

  • boolean
  • byte
  • char
  • long
  • short
  • int
  • float
  • double
  • reference
  • returnAddress

所有类型都占用一个数据元素,除了 long 和 double,他们占用两个连续数组元素。(这两个类型是 64 位的,其他是 32 位的)

Operand Stack

在执行字节代码指令过程中,使用操作对象栈的方式,与在本机 CPU 中使用通用寄存器相似。大多数 JVM 的字节码通过压栈、弹栈、复制、交换、操作执行这些方式来改变操作对象栈中的值。因此,在本地变量数组中和操作栈中移动复制数据,是高频操作。下面举例说明,通过操作对象栈,将一个简单的变量赋值为 0.

1
int i;

编译后得到以下字节码

1
2
0:	iconst_0	// 将0压到操作对象栈的栈顶
1: istore_1 // 从操作对象栈中弹栈,并将值存储到本地变量1中

更多的关于本地变量、对象操作栈和运行时常量池间的交互操作,请查看 Class File Structure 章节

Dyanmic Linking

每个帧都包含一个引用指针,指向运行时常量池。这个引用指针指向当前被执行方法所属对象的常量池。这个引用帮助进行动态链接。

C/C++ 代码可以讲一个或者多个对象文件进行链接编译成一个可执行单元或 dll。在链接过程中,每个对象文件中的引用变量,根据最终的可执行单元,被替换到实际的内存地址。在 Java 中,这个链接过程在运行时动态执行。

当 Java Class 被编译后,所有的变量和方法引用都利用一个引用标识存储在 class 的常量池中。一个引用标识是一个逻辑引用,而不是指向物理内存的实际指针。JVM 实现可以选择何时替换引用标识,例如:class 文件验证阶段、class 文件加载后、高频调用发生时、静态编译链接、首次使用时。然后,如果在首次链接解析过程中出错,JVM 不得不在后续的调用中,一直上报相同的错误。使用直接引用地址,替换属性字段、方法、类的引用标识被称作绑定(Binding), 这个操作只会被执行一次,因为引用标识都被完全替换掉,无法进行二次操作。如果引用标识指向的类没有被加载(resolved),则 JVM 会优先加载(load)它。每一个直接引用,就是方法和变量的运行时所存储的相对位置,也就是对应的内存偏移量。

Shared Between Threads

Heap

堆用作为 class 实例和数据在运行时分配存储空间。数组和对象不能被存储在栈中,因为帧空间在创建时分配,并不可改变。帧中只存储对象或者数组的指针引用。不同于原始类型,和本地变量数组的引用,对象被存储在堆中,所以当方法退出时,这些对象不会被移除。这些对象只会通过垃圾回收来移除。

想了解垃圾回收相关的内容,请查看以下的三个章节:

Young Generation,年轻代 - 在 Eden 和 Survivor 中来回切换
Old Generation (Tenured Generation),老年代或持久带
Permanent Generation

Memory Management

对象和数据不会被隐形的回收,只有垃圾回收机制可以释放他们的内存。

典型的运行流程如下:

1.新的对象和数组使用年轻代内存空间进行创建
2.年轻代 GC(Minor GC/Young GC)在年轻代内进行垃圾回收。不满足回收条件(依然活跃)的对象,将被移动从 eden 区移动到 survivor 区。
3.老年代 GC(Major GC/Full GC)一般会造成应用的线程暂停,将在年轻代中依然活跃的对象,移动到老年代 Old Generation (Tenured Generation)。
4.Permanent Generation 区的 GC 会随着老年代 GC 一起运行。其中任意一个区域在快用完时,都会触发 GC 操作。

Non-Heap Memory

属于 JVM 内部的对象,将在非堆内存区创建。

非堆内存包括:

  • Permanent Generation - the method area,方法区 - interned strings,字符串常量
  • Code Cache,代码缓存。通过 JIT 编译为本地代码的方法所存储的空间。

Just In Time (JIT) Compilation

Java 字节码通过解释执行,然后,这种方式不如 JVM 使用本地 CPU 直接执行本地代码快。为了提供新能,Oracle Hotspot 虚拟机寻找热代码(这些代码执行频率很高),把他们编译为本地代码。本地代码被存储在非堆的 code cache 区内。通过这种方式,Hotspot VM 通过最适当的方式,开销额外的编译时间,提高解释执行的效率。

Method Area

方法区中保存每个类的下列信息:

  • Classloader Reference
  • Run Time Constant Pool
    • Numeric constants
    • Field references
    • Method References
    • Attributes
  • Field data
    • Per field
      • Name
      • Type
      • Modifiers
      • Attributes
  • Method data
    • Per method
      • Name
      • Return Type
      • Parameter Types (in order)
      • Modifiers
      • Attributes
  • Method code
    • Per method
      • Bytecodes
      • Operand stack size
      • Local variable size
      • Local variable table
      • Exception table
        • Per exception handler
          • Start point
          • End point
          • PC offset for handler code
          • Constant pool index for exception class being caught

所有的线程共享同一个方法区,所以访问方法区的数据和处理动态链接必须保证线程安全。如果两个线程视图访问一个属性或者一个方法,而这个属性或方法还没有被加载,那么这两个线程必须暂停,等待加载完成。

分享到: