JVM详解

37 篇文章 4 订阅
32 篇文章 1 订阅

JVM运行时数据区

JVM:从软件层面屏蔽了底层硬件、指令层面的细节,保证了跨平台。

JRE:Java程序运行环境,JDK:Java程序的开发环境. JDK: JRE + 工具 。JRE: JVM + 类库(javaSE)。

每个Java应用程序有一个Runtime类的实例,使运行程序与运行环境相连接,Runtime的exec(string)方法可以执行dos命令。通过Runtime的getRuntime返回实例。

程序计数器:指向当前线程正在执行的字节码指令的地址(行号)。而线程只负责执行,不负责记忆。

栈帧:栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。方法嵌套调用方法会调用的时候压栈其他栈帧,后进先出。递归的时候也是多个栈帧,所以会出现stackOverflowError。

虚拟机栈:存储当前线程运行方法时所需要的数据、指令、返回地址。

本地方法栈:Native Method用到的栈,类似虚拟机栈,非java编写,比如c/c++,一般用于操作底层的硬件。在java中通过本地方法接口也就是带native修饰符的方法来调用本地方法。

方法区:类信息(class文件)、静态常量,对应的GC模型CMS里的永久代。

Heap:堆。

每个线程有自己的程序计数器、虚拟机栈、本地方法栈,多个线程共享方法区和堆。

关于值类型和引用类型:

垃圾收集

现代 JVM 的类型繁多,最主流的四个垃圾收集器分别是:Serial 收集器(常用于单 CPU 环境)、Throughput(或者 Parallel)收集器、Concurrent 收集器(CMS)和 G1 收集器。简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存,JVM 从查找不再使用的对象(垃圾对象)入手,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。然而,这种靠引用计数的方式不太靠谱:假设有一个对象链接列表,列表中的每一个对象(除了头节点)都指向列 表中的另一个对象,但是,如果没有任何一个引用指向列表头,这个列表就没人使用,可 以被垃圾回收器回收。如果这是一个循环列表(即列表的尾元素反过来又指向了头元素), 那么列表中的每一个元素都包含一个引用,即使这个列表内没有任何一个对象实际被使 用,因为没有任何一个对象指向这个列表。所以引用是无法通过计数的方式动态跟踪的;JVM 必须定期地扫描堆来查找不再使用的对象。一旦发现垃圾对象,JVM 会回收这些对象所持有的内存,把它们分配给需要内存的其他对象。然而,简单地记录空闲内存也无法保证将来有足够的可用内存,有些时候,我们还必须进行内存整理来防止内存碎片。

所有应用线程都停止运行所产生的停顿被称为时空停顿stop-the-world)。通常这些停顿对 应用的性能影响最大,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

分代垃圾收集器:虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将 堆划分成不同的代(Generation)。这些代被称为“老年代”(Old Generation Tenured Generation)和“新生代”(Young Generation)。新生代又被进一步地划分为不同的区段, 分别称为 Eden 空间和 Survivor 空间(不过 Eden 有时会被错误地用于指代整个新生代)。采用分代机制的原因是很多对象的生存时间非常短。新生代是堆的一部分,对象首先在新生 代中分配。新生代填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用 的对象会被回收,仍然在使用的对象会被移动到其他地方。这种操作被称为 Minor GC。采用这种设计有两个性能上的优势。

其一,由于新生代仅是堆的一部分,与处理整个堆相 比,处理新生代的速度更快。而这意味着应用线程停顿的时间会更短。你可能也看到了这 其中的权衡,这意味着应用程序线程会更频繁地发生停顿,因为 JVM 不再等到整个堆都 填满才进行垃圾收集;然而,就目前而言, 更短的停顿显然能带来更多的优势,即使发生的频率更高。

第二个优势源于新生代中对象分配的方式。对象分配于 Eden 空间(占据了新生代空间的 绝大多数)。垃圾收集时,新生代空间被清空,Eden 空间中的对象要么被移走,要么被回收;所有的存活对象要么被移动到另一个 Survivor 空间,要么被移动到老年代。由于所有的对象都被移走,相当于新生代空间在垃圾收集时自动地进行了一次压缩整理。所有的垃圾收集算法在对新生代进行垃圾回收时都存在“时空停顿”现象。

对象不断地被移动到老年代,最终老年代也会被填满,JVM 需要找出老年代中不再使用 的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的垃圾收集算法直接停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对堆空间进行整 理。这个过程被称为 Full GC,通常导致应用程序线程长时间的停顿。

另一方面,通过更复杂的计算,我们还有可能在应用线程运行的同时找出不再使用的对象;CMS G1 收集器就是通过这种方式进行垃圾收集。由于它们不需要停止应用线程就能找出不再用的对象,CMS G1 收集器被称为 Concurrent 垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器(有时也称 为无停顿收集器,虽然这个叫法相当不确切)。Concurrent 收集器也使用各种不同的方法对老年代空间进行压缩。使用 CMS G1 收集器时,应用程序经历的停顿会更少(也更短)。其代价是应用程序会 消耗更多的 CPUCMS G1 收集也可能遭遇长时间的 Full GC 停顿(尽量避免发生那样 的停顿是这些调优算法要考虑的重要方面)。

GC算法:JVM 提供了以下 4 种不同的垃圾收集算法

1. Serial垃圾收集器

Serial 垃圾收集器是四种垃圾收集器中最简单的一种。如果应用运行在 Client 型虚拟机 (Windows 平台上的 32 JVM 或者是运行在单处理器机器上的 JVM)上,这也是默认的 垃圾收集器。Serial 收集器使用单线程清理堆的内容。使用 Serial 收集器,无论是进行 Minor GC 还是 Full GC,清理堆空间时,所有的应用线程都会被暂停。进行 Full GC 时,它还会对老年代 空间的对象进行压缩整理。通过 -XX:+UseSerialGC 标志可以启用 Serial 收集器。注意,跟大多数的 JVM 标志不同,关闭 Serial 收集器不能简单地将加号符变成减号符(譬如,使用 -XX:-UseSerialGC)。在 Serial 收集器作为默认收集器的系统上,如果需要关闭 Serial 收集器,可以通过指定另一种垃圾收集器来实现。

2. Throughput垃圾收集器

Throughput 收集器是 Server 级虚拟机(多 CPU Unix 机器以及任何 64 位虚拟机)的默认收集器。Throughput 收集器使用多线程回收新生代空间,Minor GC 的速度比使用 Serial 收集器快 得多。处理老年代时 Throughput 收集器也能使用多线程方式。这已经是 JDK 7u4 及之后 的版本的默认行为,对于之前老版本的 JDK 7 虚拟机,通过 -XX:+UseParallelOldGC 标 志可以开启这个功能。由于 Throughput 收集器使用多线程,Throughput 收集器也常常 被称为 Parallel 收集器。Throughput 收集器在 Minor GC Full GC 时会暂停所有的应 用线程,同时在 Full GC 过程中会对老年代空间进行压缩整理。由于在大多数适用的场 景,它已经是默认的收集器,所以你基本上不需要显式地启用它。如果需要,可以使用 -XX:+UseParallelGC、-XX:+UseParallelOldGC 标志启用Throughput 收集器。

3. CMS收集器

CMS 收集器设计的初衷是为了消除 Throughput 收集器和 Serial 收集器 Full GC 周期中 的长时间停顿。CMS 收集器在 Minor GC 时会暂停所有的应用线程,并以多线程的方式 进行垃圾回收。然而,这其中最显著的不同是,CMS 不再使用 Throughput 的收集算法 (-XX:+UseParallelGC),改用新的算法来收集新生代对象(使用 -XX:+UseParNewGC 标志),CMS 收集器在 Full GC 时不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。这种算法帮助 CMS 成为一个低延迟的收集器:应用线程只在 Minor GC 以及后台线程扫描老年代时发生极其短暂的停顿。应用程序线程停顿的总时长与使用 Throughput 收集器比起来短得多。这里额外付出的代价是更高的 CPU 使用:必须有足够的 CPU 资源用于运行后台的垃圾收 集线程,在应用程序线程运行的同时扫描堆的使用情况。除此之外,后台线程不再进行任何压缩整理的工作,这意味着堆会逐渐变得碎片化。如果 CMS 的后台线程无法获得完成他们任务所需的 CPU 资源,或者如果堆变得过度碎片化以至于无法找到连续空间分配对 象,CMS 就蜕化到 Serial 收集器的行为:暂停所有应用线程,使用单线程回收、整理老年 代空间。这之后又恢复到并发运行,再次启动后台线程(直到下一次堆变得过度碎片化)。 通过 -XX:+UseConcMarkSweepGC-XX:+UseParNewGC 标志(默认情况下,这两个标志都是禁用的)可以启用 CMS 垃圾收集器。

4. G1垃圾收集器

G1 垃圾收集器(或者垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于 4 GB)时产生的停顿。G1 收集算法将堆划分为若干个区域(Region),不过它依旧属于分代 收集器。这些区域中的一部分包含新生代,新生代的垃圾收集仍然采用暂停所有应用线程 的方式,将存活对象移动到老年代或者 Survivor 空间。同其他的收集算法一样,这些操作也利用多线程的方式完成。G1 收集器属于 Concurrent 收集器:老年代的垃圾收集工作由后台线程完成,大多数的工 作不需要暂停应用线程。由于老年代被划分到不同的区域,G1 收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,这也意味着在正常的处理过程中,G1 收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用 G1 收集器的堆不大容易发生碎片化——虽然这种问题无法避免。同 CMS 收集器一样,避免 Full GC 的代价是消耗额外的 CPU 周期:负责垃圾收集的多个 后台线程必须能在应用线程运行的同时获得足够的 CPU 运行周期。通过标志 -XX:+UseG1GC(默认值是关闭的)可以启动 G1 垃圾收集器。

触发及禁用显式的垃圾收集

通常情况下垃圾收集是由 JVM 在需要的时候触发:新生代用尽时会触发 Minor GC, 老年代用尽时会触发 Full GC,或者堆空间即将填满时会触发 Concurrent 垃圾收集(如 果情况需要)。Java 也提供了一种机制让应用程序强制进行 GC:这就是 System.gc() 方法。通常情况 下,试图通过调用这个方法显式触发 GC 都不是个好主意。调用这个方法会触发 Full GC(即使 JVM 使用 CMS 或者 G1 垃圾收集器),应用程序线程会因此而停顿相当长 的一段时间。同时,调用这个方法也不会让应用程序更高效,它会让 GC 更早地开始, 但那实际只是将性能的影响往后推迟而已。

任何原则都有例外,尤其是在做性能监控或者基准测试时。运行少量的代码进行基准 测试时,为了更快地预热 JVM,在测量周期之前强制进行一次 GC 还是有意义的。类似的情况还包括在进行堆分析时,通常在获取堆转储之前,强制进行一次 Full GC 是 一个不错的主意。虽然大多数抓取堆转储的方法都能进行 Full GC,也存在其他的方法可以强制进行 Full GC:你可以通过执行 jcmd < 进程号 > GC.run,或者使用 jconsole 连接到 JVM 在内存面板上单击“进行 GC”按钮。 另一个例外是 RMI,作为它分布式垃圾收集器的一部分,每隔一小时它会调用 System.gc() 一次。这里的调用时间可以通过系统属性中的 -Dsun.rmi.dgc.server. gcInterval=N -Dsun.rmi.dgc.cli ent.gcInterval=N 进行修改。N 值的单位以毫秒 记,在 Java 7(该值与之前的版本亦不相同)中的默认值为 3 600 000(即一个小时)。 如果你运行的程序调用的第三方代码中错误地调用了 System.gc() 方法,可以通过 JVM 参数 -XX:+DisableExplicitGC 显式地禁止这种类型的 GC;默认情况下该标志是关闭的。

CMS收集器和G1收集器之间的抉择

GC算法衡量标准是响应时间和吞吐量,上一节的测试使用 CMS 收集器作为 Concurrent 收集器。一般情况下,堆空间小于 4 GB 时,CMS 收集器的性能比 G1 收集器好。CMS 收集器使用的算法比 G1 更简单,因此在比较简单的环境中(譬如堆的容量很小的情况),它运行得更快。使用大型堆或巨型堆时, 由于 G1 收集器可以分割工作,通常它比 CMS 收集器表现更好。

回收任何对象之前,CMS 收集器的后台线程必须扫描完整个老年代空间。显然,扫描完整个堆的时间与堆的大小密切相关。如果堆还未填满之前,CMS 收集器的后台线程就停止了堆的扫描,直接回收对象,CMS 收集器会发生并发模式失效(Concurrent Mode Failure): 一旦发生这样的状况,CMS 收集器就不得不回退,暂停所有的应用线程,进行 Full GC 操 作。这时处理 Full GC 的仅有唯一一个线程,性能的损耗非常严重。虽然通过调优 CMS 收集器,我们可以使用多个后台线程来减少变化带来的损失,不过随着堆的增大,CMS 后台线程需要处理的工作也越多。(CMS 收集器发生并发模式失效同时也会受应用程序的内存分配影响。)

G1 收集器采用了不同的方式来处理这个问题,它将老年代划分成不同的区域(Region), 能更加容易地使用多个线程分担扫描老年代空间的任务。如果后台线程跟不上处理的速度,G1 收集器也会发生并发模式失效,但是 G1 算法已经使得发生这种状况的几率减小了很多。由于 CMS 收集器不对堆进行压缩整理(除非发生了耗时的 Full GC),堆的碎片化也会触 发 CMS 收集器进行 Full GCG1 算法在处理过程中随时进行着堆的压缩整理,不过 G1 收集器依然可能遭遇堆的碎片化问题,但是与 CMS 收集器比较起来,它的设计让它又领先了一步。

调优 CMS 收集器和 G1 收集器避免发生这些失效的方法很多,但对一些应用程序来说却不一定奏效。随着堆的不断增大(发生 Full GC 的代价变得更加昂贵),使用 G1 收集器更易于避免这些问题的发生。最后,在这三种收集器的选择时还有一些微妙的无形因素需要考虑。Throughput 收集器是 这三个收集器中年代最久远的一个,这意味着 JVM 工程师们已经花费了大量的时间精力雕琢把玩它,它的习性也更为大家所熟知。G1 作为相对较新的一种垃圾收集算法,更容易碰到设计时无法预期的极端情况。相对而言,G1 算法中影响性能的调优控制开关更少,这可能是好事,也可能是坏事。直到 Java 7u4G1 都一直被当作实验版本,它的一些调优特性直到 Java 7u10 中才提供出来。相对于 Java 7 及之前的版本而言,G1 的性能提升主要体现在 Java 8 中。G1 将来的工作可能会关注在如何提高它在较小的堆上相对于 CMS 的性能优势。

