手撕Java虚拟机 (一)
说起“手撕Java虚拟机”,之前陆陆续续见过好多软文、出版物的书名都是这个,然后翻看一下,发现基本都是标题党,无非把Jvm规范翻译一遍,还有更过份的就直接抄袭《深入理解Java虚拟机》这本书的内容了。所以看起来,这个年代手撕的门槛也忒低了点,那么跟个风,也“手撕”一波。
0x00 起源
当年在大学学C语言的时候,老师说他们当年写程序的时候,不像现在这么幸福。他们写程序是在卡片或纸带上打孔,1打洞,0不打洞。然后将打好洞的纸带输入计算机,计算机就读取这上面的二进制代码进行计算。大概工作过程就像这种纸带八音盒(https://b23.tv/9In2Uz)。
所以,当年的程序员用于编程的原材料就是纸带,剪刀、胶水(debug用),IDE就是打孔器(猜过去应该像火车剪票器那样的东西吧)。没有代码提示,没有自动完成,需要在脑海中构建出CPU的执行过程,也就是说你是在写机器码,因为CPU只认0和1,理解不了编程语言,哪怕是汇编。
在这种条件下撸代码,可以说门槛是相当高了,不仅要脑力好,眼力也要好。随随便便的小功能,可能就要几千个洞。如果一个不小心,洞打错了,就要从头一行一行看,在无数的1和0里边找bug!所以那会程序员估计真不能996,不然光纸带可能就能把公司写破产。
由于这种工作方式实在太反人类了,并且生产力过于低下,于是人们开始考虑能不能用一些助记符(单词,或人类能懂的字符)来描述程序。于是汇编就出现,Grace Hopper开发了一套叫A-0 system,可以自动地将助记符翻译成机器码。汇编之比于机器码,就好比我们现在看Java之比于汇编。
再后来的事情,大家都知道了,汇编生B,B生C,C生万物。一时间各种编程语言“百家争鸣”。
这是一段C语言的目标文件构成, 中间是机器码,右边是汇编代码
而与此同时,硬件平台、操作系统也悄悄地“百花齐放”。于是,为了兼容不同平台,代码不得不对不同的平台进行判断、编写兼容性代码,然后在对应的平台上重新编译。于是人们又开始思考,如何屏蔽这些系统的差异性,以实现:write once, run anywhere!这个人就是Java之父——詹姆斯•高斯林。
第一代的Java运行机制大概是这样的,定义一个中间语言(现在的字节码文件),将开发者编写的代码先编译转换成中间语言指令描述。再开发一套运行中间语言指令的虚拟机,负责将中间语言指令翻译成不同平台上差异部分的API调用。我们知道,run anywhere可以通过编译器帮我们实现,只要使用不同平台上的编译器编译即可run anywhere(linux很多软件的安装方式就是下载源代码直接编译的)。
而中间语言的出现,使得write once得以实现,开发者无须关注底层硬件平台的差异进行针对性编码,平台差异性,由虚机机帮我们搞定。这就是第一代的Java。在当时,这种思路确实是想想都带感。可是由于中间多了一层转换,大约是将中间代码再翻译成C语言的模板,然后由虚机机来执行。所以在执行中间代码的时候,几乎是属于解释执行,执行效率确实是差强人意。
既然将中间代码翻译成C语言的模板这种方式太慢,干脆激进一点,我们定义好中间语言的指令集(这玩意有200多个指令),然后把这些操作直接翻译成机器码,即01代码。所以这个时候只要突破C到机器码这个边界,就可以达到这个目的。
#include <stdio.h>
// 这是一段定义计算两数和的机器码
const unsigned char code[] = "\x55\x48\x89\xe5\x89\x7d\xfc\x89\x75\xf8\x8b\x45\xfc\x03\x45\xf8\x5d\xc3";
int main(){
int a = 5;
int b = 3;
int (*fun)(int, int);
fun = (void*)code;
int r = fun(a, b);
printf("result = %d\n", r);
return 0;
}
编译运行看下结果:
惊不惊喜,意不意外,那串16进制字符串居然有对两个数做加和运行的能力。
至此,难点突破了,我们可以定义好中间代码层的那200多个中间指令,做成机器码模板,这时候,运行中间指令的这个程序就是虚拟机,而中间指令文件就是字节码。至此,我们就能实现一个简单的Java虚拟机了。其实hotspot虚拟机的实现要远比这个复杂得多,有兴趣的可以去看看openjdk的源码,位置在share/runtime的目录下,主要是javaCalls.cpp。这里就不再过多展开。
0x01 Java虚拟机的内存模型
JVM是如何执行字节码的
在了解了大致的JVM实现方式以后,我们来研究下Java虚拟机具体是如何执行Java字节码的。
首先Java源代码经过一系列词法分析、语法分析、句法分析编译成字节码文件。Java虚拟机通过类装载子系统将字节码文件加载进来,加载后的Java类会被存放在方法区当中。在实际运行过程中,虚拟机会执行方法区内的代码。
当我们使用汇编或C语言的时候,我们通常只关注段式内存管理,也就是只关注堆和栈这两种内存模型即可。但是在Java虚拟机内部,要更加的细化。Java虚拟机将栈细分为面向Java的Java方法栈和面向本地方法(native)的本地方法栈,以及用于存放线程执行位置的程序计数器。由于JVM自己定义了一套指令体系,所以当多个线程调度的时候,需要有个地方记录当前线程运行到指令的哪个位置,程序计数器就是用来做这个事情的。
在运行过程中,每调用一个Java方法,JVM会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量、操作数栈、动态连接、方法出口等信息。当当前方法执行完毕时,不管是正常返回还是异常结束,JVM会弹出当前线程的栈帧,并销毁。这也就解释了为什么局部变量是不会引发线程安全问题。
再往下,JVM解析出来的字节码指令,是JVM自己的规范,在硬件层面是无法被理解和运行的。所以JVM需要将解析到的字节码指令,翻译成对应的机器码。在hotspot里面,这种翻译有两种形式:
解释执行,就是将字节码一条一条地翻译成机器码并执行。
即时编译(JIT),将一个方法中包含的所有字节码翻译成机器码并执行
在hotspot里面,一般是混合模式,即先解释执行字节码,然后统计反复执行的热点代码,时机成熟以后,即时编译会介入,将热点代码进行即时编译,以提高运行速度。hotspot内置了多个即时编译器:C1, C2,Graal。
C1主要面向对于启动性能有要求的程序,如GUI程序,采用的优化手段相对简单,编译时间也较短。
C2是面向对性能有要求的服务端程序,优化手段较复杂,编译时间相对长,但生成的代码执行效率比较高。
Graal是实验性的,暂时不展开讨论。
从Java7以后,hotspot默认采用分层编译的方式,热点方法先会被C1编译,然后热点中的热点又会被C2再进一步编译。
运行时数据区
上图中,灰色框内的部分叫做JVM的运行时数据区,也是JVM管理的内存区域。主要有以下这几个部分:
程序计数器
这是一块较小的内存空间,它是线程私有的,可以看做是当前线程执行字节码的行号指示器,JVM在解释字节码的时候,就是通过改变这个计数器的值来选取下一条待执行指令,通过它就可以完成诸如分支、循环、跳转、异常处理、线程恢复等基础功能。跟CPU里的PC寄存器,其实是一个道理。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OOM情况的区域。
Java虚拟机栈
操作数栈也是一个栈数据结构,是用于辅助对局部变量表内容进行赋值和各种算术运算时作辅助计算。比如:整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
动态连接是一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
在这个内存区域中,如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果Java虚拟机栈容量允许动态扩展,当栈扩展时无法申请到足够的内存空间时,抛出OutOfMemoryError异常。
本地方法栈
与Java虚拟机栈类似,区别只是该区域为虚拟机使用到的本地方法服务。Java内部有不少API使用了native方式。
Java堆
对于Java应用来说,Java堆是虚拟机所管理的内存中最大的一块区域。Java堆是被所有线程所共享的,该区域的唯一目的就是用于存放对象实例。在Java的世界里,几乎所有的对象实例都分配在堆上。在《Java虚拟机规范》中对Java堆的描述是:所有对象实例以及数组都应该在堆上分配。当然,这里仅仅是从规范的角度来讲,其实随着Java语言的发展,由于即时编译器的进步,逃逸分析技术越来越强大,栈上分配、标题替换等优化手段,使得这种说法并不是那么“绝对”。后面我们讲到对象内存分配的时候会针对性地介绍。
Java堆也是垃圾收集器管理的主要内存区域,所以采用不同的垃圾回收策略,会对堆内存做不同的“格式化”操作。体现在分代垃圾回收策略和不分代垃圾回收策略下,内存管理方式不同。这块在后面垃圾回收会着重介绍这一块内容。
如果在Java堆中没有足够的空间完成实例分配,且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
方法区与Java堆一样,是各个线程共享的区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译后的代码缓存等数据。它还有一种说法叫“非堆”(Non-Heap),用于与Java堆区分开来。
如果方法区无法满足新内存分配的需求时,将抛出OutOfMemoryErro异常。
0x2 总结
虚拟机的出现,可以说是里程碑式的,代码在虚拟机中执行,使得移植成本大大降低,一量Java被编译为字节码,便可以在不同平台上的Java虚机上运行。此外,利益于Java虚拟机的自动内存管理,使我们从冗长而繁琐的人工内存管理(malloc/free)中解放出来。
Java虚拟机将内存区域划分成五个部分,分别为:方法区、堆、程序计数器、Java方法栈和本地方法栈。Java程序编译成字节码文件,需要先加载至方法区中,之后才能在虚拟机中运行。