JVM笔记(一)走进JVM
走进JVM
JVM相对于Java应用层的学习难度更大,开篇推荐掌握的预备知识: C/C++(关键)、微机原理与接口技术、计算机组成原理、操作系统、数据结构与算法、编译原理(不推荐刚学完JavaSE的同学学习),如果没有掌握推荐的一半以上的预备知识,可能学习起来会比较吃力。
本套课程中需要用到的开发工具: CLion、IDEA、Jetbrains Gateway
此阶段,我们需要深入探讨Java的底层执行原理,了解Java程序运行的本质。开始之前,推荐各位都入手一本《深入理解Java虚拟机 第三版》这本书对于JVM的讲述非常地详细:

点击查看图片来源
我们在JavaSE阶段的开篇就进行介绍了,我们的Java程序之所以能够实现跨平台,本质就是因为它是运行在虚拟机之上的,而不同平台只需要安装对应平台的Java虚拟机即可运行(在JRE中包含),所有的Java程序都采用统一的标准,在任何平台编译出来的字节码文件(.class)也是同样的,最后实际上是将编译后的字节码交给JVM处理执行。

点击查看图片来源
也正是得益于这种统一规范,除了Java以外,还有多种JVM语言,比如Kotlin、Groovy等,它们的语法虽然和Java不一样,但是最终编译得到的字节码文件,和Java是同样的规范,同样可以交给JVM处理。

