Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里 面的人却想出来。

2.1

对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的“皇帝”, 又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始 到终结的维护责任。

对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对 的delete/free代码,不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不 过,也正是因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问 题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

本章是第二部分的第1章,笔者将从概念上介绍Java虚拟机内存的各个区域,讲解这些区域的作 用、服务对象以及其中可能产生的问题,这也是翻越虚拟机内存管理这堵围墙的第一步。

本章介绍了Java技术体系的过去、现在和未来的发展趋势,并在实践中介绍了如何自己编译一个 OpenJDK 12。作为全书的引言部分,本章建立了后文研究所必需的环境。在了解Java技术的来龙去脉 后,后面章节将分为四部分去介绍Java在“自动内存管理”“Class文件结构与执行引擎”“编译器优 化”及“多线程并发”方面的实现原理。

我们在阅读OpenJDK源码的过程中,肯定会运行和跟踪调试程序来帮助理解。现在我们已学会了 如何编译一个可调试版本HotSpot虚拟机,并禁用优化,带有符号信息,这样的编译结果已经可以直接 使用GDB在命令行中进行调试了。据笔者所知,不少对Java虚拟机研发接触比较多的开发人员确实就 是使用GDB和VIM编辑器来开发、修改HotSpot源码的,不过相信大多数读者都还是更倾向于在IDE环 境而不是纯文本下阅读、跟踪HotSpot源码。为此,本节将会讲解如何在IDE中进行HotSpot源码调 试。

在本次实战里,笔者采用的IDE是JetBrains的CLion 2019.1,读者可以在JetBrains网站[1]上直接下 载并免费使用30天,如果希望使用其他IDE,譬如Eclipst CDT或者Net-Beans,可以参考本书第2版中 相同章节的内容,为节省篇幅笔者就没有把它放到附录中了。

CLion安装后,新建一个项目,选择“New CMake Project from Sources”,在源码文件夹中填入 OpenJDK源码根目录,此时,CLion已经自动选择好了需要导入的源码,如图1-10所示。点击OK按钮 就会导入源码并自动创建好CMakeLists.txt文件。

这份自动生成的CMakeLists.txt并不能直接使用,OpenJDK本身也没有为任何IDE提供支持,但如 果只是为了能够在CLion中跟踪、阅读源码,而不需要修改重新编译的话,那直接在Run/Debug Configurations中增加一个CMake Application,然后Executable选择我们刚才编译出来的FastDebug或者 SlowDebug版的java命令,运行参数加上-version或者某个Class文件的路径,再把Before launch里面的 Build去掉,就可以开始运行调试了,如图1-11所示。

省略…..

需要下载的编译环境和依赖项目都齐备后,我们就可以按照默认配置来开始编译了,但通常我们 编译OpenJDK的目的都不仅仅是为了得到在自己机器中诞生的编译成品,而是带着调试、定制化等需 求,这样就必须了解OpenJDK提供的编译参数才行,这些参数可以使用“bash configure–help”命令查询 到,笔者对它们中最有用的部分简要说明如下:

  • --with-debug-level=<level>:设置编译的级别,可选值为release、fastdebug、slowde-bug,越往后进 行的优化措施就越少,带的调试信息就越多。还有一些虚拟机调试参数必须在特定模式下才可以使 用。默认值为release。
  • --enable-debug:等效于–with-debug-level=fastdebug。
  • --with-native-debug-symbols=<method>:确定调试符号信息的编译方式,可选值为none、 internal、external、zipped。
  • --with-version-string=<string>:设置编译JDK的版本号,譬如java-version的输出就会显示该信息。 这个参数还有–with-version-=的形式,其中part可以是pre、opt、build、major、minor、 security、patch之一,用于设置版本号的某一个部分。
  • --with-jvm-variants=<variant>[,<variant>...]:编译特定模式(Variants)的HotSpot虚拟机,可以 多个模式并存,可选值为server、client、minimal、core、zero、custom。
  • --with-jvm-features=<feature>[,<feature>...]:针对–with-jvm-variants=custom时的自定义虚拟机特 性列表(Features),可以多个特性并存,由于可选值较多,请参见help命令输出。
  • --with-target-bits=<bits>:指明要编译32位还是64位的Java虚拟机,在64位机器上也可以通过交叉 编译生成32位的虚拟机。
  • --with-<lib>=<path>:用于指明依赖包的具体路径,通常使用在安装了多个不同版本的Bootstrap JDK和依赖包的情况。其中lib的可选值包括boot-jd、freetype、cups、x、alsa、libffi、jtreg、libjpeg、 giflib、libpng、lcms、zlib。
  • --with-extra-<flagtype>=<flags>:用于设定C、C++和Java代码编译时的额外编译器参数,其中 flagtype可选值为cflags、cxxflags、ldflags,分别代表C、C++和Java代码的参数。
  • --with-conf-name=<name>:指定编译配置名称,OpenJDK支持使用不同的配置进行编译,默认会 根据编译的操作系统、指令集架构、调试级别自动生成一个配置名称,譬如“linux-x86_64-server- release”,如果在这些信息都相同的情况下保存不同的编译参数配置,就需要使用这个参数来自定义配 置名称。

