在云原生的世界里,Go 语言凭借语法简单、启动速度快、依赖少、Goroutine 并发等特点,成为了一等公民。而 Java 作为 20 年前的编程语言,那个时代注重的是复杂的 OOP 设计、企业级规范,长期运行下的稳定性和性能。Java 语言似乎与当前云原生环境下的快速交付、微服务等需求格格不入。

阿里巴巴是世界上最大的 Java 用户之一,在拥抱云原生的同时,也要保持现有业务的迭代演进。因此 Alibaba JVM 团队一直致力于让 Java 语言与时俱进,适应云上场景。今天我们就来聊一聊 Java 在云上遇到的挑战以及如何通过 Dragonwell JDK 来克服这些困难。

Java 语言 & 云原生

云时代,JAVA何去何从?

Java 是一门企业级,高性能,高稳定性的编程语言。企业级简单来说就是适合开发长期运行的大型应用,比如 Linux + OpenJDK 的开发的服务如果没有发布和升级的需求,一般情况下可以保证运行一年以上不用重启。

Java 拥有丰富的生态,大量的高质量第三方类库、框架 (比如 Spring、Netty) 被维护在 maven 等中心仓库,用户只需声明式地引入包依赖,即可调用实现。举例来说,node 的 npm 生态虽然很完善,但是想要找到一个分布式事务框架就很困难;反观 Java,几乎所有开源软件与工具都会考虑对 Java 平台的支持,我们可以找到多种分布式事务框架的 Java 客户端。因此只要选择了 Java,就是选择了一个资源宝库。

作为 Java 的开发者肯定听说过 Java EE(目前捐给了 Eclipse 社区,更名为 Jakarta EE),Java EE 很大程度上成就了 Java 语言。编程语言本身只是提供控制流、数据结构定义、垃圾回收、并发基础设施、抽象手段等基础能力。而这个编程语言的杀手级场景究竟什么,是取决于语言之上的标准库、规范的。Jakarta EE 定义的 JDBC 规范就引领各大厂商为 Java 提供了接口一致的数据库驱动 ; Tomcat、JBoss 实现的 Servlet 容器让开发者有机会选择不同的 Servlet 实现。

Java 的跨平台性向开发者屏蔽了底层的硬件和操作系统细节。开发者们可以在 Mac、Windows 的开发环境开发、调试应用,最后到 Linux 的生产环境去部署,这大大降低了研发、调试、运维的工作量。

万物皆有 TradeOff,我们上述的一些设计取向给我们带来的一些麻烦。

代码加载开销高

云时代,JAVA何去何从?

为了实现跨平台性,Java 定义了自己的字节码,通过字节码描述计算,最后各个平台实现的字节码引擎来执行字节码。我们来看一段程序想要被加载需要经过的流程:

  • new 字节码或者 static 相关字节码触发类加载

  • 从一系列 jar 包中找到感兴趣的 class 文件

  • 将 class 文件的读取到内存里的 byte 数组

  • defineClass,包括了 class 文件的解析、校验、链接

  • 类初始化 (static 块,或者静态变量初始化)

  • 开始解释执行

  • 2000 次解释后被 client compiler JIT 编译,随后 15000 次执行后被 server compiler JIT 编译

简单的代码执行却涉及了大量的额外操作,一次类加载基本上是在毫秒级的,因此大型 Java 应用 (数万个 class) 的启动耗时很长,并需要一段时间来进行 JIT 预热,无法满足快速交付的需求。

内存管理造成内存占用大

云时代,JAVA何去何从?

我们经常收到到诸如 "明明 heap 只用了几百 M 内存,为啥监控提示内存水位高,进程占用了 5G 的内存"这样的答疑需求。我们结合一个实例来更好地了解 JVM 的内存管理。

  1. 开始 heap 没有对象,只分配了虚拟地址空间

  2. 随着对象分配,发生 page fault,内存被实际分配出来,当 heap 塞满,无法分配对象时,下一次对象分配将触发 gc

  3. gc 只保留了活对象,而死对象回收所空出来的空间可以被后续分配

GC 结束后,虽然有很多空闲内存,但是因为 heap 是 jvm 管理的,jvm 了解这些空间是空闲的,但是操作系统不知情,因此无法把这些空间分配给其他进程使用。JVM 之所以不把内存归还给操作系统的主要原因是这些内存很快就需要被应用使用,如果频繁进行归还,再而触发 page fault 反而带来性能下降。

使用每个请求一个线程的模型

云时代,JAVA何去何从?

基于上述的 Jakarta EE 规范,大部分 Java 通信组件或者中间件都是基于线程模型的,比如 Servlet 容器使用线程池来处理并发请求。JDBC 访问过程中需要阻塞线程,这也是规范,因此在线程模型下这些组件协作的很好。

但是多线程的抽象下编程简单了,对操作系统的负担却增加了。右图上每个竖块表示一个线程,他们分别处理一个请求,带颜色的区域说明这些线程实际在 CPU 上运行。虽然从线程视角任务是一直在运行的,而实际上是操作系统通过分时机制交替执行他们制造的假象。在高负载下操作系统调度任务的开销不容小视。

Dragonwell 助力云上转型

Dragonwell 产品介绍

云时代,JAVA何去何从?

要了解 Dragonwell 首先要了解 OpenJDK,OpenJDK 是由 Oracle 开源的 JDK 实现,是目前最广泛使用的实现,类似的实现还有 OpenJ9 等。

