GraalVM是最近几年Java相关的新技术领域不多的亮点之一, 被称之为革命性的下一代JDK,那么它究竟有什么神奇之处,又为当前的Java开发带来了一些什么样的改变呢,让我们来详细了解下
下一代的JDK
官网对GraalVM的介绍是 “GraalVM 是一个高性能 JDK,可提高基于 Java 和 JVM 的应用的性能并简化 Java 云原生服务的构建和运行。它提供优化的编译器,可以更快地生成代码并降低计算资源消耗,实现微服务即时启动“
这里面有几个关键词:高性能,云原生,低资源,即时启动。而这几个关键词其实恰恰也是当前Java应用开发面临的困境。大家都知道当前不管是企业服务还是互联网领域都在强调云原生开发,但云原生的一个核心要素就是需要资源和服务的快速调度,毕竟服务器和带宽都是要钱的,用户只想在使用服务时付费,而不是长时间让应用空转。比如目前流行的serverless模式,要求函数的生命周期尽可能的短,意味着频繁的启动和销毁,这对于服务的启动性能,资源消耗,服务包大小都有比较高的要求。但对于当前的JAVA应用来说却是无可承受的痛。所以为啥现在go,rust这些语言大行其道,其实就是迎合了云原生的需求场景。Java作为一个拥有20多年历史的语言,当然不可能坐以待毙,所以也在不断的探索怎么提升启动时间,减少资源消耗,GraalVM应该算是目前这些探索当中最靠谱,最成熟的一个。
如果GraalVM真的能解决以上的问题,那称其为革命性的下一代JDK或者说是云原生时代Java的希望之星也不为过吧。目前包括Spring Boot, Micronaut, Quarkus 这些知名框架也都对GraalVM提供了原生的支持。
那GraalVM究竟是如何做到这一切的呢?我们先来看一下GraalVM的架构图
从图上可以看出来,其实GraalVM核心是由两个部分构成的。其中之一是一个全新的名为Graal的编译器,它是完全用Java语言编写的,就是图中红色的部分,这也是GraalVM的名字的来源,可想而知这个编译器的重要性。
另外一个重要的组成部分就是用于构建高性能动态语言解释器的框架叫做 Truffle,就是最上面的这一层,它也是GraalVM能够支持多种编程语言运行在其上的基础框架,它现在支持的语言有很多呀,包括pyhton,js这些常见的脚本语言,借助于LLVM编译器,甚至可以让C或者C++都跑在GraalVM之上,这在以前是很难想象的。
最底层就是我们非常熟悉的 Hotspot JVM,这部分GraalVM 并没有对Hotspot JVM做大的调整,包括垃圾回收器,类加载器,线程管理,诊断和监控等特性都是直接复用Hotspot JVM的功能,这也保证了GraalVM能够与现有的JAVA生态保持完全的兼容,只是专注于编译优化和性能提升
Graal编译器
Graal实际上是一个用Java语言编写的JIT编译器。它的主要作用就是替换原本Hotspot JVM中用C++写的另外两个JIT编译器:C1和C2。C1主要是用client模式下,我们应该接触的比较少,C2是Hotspot JVM自带的高性能JIT编译器,当年Hotspot JVM出现时,正是凭借基于热点探测的JIT技术在性能上吊打其它的一众JVM,成为JAVA领域这么多年来事实的标准,它的名字(HOTSPOT)也正好反应了它的这个重要特性。
为啥还需要另一个JIT编译器
既然有了C2这样强大的JIT编译器,为啥还要重新开发一个呢?而且还是用Java语言来开发,Java的性能能比C++高吗?其实重写这个编译器还真不是为了优化性能,C2的性能已经非常好了,问题的关键在于C2是C++写的,而且还写的很复杂,年代也很久远,这让C2项目变得非常难以维护和扩展。
我们来看看来自C2编译器背后大佬Cliff Click的抱怨:
于是用JAVA这样一种更容易维护,安全性也要比C++高很多的语言来重写编译器也就不是 一件不可想象的事情了。至于性能,Graal毕竟比C2晚了快20年,后发优势是非常明显的,可以做到一些原来在C2中很难做到的编译优化,比如部分逃逸分析,经过测试目前Graal编译器的峰值性能已经追平甚至部分超越了C2。
另外,为了让类似Graal这样的编译器也能够有效的和底层的Hotspot VM配合工作,JAVA社区还提出了一个名为JVMCI(JAVA虚拟机编译器接口) 的提案,本质上就是将JIT编译器和底层的虚拟机解耦,提供更强的扩展性。
Java的自举
用Java编写Java的编译器,这也带来了一个里程碑式的变化,Java终于能够自举了!我们来撸一撸其中的过程:
首先,我们用java写的源代码会被javac工具编译成平台无关的字节码,这个javac工具就是完全用java语言实现的。然后字节码在运行时通过jvm的 解释器或者JIT 编译器编译成本地代码,这个解释器和JIT编译器也是完全用java语言编写的,这样就完成了闭环,这使得JAVA能够完全摆脱对其它语言的依赖,从而成为一门完全自主的语言。
静态编译
前面的Graal编译器虽然意义重大,但依然只是一个JIT编译器,Java应用的编译执行方式和原来也没有多大的区别,如果仅仅是这样那显然GraalVM也不会有这么大的影响力。我们再来看一个真正可称之为GraalVM的杀手锏的功能—静态编译
JIT与AOT
我们都知道,Java为了实现其平台无关性,首次编译(javac)的产出物其实是平台无关的字节码,这些字节码是无法直接被执行的。最早的Java Runtime都是通过JVM内置的解释器直接将字节码即时转换成本地代码来执行的,这样解释执行的方式肯定慢呀,所以当HOTSPOT JVM首次引入了JIT—即时编译或者叫做动态编译这样的概念以后,JAVA才真正确定了自己在业界的地位,也让HOTSPOT JVM一直作为官方虚拟机延续到了现在。
– JIT(Just-in-Time) 简单的说,就是在运行时,把一部分热点代码,就是反复会调用的方法直接编译成本地代码,从而提升程序的运行效率。大家可能会有这样的疑问,为啥只编译热点代码呢,全部都编译成本地代码不是更快吗?因为编译很慢呀,开销也很大,如果编译消耗的资源都大于整个程序消耗的资源了,那么这么做就是本末倒置了。所以JIT就是一种折中的做法,把频繁运行的方法编译成本地代码提升执行效率,其它偶尔才运行的代码还是通过解释器去执行,这样能够最大程度的平衡启动时间,资源消耗和性能。
– 与之相对的就是静态编译,也叫做AOT(Ahead-of-Time) 编译。这个名词最早被大家听说应该是在android领域,在安卓5.0版本,当时谷歌引入了ART的新的运行时环境,并推出了AOT编译模式,故名思意AOT编译是在应用安装时一次性把字节码编译成机器码,而不是像JIT那样切香肠式的编译,这样能大幅提升应用的启动速度。但早期的AOT编译会造成应用安装时间过长,空间占用较多的问题,后来谷歌又改成AOT和JIT混合编译的策略了。说回JAVA哈,安卓和JAVA本就是同源的,安卓可以AOT编译,JAVA当然也可以,最早在JAVA 9.0 , 就引入了这方面的概念和支持,这个版本有一个AOT编译器叫做jaotc(对应原来的javac),但因为各种原因一直没得到多少重视。
我们可以通过一张图来比较一下两种编译方式各自的特点:
一般来说,AOT在启动速度,内存占用,包体积等方面占优势,但编译过程较慢;而JIT编译很快,二期因为可以基于运行时的信息做更激进的编译优化,因此在峰值吞吐量和响应延迟这些方面可能会更有优势。
Native Image
AOT和JIT应该说是各有所长,不存在谁取代谁的问题。但在云原生,微服务架构大行其道的当下,大家更看重的还是应用的启动速度和资源占用等特点,所以静态编译又变成了一个非常有诱惑力的选型。GraalVM也提供了自己的java静态编译工具,也就是 Native Image 。这个工具可以直接将Java应用程序编译成可本地执行的文件,脱离虚拟机直接运行。它背后需要解决三方面的问题:
编什么
源代码肯定要编译,但是依赖呢?是不是所有依赖都要编译。理论上所有依赖都编译肯定是没有问题的,而且后面反射的问题也顺带就解决了。但现实场景这基本不可能,我们现在的应用程序依赖链一般都很深,如果把classpath上能扫描到的jar包都给编译了,那估计真的要等到天荒地老了。所以这一步是走不通的。
Native Image采用的方法是构建一个从应用程序的入口函数,也就是main方法开始,到所有可达代码的这样一个调用图。这个调用图可以认为是应用程序在实际执行时所有可能的执行路径之和,只要构造了这样的图,也就知道了哪些方法最终是可能被执行的,这些方法也就需要被编译。
如何适配JVM运行时
JVM运行时可不单是提供JIT的支持,垃圾回收,反射,动态代理这些所谓的运行时特性也需要在编译成静态本地代码时解决。这个的解决方案有点简单粗暴,就是直接在编译后的执行文件中塞一个精简后的JVM— SVM(Substrate VM) ,它包括了一组轻量级的运行时库和基础设施,提供包括内存管理、垃圾回收、线程管理等一系列基础特性。
除了这些,还要解决反射调用,JNI调用这类动态调用的问题。刚才也提到过,Native Image编译的时候会做静态分析,从主函数入口开始遍历所有的可达的方法作为整个编译范围,但是这样的扫描有一个缺陷,就是无法确定那些只有在运行时才会获取到的动态调用信息,也就不会将动态调用的目标加入到编译范围,这样在运行时肯定就报错了。而且现在诸如spring这样的框架,大量使用了反射调用,如果置之不理,那么常规的应用直接编译肯定都无法正常使用的。
Native Image的解决方式是,开发者主动以配置文件的方式告诉编译器哪些方法是动态调用,这样编译器就能提前将其加入到编译范围中,避免运行时出现问题。当然这个配置文件手搓肯定不现实,就算自己写的代码可以统计,那么第三方库里面这么多动态调用也是无法统计的。所以还有一个配套的小工具:native-image-agent,应用程序可以先以jar包的方式进行预执行,并配置这个代理。这个代理程序会收集应用程序在执行期间所触发的所有动态调用信息,并自动生成配置文件。
$JAVA_HOME/bin/java -agentlib:native-image-agent=config-output-dir=/path/to/config-dir/,config-write-period-secs=300,config-write-initial-delay-secs=5 ...
执行之后就会生成类似下面这样的配置文件:
有了这个配置文件以后,Native Image就会在编译时将反射的目标对象也加入到编译范围当中,并同时填充ReflectionData结构中,这个ReflectionData可以简单理解为反射对象的数据缓存,如果是在JVM中执行反射,第一次会先执行一次JNI调用,通过本地方法获取反射目标,再加入到ReflectionData缓存中,后续调用直接从缓存中读取目标对象信息,所以首次调用的开销要远高于后续的调用。而SVM在静态编译时就可以根据配置信息填充这个ReflectionData,这样反射调用就会比在JVM中要高效的多,这也是静态编译的一个优势。最后就是执行阶段,应用只需要简单的查询ReflectionData是否有相关目标信息,没有就直接报错,有才会调用。 整个反射的实现原理大概就是这样:
怎么编
最后再来看一下如何进行编译。编译器其实还是之前提到过的那个Graal编译器,但静态编译和JIT编译相比还是存在一些局限性,影响最大的就是无法获取运行时信息进行优化,比如如果基于运行时数据,JIT编译器发现某个IF判断几乎只执行其中一个分支,而另一个分支很少执行,那么JIT编译器在生成机器指令的时候就可以在IF判断前就准备好其中高频分支的相关数据,让后续代码能够快速执行,这就是所谓的分支预测。但静态编译因为不存在运行时数据,所以也就做不了相应的优化了,这里我列了一些静态编译和动态编译在编译期优化时的区别:
总结
GraalVM提供了Java应用程序运行的另一种方式,这将带来深远的影响,在云原生时代JAVA语言也终于有了自己的一席之地了。它主要有以下几个方面的好处:
-
即时启动: 这个效果非常明显,基本就是所谓的秒起,和原来动辄等待十几二十秒简直是天壤之别。
-
节约内存 : 经过实测空载内存差不多能够减少到原来的五分之一左右,虽然仍然比同一规模的go,rust等应用占用内存要多,但已经很惊艳了。
-
安全性 : 这个可能是很多同学没想到的,这要从两个方面来分析:首先是静态编译仅编译实际应用需要的类和方法,这也意味着你即使引用了一个可能有安全漏洞的包,只要你实际运行的代码里面没有调用它有漏洞的方法,那就不会被攻击,相当于减少了被攻击的几率。另外Java之前反编译的风险是非常大的,因为编译产物是与源码结构类似的字节码,除非用一些比较复杂的方式来做编译期混淆,例如我们现在用的三方混淆插件,有多难用大家应该深有体会了。而静态编译后生成的是本地代码,不说完全解决了反编译的风险,至少安全性也是大大的提升了
但是静态编译如果要大规模应用到生产环境,目前还是存在一些局限性的,主要有下面一些:
-
编译繁琐 GraalVM支持反射需要依赖于独立的配置,目前虽然可以通过native-image-agent的方式来自动生成配置文件,但这个依赖于预执行的路径覆盖,如果不能在预执行阶段做到覆盖主要的执行分支,那么有可能生成出来的反射配置文件也是不完整的。这个建议如果要在产品中使用的话,一定要为代码编写对应的单元测试用例,并尽可能覆盖到主要的业务分支,便于agent收集运行时数据进行编译。
-
调试工具的缺乏 编译成可执行文件之后,原来JVM配套的那些调试和监控工具可能就用不了了,包括我们熟知的jstack,jmap,不过Graalvm也提供了一些替代的方式帮助我们对native应用进行观测和调试,这部分大家可以下来自己看一下,这里就不展开了
-
GC策略支持有限 目前版本的GraalVM仅支持Serial GC和G1两种垃圾回收器,而且社区版本还不支持G1,只有企业版能用。Serial GC就是串行回收,是Java比较早期的垃圾回收策略,虽然GraalVM中对其进行了优化,但是也仅推荐用于堆比较小的应用程序,不太适合大型应用。这个只能寄希望于Oracle后续能够将G1也下放给社区版了,大家显然也不太可能去购买企业版本。不过好消息是Oracle前不久刚刚出了一个GraalVM的闭源免费版本(Oracle GraalVM 区别于开源版本的 GraalVM CE ),特性完全和企业版是一样的,免费使用,只是不开源,有兴趣的同学可以去研究下它的license。
欢迎关注我的公号—飞空之羽的技术手札,有深度的技术好文~