有了 HotSpot JVM 为什么还需要 OpenJ9?
什么是 OpenJ9
OpenJ9
是一个致力于构建更小内存使用,更快启动速度和更高吞吐量的独立实现的 Java 虚拟机。项目由 IBM 发起,并在之后开源并捐赠给 Eclipse 基金会。
为什么需要 OpenJ9
HotSpot JVM
在 Java 虚拟机领域独领风骚多年了,但是近年来有GraalVM
,OpenJ9
等等后起之秀崭露头角,开始在各自的领域发力。
正如OpenJ9
自己的介绍一样:
A Java Virtual Machine for OpenJDK that's optimized for small footprint, fast start-up, and high throughput
OpenJ9
的特点就是性能:低内存占用,快速启动,高吞吐。我们就来看看为了实现这些能力OpenJ9
都做了什么,然后回过头再来看他是否能够在某些场合替代HotSpot JVM
。
性能
从官网上截取了官方对于OpenJ9
的性能对比。可以看到无论是 jdk11 还是 jdk8,OpenJ9
在启动时间和内存占用上都占有较大优势。
类共享
OpenJ9
的一大特点就是类共享。共享类无需用户进行特殊处理,JVM 会自行进行处理来优化内存占用和改进启动时间。在 OpenJ9 的实现中,所有的系统类,应用类和 AOT 预编译的代码都能被存在共享内存的动态类缓存中。类共享对于多个运行相同代码的 JVM 将是巨大的优化,因此在当前的云原生的蓬勃发展下OpenJ9
是一个非常有诱惑力的选择。
类共享使用
想要开启使用类共享很简单,只要在 JVM 启动项中添加-Xshareclasses[:name=<cachename>]
即可,JVM 会自行构建缓存。
类共享原理
共享类缓存
共享类缓存(SCC, shared classes cache)是一个固定大小的共享内存区域。除非配置了不持久化,否则 SCC 数据即使在 JVM 重启后也会依然存在。
OpenJ9
的共享缓存不属于某个 JVM,各个 JVM 之间也不会有主次之分,但是所有的 JVM 都能够对共享缓存进行读写。
类缓存使用
一般的 JVM 在装载类的时候遵循如下的流程:
使用类共享的情况下类的加载机制会发生变化:
启用类共享的情况下,在父类加载器层层加载都没法获取类时会去共享缓存查询类,然后才会尝试去文件系统获取。
java.net.URLClassLoader
(在 Java9+ jdk.internal.loader.BuiltinClassLoader)已经集成了共享类缓存的 API,因此所有继承java.net.URLClassLoader
的类加载器都能够使用共享类缓存。如果是自定义的类加载器,可以使用OpenJ9
提供的 API。
在OpenJ9
的实现中,Java 类被分为了两部分:
ROMClass 只读,存储的是类的不可变数据
RAMClass 可写,存储的是类的可变数据,例如静态类变量
虽然RAMClass
指向了ROMClass
,但是这两者是完全分开的。因此在不同的 JVM 之间分享ROMClass
以及在同一个 JVM 使用RAMClass
是很安全的。在未开启类共享的情况下,当 JVM 加载类时,会分别生成RAMClass
和ROMClass
并存储在本地的内存中。如果开启了类共享,JVM 加载类时发现共享内存中已经存在了该类,那么就只需要创建RAMClass
然后存放在本地内存使用即可。
AOT
编译后的代码也会被存储在共享缓存中。当启用共享类缓存时,AOT
会将将 Java 类编译成本机代码,以便同一程序后续使用。
文件系统变化导致的类缓存问题
因为共享缓存是没有过期时间的,因此可能会存在类文件产生变动导致的缓存失效。因此 JVM 需要处理这种情况下的类缓存的更新问题。JVM 需要保证类加载器获取的类必须和文件系统中的类是一致的。
JVM 通过将时间戳值存储到缓存中并将缓存值与实际值进行比较来检测文件系统更新。在类发生更新的情况下这些操作对于类加载是透明的,因此用户对于类进行修改操作都很容易被感知到并且进行相应的处理。
缓存版本差异
在某些情况下,从一个版本的 JVM 创建的缓存可能与从不同版本创建的缓存不兼容。遇到这种情况即使两个缓存名称相同,JVM 也会依然创建一个新缓存,同时通过共享类缓存的世代号(generation number)来检测冲突。
redefine 和 retransform 类
类缓存机制听上去很合理,但是特殊情况下会有些不一样,比如当你使用了 Java Agent 时,会有一些类会被redefined
或者retransformed
。针对这两种情况,OpenJ9
做了不同的处理:
redefined redefine 会替换字节码,因此这种类不会被存放入缓存中
retransformed retransform 会修改字节码,并且有可能会进行多次的修改,这种类默认不会被存入缓存,但是可以通过
-Xshareclasses:cacheRetransformed
选项来开启
AOT
AOT 通过将 java 类编译成native code
并缓存到共享数据缓存中。后续虚拟机可以从共享数据缓存加载和使用 AOT 的代码,而不会导致性能下降。
如果要关闭,可以使用-Xnoaot
参数进行配置
内存管理
GC 策略
OpenJ9
提供了一系列 GC 的策略用于不同场合的内存管理。
gencon
gencon
(Generational Concurrent GC)是OpenJ9
默认的 GC 策略,使用-Xgcpolicy:gencon
进行配置。这个 GC 策略适用于大多数的应用,尤其是有许多生命周期很短的对象的事务性应用。此策略旨在不影响吞吐量的情况下减少 GC 暂停次数。
此策略类似于HotSpot JVM
的分代收集策略,只是OpenJ9
会在一些细节上有一些不同。
在gencon
策略中,Java 堆被分成了两部分:
nursery 存储新创建的对象
tenure 存储达到
tenure age
的对象
nursery
被分为了两个部分:allocate
与survivor
。GC 过程如下图所示:
新对象进入
nursery
的allocate
区域allocate
渐渐增长直至完全充满本地清扫程序启动,将所有可达的对象放入到
survivor
,或者如果对象已经到达tenure age
,则直接进入tenure
区域之后
allocate
与survivor
角色互换,先前的allocate
变为survivor
,先前的survivor
则变为allocate
,为下一次 GC 作准备
allocate
和survivor
的相对大小会根据一种叫做tilting
的动态调整技术来进行变化。刚开始allocate
和survivor
的大小是五五开的,在清理过程中如果发现哪一边所需的空间较小,会对空间进行动态调整以满足 GC 的需求。以此可以尽可能减少 GC 的周期。
其中tenure age
是指对象在allocate
和survivor
的切换过程中存活下来的次数,JVM 会依据此数据来决定对象是否转移到tenure
。可以通过-Xgc:scvTenureAge=<n>
参数来设置初始的tenure age
,后续的tenure age
可能会随着 GC 的进程由 JVM 进行自适应来优化当前的空间使用率。当然如果要关闭tenure age
自适应,可以使用此参数-Xgc:scvNoAdaptiveTenure
。
tenure
默认会被分为两部分:小对象区域(SOA),大对象区域(LOA),SOA 中存放不大于 64KB 的对象,LOA 则相反。如果要禁用 LOA,可以使用-Xnoloa
参数。
balanced
balanced
GC 策略使用参数-Xgcpolicy:balanced
启用(需注意此策略仅支持 64 位平台)。在此策略下 Java 堆被分为一个个不同的region
(1024 - 2048),这些region
由增量分代收集器单独管理,以减少大堆上的最大暂停时间并提高垃圾回收的效率。此策略将堆进行切分以避免全局的垃圾回收,以此来减少垃圾回收时的长暂停。
balanced
策略类似于HotSpot
中的 G1 收集器。
在虚拟机启动的时候,堆内存会被划分为大小相等的region
,这些region
就是balanced
gc 策略的基本单元。
region
存在如下特点:
由于
region
的特殊性,在一开始就强制限定了对象的最大大小。对象始终被分配在单个
region
内,不会跨region
分配。region
大小始终是 2 的 N 次幂,且是在启动时根据堆的最大值来决定的。虚拟机总是会生成 1024~2048 个
region
基于上述特性我们来看下balanced
gc 策略的 gc 流程。
上图是堆上的region
的划分。其中age
为 0 的是eden
,age
为 24 是old
,中间的region
则分布着 1-23 的age
。
在进行垃圾回收时eden
区总是会参与其中,而old
只在少数情况下会被加入其中。当进行过一次垃圾回收后,age
为 N 的幸存者会被放入到age
为 N+1 的区域中。然后随着时间的推移,可用的幸存区域会变得越来越少,之后到了某个时间节点就需要进行全局标记清理整个堆。
大多数的对象可以很轻松的存放入region
中,但是也有少部分的大对象没法正常存储在region
中,因此提供了Arraylets
来处理当前情况。
Arraylets
Arraylets
是用来解决大对象无法在单个region
中存储的问题的。Arraylets
会有一个结构Spine
,其中存放着类指针和大小,其中还包含Arrayoids
指向各个叶子结点。以此可以将大对象进行切分,存储到不同的region
中。
optavgpause
optavgpause
(optimize for pause time)策略使用参数-Xgcpolicy:optavgpause
来启用。此策略可以减少 GC 暂停时间,但是会牺牲部分吞吐量。
optavgpause
策略使用平面的 Java 堆。全局 GC 进行循环并发mark-sweep
标记清除操作。由于其全局并发处理的特性,会显著减少 GC 暂停时间,但是会大大影响吞吐量。
optthruput
optthruput
(optimize for throughput)策略使用参数-Xgcpolicy:optthruput
来启用。此策略和optavgpause
策略有着类似的设计,只是此策略专注于吞吐量的优化,因此虽然提升了吞吐量,但是会有较高的 GC 暂停时间。
optthruput
策略使用平面的 Java 堆。全局 GC 使用mark-sweep
进行循环标记清除操作。由于不是并发清理,因此需要对堆进行独占访问,导致应用程序线程在操作发生时停止。因此,可能会出现长时间的 GC 停顿。
metronome
metronome
策略使用参数-Xgcpolicy:metronome
来启用,其只支持linux x86-64
和AIX平台
。此策略是一种具备较短暂停时间的增量的,确定的垃圾回收策略。
metronome
策略会在堆上分配连续的范围,将这些划分为大小相等的区域,通常为 64Kb。其中每个区域中只存放大小相等的对象或者是arraylet
。这种形式简化了对象分配和空间合并的,以此保证 GC 的吞吐量。
如何选择合适的 GC 策略
如何使用 OpenJ9
如果之前是在使用HotSpot JVM
想要尝试一下OpenJ9
,那么可以参考本章节的建议。
目前OpenJ9
支持 jdk8,jdk11 和 jdk17。由于OpenJ9
遵循了虚拟机规范,因此在大部分的场景下不需要过多的变动。
启动项
要想尝试OpenJ9
,那么首先需要考虑到的是其启动项和其他虚拟机的不同之处。不过OpenJ9
在这方面做了兼容,绝大部分的HotSpot JVM
启动项都能够在OpenJ9
中直接使用,除了少部分。
堆参数
在OpenJ9
中所有涉及到堆的设置的参数都是需要注意的,这些参数名称虽然和HotSpot JVM
一样,但是其包含的意义会有所不同,因为两者的 GC 策略会有不同之处。但是可以简单的将 GC 策略gencon
理解为分代收集,balanced
理解为 G1,配置就大同小异了。可以参考这些链接:xmn xms
这里会有一个不同之处,OpenJ9
可以通过设置xmo来设置gencon
中的tenure
的值。
dump
在OpenJ9
中提供了-Xdump
参数,用于进行 JVM 的诊断,此参数用于替代-XX:HeapDumpPath
和-XX:+HeapDumpOnOutOfMemory
等参数,功能更加强大。当然旧的这些dump
参数OpenJ9
也做了支持,完全可以不做变动。
等价参数
以下是在HotSpot
与OpenJ9
中等价的参数
GC 策略
详情可以参照上文的 GC 章节
大致上来说使用默认的 GC 策略即可,配置也可以使用默认配置。
云原生支持
OpenJ9
提供了-Xtune:virtualized
参数来用于云原生的环境,此设置可以在云原生环境下以牺牲少量的吞吐量为代价来节省 cpu 资源。
k8s
在 k8s 场景下,如果想要使用共享类缓存的话需要为 pod 创建共享存储卷,来打通不同的 pod 之间的共享机制。
总结
OpenJ9
主打的是节约资源与快速启动。而在微服务和云原生广泛应用的当下,节约资源正是切合了当下很多企业降本增效的想法。如果大家有兴趣的话,建议可以尝试下使用OpenJ9
。
在新技术与新概念层出不穷的当下,我们面临的环境与挑战也与以往有了不同,因此有了一些针对不同场合,为了解决不同问题的JVM
应运而生,或许在不久的将来,就不再会是HotSpot
独占鳌头,而是各大不同的虚拟机各领风骚的时代。让我们不断关注吧!
参考资料
[1] https://developer.ibm.com/articles/garbage-collection-tradeoffs-and-tuning-with-openj9/
[2] https://developer.ibm.com/tutorials/j-class-sharing-openj9/
[3] https://www.eclipse.org/openj9/docs/aot/
[4] https://www.eclipse.org/openj9/docs/gc/
[5] https://www.eclipse.org/openj9/docs/shrc/
[6] https://blog.openj9.org/2019/05/01/double-map-arraylets/
版权声明: 本文为 InfoQ 作者【骑牛上青山】的原创文章。
原文链接:【http://xie.infoq.cn/article/d164d6a5d2e0e44f9b784bdc1】。文章转载请联系作者。
评论