点击查看图片来源
所以,JVM是我们需要去关注的一个部分,通过了解Java的底层运作机制,我们的技术会得到质的提升。
技术概述
首先我们要了解虚拟机的具体定义,我们所接触过的虚拟机有安装操作系统的虚拟机,也有我们的Java虚拟机,而它们所面向的对象不同,Java虚拟机只是面向单一应用程序的虚拟机,但是它和我们接触的系统级虚拟机一样,我们也可以为其分配实际的硬件资源,比如最大内存大小等。
并且Java虚拟机并没有采用传统的PC架构,比如现在的HotSpot虚拟机,实际上采用的是
基于栈的指令集架构
,而我们的传统程序设计一般都是基于寄存器的指令集架构
,这里我们需要回顾一下计算机组成原理
中的CPU结构:
image-20230306164318560
其中,AX,BX,CX,DX 称作为数据寄存器:
- AX (Accumulator):累加寄存器,也称之为累加器;
- BX (Base):基地址寄存器;
- CX (Count):计数器寄存器;
- DX (Data):数据寄存器;
这些寄存器可以用来传送数据和暂存数据,并且它们还可以细分为一个8位的高位寄存器和一个8位的低位寄存器,除了这些通用功能,它们各自也有自己的一些专属职责,比如AX就是一个专用于累加的寄存器,用的也比较多。
SP 和 BP 又称作为指针寄存器:
- SP (Stack Pointer):堆栈指针寄存器,与SS配合使用,用于访问栈顶;
- BP (Base Pointer):基指针寄存器,可用作SS的一个相对基址位置,用它可直接存取堆栈中的数据;
SI 和 DI 又称作为变址寄存器:
- SI (Source Index):源变址寄存器;
- DI (Destination Index):目的变址寄存器;
主要用于存放存储单元在段内的偏移量,用它们可实现多种存储器操作数的寻址方式,为以不同的地址形式访问存储单元提供方便。
控制寄存器:
- IP (Instruction Pointer):指令指针寄存器;
- FLAG:标志寄存器;
段寄存器:
- CS (Code Segment):代码段寄存器;
- DS (Data Segment):数据段寄存器;
- SS (Stack Segment):堆栈段寄存器;
- ES (Extra Segment):附加段寄存器;
这里我们分别比较一下在x86架构下C语言和arm架构下编译之后的汇编指令不同之处:
在arm架构下(Apple M1 Pro芯片)编译的结果为:
我们发现,在不同的CPU架构下,实际上得到的汇编代码也不一样,并且在arm架构下并没有和x86架构一样的寄存器结构,因此只能使用不同的汇编指令操作来实现。所以这也是为什么C语言不支持跨平台的原因,我们只能将同样的代码在不同的平台上编译之后才能在对应的平台上运行我们的程序。而Java利用了JVM,它提供了很好的平台无关性(当然,JVM本身是不跨平台的),我们的Java程序编译之后,并不是可以由平台直接运行的程序,而是由JVM运行,同时,我们前面说了,JVM(如HotSpot虚拟机),实际上采用的是
基于栈的指令集架构
,它并没有依赖于寄存器,而是更多的利用操作栈来完成,这样不仅设计和实现起来更简单,并且也能够更加方便地实现跨平台,不太依赖于硬件的支持。这里我们对一个类进行反编译查看:
得到如下结果:
我们可以看到,java文件编译之后,也会生成类似于C语言那样的汇编指令,但是这些命令都是交给JVM去执行的命令(实际上虚拟机提供了一个类似于物理机的运行环境,也有程序计数器之类的东西),最下方存放的是本地变量(局部变量)表,表示此方法中出现的本地变量,实际上this也在其中,所以我们才能在非静态方法中使用
this
关键字,在最上方标记了方法的返回值类型、访问权限等。首先介绍一下例子中出现的命令代表什么意思:- bipush 将单字节的常量值推到栈顶
- istore_1 将栈顶的int类型数值存入到第二个本地变量
- istore_2 将栈顶的int类型数值存入到第三个本地变量
- istore_3 将栈顶的int类型数值存入到第四个本地变量
- iload_1 将第二个本地变量推向栈顶
- iload_2 将第三个本地变量推向栈顶
- iload_3 将第四个本地变量推向栈顶
- iadd 将栈顶的两个int类型变量相加,并将结果压入栈顶
- ireturn 方法的返回操作
有关详细的指令介绍列表可以参考《深入理解Java虚拟机 第三版》附录C。
JVM运行字节码时,所有的操作基本都是围绕两种数据结构,一种是堆栈(本质是栈结构),还有一种是队列,如果JVM执行某条指令时,该指令需要对数据进行操作,那么被操作的数据在指令执行前,必须要压到堆栈上,JVM会自动将栈顶数据作为操作数。如果堆栈上的数据需要暂时保存起来时,那么它就会被存储到局部变量队列上。
我们从第一条指令来依次向下解读,显示方法相关属性:
有关descriptor的详细属性介绍,我们会放在之后的类结构中进行讲解。
接着我们来看指令:
这一步操作实际上就是使用
bipush
将10推向栈顶,接着使用istore_1
将当前栈顶数据存放到第二个局部变量中,也就是a,所以这一步执行的是int a = 10
操作。同上,这里执行的是
int b = 20
操作。这里是将第二和第三个局部变量放到栈中,也就是取a和b的值到栈中,最后
iadd
操作将栈中的两个值相加,结果依然放在栈顶。将栈顶数据存放到第四个局部变量中,也就是c,执行的是
int c = 30
,最后取出c的值放入栈顶,使用ireturn
返回栈顶值,也就是方法的返回值。至此,方法执行完毕。
实际上我们发现,JVM执行的命令基本都是入栈出栈等,而且大部分指令都是没有操作数的,传统的汇编指令有一操作数、二操作数甚至三操作数的指令,Java相比C编译出来的汇编指令,执行起来会更加复杂,实现某个功能的指令条数也会更多,所以Java的执行效率实际上是不如C/C++的,虽然能够很方便地实现跨平台,但是性能上大打折扣,所以在性能要求比较苛刻的Android上,采用的是定制版的JVM,并且是基于寄存器的指令集架构。此外,在某些情况下,我们还可以使用JNI机制来通过Java调用C/C++编写的程序以提升性能(也就是本地方法,使用到native关键字)
现在与未来
随着时代的变迁,JVM的实现多种多样,而我们还要从最初的虚拟机说起。
虚拟机的发展历程
在1996,Java1.0面世时,第一款商用虚拟机Sun Classic VM开始了它的使命,这款虚拟机提供了一个Java解释器,也就是将我们的class文件进行读取,最后像上面一样得到一条一条的命令,JVM再将指令依次执行。虽然这样的运行方式非常的简单易懂,但是它的效率实际上是很低的,就像你耳机里一边在放六级听力,你必须同时记在脑海里面然后等着问问题,再去选择问题的答案一样,更重要的是同样的代码每次都需要重新翻译再执行。
这个时候我们就需要更加高效的方式来运行Java程序,随着后面的发展,现在大多数的主流的JVM都包含即时编译器。JVM会根据当前代码的进行判断,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler)

