JVM 的问题

随着云原生浪潮的兴起,应用本身越来越小,对跨平台的需求越来越弱(因为平台问题已经由云厂商解决了),但是对应用快速启动、即起即用和高性能执行的需求越来越强。

Java 程序的冷启动问题在这种场景下就显得格外突出,成为开发人员在选择编程语言时的主要减分项。Java 社区和工业界一直在探索冷启动问题的解决之道,比如 OpenJDK 提出的 AppCDS(Application Class DataSharing)技术,可以将已经加载的类的元数据导出到文件,在下次启动时直接从文件导入这些数据,无须再次经过类的解析和加载等过程,由此削减启动时的类加载开销。但是,因为 Java 的冷启动问题的根源在于 JVM 本身,所以在 JVM 之上做的各种优化的效果都是有限的,难以实现质的飞跃。

从根本上审视 Java 冷启动问题可以发现,启动一个 Java 程序并让它达到性能的峰值需要经过 VM 初始化 → 应用程序初始化 → 字节码解释执行 →JIT 编译热点函数 → 执行 JIT 编译后的本地代码(native code)等环节,且不论在这些环节上能够做出何种优化,单这么长的一条链路已足以说明冷启动问题之复杂、难解。

越来越多的开发者在开发 Serverless 应用时转向了 Node.js、Go 等不存在冷启动问题的语言

为了解决这个问题,Oracle 公司推出的开源高性能多语言运行平台 GraalVM,打造了一个包括静态编译器和轻量级运行时的 Java 静态编译框架,可以将 Java 程序从字节码直接编译为本地可执行应用程序。

Java 程序的运行生命周期

20211229224114

Java 程序的执行生命周期如图,可以分为 VM 初始化、应用初始化、应用预热、应用稳定和关闭这 5 个阶段。图的横坐标代表应用执行的时间顺序,纵坐标代表 CPU 利用率,各个区域代表该行为的 CPU 使用率

  • VM 区域代表 JVM
  • CL 区域代表类载入
  • JIT(Just In Time)区域代表实时编译
  • GC 区域代表垃圾回收(以下简称 GC)
  • GC 右上方区域代表解释执行应用程序
  • 最右边的区域代表执行经过 JIT 编译的应用代码

可以看到应用初始化时,类加载最为耗时,因为加载类时需要先从磁盘上读取 jar 文件和 class 文件,然后将文件解析为类。而 jar 文件实际上就是 zip 压缩文件,解压并读取文件的 I/O 操作较为耗时。应用程序越是复杂,初始化时载入类的数量就越多,相应的 JVM 启动时间越长。

20211229224558

这三项的加载类数量是从-XX:DumpLoadedClassList=选项打印出的文件中统计出来的。可以明显看出,启动时间随加载类的数量增加而上升。

程序预热

Java 源代码先编译为与平台无关的字节码,然后由 JVM 解释执行。解释执行是由 JVM 将字节码逐条翻译为汇编代码,然后再执行。比如对于一个简单的加法操作:b+c;其对应的字节码大致为:

1
2
3
0: iload_0
1: iload_1
2: iadd

JVM 按照取数据、执行操作、保存数据三段式结构,为每条字节码指令都提前准备好了汇编代码模板,然后在运行时将具体数据填入模板执行。由于这样的代码缺少编译优化,只是简单地将模板中的指令堆积在一起,因此运行时性能较低。

解释执行具有平台无关和灵活性两大特点。字节码其指令行为是由 JVM 规范(JVMspecification)定义的,相当于一层接口,保证了不同平台的行为一致。通过解释执行支持诸如动态类加载这样的动态特性,Java 可以在运行时解释执行一段在编译时尚不存在的代码。

为了解决运行时性能低的问题,Java 引入了实时编译技术,即在运行时将热点函数编译为汇编代码,当程序再次运行到经过实时编译的函数时,就可以执行经过编译和优化的汇编代码,而不再需要解释执行了。由于编译是在运行时进行的,因此 JIT 编译器可以获得程序的运行时状态,比如路径、热点和变量值。基于这些信息,JIT 编译器可以做出非常激进的编译优化,比如程序中有两个分支,仅静态地看代码无法分辨哪个分支被执行的概率更大,但是如果在运行时发现程序总是只执行其中某一个分支,而不执行另一个分支,那么 JIT 编译器就可以将总是执行的分支放到条件判断的 fallthrough 下,从而节省一次跳转,甚至可以把另一个不执行的分支删除。万一出错了也没有关系,还可以回退到解释执行。这种有保底的激进优化在一些场景下甚至可以将 Java 程序运行时的性能提高到超越 C++程序的程度。

