在本文中,我们将会介绍如何开始使用HotSpot Java虚拟机以及它在OpenJDK开源项目中的实现——我们将会从两个方面进行介绍,分别是虚拟机和虚拟机与Java类库的交互。
首先让我们看看JDK源码和它所包含的相关Java概念的实现。检查源码的方式主要有两种:
这两种方式都非常有用,但是重要的是哪种方式比较舒适一点。OpenJDK的源码存储在Mercurial(一个分布式的版本控制系统,与流行的Git版本控制系统相似)中。如果你不熟悉Mercurial,可以查看这本名为“版本控制示例”的免费书,该书介绍了相关的基础内容。
为了检出OpenJDK 7的源码,你需要安装Mercurial命令行工具,然后执行以下命令:
hg clone http://hg.openjdk.java.net/jdk7/jdk7 jdk7_tl
该命令会在本地生成一个OpenJDK仓库的副本。该仓库含有项目的基础布局,但是并没有包含所有的文件——因为OpenJDK项目分别分布在几个子仓库中。
完成克隆之后,本地仓库应该有类似于下面的内容:
ariel-2:jdk7_tl boxcat$ ls -l total 664 -rw-r--r-- 1 boxcat staff 1503 14 May 12:54 ASSEMBLY_EXCEPTION -rw-r--r-- 1 boxcat staff 19263 14 May 12:54 LICENSE -rw-r--r-- 1 boxcat staff 16341 14 May 12:54 Makefile -rw-r--r-- 1 boxcat staff 1808 14 May 12:54 README -rw-r--r-- 1 boxcat staff 110836 14 May 12:54 README-builds.html -rw-r--r-- 1 boxcat staff 172135 14 May 12:54 THIRD_PARTY_README drwxr-xr-x 12 boxcat staff 408 14 May 12:54 corba -rwxr-xr-x 1 boxcat staff 1367 14 May 12:54 get_source.sh drwxr-xr-x 14 boxcat staff 476 14 May 12:55 hotspot drwxr-xr-x 19 boxcat staff 646 14 May 12:54 jaxp drwxr-xr-x 19 boxcat staff 646 14 May 12:55 jaxws drwxr-xr-x 13 boxcat staff 442 16 May 16:01 jdk drwxr-xr-x 13 boxcat staff 442 14 May 12:55 langtools drwxr-xr-x 18 boxcat staff 612 14 May 12:54 make drwxr-xr-x 3boxcat staff 102 14 May 12:54 test
接下来,你应该运行get_source.sh脚本,该脚本是初始克隆内容的一部分。该脚本会填充项目的剩余部分,克隆构建OpenJDK所需要的所有文件。
在我们深入并详细地介绍源码之前,我们必须要有“不惧怕平台源码”的信念。开发者通常会认为JDK源码一定是令人振奋且难以接近的,但这毕竟是整个平台的核心。
JDK源码是固定的、经过良好的审核和测试的,但是并不是那么无法接近。特别是这些源码并不是始终包含Java语言的最新特性。所以我们经常会在其内部找到那些依然没有泛型化的、使用原始类型的类。
对于JDK源码而言,有几个主要的仓库是你应该熟悉的:
这是类库存在的地方。几乎所有的内容都是Java(本地方法会使用一些C代码)。这是深入学习OpenJDK源码的一个非常好的起点。JDK的类在jdk/src/share/classes目录中。
HotSpot虚拟机——这里面是C/C++和汇编代码(还有一些基于Java的虚拟机开发工具)。这些内容非常高级,如果你并不是一个专业的C/C++开发人员那么这些内容会让人有一点难以入手。稍后我们会更加详细地讨论一些入门的好方法。
对于那些对编译器和工具开发感兴趣的人而言,可以从这里找到语言和平台工具。大部分是Java和C代码——学习这些内容比学习JDK代码要难,但是对于大多数开发者而言还是可以接受的。
还有一些其他的仓库,但是它们可能没有那么重要或者对大多数开发者而言没什么吸引力,这些仓库包括corba、jaxp和jaxws等内容。
Oracle最近开始了一个项目对OpenJDK做了一次全面的修整,并且简化了构建过程。这个项目称为“build-dev”,目前该项目已经完成并且成为了构建OpenJDK的标准方式。对于很多使用基于Unix系统的用户而言,构建过程现在就和安装一个编译器和一个“引导JDK”然后运行三个命令那么简单:
./configure make clean make images
如果你想获取更多与构建自己的OpenSDK相关的信息,那么AdoptOpenJDK计划(由伦敦的Java社团创建)是一个不错的起点——这是一个由100多位草根开发者组成的社团,他们都工作在警告清理、小bug解决和OpenJDK 8对主要开源项目的兼容性测试等项目上。
Java运行时环境正如OpenJDK所提供的那样,由HotSpot JVM和类库(大部分都捆绑到了rt.jar里面)组成。
因为Java是一个可移植的环境,所有需要调用操作系统的内容最终都会由一个本地方法处理。另外,还有一些方法需要JVM的特殊处理(例如类的加载)。这些内容也会通过一个本地调用移交给JVM。
例如,让我们看看原始Object类中本地方法的C代码。Object类的本地源码包含在jdk/src/share/native/java/lang/Object.c文件中,它有六个方法。
Java本地接口(JNI)通常会要求本地方法的C实现按照一种非常特别的方式命名。例如,本地方法Object::getClass()使用通用的命名约定,因此C实现被包含在一个具有如下签名的C函数中:
Java_java_lang_Object_getClass(JNIEnv *env, jobject this)
JNI还有另一种加载本地方法的方式,java.lang.Object类中剩余的5个本地方法就使用了这种方式:
static JNINativeMethod methods[] = { {"hashCode", "()I", (void *)&JVM_IHashCode}, {"wait", "(J)V", (void *)&JVM_MonitorWait}, {"notify", "()V", (void *)&JVM_MonitorNotify}, {"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll}, {"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},};
这5个方法被映射到了JVM的入口点(它们是通过在C方法名上使用JVM_前缀来指定的),——使用registerNatives()的方式(开发人员能够通过这种方式改变Java本地方法到C函数名称的映射)。
Java运行时环境是用Java编写的,仅有很少的与JVM相关的小地方不是。除了代码的执行之外,JVM的主要工作是运行时环境的内务处理和维护,这里是活动Java对象运行时表示赖以生存的地方——Java堆。
堆中的任何Java对象都是由一个普通的对象指针(OOP)表示的。在C/C++中一个OOP是一个真正的指针——一个指向Java堆里面某个内存位置的机器字。在JVM进程的虚拟地址空间中,会为Java堆分配一个单独的连续的地址范围,然后用户空间中的这块内存就会完全由JVM进程自己管理,直到JVM因为某些原因需要调整堆大小为止。
这意味着Java对象的创建和收集并不会牵扯到分配和释放内存的系统调用。
一个OOP由两个机器字头组成,它们被称为Mark和Klass字,之后是这个实例的成员字段。对于数组而言,在成员字段之前还有一个额外的字头——数组的长度。
之后我们会更加详细地介绍Mark和Klass字,但是它们的名字也暗示了一些内容——Mark字用于垃圾收集(用于标记——扫描的标记部分),而Klass字则是一个指向类元数据的指针。
在OOP头之后,实例字段会按照它在字节码中的特定顺序进行排列。如果你想了解更精确的细节,可以阅读NitsanWakart的博客文章“理解Java对象的内存分布”。
基本字段和引用字段都会排列在OOP头后面——当然,对象的引用也是OOP。下面让我们看一个Entry类(java.util.HashMap类中使用了该类)的例子:
static class Entryimplements Map.Entry { final K key; V value; Entry next; final int hash; // methods... }
现在,让我们来计算一下一个Entry对象的大小(在32位的JVM上)。
头包含一个Mark字和一个Klass字,因此在32位的HotSpot上OOP头会占用8个字节(在64位HotSpot上占用16个字节)。
一个OOP定义的总体大小是2个机器字加上所有实例字段的大小。
引用类型的字段实际上是指针——在所有健全的处理器架构中该指针都将占用一个机器字。
因此,因为我们有一个int字段,两个引用字段(对类型为K和V的对象的引用)和一个Entry字段,所以整个大小为2个字(头)+1个字(int)+3个字(指针)。
存储一个HashMap.Entry对象总共需要24个字节(6个字)。
Klass字是OOP头中最重要的部分之一。它是指向这个类元数据的指针(它由一个称为KlassOOP的C++类型表示)。在这些元数据当中最重要的是这个类的方法,它们被表示为一个C++虚拟方法表(一个“vtable”)。
我们并不想让所有的实例都携带着方法的所有细节,因为这样做效率会非常低,所以使用了一个vtable在实例之间共享这些信息。
需要注意的是,KlassOOP和类加载操作所产生的类对象是不同的。这两者之间的区别可以概括为下面两个方面:
记住这个区别最容易的方式是,将KlassOOP当作是类对象的JVM级别的“镜像”。
KlassOOP的vtable结构直接与Java的方法调度和单继承相关。要记住,默认情况下Java的实例方法调度是虚拟的(它使用被调用实例对象的运行时类型信息查找方法)。
在KlassOOPvtable中这是通过“常量vtable偏移”实现的。这意味着,重载方法在vtable中的偏移和它所重载的父类(包括祖父等)中的方法实现具有相同的偏移。
在这种情况下虚拟调度就很容易实现了,只需要简单地追溯继承层次(按照类——父类——祖父类的层次追溯)并寻找方法的实现就可以了(在vtable中的偏移始终相同)。
例如,这意味着在所有的类中toString()方法在vtable中的偏移始终相同。这个vtable结构有助于单继承,同时在使用JIT编译代码的时候也能够做一些非常好的优化。
(单击图片放大)
OOP头的Mark字是一个到某个结构的指针(实际上仅仅是一个位字段的集合,它们保存着OOP相关的内部处理信息)。
在常见的32位JVM环境中,Mark结构的位字段类似于下面的内容(查看hotspot/src/share/vm/oops/markOop.hpp了解更多内容):
hash:25 —>| age:4 biased_lock:1 lock:2
高25位包含对象的hashCode()值,紧接着的4位是对象的年龄(存活对象所经过的垃圾收集的次数)。剩下的3个位用于表明对象的同步锁状态。
Java 5引入了一种新的对象同步方式,称为偏向锁(在Java 6中是默认的锁机制)。该方案的灵感来源于对对象运行时行为的观察——在很多情况下对象永远只会被一个线程锁定。
在偏向锁中,一个对象会“偏向于”锁定它的第一个线程——然后这个线程会实现更好的锁性能。获得偏向的线程会被记录在Mark头中。
JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2
如果另一个线程试图锁定对象,那么这个偏向就会被取消(并且不会被重新获得),并且自此之后所有的线程都必须明确地锁定和解锁对象。
对象的状态可能会是:
HotSpot源中相关的OOP类型层次非常复杂。这些类型被保存在hotspot/src/share/vm/oops中,包括:
有一些稍微奇怪的历史性事件——虚拟调度表(vtable)的内容和klassOOP是分开保存的,markOOP和其他OOP看起来完全不同,但是它依然包含在同样的层次中。
一个非常有趣的地方是,我们可以从jmap命令行工具中直接看到OOP。它对堆中的内容做了一个快照,包括出现在permgen中的所有OOP(包括子类和KlassOOP所需的支持结构)。
$ jmap -histo 150 | head -18 num #instances #bytes class name ---------------------------------------------- 1: 10555 21650048 [I 2: 272357 6536568 java.lang.Double 3: 25163 5670768 [Ljava.lang.Object; 4: 229099 5498376 com.jclarity.censum.dataset.CensumXYDataItem 5: 39021 54709446: 39021 5319320 7: 8269 4031248 [B 8: 3161 3855136 9: 119759 2874216 org.jfree.data.xy.XYDataItem 10: 3161 2773120 11: 2894 2451648 12: 34012 2271576 [C 13: 87065 2089560 java.lang.Long 14: 20897 2006112 [Lcom.jclarity.censum.CollectionType; 15: 33798 1081536 java.util.HashMap$Entry
尖括号中的条目包含了各种类型的OOP,例如[I和[B分别指int类型和byte类型的数组。
HotSpot解释器
开发者通常会比较熟悉那种“在一个while循环中切换”的解释器,但是HotSpot比这种类型的解释器要更加先进。
HotSpot是一个模板解释器。这意味着它会构建一个动态的、优化的机器码调度表——特定于用户所使用的操作系统和CPU。大部分的字节码指令都是使用汇编语言代码实现的,仅有非常复杂的指令会被委托给虚拟机处理,例如从一个类文件的常量池中查找一个入口。
这提升了HotSpot解释器的性能,但是代价是难以将虚拟机移植到新的架构和操作系统上。同是对于新开发者而言也增加了他们理解解释器的难度。
对于新手开发者而言,对OpenJDK所提供的运行时环境有一个基础的理解是非常必要的:
到现在为止,开发者已经能够开始探索JDK仓库中的Java代码了,也能够尝试着积累自己的C/C++和汇编知识去深入学习HotSpot了。