GraalVM

简介

什么是冷启动?

所谓冷启动问题是指 Java 应用并不是即起即用的,而需要经过虚拟机初始化后才能达到可用状态,再经过程序预热才能达到最佳性能。图 1 给出了 Java 程序的运行时性能随运行时间(实际上是代码重复执行次数)的变化

  • 横坐标 :程序运行时间,时间越长代表程序中代码被重复执行的次数越多;
  • 纵坐标 :程序的响应时间,响应越快代表运行时性能越好。

可以看出程序响应能力分成了四个部分:第一个阶段为无穷大,因为程序启动时需要首先初始化 Java 虚拟机,然后初始化应用程序,在这个阶段应用是不会有响应的。随后经过解释执行、C1 实时编译和 C2 实时编译,应用的响应时间才从高、中到了低,最终进入稳定执行阶段。前三个阶段就是冷启动,也可以看作程序预热,最后一个阶段为稳定执行,此时的程序运行时性能最好。

在传统的单机或者服务器部署的场景中,冷启动问题并不明显,一来是应用执行时间足够长,冷启动问题就被淡化了;二来人们还可以提前将服务预热准备好,以最好的状态迎接用户的服务请求。

但是在云原生 Serverless 应用的场景中,首次请求必须经过无响应阶段,才会落在响应时间高的为位置,后续请求也会落在高的阶段,只有经过足够多的请求后才会逐渐落入稳定阶段。冷启动问题使得 Java 在 Serverless 场景下无法与 Node.js、Go 等具有快速启动优势的的语言的竞争中,落于下风。

作者:JavaGuide
链接:https://www.zhihu.com/question/274042223/answer/2313038048
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Java 程序的运行生命周期是:首先启动 JVM,执行各种 VM 的初始化动作;然后调用 Java 程序的主函数进入应用初始化,此时才会开始通过解释执行方式运行 Java 代码,随着 Java 代码运行而同时开始的还有 GC,JIT 会在出现热点函数时才开始;当程序初始化完成后,开始执行应用程序的业务代码,此时才算进入了程序执行的预热阶段,这个阶段会有大量的类加载和 JIT 编译行为;当程序被充分预热后,就进入了运行时性能最好的稳定阶段,此时的理想状态是只有应用本身和 GC 在运行,其他的行为都已渐渐退出;最后是关闭应用,各个行为次第结束。

释型语言,因为 Java 源代码并非被先编译为与机器平台相关的汇编代码再执行,而是先编译为与平台无关的字节码(bytecode),然后由 JVM 解释执行。

解释执行是由 JVM 将字节码逐条翻译为汇编代码,然后执行的过程。经过解释的代码缺少编译优化,因此运行时性能较低。不过解释执行非常灵活,可以支持诸如动态类加载这样的动态特性。Java 可以在运行时解释执行一段在编译时尚不存在的代码,这种特性对于编译执行类型的语言来说是难以想象的。

为了解决运行时性能低的问题,Java 引入了实时编译技术(JIT,Just In time),在运行时将热点函数编译为汇编代码,当程序再次运行到经过实时编译的函数时,就可以执行经过编译和优化的汇编代码,而不再需要解释执行了。由于编译是在运行时进行的,因此 JIT 编译器可以获得代码实际运行的路径、热点和变量值等信息,基于此可以做出非常激进的编译优化,从而获得执行效率更高的代码。

较少,但是编译所消耗资源也较少;后者编译得到的代码性能最好,但是编译消耗的资源也较多。

现在的 Java 程序基本都是采用解释执行加 JIT 执行的混合模式,当函数执行次数较少时解释执行,而当函数的执行次数超过一定阈值后再 JIT 执行,从而实现了热点函数 JIT 执行、非热点函数解释执行的效果。

不过既然 JIT 带来了非常显著的性能优势,为什么不全部采用 JIT 方式呢?因为编译优化本身是需要占用系统资源的资源密集型运算,它会影响应用程序的运行时性能,在实践中甚至出现过 JIT 线程占用过多资源,导致应用程序不能执行的状况。此外,如果代码执行的次数较少,编译优化代码造成的性能损失可能会大于编译执行带来的性能提升。

所以冷启动问题的原因有两点:一是 Java 的虚拟机模型机制,二是从解释执行到 JIT 执行的分层次执行模型。这两点在当前的 Java 模型下是无法更改的,它们都是 Java 运行时的基石。

如何解决冷启动问题

作者:JavaGuide
链接:https://www.zhihu.com/question/274042223/answer/2313038048
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

