讲解JVM原理的文章铺天盖地,希望这篇足够通俗易懂

4,025 阅读15分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

导读

学习过C/C++的同学都有过这样的体验,无论实现什么样的功能,用C/C++实现时,会存在下面两个问题:

  1. 内存管理:使用C/C++编程,我们必须很好地管理系统内存,如果稍有不慎,可能就会有内存溢出的风险
  2. 跨平台:比如,我们用C/C++实现聊天工具,为了让该工具可以在Windows、Mac OS、Linux等多个操作系统下使用,就光网络通讯部分,我们就不得不逐个调用这些操作系统自带的库函数来实现,这个代价是很高的

于是,Sun公司的大佬们决定开发Java语言,该语言使用JVM运行其编写的程序,让JVM来处理上面两个问题:内存管理和跨平台对接。大佬们希望通过这样的方案,让程序员们把更多的精力放在功能实现上。

网上有铺天盖地的文章讲解了JVM内存管理部分,但是,这些文章大多存在以下2个问题:

  1. 讲得不够透彻,导致你产生一种知道大概,但又感觉不够的意犹未尽之感
  2. 内容讲得的确通俗易懂,但是,总感觉支离破碎,知识点无法串联,给你一种不怎么完整的感觉

因此,今天,小k就以一个真实案例为起点,从JVM源码的角度深入剖析案例程序在JVM中的处理过程,给到你更透彻、更连贯的感受。

案例

假设掘金社区后端使用Java开发,掘金的程序员使用使用下面这段代码来启动:

package com.juejin;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
​
@SpringBootApplication
public class JueJinApplication {
​
  public static void main(String[] args) {
    SpringApplication.run(JueJinApplication.class, args);
  }
​
}

这是一段经典的Spring Boot启动类,那么,当我们将这个类打成jar包后,使用如下java命令执行这个jar:

java -cp juejin.jar com.juejin.JueJinApplication

此时,JVM内部会发生什么变化呢?

JNI

写Java的同学都知道,一段Java程序执行的入口是一个main方法,因此,JVM要执行上面这个jar包中的main方法并管理程序的内存,首先,得从jar中找到程序对应的main方法,即JueJinApplication类中的main,然后,把其加载到JVM中,这样,JVM才能自主地管理main方法使用的内存。

于是,Sun公司的程序员们开始着手编写main方法的查找逻辑,在《导读》中,我提到使用C/C++编程,我们必须很好地管理系统内存,于是,程序员们发现使用C++编写查找main方法的功能还要自己管理内存,这样太费事了,因此,他们就想出来一个方案:JNI。

JNI约定了一套Java与其他编程语言交互的契约,通过这个契约,我们就可以实现Java和其他编程语言的双向交互。比如,我们可以用C++调用Java的方法,反之,也可以用Java调用C++的函数。像下面这张图一样:

image.png

有了JNI之后,Sun公司的程序猿们就可以用Java实现案例中查找main方法的功能了,见下图:

image.png

上图就是《导读》案例中Java命令启动时,JVM查找main方法的示意图,JVM通过C++实现的LoadMainClass函数调用Java实现的checkAndLoadMain方法来查找并加载main方法。

image.png

上图中红线部分描述了JVM启动过程中,寻找和加载com.juejin.JueJinApplication及main方法的详细过程:

  1. 通过JLI_Launch函数启动JVM

  2. JLI_Launch内部调用ParseArguments函数解析启动参数

  3. 发现启动参数为-cp,JVM设置启动模式为LM_CLASS,表示指定mainClass启动

  4. 调用GetStaticMethodID函数获取方法名为checkAndLoadMain的方法ID

  5. 调用NewPlatformString函数转换checkAndLoadMain方法的入参,即启动类com.juejin.JueJinApplication的名字

  6. 调用CallStaticObjectMethod函数执行checkAndLoadMain方法,见上图最右边的黄色框:

    • 由于启动模式为LM_CLASS,使用SystemClassLoader去加载启动类mainClass,即com.juejin.JueJinApplication,当然还包括类中的方法main

通过上面的流程,我们发现,由于checkAndLoadMain是一个Java方法,因此,JVM通过JNI调用了该方法。

