JVM调优——GC调优

GC 调优基础

调整堆的大小

        选择堆的大小其实是一种平衡。如果分配的堆过于小,程序的大部分时间可能都消耗在GC 上,没有足够的时间去运行应用程序的逻辑。但是,如果增大堆的空间,GC 停顿消耗的时间取决于堆的大小,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。

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

        堆的大小由2个参数值控制:分别是初始值 (通过-Xms N设置) 和最大值 (通过 -Xmx N设置)。默认值的调节取决于多个因素,包括操作系统类型、系统内存大小、使用的 JVM。 其他的命令行标志也会对该值造成影响;堆大小的调节是 JVM 自适应调优的核心。

       JVM 的目标是依据系统可用的资源情况找到一个 “合理的” 默认初始值,当且仅当应用程序需要更多的内存 (依据垃圾回收时消耗的时间来决定 )时将堆的大小增大到一个合理的最大值。

JVM默认初始和最大堆大小

客户端

  • 初始化堆大小:至少为 8MB ;物理内存大于 512MB 且小于 1GB 时,为物理内存的六十四分之一;大于等于 1GB 时,都为 16MB
  • 最大堆大小:物理内存小于 192MB 时,为物理内存的一半;物理内存大 192MB 且小于1GB 时,为物理内存的四分之一;大于等于1GB 时,都为 256MB 

服务器端

  • 初始化堆大小:客户端 JVM 相同 
  • 最大堆小大: 32 位的 JVM 上,物理内存小于 192MB 时,为物理内存的一半;物理内存大 192MB 且小于4GB 时,为物理内存的四分之一;大于等于4GB时,都为1GB 64位的JVM 上,物理内存小于192MB时,为物理内存的一半;物理内存大 192MB 且小于 128GB 时,为物理内存的四分之一;大于等于 128GB 时,都为 32GB

附:官方文档

       如果 JVM 发现使用初始的堆太小,频繁地发生 GC ,它就会尝试增大堆的空间,直到 JVM 的 GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值,这意味着堆的大小不再需要调整了。

       如果你确切地了解应用程序需要多大的堆,那么你可以将堆的初始值和最大值直接设置成对应的数值  (譬如:-Xms 4096-Xmx4096m )。能稍微提高 GC 的运行效率,因为它不再需要估算堆是否需要调整大小了。

代空间的调整

       一旦堆的大小确定下来,JVM 就需要决定分配多少堆给新生代空间,多少给老年代空间。如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。这种分配方法,老年代就相对比较小,比较容易被填满,会更频繁地触发 Full GC 。

       所有用于调整代空间的命令行标志调整的都是新生代空间;新生代室间剩下的所有空间都被老年代占用。下面标志都能用于新生代空间的调整:

  • - XX: NewRatio =N 设置新生代与老年代的空间占用比率。
  • - XX: NewSize = N 设置新生代空间的初始大小。
  • - XX: MaxNewSize =N 设置新生代空间的最大大小。
  • - XmnN 将 NewSize和 MaxNewSize 设定为同一个值的快捷方法。

       最初新生代空间大小是由 NewRatio 指定大小,NewRatio的默认值为2。 那么我们很容易得出,默认情况下,新生代空间的大小是初始堆大小的33%。

计算空间的公式:
Initial Young Gen Size =  Initial Heap Size / (1+ NewRatio )
复制代码

        新生代的大小也可以通过 NewSize 标志显式地设定。使用 NewSize 标志设定的新生代大小,其优先级要高于通过 NewRatio 计算出来的新生代大小。  NewSize 不设置的情况下,新生代的大小会随着整个堆大小的增大而增长,直到由 MaxNewSize 标志设定的最大容量。

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

