本节书摘来异步社区《HotSpot实战》一书中的第2章,第2.2节,作者:陈涛,更多章节内容可以访问云栖社区“异步社区”公众号查看。
2.2 启动
Launcher(启动器),是用来启动JVM和应用程序的工具。在这一节中,我们将看到HotSpot中提供了两种Launcher类型,分别是通用启动器和调试版启动器。
2.2.1 Launcher
通用启动器(Generic Launcher)是指我们比较熟悉的JDK命令程序:java(含javaw)。java是由JDK自带的启动Java应用程序的工具。为启动一个Java应用程序,java将准备一个Java运行时环境(即JRE)、加载指定的类并调用它的main方法。类加载的前提条件是由JRE在指定路径下找到类加载器和应用程序类。一般来说,JRE将在以下3种路径下搜索类加载器和其他类:
- 引导类路径(bootstrap class path);
- 已安装的扩展(installed extensions);
- 用户类路径(user class path)。
类被加载进来之后,java会将全限定类名或JAR文件名之后的非选项类参数作为参数传递给main方法。
javaw命令等同于java,只是javaw没有控制台窗口。当你不想显示一个命令提示符窗口时,可以使用javaw。但是如果由于某些原因启动失败,javaw仍将显示一个对话框提供错误信息。
1.基本用法
java和javaw的命令格式如下所示:
java [ option ] class [ argument ... ]
java [ option ] -jar file.jar [ argument ... ]
javaw [ option ] class [ argument ... ]
javaw [ option ] -jar file.jar [ argument ... ]
其中class是要调用的类名,而file.jar是要调用的JAR文件名。
值得注意的是,我们需要区分选项和参数的不同用途。
选项(option)是传递给VM的参数。目前,有两类VM选项,包括标准VM选项和非标准VM选项。其中,非标准选择在使用时以“-X”或“-XX”指定。
参数(argument)是传递给main方法的参数。
注意 对于启动器,有一套标准选项(standard options),在当前和将来的版本中都将支持。此外, HotSpot虚拟机默认提供一套非标选项(non-standard options),这些非标选项有可能在将来版本中更改。另外,32位JDK和64位JDK命令选项也会有所不同。
2.标准VM选项
标准VM选项主要包括以下几项。
- -client、-server:指定HotSpot以client或server模式运行虚拟机。对于64位JDK,将忽略此选项,默认以server模式运行虚拟机。
- -agentlib:libname[=options]:按照库名libname载入本地代理库(agent library)。如-agentlib:hprof、-agentlib:jdwp=help、-agentlib:hprof=help。
- -agentpath:pathname[=options]:按照完整路径名pathname载入本地代理库。
- -classpath、-cp:指定类文件搜索路径。
- -Dproperty=value:设置系统属性值
- -jar:执行封装在jar文件中的应用程序。
- -javaagent:jarpath[=options]:加载Java编程语言代理库,可参阅java.lang.instrument。
- -verbose、-verbose:class:显示每个被加载的类信息。
- -verbose:gc:报告每个垃圾回收事件。
- -verbose:jni:报告关于调用本地方法和其他本地接口的信息。
- -X:显示非标准选项信息,然后退出。
3.非标准VM选项
以“-X”指定的非标准VM选项主要包括以下几项1。
- -Xint:以解释模式运行虚拟机。禁用编译本机代码,并由解释器(interpreter)执行所有字节码。
- -Xbatch:禁用后台编译。一般来说,虚拟机将编译方法作为后台任务,虚拟机在解释器模式下运行某方法时,需要等到后台编译完成该方法的编译任务。该参数将禁用后台编译,使方法的编译作为前台任务直到完成为止。
- -Xbootclasspath:指定引导类和资源文件的搜索路径。
- -Xcheck: jni:对于Java 本地接口JNI函数执行额外的检查。JVM验证传递给 JNI 函数的参数。在本机代码中遇到任何无效的数据将导致JVM终止。使用此选项时,会带来一些性能损失。
- -Xfuture:执行严格的类文件格式检查。
- -Xnoclassgc:禁用类垃圾回收。
- -Xincgc:启用增量垃圾回收器。
- -Xloggc::报告垃圾回收事件,并记录到指定的文件中。
- -Xms:设置Java堆的初始化大小。
- -Xmxn:设置Java堆的最大值。
- -Xssn:设置Java线程的栈大小。
- -Xprof:输出CPU性能数据。
4.隐藏的非标VM选项
这一类选项以“-XX”指定。该类VM选项数量十分可观,可以说有成百上千个也不为过。本书将在各章节中,附上一些相关的虚拟机选项和功能描述,以供参考。
5.gamma:调试版启动器
HotSpot提供了一个精简调试Launcher,称为gamma。相对于通用Launcher,gamma就安装在与JVM库相同的目录下,或者与JVM库静态链接为一个库文件,因此可以把gamma看作是精简了虚拟机选项解析等逻辑的java命令。
事实上,为便于维护,OpenJDK就是基于同一套Launcher代码维护了gamma launcher和通用launcher的,对于差异代码则使用#ifndef GAMMA进行注释区分。gamma启动器入口位于hotspot/src/share/tools/luncher/java.c;通用Launcher的入口并不在hotspot工程下,感兴趣的读者可以在与hotspot同级目录jdk下找到hotspot/../jdk/src/share/bin/main.c。
从本节开始,我们将以Launcher作为切入点,对HotSpot进行实战调试和分析。为方便调试,我们将在Linux平台上基于gamma启动器来讲解HotSpot启动过程。
2.2.2 虚拟机生命周期
图2-6描述了一个完整的虚拟机生命周期,具体过程如下。
(1)Launcher启动后,首先进入Launcher的入口,即main函数。正像稍后看到的那样,main工作的重点是:创建一个运行环境,为接下来启动一个新的线程创建JVM并跳到Java主方法做好一切准备工作。
(2)环境就绪后,Launcher启动JavaMain线程,将程序参数传递给它。如清单2-21所示,Launcher调用ContinueInNewThread()函数启动新的线程并继续执行任务。新的线程将要执行的任务由该函数的第一个参数指定,即JavaMain()函数。这时,新线程将要阻塞当前线程,并在新线程中开启一段相对独立的历程,去完成Launcher赋予它的使命。
清单2-21
来源:hotspot/src/share/tools/luncher/java.c
描述:Launcher启动JavaMain线程
return ContinueInNewThread(JavaMain, threadStackSize, (void*)&args);
(3)一般来说,JavaMain线程将伴随应用程序的整个生命周期。首先,它要做的便是在Launcher模块内调用InitializeJVM()函数,初始化JVM。值得一提的是,在理解虚拟机生命周期复杂的模块调用过程时,我们不能对Launcher模块本身抱有过高的期待。毕竟,Launcher模块本身无力实现这些核心功能,它必须借助其他专门模块来提供相应功能。因此,在阅读源代码时,我们应当培养这样的意识,在遇到某个核心功能或重要组件时,首先问自己几个问题:核心功能是由哪个模块提供的?它最终是为系统哪个组件提供服务的?它是以什么形式向调用者提供服务的?养成这种意识,对于独立分析和思考系统运作具有重要的意义。
Launcher模块本身并不具有创建虚拟机的能力。下面我们将看到,有哪些模块参与了这个过程。由于Launcher模块需要借助自身以外的力量完成任务,理所当然地,它需要拥有访问外部接口的能力。稍后将提到一些数据结构,它们持有外部接口的函数指针,Launcher通过它们可以达到调用外部接口的目的。
(4)虚拟机在Prims模块中定义了一些以“JNI_”为前缀而命名的函数,并向外部提供这些jni接口。JNI_CreateJavaVM()函数就是其中一个,它为外部程序提供创建JVM的服务。前面提到的创建JVM的任务,实际上就是调用了JNI_CreateJavaVM()函数。JNI模块是连接虚拟机内部与外部程序的桥梁,JVM系统内部的命名空间对JNI模块都是可见的,因此它可以调用内部模块并通过接口向外提供查看和操纵JVM的能力。JNI_CreateJavaVM()函数调用Threads模块create_vm()函数完成最终的虚拟机的创建和初始化工作。
(5)可以说,create_vm()函数是JVM启动过程的精华部分,它初始化了JVM系统中绝大多数的模块。
(6)调用add()函数,将线程加入线程队列。
(7)调用create()函数,创建虚拟机线程“VMThread”;
(8)调用vm_init_globals()函数,初始化全局数据结构;
(9)调用init_globals()函数,初始化全局模块;
(10)调用LoadClass()函数,加载应用程序主类;
(11)调用jni_CallStaticVoidMethod()函数,实现对Java应用程序的主方法的调用;
(12)调用jni_DetachCurrentThread()函数;
(13)调用jni_DestroyJavaVM()函数,销毁JVM后退出。
接下来,我们将选取一些重要过程展开详解。
2.2.3 入口:main函数
与其他应用程序一样,Launcher的入口是一个main函数。在不同操作系统中,main函数的原型看起来会有些差异。例如在UNIX或Linux系统中,按照POSIX规范的函数原型如清单2-22所示。
清单2-22
来源:hotspot/src/share/tools/luncher/java.c
描述:launcher入口
int
main(int argc, char ** argv)
而在Windows平台上,其原型如清单2-23所示。
清单2-23
来源:jdk/src/share/bin/main.c
描述:launcher入口
int WINAPI
WinMain(HINSTANCE inst, HINSTANCE previnst, LPSTR cmdline, int cmdshow)
main函数的程序流程如图2-7所示。
在main函数执行的最后一步,程序将启动一个新的线程并将Java程序参数传递给它,接下来阻塞自己,并在新线程中继续执行。新线程也可称为为主线程,它的执行入口是JavaMain。
2.2.4 主线程
一般来说,主线程将伴随应用程序的整个生命周期。打个形象的比喻:JavaMain好比一个外壳,应用程序便是在这个外壳的包裹下完成执行的。它的函数原型如清单2-24(a)所示。
清单2-24(a)
来源:hotspot/src/share/tools/luncher/java.c
描述:启动新线程执行该方法
int JNICALL
JavaMain(void * _args)```
在介绍JavaMain的主要流程前,我们先了解几个重要的基础数据结构,它们在调用主方法、断开主线程和销毁JVM的过程中发挥重要作用的数据结构。它们分别是JavaVM、JNIEnv和InvocationFunctions。
JavaVM类型是一个结构体,它拥有一组少而精的函数指针2。顾名思义,这几个函数为JVM提供了诸如连接线程、断开线程和销毁虚拟机等重要功能。在JavaMain的流程中,我们也可以看到这些功能的执行。HotSpot定义了大量的运行时接口,上述功能实际上是由这些接口提供的。如图2-8所示,在JavaMain运行时由InitializeJVM模块将JavaVM的这些成员赋上正确的值,指向相应的JNI接口函数上。
同JavaVM类型类似,JNIEnv也是拥有一组函数指针的结构体。不过,相对于JavaVM来说,它是一个重量级类型,JNIEnv容纳了大量的函数指针成员。同样地,在JavaMain运行时由InitializeJVM模块将JNIEnv的这些成员赋上正确的值,指向相应的JNI接口函数上。
InvocationFunctions中定义了2个函数指针,CreateJavaVM和GetDefaultJavaVMInitArgs,如图2-9所示,这2个函数在加载libjvm时中已经指派好了。
<div style="text-align: center"><img src="https://yqfile.alicdn.com/96605d8896aee78c16258ead7b0fad7f037b192f.png" width="" height="">
</div>
在JavaMain中,拥有3个局部变量:vm、env和ifn,分别对应着上述JavaVM、JNIEnv和InvocationFunctions这三种类型。JavaMain的主要流程如图2-10所示。
(1)初始化虚拟机:调用InitializeJVM模块,将JavaVM和JNIEnv类型的成员指向正确的jni函数上。
(2)获取应用程序主类(main class),如清单2-24(b)所示。
清单2-24(b)
jclass mainClass = LoadClass(env, classname);
(3)获取应用程序主方法(main method),如清单2-24(c)所示。
清单2-24(c)
jmethodID mainID = (*env)->GetStaticMethodID(env, mainClass, "main",
"([Ljava/lang/String;]V"];
(4)传递应用程序参数并执行主方法,如清单2-24(d)所示。
清单2-24(d)
(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
<div style="text-align: center"><img src="https://yqfile.alicdn.com/f36bc6fc40177c9cfcdea3fa5183949542faf4ba.png" width="" height="">
</div>
(5)与主线程断开连接,如清单2-24(e)所示。
清单2-24(e)
(*vm)->DetachCurrentThread(vm);
(6)主方法执行完毕,等待非守护线程结束,然后创建一个名为“DestroyJavaVM”的Java线程执行销毁JVM任务,如清单2-24(f)所示。
清单2-24(f)
(*vm)->DestroyJavaVM(vm);
练习3
阅读源代码,认真分析JavaMain函数,体会它在JVM中的作用和地位。如果通过前面的学习,你已经掌握了调试的基本方法,请仔细调试这部分程序。
####2.2.5 InitializeJVM函数
在第1章中,我们知道,在编译HotSpot项目后,启动脚本hotspot中会默认设置一个断点,即InitializeJVM。启动GDB调试HotSpot,JVM开始运行HelloWorld程序,但是程序并不急于打印“Hello hotspot!”,而是先停在了断点“Breakpoint 1”上(如图1-6所示),即InitializeJVM函数。
现在,我们想将断点往前挪一点,以便于我们详细了解JavaMain的运行细节。利用GDB,我们将断点设置在JavaMain函数的入口处,GDB界面中输入如下命令:
(gdb)break java.c:JavaMain
或者直接使用代码行数:
(gdb)break java.c:396`
断点设置完毕后,我们再次启动调试,如图2-11所示。
HotSpot运行至第396行,停了下来。实际上,我们在这里共设置了2个断点(JavaMain和InitializeJVM)。输入continue命令让程序继续运行至第1270行,即InitializeJVM。
InitializeJVM的原型如清单2-25所示。
清单2-25
来源:hotspot/src/share/tools/launcher/java.c & jdk/src/share/bin/java.c
描述:InitializeJVM
static jboolean
InitializeJVM(JavaVM **pvm, JNIEnv **penv, InvocationFunctions *ifn)
数字1270的含义是下一条将要运行的语句行数,如图2-12所示,这行代码是一条memset语句,用来对main()函数的参数args进行初始化填零。
利用GDB调试工具,我们还可以深入到InitializeJVM内部,看看InitializeJVM是如何初始化JVM的。由前文可以,InitializeJVM的任务之一就是需要完成对vm和env指派接口函数的重任。在调用InitializeJVM返回后,通过GDB查看命令,我们可以看到vm的函数指针成员得到了赋值,如图2-13所示。
此外,InitializeJVM中还会打印一些额外信息,如图2-12所示,可以看到InitializeJVM打印了一些与JVM的版本和选项相关的信息。
通过这些调试过程,相信读者对使用GDB调试HotSpot又有了新的认识。可是,到现在为止,我们仍然没有接触到与JVM的创建或初始化相关的实质内容,只是知道在调用CreateJavaVM之后,得到了大量的JNI函数。显然,这一过程向我们屏蔽了很多细节。但是换句话说,现在我们距离JVM初始化的核心内容仅一步之遥了。
在继续深入了解JVM的创建和初始化过程之前,我们希望你能够做些小的练习,以便巩固刚才学过的知识,同时为接下来的深入学习打下良好的实践基础。
练习4
将断点设置在JavaMain跟踪调试,在InitializeJVM返回之后,确认env成员已指派到了正确的jni接口函数上。
练习5
试一试在你安装的正式版JDK中,找到下面这些函数符号:
JNI_CreateJavaVM
JNI_GetDefaultJavaVMInitArgs
提示 在Windows上,可以使用DLL export Viewer等dll查看工具列出其中包含的符号;在UNIX上,可以通过nm等工具查看。
2.2.6 JNI_CreateJavaVM函数
创建JVM的程序模块是JNI_CreateJavaVM。JNI_CreateJavaVM主要任务是调用Threads模块的create_vm()函数,以完成最终的虚拟机创建和初始化工作。
在Threads模块中,实现了对虚拟机各个模块的初始化,以及创建虚拟机线程。这些被初始化的模块,在本书后续章节中均有大量涉及,因此理解这一过程对于其余章节的理解十分重要。为了保证知识的连贯性,避免打断对启动过程的叙述,我们将具体的初始过程安排在2.3小节中继续探讨。
注意 vm和env是在JNI_CreateJavaVM接口中实现赋值的。
此外,JNI_CreateJavaVM还将为vm和env分配JNI接口函数。
练习6:
设置断点并调试HotSpot,跟踪vm和env的赋值。
2.2.7 调用Java主方法
在JavaMain中,虚拟机得到初始化之后,接下来就将执行应用程序的主方法。通过env引用jni_CallStaticVoidMethod函数(原型如清单2-26所示),可以执行一个由“static”和“void”修饰的方法,即Java应用程序主类的main方法。
清单2-26
来源:hotspot/src/share/vm/prims/jni.h
描述:JNI函数:调用静态void方法
void
CallStaticVoidMethod(jclass cls, jmethodID methodID, ...)
读到这里,细心的读者可能会想弄明白:由清单2-24(c)和清单2-26可知,主方法是根据JVM内部一个唯一的方法ID(即methodID)定位到的。那么,我们不禁想问,JVM是如何根据methodID定位到要执行的方法的?方法在JVM内部又是什么样的呢?如果你还没想过这个问题,那么请闭上眼睛,花上几分钟思考一下这个问题。
这里我们暂时不急着回答这个问题,通过本书后续章节对类的解析以及方法区等知识点的学习,这些疑惑就可以迎刃而解了。
为了执行主类的main方法,将在jni_invoke_static中通过调用JavaCalls模块完成最终的执行Java方法。在HotSpot中,所有对Java方法的调用都需要通过类JavaCalls来完成。
清单2-27
来源:hotspot/src/share/vm/prims/jni.cpp
描述:JNI函数:jni_invoke_static
methodHandle method(THREAD, JNIHandles::resolve_jmethod_id(method_id));
JavaCalls::call(result, method, &java_args, CHECK);
清单2-27中是这部分逻辑的实现:首先根据method_id转换成方法句柄,然后调用JavaCalls模块方法实现从JVM对Java方法的调用。
2.2.8 JVM退出路径
前面讲述了JVM启动的过程,这里介绍JVM退出的过程。一般来说,JVM有两条退出路径。其中一条路径称为虚拟机销毁(destroy vm):当程序运行到主方法的结尾处,系统将调用jni_DestroyJavaVM()函数销毁虚拟机。而另外一条路径则是虚拟机退出(vm exit):当程序调用System.exit()函数,或当JVM遇到错误时,将通过这条路径直接退出。
这两条退出途径并不完全相同,但它们在Java层共享Shutdown.shutdown()和before_exit()函数,并在JVM层共享VM_Exit函数。
这里,介绍一下destroy_vm的退出流程。
- 当前线程等待直到成为最后一条非守护线程。此时,所有工作仍在继续。
- Java层调用java.lang.Shutdown.shutdown()函数。
- 调用before_exit()函数,为JVM退出做一些准备工作:首先,运行JVM层的关闭钩子函数(shutdown hooks)。这些钩子函数是通过JVM_OnExit进行注册的。目前唯一使用了这套机制的钩子函数是File.deleteOnExit()函数;其次,停止一些系统线程,如“StatSampler”,“watcher thread”和“CMS threads”等,并向JVMTI发送“thread end”和“vm death”事件;最后,停止信号线程。
- 调用JavaThread::exit()函数,这将释放JNI句柄块,并从线程列表中移除本线程。
- 停止虚拟机线程,使虚拟机进入安全点(safepoint)并停止编译器线程。
- 禁用JNI/JVM 跟踪。
- 为那些仍在运行本地代码的线程设置“_vm_exited”标记。
- 删除当前线程。
- 调用exit_globals()函数,删除tty和PerfMemory等资源。
- 返回到上层调用者。
到目前为止,我们对启动过程已经有了较为整体的认识。接下来,在2.3小节中,我们将深入了解系统初始化过程。