针对我们常用的 JDK8、JDK11 LTS 版本,OpenJDK 本身一直维持着活跃的开发,但是社区本身没有持续地提供带有最新更新的发行版本。想要用使用最新的安全的 JDK 版本有两种途径

  1. 使用 Oracle JDK:Oracle 会提供专门支持,但是这是要收费的

  2. 使用三方厂商自己维护的 JDK: 通常云厂商为了让客户们使用到最新的安全的 JDK,都会以 OpenJDK 为上游,推出自己的 JDK 发行版,Amazon Corretto、Alibaba Dragonwell 都是这样一类发行版。这些版本由云厂商维护支持。

Dragonwell 就属于上述的第二种。与 Corretto 等发行版不同的是阿里巴巴针对自己的实践,加入了大量优化特性,特别是针对云场景。我们可以选择性地打开这些优化,如果关闭则表现与 OpenJDK 一致。下面我们针对 Dragonwell 的特性,以及这些特性如何助力 Java 应用上云进行剖析。

Elastic Heap

云时代,JAVA何去何从?

基于上面描述的 GC 导致 JVM 会占用大量内存这一问题,Elastic Heap 功能会估算应用实际需要使用的内存大小。将内存定期归还给操作系统。

在阿里巴巴的场景下,每个裸金属服务器会部署大量在线业务,当在线业务处于低谷期时,elastic heap 功能会自动地释放内存。此时调度系统就可以在裸金属服务器上创建离线任务,将省出来的内存利用起来。下面看 spring boot demo 一个的例子:

云时代,JAVA何去何从?

我们使用 wrk 对应用进行压测,不久内存使用 (RES) 就到达了配置的 1G。且内存不会降下来,即便压测停止也会一直保持在这个水位。

云时代,JAVA何去何从?

随后我们使用 Elastic Heap 来改善这个状况。使用 Elastic Heap,随着压测停止,内存使用逐渐降低到了 700M。这缓解了 Java 应用占用内存过多的问题。

JWarmup

云时代,JAVA何去何从?

Java 代码需要经过足够的解释执行次数后才会被 JIT 编译器编译,通过上图的命令可以看到不同编译级别的执行次数阈值。在 Web Server 领域,应用刚刚发布完成时解释代码版本执行就慢,同时随着阈值到达,会触发编译,编译线程本身也会消耗 CPU,这就导致了 Java 服务刚发布完成时性能差。

那么能否让 JIT 编译提前完成呢?JWarmup 就是用来达成这个目的的。如图所示:

  • 在 beta 环境收集代码的 profiling 信息

  • 将收集到的 profiling 数据分 pai 到生产服务器后,进行发布启动

  • Warmup 会提前编译热点方法,保证线上请求开始处理时已经到达一个较高的编译级别了

多租户

云时代,JAVA何去何从?

多租户是 JVM 层面上提供的虚拟化技术,在 JVM 中引入了一个租户的概念,每个租户的最大资源 (通常是 CPU、内存、文件 fd) 使用是可以独立控制的。

一种用法是将多个 Java 的微服务部署到同一个 Java 虚拟机中,每个应用可以的资源是隔离的。相比容器隔离的好处是底层数据可以共享,并且应用之间的 RPC 可以被转换成方法调用,大大减少开销。

协程

云时代,JAVA何去何从?

上文中提到了 Java 使用了线程阻塞模型来处理请求,导致总体效率不够高。

这一点也是业界共识,近年来出现了大量异步处理的框架,在异步的加持下就可以用少量线程来并发处理大量请求了。node.js 是异步编程的典型框架,vertex 和 node 两个单词都是"节点"的意思,Java 的生态中 Vert.X 的流行正是表明了广大开发者对于目前的阻塞模型的现状并不满意。我们来看上图中的 Vert.X 的 JDBC client 的用法,包含了大量的回调函数使用,这对于复杂的应用的接入是一个大的挑战。但是如果我们结合 kotin 协程的支持就可以显著地简化这类异步框架的使用。

右图中对异步的 getConnection 进行了封装,调用后立即切换出协程。当操作完成,回调中会恢复协程执行。这样封装后,上层代码就可以简化了。右图的

复制代码

conn = client.aGetConnection();

本质上是事件驱动的异步操作,但写法上是顺序的。这就是协程带给我们的性能红利。

Dragonwell 的 Wisp 特性就是在 JVM 层面支持了协程,并在所有阻塞调用 (如 Socket.connect() ) 做出了类似 aGetConnection 的封装。因此现有的同步写法的代码都可以被异步调度执行,得到性能提升。

我们继续看一个案例:

我们使用了一个 Spring Boot 以 undertow 作为 Servlet 容器的 Http Server hello world 程序作为实例:

云时代,JAVA何去何从?

在关闭协程时可以看到有大量的 worker 线程在处理请求,通过多次执行、预热,throughput 最终锁定在 37193。

添加一个参数 -XX:+UseWisp2 后继续测试:

云时代,JAVA何去何从?

添加一个参数 -XX:+UseWisp2 后继续测试:

云时代,JAVA何去何从?

可以看到 2 个内核级 pthread 处理了所有请求。默认名字是 Wisp-Root-Worker

云时代,JAVA何去何从?

吞吐量最终锁定在了 51129,我们免费获得了 (51129 - 37193) / 37193 = 37% 的性能提升。

总结

  • Dragonwell 是 OpenJDK 在生产环境的可靠免费替代品

  • Dragonwell 已经推出了 JDK11 的版本,阿里云是国内第一个官方支持 JDK11 的厂商

Alibaba Dragonwell 有大量适合应用上云的特性:

  • Elastic Heap:减少微服务的内存消耗,内存分时复用

  • JWarmup: 减少预热过程,提高交付效率

  • 多租户: 支撑微服务合并部署,提高部署密度和 RPC 效率

  • Wisp 协程: 提高微服务的吞吐量