永久代和元空间的调整

        JVM 载入类的时候,它需要记录这些类的元数据。这部分数据被保存在一个单独的堆空间中。在Java7里,称为永久代( Permanent Generation ),在Java8中,被称为元空间 ( Metaspace ) 。

        永久代或者元空间内并没有保存类实例的具体信息 (即类对象) ,也没有反射对象 (譬如方法对象) ;这些内容都保存在常规的堆空间内。永久代和元空间内保存的信息只对编译器或者JVM的运行时有用,这部分信息被称为 “类的元数据” 。

      永久代或者元空间的大小与程序使用的类的数量成比率相关,应用程序越复杂,使用的对象越多,永久代或者元空间就越大。使用元空间替换掉永久代的优势之一是我们不再需要对其进行调整——因为 (不像永久代) 元空间默认使用尽可能多的空间。

      永久代的大小可以通过设置 -XX:PermSize =N 和 -XX:MaxPermSize =N 调整大小。

      元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。元空间的大小可以通过:-XX:MetaspaceSize =N 和 -XX:MaxMetaspaceSize =N 调整。

       虽然名称叫 “永久代” ,但是在其中的类像其他的对象一样会经历垃圾回收。在应用服务器中,每次有新的应用部署,应用服务器都会创建新的类加载器 ( classloader )。之后老的类加载器就不再被引用,等待 GC 的回收。

内存溢出错误

     在下列情况下,JVM 会抛出内存溢出错误( OutofMemory Error):   

  • JVM 没有原生内存可用;
  • 永久代(在 Java7 和更早的版本中) 或元空间(在 Java8 中)内存不足;
  • Java 堆本身内存不足,应用中活跃对象太多;
  • JVM执行GC耗时太多。

原生内存不足

        JVM 没有原生内存可用,其原因与堆根本无关。在 32 位的 JVM 中,一个进程的最大内存是 4GB。指定一个非常大的堆大小,比如说 3.8GB ,使应用的大小很接近 4GB 的限制,这很危险。在 64 位的 JVM 中,操作系统的虚拟内存也不是 JVM 请求多少就有多少。 

       如果 OutofMemoryError 消息中提到了原生内存的分配,那对堆进行调优解决不了问题:你需要看一下错误中提到的是何种原生内存问题。例如,下面的消息说明线程栈的原生内存耗尽了: 

Exception in thread "main"java. lang. OutofMemory Error: 
      unable to create new native thread
复制代码

永久代或元空间内存不足

       这种内存错误与堆无关,其发生原因是永久代 (在Java7中) 或元空间原生内存 (在Java8中)满了。根源可能有两种情况:

        第一种情况是应用使用的类太多,超出了永久代的默认容纳范围;解决方案是增加永久代的大小。(在Java8中,如果设置了类的元空间的最大大小,也会出现同样的问题。)

        第二种情况相:它涉及类加载器的内存泄漏。这种情况经常出现于 Java EE 应用服务器中。部署到应用服务器中的每个应用都运行在自己的类加载器中(这提供了隔离,使一个应用中的类不会和另一个应用中的类共享,也不会有干扰)。在开发中,每次修改了应用都必须重新部署,这时就会创建一个新的类加载器来加载新的类,而老的类加载器就可以退出作用域了。一旦类加载器退出了作用域,该类的元数据就可以回收了。

        如果老的类加载器没有退出作用域,那么该类的元数据也就无法释放,最后永久代就会被填满,进而抛出 OutofMemoryError 。在这种情况下,增加永久代的大小会有所帮助,但最终只是推迟了错误抛出的时机而已。 

       在 Java 8 中,如果元空间满了,错误消息将会是下面这样的: 

 Exception in thread "main" java. Lang. OutofMemoryError: Metaspace
复制代码

在 Java 7 中类似,错误消息如下:

 Exception in thread"main"java. Lang. OutofMemoryError: PermGen space
复制代码

堆内存不足

       当确实是堆本身内存不足时,错误消息会是这样的: 

 Exception in thread" main"java. Lang. OutofMemoryError: Java heap space
