前言
在JDK9之前,Java基本上平均每三年出一个版本。但是自从2017年9月份推出JDK9到现在,Java开始了疯狂更新的模式,基本上保持了每年两个大版本的节奏。从2017年至今,已经发布了一个版本到了JDK19。其中包括了两个LTS版本(JDK11与JDK17)。除了版本更新节奏明显加快之外,JDK也围绕着云原生场景的能力,推出并增强了一系列诸如容器内资源动态感知、无停顿GC(ZGC、Shenandoah)、运维等等云原生场景方面的能力。这篇文章是EDAS团队的同学在服务客户的过程中,从云原生的角度将相关的功能进行整理和提炼而来。希望能和大家一起认识一个新的Java形态。
JVM GC 发展回顾
JVM自从诞生以来,以"内存自动管理"和"一次编译到处运行"两个杀手锏能力,外加Spring这个超级生态,在企业应用开发领域中一直处于"人人模仿,从未超越"的江湖地位。内存的自动管理从技术角度,用一句通俗的语言进行简述就是:"根据设计好的堆内存布局模型,采用一定的跟踪识别与清理的算法,达到内存自动整理及回收的效果"。而一代代内存管理技术不断演进的目标,就是在不断提升并发与降低延时的同时,寻找资源利用最优的方案,从某种意义上说,如果我们不带来一些突破性的算法,这个三者的关系如同分布式中的CAP定理一样,很难兼得。如下图所示:
在JVM中,内存管理趋近等同于GC。其中CMS从1.4版本(2002年)开始引入,一度成为最为经典的GC算法。然而从JDK9开始发起弃用CMS的JEP提案,到2020年初发布的JDK14完全从代码中抹除,意味着在他成年之际正式宣告了他历史使命的结束。那么到现在我们又应该从什么角度上去理解这一技术领域的发展方向,不管技术如何演进,能确定的是变化主线是围绕着三个方向进行,分别是:堆内存布局、线程模型、收集行为。我们将从这三个点出发进行分析。
堆内存布局的变化
JVM堆内存布局最为经典的是分代模型,即年轻代和老年代进行区分,不同的区域采用的回收算法和策略也完全不一样。在一个在线应用(如微服务形态)的request<->response模型中,所产生的对象(Object)绝大多数是瞬时存活的对象,所以大部分的对象在年轻代就会被相对简单、轻量、且高频的MinorGC所回收。在年轻代中经过几次MinorGC若依然存活则会将其晋升到老年代。在老年代中,相比较而言由于对象存活多、内存容量大,所以所需要的GC时间相对也会很长,同时由于每一次的回收会伴随着长时间的Stop-The-World(简称STW)出现。在内存需求比较大且对于时延和吞吐要求很高的应用中,其老年代的表现就会显得捉襟见肘。而且由于不同的分代所采用的回收算法一般都不一样,随着业务复杂度的增加,GC行为变得越来越难以理解,调优处理也就愈发的复杂。
单纯从堆内存布局来理解,一个简单的逻辑是内存区域越小,回收效率越高,经典分代模型中的Young区已经印证了这一点。为了解决上述问题,G1算法横空出世,引出基于区域(Region)的布局模型,带来的变化是内存在物理上不再根据对象的"年龄"来划分布局,而是默认全部划分成等大小的Region和专门用来管理超级大对象的独占Region,年轻代和老年代不再是一个物理划分,只是一个Region的一个属性。直观理解上,除了能管理的内存更大(G1理论值64G)之外,这样带来一个显而易见的好处就是可以预控制一次FullGC的STW的时间,因为Region大小一致,则可以根据停顿时间来推算这次GC需要回收的Region个数,而没有必要每次都将所有的Region全部清理完毕。
随着这项技术的进一步发展,到了现代化的Pauseless(ZGC)的算法场景中,有些算法暂时没有了分代的概念,同时Region按照大小划分了Small/Medium/Large三个等级,更精细的Region管理,也进一步来更少的内存碎片和内存利用率的提升、及其STW停顿时间更精准的预测与管理。
线程模型变化
说线程模型之前,先简单提一下GC线程与业务线程,GC线程是指JVM专门用来处理GC相关任务的线程,这在JVM启动时就已经决定。在传统的串行算法中,是指只有一个GC线程在工作。在并行(Parallel)的算法中,存在多个GC线程一起工作的情况(CMS中GC线程个数默认是CPU的核数)。同时一些算法的某些阶段中(如: CMS的并发标记阶段),GC线程也可以和业务线程一起工作;这个机制就缩短了整体STW的时间,这也是我们所说的并发(Concurrent)模式。
在现代化的GC算法中,并不是所有和GC相关的任务都只能由GC线程完成,如ZGC中的Remap阶段,业务线程可以通过内存读屏障(ReadBarrier),来矫正对象在此阶段因为被重新分配到新区域后的指针变化,进而进一步减少STW的时间。
收集行为变化
收集行为是指的在识别出需要被收集的对象之后,JVM对于对象和所在内存区域如何进行处理的行为。从早期版本至今,大致分为以下几个阶段:
-
Mark Copy: 是指直接将存活对象从原来的区域拷贝至另外一个区域。这是一种典型的空间换时间的策略,好处显而易见:算法简单、停顿时间短、且调参优化容易;但同时也带来了近乎一倍的空间闲置。在早期的GC算法使用的是经典的分代模型。其中对于年轻代Survivor区的收集行为便是这种策略。
Mark Copy.png -
Mark Sweep:为了减少空间成倍的浪费,其中一个策略就是在原有的区域直接对对象 Mark 后进行擦除。但由于是在原来的内存区域直接进行对象的擦除,应用进程运行久了之后,会带来很多的内存碎片,其结果是内存持续增长,但真实利用率趋低。
Mark Sweep.png - Mark Sweep-Compact: 这是对于Mark Sweep的一个改良行为,即擦除之后会对内存进行重新的压缩整理,用以减少碎片从而提升内存利用率。但是如果每次都进行整理,就会延长每次 FullGC 后的 STW 时间。所以 CMS 的策略是通过一个开关(
-XX:+UseCMSCompactAtFullCollection
,默认开起) 和一个计数器(-XX:CMSFullGCsBeforeCompaction
,默认值为 0) 进行控制,表示 FullGC 是否需要做压缩,以及在多少次 FullGC 之后再做压缩。这个两个配置配合业务形态去做调优能起到很好的效果。
Mark Sweep-Compact.png -
Mark Sweep-Compact-Free: JVM的应用有一个“内存吞噬器”的恶名,原因之一就是在进程运行起来之后,他只会向操作系统要内存从来不会归还(典型只借不还的渣男)。不过这些在现代化的分区模型算法中开始有了改善,这些算法在FullGC之后,可以将整理之后的内存以区域(Region)为粒度归还给操作系统,从而降低这一个进程的资源水位,以此来提升整个宿主机的资源利用率。
Mark Sweep-Compact-Free.png
扩展-内存优化
JEP 345: G1 NUMA-Aware
现代化的服务器大多是属于多Node的架构,下图表示有4个Node,每一个Node内部都会有相应的CPU(有的架构会有多个CPU)和对应的物理内存条。当CPU访问访问本Node内部的物理内存进行"本地访问"时,其速度是通过QPI访问其他节点内存时的速度接近两倍,同时不同远近Node的访问速度也都不一样。在开启NUMA的情况下,每个Node内的CPU将优先使用同Node内的"本地"内存,否则系统将所有Node内的内存统一对待进行随机分配和访问。
既然Numa的作用是CPU将尽量访问"本地"内存以加速内存访问速度,常规场景下如果我们需要使用这个能力,在系统开启Numa的前提下,我们还需要对运行的程序进行绑核调优等操作,以将应用程序运行的进程和CPU有一个绑定关系。要达到这一效果,除了系统提供了一些运维管理工具(如linux中的taskset命令)之外,程序也可以通过调用系统API(如linux中的pthread_setaffinity)。在JVM多线程的模型中,如果想要通过自动编程的方式来进行CPU绑定,当下只能选择带有特定能力的商业版本,在OpenJDK中还不能很方便的完成这一能力。
那JVM内对于Numa能做什么呢?这里有一个假设,在一个线程内运行的对象大部分都是瞬时的(即这个对象的作用域跟随创建它的线程(或Runnable)的运行结束而消亡),原因和我们在上面介绍堆内存布局模型时的新生代的选择是一样逻辑。基于这个假设,JVM主要聚焦在了解决新生代的内存分配和访问的Numa感知上。其实JVM对于Numa的支持很多年前就开始了,在YoungGC的并行(Parrallel)收集器(通过-XX:+UseParallelGC
开启)中。开启Numa之后,JVM优先选择Node内部的"本地"内存进行新对象的创建。
在云原生场景下,一个Kubernetes集群通常托管高规格的机器、同时高密的部署的小规格的工作负载,这个场景下,一个工作负载一直运行在同一个CPU或固定几个 CPU 的场景会变得越来越普遍。如果JVM再把整个Worker的内存不加区分的对待并进行分配,我们的内存访问性能势必会急剧下跌。如下图所示:
G1 算法通过 JEP 345在JDK14中得到了这一能力的支持,可通过参数
-XX:+UseNUMA
开启,开启之后,G1会尽量将固定大小的各个Region均摊在所有能分配的CPU Node中,在分配新对象时,将优先使用同一Node 内的"本地"内存的 Region,如果"本地"内存Region不够时,将对此Region触发一次GC;如果还不够,再按照CPU的远近尽量获取相邻Node的Region。此策略只针对G1中新生代的内存区域生效。老年代区域和大对象区域还是沿用默认的策略。
JEP 387: Elastic Metaspace
Metaspace是用来存储JVM中类的元数据信息,包括类中的运行时数据结构、类中使用到的成员以及方法信息。他的前身是永久代,也就是PermGen。这一变化是JDK8中重要的一个升级的能力之一。从JEP122中提议并落地。这个JEP带来的具体的变化可以参考下图:
取消了永久代之后,带来两个变化如下:
- 存储信息调整:将类中定义的常量和字符串常量池(Interned String)放入到了堆中,Metaspace 只存储类元数据信息,即:
- Klass信息,描述类的基础属性和类的继承关系等;
- NonKlass信息,包含方法、内部类信息、成员变量定义等。
- 内存布局调整:与之前在堆中开辟一块区域相比,Metaspace 是直接使用操作系统的本地内存进行分配,本地内存划分成多个 Chunk,以 ClassLoader 为维度进行分配和管理。
当一个 ClassLoader 加载一个对象时,所需要的空间从空闲的 Chunk 中分配一个或多个固定大小的块,如未找到则向操作系统重新申请一个 Chunk。当某一个 ClassLoader 中所有的类都被卸载的时候,就可以将它所引用的内存块都归还给 Chunk。等到对应 Chunk 完全处于"空闲"状态的时候,这个 Chunk 也就就可以被操作系统回收。
看到这里我们先暂停一下,思考两个问题:
- 他为什么这么调整?
从JEP的描述,只提到了因为需要和JRockit(原OracleBEAJVM)做融合,而JRockit的设计中并没有永久代。而从时间上看,正好是发生在Oracle收购Sun之后。所以一个猜想就是这个变化的根因应该是组织推动大于技术驱动。当然从技术上这样带来的好处也很显而易见:不再有负载的Perm设置;元空间和堆空间完全隔离后,两边的GC不会相互影响;单次FullGC因为扫描区域更小而使得STW时间更短;按照Chunk设计的构想,在类被卸载时,有助于JVM释放一些内存给操作系统等等。 - 有没有带来新问题?
有,就是在一些应用程序中会出现多种类频繁的加载/卸载的场景下, 导致 Metaspace所管理的Chunk会不停的更新和释放而造成很严重的内存碎片,碎片整理机制的缺失导致理想中的效果并未达到。最终造成了更多的内存浪费。
在JDK 16中发布的JEP 387中,专门针对带来的新问题做了一些改进:
- 首先:减少碎片,内存管理从内置的Arena Chunk内存管理算法,改为了简单且经典的伙伴算法,对,Linux 操作系统的内存管理就是基于伙伴算法的。
伙伴系统把所有的空闲页框分组为固定个数的块链表,每个块链表分别包含固定大小为 1K, 2K, 4K, .... 4M 大小的块。当应用程序向系统申请对应的内存大小时,系统将从最接近所需大小的链表中进行分配。
- 其次:按需使用,等到真正使用内存的时候才向操作系统发起内存申请,而不是一开始就申请出来一块很大的空间。
有一些 ClassLoader(如:BoostrapClassLoader)往往需要很多的空间,但是他真正使用并不是从一开始启动就需要,而且甚至是永远都不需要。
- 第三:增加策略,为了防止频繁的向操作系统 申请/释放 内存带来额外的系统开销,新引入了一个命令行参数
-XX:MetaspaceReclaimPolicy=(balanced|aggressive|none)
来进行调整。
其中 balance 是默认选项,会在系统回收和时间消耗之间做平衡,更多是兼容之前的行为。aggressive 是一种最为 “激进” 的回收策略,通过在回收时降低对应页框大小至 16K(默认64K),使回收内存粒度更细来降低碎片。而 none 则是关闭回收行为。
JEP 351: ZGC Uncommit Unused Memory
ZGC在JDK 11时被引入,它是一款基于内存区域(Region) 布局的垃圾回收器,我们可以通过 -XX:+UseZGC进行开启。作为一款主打Pauseless的现代化的收集器,ZGC 相比于G1除了提供了三个不同大小的Region (2M/4M/8M,而G1为一个固定大小的值)进行管理之外,还因为在GC整理阶段提供了内存读屏障来矫正对象指针的技术使得最终的 STW 时间更短。但是在JDK 14之前,被清理的 Region 还是无法归还给操作系统,相比G1在JDK9中就提供了类似的能力滞后了两年多。
简单概述一下,这个JEP指的是每次GC结束,JVM都会尝试将释放一部分内存归还给操作系统。但是如上一章节介绍Elastic Metaspace章节一样,频繁的向操作系统申请/归还只能带来更多的系统开销,如何取舍是一门艺术。那么该如何选择是否有操作手段呢?请先看下面这张图:
首先,系统提供了一个额外的JVM的调整参数(SoftMaxHeapSize)来控制回收的行为,这个值应该在 -Xms 和 -Xmx 之间,当系统使用的内存低于这个值时,就是正常的收集行为,即只会进行清理和压缩。而大于这个值但是小于 -Xmx 时,FullGC 结束之后就会尝试回收空闲的内存区域(Region) 归还给操作系统。达到的效果是 ZGC 将尽量保证整体堆内存水位处于这个值之下。默认情况下这个值和 -Xmx 的大小是一致的。同时由于这个值是一个可动态调整(managable)的变量,随着系统的运行,当我们发现需要进行调整的时,在认真评估之后,可以通过
jcmd VM.set_flag SoftMaxHeapSize <bytes>
命令动态进行调整。
其次,上述方案虽然很完美的将选择权交给了应用管理人员,但是运行的过程中也会出来一种情况:如果应用真实的使用量如果恰好在 SoftMaxHeapSize上下徘徊的时候,会造成很频繁的系统内存的申请和释放。这个时候提供了另外一个策略,就是可以通过-XX:ZUncommitDelay
来设置一个回收之前的延时,即不在GC结束马上进行尝试回收,而是等一段时间(默认5分钟)后再进行回收,以免造成误伤。