# 前言
这个分类还是来补我学习 Java 欠缺的东西,记录一下 java 虚拟机的学习过程,本篇中所有代码都在 Clown_java/src/main/java/JavaSE at master・clown-q/Clown_java (github.com)
# 初识 JVM
java 有着 “一次编译,到处运行的” 口号,它能做到这样灵活的原因就是因为有 JVM 的作用。(JVM 并不是跨平台的)我们的程序编译后由 JVM 来运行。
这里先看这样一个例子
package JavaJVM; | |
/** | |
* @BelongsProject: study_java | |
* @BelongsPackage: JavaJVM | |
* @Author: Clown | |
* @CreateTime: 2023-08-25 22:46 | |
*/ | |
public class TestJVM { | |
public int test(){ | |
int a = 10; | |
int b = 20; | |
int c = a+b; | |
return c; | |
} | |
} |
原文件,这里先将其编译一下
接着这里使用 javap 命令进行一个反编译,下面是反编译得到的东西(这里我简单的加了一些注释)
PS E:\study_java\target\classes\JavaJVM> javap -v .\TestJVM.class | |
Classfile /E:/study_java/target/classes/JavaJVM/TestJVM.class | |
Last modified 2023年8月25日; size 401 bytes | |
SHA-256 checksum b57cfb338e199fa781ebdfb1dee1304c4f91bb03d49908e9d440a37b31bc0976 | |
Compiled from "TestJVM.java" | |
public class JavaJVM.TestJVM | |
minor version: 0 | |
major version: 52 | |
flags: (0x0021) ACC_PUBLIC, ACC_SUPER | |
this_class: #2 // JavaJVM/TestJVM | |
super_class: #3 // java/lang/Object | |
interfaces: 0, fields: 0, methods: 2, attributes: 1 | |
Constant pool: | |
#1 = Methodref #3.#19 // java/lang/Object."<init>":()V | |
#2 = Class #20 // JavaJVM/TestJVM | |
#3 = Class #21 // java/lang/Object | |
#4 = Utf8 <init> | |
#5 = Utf8 ()V | |
#6 = Utf8 Code | |
#7 = Utf8 LineNumberTable | |
#8 = Utf8 LocalVariableTable | |
#9 = Utf8 this | |
#10 = Utf8 LJavaJVM/TestJVM; | |
#11 = Utf8 test | |
#12 = Utf8 ()I | |
#13 = Utf8 a | |
#14 = Utf8 I | |
#15 = Utf8 b | |
#16 = Utf8 c | |
#17 = Utf8 SourceFile | |
#18 = Utf8 TestJVM.java | |
#19 = NameAndType #4:#5 // "<init>":()V | |
#20 = Utf8 JavaJVM/TestJVM | |
#21 = Utf8 java/lang/Object | |
{ | |
public JavaJVM.TestJVM(); | |
descriptor: ()V | |
flags: (0x0001) ACC_PUBLIC | |
Code: | |
stack=1, locals=1, args_size=1 | |
0: aload_0 | |
1: invokespecial #1 // Method java/lang/Object."<init>":()V | |
4: return | |
LineNumberTable: | |
line 9: 0 | |
LocalVariableTable: | |
Start Length Slot Name Signature | |
0 5 0 this LJavaJVM/TestJVM; | |
public int test(); | |
descriptor: ()I | |
flags: (0x0001) ACC_PUBLIC | |
Code: | |
stack=2, locals=4, args_size=1 | |
0: bipush 10 //将单字节压入栈顶,0是程序偏移地址,bipush是指令,10是操作数 | |
2: istore_1 //将栈顶的int类型的数据存入到第二个本地变量 | |
3: bipush 20 | |
5: istore_2 //将栈顶的int类型的数据存入到第三个本地变量 | |
6: iload_1 //将第二个本地变量推向栈顶 | |
7: iload_2 | |
8: iadd //将栈顶的两个int类型变量想家,并将结果压入栈顶 | |
9: istore_3 | |
10: iload_3 | |
11: ireturn //返回方法 | |
LineNumberTable: | |
line 11: 0 | |
line 12: 3 | |
line 13: 6 | |
line 14: 10 | |
LocalVariableTable: | |
Start Length Slot Name Signature | |
0 12 0 this LJavaJVM/TestJVM; | |
3 9 1 a I | |
6 6 2 b I | |
10 2 3 c I | |
} | |
SourceFile: "TestJVM.java" |
可以看到这里其实也是得到了类似 C 语言的那种汇编代码的东西。这里看最后一部分,public int test ();,这里就是刚刚编译的代码中的 test 方法,
descriptor: ()I |
表示没有参数,I 表示返回的数据类型是 int
flags: (0x0001) ACC_PUBLIC |
标识着其权限为 public
stack=2, locals=4, args_size=1 |
stack 表示栈的长度,locals 表示本地变量数,args_size 表示堆栈上的最大对象数量,到这里都是一些标识信息。接下来的部分是有运算的指令,实际上上面的注释中已经给出了一部分
0: bipush 10 |
通过 bipush 指令将常量 10 放到栈顶(等待处理)
2: istore_1 |
将栈顶的数据存入到本地变量,后面的 20 也是同样的操作
6: iload_1 | |
7: iload_2 |
通过利用 iload 指令,将本地变量的值压入栈顶
8: iadd | |
9: istore_3 |
将栈顶的两个数据相加后存入到第四个本地变量中
10: iload_3 | |
11: ireturn |
将第四个本地变量的值压入栈顶,然后通过 ireturn 来返回结果
到这里这个简单的加法的流程就结束了,简单总结,上面这些都是交由 JVM 去执行的命令。实际上在 JVM 中所有的数据类型都围绕着堆栈或者队列这两种数据结构,所以在某些数据需要进行处理的时候就需要将其压入堆栈中,JVM 会将栈顶的数据视为操作数。当数据需要暂存时就会存入到局部变量队列中,也因为他是操作栈的比较简单,所以指令多数没有操作数,但是弊端是性能并没有传统的汇编好。
# 编译环境配置
因为 JVM 的学习多数是要与底层的代码逻辑打交道,调试是必不可少的过程,所以这里就搭建一个测试环境
# 环境介绍
- 操作系统:Ubuntu 23.04 Server
- 环境搭建平台:VMware Workstation
- 调试工具:Jetbrains Gateway
- OpenJDK 源码:https://codeload.github.com/openjdk/jdk/zip/refs/tags/jdk8-b120
- 编译环境:
- gcc-4.8
- g++-4.8
- openjdk-8
# 报错漫天
总之很离谱,看这教程还踩了很多坑,重新配置了好几次,下面的部分简单的记录一下我配置的过程
因为我之前也没有用过这个东西,虚拟机也是新装的,所以这里先更新一下 apt,然后安装一下基本的依赖
sudo apt update | |
sudo apt install build-essential libxrender-dev xorg-dev libasound2-dev libcups2-dev gawk zip libxtst-dev libxi-dev libxt-dev gobjc |
然后去换一下源
sudo vim /etc/apt/sources.list | |
#添加 | |
deb http://archive.ubuntu.com/ubuntu xenial main | |
deb http://archive.ubuntu.com/ubuntu xenial universe |
把这两个加上
当然换源之后要更新一下 apt
我这里就出现了报错,GPG 是一个加密与解密的工具,报错的原因是没有秘钥无法进行验证,这里就根据报错添加一个 key
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32 |
这里 key 后面的值是上面报错信息中 NO_PUBKEY 后面的密钥串,这样就可以了
警告没啥影响,所以我也就没管
虚拟机中自带的 make 版本是 4.1,这里降级为 3.81,先通过 wget 从官网下载到虚拟机上
wget https://ftp.gnu.org/gnu/make/make-3.81.tar.gz |
解压后进入到目录
tar -zxvf make-3.81.tar.gz | |
cd make-3.81/ |
然后修改一下 glob/glob.c
文件
加上下面这两句
# define __alloca alloca | |
# define __stat stat |
然后直接编译安装就可以了
./configure | |
sudo make install |
这里奇奇怪怪的错误还挺多的,但是网上也有相关的修复,上面两句加上应该就没有问题了
接下来就是安装 gcc 和 g++(这里换源主要是新版本已经没有老版本的 gcc 了)
sudo apt install gcc-4.8 g++-4.8 |
但是现在的 gcc 和 g++ 版本不是我们所需要的 4.8
这里还需要配置一下优先级
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.8 100 | |
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.8 100 |
这样设置完成之后再查看版本就是我们所需要的 4.8 版本,接下来下载 openjdkhttps://codeload.github.com/openjdk/jdk/zip/refs/tags/jdk8-b120
这里我已经下载好了,然后通过 unzip 来解压,然后编译一下
bash configure --with-debug-level=slowdebug --enable-debug-symbols ZIP_DEBUGINFO_FIELS=0 |
这里显示的是一个配置信息,然后还需要修改几个文件来杜绝报错, hotspot/make/linux/makefiles/gcc.make
文件
原有的 WARNINGS_ARE_ERRORS = -Werror | |
修改为 #WARNINGS_ARE_ERRORS = -Werror |
然后是 hotspot/make/linux/Makefile
文件(这里是为了对应虚拟机的内核版本)
原有的 SUPPORTED_OS_VERSION = 2.4% 2.5% 2.6% 3% | |
修改为 SUPPORTED_OS_VERSION = 2.4% 2.5% 2.6% 3% 4% 5% 6% |
最后是 nashorn/make/BuildNashorn.gmk
文件
$(CP) -R -p $(NASHORN_OUTPUTDIR)/nashorn_classes/* $(@D)/ | |
$(FIXPATH) $(JAVA) \ | |
原有的 -cp "$(NASHORN_OUTPUTDIR)/nasgen_classes$(PATH_SEP)$(NASHORN_OUTPUTDIR)/nashorn_classes" \ | |
修改为 -Xbootclasspath/p:"$(NASHORN_OUTPUTDIR)/nasgen_classes$(PATH_SEP)$(NASHORN_OUTPUTDIR)/nashorn_classes" \ | |
jdk.nashorn.internal.tools.nasgen.Main $(@D) jdk.nashorn.internal.objects $(@D) |
然后执行 make all 即可,ok 又开始各种报错
这个其实是参数的问题
在这里加一个大写的 i,然后就是新的报错
/home/clown/jdk-jdk8-b120/jdk/src/solaris/native/java/net/PlainDatagramSocketImpl.c:38:24: fatal error: sys/sysctl.h: No such file or directory | |
#include <sys/sysctl.h> | |
^ | |
compilation terminated. | |
/home/clown/jdk-jdk8-b120/jdk/src/solaris/native/java/net/PlainSocketImpl.c:46:24: fatal error: sys/sysctl.h: No such file or directory | |
#include <sys/sysctl.h> | |
^ |
报错的主要原因是随着 glibc 2.32 的发布,Linux 系统删除了 sys/sysctl.h,将这两行注释掉即可
PlainDatagramSocketImpl.c 这个文件下
PlainSocketImpl.c 文件下
报错修改后建议执行一下
make clean | |
然后再重新运行configure | |
bash configure --with-debug-level=slowdebug --enable-debug-symbols ZIP_DEBUGINFO_FIELS=0 |
看到下图的这个样子就算是大功告成了
配置环境比较劝退,但是配置完了回头看还是比较简单的,就是坑比较多
接下来就简单了,通过 Jetbrains Gateway 来连接
连接上后这里选择 Clion,目录直接放在刚刚解压的 opnejdk 目录下
下载好后会打开这样一个页面就算是成功了
接下来创建一个测试配置
在这里进行一个设置,新建一个 tool
这里新加一个 Build
然后新加一个 Clean
加好后是下图中的样子
然后添加配置
这里添加一个 java 的 version 命令
直接运行测试
可以看到这里调用的就是上面配置的 java,接下来写一个 hello world 来编译一下
public class Main{ | |
public static void main(String[] args){ | |
System.out.println("Hello World!"); | |
} | |
} |
这里拿到一个 Main.class 文件,新建一个测试
然后这里直接运行
接下在入口点下一个断点,测试调试功能,入口点在 jdk/src/share/bin/java.c 的 JavaMain 方法
点击调试
这里可以看见调试也是正常的
# JVM 的工作流程
这里先放一张流程图
经过前面坐牢般的环境搭建,总算是可以调试了,接下来呢就来研究一下 JVM 的流程,根据他源码的注解部分我们可以知道,JVM 启动的入口点是在 JLI_Launch 函数中
还是跟着调试走一下过程,接着往下看
InitLauncher(javaw); //java 启动器初始化 | |
DumpState(); // 输出程序状态信息 | |
if (JLI_IsTraceLauncher()) { // 检查是否启用了启动器跟踪设置 | |
int i; | |
printf("Command line args:\n"); | |
for (i = 0; i < argc ; i++) { | |
printf("argv[%d] = %s\n", i, argv[i]); | |
} | |
AddOption("-Dsun.java.launcher.diag=true", NULL); // 添加一个启动选项,用于启用 JVM 启动时的诊断信息 | |
} | |
··· | |
··· | |
SelectVersion(argc, argv, &main_class); // 选择 JRE 版本 |
这段代码主要是进行一个初始化和一些必要的配置和调试作用,然后就是选择 JRE 版本
CreateExecutionEnvironment(&argc, &argv, | |
jrepath, sizeof(jrepath), | |
jvmpath, sizeof(jvmpath), | |
jvmcfg, sizeof(jvmcfg)); |
这个函数根据函数名就可以知道是用于创建执行环境的函数,通过解析 jvm.cfg 这个文件来配置
这个函数是在头文件中定义的,不同的平台根据需求去完成实现
接着尝试加载 JVM
if (!LoadJavaVM(jvmpath, &ifn)) { | |
return(6); | |
} |
这里也是一个定义在头文件中的函数
在我的环境中的实现
LoadJavaVM(const char *jvmpath, InvocationFunctions *ifn) | |
{ | |
void *libjvm; | |
JLI_TraceLauncher("JVM path is %s\n", jvmpath); | |
libjvm = dlopen(jvmpath, RTLD_NOW + RTLD_GLOBAL); | |
if (libjvm == NULL) { | |
#if defined(__solaris__) && defined(__sparc) && !defined(_LP64) /* i.e. 32-bit sparc */ | |
FILE * fp; | |
Elf32_Ehdr elf_head; | |
int count; | |
int location; | |
fp = fopen(jvmpath, "r"); | |
if (fp == NULL) { | |
JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); | |
return JNI_FALSE; | |
} | |
/* read in elf header */ | |
count = fread((void*)(&elf_head), sizeof(Elf32_Ehdr), 1, fp); | |
fclose(fp); | |
if (count < 1) { | |
JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); | |
return JNI_FALSE; | |
} | |
/* | |
* Check for running a server vm (compiled with -xarch=v8plus) | |
* on a stock v8 processor. In this case, the machine type in | |
* the elf header would not be included the architecture list | |
* provided by the isalist command, which is turn is gotten from | |
* sysinfo. This case cannot occur on 64-bit hardware and thus | |
* does not have to be checked for in binaries with an LP64 data | |
* model. | |
*/ | |
if (elf_head.e_machine == EM_SPARC32PLUS) { | |
char buf[257]; /* recommended buffer size from sysinfo man | |
page */ | |
long length; | |
char* location; | |
length = sysinfo(SI_ISALIST, buf, 257); | |
if (length > 0) { | |
location = JLI_StrStr(buf, "sparcv8plus "); | |
if (location == NULL) { | |
JLI_ReportErrorMessage(JVM_ERROR3); | |
return JNI_FALSE; | |
} | |
} | |
} | |
#endif | |
JLI_ReportErrorMessage(DLL_ERROR1, __LINE__); | |
JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); | |
return JNI_FALSE; | |
} | |
ifn->CreateJavaVM = (CreateJavaVM_t) | |
dlsym(libjvm, "JNI_CreateJavaVM"); | |
if (ifn->CreateJavaVM == NULL) { | |
JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); | |
return JNI_FALSE; | |
} | |
ifn->GetDefaultJavaVMInitArgs = (GetDefaultJavaVMInitArgs_t) | |
dlsym(libjvm, "JNI_GetDefaultJavaVMInitArgs"); | |
if (ifn->GetDefaultJavaVMInitArgs == NULL) { | |
JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); | |
return JNI_FALSE; | |
} | |
ifn->GetCreatedJavaVMs = (GetCreatedJavaVMs_t) | |
dlsym(libjvm, "JNI_GetCreatedJavaVMs"); | |
if (ifn->GetCreatedJavaVMs == NULL) { | |
JLI_ReportErrorMessage(DLL_ERROR2, jvmpath, dlerror()); | |
return JNI_FALSE; | |
} | |
return JNI_TRUE; | |
} |
函数会加载指定路径上的 Java 虚拟机库,并从库中获取需要的函数指针,接下来就初始化 JVM
return JVMInit(&ifn, threadStackSize, argc, argv, mode, what, ret); |
实现方法
int | |
JVMInit(InvocationFunctions* ifn, jlong threadStackSize, | |
int argc, char **argv, | |
int mode, char *what, int ret) | |
{ | |
ShowSplashScreen(); // 显示启动画面 | |
return ContinueInNewThread(ifn, threadStackSize, argc, argv, mode, what, ret);// 继续新线程 | |
} |
进到这里后,主要看 ContinueInNewThread 这个函数中创建了一个新的线程
int ContinueInNewThread(InvocationFunctions* ifn, jlong threadStackSize, | |
int argc, char **argv, | |
int mode, char *what, int ret) | |
{ | |
// 如果用户未指定线程堆栈大小,检查虚拟机是否有首选堆栈大小 | |
if (threadStackSize == 0) { | |
struct JDK1_1InitArgs args1_1; | |
memset((void*)&args1_1, 0, sizeof(args1_1)); | |
args1_1.version = JNI_VERSION_1_1; | |
ifn->GetDefaultJavaVMInitArgs(&args1_1); /* 忽略返回值 */ | |
if (args1_1.javaStackSize > 0) { | |
threadStackSize = args1_1.javaStackSize; | |
} | |
} | |
// 创建一个新线程来创建 JVM 并调用主方法 | |
{ | |
JavaMainArgs args; | |
int rslt; | |
args.argc = argc; | |
args.argv = argv; | |
args.mode = mode; | |
args.what = what; | |
args.ifn = *ifn; | |
rslt = ContinueInNewThread0(JavaMain, threadStackSize, (void*)&args); | |
// 如果调用者认为存在错误,我们直接返回该错误值,否则返回调用者的返回值 | |
return (ret != 0) ? ret : rslt; | |
} | |
} |
前面都是一些检查,主要看 ContinueInNewThread0 这个函数,可以看到这里的第一个参数的 JavaMain,也是一个根据平台不同的一个方法,实际上它会执行 JavaMain 这样一个函数,也就是上面搭建环境中说到的入口,这里是 JVM 加载后的第一个执行的函数。
所以这里实际上是执行了 JavaMain 函数,接下来看到 javaMain 函数这里
首先在这里初始化 JVM,如果出现报错会直接结束退出,然后中间经过一系列的校验后跟进到下面这个函数
这个函数是用于获取应用程序的主类,当然例如 JavaFX 应用这种没有主类的会跟进到 GetApplicationClass 函数
初始化完成后会去获取主类中的主方法
这里会先拿到主方法的一个 ID,然后通过这个 ID 来调用主方法
最后会在 LEAVE 函数中销毁 JVM
到这里 JVM 运行一个程序的过程就说完了