JVM 架构原理浅谈

前言

今天来讲一下 JVM 原理,记录下自己对 JVM 的理解和认知。
理解 JVM 架构设计和 Java 的工作原理对我们每一个 Java 开发人员来说都是非常重要的一课。想要进一步提升和突破自己技术上的瓶颈,就要加深对 Java 基础掌握。这相当于建房子时要夯实地基。你的房子能稳定盖多高,取决于地基打的有多牢。同样的,你的 Java 之路能走多远,除了业务因素,还取决于对原理的掌握,换言之,要抓到 Java 程序运行的本质。
文章下述基于 Java se 1.8。

背景

1995年 James Gosling 给 Sun 公司的 Microsystems 系统设计了 Java 编程语言。由于 Java 语言的一系列特点(跨平台,面向对象,结构化,垃圾回收,强类型和支持并发,反射,范型等等)和 WORA(write once, run anywhare) 设计理念而不断流行至今。
为了支持 WORA 的设计理念,Java 语言的编译产物是一份操作平台无关的字节码格式 class 文件。而相应的要运行这份编译产物,Sun 公司(5.0 之后由 Java 社区)制定和维护了一套 Java 虚拟机技术规范。

虚拟机

虚拟机,是指一种特殊的软件,在计算机平台和终端用户之间创建一种环境,终端用户基于虚拟机这个软件所创建的环境来操作运行在上面的其它软件。

Java 虚拟机

Java 虚拟机(Java Virtual Machine),是一种能够执行 Java 字节码的虚拟机。

JVM 是一套规范标准,它的实现可能会因组织或公司而异。比如开源方案 OpenJDK 和 Oracle 的商业化实现。
如果精力允许,你我都可以写一套自己的 JVM 程序。

由于 JVM 是一套遵循 Java 字节码运行规范的虚拟机系统,所以只要编程语言的编译产物为合法的 Java 字节码,就可以在 JVM 上运行。
如以下常见原生基于 JVM 的语言:

  • Java
  • Groovy
  • Kotlin
  • Scala

JVM 架构

有兴趣可以翻翻 Java 虚拟机技术规范,其中详细描述了 JVM 的设计规范要求。不过不包括实现细节。

Implementation details that are not part of the Java Virtual Machine’s specification would unnecessarily constrain the creativity of implementors.

类加载系统

在 JVM 开始运行后会驻留在内存当中并按 JVM 架构图中所示分配不同的内存空间。执行期间会通过类加载系统将 class 文件加载到内存,称为动态类加载机制。在运行期间第一次加载类时,会完成该 class 的加载,链接和初始化过程。

加载

类加载器的主要功能是将编译好的 class 文件加载到内存当中。通常类的加载从加载 main class(包含静态 main 方法的类)开始。所有后续的类加载都是根据已经加载运行的类中的引用完成。比如:

  • 静态引用了一个类的属性System.out
  • 创建一个类对象new HashMap()

Java 提供了三种类加载器,通过继承关系连接起来,它们遵循如下原则。

  1. 可见性原则
    子类加载器可以访问父类加载器加载的类,但是父类加载器无法访问子类加载器加载的类。

  2. 唯一性原则
    父类加载的类不应该被子类加载器加载,同一个类不应该发生重复的类加载过程。

  3. 委托原则
    为满足上述两条原则,JVM 遵循委托的层次结构来为每个类加载请求选择类加载器。从继承关系的子类开始,ApplicationClassLoader 将接收到的类加载请求委托给 ExtensionClassLoader,然后 ExtensionClassLoader 将加载请求委托给BootstrapClassLoader
    如果在 BootstrapClassLoader 路径中找到请求的类,则加载并返回改类。否则,加载请求返回到 ExtensionClassLoader 中从其路径查找该类。如果加载也失败了,请求会返回到 ApplicationClassLoader 中查找,如果它也没有成功加载到请求的类,会抛出运行时异常 java.lang.ClassNotFoundException

除了上述三个类加载器,开发者也可以创建自定义的类加载器。选择遵照或打破上述原则来实现更加定制化的功能。

每个类加载器有它的命名空间,用以存储其加载的类字节码。当一个类加载器加载类时,通过该类的完全限定类名从命名空间查找是否已经加载过。如果两个类有相同的完全限定类名,但存储在不同的命名空间当中,也会被作为两个不同的类。不同的命名空间意味着是由不同的类加载器加载到内存当中。

链接

链接是获取类或接口并将其组合到 JVM 的运行时状态的过程。类经过连接过程后才能够被 JVM 执行。链接一个类或接口包括验证和准备这个类或接口,如有必要,也包括它的直接父类,直接父接口和其元素类型(如果链接对象是一个数组)。链接过程中,解析类或接口的符号引用是一个可选部分。

链接的规范

  1. 类或接口必须完成加载才可以进行链接。
  2. 类或接口必须完成校验和准备才可以完成后续初始化。
  3. 如果连接过程出现错误,将会在程序执行到直接或间接涉及到该类或接口链接过程的必要环节时抛出错误。