GC调优基础

调整堆的大小:选择堆的大小其实是一种平衡。如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上,没有足够的时间去运行应用程序的逻辑。但是,简单粗暴地设置一个特别大的堆也不是解决问题的方法。GC 停顿消耗的时间取决于堆的大小,如 果增大堆的空间,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。 使用超大堆还有另一个风险。操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有 8 G 的物理内存,操作系统通过名为“交换”(swapping)(或者称之为分页,虽然这两者之间在技术上存在着差异,但是这些差异在这里不影响我们的讨论)。你可以载入需要 16 G 内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。JVM 不会了解这些:操作系统完全屏蔽了内存交换的细节。这样, JVM 愉快地填满了分配给它的 12 G 堆空间。但这样就导致了严重的性能问题,因为操 作系统需要将相当一部分的数据由磁盘交换到内存(这是一个昂贵操作的开始),更糟糕的是,这种原本期望一次性的内存交换操作在 Full GC 时一定会再次重演,因为 JVM 必须访问整个堆的内容。如果 Full GC 时系统发生内存交换,停顿时间会以正常停顿 时间数个量级的方式增长。类似地,如果使用 Concurrent 收集器,后台线程在回收堆时, 它的速度也可能会被拖慢,因为需要等待从磁盘复制数据到内存,结果导致发生代价昂贵的并发模式失效。

因此,调整堆大小时首要的原则就是永远不要将堆的容量设置得比机器的物理内存还大, 另外,如果同一台机器上运行着多个 JVM 实例,这个原则适用于所有堆的总和。除此之外,你还需要为 JVM 自身以及机器上其他的应用程序预留一部分的内存空间:通常情况 下,对于普通的操作系统,应该预留至少 1 G 的内存空间。

堆的大小由 2 个参数值控制:分别是初始值(通过 -Xms N 设置)和最大值(通过 -Xmx N 设 置)。推荐设置

如果机器的物理内存少于 192 MB,最大堆的大小会是物理内存的一半(大约 96 MB,或 者更少)。堆的大小具有初始值和最大值的这种设计让 JVM 能够根据实际的负荷情况更灵活地调整 JVM 的行为。如果 JVM 发现使用初始的堆大小,频繁地发生 GC,它就会尝试增大堆的空间,直到 JVM GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。一个经验法则是完成 Full GC 后,应该释放出 70% 的空间 (30% 的空间仍然占用)。为了衡量这个结果,你可以持续运行应用程序,直到其到达稳定 态配置:这时它已经载入了需要缓存的所有对象,或者已经创建了最多的客户端连接数, 诸如此类。之后,使用 jconsole 连接应用程序,强制进行 Full GC,观察 Full GC 结束后还有多少内存被占用(此外,对于 Throughput 垃圾收集器,如果有日志的话,你可以通过 查询 GC 日志得到对应的数据)。注意,即使你显式地设置了堆的最大容量,还是会发生堆的自动调节:初始时堆以默认的大小开始运行,为了达到根据垃圾收集算法设置的性能目标,JVM 会逐步增大堆的大小。 将堆的大小设置得比实际需要更大不一定会带来性能损耗:堆并不会无限地增大,JVM 会 调节堆的大小直到其满足 GC 的性能目标。另一方面,如果你确切地了解应用程序需要多大的堆,那么你可以将堆的初始值和最大值直接设置成对应的数值(譬如:-Xms4096m -Xmx4096m)。这种设置能稍微提高 GC 的运行效率,因为它不再需要估算堆是否需要调整大小了。

代空间的调整:一旦堆的大小确定下来,你(或者 JVM)就需要决定分配多少堆给新生代空间,多少给老年代空间。我们应该清楚地了解代的划分对性能的影响:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。任何事物都有两面性, 采用这种分配方法,老年代就相对比较小,比较容易被填满,会更频繁地触发 Full GC。 这里找到一个恰当的平衡点是解决问题的关键。不同的 GC 算法尝试使用不同的方法来解决这些平衡问题。虽然方法不同,不过所有的 GC 方法都使用了同一套标志来设置代的大小,所有用于调整代空间的命令行标志调整的都是新生代空间;新生代空间剩下的所有空间都被老年代占用。多个标志都能用于新生代空间的调整,它们分别如下所列。

-XX:NewRatio=N  设置新生代与老年代的空间占用比率。

-XX:NewSize=N  设置新生代空间的初始大小。

-XX:MaxNewSize=N 设置新生代空间的最大大小。

-XmnN 将 NewSize MaxNewSize 设定为同一个值的快捷方法

最初新生代空间大小是由 NewRatio 指定大小,NewRatio 的默认值为 2。影响堆空间大小的参数通常以比率的方式指定;这个值被用于一个计算空间分配的公式之中。下面是使用NewRatio 计算空间的公式:

Initial Young Gen Size = Initial Heap Size / (1 + NewRatio)

代入堆的初始大小和 NewRatio 的值就能得到新生代的设置值。那么我们很容易得出,默认 情况下,新生代空间的大小是初始堆大小的 33%。 除此之外,新生代的大小也可以通过 NewSize 标志显式地设定。使用 NewSize 标志设定的 新生代大小,其优先级要高于通过 NewRatio 计算出来的新生代大小。NewSize 标志没有默 认的设置(虽然使用 Printflagsfinal 标志输出的值为 1 MB)。NewSize 不设置的情况下, 初始的新生代大小由 NewRatio 计算出的值决定。

 如果堆的大小扩张,新生代的大小也会随之增大,直到由 MaxNewSize 标志设定的最大容 量。默认情况下,新生代的最大值也是由 NewRatio 的值设定的,不过它也同时受制于堆的 最大容量(注意,不是初始大小)。

试图通过指定新生代的最大及最小值区间的方式调优新生代的结果是十分困难的。如果堆 的大小是固定的(可以通过将 -Xms -Xmx 指定为相等的值实现),通常推荐使用 -Xmn 标志将新生代也设定为固定大小。如果应用程序需要动态调整堆的大小,并希望有一个更大 (或者更小)的新生代,那就需要关注 NewRatio 值的设定。

永久代和元空间的调整

JVM 载入类的时候,它需要记录这些类的元数据。从终端用户的角度来看,这些只是一些“书签”信息。这部分数据被保存在一个单独的堆空间中。在 Java 7 里,这部分空 间被称为永久代Permgen Permanent Generation),在 Java 8 中,它们被称为元空间 (Metaspace)。不过永久代和元空间并不完全一样。Java 7 中,永久代还保存了一些与类数据无关的杂项对象(miscellaneous object);这些对象在 Java 8 中被挪到了普通的堆空间内。除此之外,Java 8 还从根本上改变了保存在这个特殊区域内的元数据的类型——不过由于普通用户不需要了解这个区域内保持了什么样的数据,所以这些改变不会对我们造成什么影响。作为终端用户,我们需要知道的仅仅是永久代级元空间内保存了大量与类相关的数据,有些时候我们可能会需要调整这部分空间的大小。注意永久代或者元空间内并没有保存类实例的具体信息(即类对象),也没有反射对象 (譬如方法对象);这些内容都保存在常规的堆空间内。永久代和元空间内保存的信息只对编译器或者 JVM 的运行时有用,这部分信息被称为“类的元数据”。到目前为止都没有一个能提前计算出程序的永久代 / 元空间需要多大空间的好算法。永久代或者元空间的大小与程序使用的类的数量成比率相关,应用程序越复杂,使用的对象越多,永久代或者元空间就越大。使用元空间替换掉永久代的优势之一是我们不再需要对其 进行调整——因为(不像永久代)元空间默认使用尽可能多的空间。

这些内存区域的行为就像是分隔开的普通堆空间。它们会根据初始的大小动态地调整,需要的时候会增大到最大的堆空间。对于永久代而言,可以通过 -XX:PermSize= N 、 -XX:MaxPermSize=N 标志调整大小。而元空间的大小可以通过 -XX:MetaspaceSize= N 和 -XX:MaxMetaspaceSize=N 调整。

元空间会过大吗? 由于元空间默认的大小是没有作限制的,因此 Java 8(尤其是 32 位系统)的应用可能由于元空间被填满而耗尽内存。第 8 章中介绍的工具本地内存跟踪器(Native Memory Tracking,NMT)可以帮助诊断这种类型的问题。如果元空间增长得过大,通过设置 MaxMetaspaceSize 你可以调整元空间的上限,将其限制为一个更小的值,不过这又会导致应用程序最后由于元空间耗尽,发生 OutOfMemoryError 异常。解决这类问题的终极方法还是定位出为什么类的元空间会变得如此巨大。

调整这些区间会触发 Full GC,所以是一种代价昂贵的操作。如果程序在启动时发生大量的 Full GC(因为需要载入数量巨大的类),通常都是由于永久代或者元空间发生了大小调 整,因此这种情况下为了改善启动速度,增大初始值是个不错的主意。对于定义了大量类的 Java 7 应用,同时还需要增大永久代空间的最大值。譬如,通常情况下应用服务器永久代的最大值会设置为 128 MB192 MB 或者更多。

虽然名称叫“永久代”,保存在永久代空间中的数据并不能永久保存(元空间这个名字可能更准确)。尤其是,保存在其中的类像其他的对象一样会经历垃圾回收。在应用服务器中,这是一种非常普遍的现象,每次有新的应用部署,应用服务器都会创建新的类加载器 (classloader)。之后老的类加载器就不再被引用,像它定义的任何一个类一样,等待 GC 的回收。应用服务器漫长的运行周期中,很容易发现部署中触发的 Full GC:永久代或元空间被新的类所充斥填满,老的类的元数据等待被回收。

堆转储的信息可以用于诊断存在哪些类加载器,而这些信息反过来可以帮助确定是否存在类加载器的泄漏,最终导致永久代(或者元空间)被耗尽。除此之外,使用 jmap -permstat 参数(适用于 Java 7)、或者 -clstats 参数(适用于 Java 8)可以输 出类加载器相关的信息。不过这些命令都不是非常稳定,所以不大推荐使用。

控制并发

Serial 收集器之外几乎所有的垃圾收集器使用的算法都基于多线程。启动的线程数由-XX:ParallelGCThreads=N 参数控制。对下面这些操作,这个参数值会影响线程的数目:

• 使用 -XX:+UseParallelGC 收集新生代空间

• 使用 -XX:+UseParallelOldGC 收集老年代空间

• 使用 -XX:+UseParNewGC 收集新生代空间

• 使用 -XX:+UseG1GC 收集新生代空间

