不管你是否使用过Java开发应用程序,你可能多多少少都听过Java虚拟机(JVM)

JVM 是Java生态的核心,它让基于Java开发的软件程序实现了“一次编写,随地运行”。你可以在一台机器上开发Java代码,在任意其他机器使用JVM运行它。

JVM最初被设计时仅仅支持Java语言,然而,随着时间发展,许多其他语言例如ScalaKotlinGroovy等都被Java平台采用。这些语言统称为JVM语言。

在这篇文章中,我们会详细了解一下JVM,它是怎样工作的,和它的各个组成模块。

什么虚拟机?

在我们认识JVM之前,我们先重温一下虚拟机(VM) 的概念。

一个虚拟机就是一台物理计算机的虚拟表示,我们可以称虚拟机为客户机,运行虚拟机的物理机为宿主机。

RISC架构虚拟机 虚拟机架构的概述_JVM


一台物理机可以运行多个虚拟机,每一个虚拟机都拥有自己的操作系统和应用程序,这些虚拟机相互之间是隔离的。

什么是Java 虚拟机?

像C和C++这些编程语言,编写的代码会首先编译成平台级的机器码。这些语言叫做编译型语言

另一方面,像JavaScript和Python这些编程语言,计算机会直接执行代码指令不用提前编译,这些语言叫做解释性语言

Java采用两种技术的组合。Java代码首先编译成字节码生成一个class文件。这个class文件后续由Java虚拟机解释。同一个class文件可以在任意平台和操作系统的任意版本JVM运行。

译者注:任意平台和操作系统的任意版本JVM运行存在一定的限制,在运行平台的的JDK版本不能小于Class文件编译的版本。

和虚拟机类似,JVM也会在宿主机创建一个隔离的空间。这个空间可以用来执行宿主机上相对应的平台和操作系统的Java程序。

Java 虚拟机架构

JVM有三个模块组成:

  • 类加载器
  • 运行时数据区
  • 执行引擎

让我们详细看一下各个模块

类加载器

当你编译一个.java的源代码文件时,文件会被转换成字节码的.class文件。当你在应用程序中使用这个类时,类加载器会将它加载主存。

第一个被加载到内存中的类通常会包含main()方法。

在类加载过程中有三个步骤:加载(loading)链接(linking)初始化(initialization)

RISC架构虚拟机 虚拟机架构的概述_JVM_02

加载(Loading)

加载包括获取具有特定名称的类或接口的二进制表示(字节码),并由此生成原始的类或接口。

译者注:说白了就是将字节码解析成类或接口在程序运行时使用。

Java中有三个内置的类加载器:

  • Bootstrap Class Loader - 引导类加载器(启动类加载器,根类加载器)。它是扩展类加载器的的父类,主要用于加载标准Java库例如: java.langjava.netjava.utiljava.io等等。这些包都存放在rt.jar文件,其他核心库都存在$JAVA_HOME/jre/lib文件夹中。
  • Extension Class Loader - 扩展类加载器,它是引导类加载器子类同时是应用类加载器父类。它会加载存放在$JAVA_HOME/jre/lib/ext目录下标准Java库的扩展包
  • Application Class Loader - 应用类加载器,它是final类加载器同时是扩展类加载器的子类。它会加载在classpath目录下的文件。默认情况下,classpath就是应用程序的当前目录。这个classpath可以通过增加命令行参数-classpath-cp修改。

JVM使用方法 ClassLoader.loadClass()来加载类进入内存。它会尝试基于全路径名称来加载类。