由此,我们就总结出了通过JNI调用Java方法的契约:

  • 通过GetStaticMethodID函数获取被调用的Java方法名
  • 通过CallStaticObjectMethod函数执行被调用的Java方法

这点可以帮助你在debug JVM源码时找到对应方法的入口

仔细看图的小伙伴应该已经发现我好像少讲了一些东西。是的,这里我补充一下:JVM会根据启动模式的不同,走不同的链路来完成mainClass的加载,图中,我只画了两种模式(-cp和-jar)的链路,因为这是我们常用的两种启动模式:

  • -cp:指定启动类启动程序,这条链路我上面讲过了。

  • -jar:指定jar包启动程序,这条链路主要有这几个步骤,见上图紫色线部分:

    • JVM发现启动参数为-jar,于是,设置启动模式为LM_JAR
    • 由于启动模式为LM_JAR,于是,从jar中找到manifest文件,提取文件中的Main-Class关键字,找到对应的mainClass名
    • 和LM_CLASS模式加载启动类一样,使用SystemClassLoader去加载启动类mainClass及内部的main方法

其他两种启动模式LM_SOURCE和LM_MODULE,有兴趣的小伙伴可以自己研究一下~

我们的Java程序最终是由JVM执行的,因此,加载到JVM的main方法,最终还是要通过JVM来处理并执行。

不过在讲解JVM执行main方法前,小k先来给你做一个分析:

我们都知道,无论通过maven还是gradle打包后,打包后,包内部的class文件都是字节码,同时,我们知道这样一个定律:

image.png

如上图是CPU处理程序的定律:金字塔从上到下,CPU处理的性能逐渐下降,即处理CPU缓存是最快的,寄存器其次,处理磁盘是最慢的。

由于CPU缓存的读写,程序不能控制,因此,JVM想要高效地执行程序,肯定希望将程序尽可能地放到寄存器中,这样,CPU处理程序就很快了。

但是,我们的jar中的程序是一段字节码,而学计算机的同学都知道,寄存器中存放的是机器指令,也就是二进制指令,因此,JVM只有将程序字节码转换为机器指令,最后,才能将程序对应的机器指令放入寄存器中。

image.png

于是,如上图所示,《导读》中的案例,JVM在使用SystemClassLoader加载JueJinApplication的时候,做了字节码转指令的工作。ps:为了方便解读,图中箭头右侧的机器指令换成汇编表达了。

但是,这里有一个问题:《导读》案例中的类JueJinApplication及注解@SpringBootApplication,它们是线程共享的,而寄存器中的指令是一个一个线程去读取的,因此,将类JueJinApplication及注解@SpringBootApplication写入寄存器就不太合适了,因此,JVM就设计了MetaSpace来存放这两个信息。关于MetaSpace及JMM相关知识,网上有非常多的文章讲解,这里我就不细说了。

而JueJinApplication中的main方法执行相关的元素是线程独享的,可以存入寄存器中,因此,今天我们主要来看一下JueJinApplication中的main方法是如何转化为机器指令的?

模板解释执行

我们先来看JueJinApplication这个类的字节码长什么样:

public class com.juejin.JueJinApplication {
  public com.juejin.JueJinApplication();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
​
  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/juejin/JueJinApplication
       2: aload_0
       3: invokestatic  #3                  // Method org/springframework/boot/SpringApplication.run:(Ljava/lang/Class;[Ljava/lang/String;)Lorg/springframework/context/ConfigurableApplicationContext;
       6: pop
       7: return
}

这里我简单梳理一下里面的结构,代码中Code表示的就是字节码:

  • JueJinApplication类中的字节码:

    • aload_0:将this引用压入栈顶
    • invokespecial #1:调用JueJinApplication的父类java.lang.Object的构造方法
  • main方法中的字节码:

    • ldc #2:将类JueJinApplication压入栈顶
    • aload_0:将args参数压入栈顶
    • invokestatic #3:调用静态方法SpringApplication.run,方法入参为类JueJinApplication和args,返回结构为ConfigurableApplicationContext
    • pop:弹出SpringApplication.run方法返回值,因为main方法中没有使用SpringApplication.run的返回值