• CMS 收集器的“时空停顿”阶段(但并非 Full GC

• G1 收集器的“时空停顿”阶段(但并非 Full GC

由于 GC 操作会暂停所有的应用程序线程,JVM 为了尽量缩短停顿时间就必须尽可能地利用更多的 CPU 资源。这意味着,默认情况下,JVM 会在机器的每个 CPU 上运行一个线程,最多同时运行 8 个。一旦达到这个上限,JVM 会调整算法,每超出 5/8 CPU 启动 一个新的线程。所以总的线程数就是(这里的 N 代表 CPU 的数目):ParallelGCThreads = 8 + ((N - 8) * 5 / 8)

有时候使用这个算法估算出来的线程数目会偏大。如果应用程序使用一个较小的堆(譬如 大小为 1 GB)运行在一个八颗 CPU 的机器上,使用 4 个线程或者 6 个线程处理这个堆可 能会更高效。在一个 128 CPU 的机器上,启动 83 个垃圾收集线程可能也太多了,除非 系统使用的堆已经达到了最大上限。除此之外,如果机器上同时运行了多个 JVM 实例,限制所有 JVM 使用的线程总数是个不 错的主意。这时,垃圾收集线程运行起来会更加高效,每个线程都能 100% 地利用各 CPU 的资源(这就是前面的例子中 Throughput 收集器的平均 CPU 使用率比预期值更高的原因)。在 8 核或者 CPU 更少的机器上,垃圾收集线程会 100% 地占用机器的 CPU 处理资源。在拥有更多 CPU、运行了多个 JVM 的机器上,通常出现的问题是有太多的垃圾回收线程在同时并发运行。

以 16 CPU 的机器同时运行 4 JVM 实例为例,每个 JVM 默认会启动 13 个垃圾收集线程。如果四个 JVM 同时进行垃圾回收操作,机器上会启动大约 52 CPU 密集型线程竞争 CPU 资源。这会导致大量的冲突,如果能够限制每个 JVM 最多启动 4 个垃圾收集线程,效率会高很多。即使在同一个时刻,4 JVM 中的线程不大可能同时进行 GC 操作, 一个 JVM 上同时运行 13 个线程也意味着其他 JVM 上的应用程序线程不得不在一台总共有 16 CPU,且其中 13 CPU 被繁忙的垃圾收集任务 100% 占用的机器上竞争资源。 这种情况下,将每个 JVM 的垃圾收集线程数限制到 4 个是一个比较合理的平衡。注意, 这个标志不会对 CMS 收集器或者 G1 收集器的后台线程数作设定(虽然它们也会受设置的影响)。

自适应调整

根据调优的策略,JVM 会不断地尝试,寻找优化性能的机会,所以在 JVM 的运行过程中,堆、代以及 Survivor 空间的大小都可能会发生变化。这是一种尽力而为(Best-Effort)的方案,它进行性能调优的依据是以往的性能历史:这其中隐含了一个假设,即将来 GC 周期的状况跟最近历史 GC 周期的状况可能很类似。事实证明,在多种负荷下这一假设都是合理的,即使某个时刻内存的分配发生突变的情况, JVM 也能够依据最新的情况重新调整它的大小。自适应调整在两个方面能提供重要的帮助。其一,这意味着小型应用程序不需要再为指定了过大的堆而担心。譬如用于调整应用服务器的命令行管理程序,这类型的程序通常使用 16 MB(或者 64 MB)的堆,即使默认的堆可能增长到 1 GB 那么大的容量。有了自适应调整之后,这种类型的应用程序不再需要额外花费精力去调优,平台默认的配置就能确保他们不会使用大量的内存。其次,这意味着很多应用程序根本不需要担心它们堆的大小,如果需要使用的堆的大小超过了平台的默认值,他们可以放心地分配更大的堆,而不用关心其他的细节。JVM 会自动调整堆和代的大小,依据垃圾回收算法的性能目标,使用优化的内存量。自适应调整就是让自动调整生效的法宝。不过,空间大小的调整终归要花费一定的时间开销,这部分时间大多数消耗在 GC 停顿的时候。如果你投注了大量的时间精细地调优了垃圾回收的参数、定义了应用程序堆的 大小限制,可以考虑关闭自适应调整。如果应用程序的运行明显地可以划分成不同的阶段,你希望对这些阶段中的某一个阶段进行垃圾回收的优化,那么关闭自适应调优也是很有帮助的。

使用 -XX:-UseAdaptiveSizePolicy 标志可以在全局范围内关闭自适应调整功能(默认情况下,这个标志是开启的)。如果堆容量的最大、最小值设置成同样的值,与此同时新生代的初始值和最大值也设置为同样大小,自适应调整的功能会被关闭。不过这时的 Survivor空间是个例外,如果你想了解应用程序运行时 JVM 的空间是如何调整的,可以设置 -XX:+PrintAdaptiveSizePolicy 标志。开启该标志后,一旦发生垃圾回收,GC 的日志中会包含垃圾回收时不同的代进行空间调整的细节信息。

垃圾回收工具

由于垃圾回收对 Java 的性能影响至关重要,业界提供了大量的工具用于监控它的性能。观察垃圾回收对应用程序的性能影响最好的方法就是尽量熟悉垃圾回收的日志,垃圾回收日志中包含了程序运行过程中的每一次垃圾回收操作。垃圾回收日志的细节依据使用的垃圾回收算法各有不同,不过垃圾回收日志的基本结构 (management)是一致的。

多种方法都能开启 GC 的日志功能,其中包括:使用 -verbose:gc -XX:+PrintGC 这两个标志中的任意一个能创建基本的 GC 日志(这两个日志标志实际上互为别名,默认情况下的 GC 日志功能是关闭的)。使用 -XX:+PrintGCDetails 标志会创建更详细的 GC 日志。我们推荐使用 -XX:+PrintGCDetails 标志(这个标志默认情况下也是关闭的);通常情况下使用基本的 GC 日志很难诊断垃圾回收时发生的问题。除了使用详细的 GC 日志,我们还推荐使用 -XX:+PrintGCTimeStamps 或者 -XX:+PrintGCDateStamps,便于我们更精确地判断几次 GC 操作之间的时间。这两个参数之间的差别在于时间戳是相对于 0(依据 JVM 启动的时间)的值,而日期戳(date stamp)是实际的日期字符串。由于日期戳需要进行格式化, 所以它的效率可能会受轻微的影响,不过这种操作并不频繁,它造成的影响也很难被我们感知。

默认情况下 GC 日志直接输出到标准输出,不过使用 -Xloggc:filename 标志也能修改输出 到某个文件。除非显式地使用 -PrintGCDetails 标志,否则使用 -Xloggc 会自动地开启基本日志模式。使用日志循环(Log rotation)标志可以限制保存在 GC 日志中的数据量;对于需要长时间运行的服务器而言,这是一个非常有用的标志,否则累积几个月的数据很可能会耗尽服务器的磁盘。通过 -XX:+UseGCLogfileRotation -XX:NumberOfGCLogfiles=N -XX:GCLogfileSize=N 标志可以控制日志文件的循环。默认情况下,UseGCLogfileRotation 标志是关闭的。开启 UseGCLogfileRotation 标志后,默认的文件数目是 0(意味着不作任何限制),默认的日志文件大小是 0(同样也是不作任何限制)。因此,为了让日志循环功能真正生效,我们必须为所有这些标志设定值。需要注意的是,如果设定的数值不足 8 KB 的话,日志文件的大小会以 8 KB 为单位规整。

根据需要,你可以手工地解析、研读垃圾回收日志,也可以利用一些工具来完成这部分工作。GC Histogram http://java.net/projects/gchisto)就是这些工具中的一员。GC Histogram 能够读入 GC 日志,根据日志文件中的数据生成对应的图表和表格

在这个例子中,JVM 消耗了 41% 的时间进行垃圾回收,完成一次 Full GC 的时间长达 7 秒钟。很明显,这个应用程序需要调优它的内存使用。 使用 jconsole 可以实时监控堆的使用情况。jconsole 的内存面板可以实时查看堆的使用 状况,

    

从这幅视图我们能看到整个堆的使用情况,它在介于 100 MB 160 MB 的区间内周期性 地循环。使用 jconsole 一次只能看到一个分区的使用情况:要么是 Eden 空间,要么是Survivor 空间,要么是老年代,或者是永久代。如果选择 Eden 空间作为绘制图表的区域, 能看到 Eden 空间以相似的模式在 0 MB 60 MB 之间波动(并且,跟你的猜测一样,如果依据老年代的数据作图,那它基本将是一条横在 100 MB 的水平线)。 如果你希望使用脚本的方式获取数据,jstat 是理想的工具。jstat 提供了 9 个选项,提供 堆的各种数据;使用 jstat -options 选项能够列出所有这些选项。这其中最常用的一个选项是 -gcutil,它能够输出消耗在 GC 上的时间,以及每个 GC 区域使用的百分比。其他的选项能够以 KB 为单位输出各 GC 空间的大小。

注意,jstat 接受一个可选的参数,指定每隔多少毫秒重复执行这个命令,这个选项帮助我们长时间地监控应用程序的垃圾回收过程。下面是一个示例的输出,它以每隔一秒钟的频率运行。

在这个例子中,监控开始时,程序已经在新生代(YGC)中进行了 98 次垃圾回收操作,这总共消耗了大约 1.985 秒的时间(YGCT)。于此同时,它还完成了 8 Full GCFGC),消耗了 2.397 秒的时间(FGCT);因此 GC 消耗的总时长(GCT)为 4.382 秒.

新生代中三个区间的数据都在这里列出:两个 Survivor 空间(分别是 S0 S1)以及 1 个Eden 空间(标记为 E)。监控开始时,Eden 空间几乎要被填满了,(已经占用了 99.12% 的 空间),因此下一秒就有一次新生代的垃圾回收:这之后 Eden 空间的使用率回落到 5.55%, Survivor 空间发生了交换,一部分内存对象被晋升到了老年代空间(标记为 O),老年代的 空间使用率增长到 60.98%。跟典型的场景一样,我们没有在永久代(标记为 P)发现大幅 度的变化,因为几乎所有需要的类都已经在程序启动时载入内存。

如果你不记得如何开启 GC 日志,这是一个很好的替代方法,它能帮助我们观察垃圾回收是如何在较长的时间跨度内工作的。

垃圾收集算法

Throughput收集器

Throughput 收集器有两个基本的操作:其一是回收新生代的垃圾,其二是回收老年代的垃圾。下面展示了堆在新生代回收之前和回收之后的情况

图:1-1

通常新生代的垃圾回收发生在 Eden 空间快用尽时。新生代垃圾收集会把 Eden 空间中的所有对象挪走:一部分对象会被移动到 Survivor 空间(即这幅图中的 S0 区域),其他的会被移动到老年代;正如你看到的,回收之后老年代中保存了更多的对象。当然,还有大量的对象因为没有任何对象引用而被回收。开启了 PrintGCDetails 标志的 GC 日志中,Minor GC 形式如下:

这次 GC 在程序开始运行 17.806 秒后发生。现在新生代中对象占用的空间为 14 463 KB (约为 14 MB,位于 Survivor 空间内);GC 之前,新生代对象占用的空间为 227 983 KB (约为 227 MB)。(实际上,227 893 KB 严格折算只有 222 MB,为了便于讨论以 1000 为单位将它们折算到 KB。这里假设我是磁盘生产商。)新生代这时总的大小为 264 MB。与此同时,堆的空间总的使用情况(包含新生代和老年代)从 280 MB 减少到了 66 MB, 这个时刻整个堆的大小为 613 MB。完成垃圾回收操作耗时 0.02 秒(排在输出最后的 Real 时间是 0.0 169 320 秒——实际时间进行了归整)。程序消耗的 CPU 时间比 Real 时间往往更多,原因是新生代垃圾回收会使用多个线程(这个例子中,使用了 4 个线程)。

展示了 Full GC 之前及之后堆的使用情况

图:1-2

老年代垃圾收集会回收新生代中所有的对象(包括 Survivor 空间中的对象)。只有那些有活跃引用的对象,或者已经经过压缩整理的对象(它们占据了老年代的开始部分)会在老年代中继续保持,其余的对象都会被回收。

新生代的空间使用在经历 Full GC 之后变为 0 字节(新生代的大小为 339 MB)。老年代中的空间使用从 457 MB 减少到了 392 MB,因此整个堆的使用从 473 MB 减少到了 392 MB。 永久代空间的使用没有发生变化;在多数的 Full GC 中,永久代的对象都不会被回收。(如果永久代空间耗尽,JVM 会发起 Full GC 回收永久代中的对象,这时你会观察到永久代空间的变化——这是永久代进行回收唯一的情况。这个例子使用的是 Java 7;在 Java 8 中,类似的信息可以在元空间中找到)。由于 Full GC 要进行大量的工作,所以消耗了约 1.3 秒的 Real 时间,4.4 秒的 CPU 时间(同样源于使用了 4 个并行的线程)。

堆大小的自适应调整和静态调整

Throughput 收集器的调优几乎都是围绕停顿时间进行的,寻求堆的总体大小、新生代的大小以及老年代大小之间平衡。 考虑 Throughput 收集器的调优方案时有两种取舍。首先比较经典的是编程技术上的取舍, 即时间与空间的取舍。第二个取舍与完成垃圾回收所需的时长相关。增大堆能够减少 Full GC 停顿发生的频率,但也有其局限性:由于 GC 时间变得更长,平均响应时间也会变长。类似地,为新生代 分配更多的堆空间可以缩短 Full GC 的停顿时间,不过这又会增大老年代垃圾回收的频率 (因为老年代空间保持不变或者变得更小了)。

下面展示了采用这些取舍的效果,在使用不同大小的堆时,最大吞吐量的变化情况。使用 256 MB 的小堆时,应用服务器在垃圾回收上消耗了大量的时间(实际消耗的时间高达总时间的 36%);吞吐量因此受到限制,比较低。随着堆大小的增加,吞吐量迅速提升——直到堆的容量增大到 1500 MB。这之后吞吐量的增速迅速减缓,这时应用程序实际已经不太受垃圾回收的影响(垃圾回收消耗的时间仅仅只占总时间的 6% 左右)。收益递减规律逐渐凸显出来:虽然应用程序可以通过增加内存的方式提升吞吐量,不过其效果已经很有限了。堆的大小达到 4500 MB 后,吞吐量开始出现少量下滑。这时,应用程序面临着第二个选择:增加的内存导致 GC 周期愈加冗长,虽然它们发生的频率小得多,但这些超长的 GC周期也会影响系统整体的吞吐量。这幅图中的数据取自关闭了自适应调整的 JVM;它的最大、最小堆的容量设置成了同样的大小。对任何一种应用,我们都可以通过实验确定堆和代的最佳大小。

为 了 达 到 停 顿 时 间 的 指 标,Throughput 收 集 器 的 自 适 应 调 整 会 重 新 分 配 堆( 以 及 代 ) 的 大 小。 使 用 这 些 标 志 可 以 设 置 相 应 的 性 能 指 标:-XX:MaxGCPauseMillis=N 和 -XX:GCTimeRatio=NMaxGCPauseMillis 标志用于设定应用可承受的最大停顿时间。我们可以将其设置为 0 或者一些非常小的值,譬如 50 毫秒。请注意,这个标志设定的值同时影响 Minor GC Full GC。如果设置的值非常小,那么应用的老年代最终就会非常小:譬如,你设定该参数希望 应用在 50 毫秒内完成垃圾回收,这将会触发非常频繁的 Full GC,对应用程序的性能而言。将是灾难性的。因此,设定该值时,请尽量保持理性,将该值设定为可达到的合理值。缺省情况下,我们不设定该参数。 GCTimeRatio 标志可以设置你希望应用程序在垃圾回收上花费多少时间(与应用线程的运行时间相比较)。它是一个百分比,因此 N 值的计算稍微有些复杂。将 N 值代入下面的公式可以计算出理想情况下应用线程的运行时间所占的百分比:

GCTimeRatio 的默认值是 99。将该值代入公式能得到 0.99,这意味着应用程序的运行时间占总时间的 99%,只有 1% 的时间消耗在垃圾回收上。但是,不要被列出的默认值搞糊涂。 譬如,GCTimeRatio 设置为 95 并不意味着会使用总时间的 5% 去做垃圾回收;它表示的是最多会使用总时间的 1.94% 去做垃圾回收。先确定你期望应用程序线程工作的时间(譬如 95%),再根据下面这个公式计算 GCTimeRatio 是一个更容易操作的方法。

对于 95%0.95)的吞吐量目标,利用该公式计算出的 GCTimeRatio 19

JVM 使用这两个标志在堆的初始值(-Xms)和最大值(-Xmx)之间设置堆的大小。MaxGCPauseMillis 标志的优先级最高:如果设置了这个值,新生代和老年代会随之进行调整,直到满足对应停顿时间的目标。一旦这个目标达成,堆的总容量就开始逐渐增大,直到运行时间的比率达到设定值。这两个目标都达成后,JVM 会尝试缩减堆的大小,尽可能以最小的堆大小来满足这两个目标。

由于默认情况不设置停顿时间目标,通常自动堆调整的效果是堆(以及代空间)的大小会持续增大,直到满足设置的 GCTimeRatio 目标。不过,在实际操作中,该标志的默认设置已经 相当优化了。每个人的使用经验各有不同,但是根据我以往的经验,如果应用程序在垃圾回 收上消耗总时间的 3% 6%,其效果会是相当不错的。有些时候,我甚至会在内存严重受 限的环境中调优应用程序的性能;这些应用通常会在垃圾回收上消耗 10% 15% 的时间。 垃圾回收对这些应用程序的性能影响巨大,不过整体的性能目标还是能够达到的。

