Java优化【Optimizing Java】:高级垃圾回收

在上一章中,我们介绍了Java垃圾收集的基本理论。从那个起点开始,我们将进一步介绍现代Java垃圾收集器的理论。这是一个不可避免的权衡区域,它可以指导着工程师对收集器进行选择。

首先,我们将介绍并深入研究HotSpot JVM提供的其他收集器。这包括超低暂停的并发收集器(CMS)和现代通用收集器(G1)。
我们还将考虑一些比较少见的收集器。它们是:

  • Shenandoah
  • C4
  • Balanced
  • Legacy HotSpot collectors

并不是所有这些收集器都在HotSpot虚拟机中使用——我们还将讨论另外两个虚拟机的收集器:IBM J9 (IBM正在开源的一个以前的封闭源代码JVM)和Azul Zing(一个专有JVM)。我们在“Meet the JVMS”中已经介绍了这两个vm。

7.1 Tradeoffs and Pluggable Collectors

初学者并不总是立即认识到Java平台的一个方面是,虽然Java有垃圾收集器,但语言和VM规范并没有说明应该如何实现GC。 事实上,已经有Java实现(例如,Lego Mindstorms)根本没有实现任何类型的GC!

在Sun(现在的Oracle)环境中,GC子系统被视为可插拔的子系统。 这意味着相同的Java程序可以在不改变程序语义的情况下使用不同的垃圾收集器执行,尽管程序的性能可能会根据使用的收集器而有很大差异。

拥有可插拔收集器的主要原因是GC是一种非常通用的计算技术。特别是,相同的算法可能不适用于每个工作负载。因此,GC算法代表了相互竞争的关注点之间的折衷或折衷。

NOTE:没有一种通用的GC算法可以同时优化所有GC关注点。

在选择垃圾收集器时,开发人员经常需要考虑的主要问题包括:

  • 暂停时间(即暂停长度或持续时间)
  • 吞吐量(GC时间占到应用程序运行时的百分比)
  • 暂停频率(收集器停止应用程序的频率)
  • 回收效率(在单个GC工作循环中可以收集多少垃圾)
  • 暂停一致性(所有暂停大致相同的长度?)

其中,暂停时间往往会吸引过多的注意力。虽然它对许多应用程序很重要,但不应该孤立地考虑它。
TIP:对于工作负载很高的应用而言,暂停时间不是有效或有用的性能特征。

例如,高度并行的批处理或大数据应用程序可能更关心吞吐量,而不是暂停时间。对于许多批处理作业,甚至几十秒的暂停时间都不是真正相关的,因此支持GC的CPU效率和吞吐量的GC算法比不惜任何代价的低暂停算法更受欢迎。

性能工程师还应该注意到,在考虑如何选择收集器时,还有许多其他权衡和关注点有时很重要。 但是,在HotSpot的情况下,这种选择受到可用收集器的限制。

在Oracle / OpenJDK中,从版本10开始,有三种主流收集器用于一般生产用途。 我们已经遇到了并行(也就是吞吐量)收集器,从理论和算法的角度来看它们是最容易理解的。 在本章中,我们将会遇到另外两个主流收集器,并解释它们与Parallel GC的区别。

在本章的最后,在“Shenandoah”和其他章节中,我们还将看到一些可用的收集器,但是请注意,并不是所有的收集器都推荐用于生产使用,有些现在已经被弃用。我们还简要讨论了非HotSpot JVM中可用的收集器。

7.2 Concurrent GC Theory:并发GC理论

在特定的系统中,比如图形或动画显示系统,通常有固定的帧速率,这为GC的执行提供了固定的机会。

然而,用于通用用途的垃圾收集器没有这样的领域知识,从而无法改进其暂停的确定性。更糟糕的是,不确定性是由内存分配行为直接导致的,并且Java用于的许多系统显示了高度可变的内存分配。

这种安排的不足之处在于计算本身的延迟;它的主要缺点是这些垃圾收集间隔的不可预测性。  ---Edsger Dijkstra

