
1.3 初识Java静态编译技术
虽然冷启动问题在传统Java的框架内无法被彻底解决,但并不意味着使用Java语言就只能选择忍受冷启动问题,也不是说为了解决冷启动问题就只能放弃Java语言而转投诸如Go等不存在冷启动问题的语言。近年兴起的Java静态编译技术就彻底解决了Java语言的冷启动问题,还因为打破了Java语言与本地代码(native code)之间的界限,为Java世界解锁了更多的特性。
1.3.1 什么是Java静态编译
Java静态编译是指将Java程序的字节码在单独的离线阶段编译为汇编代码,其输入为Java的字节码,输出为native image,即二进制native程序。“静态”是相对传统Java程序的动态性而言的,因为传统Java程序是在运行时动态地解释执行和实时编译,所以静态编译需要在执行前就完成程序的编译。
静态编译的基本原则是封闭性假设(closed world assumption),要求编译器在编译时必须掌握运行时所需的全部信息,换句话说,就是运行时不能出现任何编译时未知的内容。这是因为应用程序的可达范围在静态编译时被限定了,因为没有了类加载器、解释器等组件,不能在运行时解析和执行任何动态引入的类。
Java静态编译执行模型和传统执行模型的对比如图1-5所示。

图1-5 Java静态编译与传统Java执行模型对比图
首先将Java源码用javac编译为字节码表示的class文件,无论传统的执行方式还是静态编译都需要以字节码作为输入。从字节码开始,传统的Java执行模型就会按图1-5中的右上部分进行,直接在JVM中执行Java的字节码,由面向不同平台的JVM负责与操作系统的具体交互过程。而静态编译执行模型则按图1-5的右下部分进行,该部分增加了编译阶段,先由静态编译器将应用程序字节码以及运行时支持代码编译为平台相关的本地二进制可执行文件,然后执行。
使用本书要介绍的主角GraalVM静态编译后得到的本地文件被称为native image,它是一个自举的可执行文件。“自举”是指执行native image时除了操作系统的库文件之外,不需要其他任何库文件和运行时的支持,因为native image中已经包含了应用程序、依赖库程序及运行时支持程序(如多线程支持、GC等)。由于native image在执行时会直接与操作系统交互,因此是与平台相关的。
静态编译后的native image最突出的特点就是摒弃了JVM,这是成就它所有优点的根本原因。
1.3.2 静态编译的优势
与传统Java运行模型相比,静态编译运行模型有两大特点。
一是执行的程序是与平台相关的经过编译优化的本地代码。执行本地代码不再需要经过解释执行和JIT编译,既避免了解释执行的低效,也避免了JIT编译的CPU开销,还解决了传统Java执行模型中无法充分预热,始终存在解释执行的问题,因此可以保证应用程序始终以稳定的性能执行,不会出现如图1-2所示的性能波动。
二是静态编译后的可执行程序自包含了轻量级运行时支持,不再额外需要JVM的支持。没有了JVM,自然也就消除了图1-1中第一个阶段VM初始化的开销,使得应用程序可以实现“启动即峰值”的特点。另外,因为JVM的运行也需要消耗一部分内存,去掉JVM后应用程序的内存占用也大幅降低。
这两个基本特点解决了Java程序冷启动问题——JVM初始化的开销和从解释执行到JIT编译执行的开销,因此静态编译后的Java程序可以获得极速启动的效果。
我们用GraalVM静态编译1.2节的greeting-service应用,将得到的二进制可执行文件部署到阿里云函数计算平台(部署方法参见第13章),然后与1.2节的函数调用耗时对比得到图1-6。其中,实心柱体是图1-4中的传统Java程序的函数执行时间,斜线柱体代表静态编译版本的函数执行时间。纵坐标经过对数变形处理,以便将差异巨大的数值展示在同一图中。
从图1-6中可以看到,greeting-service的静态编译版本已经不再受冷启动的影响,其首次请求的响应时间(4.27ms)相比传统JDK方式有百倍提升,而静态编译版本的第二次请求的响应时间则降到了2ms,达到了传统Java版本经过充分预热后的峰值性能,可见静态编译为greeting-service的首次启动在速度上带来了两个数量级的提升。如此具有革命性的突破为Java语言带来了更多的优势。