CMS收集器

CMS 收集器有 3 种基本的操作,分别是:

• CMS 收集器会对新生代的对象进行回收(所有的应用线程都会被暂停);

• CMS 收集器会启动一个并发的线程对老年代空间的垃圾进行回收;

• 如果有必要,CMS 会发起 Full GC

下面展示了使用 CMS 回收新生代的情况

图:1-3

CMS 收集器的新生代垃圾收集情况:与 Throughput 收集器的新生代垃圾收集非常相似:对象从 Eden 空间移动到 Survivor 空间,或者移动到老年代空间。CMS 收集的 GC 日志也非 常相似:

这时的新生代空间大小为 629 MB;垃圾回收之后变成了 69 MB(位于 Survivor 空间)。与Throughput 收集器的日志类似,整个堆的大小为 2027 MB,其中 772 MB 在垃圾回收之后依然被占用。虽然并行的 GC 线程使用了 0.42 秒的 CPU 时间,但整个垃圾回收过程仅耗 时 0.12 秒。并发的垃圾回收周期如下图

图:1-4

JVM 会依据堆的使用情况启动并发回收。当堆的占用达到某个程度时,JVM 会启动后台线程扫描堆,回收不用的对象。扫描结束的时候,堆的状况就像这幅图中最后一列所描述的情况一样。请注意,如果使用 CMS 回收器,老年代空间不会进行压缩整理:老年代空间由已经分配对象的空间和空闲空间共同组成。新生代垃圾收集将对象由 Eden 空间挪到老年代空间时,JVM 会尝试使用那些空闲的空间来保存这些晋升的对象。

通过 GC 日志,我们看到回收过程划分成了好几个阶段。虽然主要的并发回收(ConcurrentCycle)阶段都使用后台线程进行工作,有些阶段还是会暂停所有的应用线程,并因此引入短暂的停顿。

并发回收由“初始标记”阶段开始,这个阶段会暂停所有的应用程序线程:

这个阶段的主要任务是找到堆中所有的垃圾回收根节点对象。从第一组数据中可以看到这个例子中对象占用了老年代空间 1398 MB 中的 702 MB 空间。第二组数据显示整个堆的大小为 2027 MB,其中 772 MB 被占用。应用程序线程在这个 CMS 回收周期中被暂停了 0.08 秒。

下一个阶段是“标记阶段”,这个阶段中应用程序线程可以持续运行,不会被中断。GC 日志中,这个阶段的标识如下:

标识阶段耗时 0.83 秒(以及 1.11 秒的 CPU 时间)。由于这个阶段进行的工作仅仅是标记,不会对堆的使用情况产生实质性的改变,所以没有任何相关的数据输出。如果这个阶段还有数据输出,很可能是由于这 0.83 秒内新生代对象的分配导致了堆的增长,因为应用程序线程还在持续运行着。

然后是“预清理”阶段,这个阶段也是与应用程序线程的运行并发进行的:

接下来的是“重新标记”阶段,这个阶段涵盖了多个操作:

且慢,CMS 收集不是只执行一次预清理阶段吗?这个“可中断预清理”(abortable preclean)阶段是做什么的呢?使用可中断预清理阶段是由于标记阶段(严格说起来,它应该是最后的输出项)不是并发的,所有的应用线程进入标记阶段后都会被暂停。如果新生代收集刚刚结束,紧接着就是一个标记阶段的话,应用线程会遭遇 2 次连续的停顿操作,CMS 收集器希望避免这样的情况发生。使用可中断预清理阶段的目的就是希望尽量缩短停顿的长度,避免连续的停顿。

因此,可中断预清理阶段会等到新生代空间占用到 50% 左右时才开始。理论上,这时离下 一次新生代收集还有半程的距离,给了 CMS 收集器最好的机会避免发生连续停顿。这个例子中,可中断预清理阶段在 90.8 秒开始,等待常规的新生代收集开始花了 1.5 秒(根据日志的记录,92.392 秒开始)。CMS 收集器根据以往的历史记录推算新生代垃圾收集的持续时间。 这个例子中,CMS 收集器计算出的时长大约是 4.2 秒。所以 2.1 秒之后(即 94.4 秒),CMS 收集器停止了预清理阶段(这种行为被称为“放弃”了这次回收, 不过这可能是唯一能停止该次回收的方式)。这之后,CMS 回收器终于开始了标记阶段的工作执行,标记阶段的回收工作将应用程序线程暂停了 0.18 秒(在可中断预清理过程中, 应用程序线程不会被暂停)。

接下来是另一个并发阶段——清除(sweep)阶段:

这个阶段耗时 0.82 秒,回收线程与应用程序线程并发运行。碰巧这次的并发 - 清除过程被新生代垃圾回收中断了。新生代垃圾回收与清除阶段并没有直接的联系,将这个例子保留在这里是为了说明新生代的垃圾收集与老年代的垃圾收集可以并发进行。从下图中可以看到,新生代的状态在并发收集的过程中发生了变化——清除过程中新生代可能发生了多次垃圾收集(至少发生了一次新生代垃圾收集,因为可中断的预清理至少会经历一次新生代垃圾收集)。

接下来是并发重置(concurrent reset)阶段:

这是并发运行的最后一个阶段;CMS 垃圾回收的周期至此告终,老年代空间中没有被引用的对象被回收(此时堆的状况如上面CMS周期后所示)。遗憾的是,我们无法从日志中了解到底有多少对象被回收;重置阶段的日志也没有提供更多的信息,最后还有多少堆空间被占 用不得而知。为了发掘这些信息,我们尝试从新生代垃圾收集日志中找到一些蛛丝马迹, 如下所示:

89.853 秒时(即 CMS 回收周期开始之前)老年代空间的占用情况相比较,那时的空间占用大约是 703 MB(整个堆的占用为 772 MB,其中包含 69 MB Survivor 空间占用, 因此老年代占用了剩下的 703 MB)。到 98.049 秒,垃圾收集结束,老年代空间占用大约为504 MB,由此可以计算出 CMS 周期回收了大约 199 MB 的内存。

如果一切顺利,这些就是 CMS 垃圾回收会经历的周期,以及所有可能出现在 CMS 垃圾收集日志中的消息。不过,事实并不是这么简单,我们还需要查看另外三种消息,出现这些日志表明 CMS 垃圾收集碰到了麻烦。首当其冲的是并发模式失效(concurrent mode failure):

新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成 Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收,释 放空间之后老年代的占用为 1366 MB——这次操作导致应用程序线程停顿长达 5.6 秒。这 个操作是单线程的,这就是为什么它耗时如此之长的原因之一(这也是为什么发生并发模 式失效比堆的增长更加恶劣的原因之一)。

第二个问题是老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败:

在这个例子中,CMS 启动了新生代垃圾收集,判断老年代似乎有足够的空闲空间可以容纳所有的晋升对象(否则,CMS 收集器会报告发生并发模式失效)。这个假设最终被证明是错误的:由于老年代空间的碎片化(或者,不太贴切地说,由于晋升实际要占用的内存超过了 CMS 收集器的判断),CMS 收集器无法晋升这些对象。

因此,CMS 收集器在新生代垃圾收集过程中(所有的应用线程都被暂停时),对整个老年 代空间进行了整理和压缩。好消息是,随着堆的压缩,碎片化问题解决了(至少在短期内不是问题了)。不过随之而来的是长达 28 秒的冗长的停顿时间。由于需要对整个堆进行整理,这个时间甚至比 CMS 收集器遭遇并发模式失效的时间还长的多,因为发生并发模式失效时,CMS 收集器只需要回收堆内无用的对象。这时的堆就像刚由 Throughput 收集器 做完 Full GC 一样(如上图1-2):新生代空间完全空闲,老年代空间也已经整理过。

最终,CMS 收集的日志中可能只有一条 Full GC 的记录,不含任何常规并发垃圾回收的日志。

永久代空间用尽,需要回收时,就会发生这样的状况;应注意到,CMS 收集后永久代空间大小减小了。Java 8 中,如果元空间需要调整,也会发生同样的情况。默认情况下, CMS 收集器不会对永久代(或元空间)进行收集,因此,它一旦被用尽,就需要进行 Full GC,所有没有被引用的类都会被回收。

快速小结

1. CMS 垃圾回收有多个操作,但是期望的操作是 Minor GC 和并发回收 (concurrent cycle)。

2. CMS 收集过程中的并发模式失效以及晋升失败的代价都非常昂贵;我们应该尽量调优 CMS 收集器以避免发生这些情况。

3. 默认情况下 CMS 收集器不会对永久代进行垃圾回收。

针对并发模式失效的调优

调优 CMS 收集器时最要紧的工作就是要避免发生并发模式失效以及晋升失败。正如我们在 CMS 垃圾收集日志中看到的那样,发生并发模式失效往往是由于 CMS 不能以足够快的速度清理老年代空间:新生代需要进行垃圾回收时,CMS 收集器计算发现老年代没有足够 的空闲空间可以容纳这些晋升对象,不得不先对老年代进行垃圾回收。

初始时老年代空间中对象是一个接一个整齐有序排列的。当老年代空间的占用达到某个程度(默认值为 70%)时,并发回收就开始了。一个 CMS 后台线程开始扫描老年代空间, 寻找无用的垃圾对象时,竞争就开始了:CMS 收集器必须在老年代剩余的空间(30%)用尽之前,完成老年代空间的扫描及回收工作。如果并发回收在这场速度的比赛中失利,CMS 收集器就会发生并发模式失效。

有以下途径可以避免发生这种失效。

1. 想办法增大老年代空间,要么只移动部分的新生代对象到老年代,要么增加更多的堆空间

2. 以更高的频率运行后台回收线程

3. 使用更多的后台回收线程

自适应调优和 CMS 垃圾搜集

CMS 收集器使用两个配置 MaxGCPauseMllis=N GCTimeRatio=N 来确定使用多大的堆和多大的代空间。 CMS 收集与其他的垃圾收集方法一个显著的不同是除非发生 Full GC,否则 CMS 的新生代大小不会作调整。由于 CMS 的目标是尽量避免 Full GC,这意味着使用精细调优的CMS 收集器的应用程序永远不会调整它的新生代大小。 程序启动时可能频发并发模式失效,因为 CMS 收集器需要调整堆和永久代(或者元空间)的大小。使用 CMS 收集器,初始时采用一个比较大的堆(以及更大的永久代 / 元空间)是一个很好的主意,这是一个特例,增大堆的大小反而帮助避免了那些失效。如果有更多的内存可用,更好的方案是增加堆的大小,否则可以尝试调整后台线程运行的方式来解决这个问题。

1. 给后台线程更多的运行机会

为了让 CMS 收集器赢得这场比赛,方法之一是更早地启动并发收集周期。显然地,CMS收集器在老年代空间占用达到 60% 时启动并发周期,这和老年代空间占用到 70% 时才启动相比,前者完成垃圾收集的几率更大。为了实现这种配置,最简单的方法是同时设置下面这两个标志:-XX:CMSInitiatingOccupancyFraction=N -XX:+UseCMSInitiatingOccupancyOnly。 同时使用这两个参数能帮助 CMS 更容易地进行决策:如果同时设置这两个标志,那么 CMS 就只依据设置的老年代空间占用率来决定何时启动后台线程。默认情况下,UseCMSInitiatingOccupancyOnly 标志的值为假,CMS 会使用更复杂的算法判断什么时候启动并行收集线程。如果有必要提前启动后台线程,推荐使用最简单的方法,即将UseCMSInitiatingOccupancyOnly 标志的值设置为真.

CMSInitiatingOccupancyFraction 参数值的调整可能需要多次迭代才能确定。如果开启了UseCMSInitiatingOccupancyOnly 标志,CMSInitiatingOccupancyFraction 的默认值就被置为 70,即 CMS 会在老年代空间占用达到 70% 时启动并发收集周期。

对特定的应用程序,该标志的更优值可以根据 GC 日志中 CMS 周期首次启动失败时的值得到。具体方法是,在垃圾回收日志中寻找并发模式失效,找到后再反向查找 CMS 周期 最近的启动记录。日志中含有 CMS-initial-mark 信息的一行包含了 CMS 周期启动时,老年代空间的占用情况如下所示:

在这个例子中,根据日志的输出,我们可以判断该时刻老年代空间的占用率为 50%(老年代空间大小为 1398 MB,其中 702 MB 被 占 用 )。 不 过 这 个 值 还 不 够 早, 因此我们需要调整 CMSInitiatingOccupancyFraction 将其值设定为小于 50 的 某 个 值。 (虽然 CMSInitiatingOccupancyFraction 的默认值为 70,不过这个例子中没有开启 UseCMSInitiatingOccupancyOnly 标志,所以例子中 CMS 收集器在老年代空间占用达到50% 时启动了 CMS 后台线程。)

了解了 CMSInitiatingOccupancyFraction 的工作原理之后,你可能会有疑问,我们能不能将参数值设置为 0 或者其他比较小的值,让 CMS 的后台线程持续运行。通常我们不推荐进行这样的设置,但是,如果你对其中的取舍非常了解,适当地妥协也是可以接受的。

这其中的第一个取舍源于 CPU CMS 后台线程会持续运行,它们会消耗大量的 CPU 时 钟——每个 CMS 后台线程运行时都会 100% 地占用一颗 CPU。多个 CMS 线程同时运行时还会有短暂的爆发,机器的总 CPU 使用因此也会暴涨。如果这些线程都是毫无目的地持续运行,只会白白浪费宝贵的 CPU 资源。

另一方面,这并不是说使用了过多的 CPU 周期就是问题。后台的 CMS 线程需要时必须运行,即使在最好的情况下,这也是很难避免的。因此,机器必须预留足够的 CPU 周期来运行这些 CMS 线程。所以规划机器时,你必须考虑留出余量给这部分 CPU 的使用。

CMS 周期中,如果 CMS 后台线程没有运行,这些 CPU 时钟可以用于运行其他的应用吗?通常不会。如果还有另一个应用也在使用同一个时钟周期,它没有途径了解何时 CMS 线 程会运行。因此,应用程序线程和 CMS 线程会竞争 CPU 资源,而这很可能会导致 CMS 线程的“失速”(lose its race)。有些时候,通过复杂的操作系统调优,有可能让应用线程 以低于 CMS 线程优先级的方式让两种线程在同一个时钟周期内运行,但是这些方法都相 当复杂,很容易出错。因此,答案是肯定的,CMS 周期运行得越频繁,CPU 周期越长, 如果不这样,这些 CPU 周期就是空闲状态(idle)。