但这个问题并不是无解,我们可以换个角度思路思考。Java 虚拟机的主要作用是提供跨平台能力,以支持与平台无关的 Java 字节码可以在不同的操作系统中运行。解释执行、JIT 执行等问题都是由此衍生而来的。如果我们并不需要跨平台能力,是不是可以将 Java 程序直接编译为目标平台的机器码,然后提供必要的运行时支持,让它以操作系统原生程序的形式运行呢?如此一来就彻底解决了冷启动问题。

答案是肯定的,这就是Java 的静态编译技术。

Java 静态编译是指将 Java 程序的字节码在单独的离线阶段编译为汇编代码,其输入为 Java 的字节码,输出为操作系统本地原生程序。“静态”是相对传统 Java 程序的动态性而言的,因为传统 Java 程序是在运行时动态地解释执行和 JIT 编译,而静态编译需要在执行前就静态地完成程序的编译。目前由 Oracle 开发的高性能跨语言运行时框架开源项目 GraalVM 中就提供了 Java 静态编译所需的编译工具链、编译框架、编译器和运行时等全套支持,并且已经达到了生产可用的程度。

GraalVM 的静态编译的基本原则是封闭性假设(closed world assumption),要求编译器在编译时必须掌握运行时所需的全部信息,换句话说,就是运行时不能出现任何编译时未知的内容。这是因为应用程序的可达范围在静态编译时被限定了,因为没有了类加载器、解释器等组件,不能在运行时解析和执行任何动态引入的类。

与传统 Java 运行模型相比,GraalVM 的静态编译运行模型有两大特点:

一是静态编译后的可执行程序已经是本地程序,而且自包含了轻量级运行时支持,因此不再额外需要 Java 虚拟机。没有了 JVM,自然也就消除了图 1 中的响应时间无穷大阶段,使得应用程序达到即起即用的状态。另外,因为 JVM 的运行也需要消耗一部分内存,去掉 JVM 后应用程序的内存占用也大幅降低。

二是静态编译后的程序也经过了众多的编译优化,运行时不再需要经过解释执行和 JIT 编译,既避免了解释执行的低效,也避免了 JIT 编译的 CPU 开销,还解决了传统 Java 执行模型中无法充分预热,始终存在解释执行的问题,因此可以保证应用程序始终以稳定的性能执行,不会出现性能波动。

最终虚拟机?

大部分脚本语言或者有动态特效的语言都需要一个语言虚拟机运行,比如CPython,Lua,Erlang,Java,Ruby,R,JS,PHP,Perl,APL等等,但是这些语言的虚拟机水平,对,就是具体的实现,差距很大,比如CPython的VM就不忍直视,JVM的HotSpotVM,C#的CLR和JS的v8却是state of the art级别,那么能不能付出较小努力,用一个state of the art的虚拟机,来运行这些语言,让它们享受该虚拟机的一些工匠特性,比如gc,锁,jit等?

答案基本上是肯定的。首先,对于Java,Scala,Groovy这些本来就是JVM-based的语言,那没有什么压力,直接上JVM即可。对于CPython,R,Ruby,PHP乃至自己写的一门新的语言,回顾一下我们的一般做法:首先解析源代码到AST,然后写一个AST解释器->当有些人用这个语言的时候,语言设计者可能继续迭代,实现一个虚拟机,包括GC,运行时等,代码执行仍然是AST解释器->用的人多了,继续迭代,将AST转换为字节码,使用字节码解释器->用的人特别多,性能也很关键,如果这个语言社区有足够资金和人力,那么可以写JIT编译器,提升GC性能等,大部分语言都到不了这一步。

我们希望一门语言在AST解释器节点性能就足够好了,不用花那么多经历和彩礼再做性能优化等,这就是Truffle语言框架的动机,Truffle是一个java框架,自然跑在JVM上,在这个框架下,用户只需要实现具体语言的AST解释器,复出的努力比较小,性能也足够好。

为啥呢?因为基于Tuffle框架,AST数在解释过程中可以根据type feedback变形,有点Profiling Guided Optimization(分析导向优化)的意思。除此之外,Truffle还可以再AST解释器过程中使用编译器将这个AST数的一部分节点编译为机器码,后面不解释AST节点,直接执行机器码,这个过程也叫做树的部分求值(Partial Evaluation)。