以上是configure命令的部分参数,其他未介绍到的可以使用“bash configure–help”来查看,所有参 数均通过以下形式使用:

1
bash configure [options]

譬如,编译FastDebug版、仅含Server模式的HotSpot虚拟机,命令应为:

1
bash configure --enable-debug --with-jvm-variants=server

configure命令承担了依赖项检查、参数配置和构建输出目录结构等多项职责,如果编译过程中需 要的工具链或者依赖项有缺失,命令执行后将会得到明确的提示,并且给出该依赖的安装命令,这比 编译旧版OpenJDK时的“make sanity”检查要友好得多,譬如以下例子所示:

1
2
configure: error: Could not find fontconfig! You might be able to fix this by running 'sudo apt-get install libfontconfig1-dev'. 
configure exiting with result code 1

如果一切顺利的话,就会收到配置成功的提示,并且输出调试级别,Java虚拟机的模式、特性, 使用的编译器版本等配置摘要信息,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
A new configuration has been successfully created in 
/home/icyfenix/develop/java/jdk12/build/linux-x86_64-server-release
using default settings.

Configuration summary:
* Debug level: release
* HS debug level: product
* JVM variants: server
* JVM features: server: 'aot cds cmsgc compiler1 compiler2 epsilongc g1gc graal jfr jni-check jvmci jvmti management nmt parallelgc serialgc services shenandoahgc vm-structs zgc'
* OpenJDK target: OS: linux, CPU architecture: x86, address length: 64
* Version string: 12-internal+0-adhoc.icyfenix.jdk12 (12-internal)

Tools summary:
* Boot JDK: openjdk version "11.0.3" 2019-04-16 OpenJDK Runtime Environment (build 11.0.3+7-Ubuntu-1ubuntu218.04.1) OpenJDK 64-Bit Server VM (build 11.0.3+7-Ubuntu-1ubuntu218.04.1, mixed mode, sharing) (at /usr/lib/jvm/java-11-openjdk-amd64)
* Toolchain: gcc (GNU Compiler Collection)
* C Compiler: Version 7.4.0 (at /usr/bin/gcc)
* C++ Compiler: Version 7.4.0 (at /usr/bin/g++)

Build performance summary:
* Cores to use: 4
* Memory limit: 7976 MB

在configure命令以及后面的make命令的执行过程中,会在“build/配置名称”目录下产生如下目录结 构。不常使用C/C++的读者要特别注意,如果多次编译,或者目录结构成功产生后又再次修改了配 置,必须先使用“make clean”和“make dist-clean”命令清理目录,才能确保新的配置生效。编译产生的目 录结构以及用途如下所示:

1
2
3
4
5
6
7
8
9
buildtools/:用于生成、存放编译过程中用到的工具 
hotspot/:HotSpot虚拟机编译的中间文件
images/:使用make *-image产生的镜像存放在这里
jdk/:编译后产生的JDK就放在这里
support/:存放编译时产生的中间文件
test-results/:存放编译后的自动化测试结果
configure-support/:这三个目录是存放执行configure、make和test的临时文件
make-support/
test-support/

