5.2 GC调优基础
虽然处理堆时各种 GC 算法有所差异,但是它们的基本配置参数是一致的。很多情况下,我们只需要这些基础的配置就能运行应用程序。
5.2.1 调整堆的大小
GC 调整的第一堂课是调整应用程序堆的大小。关于堆大小的调整还有更高级的话题,不过作为第一步,我们首先讨论如何设置总体堆的大小。
与其他的性能问题一样,选择堆的大小其实是一种平衡。如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上,没有足够的时间去运行应用程序的逻辑。但是,简单粗暴地设置一个特别大的堆也不是解决问题的方法。GC 停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。
使用超大堆还有另一个风险。操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有 8 G 的物理内存,不过操作系统可能让你感觉有更多的可用内存。虚拟内存的数量取决于操作系统的设置,譬如操作系统可能让你感觉它的内存达到了 16 G。操作系统通过名为“交换”(swapping)(或者称之为分页,虽然这两者之间在技术上存在着差异,但是这些差异在这里不影响我们的讨论)。你可以载入需要 16 G 内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。
系统中运行着大量不同的应用程序时,这个流程工作得很顺畅,因为大多数的应用程序不会同时处于活跃状态。但是,对于 Java 应用,它工作得并不那么好。如果一个 Java 应用使用了这个系统上大约 12 G 的堆,操作系统可能在 RAM 上分配了 8 G 的堆空间,另外 4 G 的空间存在于磁盘(这个假设对实际情况进行了一些简化,因为应用程序也会使用部分的 RAM)。JVM 不会了解这些:操作系统完全屏蔽了内存交换的细节。这样, JVM 愉快地填满了分配给它的 12 G 堆空间。但这样就导致了严重的性能问题,因为操作系统需要将相当一部分的数据由磁盘交换到内存(这是一个昂贵操作的开始)。
更糟糕的是,这种原本期望一次性的内存交换操作在 Full GC 时一定会再次重演,因为 JVM 必须访问整个堆的内容。如果 Full GC 时系统发生内存交换,停顿时间会以正常停顿时间数个量级的方式增长。类似地,如果使用 Concurrent 收集器,后台线程在回收堆时,它的速度也可能会被拖慢,因为需要等待从磁盘复制数据到内存,结果导致发生代价昂贵的并发模式失效。
因此,调整堆大小时首要的原则就是永远不要将堆的容量设置得比机器的物理内存还大,另外,如果同一台机器上运行着多个 JVM 实例,这个原则适用于所有堆的总和。除此之外,你还需要为 JVM 自身以及机器上其他的应用程序预留一部分的内存空间:通常情况下,对于普通的操作系统,应该预留至少 1 G 的内存空间。
堆的大小由 2 个参数值控制:分别是初始值(通过 -Xms N 设置)和最大值(通过 -Xmx N 设置)。默认值的调节取决于多个因素,包括操作系统类型、系统内存大小、使用的 JVM。其他的命令行标志也会对该值造成影响;堆大小的调节是 JVM 自适应调优的核心。
JVM 的目标是依据系统可用的资源情况找到一个“合理的”默认初始值,当且仅当应用程序需要更多的内存(依据垃圾回收时消耗的时间来决定)时将堆的大小增大到一个合理的最大值。到目前为止,JVM 的高级调优标志以及调优细节都没有提及。为了让大家有一个感性的认识,我们列出了堆大小的默认最大值和最小值供大家参考,参见表 5-4。(为了使内存对齐,JVM 会对这些值进行圆整操作;所以 GC 日志中输出的大小可能与表中给出的值并不完全一致)。
表5-4:默认堆的大小
| 操作系统及JVM类型 | 初始堆的大小(Xms) | 最大堆的大小(Xmx) |
|---|---|---|
| Linux/Solaris,32 位客户端 | 16 MB | 256 MB |
| Linux/Soaris,32 位服务器 | 64 MB | 取 1 GB 和物理内存大小 1/4 二者中的最小值 |
| Linux/Soaris,64 位服务器 | 取 512 MB 和物理内存大小 1/64 二者中的最小值 | 取 32 GB 和物理内存大小 1/4 二者中的最小值 |
| MacOS,64 位服务器型 JVM | 64 MB | 取 1 GB 和物理内存大小 1/4 二者中的最小值 |
| 32 位 Window 系统,客户端型 JVM | 16 MB | 256 MB |
| 64 位 Window 系统,服务器型 JVM | 64 MB | 1 GB 和物理内存大小 1/4 二者中的最小值 |
如果机器的物理内存少于 192 MB,最大堆的大小会是物理内存的一半(大约 96 MB,或者更少)。
堆的大小具有初始值和最大值的这种设计让 JVM 能够根据实际的负荷情况更灵活地调整 JVM 的行为。如果 JVM 发现使用初始的堆大小,频繁地发生 GC,它就会尝试增大堆的空间,直到 JVM 的 GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。
对很多应用来说,这意味着堆的大小不再需要调整了。实际上,你只需要为你选择的 GC 算法设定性能目标:譬如你能忍受的停顿持续时间、你期望垃圾回收在整个时间中所占用的百分比等。具体的细节设置取决于你选择的垃圾收集算法,在接下来的章节我们会进行深入的讨论(然而,即使到了那个时候,为了能尽可能地适用于更多的应用程序,减少调整的代价,仍然可能使用默认值)。
通常,如果应用程序运行需要的堆不会使用超过运行平台默认的最大值,这个方法就工作得非常好。然而,如果应用程序在 GC 时消耗了太长的时间,你很有可能需要使用 -Xmx 标志增大堆的大小。选择什么样的大小没有一个硬性的或简单的规则(不过你需要确保设置的大小是机器可以支持的)。一个经验法则是完成 Full GC 后,应该释放出 70% 的空间(30% 的空间仍然占用)。为了衡量这个结果,你可以持续运行应用程序,直到其到达稳定态配置:这时它已经载入了需要缓存的所有对象,或者已经创建了最多的客户端连接数,诸如此类。之后,使用 jconsole 连接应用程序,强制进行 Full GC,观察 Full GC 结束后 还有多少内存被占用(此外,对于 Throughput 垃圾收集器,如果有日志的话,你可以通过查询 GC 日志得到对应的数据)。
注意,即使你显式地设置了堆的最大容量,还是会发生堆的自动调节:初始时堆以默认的大小开始运行,为了达到根据垃圾收集算法设置的性能目标,JVM 会逐步增大堆的大小。将堆的大小设置得比实际需要更大不一定会带来性能损耗:堆并不会无限地增大,JVM 会调节堆的大小直到其满足 GC 的性能目标。
另一方面,如果你确切地了解应用程序需要多大的堆,那么你可以将堆的初始值和最大值直接设置成对应的数值(譬如:-Xms4096m -Xmx4096m)。这种设置能稍微提高 GC 的运行效率,因为它不再需要估算堆是否需要调整大小了。
快速小结
1. JVM 会根据其运行的机器,尝试估算合适的最大、最小堆的大小。
2. 除非应用程序需要比默认值更大的堆,否则在进行调优时,尽量考虑通过调整 GC 算法的性能目标(具体内容在下一章介绍),而非微调堆的大小来改善程序性能。
5.2.2 代空间的调整
一旦堆的大小确定下来,你(或者 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 值的设定。
快速小结
1. 整个堆范围内,不同代的大小划分是由新生代所占用的空间控制的。
2. 新生代的大小会随着整个堆大小的增大而增长,但这也是随着整个堆的空间比率波动变化的(依据新生代的初始值和最大值)。
5.2.3 永久代和元空间的调整
JVM 载入类的时候,它需要记录这些类的元数据。从终端用户的角度来看,这些只是一些“书签”信息。这部分数据被保存在一个单独的堆空间中。在 Java 7 里,这部分空间被称为永久代(Permgen 或 Permanent Generation),在 Java 8 中,它们被称为元空间(Metaspace)。
不过永久代和元空间并不完全一样。Java 7 中,永久代还保存了一些与类数据无关的杂项对象(miscellaneous object);这些对象在 Java 8 中被挪到了普通的堆空间内。除此之外,Java 8 还从根本上改变了保存在这个特殊区域内的元数据的类型——不过由于普通用户不需要了解这个区域内保持了什么样的数据,所以这些改变不会对我们造成什么影响。作为终端用户,我们需要知道的仅仅是永久代级元空间内保存了大量与类相关的数据,有些时候我们可能会需要调整这部分空间的大小。
注意永久代或者元空间内并没有保存类实例的具体信息(即类对象),也没有反射对象(譬如方法对象);这些内容都保存在常规的堆空间内。永久代和元空间内保存的信息只对编译器或者 JVM 的运行时有用,这部分信息被称为“类的元数据”。
到目前为止都没有一个能提前计算出程序的永久代 / 元空间需要多大空间的好算法。永久代或者元空间的大小与程序使用的类的数量成比率相关,应用程序越复杂,使用的对象越多,永久代或者元空间就越大。使用元空间替换掉永久代的优势之一是我们不再需要对其进行调整——因为(不像永久代)元空间默认使用尽可能多的空间。表 5-5 列出了永久代和元空间的初始值及最大值。
表5-5:永久代/元空间的默认大小
| JVM类型 | 默认的初始大小 | 默认永久代大小的最大值 | 默认元空间大小的最大值 |
|---|---|---|---|
| 32 位客户端型 JVM | 12 MB | 64 MB | 没有限制 |
| 32 位服务器型 JVM | 16 MB | 64 MB | 没有限制 |
| 64 位 JVM | 20.75 MB | 82 MB | 没有限制 |
这些内存区域的行为就像是分隔开的普通堆空间。它们会根据初始的大小动态地调整,需要的时候会增大到最大的堆空间。对于永久代而言,可以通过 -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 MB、192 MB 或者更多。
虽然名称叫“永久代”,保存在永久代空间中的数据并不能永久保存(元空间这个名字可能更准确)。尤其是,保存在其中的类像其他的对象一样会经历垃圾回收。在应用服务器中,这是一种非常普遍的现象,每次有新的应用部署,应用服务器都会创建新的类加载器
(classloader)。之后老的类加载器就不再被引用,像它定义的任何一个类一样,等待 GC 的 回收。应用服务器漫长的运行周期中,很容易发现部署中触发的 Full GC:永久代或元空间被新的类所充斥填满,老的类的元数据等待被回收。
堆转储(参见第 7 章)的信息可以用于诊断存在哪些类加载器,而这些信息反过来可以帮助确定是否存在类加载器的泄漏,最终导致永久代(或者元空间)被耗尽。除此之外,使用 jmap 和 -permstat 参数(适用于 Java 7)、或者 -clstats 参数(适用于 Java 8)可以输出类加载器相关的信息。不过这些命令都不是非常稳定,所以不大推荐使用。
快速小结
1. 永久代或元空间保存着类的元数据(并非类本体的数据)。它以分离的堆的形式存在。
2. 典型应用程序在启动后不需要载入新的类,这个区域的初始值可以依据所有类都加载后的情况设置。使用优化的初始值能够加速启动的过程。
3. 开发中的应用服务器(或者任何需要频繁重新载入类的环境)上经常能碰到由于永久代或元空间耗尽触发的 Full GC,这时老的元数据会被丢弃回收。
5.2.4 控制并发
除 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 收集器的后台线程数作设定(虽然它们也会受设置的影响)。关于其中的细节,我们会在下一章中介绍。
快速小结
1. 几乎所有的垃圾收集算法中基本的垃圾回收线程数都依据机器上的 CPU 数目计算得出。
2. 多个 JVM 运行于同一台物理机上时,依据公式计算出的线程数可能过高,必须进行优化(减少)。
5.2.5 自适应调整
根据调优的策略,JVM 会不断地尝试,寻找优化性能的机会,所以在 JVM 的运行过程中,堆、代以及 Survivor 空间的大小都可能会发生变化。
这是一种尽力而为(Best-Effort)的方案,它进行性能调优的依据是以往的性能历史:这其中隐含了一个假设,即将来 GC 周期的状况跟最近历史 GC 周期的状况可能很类似。事实证明,在多种负荷下这一假设都是合理的,即使某个时刻内存的分配发生突变的情况,JVM 也能够依据最新的情况重新调整它的大小。
自适应调整在两个方面能提供重要的帮助。其一,这意味着小型应用程序不需要再为指定了过大的堆而担心。譬如用于调整应用服务器的命令行管理程序,这类型的程序通常使用 16 MB(或者 64 MB)的堆,即使默认的堆可能增长到 1 GB 那么大的容量。有了自适应调整之后,这种类型的应用程序不再需要额外花费精力去调优,平台默认的配置就能确保他们不会使用大量的内存。
其次,这意味着很多应用程序根本不需要担心它们堆的大小,如果需要使用的堆的大小超过了平台的默认值,他们可以放心地分配更大的堆,而不用关心其他的细节。JVM 会自动调整堆和代的大小,依据垃圾回收算法的性能目标,使用优化的内存量。自适应调整就是让自动调整生效的法宝。
不过,空间大小的调整终归要花费一定的时间开销,这部分时间大多数消耗在 GC 停顿的时候。如果你投注了大量的时间精细地调优了垃圾回收的参数、定义了应用程序堆的大小限制,可以考虑关闭自适应调整。如果应用程序的运行明显地可以划分成不同的阶段,你希望对这些阶段中的某一个阶段进行垃圾回收的优化,那么关闭自适应调优也是很有帮助的。
使用 -XX:-UseAdaptiveSizePolicy 标志可以在全局范围内关闭自适应调整功能(默认情况下,这个标志是开启的)。如果堆容量的最大、最小值设置成同样的值,与此同时新生代的初始值和最大值也设置为同样大小,自适应调整的功能会被关闭。不过这时的 Survivor 空间是个例外,我们在下一章中会详细介绍其中的细节。
如果你想了解应用程序运行时 JVM 的空间是如何调整的,可以设置 -XX:+PrintAdaptiveSizePolicy 标志。开启该标志后,一旦发生垃圾回收,GC 的日志中会包含垃圾回收时不同的代进行空间调整的细节信息。
快速小结
1. JVM 在堆的内部如何调整新生代及老年代的百分比是由自适应调整机制控制的。
2. 通常情况下,我们应该开启自适应调整,因为垃圾回收算法依赖于调整后的代的大小来达到它停顿时间的性能目标。
3. 对于已经精细调优过的堆,关闭自适应调整能获得一定的性能提升。
快速小结