Truffle将AST节点编译为机器代码使用的编译器是Graal,这是一个java写的即时编译器。我们提到Truffle是一个java框架,泡在JVM上,一个JAVA语言写的即使编译器则呢么编译Java代码呢??答案是通过JEP243的JVMCI。JVM是C++写的,它内置了俩个C++写的即时编译器,C1和C2。一般频繁的代码先用C1编译,这些代码即热点,如果热点继续,那么会使用C2编译。JVMCI相当于把本该交给C2编译的代码交给Graal编译,然后使用编译后的代码(其实也可以替代C1)。

JVMCI相当于把本该交给C2编译的代码交给Graal编译,然后使用编译后的代码(其实也可以替代C1)。用Java写即时编译器看起来很魔幻,其实很正常,因为即时编译说到底就是将一段byte[]代码在运行时转换为另一段byte[]代码,用什么写都可以。

总之,到目前为止, Java,Scala,Groovy已经可以在JVM运行了。CPython,R,Ruby,JS通过Truffle框架写个AST解释器也可以在JVM上运行了。那么如果是静态语言,像C/C++,Go,Fortran这些呢?可以使用Sulong(顺便一提这个sulong是汉语的速(rapid)龙(dragon))。解决方案是将C/C++这些语言用一些工具(如clang)转换为LLVM IR,然后使用基于Truffle的AST解释LLVM IR,这个解释LLVM IR的东西就是Sulong。到这里大部分语言都可以在JVM上运行了,上面提到的所有技术放到一张图里面,这个整体就叫做GraalVM,其实并不真正存在GraalVM这个语言虚拟机,GraalVM是指以Java虚拟机为基础,以Graal即时编译器为核心,以能运行多种语言为目标,包含一系列框架和技术的大杂烩:

上面途中所有的语言最终都是运行在JVM上,需要机器提前安装JDK环境,而且JVM由于自身的远呀,启动速度比较慢,内存负载高,能不能吧程序直接打包成平台相关的可执行文件,后面直接执行这个可执行文件,不依赖JVM?

答案是SubstrateVM。SVM借助Graal编译器,可以将javaAOT编译为可执行程序(没错GraalVM既可以JIT也可以AOT)

JIT Just-in-Time 实时编译

AOT Ahead-of-Time 预编译

先通过静态分析找到java程序用到的所有类,方法和字段以及一个非常小的运行时,然后把这一堆东西通过AOT编译,生成一个可执行文件如elf。SVM的想法很美好,对java的微服务Faas就是复印,只是现实很骨感,因为java有反射这些动态特性,基本上是无解的,静态分析再厉害也找不到动态运行时加载的类,对这些类只能手动写配置,或者运行时直接崩溃,这是个致命的问题。