依赖检查通过后便可以输入“make images”执行整个OpenJDK编译了,这里“images”是“product- images”编译目标(Target)的简写别名,这个目标的作用是编译出整个JDK镜像,除了“product- images”以外,其他编译目标还有:

1
2
3
4
5
6
7
8
hotspot:只编译HotSpot虚拟机 
hotspot-<variant>:只编译特定模式的HotSpot虚拟机
docs-image:产生JDK的文档镜像
test-image:产生JDK的测试镜像
all-images:相当于连续调用product、docs、test三个编译目标
bootcycle-images:编译两次JDK,其中第二次使用第一次的编译结果作为Bootstrap JDK
clean:清理make命令产生的临时文件
dist-clean:清理make和configure命令产生的临时文件

笔者使用Oracle VM VirtualBox虚拟机,启动4条编译线程,8GB内存,全量编译整个OpenJDK 12 大概需近15分钟时间,如果之前已经全量编译过,只是修改了少量文件的话,增量编译可以在数十秒 内完成。编译完成之后,进入OpenJDK源码的“build/配置名称/jdk”目录下就可以看到OpenJDK的完整 编译结果了,把它复制到JAVA_HOME目录,就可以作为一个完整的JDK来使用,如果没有人为设置 过JDK开发版本的话,这个JDK的开发版本号里默认会带上编译的机器名,如下所示:

1
2
3
4
> ./java -version
openjdk version "12-internal" 2019-03-19
OpenJDK Runtime Environment (build 12-internal+0-adhoc.icyfenix.jdk12)
OpenJDK 64-Bit Server VM (build 12-internal+0-adhoc.icyfenix.jdk12, mixed mode)

在MacOS[^1]和Linux上构建OpenJDK编译环境相对简单,对于MacOS,需要MacOS X 10.13版本以 上,并安装好最新版本的XCode和Command Line Tools for XCode(在Apple Developer网站[^2]上可以免 费下载),这两个SDK提供了OpenJDK所需的CLang编译器以及Makefile中用到的其他外部命令。

对于Linux系统,要准备的依赖与MacOS类似,在MacOS中CLang编译器来源于XCode SDK,而 Ubuntu里用户可以自行选择安装GCC或CLang来进行编译,但必须确保最低的版本为GCC 4.8或者 CLang 3.2以上,官方推荐使用GCC 7.8或者CLang 9.1来完成编译。在Ubuntu系统上安装GCC的命令 为:

1
sudo apt-get install build-essential

在编译过程中需要依赖FreeType、CUPS等若干第三方库,OpenJDK全部的依赖库已在表1-1中列 出,读者可执行相应的安装命令完成安装。

image-20210915204639903

最后,假设要编译大版本号为N的JDK,我们还要另外准备一个大版本号至少为N-1的、已经编译 好的JDK,这是因为OpenJDK由多个部分(HotSpot、JDK类库、JAXWS、JAXP……)构成,其中一 部分(HotSpot)代码使用C、C++编写,而更多的代码则是使用Java语言来实现,因此编译这些Java代 码就需要用到另一个编译期可用的JDK,官方称这个JDK为“Bootstrap JDK”。编译OpenJDK 12时, Bootstrap JDK必须使用JDK 11及之后的版本。在Ubuntu中使用以下命令安装OpenJDK 11:

1
sudo apt-get install openjdk-11-jdk

[^1]: 注意,在OpenJDK 7u4和之后的版本才能编译出MacOS系统下的JDK包,之前的版本虽然在源码和 编译脚本中也包含了MacOS目录,但是尚未完善。
[^2]: https://developer.apple.com/。

1.6 实战:自己编译JDK 1.6.2 系统需求

如果可能,笔者建议尽量在Linux或者MacOS上构建OpenJDK,这两个系统在准备构建工具链和依 赖项上要比在Windows或Solaris平台上要容易许多,本篇实践中笔者将以Ubuntu 18.04 LTS为平台进行 构建。如果读者确实有在Windows平台上完成编译的需求,或需要编译较老版本的OpenJDK,可参考 本书附录A,这篇附录是本书第1版中介绍如何在Windows下编译OpenJDK 6的实践例子,虽然里面的 部分内容已经过时了(例如安装Plug部分),但对Windows上构建安装环境和较老版本的OpenJDK编 译还是有一定参考意义的,所以笔者并没有把它删除掉,而是挪到附录之中。