img
在JDK1.4时,Sun Classic VM完全退出了历史舞台,取而代之的是至今都在使用的HotSpot VM,它是目前使用最广泛的虚拟机,拥有上面所说的热点代码探测技术、准确式内存管理(虚拟机可以知道内存中某个位置的数据具体是什么类型)等技术,而我们之后的章节都是基于HotSpot虚拟机进行讲解。
虚拟机发展的未来
2018年4月,Oracle Labs公开了最新的GraalVM,它是一种全新的虚拟机,它能够实现所有的语言统一运行在虚拟机中。

img
Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。
Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式(例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation,IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为“程序特化”(Specialized,也常称为Partial Evaluation)。Graal VM提供了Truffle工具集来快速构建面向一种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。
目前最新的SpringBoot已经提供了本地运行方案:https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/
Spring Native支持使用GraalVM原生镜像编译器将Spring应用程序编译为本机可执行文件。与Java虚拟机相比,原生映像可以为许多类型的工作负载实现更简单、更加持续的托管。包括微服务、非常适合容器的功能工作负载和Kubernetes使用本机映像提供了关键优势,如即时启动、即时峰值性能和减少内存消耗。GraalVM原生项目预计随着时间的推移会改进一些缺点和权衡。构建本机映像是一个比常规应用程序慢的繁重过程。热身后的本机映像运行时优化较少。最后,它不如JVM成熟,行为各不相同。常规JVM和此原生映像平台的主要区别是:
- 从主入口点对应用程序进行静态分析,在构建时进行。
- 未使用的部件将在构建时删除。
- 反射、资源和动态代理需要配置。
- Classpath在构建时是固定的。
- 没有类惰性加载:可执行文件中运送的所有内容将在启动时加载到内存中。
- 一些代码将在构建时运行。
- Java应用程序的某些方面有一些不受完全支持的限制。
该项目的目标是孵化对Spring Native的支持,Spring Native是Spring JVM的替代品,并提供旨在打包在轻量级容器中的原生部署选项。在实践中,目标是在这个新平台上支持您的Spring应用程序,几乎未经修改。
优点:
- 立即启动,一般启动时间小于100ms
- 更低的内存消耗
- 独立部署,不再需要JVM
- 同样的峰值性能要比JVM消耗的内存小
缺点:
- 构建时间长
- 只支持新的Springboot版本(2.4.4+)
手动编译JDK8
学习JVM最关键的是研究底层C/C++源码,我们首先需要搭建一个测试环境,方便我们之后对底层源码进行调试。但是编译这一步的坑特别多,请务必保证跟教程中的环境一致,尤其是编译环境,版本不能太高,因为JDK8属于比较早期的版本了,否则会遇到各种各样奇奇怪怪的问题。
环境配置
- 操作系统:Ubuntu 20.04 Server
- 硬件配置:i7-4790 4C8T/ 16G内存 / 128G硬盘 (不能用树莓派或是arm芯片Mac的虚拟机,配置越高越好,不然卡爆)
- 调试工具:Jetbrains Gateway(服务器运行CLion Backend程序,界面在Mac上显示)
- OpenJDK源码:https://codeload.github.com/openjdk/jdk/zip/refs/tags/jdk8-b120
- 编译环境:
- gcc-4.8
- g++-4.8
- make-3.81
- openjdk-8
开始折腾
首选需要在我们的测试服务器上安装Ubuntu 20.04 Server系统,并通过ssh登录到服务器:
先安装一些基本的依赖:
接着我们先将JDK的编译环境配置好,首先是安装gcc和g++的4.8版本,但是最新的源没有这个版本了,我们先导入旧版软件源:
在最下方添加旧版源地址并保存:
接着更新一下apt源信息,并安装gcc和g++:
接着配置:
最后查看版本是否为4.8版本:
接着安装make 3.81版本,需要从官方下载:
下载好之后进行解压,并进入目录:
接着我们修改一下代码,打开
glob/glob.c
文件:接着进行配置并完成编译和安装:
安装完成后,将make已经变成3.81版本了:
由于JDK中某些代码是Java编写的,所以我们还需要安装一个启动JDK,启动JDK可以是当前版本或低一版本,比如我们要编译JDK8的源码,那么就可以使用JDK7、JDK8作为启动JDK,对源码中的一些java文件进行编译。这里我们选择安装OpenJDK8作为启动JDK:
这样,我们的系统环境就准备完成了,接着我们需要下载OpenJDK8的源码(已经放在网盘了)解压:
接着我们需要安装JetBrains Gateway在我们的服务器上导入项目,这里我们使用CLion后端,等待下载远程后端,这样我们的Linux服务器上虽然没有图形化界面,但是依然可以使用IDEA、CLion等工具,只是服务器上只有后端程序,而界面由我们电脑上的前端程序提供(目前此功能还在Beta阶段,暂不支持arm架构的Linux服务器)整个过程根据服务器配置决定可能需要5-20分钟。
完成之后,我们操作起来就很方便了,界面和IDEA其实差不多,我们打开终端,开始进行配置:
配置完成后,再次确认是否和教程中的配置信息一致:
接着我们需要修改几个文件,不然一会会编译失败,首先是
hotspot/make/linux/Makefile
文件:接着是
hotspot/make/linux/makefiles/gcc.make
文件:接着是
nashorn/make/BuildNashorn.gmk
文件:OK,修改完成,接着我们就可以开始编译了:
整个编译过程大概需要持续10-20分钟,请耐心等待。构建完成后提示:
只要按照我们的教程一步步走,别漏了,应该是直接可以完成的,当然难免可能有的同学出现了奇奇怪怪的问题,加油,慢慢折腾,总会成功的~
接着我们就可以创建一个测试配置了,首先打开设置页面,找到
自定义构建目标
:
image-20230306164504510
点击
应用
即可,接着打开运行配置,添加一个新的自定义配置:
image-20230306164521873
选择我们编译完成的java程序,然后测试-version查看版本信息,去掉下方的构建。
接着直接运行即可:
我们可以将工作目录修改到其他地方,接着我们创建一个Java文件并完成编译,然后测试能否使用我们编译的JDK运行:

image-20230306164535518
在此目录下编写一个Java程序,然后编译:
点击运行,成功得到结果:
我们还可以在CLion前端页面中进行断点调试,比如我们测试一个入口点JavaMain,在
jdk/src/share/bin/java.c
中的JavaMain方法:
image-20230306164549328
点击右上角调试按钮,可以成功进行调试:

image-20230306164602205
至此,在Ubuntu系统上手动编译OpenJDK8完成。
JVM启动流程探究
前面我们完成了JDK8的编译,也了解了如何进行断点调试,现在我们就可以来研究一下JVM的启动流程了,首先我们要明确,虚拟机的启动入口位于
jdk/src/share/bin/java.c
的JLI_Launch
函数,整个流程分为如下几个步骤:- 配置JVM装载环境
- 解析虚拟机参数
- 设置线程栈大小
- 执行JavaMain方法
首先我们来看看
JLI_Launch
函数是如何定义的:可以看到在入口点的参数有很多个,其中包括当前的完整版本名称、简短版本名称、运行参数、程序名称、启动器名称等。
首先会进行一些初始化操作以及Debug信息打印配置等:
接着就是选择一个合适的JRE版本:
接着是创建JVM执行环境,例如需要确定数据模型,是32位还是64位,以及jvm本身的一些配置在jvm.cfg文件中读取和解析:
此函数只在头文件中定义,具体的实现是根据不同平台而定的。接着会动态加载jvm.so这个共享库,并把jvm.so中的相关函数导出并且初始化,而启动JVM的函数也在其中:
比如mac平台下的实现:
最后就是对JVM进行初始化了:
这也是由平台决定的,比如Mac下的实现为:
可以看到最后进入了一个
ContinueInNewThread
函数(在刚刚的java.c
中实现),这个函数会创建一个新的线程来执行:接着进入了一个名为
ContinueInNewThread0
的函数,可以看到它将JavaMain
函数传入作为参数,而此函数定义的第一个参数类型是一个函数指针:最后实际上是在新的线程中执行
JavaMain
函数,最后我们再来看看此函数里面做了什么事情:第一步初始化虚拟机,如果报错直接退出。接着就是加载主类(至于具体如何加载一个类,我们会放在后面进行讲解),因为主类是我们Java程序的入口点:
某些没有主方法的Java程序比如JavaFX应用,会获取ApplicationMainClass:
初始化完成:
接着就是获取主类中的主方法:
没错,在字节码中
void main(String[] args)
表示为([Ljava/lang/String;)V
我们之后会详细介绍。接着就是调用主方法了:调用后,我们的Java程序就开飞速运行起来,直到走到主方法的最后一行返回:
至此,一个Java程序的运行流程结束,在最后LEAVE函数中会销毁JVM。我们可以进行断点调试来查看是否和我们推出的结论一致:

image-20230306164622940
还是以我们之前编写的测试类进行,首先来到调用之前,我们看到主方法执行之前,控制台没有输出任何内容,接着我们执行此函数,再来观察控制台的变化:

image-20230306164639620
可以看到,主方法执行完成之后,控制台也成功输出了Hello World!
继续下一步,整个Java程序执行完成,得到退出状态码
0
:
image-20230306164706976
成功验证,最后总结一下整个执行过程:

image-20230306164716949
JNI调用本地方法
Java还有一个JNI机制,它的全称:Java Native Interface,即Java本地接口。它允许在Java虚拟机内运行的Java代码与其他编程语言(如C/C++和汇编语言)编写的程序和库进行交互(在Android开发中用得比较多)比如我们现在想要让C语言程序帮助我们的Java程序实现a+b的运算,首先我们需要创建一个本地方法:
创建好后,接着点击构建按钮,会出现一个out文件夹,也就是生成的class文件在其中,接着我们直接生成对应的C头文件:
生成的头文件位于jni文件夹下:
接着我们在CLion中新建一个C++项目,并引入刚刚生成的头文件,并导入jni相关头文件(在JDK文件夹中)首先修改CMake文件:
接着就可以编写实现了,首先认识一下引用类型对照表:

image-20230306164733817
所以我们这里直接返回a+b即可:
接着我们就可以将cpp编译为动态链接库,在MacOS下会生成
.dylib
文件,Windows下会生成.dll
文件,我们这里就只以MacOS为例,命令有点长,因为还需要包含JDK目录下的头文件:编译完成后,得到
test.dylib
文件,这就是动态链接库了。最后我们再将其放到桌面,然后在Java程序中加载:
运行,成功得到结果:

image-20230306164747347
通过了解JVM的一些基础知识,我们心目中大致有了一个JVM的模型,在下一章,我们将继续深入学习JVM的内存管理机制和垃圾收集器机制,以及一些实用工具。
————————————————
版权声明:本文为柏码知识库版权所有,禁止一切未经授权的转载、发布、出售等行为,违者将被追究法律责任。
原文链接:https://www.itbaima.cn/document/g96k66kczovvbm1i
Loading...