冷启动

阿里云的函数计算平台部署一个最简单的 springboot 应用,该服务会接受用户发来的请求并返回计数值。

20211229234041

Serverless 服务本身执行时间短。Serverless 应用强调微服务架构,服务的粒度小,耗时短。

当前各个云服务提供商对冷启动问题的解决方案是提供付费预热,应用服务提供商可以购买预热服务提前将自己的服务启动起来,或者通过在服务变冷后定时唤起的方式,让自己的应用保持一定的热度。这就要求应用服务提供商对其用户的使用模式有较为准确的预测,能够在恰到好处的时候预热程序,否则就会多付出不必要的费用。

业界也提出了多种提高 Java 启动速度、降低冷启动开销的方案,比如将通用类的数据保存下来并在不同 Java 进程之间共享,以提高启动速度的 AppCDS(Application ClassData Sharing)技术,就是 OpenJDK 社区提出的一个解决方案。但是由于冷启动问题的本质是由 JVM 的实现机制引发的问题,在传统 Java 的体系下无法彻底解决。

初识 Java 静态编译技术

Java 静态编译是指将 Java 程序的字节码在单独的离线阶段编译为汇编代码,其输入为 Java 的字节码,输出为 native image,即二进制 native 程序。

20211229235926

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

要介绍的主角 GraalVM 静态编译后得到的本地文件被称为 native image,它是一个自举的可执行文件。“自举”是指执行 native image 时除了操作系统的库文件之外,不需要其他任何库文件和运行时的支持,因为 native image 中已经包含了应用程序、依赖库程序及运行时支持程序(如多线程支持、GC 等)。

实现程序自举意味着可以被静态编译为本地共享库文件,然后被其他 native 程序(C/C++程序)直接调用,这就意味着可以用 Java 语言编写 C 程序的库文件。

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

  • 不再需要经过解释执行和 JIT 编译,应用程序始终以稳定的性能执行
  • 静态编译后的可执行程序自包含了轻量级运行时支持,不再额外需要 JVM 的支持。

将之前的最简 springboot 编译为 native image 后部署到阿里云

20211230000824

缺点

第一,虽然静态编译技术有以上诸多优点,但 java 程序失去了动态性。对于 C/C++等静态语言而言,封闭性假设似乎是天经地义的,但是 Java 程序中存在很多无法静态确认,最典型例子就是反射。

1
java.lang.Class.forName(someClassName);

someClassName 可能是运行时输入的内容,在静态时无法分析出 someClassName 的值。但并不是所有的反射都不满足封闭性,比如 someClassName 是一个字符串常量。

违反了封闭性假设的 Java 动态特性有:

  • 动态类加载
  • 反射
  • 动态代理
  • JCA(Java Cryptography Architecture),Java 的加密机制依赖反射
  • JNI,从本地函数中以 JNI 方式调用和访问 Java 中的类、变量和方法等
  • 序列化,将内存中的对象内容转换为字节流,用于数据交换

以上动态特性时,静态编译是不能直接支持的,而需要通过额外的适配工作予以解决。但适配无法覆盖所有可能性,因此这种支持也是有限的。

第二,局限是静态编译后的程序是平台相关的,不再具有 Java 程序平台无关的特性。但是从云原生 Serverless 应用的现实需求角度来看,Java 的平台无关特性已不再重要。

第三个局限是面向传统 Java 程序的调试、监控、Agent 扩展等功能不再适用。因为运行时执行的是本地程序,而不再是 Java 程序。比如 JVM 状态监控工具 jstat、程序内存导出工具 jmap、线程状态查看工具 jstack 等都不再适用;Agent 机制也不再适用;甚至连代码调试方式也不再相同,从原先相对简便的 IDE 调试变成了相对复杂的 GDB(GNU project debugger)汇编调试。