说明:本文完成了2023年5月份,当时最新的LTS版本是Java17,本文在撰写时参考了美团技术团队和阿里JDK团队相关的文章,以及本文也引了用文章中的图片。在此表示感谢!
Java版本火车
相信老牌的Java开发者和爱好者把Java的版本停留在经典的java5,Java 6,以及Java8和Java11版本上,这几个版本都是java的里程碑版本。 Java 社区决定从Java 9开始每半年发布一个新版本,同时LTS(long-term support)版本从原来的每3年改为每2年发布一个。下图是我根据Oracle官网Support Roadmap做的甘特图。
Java 7,8,11. 17以及还未发布的Java 21均是LTS(Long Term Support)版本,Oracle提供5年的维护周期,以及3年的付费额外支持,一共8年维护周期。
到目前为主,工业界的主力版本依然是Java 8和11. 随着云计算,大数据,以及多核的快速发展,Java社区也在适就这些变化,在随后的Java版本中增加了很多对云场景、新硬件的技术,比如对容器技术的技术,对大内存和NUMA技术的支持。
当前团队从2021年开始就考虑使用ZGC新特性,充分发挥大内存的特点,能更好满足大数据对多核和大内存的发展趋势,目前我们正在考虑使用Java 17 LTS版本来支持大数据领域的技术发展。
Java重要特性介绍
当前团队当前主流的Java版本是Java8和11,为了让大家对Java高版本特性有更好的了解,我从Java12开始直到Java17最为重要的特性做了初步的分析。由于篇幅有有限和对Java了解深度不足,本文只介绍Java虚拟机领域较新的特性,不涉及Java语言和Java框架发展分析。Java技术的爱好者均可从这里找到完整的Java提案。
因此,本文重点分析Java12到Java17在性能方面和云计算方面取得的进展。
性能
ZGC
ZGC无疑是Java GC算法的一个大变革,相比之前的GC算法,提供了以下亮眼的特性:
- 亚毫秒级的最大暂停时间 (最大暂停时间为10ms,但实际暂停时间基本在1ms以内)
- 暂停时间与堆大小无关,与活跃对象和根引用集合大小无关
- 堆大小从8M到16T都可以支持
ZGC最早出现在JDK 11中作为实验特性,从Java15开始作为生产特性使用. ZGC为什么能提供这么亮眼的特性,它的关键设计要点有如下:
- 最大限度的Concurrent: 除了几个非常短和工作量较为固定的阶段需要STW(Stop The World)外,其它阶都是GC线程和业务线程并发执行
- Region-Based: 可以灵活支持不同大小的Heap
- Compacting: 使用Compact机制,同时也不再分年轻代和老年代
- NUMA-aware: 感知机器的NUMA拓扑结构,让线程分配内存让尽量在本地Node上分配内存,让后续内存访延迟更小
- 使用3颜色指针和读屏碍:这是ZGC一种创新,通过通过读屏障,在Compact阶段,用户线程无须停下来,而是帮着GC线程在访问对象的同时顺便把Compact的工作也做了;3颜色则表示对象指针的3种视图来表示对象是不是正在GC状态,无须在Java对象上标准Mark状态。
- 使用透明大页(Transparent Huge Page, THP):THP是比Linux默认的hugetlb更灵活,ZGC天生支持THP,让堆对象的访问性能更高,THP(2M页)与4K页内存访问相比,性能会有5~8%的性能提升
ZGC一个回收集周期图示如下:
从上图可以看出,只有Initial Mark,Remark和Initial Transfer过程才需要STW,其它阶段都是GC线程与业务线得程并发执行,不会造成业务线程停顿。上述的3个会产生STW阶段的工作量只与根引用集合大小相关,实际上这几个过程都只需要做一次根对象扫描,耗时非常短,通常在1ms以内,也这是为什么ZGC号称最大停顿时间时只有亚毫秒级。
3颜色指针和读屏障
为了更方便支持在Compact阶段与业务线程并发执行,ZGC提出了3颜色指针方案:方案原理非常简单,那就是将操作系统分配的一块物理内存,同时映射到3块不同的虚拟地址空间,并且同一个对象的虚拟地址在这3个空间里,只是地址的高位是不同的,低42位是相同。这样就可以通过修改对象引用地址的高位,实现不同的地址视图切换。
上图是3个地址空间的划分,M0,M1和Remapped,中间有一个预留区域,是为了与下图的指针位相匹配的。
Marked 0, Marked 1和Remapped这3个标志位是互斥的,任何时候只能置一个位。当Marked 0置位时,指针指向M0空间,当前Marked 1置位时,指针向批M1空间,当Remapped置位时,指针指向Remapped空间。
ZGC的几个关键流程如下:
- 初始化:GC线程扫描所有根引用对象,并将这些引用视图修改为Remapped
- 并发标记阶段:无论是并行的GC线程扫描到对象时,将对象视图切换成M0,业务线程在些阶段访问到对象时,读屏障代码会将对象视图修改为M0. 这个阶段结束后,对象的地址视图是M0时,表示是一个活跃对象。对象依然停留在Remapped视图时,表示它是非活跃对象,可以回收。
- 并发转移阶段(compact):这个阶段的功能,是将活跃对象搬依次搬到Region的低地址区。过程是GC线程和业务并发执行,GC线程专注于搬移对象,但此过程里恰巧业务访问对象时,会在读屏障代码里将对象实施转移。对象是否已经转换成功了,全靠地址视图来判定。如果为M0时,说明还没有转移,则转移并将地址视图切换成Remapeed,否则不用做处理(因为有另一个线程帮忙做了)。
以下是JDK社区官方测试结果:
左图是Java标准的的性能测试套结果,数据做了归一处理,将ZGC的max-jOPS归一化成100%。max-jOPS表示吞吐性能,数值越大越好,critical-jOPS反应延迟性能,数据越大越好。从测试效果来看,ZGC比G1有明显的提升,max-jOPS提升10%,critical-jOPS提升约40%。
右图同时是SPECjbb@2015测试时,跟踪到的GC暂停时间。从测试结果来看,ZGC的平均值,99.99分位值和 Max值都在1ms左右,吊打此前任何版本的GC算法。
Virtual Thread
提到Java已发布的Virtual Thread,必须要提起它的真身——协程,而谈到协程,又必须多嘴提一下没有协程前的血泪史。
在刚开始的互联网时代,Appach HTTP server作为web server的实现者,以process per client的方式支持多client并发访问,在并发量达到10K时(可详见C10K问题),操作系统完全无法承受大量进程所占用的资源开销。接着Unix/Linux出现了kqueue/select多路侦听技术,client的消息调度与处理不再交给process或thread调度了,而是每个应用自己来调度client请求。通过类似于Java NIO或者事件回调这种异步处理技术,可以实现一个server高并发,性能表现非常优异,典型的例子就是Nginx。随着异步回调技术的发展,开发人员很快就陷入了回调地狱的陷阱。一个连贯的线性处理逻辑,必须拆分成多个小片段,散落在多个回调事件处理钩子中,可维护性差。在Java领域,使用reactive programming是一个方向,典型的是Vert.X框架帮助开发人员编写反应式程序。
随后协程的出现,能完美解决这个问题。在代码编写上(形式上):client处理代码只需写一个类似于while(1)的函数,读取request,然后process,再发送reponse给client,依次循环直接client主动关闭链接。在实际执行上,server由于大量的client链接,也有有大量的协程存在,但当一个client在recv 等待request时,协程调度器会快速切换另一协程(client)进行处理。 相对于传统的由kernel对线程进行调度,转换成由协程调度器在线程上下文内对协程进行调度,效率非常。
Java的Loom项目就是推动Java协程落地的项目,下图是从编程的复杂性和运行时的可伸缩性,分别几个技术方向的特点,协程是兼顾了编程简单化和可伸缩性两个方面,鱼和熊掌可兼得。
Java19开始提供Virtual Thread技术,作为一个preview特性,这意味Virtual Thread技术并没有定稿,它的实现和API在将来的生产版本也许会改变。
在介绍Virtual Thread编程API前,先说一下相关的技术概念 :
如上图所示,相比传统的Java线程模型来说,增加了Virtual Thread和Platform Thread。 Virtual Thread比较容理解,就是程序代码中的一个Virtual Thread,但是Platform thread是什么呢?这个并不难,它是协程调度器,同时也是协程的执行载体。Virtual thread和Platform Thread关系并不难理解,Plaform Thread自己会执行 Virtual Thread代码,并且它将多个VT代码交织到一起串着执行。这就像操作系统上的线程与硬件CPU的关系,CPU执行线程,并且是将线程代码交织到一起串着执行。
Plaform thread在任一时刻,只能执行一个Virtutal Thread,其它还没有得到执行的Virtual Thread 称为Unmounted Ready Virtual Thread(未绑定的就绪虚执线程),另外还有一些在等待网络报文请求的Virtual Thread称为Unmounted Blocked Virtual Thread(未绑定的阻塞虚拟线程)。
当Virtual Thread执行到会阻塞的函数调用时(比如InputStream中的read方法),Platform会注监控到,然后把该Virtual Thread注册到一个类似于select/epoll的事件侦听器上(了解NIO的朋友应该不陌生),接下来是Platform Thread 会执行相关的功能,并把当前Vritual Thread放到 Blocked列表上,然后从Ready的Virtual Threads上挑一个来执行。当网络报文达到时, 操作通知到Plaform Thread,然后Platform Thread把相应的Virtual Thread唤醒,放到Ready Virtual Thread,等待下一次被选中执行。
下面简要介绍Virtual Thread的API:
创建Virtual Thread 可用 Thread.ofVirtual()方法:
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
或者使用Thread.Builder来生成一个ThreadFactory,然后可以批量产生多个相同属性的Virtual Thread。
请注意,Virtual Thread在对象上没有创建新的子类,而且用java.lang.thread来表示传统线程或者Virtual Thread,可通过Thread.isVirtual()
方法来测试是否为Virtual Thread。
由于Virtual Thread在调用阻塞方法时,都需要Platform Thread进行监控和捕捉,所有涉及IO操作的API都需要进行修改,以支持Virtual Thread功能。因此涉及到了大量的API修改,列表如下:
- java.util.concurrent
- Networking
- http://java.io
- Java Native Interface (JNI)
- Debugging (JVM TI, JDWP, and JDI)
- JDK Flight Recorder (JFR)
- Java Management Extensions (JMX)
- java.lang.ThreadGroup
然而Virtual Thread(和协程一样)并不是万能的,它的应用场景是高并发大量网络请求,通过Platform thread快速切换Virtual Thread获得高效的性能,同时提供线性逻辑的编程方式。但对对计算型任务没有任何效果,计算型任务通常是根据CPU个数来划分并行度,中间不涉及大量的线程调度。
Virtual Thread特性到目前为止,还没有进入商用阶段。但在回过头来看看Java发展最为红火时代出现的编程框架,无一例外都使用线程池来解决多大并发问题。这些多线程架框架在持续优化,为开发人员屏蔽了很多技术细节和复杂性。所以Virtual Thread的出现,能否给Java社区带来翻天覆地的变化,我们拭目以待。
Vector API
向量(Vector)这个词相信对大家来说并不陌生,传统的CPU属于SISD(单指令单数据),随着图形计算的需求,SIMD(单指令多数据)功能应运而生。Intel处理也经历了从MMX到SSE,再到现在的AVX512指令。从软件层面说来,很多底层运行库都开始使用SIMD指令来优化内存处理,比如典型的例子是glibc的memcpy函数就使用了SSE指令加速内存拷贝速度,另一个例子应该gcc编译器在-O3优化层级下,对C/C++ for循环也尽力做向量化处理(即用SSE指令进行优化)。
用下面两例代码来体会一下Java编程世界里SISD和SIMD的差异:
// SISD 例子
void scalarComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
// SIMD 例子
void vectorComputation(float[] a, float[] b, float[] c) {
int i = 0; int upperBound = SPECIES.loopBound(a.length);
for (; i < upperBound; i += SPECIES.length()) {
// Boxing: from array to SIMD Vector
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
// parallel operation for multiple data
var vc = va.mul(va)
.add(vb.mul(vb))
.neg();
// Unboxing: from SIMD Vector to array
vc.intoArray(c, i);
}
}
SISD代码,只能依次对每份数据进行运算,而SIMD代码可以每次同时对16份数据(512 / 32 = 16)同时进行运算,是因为AVX512能同时处理位宽为512bit的多份数据。下图可以形象说明SISD和SIMD处理逻辑上的差异。
看似完美的SIMD肯定能带来性线的加速比,但事实并非如此,我们先看一下测试结果
从测试数据来看,效果并不理想。上图是一个基础的向量相加运算,并没有带来任何性能提升;下图是向量FMA (Fused-Multiply-Add)运算,在内存大小不超过L2时获取10+倍的性能加速,但数据量再大起来后,加速比回到2-3倍左右。
在这里我们可以有一些猜测,由于SIMD硬件宽度有限(以AVX512为例),每512bit数据要进行一次Boxing和Unboxing操作(FloatVector.fromArray和FloatVector.toArray),SIMD获得的红利大量分都这些操作的磨损了。
重新审视最新的 JEP (https://openjdk.org/jeps/438)会发现这个问题已在Java意料之中,Vector API有两个实现,其一是上面测试数据所体现的,使用API库的方式实现。第二个则是使用JVM的intrinsics上,通过C2 JIT编译器直接翻译成Native汇编代码,比如上图的代码在Intel X64机器翻译成如下的汇编代码:
0.43% / │ 0x0000000113d43890: vmovdqu 0x10(%r8,%rbx,4),%ymm0
7.38% │ │ 0x0000000113d43897: vmovdqu 0x10(%r10,%rbx,4),%ymm1
8.70% │ │ 0x0000000113d4389e: vmulps %ymm0,%ymm0,%ymm0
5.60% │ │ 0x0000000113d438a2: vmulps %ymm1,%ymm1,%ymm1
13.16% │ │ 0x0000000113d438a6: vaddps %ymm0,%ymm1,%ymm0
21.86% │ │ 0x0000000113d438aa: vxorps -0x7ad76b2(%rip),%ymm0,%ymm0
7.66% │ │ 0x0000000113d438b2: vmovdqu %ymm0,0x10(%r9,%rbx,4)
26.20% │ │ 0x0000000113d438b9: add $0x8,%ebx
6.44% │ │ 0x0000000113d438bc: cmp %r11d,%ebx
\ │ 0x0000000113d438bf: jl 0x0000000113d43890
直接把Java语言翻译成Native 代码,少了JDK API中间赚差价。从上面的汇编代码来,完全没有Boxing和Unboxing代码,性能发挥得极致。
从目前看来,向量计算在Java有以下的支持
- 把向量当作值类型进行处理
- 支持Vector API通用化,由intrisincs根据硬件特点,翻译相应该的SIMD硬件指令,同时能屏蔽SIMD功能上的差异,无法用硬件实现的退出成非向量指令
- 应用Intel的SVML数学库,实现SIMD不能提供的计算能力全部转交给SVML来实现。
目前最新的JEP显示,Vector特性依然还是一个实验特性,期待能早日应用到生产中。
Numa感知
NUMA (Non-uniform memory access) 技术是计算机体系统架构应对多核挑战下的重要优化:为了减少公共内存带宽的压力和瓶颈,它采用了内存分层的访问结构,正如IDC物理网络的Leaf-Spin架构一样。可从下图的物理结构一窥究竟:
上图是典型的Intel处理器当前的配置,分成两个socket,每个Socket有本地的内存,CPU和硬件。这里我们重要关注内存,所有socket内存条是统一编址的,但是CPU访问本地内存时,速度比访问远端socket的内存要快好几倍,因为他们之间需要一个socket间互联通道QPI进行通信。
实际上,硬件和操作系统对NUMA的支持已经是比较友好了,一个进程在申请内存时,操作系统会尽量从本地内存空间中分配内存给程序使用,减少远端内存访问的概率。
但是Java自身托管了Heap内存管理,所以它需要感知 NUMA,才能在适合的位置分配内存,让业务线程访问性能更高。同样由于NUMA对性能的意义重大,Java在ParallelGC是已支持NUMA感知的,但是G1回收算法在Java14之前并没有支持,所以在Java14提出了支持NUMA感知特性。
G1感知NUMA的原理并不复杂:在Heap初始化时,比较平均地从两个Socket节点申请内存;当业务线程new对象时,优先考虑从线程所在socket的heap空间上分配对象,这样对年轻代对象非常友好。因为年轻代对象生存周期往往很短,所以业务线程访问时往往没有调度到另一个Socket的CPU上,极大概率是本地访问,性能高。但如果本地Heap内存空间不足时,触发GC回收,再分配。但如果万一本地Heap没有空闲内存了,但另一个socket Heap有较多的空闲空间,G1会failback到远端Heap分配对象。
下图是SPECjbb@2015的测试结果,G1支持NUMA感知是在JDK-14 b24合入的,B23是它前一个版本,它们的性能对比最能说明优化效果。该测试使用了512GB的G1 heap做SPECjbb@2015 benchmark测试,从max-JOPS指标来看,提升了20.64%,而critical-JOPS也有9.52%的提升。
同样地,ZGC在设计是就支持NUMA感知,这样不做详细分析和讨论。
云原生
GraalVM
在云计算之前, Java有着独特的优势,在企业应用领域大红大紫,开源大数据领域all in Java。但在随着云计算的蓬勃发展,Java的优势反而成了它的不足。 传统的Java程序难以支持毫秒级的启动时间,厚重的应用编程框架,大大逊色于轻量级Javacript来开发FaaS应用,Java实际应过程遇到的各种性能和多语言问题。 为了应对云计算,以及当下发展火热云原生技术, Oracle研发了GraalVM,一种通用JVM解决方案,以解决在云计算场景的痛点。
GraalVM有以下的技术特点:
- 支持大量的编程语言:包括原本就支持的JVM语言,以及基于LLVM的语言(C/C++),动态语言:Javascript, Ruby, Python, R语言,也支持动态语言引擎:各种Javascript引擎,FastR引擎,RubyTruffle引擎
- 支持静态编译:把各种语言直接翻译成Native代码,应用程序进行二进制执行,大大加速了应该程的启动时间
- 高性能:完全Native代码运行,没有翻译解释执行,同时也消除跨语言调用的成本
- 底噪小,快速启动,多语言联合调用:解决了传统Java应用需要占用大量内存, 以及启动慢问题,对Java应用上云提供便利
下图是GraalVM官网给出的生态系统:
启动时间和内存占用,这两个云计算极为关注的竞争力,它的表现非常优异,如下两图的测试结果可看到。
上两个用Java生态下的3个应用进行测试对比,启动时间快50多倍,内存底躁降低了5倍以上。
在跨语言方面,GraalVM提供了一种在多语言之间无逢传值的方法,而不像传统方式那样需要序列化和反序列化,大大提高的跨语言的性能。正是因为跨语言能力的出现,开发者可以很容易利用另一个语言最新的库进行软件开发,这种便利性大大提升了研发效率。
启动时间是Java整个社区面临的最大挑战之一。下图清楚地展示了为什么Java启动这么慢,JVM有太多的初始化工作需要做。
这个问题最彻底的做法是将Java代码直接翻译成Native,程序可以马上直接运行,不需要加载字节码过程和第一次的解释执行,性能效果最优。由于没有了JVM,JIT组件,可以省去这部分组件所占用的内存开销,也能减少应用image的体积,这完全符合原生的理念和方法。
Java在GraalVM之前并非没有尝试走过这条路,从Java 2之前的GCJ,再到后来的Excelsior JET,再到现在GraalVM提出的通过静态编译解决Java启动慢问题,一路走过,遇到不少挑战。
但这并不是一条容易的技术路线,Java从Bytecode层面来说,它是一个动态语言,它支持灵活的反射技术,支持AOP(Aspect-Oriented Programming)编译技术,意味着JVM给开发者开放了灵活的动态编程技术。因此,在这种便利下,很多信息(比如方法代码,甚至是类)都是无法在编译时确定的,而是在运行时动态地由代码来生成决定,所以无法在在编译阶段直接生成Native。这也就是为什么在此之前只能做到AOT( ahead-of-time)技术。
但如果要求开发者把上述所有态动性功能都去掉,不需要使用,Java生态下的重要组件,如Srping, Hibernate必然跑不起来,整个Java生态会轰然崩塌。于是一条迂回曲折之路必须会摆在各个架构师前面。前面提到GraalVM支持翻译成Native代码是指其它语言跑在GraalVM可以翻译成Native,或者那些没有使用高级动态功能的 Java可以翻译成Native,但对于绝大多数的Java应用来说,还是一条很长远的路。
容器化
云计算和云原生技术栈当中,容器是最重要的技术之一。以Docker为代表的容器技术流派是最通用的,它是Linux操作系统上的Namespace和cgroup两大技术为支柱。所以,Java支持容器技术变更犹为重要。
Kubernetes作为最重的容器调度平台,在上面部署应用时,当然不少了指定每个pod的 Request和Limit资源要求(包含CPU和memory)。但由于Linux下的Namespace是一种弱隔离,Java应用程序运行在Kubernetes上之后,它仍然要读取/proc/meminfo和/proc/cpuinfo来获取它运行环境的资源情况,来决定Java Heap应该开多大,池程池应该开多大。如果一个Java容器只申请了4C8G(4 core and 8 G memory)资源,但跑在一个96C和512G的机器上面。那它会理所当然地认识它有96个Core可运行,接近500G内存可自由使用。但在云原环境上,这样做很容易带来致命的问题,那就是不断地触发OOM,服务难以正常提供。当然开发者可以通过Java环境变量传递各种资源约束,但这种做法难以适合一个应用跑在多种不同的资源环境上。
自Java17开始,OpenJDK支持CgroupV2。JDK会读取它所在的cgroup配置来感知它自己的CPU和memory配置,而不是从原来的/proc/meminfo和/proc/cpuinfo来获取这些信息。 下面是个最简例子,展示了容器里面的java从Cgroup里读到了CPU和memory的配置。
$ java -XshowSettings:system -version
Operating System Metrics:
Provider: cgroupv2
Effective CPU Count: 2
CPU Period: 100000us
CPU Quota: 200000us
CPU Shares: 1024us
List of Processors: N/A
List of Effective Processors, 4 total:
0 1 2 3
List of Memory Nodes: N/A
List of Available Memory Nodes, 1 total:
0
Memory Limit: 1.00G
Memory Soft Limit: 800.00M
Memory & Swap Limit: 1.00G
openjdk version "17.0.2" 2022-01-18
OpenJDK Runtime Environment 21.9 (build 17.0.2+8)
OpenJDK 64-Bit Server VM 21.9 (build 17.0.2+8, mixed mode, sharing
当然,Java应没有屏去以往的获得资源方式,而且完全兼容,同时也支持通过API获取Cgroup的配置,具体的使用方式请参考相关的技术文档。有了这一能力,Java更贴近云原生,用户体验更好。
小结
本文重点分析Java12到Java17的一些重进展,以及一些现在还没有完全成熟的技术,比如Vector API, GraalVM。从收益来说,免费的性能收益是令人激动的,对云原生的支持也让运维成本大大降小。但除了这两个方面,Java还涉及很多其它的领域,比如编程语言能力的拓展,开发和调试工具的发展,对整个语言生态极其重要。希望有机会再做这些方面的分析和总结。
参考文献
- 从 JDK 9 到 19,认识一个新的 Java 形态(内存篇)https://mp.weixin.qq.com/s/cF6JgJIOCF6Jxg520rRbLA
- Java 18: Vector API — Do we get free speed-up? https://medium.com/@Styp/java-18-vector-api-do-we-get-free-speed-up-c4510eda50d2
- JDK ZGC introduction https://wiki.openjdk.org/display/zgc/Main
- 新一代垃圾回收器ZGC的探索与实践 https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html
- JEP 426: Vector API (Fourth Incubator) https://openjdk.org/jeps/426
- JEP 425: Virtual Threads (Preview) https://openjdk.org/jeps/425
- 云原生时代,Java 的危与机 https://www.infoq.cn/article/rqfww2r2zpyqiolc1wbe
- Java 17: What’s new in OpenJDK's container awareness https://developers.redhat.com/articles/2022/04/19/java-17-whats-new-openjdks-container-awareness
- NUMA-Aware Memory Allocation for G1 GC https://sangheon.github.io/2020/11/03/g1-numa.html
编辑于 2023-12-29 16:22・IP 属地广东