在云原生的世界里,Go语言凭借语法简单、启动速度快、依赖少、Goroutine并发等特点,成为了一等公民。而Java作为20年前的编程语言,那个时代注重的是复杂的OOP设计、企业级规范,长期运行下的稳定性和性能。Java语言似乎与当前云原生环境下的快速交付、微服务等需求格格不入。
阿里巴巴是世界上最大的Java用户之一,在拥抱云原生的同时,也要保持现有业务的迭代演进。因此Alibaba JVM团队一直致力于让Java语言与时俱进,适应云上场景。今天我们就来聊一聊Java在云上遇到的挑战以及如何通过Dragonwell JDK来克服这些困难。
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应用(数万个class)的启动耗时很长,并需要一段时间来进行JIT预热,无法满足快速交付的需求。
我们经常收到到诸如 "明明heap只用了几百M内存,为啥监控提示内存水位高,进程占用了5G的内存"这样的答疑需求。我们结合一个实例来更好地了解JVM的内存管理。
GC结束后,虽然有很多空闲内存,但是因为heap是jvm管理的,jvm了解这些空间是空闲的,但是操作系统不知情,因此无法把这些空间分配给其他进程使用。JVM之所以不把内存归还给操作系统的主要原因是这些内存很快就需要被应用使用,如果频繁进行归还,再而触发page fault反而带来性能下降。
基于上述的Jakarta EE规范,大部分Java通信组件或者中间件都是基于线程模型的,比如Servlet容器使用线程池来处理并发请求。JDBC访问过程中需要阻塞线程,这也是规范,因此在线程模型下这些组件协作的很好。
但是多线程的抽象下编程简单了,对操作系统的负担却增加了。右图上每个竖块表示一个线程,他们分别处理一个请求,带颜色的区域说明这些线程实际在CPU上运行。虽然从线程视角任务是一直在运行的,而实际上是操作系统通过分时机制交替执行他们制造的假象。在高负载下操作系统调度任务的开销不容小视。
要了解Dragonwell首先要了解OpenJDK,OpenJDK是由Oracle开源的JDK实现,是目前最广泛使用的实现,类似的实现还有OpenJ9等。
针对我们常用的JDK8、JDK11 LTS版本,OpenJDK本身一直维持着活跃的开发,但是社区本身没有持续地提供带有最新更新的发行版本。想要用使用最新的安全的JDK版本有两种途径
Dragonwell就属于上述的第二种。与Corretto等发行版不同的是阿里巴巴针对自己的实践,加入了大量优化特性,特别是针对云场景。我们可以选择性地打开这些优化,如果关闭则表现与OpenJDK一致。下面我们针对Dragonwell的特性,以及这些特性如何助力Java应用上云进行剖析。
基于上面描述的GC导致JVM会占用大量内存这一问题,Elastic Heap功能会估算应用实际需要使用的内存大小。将内存定期归还给操作系统。
在阿里巴巴的场景下,每个裸金属服务器会部署大量在线业务,当在线业务处于低谷期时,elastic heap功能会自动地释放内存。此时调度系统就可以在裸金属服务器上创建离线任务,将省出来的内存利用起来。下面看spring boot demo一个的例子:
我们使用wrk对应用进行压测,不久内存使用(RES)就到达了配置的1G。且内存不会降下来,即便压测停止也会一直保持在这个水位。
随后我们使用Elastic Heap来改善这个状况。使用Elastic Heap,随着压测停止,内存使用逐渐降低到了 700M。这缓解了Java应用占用内存过多的问题。
Java代码需要经过足够的解释执行次数后才会被JIT编译器编译,通过上图的命令可以看到不同编译级别的执行次数阈值。在Web Server领域,应用刚刚发布完成时解释代码版本执行就慢,同时随着阈值到达,会触发编译,编译线程本身也会消耗CPU,这就导致了Java服务刚发布完成时性能差。
那么能否让JIT编译提前完成呢?JWarmup就是用来达成这个目的的。如图所示:
多租户是JVM层面上提供的虚拟化技术,在JVM中引入了一个租户的概念,每个租户的最大资源(通常是CPU、内存、文件fd)使用是可以独立控制的。
一种用法是将多个Java的微服务部署到同一个Java虚拟机中,每个应用可以的资源是隔离的。相比容器隔离的好处是底层数据可以共享,并且应用之间的RPC可以被转换成方法调用,大大减少开销。
上文中提到了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程序作为实例:
在关闭协程时可以看到有大量的worker线程在处理请求,通过多次执行、预热,throughput最终锁定在37193。
添加一个参数 -XX:+UseWisp2
后继续测试:
添加一个参数 -XX:+UseWisp2
后继续测试:
可以看到2个内核级pthread处理了所有请求。默认名字是Wisp-Root-Worker
吞吐量最终锁定在了51129,我们免费获得了(51129 - 37193) / 37193 = 37%的性能提升。
Alibaba Dragonwell有大量适合应用上云的特性:
更多Alibaba Dragonwell的信息可以在Dragonwell官网 http://dragonwell-jdk.io/ 看到。
领取专属 10元无门槛券
私享最新 技术干货