图1-6 greeting-service在Serverless场景下的函数执行时间(纵坐标已做取对数处理)
- 解决冷启动问题,实现应用程序的极速启动,因此不再需要预热,降低了用户维持应用热度的成本。
- 实现程序自举,无须JVM,降低了应用程序自身所需的内存。
- 打破了Java程序与本地代码之间的边界,JNI调用的开销减少。
- Java程序可以被静态编译为本地共享库文件,然后被其他native程序(C/C++程序)直接调用,这就意味着可以用Java语言编写C程序的库文件。
虽然静态编译技术有以上诸多优点,但是任何技术都难以兼得鱼与熊掌,只能在其中权衡取舍。传统Java语言模型在程序执行的动态性和静态性之间选择了动态性,获得了程序运行时的灵活性和可移植性;而静态编译技术选择了静态性,在获得以上各优势的同时也不可避免地带来了局限性。
1.3.3 静态编译的局限性
1. 封闭性
对于C/C++等静态语言而言,封闭性假设似乎是天经地义的,但是对Java语言则未必。Java程序中存在很多无法静态确认,而只有在运行时才能确定的内容,最典型例子就是反射。
java.lang.Class.forName(someClassName);
上边这个简单的forName反射调用会从默认的classloader里找到并返回由someClass-Name变量指定的类。但是someClassName中到底是什么内容呢?当程序执行到这个反射调用时,我们会很容易知道答案。但是在尚未运行程序时,仅静态地分析代码则很难得到答案。比如当someClassName是当前类的域(field)变量时,需要全局地分析所有对该域的可能写操作,而有些写操作的数据源可能会依赖运行时的输入,那么在静态时就无法分析出someClassName到底是什么。事实上反射分析一直以来都是软件工程领域的一项研究难点。
我们可以说这种反射调用是不满足封闭性假设的,但是否所有的反射调用都不符合该假设呢?那也未必,还是以forName反射为例,如果someClassName是一个字符串常量如"a.b.C",那么编译器在编译时即可确定反射的目标类是a.b.C。此时可以认为该反射调用是满足封闭性假设的。
由此可见,虽然反射会违反封闭性假设,但是在一定条件下可以实现从违反假设到满足假设的转换,这也是静态编译能够适用于一般Java程序的理论基础。违反了封闭性假设的Java动态特性有:
- 动态类加载;
- 反射;
- 动态代理,将对原始方法的访问在运行时代理到动态生成的代理类中;
- JCA(Java Cryptography Architecture),Java的加密机制依赖反射,所以也违反了封闭性;
- JNI,从本地函数中以JNI方式调用和访问Java中的类、变量和方法等;
- 序列化,将内存中的对象内容转换为字节流,用于数据交换。
当Java程序中使用到以上动态特性时,静态编译是不能直接支持的,而需要通过额外的适配工作予以解决。但适配无法覆盖所有可能性,因此这种支持也是有限的。
2. 平台相关性
另一个局限是静态编译后的程序是平台相关的,不再具有Java程序平台无关的特性。但是从云原生Serverless应用的现实需求角度来看,Java的平台无关特性已不再重要。
在提出“平台无关”概念的年代,面向服务端的Java应用要部署在各个企业和厂商自己的服务器上,目标机器可能是Windows服务器,也有可能是Linux服务器,考虑到硬件CPU的差异,环境就更加复杂了。面向客户端的Java应用会部署在终端用户的个人电脑上,包括Windows、Linux、Mac系统,还有移动端,甚至嵌入式版本。开发人员面临了异常纷繁复杂的部署平台场景,同一套业务逻辑往往需要开发多个版本以用于不同的终端。Java的平台无关特性使得将一份代码部署到多个平台运行变为可能,把开发人员从繁重的多平台适配工作中解放出来。
时至今日,虽然平台无关特性在很多场景下依然有着旺盛的需求,但是从云原生Serverless应用的场景下看已经不再重要了。由于将应用程序部署到物理硬件的工作已由云服务提供商接管,应用程序的开发人员不需要关心具体的部署事宜。从开发人员的角度来看,云服务就是一个屏蔽了各种平台差异的巨大虚拟机,自己只需要将程序的字节码部署到云上即可,而云服务提供商也不会直接把应用程序直接部署到物理硬件上去,而是将其部署在完全自主可控的虚拟机平台上。虚拟机平台的虚拟硬件系统的组成越单调,技术实现就越容易,维护成本就越低,因此云服务提供商会倾向于单调的平台系统,导致云服务提供商对平台无关性的需求在降低。
3. 生态变化
第三个局限是面向传统Java程序的调试、监控、Agent扩展等功能不再适用,因为运行时执行的是本地程序,而不再是Java程序。比如在Java程序运行时监控方面,JVM状态监控工具jstat、Java程序内存使用状况导出工具jmap、Java线程状态查看工具jstack等都不再适用;Java的Agent机制也不再适用;甚至连代码调试方式也不再相同,从原先相对简便的IDE调试变成了相对复杂的GDB(GNU project debugger)汇编调试。一方面因为传统Java的调试、监控、Agent扩展等能力都是由JVM提供的,静态编译去掉了JVM,所以这些Java开发人员熟悉的调试和监控的工具都不再适用。另一方面因为静态编译时不再有字节码,这些工具也失去了可工作的对象。
可以说,Java静态编译除了写代码的环境没变,其他的生态都完全不同了。在静态编译的环境下,开发人员以往基于Java应用积累起的开发、监控和调试这一整套工具生态都发生了变化,这是其推广使用的一大限制和阻碍。