在 JDK 9 之前,Java 基本上平均每三年出一个版本。但是自从 2017 年 9 月份推出 JDK9 到现在,Java 开始了疯狂更新的模式,基本上保持了每年两个大版本的节奏。从 2017 年至今,已经发布了一个版本到了 JDK 19。其中包括了两个 LTS 版本(JDK11 与 JDK17)。除了版本更新节奏明显加快之外,JDK 也围绕着云原生场景的能力,推出并增强了一系列诸如容器内资源动态感知、无停顿GC(ZGC、Shenandoah)、运维等等云原生场景方面的能力。这篇文章是 EDAS 团队的同学在服务客户的过程中,从云原生的角度将相关的功能进行整理和提炼而来。希望能和大家一起认识一个新的 Java 形态。
上一篇 (《 从 JDK 9 到 19,我们帮您提炼了和云原生场景有关的能力列表(上) 》)我们讲了在整个演进过程中针对运行时模型和运维能力的一些重要变化,这一节我们主要是来讲讲内存相关的变化。
JVM 自从诞生以来,以 “内存自动管理” 和 “一次编译到处运行” 两个杀手锏能力,外加 Spring 这个超级生态,在企业应用开发领域中一直处于“人人模仿,从未超越”的江湖地位。内存的自动管理从技术角度,用一句通俗的语言进行简述就是:“根据设计好的堆内存布局模型,采用一定的跟踪识别与清理的算法,达到内存自动整理及回收的效果”。而一代代内存管理技术不断演进的目标,就是在不断提升并发与降低延时的同时,寻找资源利用最优的方案,从某种意义上说,如果我们不带来一些突破性的算法,这个三者的关系如同分布式中的 CAP 定理一样,很难兼得。如下图所示:
在 JVM 中,内存管理 趋近等同于 GC,GC 也是 Java 程序员获得一份工作时必考的知识点。其中 CMS 从 1.4 版本(2002年)开始引入,一度成为最为经典的 GC 算法。然而从 JDK9 开始发起弃用 CMS 的 JEP 提案,到 2020 年初发布的 JDK14 完全从代码中抹除,意味着在他成年之际正式宣告了他历史使命的结束。那么到现在我们又应该从什么角度上去理解这一技术领域的发展方向,今后面试官又会从哪些方面对我们发起提问,是不管技术如何演进,能确定的是变化主线是围绕着三个方向进行,分别是:堆内存布局、线程模型、收集行为。EDAS 团队经过一段时间整理出来了这篇文章,我们也将从三个点出发进行分享,希望能给大家一些启发。
JVM 堆内存布局最为经典的是分代模型,即年轻代和老年代进行区分,不同的区域采用的回收算法和策略也完全不一样。在一个在线应用(如微服务形态)的 request <-> response 模型中,所产生的对象(Object)绝大多数是瞬时存活的对象,所以大部分的对象在年轻代就会被相对简单、轻量、且高频的 Minor GC 所回收。在年轻代中经过几次 Minor GC 若依然存活则会将其晋升到老年代。在老年代中,相比较而言由于对象存活多、内存容量大,所以所需要的 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 阶段,业务线程可以通过内存读屏障(Read Barrier),来矫正对象在此阶段因为被重新分配到新区域后的指针变化,进而进一步减少 STW 的时间。
收集行为是指的在识别出需要被收集的对象之后,JVM 对于对象和所在内存区域如何进行处理的行为。从早期版本至今,大致分为以下几个阶段:
1、Mark Copy:是指直接将存活对象从原来的区域拷贝至另外一个区域。这是一种典型的空间换时间的策略,好处显而易见:算法简单、停顿时间短、且调参优化容易;但同时也带来了近乎一倍的空间闲置。在早期的 GC 算法使用的是经典的分代模型。其中对于年轻代 Survivor 区的收集行为便是这种策略。
2、Mark Sweep:为了减少空间成倍的浪费,其中一个策略就是在原有的区域直接对对象 Mark 后进行擦除。但由于是在原来的内存区域直接进行对象的擦除,应用进程运行久了之后,会带来很多的内存碎片,其结果是内存持续增长,但真实利用率趋低。
3、Mark Sweep-Compact: 这是对于 Mark Sweep 的一个改良行为,即擦除之后会对内存进行重新的压缩整理,用以减少碎片从而提升内存利用率。但是如果每次都进行整理,就会延长每次 FullGC 后的 STW 时间。所以 CMS 的策略是通过一个开关(-XX:+UseCMSCompactAtFullCollection默认开) 和一个计数器(-XX:CMSFullGCsBeforeCompaction默认值为 0) 进行控制,表示 FullGC 是否需要做压缩,以及在多少次 FullGC 之后再做压缩。这个两个配置配合业务形态去做调优能起到很好的效果。
4、Mark Sweep-Compact-Free: JVM 的应用有一个“内存吞噬器”的恶名,原因之一就是在进程运行起来之后,他只会向操作系统要内存从来不会归还(典型只借不还的渣男)。不过这些在现代化的分区模型算法中开始有了改善,这些算法在 FullGC 之后,可以将整理之后的内存以区域(Region)为粒度归还给操作系统,从而降低这一个进程的资源水位,以此来提升整个宿主机的资源利用率。
对于云原生场景云原生的内在推动力之一是让我们的业务工作负载最大化的利用云所带来的技术红利,云带来最大的技术红利就是通过弹性等相关技术,带来我们资源的高效交付和利用,从而降低最终的经济成本。所以如何最大化的利用资源的弹性能力是很多技术产品所追求的其中一个目标。
这一小节,我们抽取了 JDK9 - JDK 19 中内存相关的代表性能力,分别是:G1 NUMA-Aware、Elastic Metaspace、ZGC Uncommit Unused Memory。和大家一起感受一下 JVM 在新的技术趋势下如何拥抱和改变。
现代化的服务器大多是属于多 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 在 JDK 14 中得到了这一能力的支持,可通过参数 -XX:+UseNUMA开启,开启之后,G1 会尽量将固定大小的各个 Region 均摊在所有能分配的 CPU Node 中,在分配新对象时,将优先使用同一 Node 内的“本地”内存的 Region,如果“本地”内存 Region 不够时,将对此 Region 触发一次 GC;如果还不够,再按照 CPU 的远近尽量获取相邻 Node 的 Region。此策略只针对 G1 中新生代的内存区域生效。老年代区域和大对象区域还是沿用默认的策略。
Metaspace 是用来存储 JVM 中类的元数据信息,包括类中的运行时数据结构、类中使用到的成员以及方法信息。他的前身是永久代,也就是 PermGen。这一变化是 JDK 8 中重要的一个升级的能力之一。从 JEP 122 中提议并落地。这个 JEP 带来的具体的变化可以参考下图:
取消了永久代之后,带来两个变化如下:
当一个 ClassLoader 加载一个对象时,所需要的空间从空闲的 Chunk 中分配一个或多个固定大小的块,如未找到则向操作系统重新申请一个 Chunk。当某一个 ClassLoader 中所有的类都被卸载的时候,就可以将它所引用的内存块都归还给 Chunk。等到对应 Chunk 完全处于“空闲”状态的时候,这个 Chunk 也就就可以被操作系统回收。
看到这里我们先暂停一下,思考两个问题:
在 JDK 16 中发布的 JEP 387 中,专门针对带来的新问题做了一些改进:
伙伴系统把所有的空闲页框分组为固定个数的块链表,每个块链表分别包含固定大小为 1K, 2K, 4K, .... 4M 大小的块。当应用程序向系统申请对应的内存大小时,系统将从最接近所需大小的链表中进行分配。
有一些 ClassLoader(如:BoostrapClassLoader)往往需要很多的空间,但是他真正使用并不是从一开始启动就需要,而且甚至是永远都不需要。
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。