已知JueJinApplication类中的字节码,那么,我们要把这些字节码指令转换成对应的机器指令,就不得不考虑一个前提:不同CPU架构的指令集对应的机器指令格式是不一样的。比如,有x86指令集、ARM指令集等等,它们的机器指令格式都不相同。因此,JVM设计了这样一个方案来实现JueJinApplication类中main方法字节码指令和机器指令的转换:

image.png

  1. Bytecodes结构中定义了Java中所有会使用到字节码,JVM将这些字节码传递给TemplateTable。如上图顶部框中aload_0、pop为JueJinApplication类中的字节码指令。

  2. TemplateTable使用上一步得到的全量字节码,生成字节码对应的模板,该模板定义了字节码和机器指令模板的映射关系。这里我以aload_0字节码指令为例看下模板:

    • aload_0 => ubcp|__|clvm|__, vtos, atos, aload_0, _ ,其中,=> 表示aload_0字节码指令和对应机器指令模板的映射:

      • =>左边的aload_0代表字节码指令aload_0

      • =>右边表示aload_0字节码指令对应的机器指令模板,模板中包含5个参数:

        • flags:里面定义了4个flag:

          • ubcp:是否使用bytecode pointer指向字节码指令,如果classfile中的方法是Java方法,那么,方法内的字节码指令就需要这个指针,这时,该flag就是true,如果classfile中的方法是native方法,由于native方法使用C/C++实现,所以,直接调用方法就行,无需指针
          • disp:是否在模板范围内进行转发,比如,goto指令会跳转到其他指令位置,这时该flag就是true
          • clvm:否需要调用vm_call函数,由于aload_0内部会调vm_call函数,因此,clvm为true,反正,为false
          • iswd:是否是宽指令,比如,iload字节码指令就是宽指令,该指令表示从局部变量表读取变量并压入栈顶,当局部变量表可容纳256个变量,即2^8,这时,iswd为false,而iload指令可能读取的局部变量会很多,会超出2^8,此时,就需要扩展局部变量表大小为2^16,即可容纳65536个变量,此时的iswd就为true

          根据flags的定义,aload_0字节码指令是Java方法的,因此,ubcp为true,

        • aload_0:表示aload_0字节码指令使用aload_0函数生成对应的机器指令,因为aload_0字节码指令对应不只一条机器指令

        • vtos:aload_0字节码指令的入参,这是执行aload_0字节码指令对应机器指令操作数的入口地址,下面在《栈顶缓存》中详细讲到

        • atos:aload_0字节码指令的出参,可能作为下一条指令的入参

        • _:aload_0字节码指令使用到的局部变量,由于aload_0的入参就是栈里的入参变量,非局部变量,因此,这个参数设为__

    然后,JVM将字节码和机器指令模板的映射关系传递给TemplateInterpreterGenerator

  3. TemplateInterpreterGenerator调用不同CPU架构汇编器生成字节码指令对应的机器指令,我还是以aload_0字节码指令为例:

    • 假设JVM调用了x86架构的汇编器生成机器指令,即上图中的x86 Assembler(汇编器):

      • 如上图,底部蓝框中左边的aload_0即第2步中模板中的aload_0参数,表示aload_0字节码指令使用该参数生成对应的机器指令。
      • 如上图,底部蓝框中右边的aload_0机器指令,表示aload_0字节码指令对应的机器指令

      因此,aload_0 => aload_0机器指令表示定义了aload_0字节码指令生成机器指令的过程。

  4. TemplateInterpreterGenerator根据第2步得到的aload_0机器指令模板,匹配第3步中x86汇编器中的aload_0参数,图中两个标红aload_0表示这个匹配,接着,调用该参数执行并生成aload_0对应的机器指令。如上图黄色框中的aload_0指令就表示aload_0字节码指令对应的机器指令。

  5. 将生成的aload_0机器指令写入ICache,指令缓存

  6. 同理,和aload_0字节码指令一样,JVM将JueJinApplication类中main方法中其他的字节码指令都转换生成对应的机器指令,并写如ICache。

JVM将上面通过TemplateInterpreterGenerator模板解释生成器直接生成机器指令,然后,执行机器指令的方式叫做模板解释执行。这是JVM执行Java程序的一种形式,在Hotspot中还有两种执行方式:字节码解释执行和C++解释执行。感兴趣的同学可以自行了解一下。