第二个取舍更加重要,它与应用程序的停顿相关。正如我们在 GC 日志中观察到的,CMS在特定的阶段会暂停所有的应用线程。使用 CMS 收集器的主要目的就是要限制 GC 停顿 的影响,因此频繁地运行更多无效的 CMS 周期只能适得其反。CMS 停顿的时间与新生代 的停顿时间比起来要短得多,应用线程甚至可能感受不到这些额外的停顿——这也是一种取舍,我们是要避免额外的停顿还是要减少发生并发模式失败的几率。不过,正如我们前面提到的,持续地运行后台 GC 线程所造成的停顿可能会导致总体的停顿,而这最终会降低应用程序的性能。 CMSInitiatingOccupancyFraction 应该设置得比活跃数据小才能提前触发 CMS 周期。

调整CMS后台线程

每个 CMS 后台线程都会 100% 地占用机器上的一颗 CPU。如果应用程序发生并发模式失效,同时又有额外的 CPU 周期可用,可以设置 -XX:ConcGCThreads=N 标志,增加后台线程的数目。默认情况下,ConcGCThreads 的值是依据 ParallelGCThreads 标志的值计算得到的:ConcGCThreads = (3 + ParallelGCThreads) / 4  上述计算使用整数计算方法,这意味着如果 ParallelGCThreads 的取值区间在 1 4, ConcGCThread 的值就为 1,如果 ParallelGCThreads 的取值在 5 8 之间,ConcGCThreads 的值就为 2,以此类推.

调整这一标志的要点在于判断是否有可用的 CPU 周期。如果 ConcGCThreads 标志值设置的偏大,垃圾收集会占用本来能用于运行应用线程的 CPU 周期;最终效果上,这种配置会导致应用程序些微的停顿,因为应用程序线程需要等待再次在 CPU 上继续运行的机会。除此之外,在一个配备了大量 CPU 的系统上,ConcGCThreads 参数的默认值可能偏大。如果没有频繁遭遇并发模式失败,可以考虑减少后台线程数,释放这部分 CPU 周期用于应用线程的运行。

快速小结

1. 避免发生并发模式失效是提升 CMS 收集器处理能力、获得高性能的关键。

2. 避免并发模式失效(如果有可能的话)最简单的方法是增大堆的容量。

3. 否则,我们能进行的下一个步骤就是通过调整 CMSInitiatingOccupancy- Fraction 参数,尽早启动并发后台线程的运行。

4. 另外,调整后台线程的数目对解决这个问题也有帮助。

CMS收集器的永久代调优

从 CMS例子垃圾收集日志中我们发现,如果永久代需要进行垃圾收集,就会发生 FullGC(如果元空间的大小需要调整也会发生同样的情况)。FullGC时,除gc线程外的所有用户线程处于暂停状态,也就是不会有响应了。一般fullgc速度很快,毫秒级的,用户无感知。除非内存特别大,或者fullgc也无法收集到足够内存导致一直fullgc,应用的外在表现就是程序卡死了,这往往发生在程序员频繁部署(或者重新部署)应用的服务器上,或发生在需要频繁定义(或回收)类的应用中。

默认情况下,Java 7 中的 CMS 垃圾收集线程不会处理永久代中的垃圾,如果永久代 空间用尽,CMS 会发起一次 Full GC 来回收其中的垃圾对象。除此之外,还可以开启 -XX:+CMSPermGenSweepingEnabled 标志(默认情况下,该标志的值为 false),开启后,永久代中的垃圾使用与老年代同样的方式进行垃圾收集:通过一组后台线程并发地回收永久代中的垃圾对象。注意,触发永久代垃圾回收的指标与老年代的指标是相互独立的。使用 -XX:CMSInitiatingPermOccupancyFraction=N 参数可以指定 CMS 收集器在永久代空间占用 比达到设定值时启动永久代垃圾回收线程,这个参数的默认值为 80%

不过,开启永久代垃圾收集只是整个流程中的一步,为了真正释放不再被引用的类,我们还需要设置 -XX:+CMSClassUnloadingEnabled 标志。否则,即使启用了永久代垃圾回收也只 能释放少量的无效对象,类的元数据并不会被释放。由于永久代中大量的数据都是类的元数据,因此启动 CMS 永久代垃圾收集时,这个标志同时也应该开启。

Java 8 中,CMS 收集器默认就会收集元空间中不再载入的类。如果由于某些原因,你希望关闭这一功能,可以通过 -XX:-CMSClassUnloadingEnabled 标志进行关闭(默认情况下这个标志是开启的,即该值为 true)。

增量式CMS垃圾收集

为了进行有效的 CMS 垃圾收集,需要消耗额外的 CPU 处理资源。如果你只有一个单 CPU 的机器,或者你有多个非常忙碌的 CPU,但是希望使用低延迟的垃圾收集器,这时有什么好的建议呢?

增量式 CMS 垃圾收集在 Java 8 中已经不推荐使用

增量式 CMS 垃圾收集(iCMS)在 Java 8 中已经不推荐使用了,不过暂时还保留在其中,但是在 Java 9 中很可能会被移除。 使用增量式 CMS 垃圾收集的主要好处是后台线程会间歇性地停顿,让出一部分 CPU 给应用程序线程运行,从而使得 CMS 收集器即使在只配备了有限 CPU 资源的机器上也能运行。随着多核技术的发展,多处理器几乎已经成为所有系统的标准配置(连我的手机都装载了 4 核的 CPU 芯片),这使得 iCMS 存在的意义变得不再那么重要。 如果系统确实只配备了极其有限的 CPU,作为替代方案,可以考虑使用 G1 收集器—— 因为 G1 收集器的后台线程在垃圾收集的过程中也会周期性地暂停,客观上减少了与应用线程竞争 CPU 资源的情况。这些情况下,使用 CMS 收集器进行增量式的垃圾收集,即只要有后台线程运行(同一个时刻处于运行状态的线程数不应该超过一个),垃圾收集器就不会马上对整个堆进行垃圾收集。这个后台线程间断性地暂停,有助于整个系统吞吐量的提高,因为更多的 CPU 处理资源让给了应用线程的运行。当然,如果 CMS 收集线程一旦运行起来,还是会与应用程序线程争夺有限的 CPU 处理周期。

指定 -XX:+CMSIncrementalMode 标志可以开启增量式 CMS 垃圾收集。通过改变标志 -XX:CMSIncrementalSafetyFactor=N、-XX:CMSIncrementalDutyCycleMin=N -XX:CMSIncrementalPacing 可以控制垃圾收集后台线程为应用程序线程让出多少 CPU 周期。增量式 CMS 垃圾收集依据责任周期(duty cycle)原则进行工作,这个原则决定了 CMS 垃圾收集器的后台线程在释放 CPU 周期给应用线程之前,每隔多长时间扫描一次堆。从操作系统的层次上看,CMS 垃圾收集器的后台线程已经和应用的线程发生了竞争(通常是基于时间片的)。换个角度看,这些标志实际控制着主动暂停运行、释放资源给应用线程运行之前,后台线程持续运行的时间。责任周期的时间长度是以新生代相邻两次垃圾收集之间的时间长度计算得出的;默认情况 下,增量式 CMS 垃圾收集持续的时间是该时长的 20% 左右(至少初始时是这个值,不过 CMS 会不断调整该值以适应不断晋升到老年代的对象数目)。如果这个时间不够长,就会发生并发模式失效(以及 Full GC)。我们的目标就是通过调整增量式 CMS 垃圾收集,避免发生这种 GC(或者尽量减少它们发生的频率)。我们从调整增大 CMSIncrementalSafetyFactor 参数入手,这个参数设置是增加到默认责任周期的时间百分比。责任周期的默认值是 10%,默认情况下,安全因子(safety factor) 的值是再增加 10%(这样默认的初始责任周期所占用的时间百分比就变成了 20%)。通过增大安全因子(最大可以增加到 90,不过这会导致增量周期占用所有的时间),可以让后台线程有更多的运行时间。除此之外,如果参数 CMSIncrementalDutyCycleMin 设置得比默认值(10)更大也可以调整 责任周期的长度。不过这个参数值会受 JVM 自动调节机制的影响,因为 JVM 的自动调节机制会监控由新生代晋升到老年代的对象数并进行相应的调节。所以,即使增大这个值,JVM 可能还是会依据自身的判断,即增量式垃圾收集运行不需要运行得过于频繁,而减小这个参数的值。如果应用程序运行时操作有爆发式的波峰,通过自动调节机制计算出的结果通常不准确,你需要显式地设置责任周期,同时调整 CMSIncrementalDutyCycle 标志关闭自动参数调节(CMSIncrementalDutyCycle 的值默认为真,即开启)。

快速小结

1. 应用在 CPU 资源受限的机器上运行,同时又要求较小的停顿,这时使用增量式 CMS 收集器是一个不错的选择。

2. 通过责任周期可以调整增量式 CMS 收集器;增加责任周期的运行时间可以避免 CMS 收集器发生并发模式失效。

G1垃圾收集器

G1 垃圾收集器是一种工作在堆内不同分区上的并发收集器。分区(region)既可以归属于 老年代,也可以归属于新生代(默认情况下,一个堆被划分成 2048 个分区),同一个代的分区不需要保持连续。为老年代设计分区的初衷是我们发现并发后台线程在回收老年代中 没有引用的对象时,有的分区垃圾对象的数量很多,另一些分区的垃圾对象相对较少。虽然分区的垃圾收集工作实际仍然会暂停应用程序线程,不过由于 G1 收集器专注于垃圾最 多的分区,最终的效果是花费较少的时间就能回收这些分区的垃圾,这种只专注于垃圾最多分区的方式就是 G1 垃圾收集器名称的由来,即首先收集垃圾最多的分区。

不过这一算法并不适用于新生代的分区:新生代进行垃圾回收时,整个新生代空间要么被回收,要么被晋升(对象被移动到 Survivor 空间,或者移动到老年代)。新生代也采用分区机制的部分原因,是因为采用预定义的分区能够便于代的大小调整。

G1 收集器的收集活动主要包括 4 种操作:

新生代垃圾收集;

• 后台收集,并发周期;

• 混合式垃圾收集;

• 以及必要时的 Full GC

首先讨论的是 G1 收集器的新生代垃圾收集,如图 2-1 所示

 图2-1

图中的每一个小方块都代表一个 G1 的分区。分区中黑色的区域代表数据,每个分区中的字母表示该区域属于哪个代([E] 代表 Eden 空间,[O] 代表老年代,[S] 代表 Survivor 空间)。空的分区不属于任何一个代;需要的时候 G1 收集器会强制指定这些空的分区用于任何需要的代。

Eden 空间耗尽会触发 G1 垃圾收集器进行新生代垃圾收集(这个例子中,标识为 Eden 4个分区填满之后就会触发新生代收集)。新生代收集之后不会有新的分区马上分配到 Eden 空间,因为这时 Eden 空间为空。不过至少会有一个分区分配到 Survivor 空间(这个例子 中,Survivor 空间被部分填满),一部分数据会移动到老年代。

G1 垃圾收集器中,新生代垃圾收集的日志与其他的收集器略有不同。与往常一样,我们可以使用 PrintGCDetails 输出例子的垃圾回收日志,不过 G1 收集的日志要详细得多。这里仅仅列出了例子中重要的几行。 下面是新生代垃圾收集的标准流程:

这里新生代垃圾收集的 Real 时间消耗是 0.23 秒,这期间,垃圾收集线程消耗了 0.85 秒的CPU 时间,1286 MB 的对象移出了 Eden 空间(Eden 空间的大小调整到了 1212 MB);这其中的 74 MB 移动到了 Survivor 空间(Survivor 空间的大小从 78 MB 增加到了 152 MB), 其余的空间都被垃圾收集器回收掉了。通过观察堆的总占用降低了 1212 MB 我们知道,这些空间被释放了。通常情况下,一部分对象已经从 Survivor 空间移动到老年代空间,如果Survivor 空间被填满,无法容纳新生代的晋升对象,部分 Eden 空间的对象会被直接晋升到 老年代空间——这种情况下,老年代空间的占用也会增加。

图 2-2 是G1并发 垃圾收集周期(concurrent G1 cycle)开始和结束时的情况。

图2-2

这幅图中有三方面值得我们关注。首先,新生代的空间占用情况发生了变化:在并发周期中,至少有一次(很可能是多次)新生代垃圾收集。因此,在将 Eden 空间中的分区标记 为完全释放之前,新的 Eden 分区已经开始分配了。

其次,我们注意到一些分区现在被标记为 X。这些分区属于老年代(注意,它们依然还保持着数据),它们就是标记周期(marking cycle)找出的包含最多垃圾的分区。

最后,我们还要留意老年代(包括标记为 O 或者 X 的分区)的空间占用,在周期结束时实际可能更多。这是因为在标记周期中,新生代的垃圾收集会晋升对象到老年代。除此之外,标记周期中实际不会释放老年代中的任何对象:它仅仅锁定了那些垃圾最多的分区。 这些分区中的垃圾数据会在之后的周期中被回收释放。

G1 收集器的并发周期包括多个阶段,其中的一些会暂停所有应用线程,另一些则不会。并发周期的第一个阶段是初始—标记(initial-mark)阶段。这个阶段会暂停所有应用线程——部分源于初始—标记阶段也会进行新生代垃圾收集。

同常规的新生代垃圾收集一样,初始—标记阶段中,应用线程被暂停(大约时长 0.28 秒),之后新生代被清空(71 MB 的数据从新生代移到了老年代)。初始—标记阶段的输出日志表明后台并发周期启动。由于初始—标记阶段也需要暂停所有的应用线程,G1 收集器重 用了新生代 GC 周期来完成这部分的工作。在新生代垃圾收集中添加初始标记阶段的影响并不大:与之前的垃圾收集相比较,CPU 周期的开销增加了大约 20%,即便如此,停顿 时间只有些微的增长(幸运的是,这台机器上有空闲的 CPU 周期可以运行并发 G1 收集线程,否则停顿时间会更长一些)。