生产环境上我知道的有Twitter和阿里尝试过SVM落地,阿里的SVM落地虽然成功了但是付出了巨大努力(狗头,而且是针对特定的app,换个app就需要再次付出努力。。除了这些外,现在SVM的GC是一个比较简单的分代GC,还缺少很多debug工具,编译速度也比较感人(主要是静态分析),这些还好,都在慢慢完善中。。。

JIT编译器的概念

graal和C2的区别

Graal和C2最为明显的区别是:Graal是用java写的,而C2是用C++写的。相对来说,Graal更加模块化,也更容易开发与维护,毕竟,连C2的开发者都不想维护C2了。

许对人会觉得用C++写的C2肯定要比Java快。实际上在充分预热的情况下,java程序中热点代码早已经通过即使编译转换为二进制码,在执行速度上并不亚于静态编译的c++程序。

Graal的内联算法对新语法、新语言更加友好,例如lambda表达式和scala语言

JVMCI

前文解释过,编译器是 Java 虚拟机中相对独立的模块,它主要负责接收 Java 字节码,并生成可以直接运行的二进制码。

传统情况下(JDK8),即时编译器是与 Java 虚拟机紧耦合的。也就是说,对即时编译器的更改需要重新编译整个 Java 虚拟机。这对于开发相对活跃的 Graal 来说显然是不可接受的。

为了让Java虚拟机与Graal解耦,我们引入了Java虚拟机编译器接口(JVM Compiler Interface),将即时编译器的功能抽象成一个Java层面的接口。这样一来Graal所依赖的JVMCI版本不变的情况下,我们仅需要替换Graal编译器相关的jar包(Java9以后的jmod文件),便可完成对Graal的升级

LLVM

除了JDK以外,另一个多语言的编译器就是LLVM了,不过LLVM区分前后端最开始的目的,是为了让动态类型语言和静态类型语言,有一个公用的编译器后端

所以llvm也设计了一个中间产物,也就是ir,一般llvm的ir是bitcode

所以llvm这么设计的目的,是为了统一不同类型的语言语法,所以搞了这么一个ir

java的子节码也是一种ir,但是java最开始这么设计,是为了统一不同的操作系统,提供一个统一的接口

而llvm这么做,是为了统一不同的语言,现在基于llvm后端的编程语言,有苹果的swift,还有rust这些


作者:圆胖肿
链接:https://www.zhihu.com/question/505643454/answer/2322678819
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

然后我们来说java的后端,也就是jvm

jvm最核心的目的,在设计之初,就是把子节码,在运行时候,翻译成机器码,然后执行

因为是在运行时候才编译,所以叫做jit,just in time,即时编译

jit不同于解释执行,解释执行就是在运行时候,把源代码,编译成机器码,然后执行

jit不同于超前编译(aot),就是在编译期,就把源代码编译成机器码

jit是介于两者之间的产物

然后这里又有概念要解释

jvm其实是jit 编译器,以及其他的一些组件共同组成,但是核心就是这个jit编译器

openjdk的jit编译器,区分成客户端(c1)和服务器端(c2)两种模式

但是时至今日,c1和c2都已经混用了,也就是你今天用的jit编译器,其实是c1+c2

在一个代码刚启动时候,用的是c1,c1不怎么产生优化代码,编以后就执行了

c2则是要经过数次运行之后,会根据运行时,生产优化后的代码,并将其缓存起来,因为多了一些步骤,所以c2在第一次运行的时候,并没有c1那么快,所以c2更适合服务器端应用,而c1则更适合客户端应用,这就是为什么c1叫做客户端模式,c2则被叫做服务器端模式

还有一个,因为代码需要数次执行之后,才会被缓存起来,所以jvm需要分析,哪些代码是热点代码,然后才会将其编译,优化,并缓存起来,这就是hotspot这个名称的由来,hotspot翻译过来就是热点的意思

那时至今日,实际上hotspot里面的代码,都是c1和c2混用,你没有必要去区分什么客户端和服务器端模式,现在都是混合模式运行

发展

Graal

Hotspot中在JDK10前有两类虚拟机,客户端编译器(C1):编译耗时短,优化程度低,服务端编译器(C2):编译耗时长,优化程度高。JDK10后加入了一个全新的即时编译器:Graal编译器,主要是为了替代C2,借鉴了其优点,在保持输出相近质量的编译代码的同时,开发效率和拓展性上都要显著优于C2编译器

GraalVM的改进SubstrateVM(AOT提前编译框架):

对于长时间运行的,或者小型化的应用而言,java天生就带有劣势,表现在对于微服务架构推行下,java启动时间长,需要预热才能达到最高性能。为了解决这个问题,就逐步开始对提前编译提供支持,即虚拟机在编译成二进制码后能直接调用。

SubstrateVM是GraalVM0.20版本里新出现的一个极小型的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理(垃圾收集)和JNI访问等组件,目标是替代Hotspot用来支持提前编译后的程序执行。同时还包含一个本地镜像的构造器(Native Image Generator),用来给用户程序建立基于SubstrateVM的本地运行时镜像。该构造函数采用指针分析技术,从用户提供的程序入口,搜索所有可达的代码。其轻量特性,使其十分适合嵌入其它系统,也让Graal VM支持其它语言不会有重量级的运行负担。Substrate VM带来的好处是能显著降低了内存占用及启动时间,由于HotSpot本身就会有一定的内存消耗(通常约几十MB),这对最低也从几GB内存起步的大型单体应用来说并不算什么,但在微服务下就是一笔不可忽视的成本。根据Oracle官方给出的测试数据,运行在Substrate VM上的小规模应用,其内存占用和启动时间与运行在HotSpot相比有了5倍到50倍的下降。Substrate VM使得GraalVM支持其它语言时不会有重量级的运行负担。

SpringVM(基于GraalVM)

通过Spring Native,Spring应用将有机会与GraalVM原生镜像的方式运行。为了更好地支持原生运行,SpringNative提供了Maven和Gradle插件,并且提供了优化原生配置的注解。

实际上,这意味着自Spring成立以来,除了Spring支持的常规Java虚拟机之外,我们还将添加Beta支持,以使用GraalVM将Spring应用程序编译到本机映像中,从而提供一种部署Spring应用程序的新方法。支持Java和Kotlin。

这些本机Spring应用程序可以部署为独立的可执行文件(无需安装JVM),并提供有趣的特性,包括几乎即时启动(通常<100ms),即时峰值性能和较低的内存消耗,但所需的构建时间和运行时优化次数少于JVM。

Last modification:November 27, 2023
如果觉得我的文章对你有用,请随意赞赏