深入理解 Java 虚拟机(六)~ Garbage Collection 剖析 - Go语言中文社区

深入理解 Java 虚拟机(六)~ Garbage Collection 剖析


Java 虚拟机系列文章目录导读:

深入理解 Java 虚拟机(一)~ class 字节码文件剖析
深入理解 Java 虚拟机(二)~ 类的加载过程剖析
深入理解 Java 虚拟机(三)~ class 字节码的执行过程剖析
深入理解 Java 虚拟机(四)~ 各种容易混淆的常量池
深入理解 Java 虚拟机(五)~ 对象的创建过程
深入理解 Java 虚拟机(六)~ Garbage Collection 剖析

前言

Java 虚拟机中的垃圾回收相关的知识点非常多也非常复杂,但是 理解 Java 虚拟机中的垃圾回收相关的知识对于理解和开发出高质量的程序还是很有裨益的

本文主要内容:

  • 什么样的对象可以被回收?
    • 引用计数算法
    • 可达性分析算法
    • 什么对象可以作为 GC Roots
  • 垃圾回收的基础算法及小结
  • 为什么要分代回收
  • 对象什么时候进入老年代
  • 分代回收的执行过程
  • 什么时候会触发 Major GC
  • PermGen VS Metaspace
  • Compressed Class Space
    • Instrumentation 计算对象占用内存的大小
    • JOL 查看 Java 对象内存布局
  • Code Cache
  • 理解 OutOfMemoryError 异常
  • JVM 垃圾收集器
    • 垃圾收集器相关术语
    • 7 种收集器详解
    • G1 收集器及最佳实践
  • Java 7, Java 8, Java 9 垃圾回收的变化
    • JDK 7 Changes
    • JDK 8 Changes
    • JDK 9 Changes

什么样的对象可以被回收?

在垃圾清理之前首先要做的事情就是确定哪些对象可以被回收。确定对象是否可以被回收主要有两种方案:引用计数算法、可达性分析算法。

引用计数算法

引用计数算法的原理是为对象添加一个引用计数器,每当有一个地方引用该对象,那么该对象的引用计数器加 1 ;当引用失效时,引用计数器就减 1

如果一个对象的引用计数器为 0 ,那么该对象就可以被清理回收了。像 Python 语言就是引用计数算法来进行内存管理的。

但是主流的 Java 虚拟机没有选用引用计算法来管理内存,主要的原因在于它很难解决对象之间循环引用的问题。

可达性分析算法

在主流的商用语言,如 Java、C# 主流的实现中,都是通过可达性分析来判断对象是否存活的。可达性分析算法的基本思想是通过一系列称为 GC Roots 的对象作为起点。从这些节点往下搜索,搜索所做过的路径称之为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连,则证明此对象不可用,可以被回收了。如下图所示:

GC Roots

那么什么对象可以作为 GC Roots 对象呢?

根据 Eclipse 对 GC Root 的描述,垃圾收集根是一个可以从堆外部访问的对象。以下原因使得对象成为GC根:

  • System Class

    被 Bootstrap ClassLoader 加载的类(rt.jar)

  • JNI Local

    Native 代码中的局部变量

  • JNI Global

    Native 代码中的全局变量(Global variable)

  • Thread Block

    从当前活动的线程块引用的对象。

  • Thread

    开始,但没有停止的线程。

  • Busy Monitor

    调用了 wait(), notify()方法的对象,或者 synchronize 的锁对象

  • Java Local

    局部变量. 例如,方法入参或方法中创建的本地变量仍然在线程的栈中

  • Native Stack

  • Finalizable

    在 finalizer queue 中的等待被 finalize 的对象

  • Unfinalized

    一个拥有 finalize 方法的对象,但是还没有被 finalized 并且不在 finalizer queue 中

  • Unreachable

    从任何其他根中都无法访问的对象,但是 MAT 将其标记为根,以保留不包含在分析中的对象。

  • Java Stack Frame

垃圾回收的基础算法

标记-清除算法

标记清除(Mark Sweep) 算法分为 标记清除 两个阶段。

标记 - 清除算法是最基础的收集算法,后续的收集算法都是基于这种思路进行改造的。

原理:标记阶段会标记出需要回收的对象,标记完成后统一回收所有被标记的对象。

不足

  • 1、效率不高。标记和清除的两个过程的效率都不高;
  • 2、空间问题。标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致导致程序运行过程中分配大对象时,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。

标记清除算法执行过程如下图所示:

gc-mark-sweep

复制算法

由于标记清除算法的效率不高和内存碎片化问题,复制(Copying)算法就出现了。

原理:将可用内存平均分为 2 块,每次只使用其中的一块。当这块内存使用完了,就将还存活的对象复制到另一块内存里,然后统一回收刚刚用完的那块内存。