无论在什么平台下进行编译,都建议读者认真阅读一遍源码中的doc/building.html文档,编译过程 中需要注意的细节较多,如果读者是第一次编译OpenJDK,那有可能会在一些小问题上耗费许多时 间。在本次编译中采用的是64位操作系统,默认参数下编译出来的也是64位的OpenJDK,如果需要编 译32位版本,笔者同样推荐在64位的操作系统上进行,理由是编译过程可以使用更大内存(32位系统 受4G内存限制),通过编译参数(–with-target-bits=32)来指定需要生成32位编译结果即可。在官方 文档上要求编译OpenJDK至少需要2~4GB的内存空间(CPU核心数越多,需要的内存越大),而且 至少要6~8GB的空闲磁盘空间,不要看OpenJDK源码的大小只有不到600MB,要完成编译,过程中 会产生大量的中间文件,并且编译出不同优化级别(Product、FastDebug、SlowDebug)的HotSpot虚 拟机可能要重复生成这些中间文件,这都会占用大量磁盘空间。

对系统环境的最后一点建议是,所有的文件,包括源码和依赖项目,都不要放在包含中文的目录 里面,这样做不是一定会产生不可解决的问题,只是没有必要给自己找麻烦。

1.6 实战:自己编译JDK

想要窥探Java虚拟机内部的实现原理,最直接的一条路径就是编译一套自己的JDK,通过阅读和 跟踪调试JDK源码来了解Java技术体系的运作,虽然这样门槛会比阅读资料更高一点,但肯定也会比 阅读各种文章、书籍来得更加贴近本质。此外,Java类库里的很多底层方法都是Native的,在了解这些 方法的运作过程,或对JDK进行Hack(根据需要进行定制微调)的时候,都需要有能自行编译、调试 虚拟机代码的能力。

现在网络上有不少开源的JDK实现可以供我们选择,但毫无疑问OpenJDK是使用得最广泛的 JDK,我们也将选择OpenJDK来进行这次编译实战。

1.6.1 获取源码

编译源码之前,我们要先明确OpenJDK和OracleJDK之间、OpenJDK的各个不同版本之间存在什 么联系,这有助于确定接下来编译要使用的JDK版本和源码分支,也有助于理解我们编译出来的JDK 与Oracle官方提供的JDK有什么差异。

从前面介绍的Java发展史中我们已经知道OpenJDK是Sun公司在2006年年末把Java开源而形成的项 目,这里的“开源”是通常意义上的源码开放形式,即源码是可被复用的,例如OracleJDK、Oracle OpenJDK、AdoptOpenJDK、Azul Zulu、SAP SapMachine、Amazon Corretto、IcedTea、UltraViolet等 都是从OpenJDK源码衍生出的发行版。但如果仅从“开源”字面意义(开放可阅读的源码)上讲的话, 其实Sun公司自JDK 5时代起就曾经以JRL(Java Research License)的形式公开过Java的源码,主要是开 放给研究人员阅读使用,这种JRL许可证的开放源码一直持续到JDK 6 Update 23才因OpenJDK项目日 渐成熟而终止。如果拿OpenJDK中的源码跟对应版本的JRL许可证形式开放的Sun/OracleJDK源码互相 比较的话,会发现除了文件头的版权注释之外,其余代码几乎都是相同的,只有少量涉及引用第三方 的代码存在差异,如字体栅格化渲染,这部分内容OracleJDK采用了商业实现,源码版权不属于Oracle 自己,所以也无权开源,而OpenJDK中使用的是同样开源的FreeType代替。

当然,笔者说的“代码相同”必须建立在两者共有的组件基础之上,OpenJDK中的源码仓库只包含 了标准Java SE的源代码,而一些额外的模块,典型的如JavaFX,虽然后来也是被Oracle开源并放到 OpenJDK组织进行管理(OpenJFX项目),但是它是存放在独立的源码仓库中,因此OracleJDK的安 装包中会包含JavaFX这种独立的模块,而用OpenJDK的话则需要单独下载安装。

