讲解JVM原理的文章铺天盖地,希望这篇足够通俗易懂
Posted JAVA炭烧
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了讲解JVM原理的文章铺天盖地,希望这篇足够通俗易懂相关的知识,希望对你有一定的参考价值。
导读
学习过C/C++的同学都有过这样的体验,无论实现什么样的功能,用C/C++实现时,会存在下面两个问题:
- 内存管理:使用C/C++编程,我们必须很好地管理系统内存,如果稍有不慎,可能就会有内存溢出的风险
- 跨平台:比如,我们用C/C++实现聊天工具,为了让该工具可以在Windows、Mac OS、Linux等多个操作系统下使用,就光网络通讯部分,我们就不得不逐个调用这些操作系统自带的库函数来实现,这个代价是很高的
于是,Sun公司的大佬们决定开发Java语言,该语言使用JVM运行其编写的程序,让JVM来处理上面两个问题:内存管理和跨平台对接。大佬们希望通过这样的方案,让程序员们把更多的精力放在功能实现上。
网上有铺天盖地的文章讲解了JVM内存管理部分,但是,这些文章大多存在以下2个问题:
- 讲得不够透彻,导致你产生一种知道大概,但又感觉不够的意犹未尽之感
- 内容讲得的确通俗易懂,但是,总感觉支离破碎,知识点无法串联,给你一种不怎么完整的感觉
因此,今天,小k就以一个真实案例为起点,从JVM源码的角度深入剖析案例程序在JVM中的处理过程,给到你更透彻、更连贯的感受。
案例
假设CSDN后端使用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++的函数。像下面这张图一样:
有了JNI之后,Sun公司的程序猿们就可以用Java实现案例中查找main方法的功能了,见下图:
上图就是《导读》案例中Java命令启动时,JVM查找main方法的示意图,JVM通过C++实现的LoadMainClass函数调用Java实现的checkAndLoadMain方法来查找并加载main方法。
上图中红线部分描述了JVM启动过程中,寻找和加载com.juejin.JueJinApplication及main方法的详细过程:
-
通过JLI_Launch函数启动JVM
-
JLI_Launch内部调用ParseArguments函数解析启动参数
-
发现启动参数为-cp,JVM设置启动模式为LM_CLASS,表示指定mainClass启动
-
调用GetStaticMethodID函数获取方法名为checkAndLoadMain的方法ID
-
调用NewPlatformString函数转换checkAndLoadMain方法的入参,即启动类com.juejin.JueJinApplication的名字
-
调用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文件都是字节码,同时,我们知道这样一个定律:
如上图是CPU处理程序的定律:金字塔从上到下,CPU处理的性能逐渐下降,即处理CPU缓存是最快的,寄存器其次,处理磁盘是最慢的。
由于CPU缓存的读写,程序不能控制,因此,JVM想要高效地执行程序,肯定希望将程序尽可能地放到寄存器中,这样,CPU处理程序就很快了。
但是,我们的jar中的程序是一段字节码,而学计算机的同学都知道,寄存器中存放的是机器指令,也就是二进制指令,因此,JVM只有将程序字节码转换为机器指令,最后,才能将程序对应的机器指令放入寄存器中。
于是,如上图所示,《导读》中的案例,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方法字节码指令和机器指令的转换:
-
Bytecodes结构中定义了Java中所有会使用到字节码,JVM将这些字节码传递给TemplateTable。如上图顶部框中aload_0、pop为JueJinApplication类中的字节码指令。
-
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个变量,即28,这时,iswd为false,而iload指令可能读取的局部变量会很多,会超出28,此时,就需要扩展局部变量表大小为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
-
-
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字节码指令生成机器指令的过程。
-
-
TemplateInterpreterGenerator根据第2步得到的aload_0机器指令模板,匹配第3步中x86汇编器中的aload_0参数,图中两个标红aload_0表示这个匹配,接着,调用该参数执行并生成aload_0对应的机器指令。如上图黄色框中的aload_0指令就表示aload_0字节码指令对应的机器指令。
-
将生成的aload_0机器指令写入ICache,指令缓存
-
同理,和aload_0字节码指令一样,JVM将JueJinApplication类中main方法中其他的字节码指令都转换生成对应的机器指令,并写如ICache。
JVM将上面通过TemplateInterpreterGenerator模板解释生成器直接生成机器指令,然后,执行机器指令的方式叫做模板解释执行。这是JVM执行Java程序的一种形式,在Hotspot中还有两种执行方式:字节码解释执行和C++解释执行。感兴趣的同学可以自行了解一下。
栈顶缓存
在前面,我提到JVM将字节码转为机器指令的目的是将转化后的指令写入寄存器,来提升CPU处理程序的性能,在JVM中,这样的写入方式就叫做栈顶缓存。我们就以main方法中的aload_0字节码指令为例,来看下JVM是如何做栈顶缓存的。
写栈顶缓存
JVM将转换后的机器指令写入寄存器是在生成完机器指令后做的,上图展示了《导读》案例中main方法的aload_0字节码指令写入的过程:
-
由于解析完classfile后,我们就知道main方法的入参是args,所以,将args压入栈顶。如上图虚线部分。
-
栈顶缓存定义了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变量都指向了栈顶。
-
将每个ep变量写入一个二维数组,该数组的下标为
[栈顶缓存状态][字节码指令]
,这个二维数组就是栈顶缓存。如上图,entry就是这个二维数组,JVM将entry数组中的每一个ep变量,即aload指令操作数在栈中的位置写入[vtos][aload_0],[atos][aload_0]
等等。这样就完成了栈顶缓存。
读取栈顶缓存
有了栈顶缓存,JVM在执行main方法对应机器指令时就可以根据指令+操作数从栈顶缓存中找到对应的操作数,最后,交由CPU执行指令,以案例中的main方法的aload_0字节码指令为例,具体过程如下:
我们关注图中红线部分:
- JVM根据aload_0 + 入参args(表示从栈顶取args值),从栈顶缓存二维数组中定位到
[vtos][aload_0]
,该单元中存的就是vep对应的栈的位置:栈顶 - 由于vtos对应vep指向栈顶,于是,JVM从栈顶取到入参args的值
- 将args的值传递给CPU
- CPU从ICache中取出aload_0对应的机器指令
- CPU执行机器指令(aload_0指令 + 操作数args的值)
总结
在这篇文章中,我主要讲解了JNI、模板解释执行、栈顶缓存的概念。我相信你可能还有一些关联问题,比如:
- 栈是怎么生成的,什么时候生成?
- 栈里存的到底是什么数据,二进制还是16进制,又或者根据数据类型相关?
- JVM是怎么操作栈的?
以上是关于讲解JVM原理的文章铺天盖地,希望这篇足够通俗易懂的主要内容,如果未能解决你的问题,请参考以下文章
通俗易懂讲解dpdk,使用场景,实现原理,dpdk的技术生态
web安全:通俗易懂,以实例讲述破解网站的原理及如何进行防护!如何让网站变得更安全。收藏