现代GC理论的出发点是试图解决Dijkstra的观点,即STW暂停(无论是持续时间还是频率)的不确定性本质是使用GC技术的主要烦恼。

一种方法是使用并发(或至少部分或大部分并发)的收集器,以便在应用程序线程运行时执行一些收集所需的工作,从而减少暂停时间。这不可避免地降低了应用程序实际工作可用的处理能力,并使执行收集所需的代码复杂化。

然而,在讨论并发收集器之前,有一个重要的GC术语和技术我们需要解决,因为它对于理解现代垃圾收集器的性质和行为非常重要。

JVM Safepoints

为了执行STW垃圾收集,例如HotSpot的并行收集器执行的垃圾收集,必须停止所有应用程序线程。这看起来是一个恒真的命题【tautology】,但直到现在我们还没有详细讨论JVM如何实现这一目标。

JVM实际上并不是一个完全抢占式的多线程环境。 ----秘密

这并不意味着它纯粹是一种协作环境——恰恰相反。操作系统仍然可以在任何时候抢占(从核心【core】删除线程)。例如,当线程耗尽了它的时间片或将自己放入wait()中时,就会执行此操作。

除了这个处理器核心操作系统功能之外,JVM还需要执行一些协作的操作。为了方便实现这一点,运行时要求每个应用程序线程都有称为safepoints的特殊执行点,在该点,线程的内部数据结构处于已知的良好状态。在这些时候,线程可以挂起以进行协作的操作。

NOTE:我们可以在STW GC(经典示例)和线程同步中看到安全点【safepoints】的影响,但是还有其他影响。

要理解safepoints的意义,请考虑完全STW垃圾收集器的情况。要运行它,需要一个稳定的对象图。这意味着必须暂停所有应用程序线程。但是GC线程无法要求OS在应用程序线程上强制执行此需求,因此应用程序线程(作为JVM进程的一部分执行)必须协作来实现这一点。有两个主要规则来管理JVM的安全点方法:

  • JVM不能强迫线程进入safepoint状态。
  • JVM可以防止线程离开safepoint状态。

这意味着JVM解释器的实现必须包含代码,以便在需要安全点时在屏障处生成代码。对于JIT编译的方法,必须在生成的机器码中插入等效的屏障。到达安全点的一般情况是这样的:

  • JVM设置一个全局的“安全点时间【time to safepoint】”标志。
  • 各个应用程序线程轮询,以查看是否设置了该标志(即安全点时间标志)。
  • 各个线程停下来,等待再次被唤醒。

设置此标志后,所有应用程序线程必须停止。快速停止的线程必须等待较慢停止的线程(在暂停时间统计数据中,这段时间可能没有被完全计算在内)。

普通的应用程序线程使用这种轮询机制。它们总是在解释器中执行任意两个字节码之间进行检入。在编译后的代码中,JIT编译器为safepoints插入轮询的最常见情况是退出编译后的方法,以及循环向后分支(例如,到循环的顶部)。

一个线程可能需要很长时间才能到达safepoint,甚至在理论上永远不会停止。
NOTE:在STW阶段开始之前,必须完全停止所有线程的想法类似于使用锁存器,例如java.util.concurrent中的CountDownLatch实现的锁存器。

这里值得一提的是安全点【safepoint 】条件的一些具体案例。
如果以下情况,则线程自动处于安全点:

  • 在监视器上被阻塞
  • 正在执行JNI代码

如果以下情况,则线程不一定处于安全点:

  • 在执行字节码(解释模式)的过程中
  • 被操作系统中断了

稍后我们将再次讨论安全点机制,因为它是JVM内部工作的关键部分。

Tri-Color Marking:三色标记

Dijkstra和Lamport在1978年的论文中描述了他们的三色标记算法,这对于并发算法和GC的正确性证明都是一个里程碑,文中描述的基本算法仍然是垃圾收集理论的重要组成部分。