此外,在JDK 11以前,OracleJDK中还会存在一些OpenJDK没有的、闭源的功能,即OracleJDK 的“商业特性”。例如JDK 8起从JRockit移植改造而来的Java Flight Recorder和Java Mission Control组件、 JDK 10中的应用类型共享功能(AppCDS)和JDK 11中的ZGC收集器,这些功能在JDK 11时才全部开 源到了OpenJDK中。到了这个阶段,我们已经可以认为OpenJDK与OracleJDK代码实质上[^1]已达到完 全一致的程度。

根据Oracle的项目发布经理Joe Darcy在OSCON大会上对两者关系的介绍[^2]也证实了OpenJDK和 OracleJDK在程序上是非常接近的,两者共用了绝大部分相同的代码(如图1-7所示,注意图中的英文 提示了两者共同代码的占比要远高于图形上看到的比例),所以我们编译的OpenJDK,基本上可以认 为性能、功能和执行逻辑上都和官方的OracleJDK是一致的。

下面再来看一下OpenJDK内部不同版本之间的关系,在OpenJDK接收Sun公司移交的JDK源码 时,Java正处于JDK 6时代的初期,JDK 6 Update 1才刚刚发布不久,JDK 7则还完全处于研发状态的 半成品。OpenJDK的第一个版本就是来自于当时Sun公司正在开发的JDK 7,考虑到OpenJDK 7的状况 在当时完全不足以支持实际的生产部署,因此又在OpenJDK 7 Build 22的基础上建立了一条新的 OpenJDK 6分支,剥离掉所有JDK 7新功能的代码,形成一个可以通过TCK 6测试的独立分支,先把 OpenJDK 6发布出去给公众使用。等到OpenJDK 7达到了可正式对外发布的状态之后,就从OpenJDK 7的主分支延伸出用于研发下一代Java版本的OpenJDK 8以及用于发布更新补丁的OpenJDK 7 Update两 条子分支,按照开发习惯,新的功能或Bug修复通常是在最新分支上进行的,当功能或修复在最新分 支上稳定之后会同步到其他老版本的维护分支上。后续的JDK 8和JDK 9都重复延续着类似的研发流 程。通过图1-8(依然是从Joe Darcy的OSCON演示稿截取的图片)可以比较清楚地理解不同版本分支 之间的关系。