栈顶缓存

在前面,我提到JVM将字节码转为机器指令的目的是将转化后的指令写入寄存器,来提升CPU处理程序的性能,在JVM中,这样的写入方式就叫做栈顶缓存。我们就以main方法中的aload_0字节码指令为例,来看下JVM是如何做栈顶缓存的。

写栈顶缓存

image.png

JVM将转换后的机器指令写入寄存器是在生成完机器指令后做的,上图展示了《导读》案例中main方法的aload_0字节码指令写入的过程:

  1. 由于解析完classfile后,我们就知道main方法的入参是args,所以,将args压入栈顶。如上图虚线部分。

  2. 栈顶缓存定义了10种状态,表示缓存的变量类型,如上图绿框部分,这里,我先解释一下:

    • btos:缓存bool类型的变量,对应bep表示,该变量在栈中的地址
    • ztos:缓存byte类型的变量,对应bep表示,该变量在栈中的地址
    • ctos:缓存char类型的变量,对应cep表示,该变量在栈中的地址
    • stos:缓存short类型的变量,对应sep表示,该变量在栈中的地址
    • itos:缓存int类型的变量,对应iep表示,该变量在栈中的地址
    • ltos:缓存long类型的变量,对应lep表示,该变量在栈中的地址
    • ftos:缓存float类型的变量,对应fep表示,该变量在栈中的地址
    • dtos:缓存double类型的变量,对应dep表示,该变量在栈中的地址
    • atos:缓存object类型的变量,对应aep表示,该变量在栈中的地址
    • vtos:这个很特殊,表示指令所需变量/参数已经在栈顶,无需缓存,对应vep表示,该变量在栈中的地址

    执行指令前后,操作数在栈中的变化都反映在*ep这个变量里。这些*ep组成一个数组entry,如上图绿色部分。为什么用数组,是因为一条指令执行前后的状态是通过多个ep变量反映在栈中的

    因为aload_0指令中0表示取栈顶中的变量,说明取数是变量已在栈顶,因此,参考上面的栈顶缓存的10种状态,该aload_0指令对应的vep为栈顶的地址。如上图,entry数组中的vep指向了栈顶。因为aload_0指令没有其他操作数,因此,其他ep变量都指向了栈顶。

  3. 将每个ep变量写入一个二维数组,该数组的下标为[栈顶缓存状态][字节码指令],这个二维数组就是栈顶缓存。如上图,entry就是这个二维数组,JVM将entry数组中的每一个ep变量,即aload指令操作数在栈中的位置写入[vtos][aload_0],[atos][aload_0]等等。这样就完成了栈顶缓存。

读取栈顶缓存

有了栈顶缓存,JVM在执行main方法对应机器指令时就可以根据指令+操作数从栈顶缓存中找到对应的操作数,最后,交由CPU执行指令,以案例中的main方法的aload_0字节码指令为例,具体过程如下:

image.png

我们关注图中红线部分:

  1. JVM根据aload_0 + 入参args(表示从栈顶取args值),从栈顶缓存二维数组中定位到[vtos][aload_0],该单元中存的就是vep对应的栈的位置:栈顶
  2. 由于vtos对应vep指向栈顶,于是,JVM从栈顶取到入参args的值
  3. 将args的值传递给CPU
  4. CPU从ICache中取出aload_0对应的机器指令
  5. CPU执行机器指令(aload_0指令 + 操作数args的值)

总结

在这篇文章中,我主要讲解了JNI、模板解释执行、栈顶缓存的概念。我相信你可能还有一些关联问题,比如:

  • 栈是怎么生成的,什么时候生成?
  • 栈里存的到底是什么数据,二进制还是16进制,又或者根据数据类型相关?
  • JVM是怎么操作栈的?

都是很好的问题,小k在后面的文章中,慢慢会详细讲解。

最后,小k还是希望掘金的小伙伴们通过文章的学习,可以有所启发、收获和成长。 当然,如果有任何疑问都可以在评论区留言哈,相信每一位小伙伴将来都会成为技术大牛!