链接包括分配新的数据结构,因此可能会失败并抛出 OutOfMemoryError

校验

校验确保类或接口的 class 文件在结构上是正确的。这是类加载过程中最复杂的测试过程,也是耗时最长的。尽管它减慢了类加载的过程,但它避免了在执行字节码时多次进行这些检查的环节,从而使整体执行高效和有效。如果校验失败,会抛出运行时错误 java.lang.VerifyError

校验过程可能包含如下环节:

  • 一致且格式正确的符号表
  • final 方法或类没有被重载
  • 类或方法调用遵照访问控制关键字限制
  • 方法的参数类型和个数正确
  • 字节码指令没有错误的堆栈操作
  • 变量在访问前完成初始化
  • 变量类型正确
准备

准备阶段会进行内存分配,包括为类或接口创建静态字段并将这些字段初始化为默认值。这里不需要执行任何 Java 虚拟机代码;静态字段的显式初始化将在链接之后的初始化环节完成,而不是在准备阶段。

解析

解析是从运行时常量池中的直接引用替换符号引用的过程。它是通过搜索方法区来定位引用的实体来完成的。

初始化

类或接口的初始化包括执行其初始化方法 <clinit>
此阶段将执行每个加载的类或接口的初始化逻辑,比如调用类的构造函数。由于 JVM 是多线程的,因此类或接口的初始化应该非常谨慎,避免多个线程同时尝试初始化同一个类或接口(使其成为线程安全的)。

初始化是类加载的最后阶段,其中所有变量都会分配其代码中定义的初始值,并执行静态代码块。
初始化的执行顺序在层次结构中从父类到子类逐级执行,类的内部从上到下逐行执行。

下面代码的输出日志你清楚吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class InitializationTest {
@Test
public void initializationTest() {
System.out.println("test a:" + TestClass.a);
System.out.println("test b:" + TestClass.b);
System.out.println("test c:" + TestClass.c);
}
}

class TestClass {
static final int a = 3;
static final String b = "test block b";
static final Object c = new Object();

static {
System.out.println("static block called");
}

TestClass() {
System.out.println("constructor called");
}
}
查看结果

test a:3
test b:test block b
static block called
test c:java.lang.Object@4f6b3a3f
和你想的一样吗?

运行时数据区

运行时数据区是 JVM 程序在操作系统上运行时分配的内存区域。Java 虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区是在 Java 虚拟机启动时创建的,只有在 Java 虚拟机退出时才会被销毁。一些数据区域是线程私有的,这些数据区在创建线程时创建,并在线程退出时销毁。

类加载系统除了读取 .class 文件外,还会生成相应的二进制数据,并非分别在方法区为每个类保存下面信息:

  • 已加载的类和其直接父类的完全限定类名
  • .class 文件关联类型(类/接口/枚举)
  • 修饰符、静态变量和方法信息等。

然后,对于每个已加载的 .class 文件,虚拟机创建唯一的一个 java.lang.Class 对象实例来表示堆内存中的文件。这个 Class 对象可以被用来读取类信息(类名,父类名,方法,变量信息,静态变量等)。

方法区(线程共享)

方法区是一个在所有 JVM 线程之间共享的内存区域。因此对方法区数据的访问和动态链接的过程必须是线程安全的。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法。

方法区存储类级别的数据(包括静态变量),如:

  • 类加载器的引用
  • 运行时常量池 – 数值常量、字段引用、方法引用、属性;除了每个类和接口的常量外,还包含方法和字段的所有引用。当一个方法或字段被引用时,JVM 通过运行时常量池在内存中查找该方法或字段的实际地址。
  • 字段数据 – 字段的名称、类型、修饰符、属性
  • 方法数据 – 方法的名称、返回类型、参数类型(按顺序)、修饰符、属性
  • 方法代码 – 方法的字节码、操作数堆栈大小、局部变量大小、局部变量表、异常表;异常表中存有异常处理程序:起始点、结束点、处理程序代码的 PC 偏移量、被捕获的异常类的常量池索引

堆(线程共享)

堆区也是一个线程间共享的内存区域。所有对象及对应的实例变量和数组的信息都存储在堆区中。因为 方法区和堆区是线程间共享内存,因此方法区和堆区存储的数据不是线程安全的。GC 很重要的活动区域就是堆区。

栈(线程私有)

栈区是一个线程私有的内存区域。每一个 JVM 线程在启动时,会创建一个独立的运行时栈区用来完成方法调用。每当一个方法调用时都会产生一个栈帧并完成压栈操作,存储到栈结构的顶部。栈帧用来存储方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。局部变量表和操作数堆栈的大小在编译时确定。因此,每个方法的栈帧的大小是固定的。

当方法正常返回或在调用期间抛出未捕获的异常时,将进行弹栈操作。如果发生异常,日志中堆栈信息(通过 printStrackTrace() 打印)的每一行表示一个栈帧。栈区是线程安全的,因为他是线程私有空间。