接下来,G1 收集器会扫描根分区(root region):

这个过程耗时 0.58 秒,不过扫描过程中不需要暂停应用现场,G1 收集器使用后台线程进行扫描工作。不过,这个阶段中不能发生新生代垃圾收集,因此预留足够的 CPU 周期给后台线程运行是非常重要的。如果扫描根分区时,新生代空间刚巧用尽,新生代垃圾收集(会暂停所有的应用线程)必须等待根扫描结束才能完成。效果上,这意味着新生代垃圾收集的停顿时间会更长(远超过正常的耗时)。这种情况在 GC 日志中如下所示:

此处 GC 的停顿发生在根分区扫描之前,这意味着 GC 停顿还会继续等待,我们会看到GC 日志中的相互交织的输出。GC 日志的时间戳显示应用线程等待了大概 100 毫秒——这就是新生代 GC 停顿时间比日志中其他停顿的平均持续时间还长 100 毫秒的原因。这是一个信号,说明你的 G1 收集器需要进行调优。

根分区扫描完成后,G1 收集器就进入到并发标记阶段。这个阶段完全在后台运行,阶段启动和停止时在 GC 日志中各会打印一条日志。

并发标记阶段是可以中断的,所以这个阶段中可能发生新生代垃圾收集。紧接在标记阶段 之后的是重新标记(remarking)阶段和正常的清理阶段。

这几个阶段都会暂停应用线程,虽然暂停的时间通常很短。紧接着是一个额外的并发清理 阶段:

这之后,正常的 G1 周期就结束了——至少是垃圾的定位就完成了。清理阶段真正回收的内存数量很少,G1 到这个点为止真正做的事情是定位出哪些老的分区可回收垃圾最多 (即图 2-2 中标记为 X 的分区)。

现在,G1 会执行一系列的混合式垃圾回收(mixed GC)。这些垃圾回收被称作“混合式” 是因为它们不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。 混合式垃圾收集的效果如图 2-3 所示。

同新生代垃圾收集通常的行为一样,G1 收集器已经清空了 Eden 空间,同时调整了Survivor 空间的大小。此外,标记的两个分区也已经被回收。这些分区在之前的扫描中已经证实包含大量垃圾对象,因此绝大部分已经被释放。这些分区中的活跃数据被移动到另一个分区(就像把活跃数据从新生代移动到老年代的分区)。这就是为什么 G1 收集器最终出现碎片化的堆的频率,跟 CMS 收集器比较起来要小得多的原因——随着 G1 垃圾的回收以这种方式移动对象,实际伴随着压缩。关于混合式垃圾回收操作,请参考下面的日志:

应注意,减少的整个堆的使用不仅仅是 Eden 空间移走的 1222 MB。这其中的差异看起来很小(只有 16 MB),但是同时还有部分 Survivor 空间的对象晋升到了永久代,除此之外, 每次混合式垃圾回收只会清理部分目标老年代分区。接下来的讨论中,我们会看到确保混合式垃圾收集清理掉足够的内存对避免将来发生并发失效有多重要。

混合式垃圾回收周期会持续运行直到(几乎)所有标记的分区都被回收,这之后 G1 收集器会恢复常规的新生代垃圾回收周期。最终,G1 收集器会启动再一次的并发周期,决定哪些分区应该在下一次垃圾回收中释放。 同 CMS 收集器一样,有的时候你会在垃圾回收日志中观察到 Full GC,这些日志是一个信号,表明我们需要进一步调优(具体的方式很多,甚至很可能要分配更多的堆空间)才能提升应用程序的性能。主要有 4 种情况会触发这类的 Full GC,如下所列

并发模式失效

G1 垃圾收集启动标记周期,但老年代在周期完成之前就被填满,在这种情况下,G1 收集器会放弃标记周期:

发生这种失败意味着堆的大小应该增加了,或者 G1 收集器的后台处理应该更早开始,或者是需要调整周期,让它运行得更快(譬如,增加后台处理的线程数)。

晋升失败

G1 收集器完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。垃圾回收日志中,这种情况的现象通常是混合式 GC 之后紧接着一次 Full GC

这种失败通常意味着混合式收集需要更迅速地完成垃圾收集;每次新生代垃圾收集需要处理更多老年代的分区。

疏散失败

进行新生代垃圾收集时,Survivor 空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在 GC 日志中通常被当成一种特别的新生代:

这条日志表明堆已经几乎完全用尽或者碎片化了。G1 收集器会尝试修复这一失败,但是你可以预期,结果会更加恶化:G1 收集器会转而使用 Full GC。解决这个问题最简单 的方式是增加堆的大小。

巨型对象分配失败

使用 G1 收集器时,分配非常巨大对象的应用程序可能会遭遇另一种 Full GC

快速小结

1. G1 垃圾收集包括多个周期(以及并发周期内的阶段)。调优良好的 JVM 运行 G1 收集器时应该只经历新生代周期、混合式周期和并发 GC 周期。

2. G1 的并发阶段会产生少量的停顿。

3. 恰当的时候,我们需要对 G1 进行调优,才能避免 Full GC 周期发生。

G1垃圾收集器调优

G1 垃圾收集器调优的主要目标是避免发生并发模式失败或者疏散失败,一旦发生这些失败就会导致 Full GC。避免 Full GC 的技巧也适用于频繁发生的新生代垃圾收集,这些垃圾收集需要等待扫描根分区完成才能进行。其次,调优可以使过程中的停顿时间最小化。下面所列的这些方法都能够避免发生 Full GC。

通过增加总的堆空间大小或者调整老年代、新生代之间的比例来增加老年代空间的大小。

• 增加后台线程的数目(假设我们有足够的 CPU 资源运行这些线程)。

• 以更高的频率进行 G1 的后台垃圾收集活动。

• 在混合式垃圾回收周期中完成更多的垃圾收集工作。

这里有很多的调优可以做,不过 G1 垃圾收集器调优的目标之一是尽量简单。为了达到这个目标,G1 收集器最主要的调优只通过一个标志进行:这个标志跟 Throughput 收集器的标志一致,也是 -XX:MaxGCPauseMillis=N。使用 G1 垃圾收集器时,该标志有一个默认值:200 毫秒(这一点跟 Throughput 收集器有所不同)。如果 G1 收集器发生时空停顿(stop-the-world)的时长超过该值,G1 收集器就会尝试各种方式进行弥补——譬如调整新生代与老年代的比例,调整堆的大小,更早地启动后台处理,改变晋升阈值,或者是在混合式垃圾收集周期中处理更多或更少的老年代分区(这是最重要的方式)

通常的取舍就发生在这里:如果减小参数值,为了达到停顿时间的目标,新生代的大小会相应减小,不过新生代垃圾收集的频率会更加频繁。除此之外,为了达到停顿时间的目标,混合式 GC 收集的老年代分区数也会减少,而这会增大并发模式失败发生的机会。

如果设置停顿时间目标无法避免 Full GC,我们可以进一步针对不同的方面逐一调优。对G1 垃圾收集器而言,调整堆大小的方法与其他的垃圾收集算法并没有什么不同。

调整G1垃圾收集的后台线程数

为了帮助 G1 赢得这场垃圾收集的比赛,可以尝试增加后台标记线程的数目(假设机器有足够的空闲 CPU 可以支撑这些线程的运行)。调整 G1 垃圾收集线程的方法与调整 CMS 垃圾收集线程的方法类似:对于应用线程暂停运行的周期,可以使用 ParallelGCThreads 标志设置运行的线程数;对于并发运行阶段可以使用 ConcGCThreads 标志设置运行线程数。不过,ConcGCThreads 标志的默认值在 G1 收集器中不同于 CMS 收集器。它的计算方法如下: ConcGCThreads = (ParallelGCThreads + 2) / 4 这个算法依然是基于整数的;G1 收集器与 CMS 收集器的计算方法相差无几。

调整G1垃圾收集器运行的频率

如果 G1 收集器更早地启动垃圾收集,也能赢得这场比赛。G1 垃圾收集周期通常在堆的占用达到参数 -XX:InitiatingHeapOccupancyPercent=N 设定的比率时启动,默认情况下该参数的值为 45。注意,跟 CMS 收集器不太一样,这个参数值的依据是整个堆的使用情况,不单是老年代的。

InitiatingHeapOccupancyPercent 的值是个常数,G1 收集器自身不会为了达到停顿时间目标而修改这个参数值。如果该参数设置得过高,应用程序会陷入 Full GC 的泥潭之中,因为并发阶段没有足够的时间在剩下的堆空间被填满之前完成垃圾收集。如果该值设定得过 小,应用程序又会以超过实际需要的节奏进行大量的后台处理。我们在介绍 CMS 收集器时讨论过,必须要有能支撑后台处理的 CPU 周期,因此消耗额外的 CPU 就不那么重要。 然而,这可能会带来非常严重的后果,因为并发阶段会出现越来越多的短暂应用线程的停顿。这些停顿会迅速累积起来,因此使用 G1 收集器时要避免频繁地进行后台清理。并发周期结束之后,检查下堆的大小,确保 InitiatingHeapOccupancyPercent 的值大于此时堆 的大小。

调整G1收集器的混合式垃圾收集周期

并发周期之后、老年代的标记分区回收完成之前,G1 收集器无法启动新的并发周期。因此,让 G1 收集器更早启动标记周期的另一个方法是在混合式垃圾回收周期中尽量处理更多的分区(如此一来最终的混合式 GC 周期就变少了)。

混合式垃圾收集要处理的工作量取决于三个因素。第一个因素是有多少分区被发现大部分是垃圾对象。目前没有标志能够直接调节这个因素:混合式垃圾收集中,如果分区的垃圾占用比达到 35%,这个分区就被标记为可以进行垃圾回收。(这个因素在将来的某个时刻可能也能调整,在开源的实验版本中已经有名为 -XX:G1MixedGCLiveThresholdPercent=N 的参数可以对其进行调整)。

第二个因素是 G1 垃圾收集回收分区时的最大混合式 GC 周 期 数, 通 过 参 数-XX:G1MixedGCCountTarget=N 可以进行调节。这个参数的默认值为 8;减少该参数值可以帮助解决晋升失败的问题(代价是混合式 GC 周期的停顿时间会更长)。另一方面,如果混合式 GC 的停顿时间过长,可以增大这个参数的值,减少每次混合式 GC 周期的工作量。不过调整之前我们需要确保增大值之后不会对下一次 G1 并发周期带来太大的延迟,否则可能会导致并发模式失败。

最后,第三个影响因素是 GC 停顿可忍受的最大时长(通过 MaxGCPauseMillis 参数设定)。MaxGCPauseMillis 标志设定的混合式周期时长是向上规整的,如果实际停顿时间在停顿最大时长以内,G1 收集器能够收集超过八分之一标记的老年代分区(或者其他设定的值)。 增大 MaxGCPauseMillis 能在每次混合式 GC 中收集更多的老年代分区,而这反过来又能帮助 G1 收集器在更早的时候启动并发周期。

快速小结

1. 作为 G1 收集器调优的第一步,首先应该设定一个合理的停顿时间作为目标。

2. 如果使用这个设置后,还是频繁发生 Full GC,并且堆的大小没有扩大的可能,这时就需要针对特定的失败采用特定的方法进行调优。

a. 通过 InitiatingHeapOccupancyPercent 标志可以调整 G1 收集器,更频繁地启动后台垃圾收集线程。

b. 如果有充足的 CPU 资源,可以考虑调整 ConcGCThreads 标志,增加垃圾收集线程数。

c. 减小 G1MixedGCCountTarget 参数可以避免晋升失败。

高级调优

晋升及Survivor空间

新生代垃圾收集时,有的对象可能还处于活跃期。这些对象中,有些是刚创建的新对象,这些对象还会存活相当长的一段时间,还有一些只有短暂的生命周期。如果 JVM 在循环的中段启动垃圾回收,这些超短寿 (very-short-lived)的 BigDecimal 对象面临的局面就变得非常尴尬:它们刚被创建,因此不能被回收释放;但是它们的生命周期又非常短,无法满足晋升到老年代的条件。这就是新生代被划分成一个 Eden 空间和两个 Survivor 空间的原因。这种布局让对象在新生代内有更多的机会被回收,不再局限于只能晋升到老年代(最终填满老年代)。

新生代垃圾收集时,如果 JVM 发现对象还十分活跃,会首先尝试将其移动到 Survivor空间,而不是直接移动到老年代。首次新生代垃圾收集时,对象被从 Eden 空间移动到Survivor 空间 0。紧接着的下一次垃圾收集中,活跃对象会从 Survivor 空间 0 Eden 空 间移动到 Survivor 空间 1。这之后,Eden 空间和 Survivor 空间 0 被完全清空。下一次的垃圾回收会将活跃对象从 Survivor 空间 1 Eden 空间移回 Survivor 空间 0,如此反复。 (Survivor 空间也被称为“To”空间和“From”空间;每次回收,对象由“From”空间移 出,移入到“To”空间。“From”和“To”只是简单地表示两个 Survivor 空间之间的指向, 每次垃圾回收时,方向都会互换。)

两种情况下,对象会被移动到老年代。第一,Survivor 空间的大小实在太小。新生代垃圾收集时,如果 目标 Survivor 空间被填满,Eden 空间剩下的活跃对象会直接进入老年代。第二,对象在Survivor 空间中经历的 GC 周期数有个上限,超过这个上限的对象也会被移动到老年代。 这个上限值被称为晋升阈值(Tenuring Threshold)。

这些影响垃圾收集的因素都有对应的调优标志。Survivor 空间是新生代空间的一部分,跟堆内的其他区域一样,JVM 可以对它进行动态的调节。Survivor 空间的初始大小由 -XX:InitialSurvivorRatio=N 标志决定。这个参数值在下面的这个公式中使用:

survivor_space_size = new_size / (initial_survivor_ratio + 2)  初始 Survivor 空间的占用比率(initial_survivor_ratio)默认为 8,由此我们可以计算出每个 Survivor 空间会占用大约 10% 的新生代空间。