例如将可用内存划分为 A、B 两块,当 A 使用完毕后,会将 A 中存活对象复制到 B 块内存中,然后把 A 内存统一回收掉,如下图所示:

gc-copying

优点:效率比标记清除算法好,也不会出现内存碎片的情况

缺点:

  • 内存利用率不高。将内存平均分成 2 块,可用的内存就变成了原来的一半
  • 如果对象的存活率比较高的话,复制的操作就会比较频繁

标记整理算法

由于复制算法对于存活率高的内存进行垃圾收集需要频繁的复制操作,而标记-清除算法又会造成内存碎片化。所以有人提出了 标记-整理(Mark Compact)算法。

标记-整理将存活对象都向一端移动,然后清理掉存活对象边界以外的内存。如下图所示:将存活对象都向一端移动,然后清理掉存活对象边界以外的内存。如下图所示:

GC-Mark-Compact

分代收集算法

当前商业虚拟机垃圾收集器都采用 “分代收集(Generational Collection)” 算法。

这种算法的主要思想是:根据对象的存活周期的不同将内存划为几块,一般是把 Java 堆分为 新生代(Young Generation)和老年代(Old Generation)

在新生代中,每次垃圾收集都会发现大量对象死去,只有少量对象存活,那就可以使用复制算法。只需要付出少量的复制成本就可以完成收集。

在老年代中,对象的存活率高,由于复制收集算法在对象存活较高时需要更多的复制操作,效率将会变低,所以在老年代不适合使用复制算法,一般使用 标记-清理标记-整理 算法。

发生在新生代的 GC 称之为 Minor GC

发生在老年代的 GC 称之为 Major GC 或 Full GC,在执行 Major GC 之前也有可能会先执行 Minor GC

新生代由 1 个 Eden 区和 2 个 Survivor 区组成,如下图所示:

young generation area

在 Hotspot 虚拟机中 1 个 Eden 区和 2 个 Survivor 区它们之间的比例关系为 8:1:1

每次使用 Eden 和其中一块 Survivor 空间,最后清理 Eden 和刚刚使用过的 Survivor 空间。

垃圾回收基础算法小结

垃圾回收的基础算法是后面算法改进的基础,下面对这几种算法的优缺点做一个小结:

算法 优点 缺点
复制 吞吐量达(一次能回收整个空间),分配效率高(对象可连续分配),没有内存碎片 堆的使用效率低(需要额外的一个空间 To Space),需要移动对象
标记清除 无须移动对象,算法简单 内存碎片化,分配慢(需要找到一个合适的空间)
标记整理 堆的使用效率高,无内存碎片 暂停时间更长,对缓存不友好(对象移动后,顺序关系不存在)
分代 组合算法,分配效率高,堆的使用效率高 算法复杂

为什么要分代回收

早期没有进行分代的时候,虚拟机需要为所有的对象进行标记(marking)和压缩(compact)。随着越来越多的对象的创建,导致垃圾回收的时间越来越长。但是经过数据分析表明,绝大部分的对象生命周期是非常短的。

例如下面一张图,纵坐标表示内存分配的字节数,横坐标表示随着时间的推移内存分配的字节数:

在这里插入图片描述

所以如果每次垃圾回收都对整个堆进行标记和压缩,那么垃圾回收的效率就会变得很低。

对象分配在 Eden 区,随着数次 Minor GC 的执行,将仍然存活的对象移到老年代。

由于绝大部分的对象的生命周期都是非常短的,所以年轻代的 Minor GC 的执行是最频繁的。

分代回收策略使得大部分回收操作都在堆内存区域的年轻代中进行,而不是整个堆内存,从而使得垃圾回收的效率得到提高。

对象什么时候进入老年代

我们上面说到对象分配在 Eden 区,随着数次 Minor GC 的执行,将仍然存活的对象移到老年代,如下图所示:

在这里插入图片描述

那么有什么具体的标准表示对象会进入老年代呢?主要有 3 中情况:

  • 大对象(如大数组)直接进入老年代
  • 对象的年龄达到了 MaxTenuringThreshold 设置的阈值,默认为 15
  • 如果 Survivor 空间中相同年龄的对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,不用等到对象年龄达到 MaxTenuringThreshold 设置的阈值

分代回收的执行过程

上面我们介绍了为什么要分代回收,对象什么时候进入老年代等。还有一些细节问题没有说到,比如什么时候执行 Minor GC,什么时候对象的年龄加1等等。

