# 前言

这个分类还是来补我学习 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;
    }
}

原文件,这里先将其编译一下

image-20230825230320241

接着这里使用 javap 命令进行一个反编译,下面是反编译得到的东西(这里我简单的加了一些注释)

PS E:\study_java\target\classes\JavaJVM> javap -v .\TestJVM.class
Classfile /E:/study_java/target/classes/JavaJVM/TestJVM.class
  Last modified 2023825; 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

image-20230828174225244

然后去换一下源

sudo vim /etc/apt/sources.list
#添加
deb http://archive.ubuntu.com/ubuntu xenial main
deb http://archive.ubuntu.com/ubuntu xenial universe

把这两个加上

image-20230828174416980

当然换源之后要更新一下 apt

image-20230828175102303

我这里就出现了报错,GPG 是一个加密与解密的工具,报错的原因是没有秘钥无法进行验证,这里就根据报错添加一个 key

sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32

这里 key 后面的值是上面报错信息中 NO_PUBKEY 后面的密钥串,这样就可以了

image-20230828180927670

警告没啥影响,所以我也就没管

虚拟机中自带的 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 文件

image-20230829181257263

加上下面这两句

# define __alloca alloca
# define __stat stat

然后直接编译安装就可以了

./configure
sudo make install

这里奇奇怪怪的错误还挺多的,但是网上也有相关的修复,上面两句加上应该就没有问题了

image-20230829182055709

接下来就是安装 gcc 和 g++(这里换源主要是新版本已经没有老版本的 gcc 了)

sudo apt install gcc-4.8 g++-4.8

image-20230828193906092

但是现在的 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

image-20230828194214226

这样设置完成之后再查看版本就是我们所需要的 4.8 版本,接下来下载 openjdkhttps://codeload.github.com/openjdk/jdk/zip/refs/tags/jdk8-b120

image-20230828205943184

这里我已经下载好了,然后通过 unzip 来解压,然后编译一下

bash configure --with-debug-level=slowdebug --enable-debug-symbols ZIP_DEBUGINFO_FIELS=0

image-20230829115512921

这里显示的是一个配置信息,然后还需要修改几个文件来杜绝报错, hotspot/make/linux/makefiles/gcc.make 文件

原有的 WARNINGS_ARE_ERRORS = -Werror
修改为 #WARNINGS_ARE_ERRORS = -Werror

image-20230829120738888

然后是 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%

image-20230829124001467

最后是 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)

image-20230829123558599

然后执行 make all 即可,ok 又开始各种报错

image-20230829184752164

这个其实是参数的问题

image-20230829184823896

在这里加一个大写的 i,然后就是新的报错

image-20230829190519901

/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 这个文件下

image-20230829190833281

PlainSocketImpl.c 文件下

image-20230829190927065

报错修改后建议执行一下

make clean
然后再重新运行configure
bash configure --with-debug-level=slowdebug --enable-debug-symbols ZIP_DEBUGINFO_FIELS=0

看到下图的这个样子就算是大功告成了

image-20230829191245074

配置环境比较劝退,但是配置完了回头看还是比较简单的,就是坑比较多

接下来就简单了,通过 Jetbrains Gateway 来连接

image-20230829113914235

连接上后这里选择 Clion,目录直接放在刚刚解压的 opnejdk 目录下

image-20230829114138016

下载好后会打开这样一个页面就算是成功了

image-20230829191347264

接下来创建一个测试配置

image-20230829191943975

在这里进行一个设置,新建一个 tool

image-20230829192018670

这里新加一个 Build

image-20230829192306121

然后新加一个 Clean

image-20230829192432920

加好后是下图中的样子

image-20230829192503301

然后添加配置

image-20230829192547017

这里添加一个 java 的 version 命令

image-20230829192842831

直接运行测试

image-20230829192949091

可以看到这里调用的就是上面配置的 java,接下来写一个 hello world 来编译一下

public class Main{
        public static void main(String[] args){
                System.out.println("Hello World!");
        }       
}

image-20230829210307268

这里拿到一个 Main.class 文件,新建一个测试

image-20230829210659383

然后这里直接运行

image-20230829210744978

接下在入口点下一个断点,测试调试功能,入口点在 jdk/src/share/bin/java.c 的 JavaMain 方法

image-20230829211018996

点击调试

image-20230829211400857

这里可以看见调试也是正常的

# JVM 的工作流程

这里先放一张流程图

image-20230830211538817

经过前面坐牢般的环境搭建,总算是可以调试了,接下来呢就来研究一下 JVM 的流程,根据他源码的注解部分我们可以知道,JVM 启动的入口点是在 JLI_Launch 函数中

image-20230830131413009

还是跟着调试走一下过程,接着往下看

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 这个文件来配置

image-20230830142124707

这个函数是在头文件中定义的,不同的平台根据需求去完成实现

image-20230830144310033

接着尝试加载 JVM

if (!LoadJavaVM(jvmpath, &ifn)) {
        return(6);
    }

这里也是一个定义在头文件中的函数

image-20230830142552561

在我的环境中的实现

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 这个函数中创建了一个新的线程

image-20230830151110355

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 加载后的第一个执行的函数。

image-20230830171256783

所以这里实际上是执行了 JavaMain 函数,接下来看到 javaMain 函数这里

image-20230830192815485

首先在这里初始化 JVM,如果出现报错会直接结束退出,然后中间经过一系列的校验后跟进到下面这个函数

image-20230830195832828

这个函数是用于获取应用程序的主类,当然例如 JavaFX 应用这种没有主类的会跟进到 GetApplicationClass 函数

image-20230830200523303

初始化完成后会去获取主类中的主方法

image-20230830200754464

这里会先拿到主方法的一个 ID,然后通过这个 ID 来调用主方法

image-20230830205344437

最后会在 LEAVE 函数中销毁 JVM

image-20230830205545402

到这里 JVM 运行一个程序的过程就说完了