JVM 可以增大 Survivor 空 间 的 大 小 直 到 其 最 大 上 限, 这个上限可以通过 -XX:MinSurvivorRatio=N 参数设置。MinSurvivorRatio 标志在下面这个公式中使用:maximum_survivor_space_size = new_size / (min_survivor_ratio + 2) 这个参数值默认为 3,意味着 Survivor 空间的最大值为新生代空间的 20%。再次提醒,这个参数值是个分母,分母值最小时,Survivor 空间的容量最大。

为了保持 Survivor 空间的大小为某个固定值,我们可以使用 SurvivorRatio 参数,将其设定为期望的值,同时关闭 UseAdaptiveSizePolicy 标志(然而,我们需要注意一点,即关闭自适应大小调整会同时影响新生代和老年代)。

JVM 依据垃圾回收之后 Survivor 空间的占用情况判断是否需要增加或者减少 Survivor 空间 的大小(由定义的比率决定)。默认情况下,Survivor 空间调整之后要能保证垃圾回收之后有 50% 的空间是空闲的。通过标志 -XX:TargetSurvivorRatio=N 可以设置这个值。

最后,还有一个问题,即对象在移动到老年代之前,需要在 Survivor 空间之间来回移动多少个 GC 周期。这个问题取决于晋升阈值的设定。JVM 会持续地计算,寻找它认为最合适的晋升阈值。通过 -XX:InitialTenuringThreshold=N 标志可以设置初始的晋升阈值(对 于 Throughput 收集器和 G1 收集器,默认值是 7,对于 CMS 收集器默认值为 6)。JVM 最终会在 1 和最大晋升阈值(由 -XX:MaxTenuringThreshold=N 标志设定)之间选择一个合适的值。对于 Throughput 收集器和 G1 收集器,默认的最大晋升阈值为 15,对 CMS 收集器, 最大的晋升阈值为 6

两种窘境一直晋升与从不晋升

晋升阈值总是在 1 MaxTenuringThreshold 之间取值。即使 JVM 启动时将初始晋升阈值设置为最大值,这个参数也不一定会一直保持,JVM 可能会在某个时刻减小这个阈值。 使用两个标志可以通过极端的方式避免出现这种情况。如果你确切地知道新生代垃圾收 集存活下来的对象在之后很长的一段时间内都会存在,可以使用 -XX:+AlwaysTenure 标 志(默认值为 false),开启这个标志的效果与将 MaxTenuringThreshold 设置为 0 的效 果在本质上是一样的。然而,只有非常罕见的情况才需要开启这个标志,启用之后对象 会直接晋升到老年代,不会再存放于 Survivor 空间。

第二个标志是 -XX:+NeverTenure(默认值也是 false)。这个标志有两方面的影响:设置参数后 JVM 会认为初始晋升阈值和最大晋升阈值都无限大;一旦设置了该参数,JVM 就不再调整晋升阈值,也不会将其降低。换句话说,开启 -XX:+NeverTenure 标志后只要 Survivor 空间有容量,就不会有对象被晋升到老年代。

我们已经学习了很多的参数,那么什么情况下应该使用哪些参数呢?观察晋升的统计信息能够帮助我们更好地做出决定。使用 -XX:+PrintTenuringDistribution 标志可以在 GC 日 志中增加这部分信息(默认情况下,-XX:+PrintTenuringDistribution 的值为 false)。

查看 GC 日志时,最重要的是观察在 Minor GC 中是否存在由于 Survivor 空间过小,对象直接晋升到老年代的情况。我们要尽量避免发生这种情况:如果大量的短期对象最终填满 老年代,会导致频繁的 Full GC

使用 G1 收集器或 CMS 收集器时,我们可以从垃圾收集日志中获取更多的信息:

期望的 Survivor 空间大约是 35 MB,但是我们能看到更多信息,包括 Survivor 空间中所有对象的大小。由于需要晋升 37 MB 的数据,Survivor 空间的确会发生溢出。如果对象的生命周期很长,跨 越多个垃圾收集周期,无论怎样调整它们最终都会移动到老年代,在这种情况下,调整Survivor 空间和晋升阈值不会有太大的帮助。但是,如果对象经过几个 GC 周期就会被回收,合理安排 Survivor 空间更高效地加以利用,能够提升一定的程序性能。如果增大 Survivor 空间的大小,内存由新生代的 Eden 空间 划分到 Survivor 空间。不过对象的分配都发生在 Eden 空间,这意味着在 Minor GC 之前能分配的对象数目会更少。因此,我们不推荐采用这种方式。另一种可能是增大新生代的大小。采用这种方式的效果可能事与愿违:虽然对象晋升到老年代的频率降低了,但是老年代空间变得更小,应用程序可能会更频繁地发生 Full GC。如果堆的大小可以同时增加,那么新生代和老年代都能获得更多的内存,这是最好的解决方案。推荐的流程是增大堆的大小(或者至少增大新生代),同时减小存活率。采用这 种方法 Survivor 空间增大的值会比 Eden 空间的增长更大。应用程序最终的新生代垃圾收集次数与调节之前基本持平。不过 Full FC 的次数会更少,因为晋升到老年代的对象数 更少了(再次重申,这种调优适用的应用程序,其大多数对象在几个 GC 周期之后就不再存活)。

我们可以增大 MaxTenuringThreshold 值,让对象在 Survivor 空间中停留更多的周期。但是,我们也要注意,晋升阈值增大,对象在 Survivor 空间停留的时间越长,将来的新生代收集中,Survivor 空闲空间就会越少: 越有可能发生 Survivor 空间溢出,对象再次被直接晋升到老年代。

快速小结

1. 设计 Survivor 空间的初衷是为了让对象(尤其是已经分配的对象)在新生代停留更多的 GC 周期。这个设计增大了对象晋升到老年代之前被回收释放的几率。

2. 如果 Survivor 空间过小,对象会直接晋升到老年代,从而触发更多的老年代 GC

3. 解决这个问题的最好方法是增大堆的大小(或者至少增大新生代),让JVM 来处理 Survivor 空间的回收。

4. 有的情况下,我们需要避免对象晋升到老年代,调整晋升阈值或者Survivor 空间的大小可以避免对象晋升到老年代

分配大对象

“大型”是一个相对的概念;正如我们后面会看到的,它取决于 JVM 内的“线程本地分配缓冲区” (Thread Local Allocation BufferTLAB),TLAB 的大小是各种垃圾收集算法进行垃圾收集时都要考虑的因素,除此之外,G1 收集 器对超大型对象还有一些额外的考量(再次重申,大型是个相对的术语,对于 2 GB 的堆, 对象大小如果超过 512 MB 就算大型对象)。

Eden 空间让更快地进行对象分配成为可能(尤其是对于分配之后又被迅速回收的对象)。 结果表明,Eden 空间中对象分配速度更快的原因是每个线程都有一个固定的分区用于分配对象,即一个 TLAB。对象在一个共享的空间中分配,我们需要采用一些同步机制来管理空间内的空闲空间指针。每个线程有固定的分配区域,分配对象时,线程之间不需 要进行任何的同步。(这是利用线程本地变量避免锁争用的一个变种。)

默认情况下 TLAB 就是开启的,JVM 管理着它们的大小及如何使用。我们需要意识到的最重要的事是 TLAB 都不大, 因此大型对象无法在 TLAB 内进行分配。大型对象必须直接从堆上分配,由于需要同步, 这会消耗额外的时间。一旦 TLAB 空间用尽,特定大小的对象就无法再继续分配。这时,JVM 可以有不同的选 择。一个选项是回收这块 TLAB,为该线程分配一块新的 TLAB。由于 TLAB 只是 Eden 空间中的一个区段,下一次新生代垃圾收集时这块 TLAB 整个都会被回收,并在之后的空间 分配中重用。除此之外,JVM 还可以直接在堆上分配对象,保留当前的 TLAB 不动(至少 在线程分配新的对象到 TLAB 之前保持不变)。例如发生下面这种情况,TLAB 的大小为 100 KB,其中 75 KB 都已经被占用。这时来了个新的空间分配请求,需要分配 30 KB 的 空间,我们可以回收整个 TLAB,这种方式会浪费 25 KB Eden 空间。或者直接在堆上 分配这个对象,如果下一次的对象分配空间要求小于等于 25 KB,线程还可以将 TLAB 的空闲空间分配给这些对象。

JVM 提供了各种参数可以控制这些行为,但这一切都取决于 TLAB 大小。默认情况下,TLAB 的大小由三个因素决定:应用程序的线程数、Eden 空间的大小以及线程的分配率。因此两类的应用程序会受益于 TLAB 参数的调整:需要分配大量巨型对象的应用程序,以 及相对于 Eden 空间的大小而言,应用程序线程数量过多的应用。默认情况下,TLAB 就是开启的;使用 -XX:-UseTLAB 可以关闭 TLAB,不过考虑到 TLAB 带来的性能提升,关闭这个功能不是个明智的决定。

由于 TLAB 空间大小的计算在一定程度上基于线程的分配率,我们不大可能准确预测应用程序的 TLAB 大小。我们能做的是监控 TLAB 的分配情况,看是否有任何对象的分配发生在 TLAB 之外。如果发现大量的对象分配发生在 TLAB 之外,我们有两种选择:减小分配对象的大小,或者调整 TLAB 的参数。与其他的工具比较起来,JFR TLAB 分配的监控方面要强大得多。

在这段 5 秒钟的记录中,49 个对象分配发生在 TLAB 之外;这些对象的最大值为 48 字节。由 TLAB 的最小值为 1.35 MB 我们得知,这些对象被分配到堆上的原因是空间分配时 TLAB 已经耗尽:它们不是由于对象大小的原因被直接分配到了堆上。这种情况通常发生 在新生代垃圾回收之前(由于 Eden 空间耗尽,而 TLAB Eden 空间切出来的一部分)。这段时间内,对象分配的总大小为 1.59 KB,这个例子中无论是分配的数量,还是分配的 大小都不是问题。总会有一些对象在 TLAB 之外分配,尤其是当 Eden 空间的使用接近新 生代收集的边缘时。

这段记录中,TLAB 中内存分配的总数是 952.96 MBTLAB 之外对象分配使用的总内存数是 568.32 MB。对于这个例子,无论是改变应用程序,使用更小的对象,还是调整 JVM 将更大的 TLAB 分配给这些对象,都能取得不错的效果。

对于开源版本的 JVM(不附带 JFR),要监控 TLAB 的分配情况,最好的途径就是在命令行中添加 -XX:+PrintTLAB 标志。这样,每次新生代垃圾收集时,GC 日志中就同时包含了两种类型的信息:每个线程都有一行描述该线程的 TLAB 使用情况,以及一行摘要信息, 描述 JVM 整体的 TLAB 使用情况,每个线程一行的日志如下所示:

输出中的 gc 表明这一行日志是在垃圾回收时输出的;线程自身是一个常规的应用线程。 线程的 TLAB 大小是 221 KB。从上次新生代收集开始,已经有八个对象在堆上分配(slow allocs);占线程分配对象总量的 1.6%0.01613),总计大小为 11 058 KBTLAB 空间的 0.1% 被“浪费”了,主要的源头是三件事:并发垃圾收集启动时 TLAB 中的 10 336 字节 空间被回收释放,其他(释放的)TLAB 2112 字节被释放,以及“快速”分配器分配的空间大小为“0”字节。 每个线程的 TLAB 数据输出后,JVM 还会输出一条概略日志,如下所示:

在这个例子中,从上次新生代垃圾收集起,66 个线程进行了各种形式的内存分配。这些线程总共填充了 TLAB 3234 次,最活跃的线程填充了它的 TLAB 105 次。总共在堆上分配 了 406 次对象(一个线程最多分配了 14 次对象),并且 TLAB 中有 1.1% 的空闲空间由于 TLAB 空间的释放被浪费。 在每个线程的日志中,如果发现线程有大量的对象分配发生在 TLAB 之外,就应该考虑对 TLAB 进行调整了。

调整TLAB的大小

对于花费大量时间在 TLAB 之外分配对象的应用程序,将分配移动到 TLAB 之内能有效提升应用程序的性能。如果只有极少数对象的分配发生在 TLAB 之外,提升性能最好的方案 是修改应用程序。 如果不可能变更应用程序代码,你还可以尝试通过调整 TLAB 的大小来适配应用程序的 需要。由于 TLAB 的大小基于 Eden 空间,通过参数调整(增大)Eden 空间会自动增大 TLAB 的大小。 使用 -XX:TLABSize=N 标志可以显式地指定 TLAB 的大小(默认为 0,表示使用前面介绍的方法动态计算得出)。这个标志只能设置 TLAB 的初始大小;为了避免在每次 GC 时都调 整 TLAB 的大小,可以使用 -XX:-ResizeTLAB 标志(大多数的平台上,这个参数的默认值 都是 true)。这是通过调整 TLAB,充分提升对象分配性能最简单的方法(坦率地说,通常这也是最有效的方法)。

一个新的对象无法适配到当前的 TLAB 中(但是可以容纳于一个新的、空闲的 TLAB 中)时,JVM 就需要做一些抉择:到底是在堆上分配这个对象,还是要回收当前的 TLAB,重 新分配一个新的 TLAB 来完成这次对象分配,这个决策取决于几个参数。TLAB 日志的输 出中,refill waste 的值代表了决策的当前阈值:如果 TLAB 无法容纳新对象的大小超过 这个阈值,那么就会在堆上分配新的对象。如果有问题的对象的大小比这个阈值小,就回收老的 TLAB 空间。

这个值是动态计算得出的,但是默认的初始值是 TLAB 大小的 1%,或者是由参数 -XX:TLABWasteTargetPercent=N 特别设定的值。每当发生堆上的分配,这个值就增大一 笔,增量值由参数 -XX:TLABWasteIncrement=N 设定(默认值为 4)。这种设计能够避免线 程达到 TLAB 空间占用的阈值,从而持续地在堆上分配对象:随着目标百分比(Target Percentage)的增大,TLAB 空间被回收的几率也在增加。调整 TLABWasteTargetPercent 参 数的结果往往同时伴随着 TLAB 空间大小的调整,所以,虽然可以调整这个参数,但是效 果往往不那么确定。

最终,TLAB 空间调整生效时,其容量的最小值可以使用 -XX:MinTLABSize=N 参数设置(默认为 2 KB)。TLAB 空间的最大容量略小于 1 GB(使用整型数组可以用到 TLAB 空间的最 大上限,由于对象对齐的原因,最大上限会向下圆整),并且不能修改。

