JVM与Java体系结构
Java技术生态体系
Java能获得如此广泛的认可,除了它拥有一门结构严谨、面向对象的编程语言之外,还有许多不可忽视的优点:
- 它摆脱了硬件平台的束缚,实现了
“一次编写,到处运行”
的理想; - 它提供了一种相对安全的
内存管理和访问机制
,避免了绝大部分内存泄漏和指针越界问题; - 它实现了
热点代码检测
和运行时编译及优化
,这使得Java应用能随着运行时间的增长而获得更高的性能; - 它有一套完善的应用程序接口,还有无数来自商业机构和开源社区的第三方类库来帮助用户实现各种各样的功能;
我们可以把Java程序设计语言
、Java虚拟机
、Java类库
这三部分统称为JDK
(Java Development Kit),JDK是用于支持Java程序开发的最小环境,为行文方便,在不产生歧义的地方常以JDK来代指整个Java技术体系。可以把Java类库API中的Java SE API子集
和Java虚拟机
这两部分统称为JRE
(Java Runtime Environment),JRE是支持Java程序运行的标准环境。
随着Java7的正式发布,Java虚拟机的设计者们通过JSR-292规范基本实现了在Java虚拟机平台上运行非Java语言编写的程序。
Java虚拟机根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心
字节码文件
。也就是说Java虚拟机拥有语言无关性,并不会单纯地与Java语言“终身绑定”,只要其他编程语言的编译结果满足并包含Java虚拟机的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。
Java平台上的多语言混合编程
正成为主流,通过特定领域的语言去解决特定领域的问题
是当前软件开发应对日趋复杂的项目需求的一个方向。对这些运行于Java虚拟机之上、Java之外的语言,来自系统级的、底层的支持正在迅速增强,以JSR-292为核心的一系列项目和功能改进(如DaVinci Machine项目、Nashorn引擎、InvokeDynamic指令、java.lang.invoke包等),推动Java虚拟机从”Java语言的虚拟机”向”多语言虚拟机”的方向发展。
Java虚拟机-JVM
虚拟机
所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。
- 大名鼎鼎的VMware就属于
系统虚拟机
,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台
。 程序虚拟机
的典型代表就是Java虚拟机
,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令
。
无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
Java虚拟机
Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的字节码也未必由Java语言编译而成。JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine),因为所有的Java程序都运行在Java虚拟机内部。
Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java字节码指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
JVM所处位置
JVM是运行在操作系统之上的,它与硬件没有直接的交互:
Java的体系结构:
JVM整体架构
- HotSpot VM是目前市面上高性能虚拟机的代表作之一。
- 它采用
解释器与即时编译器并存
的架构。 - 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一较高下的地步。
执行引擎包含三部分:解释器,即时编译器,垃圾回收器,运行时数据区中的方法区和堆是共享资源,Java虚拟机栈、本地方法栈和程序计数器是线程私有的。
Java代码执行流程
只要能生成被Java虚拟机所能解释的字节码文件,那么理论上就可以自己设计一套代码语言,重点是前端编译器的设计。
JVM的架构模型
Java编译器输入的指令流是一种基于栈的指令集架构
,另外一种指令集架构则是基于寄存器的指令集架构。具体来说:这两种架构之间的区别:
基于栈式架构的特点
- 设计和实现更简单,适用于资源受限的系统
- 避开了寄存器的分配难题:使用零地址指令方式分配
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈,
指令集更小,编译器容易实现
不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器架构的特点
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机
指令集架构则完全依赖硬件,可移植性差
性能优秀和执行更高效
- 花费更少的指令去完成一项操作
代码举例
在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。同样执行2+3这种逻辑操作,其指令分别如下:
基于栈的计算流程(以Java虚拟机为例):
1 | iconst_2 # 常量2入栈 |
而基于寄存器的计算流程:
1 | mov eax,2 # 将eax寄存器的值设为2 |
字节码反编译
我们编写一个简单的代码,然后查看一下字节码的反编译后的结果
1 | public class StackStructureTest { |
然后我们找到编译后的 class文件,使用下列命令进行反编译
1 | javap -v .\StackStructureTest.class |
得到的文件为:
由于跨平台性
的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
JVM发展历程
虚拟机始祖-Classic/Exact VM
1996年1月23日,Sun发布JDK 1.0,Java语言首次拥有了商用的正式运行环境,这个JDK中所带的虚拟机就是Classic VM
。这款虚拟机只能使用纯解释器方式
来执行Java代码,如果要使用即时编译器那就必须进行外挂,但是假如外挂了即时编译器的话,即时编译器就会完全接管虚拟机的执行系统,解释器便不能再工作了。在JDK 1.2及之前,用户用Classic虚拟机执行java-version
命令,将会看到类似下面这行的输出:
1 | java version "1.2.2" |
其中的“sunwjit”(Sun Workshop JIT)就是Sun提供的外挂编译器。由于解释器和编译器不能配合工作,这就意味着如果要使用编译执行,编译器就不得不对每一个方法、每一行代码都进行编译,而无论它们执行的频率是否具有编译的价值。基于程序响应时间的压力,这些编译器根本不敢应用编译耗时稍高的优化技术
,因此这个阶段的虚拟机虽然用了即时编译器输出本地代码,其执行效率也和传统的C/C++程序有很大差距,“Java语言很慢”的印象就是在这阶段开始在用户心中树立起来的。
Sun的虚拟机团队努力去解决Classic虚拟机所面临的各种问题,提升运行效率,在JDK 1.2时,曾在Solaris平台上发布过一款名为Exact VM
的虚拟机,它的编译执行系统已经具备现代高性能虚拟机雏形,如热点探测、两级即时编译器、编译器与解释器混合工作模式等。
Exact VM因它使用准确式内存管理
(Exact Memory Management)而得名。准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型
。譬如内存中有一个32bit的整数123456,虚拟机将有能力分辨出它到底是一个指向了123456的内存地址的引用类型还是一个数值为123456的整数,准确分辨出哪些内存是引用类型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。由于使用了准确式内存管理,Exact VM可以抛弃掉以前Classic VM基于句柄(Handle)的对象查找方式
(原因是垃圾收集后对象将可能会被移动位置,如果地址为123456的对象移动到654321,在没有明确信息表明内存中哪些数据是引用类型的前提下,那虚拟机肯定是不敢把内存中所有为123456的值改成654321的,所以要使用句柄来保持引用值的稳定),这样每次定位对象都少了一次间接查找的开销
,显著提升执行性能。
武林盟主-HotSpot VM
HotSpot既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势,如它名称中的HotSpot指的就是它的热点代码探测技术
(这里的描写带有“历史由胜利者书写”的味道,其实HotSpot与Exact虚拟机基本上是同时期的独立产品,HotSpot出现得还稍早一些,一开始HotSpot就是基于准确式内存管理的,而Exact VM之中也有与HotSpot几乎一样的热点探测技术,为了Exact VM和HotSpot VM哪个该成为Sun主要支持的虚拟机,在Sun公司内部还争吵过一场,HotSpot击败Exact并不能算技术上的胜利),HotSpot虚拟机的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码
,然后通知即时编译器以方法为单位进行编译
。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译(On-Stack Replacement,OSR)行为。
[!NOTE]
通过编译器与解释器恰当地协同工作,可以在
最优化的程序响应时间
与最佳的执行性能
中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。
得益于Sun/OracleJDK在Java应用中的统治地位,HotSpot理所当然地成为全世界使用最广泛的Java虚拟机,是虚拟机家族中毫无争议的“武林盟主”。
天下第二-JRockit/J9 VM
JRockit虚拟机曾经号称是“世界上速度最快的Java虚拟机”
(广告词,IBM J9虚拟机也这样宣传过,总体上三大虚拟机的性能是交替上升的),它是BEA在2002年从Appeal Virtual Machines公司收购获得的Java虚拟机。BEA将其发展为一款专门为服务器硬件和服务端应用场景高度优化的虚拟机
,由于专注于服务端应用,它可以不太关注于程序启动速度,因此JRockit内部不包含解释器实现
,全部代码都靠即时编译器编译后执行
。除此之外,JRockit的垃圾收集器和Java Mission Control故障处理套件等部分的实现,在当时众多的Java虚拟机中也处于领先水平。
J9虚拟机最初是由IBM Ottawa实验室的一个SmallTalk虚拟机项目扩展而来,当时这个虚拟机有一个Bug是因为8KB常量值定义错误引起,工程师们花了很长时间终于发现并解决了这个错误,此后这个版本的虚拟机就被称为K8,后来由其扩展而来、支持Java语言的虚拟机就被命名为J9。与BEA JRockit只专注于服务端应用不同,IBM J9虚拟机的市场定位与HotSpot比较接近,它是一款在设计上全面考虑服务端、桌面应用,再到嵌入式的多用途虚拟机
,开发J9的目的是作为IBM公司各种Java产品的执行平台,在和IBM产品(如IBM WebSphere等)搭配以及在IBM AIX和z/OS这些平台上部署Java应用。
IBM J9直至今天仍旧非常活跃,IBM J9虚拟机的职责分离与模块化
做得比HotSpot更优秀,由J9虚拟机中抽象封装出来的核心组件库(包括垃圾收集器、即时编译器、诊断监控子系统等)就单独构成了IBM OMR项目,可以在其他语言平台如Ruby、Python中快速组装成相应的功能。从2016年起,IBM逐步将OMR项目和J9虚拟机进行开源,完全开源后便将它们捐献给了Eclipse基金会管理,并重新命名为Eclipse OMR和OpenJ9。如果为了学习虚拟机技术而去阅读源码,更加模块化的OpenJ9代码其实是比HotSpot更好的选择。
展望Java技术未来
无语言倾向
2018年4月,Oracle Labs新公开了一项黑科技:Graal VM
,从它的口号“Run Programs Faster Anywhere”
就能感觉到一颗蓬勃的野心,这句话显然是与1995年Java刚诞生时的“WriteOnce,Run Anywhere”在遥相呼应。
Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机
,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件
。
Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式(例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation,IR),譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程序特化(Specialized,也常被称为Partial Evaluation)。Graal VM提供了Truffle工具集
来快速构建面向一种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。
新一代即时编译器
对需要长时间运行的应用来说,由于经过充分预热,热点代码会被HotSpot的探测机制准确定位捕获,并将其编译为物理硬件可直接执行的机器码,在这类应用中Java的运行效率很大程度上取决于即时编译器所输出的代码质量。
HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译器
(简称为C1
)以及编译耗时长但输出代码优化质量也更高的服务端编译器
(简称为C2
),通常它们会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统。
自JDK 10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器
,看名字就可以联想到它是来自于前一节提到的Graal VM。Graal编译器是以C2编译器替代者
的身份登场的。C2的历史已经非常长了,可以追溯到Cliff Click大神读博士期间的作品,这个由C++写成的编译器尽管目前依然效果拔群,但已经复杂到连Cliff Click本人都不愿意继续维护的程度。而Graal编译器本身就是由Java语言写成,实现时又刻意与C2采用了同一种名为“Sea-of-Nodes”的高级中间表示(High IR)形式,使其能够更容易借鉴C2的优点。
Graal编译器比C2编译器晚了足足二十年面世,有着极其充沛的后发优势,在保持输出相近质量的编译代码的同时,开发效率和扩展性上都要显著优于C2编译器,这决定了C2编译器中优秀的代码优化技术可以轻易地移植到Graal编译器上,但是反过来Graal编译器中行之有效的优化在C2编译器里实现起来则异常艰难。这种情况下,Graal的编译效果短短几年间迅速追平了C2,甚至某些测试项中开始逐渐反超C2编译器。Graal能够做比C2更加复杂的优化,如“部分逃逸分析”(PartialEscape Analysis),也拥有比C2更容易使用激进预测性优化(Aggressive Speculative Optimization)的策略,支持自定义的预测性假设等。
向Native迈进
对不需要长时间运行的,或者小型化的应用而言,Java(而不是指Java ME)天生就带有一些劣势,这里并不只是指跑个HelloWorld也需要百多兆的JRE之类的问题,更重要的是指近几年在从大型单体应用架构向小型微服务应用架构发展的技术潮流下,Java表现出来的不适应。
在微服务架构的视角下,应用拆分后,单个微服务很可能就不再需要面对数十、数百GB乃至TB的内存,有了高可用的服务集群,也无须追求单个服务要7×24小时不间断地运行,它们随时可以中断和更新;但相应地,Java的启动时间相对较长,需要预热才能达到最高性能等特点就显得相悖于这样的应用场景
。在无服务架构中,矛盾则可能会更加突出,比起服务,一个函数的规模通常会更小,执行时间会更短,当前最热门的无服务运行环境AWS Lambda所允许的最长运行时间仅有15分钟。
一直把软件服务作为重点领域的Java自然不可能对此视而不见,在最新的几个JDK版本的功能清单中,已经陆续推出了跨进程的、可以面向用户程序的类型信息共享
(Application Class Data Sharing,AppCDS,允许把加载解析后的类型信息缓存起来,从而提升下次启动速度
,原本CDS只支持Java标准库,在JDK 10时的AppCDS开始支持用户的程序代码)、无操作的垃圾收集器
(Epsilon,只做内存分配而不做回收的收集器,对于运行完就退出的应用十分合适)等改善措施。而酝酿中的一个更彻底的解决方案,是逐步开始对提前编译(Ahead of Time Compilation,AOT)提供支持。
提前编译是相对于即时编译的概念,提前编译能带来的最大好处是Java虚拟机加载这些已经预编译成二进制库之后就能够直接调用
,而无须再等待即时编译器在运行时将其编译成二进制机器码。理论上,提前编译可以减少即时编译带来的预热时间,减少Java应用长期给人带来的“第一次运行慢”的不良体验,可以放心地进行很多全程序的分析行为,可以使用时间压力更大的优化措施。
但是提前编译的坏处也很明显,它破坏了Java“一次编写,到处运行”的承诺
,必须为每个不同的硬件、操作系统去编译对应的发行包;也显著降低了Java链接过程的动态性
,必须要求加载的代码在编译期就是全部已知的,而不能在运行期才确定,否则就只能舍弃掉已经提前编译好的版本,退回到原来的即时编译执行状态。
早在JDK 9时期,Java就提供了实验性的Jaotc命令来进行提前编译,不过多数人试用过后都颇感失望,大家原本期望的是类似于Excelsior JET那样的编译过后能生成本地代码完全脱离Java虚拟机运行的解决方案,但Jaotc其实仅仅是代替即时编译的一部分作用而已,仍需要运行于HotSpot之上。
[!NOTE]
补充内容:
直到Substrate VM出现,才算是满足了人们心中对Java提前编译的全部期待。Substrate VM是在Graal VM 0.20版本里新出现的一个极小型的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理(垃圾收集)和JNI访问等组件,
目标是代替HotSpot用来支持提前编译后的程序执行
。它还包含了一个本地镜像的构造器(Native Image Generator),用于为用户程序建立基于Substrate VM的本地运行时镜像。这个构造器采用指针分析(Points-To Analysis)技术,从用户提供的程序入口出发,搜索所有可达的代码。在搜索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。这样一来,Substrate VM就可以直接从目标程序开始运行,而无须重复进行Java虚拟机的初始化过程。但相应地,原理上也决定了Substrate VM必须要求目标程序是完全封闭的,即不能动态加载其他编译器不可知的代码和类库。基于这个假设,Substrate VM才能探索整个编译空间,并通过静态分析推算出所有虚方法调用的目标方法。
Substrate VM带来的好处是能显著降低内存占用及启动时间
,由于HotSpot本身就会有一定的内存消耗(通常约几十MB),这对最低也从几GB内存起步的大型单体应用来说并不算什么,但在微服务下就是一笔不可忽视的成本。根据Oracle官方给出的测试数据,运行在Substrate VM上的小规模应用,其内存占用和启动时间与运行在HotSpot上相比有5倍到50倍的下降。
Substrate VM补全了Graal VM“Run Programs Faster Anywhere”愿景蓝图里的最后一块拼图,让Graal VM支持其他语言时不会有重量级的运行负担
。譬如运行JavaScript代码,Node.js的V8引擎执行效率非常高,但即使是最简单的HelloWorld,它也要使用约20MB的内存,而运行在Substrate VM上的Graal.js,跑一个HelloWorld则只需要4.2MB内存,且运行速度与V8持平。Substrate VM的轻量特性,使得它十分适合嵌入其他系统,譬如Oracle自家的数据库就已经开始使用这种方式支持用不同的语言代替PL/SQL来编写存储过程。
由于AOT编译没有运行时的监控信息,很多由运行信息统计进行向导的优化措施不能使用,所以尽管没有编译时间的压力,效果也不一定就比JIT更好。
语言语法持续增强
笔者将语言的功能特性和语法放到最后来讲,因为它是相对最不重要的改进点,毕竟连JavaScript这种“反人类”的语法都能获得如此巨大的成功,而比Java语法先进优雅得多的挑战者C#现在已经“江湖日下”,成了末路英雄。
但一门语言的功能、语法又是影响语言生产力和效率的重要因素,很多语言特性和语法糖不论有没有,程序也照样能写,但即使只是可有可无的语法糖,也是直接影响语言使用者的幸福感程度的关键指标
。JDK 7的Coins项目结束以后,Java社区又创建了另外一个新的语言特性改进项目Amber,JDK10至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 305:Pattern Matching for instanceof,用instanceof判断过的类型,在条件分支里面可以不需要做强类型转换就能直接使用。
除语法糖以外,语言的功能也在持续改进之中,以下几个项目是目前比较明确的,也是受到较多关注的功能改进计划:
- Project Loom:现在的Java做并发处理的最小调度单位是线程,Java线程的调度是直接由操作系统内核提供的,会有内核态、用户态的切换开销。而很多其他语言都提供了
更加轻量级的、由软件自身进行调度的用户线程
(曾经非常早期的Java也有绿色线程),譬如Golang的Groutine、D语言的Fiber等。Loom项目就准备提供一套与目前Thread类API非常接近的Fiber实现。 - Project Valhalla:提供值类型和基本类型的泛型支持,并提供明确的不可变类型和非引用类型的声明。不可变类型在并发编程中能带来很多好处,没有数据竞争风险带来了更好的性能。一些语言(如Scala)就有明确的不可变类型声明,而Java中只能
在定义类时将全部字段声明为final来间接实现
。基本类型的泛型支持是指在泛型中引用基本数据类型不需要自动装箱和拆箱,避免性能损耗。 - Project Panama:目的是
消弭Java虚拟机与本地代码之间的界线
。现在Java代码可以通过JNI来调用本地代码,这点在与硬件交互频繁的场合尤其常用(譬如Android)。但是JNI的调用方式充其量只能说是达到能用的标准而已,使用起来仍相当烦琐,频繁执行的性能开销也非常高昂,Panama项目的目标就是提供更好的方式让Java代码与本地代码进行调用和传输数据。
随着Java每半年更新一次的节奏,新版本的Java中会出现越来越多其他语言里已有的优秀特性,相信博采众长的Java,还能继续保持现在的勃勃生机相当长时间。