到了JDK 10及以后的版本,在组织上出现了一些新变化,此时全部开发工作统一归属到JDK和 JDK Updates两条主分支上,主分支不再带版本号,在内部再用子分支来区分具体的JDK版本。 OpenJDK不同版本的源码都可以在它们的主页(http://openjdk.java.net/)上找到,在本次编译实践中, 笔者选用的版本是OpenJDK 12。

获取OpenJDK源码有两种方式。一是通过Mercurial代码版本管理工具从Repository中直接取得源 码(Repository地址:https://hg.openjdk.java.net/jdk/jdk12),获取过程如以下命令所示:

1
hg clone https://hg.openjdk.java.net/jdk/jdk12

这是直接取得OpenJDK源码的方式,从版本管理中看变更轨迹也能够更精确地了解到Java代码发 生的变化,但弊端是在中国访问的速度实在太慢,虽然代码总量只有几百MB,无奈文件数量将近十 万,而且仓库没有国内的CDN节点。以笔者的网络状况,不科学上网的话,全部复制到本地需要耗费 数小时时间。另外,考虑到Mercurial远不如Git常用,甚至普及程度还不如SVN、ClearCase以及更古老 的CVS等版本控制工具,对于大多数读者,笔者建议采用第二种方式,即直接在仓库中打包出源码压 缩包,再进行下载。

读者可以直接访问准备下载的JDK版本的仓库页面(譬如本例中OpenJDK 12的页面 为https://hg.openjdk.java.net/jdk/jdk12/),然后点击左边菜单中的“Browse”,将显示如图1-9的源码根目 录页面。

此时点击左边的“zip”链接即可下载当前版本打包好的源码,到本地直接解压即可。在国内使用这 种方式下载比起从Mercurial复制一堆零散文件要快非常多。笔者下载的OpenJDK 12源码包大小为 171MB,解压之后约为579MB。

[^1]: 严格来说,这里“实质上”可以理解为除去一些版权信息(如java-version的输出)、除去针对Oracle 自身特殊硬件平台的适配、除去JDK 12中OracleJDK排除了Shenandoah这类特意设置的差异之外是一致 的。
[^2]: 全文地址:https://blogs.oracle.com/darcy/resource/OSCON/oscon2011_OpenJDKState.pdf。

1.5.5 语言语法持续增强

笔者将语言的功能特性和语法放到最后来讲,因为它是相对最不重要的改进点,毕竟连JavaScript 这种“反人类”的语法都能获得如此巨大的成功,而比Java语法先进优雅得多的挑战者C#现在已经“江湖 日下”,成了末路英雄[^1]。

但一门语言的功能、语法又是影响语言生产力和效率的重要因素,很多语言特性和语法糖不论有 没有,程序也照样能写,但即使只是可有可无的语法糖,也是直接影响语言使用者的幸福感程度的关 键指标。JDK 7的Coins项目结束以后,Java社区又创建了另外一个新的语言特性改进项目Amber,JDK 10至13里面提供的新语法改进基本都来自于这个项目,譬如:

  • JEP 286:Local-Variable Type Inference,在JDK 10中提供,本地类型变量推断。
  • JEP 323:Local-Variable Syntax for Lambda Parameters,在JDK 11中提供,JEP 286的加强,使它可 以用在Lambda中。
  • JEP 325:Switch Expressions,在JDK 13中提供,实现switch语句的表达式支持。
  • JEP 335:Text Blocks,在JDK 13中提供,支持文本块功能,可以节省拼接HTML、SQL等场景里 大量的“+”操作。

还有一些是仍然处于草稿状态或者暂未列入发布范围的JEP,可供我们窥探未来Java语法的变化, 譬如:

  • JEP 301:Enhanced Enums,允许常量类绑定数据类型,携带额外的信息。
  • JEP 302:Lambda Leftovers,用下划线来表示Lambda中的匿名参数。
  • JEP 305:Pattern Matching for instanceof,用instanceof判断过的类型,在条件分支里面可以不需要 做强类型转换就能直接使用。

除语法糖以外,语言的功能也在持续改进之中,以下几个项目是目前比较明确的,也是受到较多 关注的功能改进计划:

  • Project Loom:现在的Java做并发处理的最小调度单位是线程,Java线程的调度是直接由操作系统 内核提供的(这方面的内容可见本书第12章),会有核心态、用户态的切换开销。而很多其他语言都 提供了更加轻量级的、由软件自身进行调度的用户线程(曾经非常早期的Java也有绿色线程),譬如 Golang的Groutine、D语言的Fiber等。Loom项目就准备提供一套与目前Thread类API非常接近的Fiber实 现。
  • Project Valhalla:提供值类型和基本类型的泛型支持,并提供明确的不可变类型和非引用类型的声 明。值类型的作用和价值在本书第10章会专门讨论,而不可变类型在并发编程中能带来很多好处,没 有数据竞争风险带来了更好的性能。一些语言(如Scala)就有明确的不可变类型声明,而Java中只能 在定义类时将全部字段声明为final来间接实现。基本类型的范型支持是指在泛型中引用基本数据类型不需要自动装箱和拆箱,避免性能损耗。
  • Project Panama:目的是消弭Java虚拟机与本地代码之间的界线。现在Java代码可以通过JNI来调用 本地代码,这点在与硬件交互频繁的场合尤其常用(譬如Android)。但是JNI的调用方式充其量只能 说是达到能用的标准而已,使用起来仍相当烦琐,频繁执行的性能开销也非常高昂,Panama项目的目 标就是提供更好的方式让Java代码与本地代码进行调用和传输数据。

随着Java每半年更新一次的节奏,新版本的Java中会出现越来越多其他语言里已有的优秀特性,相 信博采众长的Java,还能继续保持现在的勃勃生机相当长时间。

[^1]: 笔者个人观点,读者请勿从“反人类”“江湖日下”这些词语中挑起语言战争。毕竟“PHP是世界上最 好的语言”(梗)。

HotSpot的定位是面向各种不同应用场景的全功能Java虚拟机[^1],这是一个极高的要求,仿佛是让 一个胖子能拥有敏捷的身手一样的矛盾。如果是持续跟踪近几年OpenJDK的代码变化的人,相信都感 觉到了HotSpot开发团队正在持续地重构着HotSpot的架构,让它具有模块化的能力和足够的开放性。 模块化[^2]方面原本是HotSpot的弱项,监控、执行、编译、内存管理等多个子系统的代码相互纠缠。 而IBM的J9就一直做得就非常好,面向Java ME的J9虚拟机与面向Java EE的J9虚拟机可以是完全由同一 套代码库编译出来的产品,只有编译时选择的模块配置有所差别。

现在,HotSpot虚拟机也有了与J9类似的能力,能够在编译时指定一系列特性开关,让编译输出的 HotSpot虚拟机可以裁剪成不同的功能,譬如支持哪些编译器,支持哪些收集器,是否支持JFR、 AOT、CDS、NMT等都可以选择。能够实现这些功能特性的组合拆分,反映到源代码不仅仅是条件编 译,更关键的是接口与实现的分离。

早期(JDK 1.4时代及之前)的HotSpot虚拟机为了提供监控、调试等不会在《Java虚拟机规范》 中约定的内部功能和数据,就曾开放过Java虚拟机信息监控接口(Java Virtual Machine Profiler Interface,JVMPI)与Java虚拟机调试接口(Java Virtual Machine Debug Interface,JVMDI)供运维和性 能监控、IDE等外部工具使用。到了JDK 5时期,又抽象出了层次更高的Java虚拟机工具接口(Java Virtual Machine Tool Interface,JVMTI)来为所有Java虚拟机相关的工具提供本地编程接口集合,到 JDK 6时JVMTI就完全整合代替了JVMPI和JVMDI的作用。

在JDK 9时期,HotSpot虚拟机开放了Java语言级别的编译器接口[^3](Java Virtual Machine Compiler Interface,JVMCI),使得在Java虚拟机外部增加、替换即时编译器成为可能,这个改进实现起来并不 费劲,但比起之前JVMPI、JVMDI和JVMTI却是更深层次的开放,它为不侵入HotSpot代码而增加或 修改HotSpot虚拟机的固有功能逻辑提供了可行性。Graal编译器就是通过这个接口植入到HotSpot之 中。

到了JDK 10,HotSpot又重构了Java虚拟机的垃圾收集器接口[^4](Java Virtual Machine Compiler Interface),统一了其内部各款垃圾收集器的公共行为。有了这个接口,才可能存在日后(今天尚未) 某个版本中的CMS收集器退役,和JDK 12中Shenandoah这样由Oracle以外其他厂商领导开发的垃圾收 集器进入HotSpot中的事情。如果未来这个接口完全开放的话,甚至有可能会出现其他独立于HotSpot 的垃圾收集器实现。

经过一系列的重构与开放,HotSpot虚拟机逐渐从时间的侵蚀中挣脱出来,虽然代码复杂度还在增 长,体积仍在变大,但其架构并未老朽,而是拥有了越来越多的开放性和扩展性,使得HotSpot成为一 个能够联动外部功能,能够应对各种场景,能够学会十八般武艺的身手灵活敏捷的“胖子”。

[^1]: 定位J9做到了,HotSpot实际上并未做到,譬如在Java ME中的虚拟机就不是HotSpot,而是CDC-HI/CLDC-HI。
[^2]: 这里指虚拟机本身的模块化,与Jigsaw无关。
[^3]: 并不是指内部代码上统一C1、C2的编译器接口,而是特指会开放给外部的、使用Java语言实现的编 译器接口。
[^4]: 这个接口目前只在HotSpot内部使用,并未对外开放,所以也就没有JVMGI的提法。