快速小结:

对需要分配大量大型对象的应用,TLAB 空间的调整就变得必不可少(不过, 通常情况下,我们更推荐在应用程序中使用小型对象的做法)。

巨型对象

TLAB 空间中无法分配的对象,JVM 会尽量尝试在 Eden 空间中进行分配。如果 Eden空间无法容纳该对象,就只能在老年代中分配空间。而这种内存布局打乱了该对象正常的 垃圾回收周期,如果它是一个短期存在的对象,还会对垃圾收集造成负面的影响。对于这 种情况,除非修改应用程序,放弃使用那些短期存在的巨型对象,否则别无它法。G1 收集器使用不同的方法处理巨型对象,不过如果对象的大小超过了 G1 收集器的分区, 这些对象也会被分配到老年代。因此,对于使用大量巨型对象的应用程序,即使使用 G1 收集器还是需要特别的调优才能弥补这部分的性能损失。

G1分区的大小

G1 收集器将堆划分成了一定数量的分区,每个分区的大小都是固定的。分区的大小不是动态变化的,具体的值是启动时,依据堆大小的最小值(即 Xms 的值)得出的。分区大小 的最小值是 1 MB。如果堆的最小值超过 2 GB,分区的大小会依据下面的公式计算得出 (使用基数为 2,取 log 的算法):

分区大小 = 1 << log(初始堆的大小 / 2048);

简言之,初始划分堆时,分区的大小是 2 的最小的 N 次幂,使其结果最接近于 2048 个分区。这里还有一些最小、最大值的限制;分区的大小最小是 1 MB,最大不能超过 32 MB。下面列出了所有的可能性

G1 分区的大小可以通过 -XX:G1HeapRegionSize=N 标志设置(正常情况下,默认值是 0,意味着使用刚才描述的动态算法计算)。设定的参数值应该是 2 的幂(譬如:1 MB 或者 2 MB);否则,这个值会向下圆整到最接近 2 的幂。

G1 收集的分区调整及大堆

通常情况下,G1 收集器分区的大小调整只有在处理巨型对象分配时才需要进行,但是也 有一种例外的情形。 如果应用程序设定了一个非常大的堆区间,譬如 -Xms2G -Xmx32G,这种情况下分区的大小是 1 MB。当堆充分扩张时,G1 收集器的分区数可以高达 32 000 个。这是一个数量巨大的待处理分区,G1 收集算法最初的设计并没有针对这样大量的分区,它期望的分区数是 2048 个左右。这个例子中,增大 G1 收集器分区的大小能提高 G1 垃圾收集的效率;我们需要依据堆的大小选择合适的分区大小,让分区的数量尽量接近 2048 个。

使用G1收集器分配巨型对象

如果 G1 收集器的分区大小是 1 MB,应用程序分配了个 2 百万字节的数组,这个数组是没有办法在一个 G1 分区中存放的。但是这些巨型对象又必须被保存在连续的 G1 分区内。 如果 G1 分区的大小是 1 MB,程序分配了个 3.1 MB 的数组,G1 收集器必须在老年代内 找到 4 个连续的分区才能完成这次分配工作(最后一个分区的剩余部分会保持空闲,导致 0.9 MB 的空间浪费)。这种做法成功地打败了 G1 收集器传统的收集方式,即压缩,它能 够依据分区的满溢程度自主地选择回收哪些分区。通常,为了找到连续的分区,G1 收集 器还不得不启动 Full GC

由于巨型对象直接在老年代中分配,它们不会被新生代垃圾收集所回收。因此,如果短寿型对象采用这种方式分配,收集器的分代机制就不再生效。巨型对象只能在 G1 收集器的 并发周期中回收。好消息是,巨型对象的回收会更迅速,因为它是所在分区唯一的对象。 巨型对象会在并发周期中的清理阶段(而不是混合式 GC 阶段)被回收释放。

增大 G1 分区的大小,让其能够在一个分区内分配应用需要的所有对象能够提升 G1 收集的效率。为了判断应用的 Full GC 是否源于巨型对象的分配,我们需要开启自适应大小调 整(Adaptive Size PolicyGC 的日志记录。应用程序分配巨型对象时,G1 收集器首先会 尝试启动一个并发周期。

这行日志表明发生了巨型对象分配,触发了并发 G1 周期。在这个例子中,对象分配成功, 没有对垃圾收集的其他方面造成影响(G1 收集器刚好找到了需要的连续分区)。如果 G1 收集器没有找到连续的空闲分区,就会启动一次 Full GC

由 于 堆 无 法 为 适 配 新 的 巨 型 对 象 而 进 行 扩 展, 因 此 为 了 给 分 配 请 求 提 供 连 续 的 分 区,G1 收集器只能进行 Full GC。 在 这 种 情 况 下, 一 旦 发 生 问 题, 即 便 是 开 启 PrintAdaptiveSizePolicy 标志也无法提供更多的垃圾回收日志,标准的 G1 垃圾收集日志 也无法提供足够的信息,以诊断问题的根源。

为了避免发生这种 Full GC,首先是要确定导致问题的巨型对象大小(本例中,从垃圾收 集日志中定位的对象大小是 524 304 字节)。接下来,需要判断是否有办法减少程序中这些 对象的大小。下下策才是针对这些对象,调整 JVM。如果无法减少对象的大小,就需要计 算容纳这些对象所需要的分区大小。如果对象占用的空间达到分区容量的 50% 以上,G1 收集器就认为这是个巨型对象。因此,这个例子中,如果被质疑的对象大小为 524 304 字 节,G1 分区的大小至少应该是 1.1 MB。由于 G1 收集算法中,分区的大小总是 2 的幂, 所以 G1 分区的大小应该为 2 MB,才能保持在标准的 G1 分区中完成这些对象的分配。

快速小结

1. G1 分区的大小是 2 的幂,最小值为 1 MB

2. 如果堆的初始大小跟最大值相差很大,这种堆会有大量的 G1 分区,在这 种情况下,应该增大 G1 分区的大小。(提初始和最大一样)

3. 如果要分配的对象大小超过了 G1 收集器分区容量的一半,对于这种应用程序,我们应该增大 G1 分区的容量,让 G1 分区能更好地适配这些 对象。遵循这个原则,应用程序分配对象的大小至少应是 512 KB(因为 G1 分区的最小值为 1 MB)。

AggressiveHeap标志

虽然这个标志已经更换了很多个版本,到目前依然存在,但是我们不推荐使用该标志(虽然这个标志暂时还没有被官方正式弃用)。这个标志的问题在于它隐藏了很多实际采用的 调优工作,让我们很难了解 JVM 实际运行时的设置。它的一些参数值设置是依据运行 JVM 中物理机的情况动态调整的,因此,开启这个标志有时对性能的影响是负面的。

全盘掌控堆空间的大小

 堆的默认大小依据机器的内存配置确定,不过也可以通过参数 -XX:MaxRAM=N 设置。通常情况下,这个值是由 JVM 检测机器的物理内存计算得出。不过,JVM 同时设置了一些限制,譬如对于 32 位的 client 编译器,MaxRam 最大只能是 1 GB,对于 32 位的 server 编译 器,MaxRam 可达 4 GB,如果是 64 位编译器,上限最大可以达到 128 GB。堆的最大容量是 MaxRAM 值的四分之一。这就是为什么堆的默认大小在不同的机器上会有不同的原因:如果 机器的物理内存比 MaxRAM 的值小,默认堆的大小就是物理内存的 1/4。但是,相反的规则 并不适用,即使机器配置了数百 GB 的内存,JVM 能使用的最大堆容量也不会超过默认值 32 GB,即 128 GB 1/4。 默认最大堆的计算实际采用下面的公式: Default Xmx = MaxRAM / MaxRAMFraction 因 此, 默 认 最 大 堆 的 大 小 也 可 以 通 过 -XX:MaxRAMFraction=N 标 志 值 进 行 调 整,MaxRAMFraction 的默认值为 4。最后,为了让堆的默认值调整更加完备 ,JVM 还提供了另 一个参数调整最大堆的默认值,这个参数是 -XX:ErgoHeapSizeLimit=N。该参数默认值为 0 (表示忽略该标志);否则,如果设置的限制值比 MaxRAM/MaxRAMFraction 还小,就使用该 参数指定的值。

另一方面,如果机器配置的物理内存非常少,JVM 还要确保预留足够的内存给操作系统使用。这就是为什么在内存只有 192 MB 的机器上,JVM 会限制最大堆的大小为 96 MB 甚至 更少。这个值的计算是基于 -XX:MinRAMFraction=N 参数,默认值为 2

计算堆的初始大小与此类似,不过相对简单一些,影响因素更少。堆的初始大小计算采用下面的公式: Default Xms = MaxRAM / InitialRAMFraction 计算默认最小堆的大小的方法同样也适用,InitialRAMFraction 参数的默认值为 64。不 过,这个参数也不能完全控制堆的初始值,如果该参数值小于 5 MB,或者更确切地说, 指定的 InitialRAMFraction 小于 -XX:OldSize=N 参数的设定(该参数默认为 4 MB)时会采用另外的处理方式。这种情况下,堆的初始大小等于新生代和老年代大小之和。

在调优之前,先试着回答这些问题,它们能帮你理清思路,选择合适的调优措施:

你的应用能够忍受 Full GC 的停顿吗?

如果答案是肯定的,选择 Throughput 收集器能获得最高的性能,同时,使用的 CPU 和堆的大小也比其他的垃圾收集器少。如果答案是否定的,你需要依据可用的堆大小做 选择,如果可用的堆较小,你可以选择并发收集器,譬如 CMS 收集器或者 G1 收集器; 如果可用的堆比较大,推荐使用 G1 收集器。

使用默认设置能达到你期望的性能目标吗?

尽量首先使用默认的设置。因为垃圾收集技术在不断发展成熟,自动调优大多数情况下取得的效果是最好的。如果使用默认设置没有达到你需要的性能目标,请确认垃圾收集 是否是性能瓶颈。查看垃圾收集日志能帮我们了解 JVM 在垃圾收集上花费了多长时间、 垃圾收集发生的频率是多少。对于负荷较高的应用,如果 JVM 花在垃圾收集上的时间 不超过 3%,即使进行垃圾调优也不会得到太大的性能提升(不过,如果那些指标是你 关注的方面,你仍然可以尝试通过调优缩短某些指标)。

应用的停顿时间与你预期的目标接近吗?

如果停顿时间与你预期的目标很接近,调整最大停顿时间的设定可能是你需要做的。如果不是,你需要进行其他的调整。如果停顿时间太长,但是应用的吞吐量正常,你可以尝试减小新生代的大小(如果瓶颈是 Full GC 的停顿,就减小老年代的大小);调整之 后,停顿的频率会增加,但是单次停顿的时长会变短。

虽然 GC 的停顿时间已经非常短了,但应用的吞吐量依旧上不去?

这种情况下你需要增大堆的大小(至少要增大新生代)。但是,这并不意味着堆越大越好:更大的堆会导致更长的停顿时间。即便是并发收集器,默认情况下,增大堆也还是 意味着增大新生代,因此你会发现新生代的停顿时间变长了。即便是这样,如果有可 能,还是应该增大堆的大小,或者增大对应代的大小。

你使用并发收集器吗?是否发生了由并发模式失败引起的 Full GC

如果你有足够的 CPU 资源,可以尝试增加并发 GC 线程的数量,或者通过调整InitiatingHeapOccupancyPercent 参数在更早的时候启动后台清理线程。对于 G1 收集 器,如果有混合式垃圾收集尚未完成,并发周期就不会启动。在这个时候,可以尝试降 低混合式 GC 的回收目标(Mixed GC count target)。

你使用并发收集器吗?是否发生了由晋升失败引起的 Full GC

CMS 收集器中,发生晋升失败意味着堆发生了碎片化。这种情况下,我们能做的事情不多:使用更大的堆,或者尽早地启动后台回收都能在一定程度上缓解堆的碎片化。 处理这种情况,更好的解决方法可能是使用 G1 收集器。G1 收集器中,疏散失败(To 空间溢出)表明遭遇了同样的情况,但是 G1 收集器能解决碎片化的问题,如果它的后 台线程在更早的时候启动,且混合式 GC 的速度更快的话。你可以尝试通过增大并发 G1 收集线程的数目,调整 InitiatingHeapOccupancyPercent,或者降低混合式 GC 的目 标来解决 G1 收集器中堆碎片化的问题。

内存溢出错误

在下列情况下,JVM 会抛出内存溢出错误(OutOfMemoryError

• JVM 没有原生内存可用; Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

• 永久代(在 Java 7 和更早的版本中)或元空间(在 Java 8 中)内存不足;Java 8 中  Exception in thread "main" java.lang.OutOfMemoryError: Metaspace   Java 7 中  Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

• Java 堆本身内存不足——对于给定的堆空间而言,应用中活跃对象太多; Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

• JVM 执行 GC 耗时太多。Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

当满足下列所有条件时就会抛出该错误。

(1) 花在 Full GC 上的时间超出了 -XX:GCTimeLimit=N 标志指定的值。其默认值是 98(也就是,如果 98% 的时间花在了 GC 上,则该条件满足)。

(2) 一次 Full GC 回收的内存量少于 -XX:GCHeapFreeLimit=N 标志指定的值。其默认值是 2, 这意味着如果 Full GC 期间释放的内存不足堆的 2%,则该条件满足。

(3) 上面两个条件连续 5 Full GC 都成立(这个数值是无法调整的)。

(4) -XX:+UseGCOverhead-Limit 标志的值为 true(默认如此)。

请注意,所有四个条件必须都满足。一般来说,应用中连续执行了 5 次以上的 Full GC,不一定会抛出 OutOfMemoryError。其原因是,即便应用将 98% 的时间花费在执行 Full GC 上,但是每次 GC 期间释放的堆空间可能会超过 2%。这种情况下可以考虑增加 GCHeapFreeLimit 的值。 还请注意,如果前两个条件连续 4 Full GC 周期都成立,作为释放内存的最后一搏, JVM 中所有的软引用都会在第五次 Full GC 之前被释放。这往往会防止该错误,因为第五次 Full GC 很可能会释放超过 2% 的堆内存(假设该应用使用了软引用)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值