算法是这样工作的:

  • GC Root部呈灰色。
  • 所有其他对象都是白色的。
  • 标记线程移动到随机灰色节点。
  • 如果节点有任何白色子节点,则标记线程首先将它们着色为灰色,然后将节点着色为黑色。
  • 重复该过程直到没有灰色节点。
  • 所有的黑色对象都被证明是可达的,并且必须保持活动状态。
  • 白节点符合收集条件,并对应于不再可访问的对象。

虽然有些复杂,但这是算法的基本形式。图7-1显示了一个示例。
在这里插入图片描述
图7-1 三色标记

并发收集还经常使用一种称为开始时快照【snapshot at the beginning】(SATB)的技术。这意味着,如果对象在垃圾收集周期的开始时是可达的,或者从那以后他们就被内存分配了,那么收集器将其视为活动对象。这为算法增加了一些细微的褶皱,例如,如果一个集合正在运行,mutator线程需要创建处于黑色状态的新对象;如果没有正在进行的集合,则需要创建处于白色状态的新对象。

三色标记算法需要与少量额外工作相结合,以确保运行的应用程序线程引入的更改不会导致活动对象被收集。 这是因为在并发收集器中,应用程序(mutator)线程正在更改对象图,而标记线程则在执行三色算法。

考虑这样一种情况:一个对象已经被标记线程涂成黑色,然后被一个mutator线程更新为指向一个白色对象。如图7-2所示。
在这里插入图片描述
图 7-2. A mutator thread could invalidate tri-color marking

如果从灰色对象到新的白色对象的所有引用现在都被删除了,那么我们就会遇到这样的情况:根据算法的规则,白色对象仍然是可到达的,但是会被删除,因为它不会被找到。

这个问题可以用几种不同的方法来解决。例如,我们可以将黑色对象的颜色更改为灰色,将其添加回mutator线程处理更新时需要处理的节点集。

这种使用“写屏障【write barrier】”进行更新的方法具有很好的算法特性,它可以在整个标记周期中保持三色不变量。

在并发标记期间,黑色对象节点不能持有对白色对象节点的引用。  --Tri-color invariant

另一种方法是保持一个包含所有可能违反不变量的更改的队列,然后在主阶段完成后运行一个辅助的“修复”阶段。不同的收集器可以根据性能或所需锁的数量等标准,以不同的方式使用三色标记解决此问题。

在下一节中,我们将介绍低暂停收集器CMS。尽管它是一个适用性有限的收集器,但我们比其他收集器更早地介绍了这个收集器。这是因为开发人员常常不知道GC调优需要权衡和妥协的程度。

通过首先考虑CMS,我们可以展示性能工程师在考虑垃圾收集时应该注意的一些实际问题。我们的希望是,这将导致一种更基于证据的调优方法,以及在选择收集器时的固有权衡,并减少民间传说中的调优。

7.3 CMS

并发标记和扫描【 Concurrent Mark and Sweep】(CMS)收集器被设计为只针对永久代(也就是老年代)空间的极低暂停的收集器。它通常与稍作修改的并行收集器配对,用于收集年轻代(称为ParNew,而不是Parallel GC)。

CMS在应用程序线程仍在运行时尽可能多地执行工作,从而最小化暂停时间。使用的标记算法是三色标记的一种形式,这当然意味着,当收集器扫描堆时,对象图可能正在发生变化。因此,CMS必须修复它的记录,以避免违反垃圾收集器的第二条规则,收集了仍然活动的对象。

这导致CMS中的阶段比并行收集器中更为复杂。这些阶段通常称为:

  • Initial Mark (STW):初始标记(STW)
  • Concurrent Mark:并行标记
  • Concurrent Preclean:并发预清理
  • Remark (STW):重新标记(STW)
  • Concurrent Sweep:并发扫描
  • Concurrent Reset:并发重置

对于大多数阶段,GC都是与应用程序线程一起运行的。但是,对于两个阶段(初始标记【Initial Mark】和重新标记【Remark 】),必须停止所有应用程序线程。总体效果相当于是将一个较长的STW暂停替换为两个通常很短的STW暂停。

