网站建设 麦肯趋势,新北方app下载,软件开发外包合同,房子装修设计软件经历了数千次改进#xff0c;Java 的垃圾回收在吞吐量、延迟和内存大小方面有了巨大的进步。
2014 年3 月 JDK 8 发布#xff0c;自那以来 JDK 又连续发布了许多版本#xff0c;直到今日的 JDK 18 是 Java 的第十个版本。借此机会#xff0c;我们来回顾一下 HotSpot JVM 的…经历了数千次改进Java 的垃圾回收在吞吐量、延迟和内存大小方面有了巨大的进步。
2014 年3 月 JDK 8 发布自那以来 JDK 又连续发布了许多版本直到今日的 JDK 18 是 Java 的第十个版本。借此机会我们来回顾一下 HotSpot JVM 的垃圾回收器的发展全过程。 1. 关于垃圾回收、度量和取舍 HotSpot JVM 中负责管理应用程序堆的组件叫做“垃圾回收器”Garbage Collector即GC。GC 负责管理应用程序堆对象的整个生命周期从应用程序分配内存到内存被回收都由 GC 负责。 从高层来看JVM 垃圾回收算法的最基本功能如下 当应用程序请求分配内存时GC 负责提供内存。提供内存的过程应尽可能快 GC 检测应用程序不再使用的内存。这个操作也应当十分高效不应消耗太多时间。这种不再使用的内存称为“垃圾” GC 将同一块内存再次提供给应用程序最好是“实时”也就是要快。 好的垃圾回收算法还有更多的需求但这三条是最基本的也足以支撑本文的讨论了。
满足这些需求有很多方法但很不幸我们并没有一蹴而就的方法也没有能一次性解决所有需求的方法。因此JDK 提供了多种垃圾回收算法以供选择每种算法适用于不同的场景。这些算法的实现基本上可以根据吞吐量、延迟和内存大小这三个性能度量以及对应用程序的影响进行归类。 吞吐量指的是单位时间内能够完成的工作量。在此语境下垃圾回收算法的优劣取决于能在单位时间内完成的回收工作量这些算法可以让 Java 应用程序实现更高的吞吐量 延迟指的是单次操作所需时间。垃圾回收算法需要尽可能减小延迟。在垃圾回收的语境下关键点就是垃圾回收期是否会导致暂停、暂停的范围以及暂停的时长 在垃圾回收的语境下内存大小指的是为了让垃圾回收期正常工作需要在正常的应用程序堆内存之外再额外占用多少内存。如果 GC或更一般地JVM需要的内存很少就可以给应用程序堆留出更多内存。 这三个度量是互相关联的高吞吐量的垃圾回收器可能会严重影响延迟但对应用程序的影响最小。为了降低内存消耗我们需要采用在其他度量方面不是那么出色的算法。延迟较低的回收期需要并行进行更多工作或以更小的单位进行工作这就会消耗更多处理器资源。 这些关系通常可以画成一个三角形如图1所示。每个垃圾回收算法占据三角形的一个角。 图1. GC 性能度量三角 提高 GC 在某方面的表现通常会导致其他方面的表现降低。 2. JDK 18 中的 OpenJDK GC OpenJDK 提供了五种 GC分别专注于不同的性能度量。表 1 列出了 GC 的名称、专注领域以及实现特定特性所使用的核心概念。 表1. OpenJDK的五种GC Parallel GC 是 JDK 8 以及更早版本的默认回收期。它专注于吞吐量尽快完成工作而很少考虑延迟暂停。 Parallel GC 会在 STW全局暂停期间以更紧凑的方式将正在使用中的内存移动复制到堆中的其他位置从而制造出大片的空闲内存区域。当内存分配请求无法满足时就会发生 STW 暂停然后JVM完全停止应用程序运行投入尽可能多的处理器线程让垃圾回收算法执行内存压缩工作然后分配请求的内存最后恢复应用程序执行。 Parallel GC 也是一个分代回收器旨在最大化垃圾回收效率。本文稍后会详细讨论分代式回收的思想。 G1 GC 是JDK 9 以后的默认回收期。G1 试图平衡吞吐量和延迟。一方面在 STW 暂停期间依然会利用分代继续执行内存回收工作从而最大化效率这一点和 Parallel GC 相同但是它还会尽可能避免在暂停期间执行需要较长时间的操作。 G1 的长时间操作会与应用程序并行进行即通过多线程方式在应用程序运行时执行。这样可以大幅度减少暂停代价是整体的吞吐量会降低一点。 ZGC 和 Shenandoah GC 专注于用吞吐量换延迟。这两种回收器会尝试在不进行明显的暂停的前提下完成所有垃圾回收工作。目前这两者都不是分代式的。它们的非实验性版本分别于 JDK 15 和 JDK 12 引入。 Serial GC 专注于内存大小和启动时间。这个 GC 像是更简单、更慢的 Parallel GC它在 STW 暂停期间仅使用一个线程完成所有工作。堆也是按照分代组织的。但是 Serial GC 占用的内存更小、启动速度更快。由于它更简单所以更适合小型、短时间运行的应用程序。 OpenJDK 还提供了另一个名为 Epsilon 的 GC。为什么没有在表 1 中列出呢因为 Epsilon 只执行内存分配从不进行内存回收因此不满足 GC 的所有条件。但是Epsilon 适合一些非常特殊的应用程序。 3. G1 GC 简介 G1 GC 于 JDK 6 update 14 作为实验特性引入从 JDK 7 update 4 开始正式支持。从JDK 9 开始G1 由于其多用性成了 HotSpot JVM 的默认垃圾回收器它非常稳定、成熟维护也非常活跃而且一直在改进。 那么G1 是如何在吞吐量和延迟之间进行平衡的呢 一项关键技术就是分代垃圾回收。该技术利用了一个特点最近分配的对象很可能可以立即回收即它们“死亡”得更快。所以 G1以及其他分代式 GC将 Java 的堆分为两个区域一个叫做“青年代”用于存放刚刚分配的对象另一个叫做“老年代”用于存放经历了几次垃圾回收后依然存活的对象从而减少回收时所需的操作。 通常青年代要比老年代小得多。因此回收青年代的开销更小再加上G1这种跟踪式的垃圾回收器在回收青年代对象时通常只会处理活跃对象这就意味着青年代的垃圾回收一般非常快而且能回收大量内存。 在某个时间点长时间存活的对象会被移动到老年代中。 因此随着老年代不断增长我们也需要对其进行垃圾回收。由于老年代一般很大而且通常包含相当多的活跃对象对其进行回收需要花费很长时间。例如Parallel GC的完全回收过程通常需要消耗青年代回收数倍的时间。 因此G1 将老年代垃圾回收过程分成了两个阶段。 G1首先跟踪活跃对象这一操作与Java应用程序并行进行。这样从老年代回收内存的大量操作就不需要在垃圾回收暂停期间执行了从而减小延迟。不过实际的内存回收操作如果一次性完成的话对于大型应用程序的堆而言依然需要大量时间。 因此G1会增量式地从老年代回收内存。在跟踪了活跃对象之后在接下来的几次对青年代进行回收的同时G1会额外对老年代中的一小部分进行压缩这样长期即可达到对年长对象进行回收的效果。 对年长对象进行增量回收比一次性回收如 Parallel GC 的做法的效率略低因为跟踪对象关系图总会不准确而且增量回收所需的数据结构的管理也需要额外的时间和空间开销但这种方式可以有效减小暂停的时长。大致来看增量式垃圾回收所需的时长基本上等于只回收青年代的算法在暂停中所花费的时长。 此外你还可以通过 MaxGCPauseMillis 命令行选项设置两种垃圾回收算法的暂停时长的目标。G1 会尽可能将暂停时长保持在目标以下。默认的时长为 200 毫秒这个值也许不适合你的应用程序但它只是最大值的目标。G1 会尽可能将暂停时长控制在该值以下。因此改善暂停时长的第一步可以从减小 MaxGCPauseMillis 开始。 4. 从 JDK 8 到 JDK 18 的进步 介绍完了 OpenJDK 的 GC我们来进一步看看在过去 10 次 JDK 发布中GC 在吞吐量、延迟和内存大小三个性能度量方面的进步。 G1 的吞吐量增长。为了演示吞吐量和延迟方面的进步本文采用了 SPECjbb2015 基准测试。SPECjbb2015 是一个衡量 Java 服务器性能的常用业界测试它包含了一系列各种各样的操作。该测试包含两个度量 maxjOPS 是系统能够提供的最大事务数量。这是吞吐量的度量指标 criticaljOPS 测量在几个特定的服务级别协议SLA下的吞吐量比如从 10 毫秒到 100 毫秒的响应时间。 本文采用 maxjOPS 作为比较不同 JDK 版本的吞吐量的基准采用实际暂停时长的改进量作为比较延迟的基准。虽然 criticaljOPS 也表明了暂停时长引起的延迟但该指标还包含其他来源的延迟。直接比较暂停时长可以避免这个问题。 图 2 展示了 G1 在组合模式下在一个 16GB 的 Java 堆上的 maxjOPS 结果图中给出了JDK 8、JDK 11 和 JDK 18 的对比。可以看出JDK 版本越新吞吐量得分就越高。JDK 11 比 JDK 8 高出了约 5%而 JDK 18 高出了约 18%。简单来说JDK 版本越新用于应用程序实际工作的资源就越多。 图2. G1d 的吞吐量增长利用 SPECjbb2015 的 maxjOPS 测量 下面我们着重讨论垃圾回收的改进对于吞吐量增长的贡献。但是其他的一般性改进如代码编译也对垃圾回收的性能——特别是吞吐量的增长——有很大的贡献所以垃圾回收的改进并不是唯一的贡献者。 JDK 9 之前的一个重大改进是 G1 采用了懒惰式老年代回收它会尽可能推迟回收操作。 在 JDK 8 中用户需要手动设置 G1 何时应该对老年代回收中的活跃对象进行并行跟踪。如果时机设置得太早JVM 在回收操作开始之前并没有用完所有分配给老年代的堆内存如此老年代中的对象并没有得到足够多的时间从而变成可回收的状态。因此 G1 不仅需要更多的处理资源来分析其活跃状态因为许多数据依然处于活跃中还要做许多额外的工作才能从老年代中释放内存。 另一个问题是如果开始老年代回收的时机太晚JVM就可能会耗尽内存从而导致内存回收过程极其缓慢。从JDK 9开始G1会自动决定开始老年代跟踪的最佳时机甚至还会自动适配应用程序的行为。 JDK 9 中实现的另一个思想涉及到 G1 对于老年代中的大型对象的回收频率比其他对象高的现象。与分代的思想类似这是另一个投入产出比很高的想法。毕竟大型对象所占用的内存空间很多。在某些应用程序中尽管不太常见该方法甚至能大幅度减少垃圾回收的次数并降低整体的暂停时长使 G1 的吞吐量大大超过 Parallel GC。 一般来说每次发布都会包含一些改进减小垃圾回收在执行同样操作时的暂停时长。这样就会自然地改善吞吐量。还有许多可以写在本文中的改进接下来我们在讨论延迟改进时会提到一些。 与 Parallel GC 类似从 JDK 14 开始G1 在 Java 堆上分配内存时可以独立地感知非统一性内存访问NUMA。从那时起在拥有多内存插槽且各个内存的访问时间不一致的机器上也就是说内存访问与内存插槽有关即某些内存访问更慢G1 会尽可能利用本地性。 有了 NUMA 感知后G1 GC 会假设在某个内存节点上由单个线程或线程组分配的对象基本上被来自同一个节点的其他对象引用。因此当对象属于青年代时G1 会将对象保持在同一节点上甚至还会将老年代中的长时间生存的对象分布到不同节点上以最小化访问时间的不一致性。这与 Parallel GC 的实现类似。 还有一个我想讨论的改进是关于一些罕见情况的比如完整回收。正常情况下G1 会调整内部参数尽力避免完整回收但是在一些极端情况下G1 会在暂停期间进行完整回收。直到 JDK 10 之前该算法都是单线程的所以非常慢。而目前的实现与 Parallel GC 的完整回收过程不相上下。它依然很慢依然应当尽力避免但比以前已经好多了。 Parallel GC 的吞吐量增长。关于 Parallel GC图3给出了从 JDK 8 到 JDK 18 中 maxjOPS 的改进结果堆的设置与之前的测试相同。同样即使是 Parallel GC仅仅替换 JVM 也可以获得大约2%的吞吐量提升最好情况下甚至能提升 10%。提升比 G1 小因为 Parallel GC 原本的起点就很高因此增长较小。 图3. Parallel GC 的吞吐量增长用 SPECjbb2015 的 maxjOPS 度量 G1 的延迟改进。为了演示 HotSpot JVM GC 在延迟方面的改进本节采用了 SPECjbb2015 基准测试负载固定然后测量其暂停时长。Java 堆设置为 16GB。表 2 总结了暂停时长的平均值和第 99 百分位值P99以及在 200 毫秒的默认暂停时长目标值下不同 JDK 的相对暂停总时长。 表2 默认的200毫秒暂停时长下的延迟改进 JDK 8 的暂停平均时长为 124 毫秒P99 为 176 毫秒。JDK 11 将平均时长提高到了 111 毫秒P99 提高到了 134 毫秒总体减少了 15.8% 的暂停时长。JDK 18 再次显著改善平均时长减少到了 89 毫秒P99 减小到了 104 毫秒总时长减小了 34.4%。 我扩展了试验范围增加了JDK 18 下暂停时长设置为 50 毫秒因为之前随意设置的 -XX:MaxGCPauseMillis 为 200 毫秒还是太长了。平均来看G1 达到了暂停时长的目标P99 垃圾回收暂停时长为 56 毫秒见表 3。总体上与 JDK 8 相比暂停花费的总时间并没有增加太多0.06%。 换句话说将 JDK 8 JVM 替换成 JDK 18 JVM就能获显著降低平均暂停时长同时还有可能在同样的暂停时长目标下提升吞吐量或者将 G1 的暂停时长保持在更低的水平50 毫秒而暂停总时长保持不变同时保持相同的吞吐量。 表 3. 将暂停时长目标设置为 50 毫秒后的延迟改进 表 3 的结果是自从 JDK 8 以来大量改进的结果。下面是最值得一提的改进。 降低延迟的许多改进都用在了减小收集老年代对象所需的元数据上。“记住的集合”remembered sets的数据结构得到了大幅度删减部分原因是数据结构的精简另一部分是不存储永远不会用到的数据。在今天的计算机体系架构中减小元数据意味着更小的内存访问开销能够带来性能的提升。 有关“记住的集合”的另一个方面是人们改进了查找指向堆中当前被移动的区域的引用的算法使其更容易并行化。G1 不再并行遍历整个数据结构并在内层循环中过滤掉重复数据而是分别并行地过滤掉重复数据再并行地处理剩余数据。这样可以让两个步骤都更有效、更容易并行化。 进一步处理记住的集合的过程也被仔细分析删减了不必要的代码优化了常用路径。 JDK 8 之后的另一个焦点是通过一个暂停来改进任务的并行化。人们尝试将任务的多个阶段并行化或将较小的顺序阶段变成更大的并行阶段以此避免不必要的同步从而改进并行化。人们在这方面投入了大量资源来改进并行阶段的负载平衡性这样如果某个线程没有任务时它会尝试从其他线程那里获取任务。 此外后续的JDK开始着手更罕见的情况其中之一就是内存移动失败evacuation failure。如果会在垃圾回收时没有足够的空间复制对象时就会发生内存移动失败。 ZGC 的垃圾回收暂停。如果你的应用程序需要更短的垃圾回收暂停时长可以参考表 4该表比较了 G1 与另一个专注于暂停时长的垃圾回收期 ZGC。该表采用的负载与前面相同。最右边一列给出了 ZGC 的暂停时长。 表 4. ZGC 与 G1 的延迟比较 ZGC 实现了亚毫秒级别的暂停时长目标它的全部内存回收工作都与应用程序并行执行。只有部分不重要的工作依然需要暂停。可以想象这些暂停非常短暂在上述情况下暂停时长甚至远远低于 ZGC 声称的毫秒级别。 G1 的内存占用改进。本文的最后一项指标就是 G1 垃圾回收算法的内存占用方面的改进。此处算法的内存大小指的是垃圾回收算法为了正常工作在正常的 Java 堆之外所需的额外内存大小。 对于 G1 来说除了依赖于 Java 堆大小的静态数据大小大约为 Java 堆尺寸的 3.2%另一个主要的内存消耗来源是“记住的集合”它负责分代垃圾收集以及老年代的增量垃圾收集处理。 会给G1的记住的集合带来压力的应用之一是对象缓存。每当对象缓存增加或删除新的缓存项时都会在堆上的老年代中不断生成区域之间的引用。 图 4 展示了从 JDK 8 到 JDK 18 中G1 的原生内存占用情况测试应用程序实现了一个对象缓存对象表示缓存信息对象可以被查询、添加并以最近最少使用LRU的方式从一个更大的堆中删除。本例中的Java堆为20GB使用了JVM的原生内存跟踪NMT机制来确定内存使用情况。 图 4. G1 GC 的原生内存大小 在 JDK 8 中经过了短暂的预热阶段后G1 原生内存使用稳定在 5.8GB 左右。JDK 11在此基础上将原生内存代销降低到了 4GB 左右JDK 17 进一步改进到 1.8GB而 JDK 18 稳定在 1.25GB。额外内存使用量从 JDK 时代的 30% 堆大小降低到了 JDK 18 时代的 6% 左右。 如前所示这些改进并没有造成吞吐量下降或延迟提升。实际上G1 GC 减小元数据也给其他度量带来了提升。 从 JDK 8 到 JDK 18这些改进的主要原则是将垃圾回收元数据严格维持在仅保存必须数据的限度。因此G1 会并行地重建并管理内存尽快释放数据。JDK 18 对元数据的表现方式和存储也进行了改进存储得更紧密因此有效降低了内存大小。 图4还表明在新版的 JDK 中G1 更为积极会主动查找稳态操作的高峰和低谷中的差异更积极地将内存交还给操作系统。在最新的版本中G1 甚至会并行执行该操作。 5. 垃圾回收的未来 尽管很难预测未来会怎样、以后会有多少垃圾回收方面的项目但 G1 很可能会在 HotSpot JVM 中实现下面这些改进。 人们在努力解决的问题之一是在原生代码使用 Java 对象时会阻止垃圾回收的进行。如果有任何区域引用了原生代码中使用的 Java 对象触发垃圾回收的 Java 线程就必须等待。最糟糕的情况下原生代码甚至会阻止垃圾回收长达数分钟。这会导致开发人员完全避免使用原生代码从而大幅度影响吞吐量。JEP 423 给出了解决方案因此 G1 GC 很快就能解决该问题。 与 Parallel GC 相比G1 GC 的另一个已知问题是它会影响吞吐率。根据用户报告在极端情况下影响甚至会达到 10%~20%。问题的原因是已知的人们已经提出了几种在不影响 G1 GC 其他方面的品质的前提下的解决方案。 最近人们还发现暂停时长和暂停期间的负载分散的效率依然不是最优的。 最近人们的焦点是将 G1 的最大的辅助数据结构标记位图削减一半。G1 算法使用了两个位图用于确定哪些对象活跃可以安全地并行检查。一项仍在讨论的建议表明这两个位图之一可以通过其他方式取代。这就能将G1的元数据削减至一半大小至 Java 堆大小的1.5%。 ZGC 和 Shenandoah GC 也有很多在积极开发的项目着眼于将这两个垃圾回收器改造成分代式垃圾回收器。在许多应用中这两个 GC 的单分代设计在吞吐量和即时性方面有太多的缺陷因此需要更大的堆大小来补偿。 6. 总结 本文展示了 HotSpot JVM 垃圾回收算法从 JDK 8 到 JDK 18 的改进。这些改进非常显著所有三个性能指标包括吞吐量、延迟和内存大小都得到了显著提升。每次 JDK 发布新版本都会带来可见的提升。在可见的未来这种趋势仍将继续所以请期待这些改进吧。 感谢 OpenJDK 各位贡献者们付出的努力。