「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
导读
学习过C/C++的同学都有过这样的体验,无论实现什么样的功能,用C/C++实现时,会存在下面两个问题:
- 内存管理:使用C/C++编程,我们必须很好地管理系统内存,如果稍有不慎,可能就会有内存溢出的风险
- 跨平台:比如,我们用C/C++实现聊天工具,为了让该工具可以在Windows、Mac OS、Linux等多个操作系统下使用,就光网络通讯部分,我们就不得不逐个调用这些操作系统自带的库函数来实现,这个代价是很高的
于是,Sun公司的大佬们决定开发Java语言,该语言使用JVM运行其编写的程序,让JVM来处理上面两个问题:内存管理和跨平台对接。大佬们希望通过这样的方案,让程序员们把更多的精力放在功能实现上。
网上有铺天盖地的文章讲解了JVM内存管理部分,但是,这些文章大多存在以下2个问题:
- 讲得不够透彻,导致你产生一种知道大概,但又感觉不够的意犹未尽之感
- 内容讲得的确通俗易懂,但是,总感觉支离破碎,知识点无法串联,给你一种不怎么完整的感觉
因此,今天,小k就以一个真实案例为起点,从JVM源码的角度深入剖析案例程序在JVM中的处理过程,给到你更透彻、更连贯的感受。
案例
假设掘金社区后端使用Java开发,掘金的程序员使用使用下面这段代码来启动:
1 | typescript复制代码package com.juejin; |
这是一段经典的Spring Boot启动类,那么,当我们将这个类打成jar包后,使用如下java命令执行这个jar:
1 | bash复制代码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这个类的字节码长什么样:
1 | vbnet复制代码public class com.juejin.JueJinApplication { |
这里我简单梳理一下里面的结构,代码中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个变量,即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
- 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变量都指向了栈顶。
3. 将每个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是怎么操作栈的?
都是很好的问题,小k在后面的文章中,慢慢会详细讲解。
最后,小k还是希望掘金的小伙伴们通过文章的学习,可以有所启发、收获和成长。
当然,如果有任何疑问都可以在评论区留言哈,相信每一位小伙伴将来都会成为技术大牛!
本文转载自: 掘金