初始标记【Initial Mark】阶段的目的是为区域内的GC提供一组稳定的起点;它们被称为内部指针【internal pointers】,并针对垃圾收集周期目而提供与GC Root相同的集合。这种方法的优点是,它允许标记阶段集中于单个GC pool,而不必考虑其他内存区域

初始标记【Initial Mark】完成后,并发标记【Concurrent Mark】阶段开始。这实际上是在堆上运行三色标记算法,跟踪以后可能需要修复的任何更改。

并发预清理【Concurrent Preclean】阶段似乎试图尽可能缩短STW重新标记【Remark】阶段的长度。重新标记【Remark】阶段使用卡片表【card tables】来修复在并发标记【 Concurrent Mark】阶段可能受到mutator线程影响的标记。

对于大多数工作负载,使用CMS的可观察效果如下:

  • 应用程序线程不会停止很长时间。
  • 单个full GC循环需要更长的时间(在挂钟时间内)。
  • CMS GC周期运行时,应用程序吞吐量会降低。
  • GC使用更多内存来跟踪对象。
  • 执行GC需要相当多的CPU时间。
  • CMS不会压缩堆,因此Tenured可能会变得支离破碎。

细心的读者会注意到,并非所有这些特征都是积极的。请记住,对于GC,没有银弹,只有一组适合(或可接受)工程师正在调整的特定工作负载的选项。

How CMS Works

CMS最常被忽视的一个方面就是它的强大实力。 CMS主要与应用程序线程同时运行。 默认情况下,CMS将使用一半的可用线程来执行GC的并发阶段,而另一半则让应用程序线程执行Java代码——这不可避免地涉及分配新对象。这听起来似乎是显而易见的,但它有直接的后果。 如果在CMS运行时Eden填满了会发生什么?

毫不奇怪,答案是,由于应用程序线程不能继续,它们会暂停,并在CMS运行时同时运行一个(STW)young GC。这个young GC运行时间通常比并行收集器的运行时间要长,因为它只有一半的内核可以用于年轻代GC(另一半内核运行CMS)。

在这个年轻代GC结束时,一些对象通常有资格提升到Tenured(老年代/永久代)。 当CMS仍在运行时,这些提升的对象需要移动到Tenured,这需要两个收集器之间的一些协调。 这就是为什么CMS需要一个略有不同的年轻代收集器。

在正常情况下,年轻代的GC只会将少量对象提升到老年代,而CMS的老年代的GC会正常完成,从而释放出老年代中的空间。然后应用程序返回正常处理,为应用程序线程释放所有的CPU核心。

但是,考虑到内存分配非常高的情况,可能会导致过早提升。这可能会导致这样一种情况,即young GC中有太多的对象需要提升,以占用永久代的空间。如图7-3所示。
在这里插入图片描述
图 7-3. Concurrent mode failure from allocation pressure

这称为并发模式故障(concurrent mode failure, CMF),此时JVM别无选择,只能回退使用ParallelOld(它是完全STW的)。实际上,由于内存分配压力非常大,以至于CMS没有时间在所有“headroom”空间上容纳新提升的对象之前完成对老年代的处理。

为了避免频繁的并发模式故障(concurrent mode failure, CMF),CMS需要在永久代完全满之前启动垃圾收集周期。CMS触发垃圾回收时永久代堆占用水平应该处于何种水平,这是由由观察到的堆的行为控制的。它可能会受到切换的影响,并且默认为Tenured的75%。

还有一种情况可能导致并发模式失败,这就是堆碎片。与ParallelOld不同,CMS在运行时不会压缩永久代。这这意味着在完成CMS运行之后,Tenured中的可用空间不是单个连续块,并且提升的对象必须填充到现有对象之间的间隙中。