下面我们以一组图文的方式来演示下对象从年轻代到老年代的完整过程(方块中的数字表示对象的年龄):

  1. 绝大多数的对象都分配在 Eden 区,如下面一个对象将在 Eden 去分配内存:

    流程1

  2. 当 Eden 区被没有可用空间时,将会触发 Minor GC:

    流程2

  3. 将 Eden 区中仍然被引用的对象拷贝到第一个 Survivor 空间(S0),清空 Eden 区域时释放无用对象:

    流程3

  4. 在下一次 Minor GC 时,会执行上面相同的操作:不被引用的对象将会被删除,存活的对象将会被拷贝到 Survivor 空间,只不过这次是不是拷贝到第一个 Survivor 空间(S0),而是拷贝到第二个 Survivor 空间(S1),此时它们的对象年龄也会加 1。最后 Eden 和 第一个 Survivor 空间都会被清空:

    流程4

  5. 如果又迎来了一次 Minor GC,也会执行相同的操作,此时对象是拷贝到第一个 Survivor 空间(可见每一次 Minor GC 都会切换到另一个 Survivor 空间):

    流程5

  6. 再一次 Minor GC 后,对象的年龄达到阈值后会将对象提升到老年代中(本例子的阈值为8):

    流程6

  7. 随着 Minor GC 不断的执行,不断的会有对象进入老年代:

    流程7

  8. 以上基本上完整的覆盖了年轻代的处理过程。最终会在清理压缩老年代的时候进行 Major GC:

    流程8

什么时候会触发 Major GC

通过上面的分析我们知道,当 Eden 区被填满的时候会触发 Minor GC。那么什么时候会触发 Major GC 来回收老年代呢?

主要有以下几个触发条件:

  1. System.gc()方法的调用

    此方法的调用是建议JVM进行Major GC,虽然只是建议而非一定,但很多情况下它会触发 Major GC,从而增加 Major GC 的频率,也即增加了间歇性停顿的次数。强烈建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过 -XX:+ DisableExplicitGC 来禁止调用 System.gc。

  2. 老年代空间不足

    老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行 Major GC 后空间仍然不足,则抛出如下错误:java.lang.OutOfMemoryError: Java heap space 为避免以上两种状况引起的 MajorGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

  3. 方法区空间不足

    JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中又被习惯称为永生代或者永生区,Permanet Generation 中存放的为一些 class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Major GC。如果经过 Major GC 仍然回收不了,那么 JVM 会抛出如下错误信息:
    java.lang.OutOfMemoryError: PermGen space
    为避免 Perm Gen 占满造成 Major GC 现象,可采用的方法为增大 Perm Gen 空间或转为使用 CMS GC。

  4. 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存

    如果发现统计数据之前 Minor GC 的平均晋升大小比目前老年代剩余的空间大,则不会触发 Minor GC 而是转为触发 Major GC

  5. 由 Eden 区、From Space区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

PermGen VS Metaspace

在 Hotspot 虚拟机中除了年轻代、老年代,还有永久代(PermGen)。

根据 Oracle JVM 官网答疑 对永久代的介绍,永久代主要用于存放:

  • 虚拟机中对 Class 的描述信息,以及 Class 的元数据
  • Class 的静态数据
  • Interned String

我们在《深入理解 Java 虚拟机(三)~ class 字节码的执行过程剖析》中提到 方法区 主要用来存放已被虚拟机加载的 class 的结构信息,如运行时常量池、字段和方法数据、方法的代码。

由此可见,方法区的数据时放在永久代(PermGen)中的。

不同的 JDK 版本对永久代的调整可能对其有调整。根据 Oracle 官方对 JDK1.7 更新描述 可以的得知:
JDK1.7 中不会将 interned strings 放在堆中的永久代中,而是放在主堆中,也就是年轻代和永久代。也就是说会有更多的数据将会在主堆中分配,那么永久代的数据就变少了。绝大部分的程序不会受到此次修改影响较小,除非是哪些需要加载非常多的类或大量使用 String.intern() 方法的程序。以下是官方的原文:

In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application. This change will result in more data residing in the main Java heap, and less data in the permanent generation, and thus may require heap sizes to be adjusted. Most applications will see only relatively small differences in heap usage due to this change, but larger applications that load many classes or make heavy use of the String.intern() method will see more significant differences.

JDK1.8 中彻底的移除了永久代(PermGen),Class 的元数据信息存放在一个叫 Metaspace 的空间中,内存示意图如下所示:

metaspace

Metaspace 和 PermGen 的主要区别:

  • 永久代是和 Java Heap 相邻的(Contiguous with the Java Heap),而 Metaspace 和 Java Heap 不相邻(Not contiguous with the Java Heap)
  • Metaspace 是在本机内存中分配的
  • Metaspace 的最大可用空间取决于本机系统的可用空间
  • 可用通过 -XX:MaxMetaspaceSize 选项来控制 Metaspace 最大空间

Compressed Class Space