栈帧可以分为三个部分:

  • 局部变量表 – 它是一个从0开始索引的数组。对于当前方法涉及多少个局部变量,对应的值存储在这里。0 是方法所属的类实例的引用(体现为方法中关键字 this,从 1 开始为方法的参数。在方法参数之后,保存了方法的局部变量。
    思考:方法中循环体内声明创建的局部变量与循环体外创建的局部变量在局部变量表是否有差异?可以参考这边文章的实验解释
  • 操作数栈 – 必要时进行运行时工作区的中间操作。每个方法执行时都会在操作数栈和局部变量表之间交换数据,并进行其上方法调用的压栈和弹栈操作。操作数栈的必要空间大小可以在编译时确定。
  • 栈帧数据 – 与当前方法相关的所有符号都存储在这里。异常的 catch 块信息也会保存在帧数据中。
    线程终止后,它的栈帧也会被 JVM 销毁。
    栈可以是动态或固定大小。如果线程需要的栈空间大于当前允许的最大值,会抛出 StackOverflowError 异常。如果线程需要一个新的栈帧但没有足够的内存空间分配给他,会抛出 OutOfMemoryError

程序计数器(线程私有)

对于每个 JVM 线程,当线程启动时,会创建一个单独的 PC(程序计数器)寄存器。它用来保存当前执行指令的地址(方法区中的内存地址)。如果当前方法是本地方法,则 PC 未定义。一旦方法执行结束,PC 寄存器会更新为下一条指令的地址。

本地方法栈(线程私有)

Java 线程和操作系统线程之间存在直接对应关系。在准备好 Java 线程的所有状态后,会创建一个单独的本地栈空间。它用来存储 JNI 调用的本地方法信息(通常由 C/C++ 实现)。当本地线程完成创建和初始化,它就会调用 Java 线程中的 run() 方法。当 run() 方法返回时,会处理未捕获的异常,然后本地线程确认是否需要终止 JVM (当前线程是否是最后一个非守护线程)。当线程终止时,本地线程和 Java 线程的所有资源及都将被释放。
Java 线程终止后本地线程也会被回收。操作系统负责调度所有线程并进行 CPU 的分派工作。

执行引擎

执行引擎用来运行.class(字节码)。它逐行读取字节码,读写各个内存区域中的数据并执行 JVM 指令集。它可以分为下面几个部分:

解释器

逐行解释字节码,然后执行。存在一个问题是执行解释结果的速度很慢,且当一个方法被多次调用时,每次都需要解释。

即时(JIT)编译器

用来提高解释器的执行效率。它将整个字节码编译为本地代码(机器码)。因此当解释器需要处理重复的方法调用时,JIT 会提供该部分直接的机器码,因此不需要重新解释,从而提高效率。执行本地代码比执行一条条解释指令要快很多。
它的问题是 JIT 编译时间比解释器解释时间要长。对于只执行一次的代码段,最好的选择可能是解释执行而不是编译执行。本地代码也存储在内存缓存当中,对资源的占用也是一项成本。因此,JIT 编译器会内部检查每个方法调用的频率,并在方法调用超过一定次数时才编译该方法。

JVM 的编译器优化

对 JVM 性能优化,下面四个组件可以用来提高性能

  • Intermediate Code Genertor 生成中间代码
  • Code Optimizer 负责优化上面生成的中间代码
  • Target Code Generator 负责生成本地代码(机器码)。
  • Profiler 组件用于查找性能瓶颈,aka hotspots。比如方法被多次调用的对象实例。
    Oracle Hotspot VMs 热点虚拟机
    Oracle 有一个包含 Hotspot Compiler 的 JVM 实现。它可以分析识别出最需要 JIT 编译的热点,然后将代码的性能平静部分编译为本地代码。随着后续程序不断执行,如果有编译方法不再被频繁调用,它会将该方法识别为非热点方法,并从缓存中删除本地代码,开始以解释器模式运行。这种方式提高了性能的同时,也避免了对很少使用的代码进行不必要的编译。热点编译器也会使用内联等技术优化编译后的代码。编译器的运行时分析帮助它确定最大性能收益的优化方案。
    Oracle 的 Java 热点技术特点有快速的内存分配,高效 GC 和服务器多处理器下易扩展多线程处理能力。
    IBM AOT Compiling 提前编译
    特点是 JVM 共享通过共享缓存编译的本地代码,因此通过 AOT 编译器编译的代码可以被另外的 JVM 实例使用而无需编译。

垃圾回收

用来进行销毁未引用的对象。如果一个对象不再被引用,程序代码就无法访问该对象,垃圾回收器可以移除这些对象并回收内存。

Java 本地接口(JNI)

本地接口用来和本地方法库进行交互。可以使 JVM 能够调用 C/C++ 或其他编程语言编写的本地应用程序和库。

本地方法库

其他编程语言编写的库。可以通过 JNI 加载。

参考文档


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!