在某些情况下,年轻代GC可能会遇到这样一种情况:由于缺少足够的连续空间来将对象复制到其中,因此无法将对象提升为永久对象。如图7-4所示。
在这里插入图片描述
图 7-4. Concurrent mode failure from fragmentation
这是由堆碎片引起的并发模式故障,和以前一样,惟一的解决方案是回退以使用ParallelOld(该收集器是由压缩功能的),因为这会释放足够的连续空间来提升对象到永久代。

在堆碎片情况下和young GC超过CMS的情况下,对于应用程序来说,需要回退到具有完整STW的ParallelOld可能是一个主要事件。事实上,使用CMS进行应用程序低延迟调优,以避免遭受CMF之苦,这本身就是一个主要话题。

在内部,CMS使用内存块的空闲列表来管理可用的空闲空间。在最后一个阶段,并发清理【Concurrent Sweep】,连续的空闲块将被清理线程合并。这是为了提供更大的空闲空间块,并尽量避免碎片导致的CMFs。

然而,清理器与mutators同时运行。因此,除非清理器和分配器线程正确同步,否则新分配的块可能会被错误地清理。为了防止这种情况,清理器在清理时锁定空闲列表。

Basic JVM Flags for CMS

CMS收集器使用此标志激活:

-XX:+UseConcMarkSweepGC

在HotSpot的现代版本中,此标志也将激活ParNewGC(它是并行年轻代收集器的一个变体版本)。

通常,CMS提供了大量可以调整的标志【Flag】(超过60个)。 有时候,通过仔细调整CMS提供的各种选项来尝试优化性能的基准测试练习是很诱人的。这应该被抵制,因为在大多数情况下,这实际上是伪装更大的图片或民间传说调整(参见“性能反模式目录”)反模式。

我们将在 “Tuning Parallel GC”中更详细地介绍CMS调优。

7.4 G1

G1(“Garbage First”收集器)是一种与并行收集器或CMS风格非常不同的收集器。它最初是在Java 6中以一种高度实验性和不稳定的形式引入的,但是在Java 7的整个生命周期中被广泛重写,直到Java 8u40的发布才真正变得稳定和生产就绪。
TIP:我们不建议在8u40之前的任何Java版本中使用G1,无论考虑的工作负载类型如何。

G1最初打算作为低暂停收集器的替代品,即:

  • 比CMS更容易调优
  • 不易过早提升
  • 能够在大堆上实现更好的扩展行为(特别是暂停时间)
  • 能够消除(或大大减少)完全STW收集的需要

然而,随着时间的推移,G1逐渐被认为是一种通用的收集器,在更大的堆上有更好的暂停时间(越来越多的人认为这是一种“新常态”)。
NOTE:Oracle坚持认为G1成为Java 9中的默认收集器,它取代了并行收集器,而不考虑对最终用户的影响。因此,性能分析人员很好地理解G1是非常重要的,任何从8移动到9的应用程序都将作为迁移的一部分进行适当的重新测试。

G1收集器的设计重新考虑了我们到目前为止遇到的分代的概念。与并行或CMS收集器不同,G1没有每一代专用的、连续的内存空间。此外,它不遵循我们将看到的半球形堆布局。

G1 Heap Layout and Regions

G1堆基于区【regions】的概念。这些区默认大小为1 MB(但是在更大的堆上更大)。区的使用允许非连续的分代,并使收集器在每次运行时不需要收集所有垃圾成为可能。

NOTE:总的G1堆在内存中仍然是连续的——只是组成每一代的内存不再是连续的。

G1堆的基于区的布局如图7-5所示。
在这里插入图片描述
图 7-5. G1 regions

G1的算法允许区的大小可以是任意的1、2、4、8、16、32或64 MB,默认情况下,它期望堆中有2048到4095个区,并将调整区大小来实现这一点。

为了计算区大小,我们可以像下面这样来计算这个值:

<Heap size> / 2048

然后四舍五入到最近的允许区的大小值。区的数目可以这样计算:

Number of regions = <Heap size> / <region size>

通常,我们可以通过应用运行时开关【runtime switch】来更改这个值。

G1 Algorithm Design

G1收集器的掠影:

  • 使用并发标记【concurrent marking】阶段
  • 是一个疏散收集器
  • 提供了“统计压缩”