复制代码

       应用可能只是需要更多堆空间:活跃对象的数目在为其配置的堆空间中已经装不下了。也可能是应用存在内存泄漏:它持续分配新对象,却没有让其他对象退岀作用域。对于第一种情况,增加堆大小可以解决问题;第二种情况,增加堆大小只不过将错误的出现时机推迟了。

       不管是哪种情况,要找出哪些对象消耗的内存最多,堆转储分析都是必要的;可以集中到减少那些对象的数目(或大小)上。如果应用存在内存泄漏,可以间隔几分钟,获得连续的一些堆转储文件,然后加以比较。堆转储标志:

  • -XX:+HeapDumpOnOutOfMemoryError

      该标志默认为 false,打开该标志,JVM 会在抛出 OutofMemoryError 时创建堆转储。

  • -XX:HeapDumpPath=${目录}

      该标志指定了堆转储将被写入的位置;默认会在应用的当前工作目录下生成  java_pid.hprof 文件。这里的路径可以指定目录(这种情况下会使用默认的文件名),也 可以指定要生成的实际文件的名字。

  • XX: +HeapDumpAfterFullGC

       这会在运行一次 Full GC 后生成一个堆转储文件。 

  • XX: +HeapDumpBeforeFullGC

       这会在运行一次 Full GC 之前生成一个堆转储文件。

达到 GC 的开销限制

       JVM抛出 OutofMemoryError 的最后一种情况是 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:+ UseGCOverheadLimit 标志的值为true (默认)。 

       所有四个条件必须都满足。一般来说,应用中连续执行了5次以上的 Full GC ,不一定会抛出 OutofMemoryError 。其原因是,即便应用将98%的时间花费在执行 Full GC 上,但是每次 GC 期间释放的堆空间可能会超过2% 。这种情况下可以考虑增加 GCHeapFreeLimit 的值。

减少内存使用

        在 Java 中,第一种更高效使用内存的方式是减少堆内存的使用。堆内存用的越少,堆被填满的几率就越低,需要的 GC 周期也越少。新生代回收的次数更少,对象的晋升年龄也就不会很频繁地增加,这意味着对象被提升到老年代的可能性也降低了。Full GC 周期也会减少。

减少对象大小

       对象会占用一定数量的堆内存,所以要减少内存使用,最简单的方式就是让对象小一些考虑运行程序的机器的内存限制,增加10%的堆有可能是无法做到的,但是堆中一半对象的大小减少20%,能够实现同样的目标。

      减少对象大小有两种方式:减少实例变量的个数(效果很明显),或者减少实例变量的大小(效果没那么明显)。

对象大小计算

         对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)实例数据(Instance Data)和 对齐填充(Padding)

对象头大小

对于普通对象,对象头字段在 32 位 JVM 上占 8 字节,在 64 位 JVM 上占16字节。开启对象压缩( -XX:+UseCompressedOops 默认开启)对象头大小为12字节。

对于数组,对象头字段在 32 位 JVM 以及堆小于 32GB 的 64 位 JVM 上占 16字节,其他情况下是 64 字节。

实例数据

    在 Java 中不同类型实例变量的大小。

 这里的引用类型指的是指向任何类型 Java 对象 (包括类或数组的实例) 的引用。

对齐填充

       对齐填充,不是必然存在的,起着占位符的作用。HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍。 (对象头 + 实例数据 + padding) % 8 等于0 且0 <= padding < 8

例子:对象大小 = 对象头 + 实例数据 + 对齐填充

也可以通过 ObjectSizeCalculator.getObjectSize(a); 方法获取对象大小

// 对象A:对象头12B + 内部对象s引用 4B + 内部对象i 基础类型int 4B + 对齐 4B = 24B
// s没有被分配堆内存空间
// 对象A 24B
class A{
  String s;
  int i = 0;
}
public static void main(String[] args) {
	A a= new A();
	long objectSize = ObjectSizeCalculator.getObjectSize(a);
}
+++++++
24
复制代码

       仅定义需要的实例变量,这是节省对象空间的一种方式。还有一种效果不那么明显的方案,就是使用更小的数据类型。

延迟初始化

        很多时候,决定一个特定的实例变量是否需要并不是非黑即白的问题。某个特定的类可能只有10% 时间需要一个这个对象,但是这个对象创建成本很高,所以保留这个对象备用,而不是需要的时候再重新创建。

  1. 只有当常用的代码路径不会初始化某个变量时,才去考虑延迟初始化该变量。 
  2. 一般不会在线程安全的代码上引入延迟初始化,否则会加重现有的同步成本。 
  3. 对于使用了线程安全对象的代码,如果要采用延迟初始化,应该使用双重检查锁。

参考

Java性能权威指南
深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)