如果启用了类指针压缩 UseCompressedClassesPointers 选项,将会有两个独立的内存区域分别用来存储 class 和它的元数据。这两个独立的区域分别叫做:MetaspaceCompressed class spaceCompressed class space 逻辑上属于 Metaspace。如下图所示:

Compressed Class Space

下面我们来看下 UseCompressedClassesPointers 选项对于对象内存占用的影响。比如一个空 Object 对象(new Object()),会占用多大内存?

我们在 深入理解 Java 虚拟机(五)~ 对象的创建过程 中介绍到一个对象的内存布局为:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

下面我们通过编程的方式来精确计算对象的内存占用:

  • 通过 Instrumentation 类来计算对象大小

    Instrumentation 有一个 getObjectSize 方法可以用来统计对象的大小,Instrumentation 是一个接口,它的实现类对象不需要我们创建,运行 main 之前会自动调用 premain 方法,会将 Instrumentation 对象传递进来。

    package gc.objsize;
    import java.lang.instrument.Instrumentation;
    
    public class ObjectSize {
        private static volatile Instrumentation instrumentation;
    
        public static void premain(String args, Instrumentation inst) {
            instrumentation = inst;
        }
    
        public static long getObjectSize(Object obj) {
            if (instrumentation == null)
                throw new IllegalStateException("Instrumentation not initialed");
            return instrumentation.getObjectSize(obj);
        }
    }
    
  • 将 java 类打包成 jar 文件

    1)在 src 目录下新建一个 MANIFEST.MF 文件:

    Manifest-Version: 1.0
    Premain-Class: gc.objsize.ObjectSize
    Can-Redefine-Classes: true
    

    Premain-Class 指定的就是需要注入的 Instrumentation 对象的类

    2)将相关的 java 文件编译成 class,并打包成 jar 文件:

    // 编译 java 文件
    src>javac -encoding UTF-8 gc/objsize/*.java
    
    // 将 class 文件打包成名为 agent 的 jar 文件
    src>jar -cmf MANIFEST.MF agent.jar gc/objsize/*.class
    
  • 通过 javaagent 注入 Instrumentation 对象

    然后就可以运行我们生成的 ObjectSize 类了:

    src>java -javaagent:agent.jar -cp . gc.objsize.ObjectSize
    

我们测试下面对象的内存占用情况:

public static void main(String[] args) {
    System.out.println("empty object = " + getObjectSize(new Object()));
    System.out.println("myObject1 = " + getObjectSize(new MyObject1()));
    System.out.println("byte[0] = " + getObjectSize(new byte[0]));
    System.out.println("byte[7] = " + getObjectSize(new byte[7]));
    System.out.println("byte[9] = " + getObjectSize(new byte[9]));
    System.out.println("byte[1024 * 1024] = " + getObjectSize(new byte[1024 * 1024]));
}

public class MyObject1 {
    private Object obj;
}

运行 java -javaagent:agent.jar gc.objsize.ObjectSize 命令的结果如下:

empty object = 16
empty myObject1 = 16
byte[0] = 16
byte[7] = 24
byte[9] = 32
byte[1024 * 1024] = 1048592

由于类指针压缩 UseCompressedClassesPointers 选项是默认开启的,我们将该选择关闭,看下输出:

// 关闭 UseCompressedClassesPointers
// java -XX:-UseCompressedClassPointers -javaagent:agent.jar gc.objsize.ObjectSize

empty object = 16
empty myObject1 = 24
byte[0] = 24
byte[7] = 32
byte[9] = 40
byte[1024 * 1024] = 1048600

可见类指针压缩功能,一定程度上减少了内存的占用。

由此可见一个 Object 对象,在 64bit 的 HotSpot VM 中占用 16 bytes

上面的 MyObject 中有一个 Object 类型的字段,在 64bit 的 HotSpot VM 中一个 MyObject 在开启类指针压缩的情况下占用 16 个字节,不开启类压缩指针占用 24 个字节。

通过 Instrumentation 可以获取到对象的占用大小。通过开启、关闭 类压缩指针选项,可以对比出对象内存的不同。但是不能详细的展示一个对象的哪些部分占用多少内存,在不开启类指针压缩的情况下对象的哪部分内存占用多了。 这可以使用 JOL (Java Object Layout)来查看对象的内存布局。

System.out.println(VM.current().details());
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
System.out.println(ClassLayout.parseInstance(new MyObject1()));
System.out.println(ClassLayout.parseInstance(new byte[0]).toPrintable());
System.out.println(ClassLayout.parseInstance(new byte[7]).toPrintable());
System.out.println(ClassLayout.parseInstance(new byte[9]).toPrintable());
System.out.println(ClassLayout.parseInstance(new byte[1024 * 1024]).toPrintable());

在 64bit 的 HotSpot VM 中 开启类压缩指针 的情况下,各个对象的内存布局:

# Running 64-bit HotSpot VM.
# Using compressed oop with 0-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION              VALUE
      0     4        (object header)          01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)          00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)          e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

gc.objsize.MyObject1 object internals:
 OFFSET  SIZE               TYPE DESCRIPTION            VALUE
      0     4                    (object header)        01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)        d4 13 01 20 (11010100 00010011 00000001 00100000) (536941524)
     12     4   java.lang.Object MyObject1.obj          null
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION            VALUE
      0     4        (object header)        01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)        f5 00 00 20 (11110101 00000000 00000000 00100000) (536871157)
     12     4        (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     0   byte [B.<elements>          N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION            VALUE
      0     4        (object header)        01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)        f5 00 00 20 (11110101 00000000 00000000 00100000) (536871157)
     12     4        (object header)        07 00 00 00 (00000111 00000000 00000000 00000000) (7)
     16     7   byte [B.<elements>          N/A
     23     1        (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 1 bytes external = 1 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION             VALUE
      0     4        (object header)         01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)         00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)         f5 00 00 20 (11110101 00000000 00000000 00100000) (536871157)
     12     4        (object header)         09 00 00 00 (00001001 00000000 00000000 00000000) (9)
     16     9   byte [B.<elements>           N/A
     25     7        (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION             VALUE
      0     4        (object header)         01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)         00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)         f5 00 00 20 (11110101 00000000 00000000 00100000) (536871157)
     12     4        (object header)         00 00 10 00 (00000000 00000000 00010000 00000000) (1048576)
     16 1048576   byte [B.<elements>         N/A
Instance size: 1048592 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

在 64bit 的 HotSpot VM 中 不开启类压缩指针 的情况下,各个对象的内存布局:

# Running 64-bit HotSpot VM.
# Using compressed oop with 0-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION             VALUE
      0     4        (object header)         01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)         00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)         00 1c c9 16 (00000000 00011100 11001001 00010110) (382278656)
     12     4        (object header)         00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

gc.objsize.MyObject1 object internals:
 OFFSET  SIZE               TYPE DESCRIPTION            VALUE
      0     4                    (object header)        01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)        c0 b9 36 17 (11000000 10111001 00110110 00010111) (389462464)
     12     4                    (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     4   java.lang.Object MyObject1.obj          null
     20     4                    (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                 VALUE
      0     4        (object header)             01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)             a8 07 c9 16 (10101000 00000111 11001001 00010110) (382273448)
     12     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     20     4        (alignment/padding gap)                  
     24     0   byte [B.<elements>               N/A
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                 VALUE
      0     4        (object header)             01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)             a8 07 c9 16 (10101000 00000111 11001001 00010110) (382273448)
     12     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     4        (object header)             07 00 00 00 (00000111 00000000 00000000 00000000) (7)
     20     4        (alignment/padding gap)                  
     24     7   byte [B.<elements>               N/A
     31     1        (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 1 bytes external = 5 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                 VALUE
      0     4        (object header)             01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)             a8 07 c9 16 (10101000 00000111 11001001 00010110) (382273448)
     12     4        (object header)             00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     4        (object header)             09 00 00 00 (00001001 00000000 00000000 00000000) (9)
     20     4        (alignment/padding gap)                  
     24     9   byte [B.<elements>               N/A
     33     7        (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 4 bytes internal + 7 bytes external = 11 bytes total

[B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                  VALUE
      0     4        (object header)              01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)              00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)              a8 07 c9 16 (10101000 00000111 11001001 00010110) (382273448)
     12     4        (object header)              00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     4        (object header)              00 00 10 00 (00000000 00000000 00010000 00000000) (1048576)
     20     4        (alignment/padding gap)                  
     24 1048576   byte [B.<elements>              N/A
Instance size: 1048600 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

由此可见,在 64bit 的 Hotspot VM 中虽然一个 Object 对象在开启和不开启类压缩指针的情况下都是占用 16 个字节,但是他们的内存布局还是不一样的。

在开启类指针压缩的情况下,一个 Object 对象,它的对象头(Object header)占用 12 个字节,内存对齐填充 4 个字节,总共 16 字节。

不开启类指针压缩的情况下,一个 Object 对象,它的对象头(Object header)占用 16 个字节,刚好是 8 的倍数,所以不需要内存对齐填充,总共 16 字节。

另外数组对象在对象头中还会存放数组的大小,如 byte[7] 的对象头中最后的 4 字节就是用来存储数组的长度的:

 OFFSET  SIZE   TYPE DESCRIPTION            VALUE
      0     4        (object header)        01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)        00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)        f5 00 00 20 (11110101 00000000 00000000 00100000) (536871157)
     12     4        (object header)        07 00 00 00 (00000111 00000000 00000000 00000000) (7)

需要注意的是,开启 UseCompressedClassPointers 的同时需要开启 UseCompressedOops 选项,否则虚拟机会提示:

Java HotSpot(TM) 64-Bit Server VM warning: UseCompressedClassPointers requires UseCompressedOops

UseCompressedOops 中的 Oop 全称是 ordinary object pointer (普通对象指针),该选项从 JDK6_u23 版本被默认开启。

例如对象的属性指针,数组元素指针都是普通对象指针(Oop)

上面的 MyObject1 类中就有一个 obj 成员属性,这就是一个普通对象指针

public class MyObject1 {
    private Object obj;
}

在 64 bit 的 Hotspot VM 中,如果开启 UseCompressedOops,关闭 UseCompressedClassPointers,obj 指针占用 4 字节:

// 运行时虚拟机参数
-XX:-UseCompressedClassPointers -XX:+UseCompressedOops

gc.objsize.MyObject1 object internals:
 OFFSET  SIZE               TYPE DESCRIPTION           VALUE
      0     4                    (object header)       01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)       00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)       88 13 82 17 (10001000 00010011 10000010 00010111) (394400648)
     12     4                    (object header)       00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     4   java.lang.Object MyObject1.obj         null
     20     4                    (loss due to the next object alignment)
Instance size: 24 bytes

在 64 bit 的 Hotspot VM 中,如果关闭 UseCompressedOops、UseCompressedClassPointers,obj 指针占用 8 字节:

// 运行时虚拟机参数
-XX:-UseCompressedClassPointers -XX:-UseCompressedOops

gc.objsize.MyObject1 object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           88 d3 44 17 (10001000 11010011 01000100 00010111) (390386568)
     12     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     8   java.lang.Object MyObject1.obj                             null
Instance size: 24 bytes

UseCompressedClassPointers 和 UseCompressedOops 都是默认开启的。

Code Cache

Code CacheCompressed Class Space 一样都属于非堆(NON_HEAP)区域。 Code Cache 也是在本地内存(Native Memory)中分配的。

Code Cache 用于存储 JIT(Just in Time Compiler) 编译器生成的代码。在 Java 中一提到编译器我们首先想到的可能是 javac 编译器,它将 Java 文件编译成 class 文件,以便 JVM 来执行。

但是 class 文件不能被本地机器直接执行,JVM 需要通过解释器(Interpreter)将 class 文件翻译层机器能看懂的语言。这种解释执行的方式性能是比较低的,特别是当某些代码被执行的频率比较大的,这种方式就显得更加低效了。

所以后来 JVM 就加入了 JIT 编译器,当某块方法或代码被执行的次数达到某个阈值时,该段代码(也称之为 Hot Spot Code 热点代码)将会被 JIT 编译器编译成本地机器码,然后放入 Code Cache 中存储,下一次执行时,直接执行机器码即可。

Code Cache 区域是通过 Code Cache Sweeper 来进行管理的。

关于 Code Cache 需要介绍的的东西还有很多,比如 Code Cache 相关的参数设置,以及实际开发中 Code Cache 的监控与管理,这里分享两篇文章:

到此为止,本文已经介绍了许多关于 JVM 的内存区域,有的是堆区(HEAP),有的是非堆区(NON_HEAP)。在这里做一个小结:

Memory Pool Name Type
Eden Space
Survivor Space
Old Gen
Metaspace 非堆
Compressed Class Space 非堆
Code Cache 非堆

还可以通过 JVM 工具 jmc 来查看 每个内存区域的使用情况:

JMC

上面的 jmc 展示的内存区域名称有的在前面加上了 PS,例如 PS Survivor Space。这里的 PS 指的是收集器的简称,PS 的全称是 Parallel Scavenge。关于收集器后面统一介绍。

理解 OutOfMemoryError 异常

虽然在 JDK 8 中将 PermGen 移除了,新增了 Metaspace。而 Metaspace 是在本机内存中分配的,如果超出了本机内存或者超过了 MaxMetaSpaceSize 设置的值也会抛出 OutOfMemoryError。如果一个对象在堆内存中分配,如果堆中没有足够的空间也会抛出 OutOfMemoryError 异常。所以在开发中可能会遇到各种个一样的 OutOfMemoryError,在这里我们统一介绍下各式各样的 OutOfMemoryError。

JVM 垃圾收集器

上面我们介绍完了常用的收集算法和 JVM 中关于垃圾回收的内存区域,我们就可以来介绍 JVM 中内置的一些垃圾收集器(Garbage Collector)了,垃圾回收的工作正是垃圾收集器来完成的。

在介绍垃圾收集器之前,我们需要明白一些与之相关的术语:

  • Stop the world

    当垃圾回收期在执行回收的时候,应用程序的所有线程被暂停

  • Parallel

    Parallel(并行)指两个或多个事件在同一时刻发生,在现代计算机中通常指多台处理器上同时处理多个任务

    上面是对并行的传统定义,是从处理器角度出发的,但是在 JVM 垃圾回收器的并行不是从处理器角度出发的,这里的并行是指多个垃圾回收线程在操作系统上并行运行,这里强调的是垃圾回收线程。Java 应用程序暂停执行(Stop the world)。

  • Concurrent

    Concurrent(并发)指两个或多个事件在同一时间间隔内发生,在现代计算机中一台处理器 “同时” 处理多个任务,那么这个任务只能交替运行,从处理器的角度上看只能串行执行,从用户的角度看这些任务是 “并行” 执行。

    上面是对并发的传统定义,是从处理器角度出发的,但是在 JVM 垃圾回收器的并发并不是从处理器角度出发,指的是垃圾回收的线程并发运行,同时这些线程和 Java 应用程序并发运行。

  • Incremental

    垃圾回收器对堆的某部分(增量)进行回收而不是扫描整个堆。

  • Throughput

    吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%

接下来,我们将会介绍 7 种常见的垃圾收集器。由于每个垃圾收集器的特点不同,它们回收的堆内存区域也不同,有的收集器是是针对年轻代的,有的是针对老年代的。下面通过一张图来概要性的描述垃圾收集器是作用在哪个代(Generation)的 ,哪些收集器是可以进行组合工作的:

JVM-Collectors

实线连接的表示可以进行组合收集,间隔虚线连接的表示在 Java 9 中不能组合。左下角虚线表示当 CMS 发生 CMS Concurrent mode failure 时可以使用 Serial Old 作为 CMS 的备用方案。

Serial 收集器

Serial(串行)收集器使用单线程进行垃圾回收,在回收的时候需要暂停其他的工作线程,新生代通常采用复制算法,老年代通常采用标记(标记清理、 标记整理)算法。Serial 收集器的线程交互图如下图所示:

Serial 收集器

可以通过 -XX:+UseSerialGC 来告诉虚拟机使用 Serial 收集器

如果应用程序的数据集比较小(小于100M),可以使用 Serial 收集器

如果应用程序将在单个处理器上运行,并且不需要暂停时间,那么让虚拟机选择收集器,或者指定使用 Serial 收集器

ParNew 收集器

ParNew 收集器就是 Serial 收集器的多线程版本。除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与 Serial 收集器完全相同,两者共用了相当多的代码。ParNew 收集器的线程交互图如下图所示:

ParNew 收集器

可以通过 -XX:+UseParNewGC 来告诉虚拟机使用 ParNew 收集器。它默认开启的收集线程数与 CPU 的数量相同,也可以通过 -XX:ParallerGCThreads 来设置 GC 线程数量

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个并行的多线程新生代收集器。Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的 吞吐量(Throughput)

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。

而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

可以通过 -XX:+UseParallelGC 来启用 Parallel Scavenge 收集器

可以通过 -XX:MaxGCPauseMillis 来设置吞吐量,也可以直接设置吞吐量 -XX:GCTimeRatio,值为 0 ~ 100

还可以打开 -XX:+UseAdaptiveSizePolicy 开关,这样就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。

Serial Old 收集器

Serial Old 收集器是 Serial 的老年代版本,它同样是单线程的收集器,使用了 “标记清理” 算法。

主要用于:

  • 给 Client 模式下的虚拟机使用
  • 如果是 Server 模式,在 JDK 1.5 之前的版本与 Parallel Scavenge 搭配使用
  • 如果是 Server 模式,在 CMS 收集器发生 Concurrent Mode Failure 时,作为 CMS 的备用方案

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上就可以看出它是基于 “标记-清除” 算法实现的。

CMS 的运作过程相对前面几种收集器要复杂一些,整体步骤分为 4 个步骤:

  • 初始标记(Initial Mark)

    仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要 “Stop The World”。

  • 并发标记

    进行 GC Roots Tracing 的过程,在整个过程中耗时最长。

  • 重新标记

    为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要 “Stop The World”。

  • 并发清除

    在标记阶段收集标识为不可到达的对象。死对象的集合将该对象的空间添加到空闲列表中,供以后分配。此时可能会发生死对象的合并。注意,活动对象不会被移动。

CMS 收集器的运行示意图:

CMS 收集器的运行示意图

通过 -XX:+UseConcMarkSweepGC 参数,启用 CMS 收集器

CMS 是一款优秀的收集器:并发收集、低延迟。Sun 公司的官方文档上也称之为并发低停顿收集器(Concurrent Low Pause Collector),虽然 CMS 很优秀,但是也有 3 个明显的缺点:

更多关于 CMS 收集器的信息,可以参考 官网 对 CMS 的描述。

Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用 “标记-整理” 算法。

该收集器于 JDK 1.6 版本开始提供,在此之前新生代的 Parallel Scavenge 只能和 Serial Old 进行搭配使用,但是 Serial Old 收集器在服务器端应用性能上表现不好。直到 Parallel Old 收集器出现或,“吞吐量有限” 收集器才有比较名副其实的应用组合,在注重吞吐量和 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 和 Parallel Old 收集器。

Parallel Scavenge 和 Parallel Old 收集器的运行示意图:

Parallel Scavenge/Parallel Old

G1 收集器

G1(Garbage-First)是一款面向服务端(Server-Style)应用的垃圾收集器,用于多核处理器和大内存的机器上。实现高吞吐量的情况下,尽可能的降低暂停时间(pause time)。G1 收集器在 JDK7 update 4 版本上得到完全的支持,主要是为以下应用而设计的一款收集器:

  • 像 CMS 收集器一样,能够与应用线程一起并发操作
  • 不会因为整理(Compact)堆内存而导致 Pause time
  • 需要更多可预测的 GC 暂停时间
  • 不想牺牲太多的吞吐量性能
  • 不需要更大的 Java 堆

与上面介绍的 GC 收集器相比,G1 具备如下特点:

  • 并行与并发。G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短 “Stop The World” 停顿时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

  • 分代收集。与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。

  • 空间整合。G1从整体来看是基于 “标记-整理” 算法实现的收集器,从局部(两个Region之间)上来看是基于 “复制” 算法实现的。这意味着 G1 运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。

  • 可预测的停顿。这是 G1 相对 CMS 的一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在GC上的时间不得超过 N 毫秒。

Heap Region

虽然在 G1 收集器中保留了分代的概念,但是它不要求堆内存是连续的,G1 将堆拆分成一系列的分区(Heap Region),这样在一段时间内,大部分的垃圾回收操作只只针对一部分区域,而不是整个堆。

G1 的分区也称堆分区,是 G1 堆和操作系统交互的最小管理单位。G1 的分区类型(HeapRegion Type)大致可以分为四类:

  • 自由分区(Free Heap Region,FHR)
  • 新生代分区(Young Heap Region,YHR)
  • 大对象分区(Humongous Heap Region,HHR)
  • 老生代分区(Old Heap Region,OHR)

G1 堆布局如下图所示:

G1 堆布局

Heap Region 的大小随虚拟机启动被确定,可以通过参数 -XX:G1HeapRegionSize 来指定,它的范围是: 1M ~ 32M。如果不指定 Heap Region Size 虚拟机会根据 Heap 大小启发推断出它的大小

G1 的最佳实践

不要设置年轻代大小

显式地通过 -Xmn 设置年轻代的大小会干预了 G1 收集器的默认行为。

  • G1 将不再考虑垃圾收集的暂停时间的目标。因此,从本质上讲,设置年轻一代的规模会阻碍暂停时间目标的实现。
  • G1 不再能够根据需要扩展和收缩年轻一代的空间。因为大小是固定的,所以不能改变大小。

响应时间指标

不要使用平均响应时间(ART)作为设置 XX:MaxGCPauseMillis=<N> 的度量,G1 可能只会满足你目标值 90% 或者花费更多的时间。这意味着 90% 发出请求的用户的响应时间不会高于目标值。暂停时间是一个目标,G1 并不能保证总是能达到。

避免转移失败(Evacuation Failure)

当 JVM 收集 Survivor 和 对象晋级(Promote)时虚拟机用尽了 Heap Region 内存,将会发生 promotion failure。因为堆内存已经达到了最大值,不能扩展。-XX:+PrintGCDetails 输出的日志 to-space 将会体现这个错误。出现这个错误会拖累程序的性能:

  • GC 仍然需要继续,所以必须释放空间。
  • 未成功复制的对象必须保留在适当的位置。
  • CSet 中区域的 RSets 的任何更新都必须重新生成
  • 所有的这些步骤代价都是昂贵,所以尽量避免 Evacuation Failure

G1 收集器是非常复杂的,更多关于 G1 收集器相关的知识,大家可以查阅相关的资料和书籍,这里列举一些官方的文档:

Java 7, Java 8, Java 9 垃圾回收的变化

JDK 7 Changes

JDK 7 开始移除了部分 PermGen:

  • 符号引用(Symbols)从 PermGen 移到了 Native Memory
  • Interned String 从 PermGen 移到了 Heap Memory
  • Class 静态数据从 PermGen 移到了 Java Heap
  • 可能会略微增加年轻代垃圾收集的暂停时间
  • 你可以
版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/johnny901114/article/details/103229687
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