在预热过程中,收集器跟踪每个GC工作周期可以收集多少个“标准的”区的统计数据。如果能够收集足够的内存来平衡自上次GC以来分配的新对象,那么G1就不会输给内存分配。

TLAB的分配、疏散到幸存者空间和提升到永久代的概念与我们已经遇到的其他HotSpot GCs非常相似。

NOTE:占用内存空间超过半个区的大小的对象被认为是巨大的物体。它们直接分配到特殊的巨大区【humongous regions】,这些区域是自由的、连续的区域,它们立即成为永久代(而不是Eden)的一部分。

G1仍然有一个由Eden和survivor区组成的年轻代的概念,但是当然组成年轻代的区域在G1中不是连续的。年轻代的大小是自适应的,并基于总体暂停时间目标。

回想一下,当我们遇到ParallelOld收集器时,在“弱分代假设”中讨论了启发式的“从旧对象到年轻对象的引用很少”。HotSpot使用一种名为card tables的机制来帮助在并行和CMS收集器中利用这种现象。

G1收集器有一个相关的特性来帮助区域跟踪。Remembered Sets(通常称为rset)是跟踪指向堆区域的外部引用的每个区域条目。这意味着G1不需要遍历整个堆来查找指向某个区域的引用,只需要检查rset,然后扫描这些区域来查找引用。

图7-6显示了G1中用于分配器和收集器之间划分GC工作的方法,是如何通过rset实现的。

Figure 7-6. Remembered Sets

rset和card table都是可以帮助解决称为floating garbage的GC问题的方法。这是由当前收集集之外的死对象的引用保持活动状态而导致的问题。也就是说,在全局标记上,它们可以被视为死的,但更有限的局部标记可能错误地将它们报告为活动的,这取决于所使用的根集。

G1 Phases、

G1有一组与我们已经见过的阶段有些相似的阶段,特别是对于CMS:

  1. Initial Mark (STW):初始标记(STW)
  2. Concurrent Root Scan:并发Root扫描
  3. Concurrent Mark:并发标记
  4. Remark (STW):重新标记
  5. Cleanup (STW):清理
    Concurrent Root Scan(并发Root扫描)是一个并发阶段,它扫描在初始标记中引用了老年代的幸存区。在开始下一个young GC之前,这个阶段必须完成。在Remark阶段,完成标记周期。此阶段还执行引用处理(包括弱引用和软引用)并处理与实现SATB方法相关的清理。

清理主要是STW,包括记帐和RSet“清洗”。记账任务识别出现在完全空闲且可以重用的区域(例如Eden区域)。

Basic JVM Flags for G1

您需要启用G1(在Java 8及之前)的开关是:

+XX:UseG1GC

回想一下G1是基于暂停目标的。这允许开发人员指定应用程序在每个垃圾收集周期中应该暂停的最大时间。这表示为一个目标,并且不能保证应用程序能够满足它。如果将此值设置得太低,则GC子系统将无法满足此目标。

NOTE:垃圾收集是由内存分配驱动的,这对于许多Java应用程序来说是高度不可预测的。这可能会限制或破坏G1实现暂停目标的能力。

控制收集器核心行为的开关为:

-XX:MaxGCPauseMillis=200

这意味着默认的暂停时间目标是200 ms。在实践中,100 ms以下的暂停时间很难可靠地实现,收集器可能无法实现这些目标。另一个可能有用的选项是改变区大小,覆盖默认算法:

-XX:G1HeapRegionSize=<n>

注意,必须是2的幂,在1到64之间,并且以MB表示一个值。

总的来说,G1作为一种算法是稳定的,Oracle完全支持它(推荐使用8u40)。对于真正的低延迟工作负载,它仍然没有大多数工作负载的CMS那么低暂停,并且不清楚它是否能够在纯暂停时间上挑战CMS这样的收集器;然而,收集器仍在改进,它是JVM团队中Oracle GC工程工作的重点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值