如果一个父类加载器不能找到一个类,它会委托子类去完成查找工作。如果最后一个子加载器也不能加载这个类,它会抛出异常(NoClassDefFoundErrorClassNotFoundException

译者注:上述这句话有点偏向于宏观的逻辑,严格意义上可能存在表达不准确。实际这里有一个非常著名的双亲委派机制
双亲实际就是指父类,双亲委派机制指子类加载器在加载类时会先检查类是否被加载过,如果没有则将加载工作委托给父类加载器,父类加载器会再次检查类是否加载过,如果没有会再起委托给它自己的父加载器,一直到引导类加载器,如果引导类加载器加载没有找到这个类,后续会反过来每个类加载器自己去尝试加载这个类。如果最终都没有找到这抛出异常。
这里可以看到就是一个递归的过程,递归的深度取决于当前类加载器位于哪一个位置。
需要说明的时还有一个自定义类加载器,是应用类加载器子类。
双亲委派机制的主要目的就是保护类加载不会重复加载以及防止子类加载自定义的类来覆盖JDK的标准java库中类。例如:自定义一个java.lang.Object会交给引导类加载器来加载,引导类加载器判断已经加载过了则不会在加载,即便没有加载也不会使用自定义的类,而是从rt.jar中获取来保障应用的安全性。

译者增加示例说明:

public class LoaderTest {
    public static void main(String[] args) {
        //application class loader
        System.out.println("application class loader---------------------");
        ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(appClassLoader.getClass());
        //LoaderTest类是当前应用类存放在classpath路径下,所以类加载器是应用类加载器。
        System.out.println(LoaderTest.class.getClassLoader().getClass());

        //extension class loader
        System.out.println("extension class loader---------------------");
        ClassLoader extClassLoader = appClassLoader.getParent();
        System.out.println(extClassLoader.getClass());

        //bootstrap class loader
        System.out.println("bootstrap class loader---------------------");
        ClassLoader rootClassLoader = extClassLoader.getParent();
        //结果NULL 因为引导类加载器使用本地函数库(C\C++)写的,所以返回空。
        System.out.println(rootClassLoader);
        //String类是标准函数库java.lang包中的,所以类加载器是引导类加载器所以为空。
        System.out.println(String.class.getClassLoader());
        //File是标准函数库java.io包中的,所以类加载器是引导类加载器所以为空。
        System.out.println(File.class.getClassLoader());
        //List是标准函数库java.util包中的,所以类加载器是引导类加载器所以为空。
        System.out.println(List.class.getClassLoader());
    }
}

运行结果:

application class loader---------------------
class sun.misc.Launcher$AppClassLoader
class sun.misc.Launcher$AppClassLoader
extension class loader---------------------
class sun.misc.Launcher$ExtClassLoader
bootstrap class loader---------------------
null
null
null
null

链接

一个类被加载内存以后会进入链接过程。链接一个类或接口需要将一个程序的不同元素和依赖组合在一起。

链接包括三个步骤:

  • 验证:这个阶段检查.class文件的结构正确性,主要检查它是否违反了一系列的约束或规则。如果因为某个原因验证失败会抛出VerifyException
    举个例子,如果代码是使用Java 11开发编译的,而运行程序的系统上只安装了Java 8,那么验证阶段就失败了。
  • 准备:这个阶段JVM为类或者接口的静态字段分配内存,然后使用默认值进行初始化。
    举个例子,假设在你的类中定义下面的变量:
private static final boolean enabled = true;
  • 在主备阶段,JVM会为变量enabled分配内存然后设置一个boolean默认值false
  • 解析:这个阶段符号引用会被替换为运行时常量池中的直接引用。
    举个例子,如果你有一个指向其他类的引用或者位于其他类中的静态变量,在这个阶段它们会被解析替换为真实的引用。

初始化

初始化需要执行类或者接口的初始化方法(叫作<clinit>)。包括调用类的构造函数,执行静态代码块,分配所有静态变量的值。这个是类加载最后一步。
举例,当我们声明之前看过的下面代码:

private static final boolean enabled = true;

这个变量enabled在准备阶段被设置初始值为false,在初始化阶段,这个变量被分配真实的值true

注意:JVM是多线程的,在同一时刻,可能有多个线程尝试去实例化同一个类,这可能会导致并发问题。你需要处理线程安全来保证在一个多线程环境下程序正常工作。

运行时数据区

在运行时数据区有五个模块:

RISC架构虚拟机 虚拟机架构的概述_JVM_03

方法区

所有类级别的数据例如运行时常量池,字段和方法数据以及方法和构造函数的代码都存储在这个区域。

如果方法区的可用内存不足以程序启动,JVM会抛出OutOfMemeryError

举例,假如你定义了下面的类:

public class Employee {
  
  private String name;
  private int age;
  
  public Employee(String name, int age) {
  
    this.name = name;
    this.age = age;
  }
}

在这个代码示例中,字段级别数据例如nameage和构造详细都会被加载进方法区。

方法区在虚拟机启动时创建,每一个虚拟机只有一个方法取。

译者注:
1、方法区在JKD1.8以后改成元空间(metaspace)。方法区使用的JVM申请的到的内存。而元空间则使用的是直接内存。默认没有大小限制。即有多少宿主机有多少物理可用内存都可以为元空间分配内存。
2、常量池有多个分类:狭义常量池(存放字面量和符号引用,简单理解就是代码程序),运行时常量池(存放静态数据),字符串常量池(存放字符串,字符串是不可变的)。在JDK1.7以后将字符串常量池转移到堆区。

堆区

所有的对象和他们实例变量都存在堆区。这是一个运行时数据区域,用于所有类的实例和数组分配内存。

假如你声明了下面的实例:

Employee employee = new Employee();

在这个代码示例中,一个Employee的实例被创建然后被加载到堆区。

这个堆是在虚拟机启动时创建的,每一个JVM只有一个堆区。

注意:因为方法区和堆区是多线程共享内存,存储在这里的数据不是线程安全的。

栈区

一个线程不管什么时候在JVM中被创建,与此同时都会有一个独立的运行时栈被创建。所有的局部变量,方法调用,部分结果都会被存储在栈区。

如果线程在执行处理时需要的栈空间比可用空间小,JVM会抛出StackOverflowError

每次方法调用时,在占空间中都会创建一个条目叫作栈帧。方法调用结束时,栈帧会被销毁。

栈帧可以分成三个部分:

  • 本地变量 – 每个栈帧都包含一个变量数组,这些变量叫作局部变量。所有的变量和它的值都被存储在栈帧中。数组的长度在编译时就被确定下来。Each frame contains an array of variables known as its local variables. All local variables and their values are stored here. The length of this array is determined at compile-time.
  • 操作数栈 – 每个栈帧都包含一个后进先出(LIFO)的栈叫作操作数栈。它作为一个运行时工作区来执行所有的中间操作。这个栈的最大深度也是在编译时确定下来
  • 帧数据 – 所有的指向方法的符号引用都存储在这里。它也会存储catch代码块的信息防止异常出现。

举例你定义了下面的代码:

double calculateNormalisedScore(List<Answer> answers) {
  
  double score = getScore(answers);
  return normalizeScore(score);
}

double normalizeScore(double score) {
  
  return (score – minScore) / (maxScore – minScore);
}

在这个代码示例中,像answersscore的变量都会被存储在局部变量数组中。操作数栈包括变量和需要的操作数来执行减法和除法的数学运算。

RISC架构虚拟机 虚拟机架构的概述_JVM_04


注意:因为栈区不是共享的,所以它也是线程安全的。

程序计数器

同一时间JVM支持多个线程。每个线程都有自己的程序计数器来存储当前执行JVM指令的地址。只要这个执行被执行了,程序计数器就会被下一条指令更新(指令地址)。

本地方法栈

JVM还包括支持本地方法的栈。这些本地方法不是使用Java语言编写的,而是使用其他语言例如C和C++。对应每一个新的线程,一个独立的本地方法栈也会被分配。

执行引擎

只要字节码被加载主存中和细节信息在运行时数据去可用,下一步就是运行这个程序。这个执行引擎就是通过执行在类中代码来完成这个工作。

然后,在执行程序之前,字节码需要被转换成本地机器语言指令。JVM可以使用一个解释器或者一个即时(JIT)编译器执行。

RISC架构虚拟机 虚拟机架构的概述_JVM_05

解释器

解释器一行一行的读取和执行字节码指令。因为逐行执行,解释器相对较慢。
另一个缺点就是当一个方法被调用多次时,每次都需要重新解释。

即时编译器

即时编译器克服了解释器的缺点,执行引擎会先使用解释器来执行字节码,但是当它发现了许多重复代码,它会使用即时编译器。
即时编译器然后编译整体的字节码,把它转换成本地机器码。这个本地机器代码可以被直接使用于重复方法调用,从而提升系统的性能。

即时编译器有下面这些模块:

  • 中间代码生成器 - 生成中间代码
  • 代码优化器 - 基于更好的性能考虑优化中间代码。
  • 目标代码生成器 - 转换中间代码为本地机器代码。
  • 分析器 - 查找可以重复执行的热点代码。

为了更好的理解解释器和即时编译器的区别,假设编写了下面的代码:

int sum = 10;
for(int i = 0 ; i <= 10; i++) {
   sum += i;
}
System.out.println(sum);

解释器在循环过程中会一次一次的从内存中抓取sum的值,与i的值相加后赋值给自己,然后写会到内存。这是一个很耗费成本的操作,因为它每次进入循环后都会访问内存。

然而,即时编译器会识别出热点代码,与此同时会针对热点代码进行优化。它会为这个线程存储一份sum的本地拷贝,然后在循环中与i值相加后赋值给自己。一旦循环结束,它就会将sum的值写会到内存。

注意:即时编译器比解释器逐行解释代码要耗费更多的时间来编译代码。如果你打算只仅仅执行一次程序,那么使用解释器更好。

垃圾回收器

垃圾收集器从堆区中收集和移除没有引用的对象。它是一个通过销毁运行时无用对象来回收它们的过程。

垃圾收集让Java内存有效利用,它从堆区中移除没有引用的对象,为新对象预留空闲空间。它涉及两个阶段:

  • 标记(Mark) - 在这一步骤垃圾回收器会标识内存中的无用对象。
  • 清理(Sweep) - 在这一步垃圾回收器会移除上一步被标识的对象。

垃圾收集是JVM每个一段时间自动完成的,不需要单独处理。它也可以通过调用方法System.gc()来触发,但是这个垃圾回收操作不能保证调用完就会执行。

JVM包括三种不同类型的垃圾回收器:

  • 串行垃圾回收器 - 这个是最简单的垃圾回收器实现,为运行在单线程环境上的小型应用而设计。垃圾回收时使用单线程,它会导致STW(stop the world),整个应用都会被暂停。JVM参数-XX:+UseSerialGC来指定串行垃圾回收器。
  • 并行垃圾回收器 - 这个是JVM中默认的垃圾回收器的实现,也被称为吞吐量优先的垃圾回收器。垃圾回收时使用多个线程,但是也还会导致整个应用暂停。JVM参数-XX:+UseParallelGC指定并行垃圾回收器。
  • G1垃圾回收器 - G1垃圾回收器为拥有大量可用堆空间(超过4G)的多线程应用设计的。它把堆分区成一系列的等大小的区域,使用多个线程取扫描它们。G1垃圾回收器标识出具有最多垃圾的区域然后优先在这个区域进行垃圾回收。JVM参数-XX:+UseG1GC指定使用G1垃圾回收器。

注意:这里还有另一款垃圾回收器叫作并发标记清理垃圾回收器(CMS)。然而,它已经在Java 9版本变成过时的,在Java14被完全移除。G1GC完全取代CMS。

Java 本地接口(JNI)

有时,使用本地代码(非Java编写而是C/C++)。这些接口主要在我们需要与硬件进行交互,或者克服在Java中的内存管理和性能等约束时会被用到。Java通过Java本地接口(JNI)支持本地代码执行。

JNI充当了允许其他编程语言(如C、c++等)支持包的桥梁。当你需要写一些完全不被Java支持的代码,例如有些特定平台只能使用C来编写,这是JNI就有用武之地了。

你可以使用native关键字来指明这个方法实现是有一个本地库提供。你也需要调用System.loadLibrary来加载共享本地库进入内存,让本地库的函数对Java可用。

本地方法库

本地方法库指那些使用其他语言例如C、C++和汇编等编写的库。这些库通常以.dll.so的文件格式呈现。这些库可以通过JNI被加载到Java应用内存使用。

常见JVM错误

  • ClassNotFoundExcecption - 当类加载器试图使用class.forName()ClassLoader.loadClass()ClassLoader.findSystemClass()加载类时,但是通过限定名称没有找到类的定义会抛出这个异常。
  • NoClassDefFoundError - 编译器已经成功编译了一个类,但是类加载器在运行时不能定位到这个类。
  • OutOfMemoryError - JVM不能分配对象内存时发生,通常因为超过可用内存大小同时进行垃圾回收也不能获得更多内存。 StackOverflowError - 当处理一个线程时需要创建新的栈帧而没有足够的空间使用时发生。

总结

本文讨论JVM的架构和它的各个模块。平时我们一般不会深入研究JVM的内部机制、不关注JVM在运行代码时是怎样工作。

仅仅在出问题时需要调整JVM或修复内存泄漏,我们才会去理解它的内部机制。

这也是一个非常常见的面试题,不管是初级还是高级职位。深入理解JVM可以帮助你写出更好的代码和避免与堆栈和内存错误相关的陷阱。

英文原文: jvm-tutorial-java-virtual-machine-architecture-explained-for-beginners