走近科学之《JVM 的秘密》

JVM

JVM、内存模型、类加载机制、对象的创建、垃圾回收机制、对象内存分配策略、JVM调优等。


1 简介

  JVM 即 java 虚拟机(Java Virtual Machine),JVM是一种用于计算设备的规范,是一个虚构出来的计算机。是通过在实际计算机上仿真模拟各种计算机功能来实现的。

  JVM 本质上是一个程序,当它在命令行启动的时候就开始执行保存在字节码文件中的指令。java 语言的平台无关性就是因为 JVM 屏蔽了具体平台相关的信息。

2 构成及作用

  JVM 由两个子系统和两个组件构成。两个子系统即 类加载子系统、执行引擎;两个组件即 运行时数据区、本地接口。

  • 类加载: 即 Class Loader,根据给定的全限定类名(如 org.xgllhz.Test)来加载 class 文件到 Runtime Data Area 中的 Method Area。
  • 执行引擎: 即 Excution Engine,执行 class 文件中的指令、垃圾回收。
  • 运行时数据区: 即 Runtime Data Area,JVM 的内存模型。
  • 本地接口: 即 Native Interface,与 native libraries 交互,调用其它编程语言的接口。

走近科学之《JVM 的秘密》_第1张图片

作用:
  即 JVM 的运行机制。首先通过编译器将 java 文件转化为字节码文件,然后类加载器(Class Loader)将字节码加载到 JVM 内存中,并将其放到运行时数据区(Runtime Data Area)的方法区(Method Area)内,因为字节码文件只是 JVM 的一套指令集规范,并不能直接交由底层操作系统去执行,所以需要特定的命令解析器执行引擎(Excution Engine),将其翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其它编程语言实现的本地接口(Native Interface)来完成程序的功能。

3 JVM 内存模型

  JVM 在执行 java 程序时会为它分配内存,并且把它所管理的内存区域划分为若干个不同的数据区域。这个区域有各自的用途,以及不同的创建和销毁时间。有些随着进程的启动而存在,有些随着线程的启动结束而创建销毁。

  JVM 内存被划分为如上图所示的运行时数据区的五个区域,分别是:方法区、堆、虚拟机栈、本地方法栈和程序计数器。其中方法区和堆为线程共享数据区,而虚拟机栈、本地方法栈和程序计数器为线程隔离数据区。

3.1 方法区

  方法区,即 Method Area,用来存储被加载到 jvm 中的类元信息(成员变量、方法等)、类加载器、运行时常量池等数据。为线程共享区域。在 jvm 启动时会创建方法区。

  在 jvm 的规范中,方法区在逻辑上属于堆的一部分,但不同 jvm 厂商在实现 jvm 时并没有严格按照 jvm 规范进行实现。如 oracle 的 HotSpot jvm 在 1.6 中对方法区的实现叫做永久代(PermGen ),其在物理结构上属于堆内存。永久代用来存储类元信息、类加载器、运行时常量池,其中运行时常量池中又包含了串池(StringTable)。HotSpot jvm 在 1.8 的实现中将对方法区的实现由永久代改编成了元空间(Metaspace),且其物理结构从堆内存移出到了系统内存。元空间在存储的内容上也有所变化,其将运行时常量池中的串池(StringTable)移到了堆内存中。所以 jvm 内存模型中的方法区在逻辑上属于 jvm 的一种规范或概念,而像永久代、元空间这些属于其物理上的具体实现。

  同时,jvm 也为方法区规定了内存溢出情况,其中在永久代实现中,内存溢出错误为 PermGen space,而在元空间实现中,内存溢出错误则为 Metaspace。

3.2 堆

  堆,即 Heap,用来存储几乎所有的 java 对象。其是 jvm 中占用内存最大的一块区域。其是线程共享区域,因此堆中的对象需要考虑线程安全问题。其会受到垃圾回收机制的管理。

3.3 虚拟机栈

  虚拟机栈,即 java virtual machine stacks,用来存储方法执行是的局部变量表、操作数栈、动态链接、方法出口等信息。为线程隔离区,即线程私有。

  每个线程启动后 jvm 都会为其分配虚拟机栈内存,默认分配的大小由操作系统决定,如 Linux、MacOS 默认分配的栈内存大小是 1024kb,Windows 系统默认分配的栈内存大小依赖于系统的虚拟内存。

 虚拟机栈又可以理解为是描述 java 方法执行的内存模型,每个方法执行时会创建一个栈帧(Frame),每个战争对应着一次方法的调用,即每一个方法从调用到结束对应着一个栈帧在栈中的入栈和出栈的过程。所以,栈是由多个栈帧组成的。同时,每个栈内只能有一个活动栈帧,对应着该栈对应线程正在执行的方法,活动栈帧即栈顶的栈帧。

注:

  • 垃圾回收不会涉及到栈内存。因为栈内存会随着方法执行结束或线程结束而释放。
  • 栈内存可通过参数自行分配,但并不是越大越好。因为系统内存一定,而栈内存有与线程挂钩,所以栈内存越大意味着可创建的线程就会越少,会降低系统资源利用率。
  • 方法内的局部变量在某些情况下不是线程安全的:
    • 若方法内的局部变量没有逃离方法的作用访问,则它是线程安全的。
    • 若方法内的局部变量为引用类型,且逃离的方法的作用访问,则它不是线程安全的。

  因为每个栈的大小是固定的(默认 1MB),所以有些异常程序中,会出现栈内存溢出。栈内存溢出:

  • 栈帧过多时会造成栈内存溢出(一般在方法递归调用时可能会出现这种情况)。
  • 栈帧过大时会造成栈内存溢出。
3.4 本地方法栈

  本地方法栈,即 Native Method Stacks,其和虚拟机栈的作用是一样的,只不过二者的服务对象不同。虚拟机栈服务的是 java 方法,而本地方法栈服务的是本地方法。

  本地方法即用 C 或 C++ 语言编写的直接与操作系统交互的方法,在 java 中则体现为被 native 关键字修饰的方法。

3.5 程序计数器

  程序计数器,即 Program Counter Register,其在物理上基于寄存器实现,用来记录当前线程所执行的字节码的行号指示器。字节码解析器的工作就是通过改变这个计数器的值,来选取下一条要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要这个计数器来完成。

  程序计数器是线程私有的,且该区域是 jvm 中唯一一个没有规定任何 OutOfMemoryError 情况的区域。

  正在执行 java 方法的话,计数器记录的是当前虚拟机字节码指令的地址;若是本地方法,则为空。

3.6 堆栈的区别
物理地址 堆的物理地址分配对对象是不连续的,所以性能较慢,且在 GC 的时候要考虑由于不连续的分配而造成的内存碎片问题,所以有各种 GC 算法,如标记-清楚、复制、标记压缩等。 栈的物理地址分配是连续的,使用的是数据结构中的栈,先进先出,所以性能快。
内存分别 堆因为是不连续的,所以分配的内存是在运行期确认的,且大小是不固定,一般堆的大小要远大于栈大小。 栈因为是连续的,所以分配的内存实在编译器确认的,且大小是固定的。
存放内容 堆存放的是对象实例和数组,所以该区更关注数据的存储。 栈存放的是局部变量、操作数栈、返回结果等,所以该区更关注程序方法的执行。
可见度 堆是线程共享区域,即对整个程序都是可见、共享的。 栈是线程隔离区域,即是线程私有的,支队线程可见,且其生命周期和线程相同。

PS:静态变量放在方法区,静态对象还是放在堆区。

3.7 内存溢出

  在 jvm 中,除却程序计数器外,其它区域都规定了内存溢出情况,在异常上分为 OutOfMemoryError 和 StackOverFlowError,分别表示内存溢出和栈内存溢出。

  • 内存溢出

    内存溢出是指 JVM 已经没有足够的内存空间来为你分配出你所需的内存。

    内存泄漏是指不再被使用的对象或变量一直占据着内存空间,当这种对象或变量变多时,就会耗尽内存,导致内存溢出。

    理论上来说 java 是由垃圾回收机制,也就是说,不再被使用的对象会被 GC 回收掉,自动从内存中清除。但即使这样,也依然存在内存泄漏的情况。java 中导致内存泄漏的原因很明确,即长生命周期的对象持有短生命周期的对象引用,尽管短生命周期已经不再被需要,但由于长生命周期持有它的引用而导致其不能被回收。这就是 java 中内存泄漏发生的场景。

  • 栈内存溢出

    栈是线程私有的,它的生命周期和线程相同。每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表又包含基本数据类型和对象引用类型。

    如果线程请求的栈深度大于虚拟机最大栈深度,则会抛出 StackOverFlowError 异常。通常在方法递归调用中会出现这种情况。

    如果虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但依然没有申请到足够的内存;或者在新创建线程时没有足够的内存去创建对应的虚拟机栈,则会抛出 OutOfMemoryError,即内存溢出。

3.8 常量池与运行时常量池
  • 常量池

    常量池是属于 .class 文件中的一部分,可以将其理解为一张常量表,程序执行时会根据虚拟机指令在这张常量表中找到要执行的类名、方法名、参数类型、字面量等信息。

    对于以下代码:

    public class ConstantPoolTest {
        public static void main(String[] args) {
            System.out.println("Hello world");
        }
    }
    

    其常量池则长下面这个样子:

    Constant pool:
       #1 = Methodref          #6.#21         // java/lang/Object."":()V
       #2 = Fieldref           #22.#23        // java/lang/System.out:Ljava/io/PrintStream;
       #3 = String             #24            // Hello world
       #4 = Methodref          #25.#26        // java/io/PrintStream.println:(Ljava/lang/String;)V
       #5 = Class              #27            // org/xgllhz/test/test/ConstantPoolTest
       #6 = Class              #28            // java/lang/Object
       #7 = Utf8               
       #8 = Utf8               ()V
       #9 = Utf8               Code
      #10 = Utf8               LineNumberTable
      #11 = Utf8               LocalVariableTable
      #12 = Utf8               this
      #13 = Utf8               Lorg/xgllhz/test/test/ConstantPoolTest;
      #14 = Utf8               main
      #15 = Utf8               ([Ljava/lang/String;)V
      #16 = Utf8               args
      #17 = Utf8               [Ljava/lang/String;
      #18 = Utf8               MethodParameters
      #19 = Utf8               SourceFile
      #20 = Utf8               ConstantPoolTest.java
      #21 = NameAndType        #7:#8          // "":()V
      #22 = Class              #29            // java/lang/System
      #23 = NameAndType        #30:#31        // out:Ljava/io/PrintStream;
      #24 = Utf8               Hello world
      #25 = Class              #32            // java/io/PrintStream
      #26 = NameAndType        #33:#34        // println:(Ljava/lang/String;)V
      #27 = Utf8               org/xgllhz/test/test/ConstantPoolTest
      #28 = Utf8               java/lang/Object
      #29 = Utf8               java/lang/System
      #30 = Utf8               out
      #31 = Utf8               Ljava/io/PrintStream;
      #32 = Utf8               java/io/PrintStream
      #33 = Utf8               println
      #34 = Utf8               (Ljava/lang/String;)V
    
  • 运行时常量池

    上面说到常量池是属于 .class 文件的内容,当编译后的 java 文件被加载到 jvm 中时,其常量池中的内容将被放入运行时常量池。同时将其中的符号地址变为真实地址,也就是类加载阶段的第四步:解析,将符号引用转变为直接引用。

3.9 串池(StringTable)

  串池,即 StringTable,是 jvm 中用来存储字符串对象的一块区域,其在设计上采用了哈希表的数据结构。其存在以下特性:

  • 运行时常量池中的字符串仅是符号,第一次被执行时才会变成字符串对象,并会入到串池中。
  • 串池的存在可以避免对象的重复创建。因为在字符串对象入串池时,若串池中已存在则在后续引用中会直接使用串池中的对象。
  • 字符串变量的拼接原理是使用 StringBuilder,最终会在堆中产生一个 String 对象。
  • 字符串常量(字面量)的拼接原理是在编译期优化,最终会在串池中产生一个 String 对象。
  • 可以使用 intern() 方法主动将字符串入到串池中:
    • jdk 1.8 中,在使用 intern() 方法入池时,若池中没有该字符串则放入,并返回池中对象的地址;若池中已存在该字符串则不会放入,并返回池中对象的地址,原来存在于堆中的对象则被抛弃,在下一次垃圾回收时将被回收。
    • jdk 1.6 中,在使用 intern() 方法入池时,若池中没有该字符串则将堆中的对象拷贝一份再放入,并返回池中对象的地址;若池中已存在该字符串则不会放入,并返回池中对象的地址,原来存在于堆中的对象则被抛弃,在下一次垃圾回收时将被回收。

  对于串池在物理上的地址,不同的 jvm 实现可能也不相同。在 jdk 1.6 中,串池是存在于方法区(或永久代)中的运行时常量池内的;在 jdk 1.8 中,串池是存在于堆中的。同时,由于串池存在于永久代或堆中,所以其也是会受到垃圾回收管理的。

  串池性能调优,因为串池在设计上使用了哈希表的数据结构,所以当程序中的字符串常量(字面量)过多时,可以考虑通过增大哈希桶的个数来降低哈希冲突的概率,以提高串池中字符串获取或放入的时间。串池哈希桶的个数可通过命令 -XX:StringTableSize=buckets 来调整。

// 以下代码简单描述了串池的作用

String s1 = "a";   // 执行此代码时 字符 a 将以字符串对象的方式入到串池中
String s2 = "b";   // 执行此代码时 字符 b 将以字符串对象的方式入到串池中
String s3 = "a" + "b";   // 此代码在编译期会被优化为 s3 = "ab" 即字符串常量(字面量)的拼接 执行此代码时 字符 ab 将以字符串对象的方式入到串池中
String s4 = s1 + s2;   // 执行此代码时 实际上执行的是 new StringBuild().append("a").append("b").toString() 所以最终会在堆中创建一个字符串对象 "ab"
String s5 = "ab";   // 执行此代码时 字符 ab 将以字符串对象的方式入池 但因为第三行代码已经将 "ab" 入池 所以此处不会入池 返回串中 "ab" 对象的引用。
String s6 = s4.intern();   // 执行此代码时 因为在第三行代码中已经将 "ab" 对象入到池中 所以此处不会入池 返回池中 "ab" 对象的引用。

System.out.println(s3 == s4);   // false s3 是池中对象 s4 是堆中对象 二者地址不相同
System.out.println(s3 == s5);   // true s3 是池中对象 s5 也指向池中的 "ab" 对象 二者地址相同
System.out.println(s3 == s6);   // true s3 是池中对象 s4 虽然是堆中对象 但其在调用 intern() 方法后 最后都会返回池中对象 "ab" 的地址 二者地址相同
String s1 = new String("a") + new String("b");   // 字符串变量拼接 堆中对象
String s2 = "ab";   // 字符串字面量 池中对象
s1.intern();   // 入池失败
System.out.println(s1 == s2);   // false s1 堆中 s2 池中 二者地址不相同

// jdk 1.8 下
String s1 = new String("a") + new String("b");   // 字符串变量拼接 堆中对象
s1.intern();   // 入池成功 返回池中地址
String s2 = "ab";   // 字符串字面量 池中已有 池中对象
System.out.println(s1 == s2);   // true s1 入池成功 故引用池中地址 s2 本引用池中地址 二者地址相同

// jdk 1.6 下
String s1 = new String("a") + new String("b");   // 字符串变量拼接 堆中对象
s1.intern();   // 入池成功 返回池中地址 但由于是将 s1 拷贝了一份再入的池 所以 s1 仍引用堆中对象
String s2 = "ab";   // 字符串字面量 池中已有 池中对象
System.out.println(s1 == s2);   // flase s1 堆中对象 s2 池中对象 二者地址不相同
3.10 直接内存

  直接内存,即 DirectMemory。不属于 jvm 内存区域,是直接向系统申请的系统内存。如常见的 NIO 操作中的数据缓冲区即是 java 程序直接向系统申请的系统内存。直接内存的特点是读写性能高,但分配回收成本高;不受 jvm 内存管理;也会出现 OOM 的情况。

  直接内存的分配和回收底层是使用了 Unsafe 类的 aollcateDirect() 和 freeMemory() 方法,前者负责分配直接内存,后者负责释放直接内存。在 ByteBuffer 的实现类中,使用了 Cleaner(虚引用)来检测 ByteBuffer 对象,当该对象被垃圾回收后,就会由 ReferenceHandler 线程通过 Cleaner 的 clean() 方法来释放直接内存(内部调用 freeMemory())。

4 类加载机制

  JVM 类加载机制是指 JVM 通过类加载子系统将描述类信息的 .class 文件中字节码加载到内存中,并通过验证、准备、解析、初始化等阶段,最终产生能被 JVM 直接使用的 java.lang.Class 对象的过程。

  JVM 类加载机制分为五个步 骤,分别是:加载、验证、准备、解析、初始化。

走近科学之《JVM 的秘密》_第2张图片

4.1 加载

  这一阶段的主要作用是将描述类信息的字节码文件从磁盘加载到内存中,并生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。

  java 的类加载是动态的,它并不会一次性把所有的都加载后再运行,而是将保证程序运行的基础类加载到 jvm 中,其它的类,则是在需要的时候再加载,即懒加载。

  类的加载方式有两种,即隐式加载和显式加载。隐式加载是指在程序运行过程中,当遇到通过 new 等方式创建对象时,才隐式的调用类加载器将对应的类加载到 jvm 中。显式加载是指通过 class.forName() 等方法,显式加载所需要的类,如反射。

  jvm 中的类加载是由类加载器(Class Loader)和它的子类实现的,负责在运行时查找和载入类。类加载器包括根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)、用户自定义加载器(java.lang.ClassLoader 的子类)。类加载过程采用了父亲委托机制,即类加载首先请求父加载器,父加载器无能为力时才由子加载器加载,即双亲委派模型。

  打破双亲委派模型不仅要继承 ClassLoader 类,还要重写 loadClass 和 findClass 方法。

  • BootStrap:
    一般用本地代码实现,负责加载 jvm 基础核心类库(rt.jar)。
  • Extension:
    从 java.ext.dirs 系统属性所指定的目录中加载类库。它的父加载器是 BootStrap。
  • System:
    又叫应用类加载器,它从环境变量 classpath 或 java.class.path 所指定的目录中加载类。其父类是 Extension。
  • 自定义加载器:
    以用户自定义的方式加载用户指定的类。其父类是 System。

主动引用:

  • 使用 new 实例化对象时;读取和设置静态变量、静态非字面值常量时、调用静态方法时。
  • 使用反射时。
  • 初始化某个类时,若其有父类且父类没初始化时,先初始化父类。
  • 程序启动 main 方法所在的类。
  • 使用 1.7 的动态语言支持时。

被动引用:

  • 通过子类引用父类的静态字段时,只会触发父类的初始化,而不会触发子类的初始化。
  • 定义某个类类型的数组或集合时,不会触发该类的初始化。
  • 类 A 引用类 B 的 static final 常量时,不会触发类 B 的初始化(注意该静态常量必须是字面值常量,否则会导致类 B 的初始化)。
4.2 验证

  这一阶段的主要目的是为了确认加载到内存中的字节码信息是否符合当前虚拟机的要求,且是否会对虚拟机自身产生危害。

4.3 准备

  准备阶段是指在方法区中为类中的变量分配内存并设置变量初始值。

  只会对 static 修饰的静态变量分配内存、赋默认值(如 0、0L、null、false)。

  对 final 修饰的静态字面值常量直接赋初值(若不是字面值常量,则会赋默认值)。实际上在编译阶段会为 static final 修饰的变量生成 ConstantValue 属性,在准备阶段 jvm 会根据该属性将该变量的值赋为初值。

4.4 解析

  解析阶段是指 jvm 将常量池中的符号引用替换为直接引用的过程。

  符号引用的引用目标不一定已经加载到内存中,其和具体虚拟机的实现有关。直接引用可以是指向目标的指针、内存中的地址,其引用目标必定是已经存在于内存中的。

  符号引用即 .class 中的 CONSTANT_Class_info、CONSTANT_Field_info、CONSTANT_Method_info 等类型的常量。

4.5 初始化

  初始化阶段主要目的是为类的静态变量赋予正确的初始值,jvm 对类进行初始化,主要对类变量进行初始化。

类变量初始化的方式有两种:

  • 声明类变量是指定初始值。如 private static String a = “momo”。
  • 在静态代码块中指定初始值。如 static { a = “momo” }。

5 对象的创建

jvm 创建对象的主要流程如下:
走近科学之《JVM 的秘密》_第3张图片

  当虚拟机遇到一条 new 指令,首先检查常量池中是否已经加载到相应的类,若没有,则必须执行相应的类加载操作;接下来是分配内存,若堆内存是绝对规整的,则使用指针碰撞的方式分配,若不是,则从空闲列表中分配,即空闲列表方式;分配内存时还需要考虑并发问题,可以通过 CAS 同步处理或本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)这两种方式来处理;然后进行内存空间初始化操作;接着进行一些必要的对象设置(元信息、哈希码);最后执行 < init > 方法。

java 中对象的创建方式:

创建方式 是否使用构造函数
使用 new 关键字 调用了构造函数
使用 Class 的 newInstance 方法 调用了构造函数
使用 Constructor 类的 newInstance 方法 调用了构造函数
使用 clone 方法 没有调用构造函数
使用反序列化 没有调用构造函数
分配内存

  类加载完成后,接着会在堆内存中为对象分配内存,内存分配取决于堆内存是否规整,有两种方式:

  • 指针碰撞:
      如果堆内存是规整的,即正在使用中的在一边,空闲的在另一边,分配内存时会将位于中间的指针指示器向空闲的内存中移动一段与对象大小相等的距离。这样便完成了对象的内存分配。
  • 空闲列表:
      如果堆内存是不规整的,虚拟机维护了一个列表来记录那些内存碎片是可用的,这时候会从列表中查询到足够大的记录来分配给对象,并在分配完成之后更新记录列表。

  采用那种方式分配内存是由堆内存是否规整决定的,而堆内存是否规整又是由采用的垃圾收集器是否带有压缩功能来决定的。

并发安全问题

  对象的创建在虚拟机中是一个频繁的操作,哪怕只是修改一个指针指向的位置,在并发情况下也是不安全的。如可能会出现正在给对象 A 分配内存,指针还没来得急修改,对象 B 又同时使用了原来的指针分配内存的情况。解决这种情况有两种方式:

  • CAS 同步处理:
      即 CAS + 失败重试来保证更新操作的原子性。
  • 本地线程分配缓冲:
      把分配内存的动作按照线程放在不同的空间中进行,即每个线程在堆内存中预留一小块内存,称为本地线程分配缓冲(Thread Local Allication Buffer)。哪个线程要分配内存时,就从该线程的 TLAB 中分配。只有当线程的 TLAB 用完并重新分配时才需要同步锁。可以通过 -XX:+/-UserTLAB 参数来设置虚拟机是否使用 TLAB。
对象的访问定位

  java 程序需要通过 jvm 栈上的引用来访问堆中的具体对象。对象的访问方式取决于 jvm 虚拟机的具体实现,目前主流的访问方式有两种,分别是指针和句柄。

  • 指针:
      指向对象,代表一个对象在内存中的起始地址。
      优势:速度更快,节省了一次指针定位的时间开销,由于对象访问在程序中非常频繁,所以这种开销积少成多后也是非常可观的。
  • 句柄:
      可以理解为指向对象指针的指针,维护着对象的指针。句柄不能直接指向对象,而是指向对象的指针,再由对象的指针指向对象在内存中的真实地址。句柄不发生变化,指向固定的内存地址。
      堆内存中会划分出一块内存来作为句柄池,引用中存储的是对象的句柄地址。句柄中包含了对象的实例数据和对象类型数据的具体地址信息。
      优势:引用中存储的是句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

6 垃圾回收机制

  内存处理从某方面来说在程序中是非常重要的,它关乎着你的程序是否可以更高效的运行。且内存处理又是最容易被编程人员忽略或者出错的地方,忘记或者错误的内存回收可能会导致系统的不稳定甚至崩溃。jvm 提供的垃圾收集机制有效的解决了这个问题。

6.1 简介及原理
  • 简介:
      jvm 中有一个垃圾回收线程,它是低优先级的,正常情况下不会执行,只有当虚拟机空闲或者堆内存不足时才会触发执行。执行时,扫描那些没有被任何引用的对象,将它们添加到要回收的集合中,进行回收,释放其所占用的内存空间。
  • 原理:
      对于 GC 来说,当对象被创建时,GC 就开始监视这个对象的地址、大小、以及使用情况。通常,GC 采用有向图的方式记录和管理堆中的对象。通过这种方式来确定那些对象是 “可达的”,那些对象是 “不可达的”,当某个对象不可达时,GC 就有责任回收该对象占用的内存空间。

  通常情况下,GC 是被动触发执行的,当然程序员也可以通过 System.gc() 方法来通知 GC 运行,但 java 语言规范并不能保证 GC 一定会运行。

  垃圾回收机制有效的防止了内存泄漏,从某方面来说提高了内存的使用效率。

6.2 对象被回收时机

  垃圾收集器在垃圾回收的时候,首先要判断那些内存是可以被回收的,即判断哪些对象是 存活 的,哪些对象已经 死掉 了。一般有两种判断方法:

  • 引用计数器法:
      为每个对象创建一个引用计数,当该对象被引用时,计数器 +1,当引用被释放时,计数器 -1,当计数器为 0 时,则说明该对象可以被回收。引用计数器法有一个缺点就是不能解决循环引用的问题。
  • 可达性分析法:
      通过一系列 “GC Roots” 对象作为起点进行搜索。当 “GC Roots” 与一个对象之间没有可达路径时,则称该对象不可达。不可达不等于可回收,不可达对象变为可回收对象至少要经过两次标记过程,若两次标记后仍为不可达对象,则说明该对象会被回收。
      可达性分析法解决了引用计数器法的不能解决的循环引用问题。

四种引用:

  • 强引用:

    即类似于 A a = new A() 这种对象直接赋值的引用被称为强引用。其特点是只要有 GC Roots 强引用该对象,则该对象就不能被垃圾回收。

  • 软引用:

    即 SoftReference。其特点是内存够就不回收,内存不够就回收。可以配合引用队列来释放软引用自身。

  • 弱引用:

    即 WeakReference。其特点是只要发生垃圾回收,则其一定会被回收。可以配合引用队列来释放弱引用自身。

  • 虚引用:

    即 PhantomReference。当被引用的对象回收时,虚引用对象将被放入引用队列(ReferenceQueue),由 ReferenceHandler 线程来调用虚引用对象的相关方法释放虚引用自身所占用的内存。其必须配合引用队列来使用。

  • 终结器引用:

    即 FinalReference。在垃圾回收时,终结器引用对象将入列(此时终结器引用的对象还没有被回收),再由 Finalizer 线程通过终结器引用找到被引用的对象,然后调用其 finalize() 方法,在第二次垃圾回收时被终结器引用的对象将被回收。

6.3 堆内存分代

  堆内存分代使 jvm 能够更好的管理内存中的对象,且提到了内存空间的利用率。

  jvm 堆内存从 GC 的角度还可以分为 新生代 和 老年代,其分别占堆内存空间的 1/3 和 2/3。新生代又可分为 Eden 区、From Survivor 区、To Survivor 区。其中 Eden、From、To 这三个区又分别占新生代的 8/10、1/10、1/10。

走近科学之《JVM 的秘密》_第4张图片

  堆大小 = 新生代 + 老年代,新生代 = Eden 区 + From 区 + To 区。其中堆大小可通过 -Xms 和 -Xmx 参数来设置;新生代和老年代占比为 1 : 2,该比例可通过 -XX:NewRatio 参数来设置;Eden : From : To 为 8 : 1 : 1,可通过 -XX:SurvivorRatio 来设置。

  • 新生代:
      新生代一般是用来存放新对象的,因为对象创建频繁,所以新生代会频繁触发 MinorGC 进行垃圾回收。
    • Eden 区:
        java 新对象的出生地(如果创建的对象占用内存过大,则会被直接分配到老年代),当 Eden 区内存不够时,就会触发一次 MinorGC,堆新生代进行垃圾回收。
    • From 区:
        上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
    • To 区:
        保留了一次 MinorGC 过程中的幸存者。
  • 老年代:
      老年代一般存放程序中生命周期较长的对象,所以老年代的对象比较稳定,一般通过 MajorGC/FullGC 进行垃圾回收,但不会像 MinorGC 那样频繁触发。
    • 当新生代对象晋身到老年代,导致老年代空间不足时会触发 MajorGC/FullGC,所以在 MajorGC/FullGC之前一般都会进行一次 MinorGC。
    • 当无法找到足够大的空间分配给新创建的大对象时,也会触发一次 MajorGC/FullGC。
  • 永久代:
      永久代是指永久保存的区域,主要存放 Class 和 Meta(元数据)信息。Class 被加载后进入永久区域,它和存放对象实例的区域不同,GC 不会在程序运行期间对其清理,所以这也就导致了随着加载的 Class 信息越来越多,最终会抛出 OOM 异常。垃圾回收不会发生在永久代,如果永久代满了或者超过了临界值,则会触发 FullGC。(jdk 8 中移除了永久代,被元数据区(元空间)代替)。

MinorGC、MajorGC、FullGC:

  • MinorGC:
      MinorGC 的过程:复制 - 清空 - 互换。MinorGC 采用 复制 算法(GC 算法之一)。首先将 Eden 和 From 的对象复制到 To,若有对象的年龄到达老年,则直接将其复制到老年代,若 To 区位置不够则也复制到老年代,同时将这些对象的年龄 +1;清空 Eden 和 From 区中的对象;互换 From 和 To,即原来的 To 区变为 From 区,作为下一次 MinorGC 时的 From 区(即复制步骤中的 From)。当对象年龄超过 15 时还存活时,会晋身至老年代。可通过 -XX:+MaxTenuringThreshold 参数来修改。当 Eden 区内存不足时会触发 MMinorGC。且,MinorGC 会引发 STW,即 GC 期间会暂停用户线程,直到垃圾回收结束,才会恢复用户线程。
  • MajorGC:
      MajorGC 采用 标记清除 算法,其作用在老年代。首先会扫描一次老年代的所有对象,标记出存活的对象,然后回收没有被标记的对象。因为需要先扫描再回收,所以较耗时。且会产生内存碎片,为了提高内存利用率,一般会对内存碎片合并或者记录,以便下次使用。当老年代也放不下对象时就会抛出 OOM 异常。当老年代内存不足时会触发 MajorGC。
  • FullGC:
      FullGC 采用 标记清除 算法,其作用在整个堆内存,也就是新生代和老年代。首先会扫描一次堆中所有对象,标记出存活的对象,然后回收没有被标记的对象。因为需要先扫描再回收,所以较耗时。且会产生内存碎片,为了提高内存利用率,一般会对内存碎片合并或者记录,以便下次使用。当 jvm 内存不足时会触发 FullGC。注:Full GC 在早先版本中为单线程,后续版本中为多线程。
6.4 垃圾回收算法

  GC 的常用算法有 标记清除算法、复制算法、标记压缩算法、分代收集算法。常用的垃圾收集器一般都采用分代收集算法。

  • 标记清除算法:
      即 Mark-Sweep,是一种常见的基础垃圾收集算法,分为两个阶段:标记、清除。首先标记存活的对象,然后回收没有被标记的对象。一般用在老年代。
    • 优点:实现简单,不需要对对象进行移动。
    • 缺点:会产生内存碎片;在执行过程中程序会停止,性能低。
  • 标记复制算法:
      即 Mark-Copying,它将内存空间分为大小相等的两块区域,每次只使用其中的一块。当正在使用的这块区域内存不足时,会从该区域筛选出存活的对象,然后将其复制到另一块区域,最后将该区域的对象全部清理掉。一般用在新生代。
    • 优点:实现简单,效率高,解决了内存碎片问题。
    • 缺点:内存利用率低,因为每次只使用内存空间的一般;会对存活时间长的对象频繁复制。
  • 标记压缩/整理算法:
      即 Mark-Compact,分为 标记、压缩/整理 两个阶段。首先扫描所有对象,标记出所有存活的对象,然后将被标记的对象移动到一端,最后将端界外的对象清理掉。一般用在老年代。
    • 优点:解决了内存碎片问题。
    • 缺点:需要对局部对象进行移动,一定程度上降低了效率。
  • 分代收集算法:
      即 Generational Collection,根据对象生命周期的不同将内存空间划分为不同的区域,如新生代和老年代,然后根据各个区域的特点采用不同的 GC 算法,如新生代采用复制算法,老年代采用标记整理算法。是目前大多数 jvm 采用的 GC 算法。
    • 优点:合乎情理。
    • 缺点:?。
6.5 垃圾收集器

  垃圾收集算法是内存回收理论,垃圾收集器是内存回收的具体实现。常见的垃圾收集器有 Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1。

  对于经典的分代收集算法来说,Serial、ParNew、Parallel Scavenge 常用于新生代;Serial Old、Parallel Old、CMS 常用于老年代;G1 常用于整个堆内存的回收。它们之间的连线表示可以相互搭配使用。

走近科学之《JVM 的秘密》_第5张图片

收集器特点及对比:

走近科学之《JVM 的秘密》_第6张图片

注:STW,即 Stop-The-World,jvm 中一种机制,在垃圾收集时,java 应用程序的所有线程都被挂起(垃圾收集帮助器除外),一种全局停顿现象,所有 java 代码停止,native 代码可运行,但不能与 jvm 发生交互。这种现象多半由 GC 引起。

6.5.1 CMS

  CMS 即 Concurrent Mark Sweep,并发标记清除垃圾收集器。其主要目标是获取最短的垃圾回收停顿时间,其使用标记清除算法。

6.5.1.1 工作流程

  CMS 垃圾收集器的工作流程可分为五个阶段,分别是:

  • 初始标记:

    暂停所有用户线程(STW),只标记被 GC Roots 直接引用的对象。因为只标记 GC Roots 直接引用的对象,所以这个过程耗时非常短。

  • 并发标记:

    在不暂停用户线程的情况下,从 GC Roots 向下遍历所有被关联的对象。虽然这个过程较为耗时,但垃圾回收线程是和用户线程并行工作的,所以并不会影响到用户线程。但由于在并发标记过程中用户线程仍在运行,所以标记结果和实际情况可能会有所出入。

  • 重新标记:

    重新标记是为了核对在并发标记期间用户线程的运行对标记结果产生的 “误标” 问题。所以这个阶段会导致所有用户线程暂停(STW),且停顿时间比初始标记时的停顿时间较长些。底层主要通过三色标记的增量更新算法实现。

  • 并发清除:

    即对 GC Roots 不可达的对象进行清理。同时,这个阶段垃圾回收线程也是和用户线程一起工作的,即在该阶段会产生新的垃圾,称之为浮动垃圾,当浮动垃圾过多时,CMS 会退化为 Serial Old。

  • 并发重置:

    重置本地 GC 过程中的被标记的数据。

走近科学之《JVM 的秘密》_第7张图片

6.5.1.2 特点
  • 新生代空间不足时发生的垃圾回收成为 MinorGC。
  • 新生代进行 MinorGC 时,to 区放不下,对象进入老年代,老年代也放不下时会触发 Full,此时会产生 promotion failed 日志,即若日志中产生 promotion failed,则是由 to -> 老年代,但老年代放不下造成的。在 CMS GC 时,又有对象进入老年代名名,而老年代放不下时也会触发 Full,此时会产生 concurrent mode failed 日志。
  • 在并发标记阶段和并发清除阶段又会产生新的垃圾,称之为浮动垃圾,需要等到下一次垃圾回收时才能被清理。
  • CMS GC 使用标记清除算法,该算法会产生内存碎片,可以通过参数 -XX:+UseCMSCompactAtFullCollection(默认开启)来设置让其在标记清除后对内存碎片进行整理。但整理时会造成 STW,这是可通过参数 -XX:CMSFullGCsBeforCompact = n(默认为 0)来设置再进行 n 次 CMS GC 后再进行内存碎片的整理。
  • 当出现 concurrent mode failed 时,会触发 Full GC,Full GC 有些许耗时,故其又提供了参数 -XX:CMSInitiatingOccupancyFraction = n(默认值为 92)来控制垃圾回收的时机,其意为当老年代的内存使用率超过 92% 时才会触发垃圾回收。
6.5.1.3 三色标记法

  并发标记阶段,垃圾回收线程与用户线程并发运行,此时会出现已经标记过的对象被用户线程修改其引用的情况。如 A a = b = c,即 a 引用 b,b 引用 c,在并发标记前 b 取消对 c 的引用,此时则变成 A a = b,并发标记时发现 c 未被引用则不标记 c,但在用户线程执行的同时又将 aa 的引用指向 c,即 A aa = c,但此时 c 已经被处理过了,且未被标记,即被定义为垃圾,这时就造成了漏标。为了解决这种漏标问题,CMS 在设计上使用了三色标记算法和读写屏障来解决。

  三色标记法即,其使用黑、灰、白三种颜色来表示 GC Roots 可达性分析时的对象状态。

  • 白色:在可达性分析前,所有对象都是白色,即表示未处理。在可达性分析结束后,若其仍然为白色则说明其是垃圾。
  • 黑色:在可达性分析过程中,已经被分析过的对象为黑色,即表示已处理。在可达性分析后,若其仍然为黑色,则表示其为存活对象。
  • 灰色:在可达性分析时,正在被分析的对象为灰色,即表示正在处理。在可达性分析后,若其被强引用,则变为黑色,若其未被强引用,则置为白色,表示其为垃圾。

  此时,也就是在并发标记结束后,仍然没有解决漏标的情况,于是就有了重新标记,同时使用读写屏障来解决漏标问题。

6.5.1.4 读写屏障

  对于上文描述的漏标,理论上有两种解决办法,即增量更新(Incremental Update)和原始快照(Snapshot At The Begining 即 SATB)。

  • 增量更新:

    即当黑色对象(即已经可达性分析过且为存活对象)中增加了一个指向白色对象的引用时,则将这个黑色对象记录下来,待并发标记结束后重新标记时,再以记录下来的黑色对象为根重新进行可达性分析。简单讲即为若黑色对象多了对白色对象的引用则将其置为灰色。

  • 原始快照:

    即当要删除灰色对象的某个指向白色对象的引用时,将这个白色对象记录下来,待并发标记结束后重新标记时,再检查这些记录下来的对象,若引用,则视为垃圾。

  对于增量更新和原始快照,虚拟机是通过读写屏障来实现的。类似于 aop。

// ?

  以 HotSpot 虚拟机为例,各垃圾收集器在针对漏标的处理上如下:

  • CMS: 写屏障 + 增量更新。
  • G1: 写屏障 + 原始快照。
  • ZGC: 读屏障。
6.5.1.5 卡表、记录表与跨代引用

  在新生代进行 Minor GC 时,需要先进行初始标记,即首先要知道那些对象是 GC Roots 直接引用的对象,而对于新生代来说部分 GC Roots 是存在于老年代的,即老年代的某些对象引用了新生代的对象,这种现象称为跨代引用,在分代垃圾收集或区域垃圾收集中是非常常见的。

  新生代初始标记 GC Roots 直接引用的对象时,为了避免直接扫描老年代的全部对象(老年代对象多,全扫耗时),对于这种会出现跨代引用的垃圾收集器,jvm 给其设计了卡表和记录表的功能。

  • 卡表:

    即将老年代分成固定大小的多个区域(一般每 512k 为一个区域),每个区域则称为一个卡。当某个卡中的对象引用了新生代的对象时,则将该卡标记为脏卡。这个改变是通过写屏障来实现的。

  • 记录表:

    对于新生代,其会维护一个记录集(Remember Set 数据结构,在多区域的堆内存设计中,每个区域的新生代都会维护一个记录集),记录了老年代对新生代的引用。

  这样,当进行 Minor GC 时,若该区域的新生代的记录集中存在记录,则根据记录找到老年代的对应的脏卡,然后遍历脏卡中的对象,从而找到新生代对象在老年代中的根对象。通过这种方式提高了 Minor GC 的效率。

6.5.2 G1

  G1 即 Garbage First,和其它垃圾收集器不同的是,其作用于整个堆内存,且同时注重高吞吐量和低延迟。其是 jdk 9 默认使用的垃圾收集器(9 之前默认使用的是 CMS,故在 jdk 8 之前若要使用 G1 则需要使用 -XX:+UseG1GC 来开启)。其适用于大堆内存。

6.5.2.1 内存模型
走近科学之《JVM 的秘密》_第8张图片

  如上图所示,G1 垃圾收集器会将整个堆内存分为大小相等的多个 Region(区域)(可通过参数 -XX:G1HeapRegionSize = n 来设置每个 Region 的大小,且值必须为 2 的幂),每个 Region 都可单独作为 Eden 区、Survivor 区、Old 区、Humongous 区,其分别代表新生代中的对象出生区、新生代中的幸存区、老年代、大对象区。也就是说,在 G1 垃圾收集模式下,整个堆内存将被划分为多个大小相等的 Region ,这些 Region 共分为四个类型。且每个 Region 所承担的类型不是固定不变的,可能这次承担的是 Eden 区,在执行一次垃圾回收之后就承担老年区。

  堆内存被分为多个 Region 之后,不同类型的 Region 将在物理上不再连续。如图中所有绿色 Region 都为 Eden 区,所有红色 Region 都为 Old 区。

  默认情况下,新生代占整个堆内存的 5%,在系统运行期间 jvm 会动态的增加新生代的 Region 数,但新生代的总占比不会超过阈值 60%。新生代占比的阈值可通过参数 -XX:G1MaxNewSizePercent = n 来调整。同时,在所有新生代的 Region 中,依旧会按照 8:1:1 的比例给 Eden、From、To 分配。

  Humongous 是 G1 专门为存储大对象准备的空间。其判断大对象的规则是当一个对象所占空间大于一个 Region 的 50% 时,则认为其为大对象,将被存储在 Humongous 类型的 Region 中。G1 不会对大对象进行复制,同时垃圾回收时优先考虑大对象,G1 会跟踪老年代对象的 incoming 引用,老年代大对象的 incoming 引用为 0 时会在新生代垃圾回收时进行回收。

  同时 G1 规定了 STW 时间,默认为 200 ms,可通过参数 -XX:MaxGCPauseMillis = n 来设置。其整体上使用 标记整理 算法,两个 Region 之间使用 复制算法。

6.5.2.2 工作流程

  G1 垃圾收集器的工作流程为 Young GC -> Young GC + CM -> Mixed GC。并且这三个是一个闭环流程,即当 Mixed GC 结束后将再次进入 Young GC 阶段。其详细流程见下文。

6.5.2.3 Young GC

  Young GC,顾名思义,即新生代的垃圾收集,其大致流程为初始标记、幸存对象复制、晋升对象复制。且全过程会导致 STW。同时,Young GC 为 G1 垃圾收集器的第一个阶段。

  • 初始标记:

    即进行 GC Roots 标记,这里将会涉及到跨代引用问题(同 CMS 中描述到的跨代引用),解决办法亦为卡表与记录表。

  • 幸存对象复制:

    即将 Eden 区的幸存对象复制到 Survivor 区(To 区),同时将 Survivor 区(From 区)的幸存着也复制到 Survivor(To 区)。

  • 晋升对象复制:

    即在复制 Survivor 区(From 区)的幸存着时,若其年龄达到晋升标准,则将其复制到 Old 区。

6.5.2.4 Young GC + CM

  Young GC + CM 即新生代垃圾收集和并发标记。这是 G1 垃圾收集器的第二个阶段,在一个阶段中,Young GC 会一直进行,也就是说可能会一直有长时间存活的对象晋升至 Old 区,当 Old 区的使用量达到堆空间的 45% 时(可通过参数 -XX:InitiatingHeapOccupancyPercent = n 来调整),就会进入 Young GC + CM 阶段。在 Young GC + CM 阶段,新生代垃圾收集和并发标记会同时执行,并发标记不会造成 STW。

  这个阶段的主要任务是进行并发标记(并发标记依赖于初始标记的 GC Roots 对象,这个是在 Young GC 时产生的,且在进行并发标记的过程中也会继续进行 Young GC)。

6.5.2.5 Mixed GC

  Mixed GC 即混合回收,其会对 Eden、Survivor、Old 区域进行全面垃圾回收。当第二个阶段的并发标记结束后就会进入混合回收阶段。这个阶段共分为两个步骤,分别为最终标记和筛选回收。

  • 最终标记:

    最终标记的目的和 CMS 中重新标记的目的一样,是为了校验并发标记阶段用户线程对标记结果的 “误标”。但其解决手段和 CMS 稍有不同,CMS 是采用 写屏障 + 增量更新,而 G1 是采用 写屏障 + 原始快照。

  • 筛选回收:

    筛选回收也可分为两个阶段即先筛选,后回收。其筛选逻辑是先根据每个 Region 的回收价值和成本进行排序(即在最短时间内可回收到最大的空间);因为 G1 设置了 STW 时间,所以其会选择回收成本(时间)之和尽量接近 STW 时间且回收价值之和最高的几个 Region 来回收。其回收时也是采用复制算法,即将存活的对象复制到其它的空 Region 中。

  在 Mixed GC 结束后,将又进入 Young GC 阶段,形成一个循环。注意,当垃圾产生的速度超过 G1 垃圾收集的速度时,将会退化为 Full GC。

6.5.2.6 G1 与 CMS

  G1 与 CMS 的区别如下:

  • 内存模型:

    CMS:延用了 jvm 的内存模型。

    G1:堆内存分区,堆内存会被划分为大小相等(Region 大小为 2 的幂次方 MB)的区域,且存在分代概念。

  • 作用范围:

    CMS:作用于老年代,且可结合 Serial、ParNew、Serial Old 垃圾收集器使用。

    G1:作用于整个堆,单独使用。

  • 工作流程:

    CMS:主要体现在并发清除阶段。该阶段会通过并发的方式释放掉不活跃对象所占用的空间。同时,在并发清除阶段,由于 GC 线程和用户线程时并发执行,所以此阶段会产生新的垃圾,称之为浮动垃圾,当浮动垃圾过多时,其会退化为 Serial Old。

    G1:主要体现在筛选回收阶段。该阶段会预测回收成本并与 STW 比较,在 STW 范围内通过复制方式将存活对象转移到新 Region。该阶段是 STW 的。

  • 算法类型:

    CMS:使用标记清除算法,会产生内存碎片。

    G1:使用标记整理算法,不会产生内存碎片。

6.5.3 ZGC
6.5.3.1 简介

  ZGC 即 The Z Garbage Collector,是 jdk 11 推出的一款低延迟拉满的垃圾回收器。其源自于 Azul System 公司开发的 C4 垃圾回收器。其是在 G1 垃圾回收器在大堆场景下性能不足的背景下出现的。

走近科学之《JVM 的秘密》_第9张图片

  如上图所示,ZGC 的四个首要目标是:

  • 支持 8MB ~ 4TB 级别的堆,jdk 13 后已经支持 16TB 的堆。4G 以下可以用 Parallel GC,4G ~ 8G 选择 CMS,8G 以上用 G1,百 G 以上则用 ZGC。
  • 垃圾回收停顿时间(STW 时间)不超过 10ms,jdk 16 中已经实现了不超过 1ms。Minor GC 一般在 10ms 左右,Major GC 一般在 100ms 以上。且 ZGC 的 STW 停顿时间只与 GC Roots 数量有关。
  • 为未来的 GC 特性奠定了基础。
  • 最坏的情况下应用程序的吞吐量会下降 15%。大部分情况下吞吐量下降 15% 影响不大,若追求极致的吞吐量,则完全可以通过扩容解决。

  总之,ZGC 适用于低延迟、高吞吐量、大堆的应用服务。

6.5.3.2 内存模型
走近科学之《JVM 的秘密》_第10张图片

  如上图所示,其为 ZGC 内存模型示意图。与 G1 类似,ZGC 在内存模型的设计上并没有分代的概念,同时其也会将堆内存划分为一个个的小区域 Region,在 ZGC 中称之为 Page 即页面:

  • 小 Region (Small Region):容量固定为 2MB,用来存放小于 256KB 的对象。
  • 中 Region (Medium Region):容量固定为 32MB,用来存放大于 256KB 且小于 4MB 的对象。
  • 大 Region (Large Region):容量不固定,动态变化,但须大于等于 4MB,且须为 2 的幂次方,用来存放大于等于 4MB 的大对象,每个大型 Region 中只放一个大对象。该类型的 Region 不参与 GC 过程中的对象转移过程,因为复制大对象是需要非常大的代价的。
6.5.3.3 工作流程

走近科学之《JVM 的秘密》_第11张图片

  如上图所示,其为 ZGC 工作流程示意图。同 G1 一样,ZGC 在整体上也使用标记整理算法,各个 Region 之间也是标记复制算法。

  标记复制算法可分为三个阶段:

  • 标记阶段:从 GC Roots 开始遍历对象图进行可达性分析。
  • 转移阶段:把已标记的对象(活跃对象)转移到新的区域。
  • 重定位阶段:对象转移导致其地址发生了变化,因此重定位阶段,所有指向对象旧地址的指针需要调整到对象的新地址上。

  ZGC 的工作流程可以分为七个阶段:

  • 1、初始标记:STW,标记被 GC Roots 直接引用的对象。

  • 2、并发标记:从被标记的 GC Roots 直接引用的对象开始遍历对象图进行可达性分析。该阶段会更新颜色指针(颜色指针是其实现低延迟的关键技术),G1 在该阶段更新的是对象头信息。

  • 3、重新标记:STW,理论上在并发标记结束后所有待标记对象(活跃对象)都会被标记,但由于 GC 线程和用户线程是并发执行的,所以 GC 线程已标记的结果对象的引用可能会被用户线程修改,从而导致漏标。该阶段的主要作用就是再标记那些漏标的对象。同时,该阶段还会对非强根(软引用、虚引用等)进行标记。

  • 4、并发转移准备:扫描所有 Region,根据特定的查询条件统计出本次 GC 需要清理的 Region,将这些 Region 组成重分配集 (Relocation Set)。

  • 5、初始转移:STW,转移 GC Roots 直接引用的对象。

  • 6、并发转移:将重分配集中存活的对象复制到新的 Region 中,并为重分配集中每个 Region 维护了一个转发表 (Forward Table),用来记录对象从旧地址到新地址的对应关系。并发转移阶段 GC 线程和用户线程是并发执行的,若某个活跃对象被 GC 线程移动到新的 Region 中,但该对象的引用的地址还未更新,此时,若用户线程访问该对象,则会访问到旧地址上,这样就发生了错误。ZGC 通过转发表和内存屏障解决了这个问题。当用户线程访问某个处于转发表中的对象时,就会被预置的内存屏障截获,然后根据转发表中的对应关系,将访问转到该对象的新地址上,同时修改该引用的值(ZGC 将这种行为称为 指针自愈能力 Self Healing)。ZGC 又是如何知道对象是否处于转发表中的呢,这和引用的地址有关,且其是由 颜色指针读屏障 实现的。

    注:因为指针自愈能力的存在,所以被记录在转发表中的对象只有在第一次访问时才会较慢。且一旦重分配集中的某个 Region 中的存活对象都被转移了,那这个 Region 就可以立即释放用于新对象的分配,但这个转发表还不能释放,因为其记录的部分对象的还未被访问。
    
  • 7、并发重映射:该阶段的作用就是修正指向被转发表记录的对象的引用。因为转发表中记录的对象都被移动了,所以需要更新对象的引用。该阶段和指针自愈做的是同一件事,区别是指针自愈是被动的,即只有当对象被访问时才会修正,而并发重映射是主动的。因为有指针自愈的存在,所以该阶段的优先级并不高,且因为该阶段也需要遍历所有对象,所以 ZGC 将其放在了下一次 GC 时的并发标记阶段(并发标记阶段需要遍历所有对象),这样就节省了一次遍历对象图的开销。记录在转发表中的对象的引用部分会在并发转移阶段被指针自愈修正,剩下的则会在下一次 GC 的并发标记阶段修正,所以在并发标记执行结束时,上一次 GC 所产生记录在转发表中的对象的引用都会被修正,转发表也会被释放,同时也意味着上一次 GC 需要清理的 Region 也会被释放。

6.5.3.4 颜色指针

走近科学之《JVM 的秘密》_第12张图片

  如上图所示,为 ZGC 颜色指针示意图。历史版本的垃圾回收器都是将 GC 信息保存在对象头中,而 ZGC 则是将 GC 信息保存在对象的指针中。这是 ZGC 的核心技术之一,同时也是其低延迟的重要原因之一。

  颜色指针共有 64 位,故 ZGC 只支持 64 位操作系统。其各位用途如下:

  • 0 ~ 41:用来存放对象在内存中的地址,即 Object address。占 42 位,故 ZGC 只支持 4TB (2^42) 的堆(在 jdk 16 中可支持的堆大小达到 16TB)。
  • 42 ~ 45:
    • 42、43:用来标记存活对象,即 Marked0、Marked1。二者交替使用,如本次 GC 使用 M0 标记,则下一次 GC 使用 M1 标记。用两位来标记主要是为了区分两次 GC 的标记情况。
    • 44:预留。
    • 45:用来标记对象重映射(重定位),即 Remapped。若某个对象在某次 GC 中存活在来,随后会被移动到其它 Region 中,但此时用户线程持有的还是改对象就地址的引用,所以为了用户线程能够正确地访问到被移动的对象,需要对对象的引用与对象新地址进行重映射,重映射后对象的引用会指向对象新地址。如果对象指针处于该标记位,则说明该对象指针已经完成重映射,可以直接访问;若为处于该标记位,则需要进行指针修正。
  • 46 ~ 63:预留。
其中表示 GC 信息的三位 M0、M1、Remapped 是互斥的,即对象指针在某一时刻只能处于其中一个标记位,如要么是 100,要么是 010,要么是 001。实际的业务含义则为要么改对象第一次被标记,要么改对象第二次被标记,要么改对象已经完成重映射。

  颜色指针在 ZGC 各阶段中的轮转:

  • 对象创建:因为对象刚创建,所以其不需要重映射,也可以理解为已经重映射。此时指针处于 Remapped。
  • 初始标记:STW。标记 GC Roots 直接引用的对象,被标记的对象的指针会从 Remapped 设置为 M0,即初始标记结束后被标记的对象指针处于 M0。
  • 并发标记:根据初始标记的结果遍历对象图进行可达性分析,若可达则说明对象存活,将指针从 Remapped 设置为 M0。并发标记结束后,被标记的对象的指针处于 M0,处于 Remapped 的则视为垃圾。
  • 重新标记:STW。并发标记期间用户线程可能对已标记的对象的引用作出修改,会导致漏标,所以需要重新标记。其完成后对象指针若处于 M0 则说明对象活跃,若处于 Remapped 则说明对象为垃圾。
  • 并发转移准备:统计本次 GC 需要清理的 Region,不会对指针状态作出改变。
  • 初始转移:STW。转移被标记的 GC Roots 直接引用的对象。转移后对象指针仍处于 M0。
  • 并发转移:转移其它被标记的对象(存活的对象)。转移后对象指针处于 M0。此时若用户线程访问转移后的对象,则会触发指针自愈,被访问的对象指针将从 M0 设置为 Remapped。所以并发转移结束后,被转移对象(存活对象)的指针将处于两种情况:M0:对象转移后没被用户线程访问的;Remapped:对象转移后被用户线程访问过的。本次 GC 结束。
  • . . .
  • 初始标记(下次 GC 开始):STW。标记 GC Roots 直接引用的对象,被标记的对象的指针会从 Remapped 设置为 M1,即初始标记结束后被标记的对象指针处于 M1。
  • 并发标记:根据初始标记的结果遍历对象图进行可达性分析,若可达则说明对象存活,将指针从 Remapped 设置为 M1。在遍历对象图会访问对象,此时会将上一次被转移的且处于 M0 的对象重映射。所以并发标记结束后,被标记的对象的指针处于 M1,处于其它状态的则视为垃圾。
  • . . . 如此循环。

  G1 在大堆情况下停顿时间长是因为其筛选回收阶段是 STW 的,即其转移存活对象是 STW 的,所以其 STW 的时间受限要移动的对象的数量。究其原因是因为 G1 未能解决对象被移动后的重映射问题,所以其在移动对象期间需要 STW,确保移动期间无用户线程访问对象。

  ZGC 整个过程会出现三次 STW,分别是初始标记、重新标记、初始转移。其中初始标记与初始转移与 GC Roots 数量有关,而 GC Roots 数量相比而言又是很少的,所以停顿时间很短。重新标记所涉及到的对象很少,所以也很快。所以在 ZGC 解决了 G1 的痛点后,整个过程的 STW 很短了,且还与堆的大小或存活对象的数量无关。这也是其在大堆、超大堆环境下依旧能保持低延迟的原因。

6.5.3.5 触发时机

  ZGC 的触发时机有以下几种:

  • 基于预热规则:

    jvm 启动预热,如果从来都没发生过 GC,那么会在堆使用超过 10%、20%、30% 时分别触发一次。日志中的关键字为 Warmup。

  • 基于分配速率的自适用算法:

    最主要的触发方式,同时也是默认触发方式。其算法原理是 ZGC 会根据近期对象的分配速率和 GC 时间来,计算出内存使用何时达到阈值,以确定下次 GC 的时间。可通过参数 ZAllocationSpikeTolerance 来控制阈值大小,默认为 2,值越大越早触发 GC。日志中的关键字为 Allocation Rate。

  • 基于固定时间间隔:

    会在指定时间内触发。可通过参数 ZCollectionInterval 来设置时间间隔。

  • 主动触发:

    类似于基于固定时间间隔规则,但间隔时间不固定,是 ZGC 通过一定规则计算得出。如果已经设置基于固定时间间隔规则,那么可以通过参数 ZProactive 将主动触发关闭,以免 GC 频繁,影响服务可用性。

  • 阻塞内存分配请求触发:

    当垃圾来不及回收,堆内存快要占满时,部分线程会因此阻塞,此时会触发。但在实际使用当中要尽量避免这种情况的发生。日志中的关键字为 Allocation Stall。

  • 显示触发:

    以编程方式显示调用 System.gc() 触发。因为 ZGC 会统计垃圾回收信息从而调节垃圾回收过程,所以通过显示触发可能会导致部分功能不可用。G1 也是一样。日志中的关键字为 System.gc()。

  • 元空间分配触发:

    元空间内存不足时会触发,但一般无需关注。日志中的关键字为 Metadata GC Threshold。

6.5.3.6 ZGC 与 G1

  ZGC 是在 G1 存在大堆情况下延迟升高的背景下出现的,二者之间的区别是:

  • 内存模型:

    G1:堆内存分区,堆内存会被划分为大小相等(Region 大小为 2 的幂次方 MB)的区域,且存在分代概念。

    ZGC:堆内存分区,堆内存被划分为小、中、大三种类型的区域,不存在分代概念。

  • 算法类型:

    二者都在整体上采用标记整理算法,各个 Region 之间采用标记复制算法。

  • 转移(复制)阶段:

    G1:STW 转移。STW 时间取决于存活对象的多少。

    ZGC:并发转移。这是 ZGC 低延迟的主要原因。

  • 标记方式:

    G1:标记对象头,即在标记过程中,会将 GC 信息标记在对象头中,因此会访问对象,内存访问。

    ZGC:标记指针,即在标记过程中,通过更新颜色指针来表示 GC 信息,不会访问对象,且访问指针访问的是寄存器,要比访问内存更快。

  • STW:

    G1:G1 中的 STW 时间主要来自于初始标记、重新标记、筛选、回收阶段的回收中。其中初始标记阶段的 STW 与 GC Roots 数量成正比,重新标记阶段只发生变化的对象有关,筛选阶段只是计算待回收 Region 的回收成本,回收阶段涉及到对象转移,会比较耗时。所以 G1 的 STW 时间与存活对象的数量和其复杂度成正比。

    ZCG:ZGC 中的 STW 时间主要来自于初始标记、重新标记、初始转移阶段。其中初始标记、初始转移阶段的 STW 时间与 GC Roots 数量成正比,重新标记阶段的 STW 时间最多 1ms,超过 1ms 则进入并发标记阶段。所以 ZGC 中的 STW 时间与 GC Roots 数量成正比。不会随着堆大小或存活对象的数量而增加。

  • 关键技术:

    G1:三色标记法、卡表、记录表、读屏障、原始快照。

    ZGC:颜色指针、读屏障。

6.6 GC 与 Full GC

  不同的垃圾收集器在针对不同区域所发生的 GC 并不相同,但总得来说分为两中,分别是 Minor GC 和 Full GC。

  • SerialGC:

    新生代空间不足发生 Minor GC。

    老年代空间不足发生 Full GC。

  • ParallelGC:

    新生代空间不足发生 Minor GC。

    老年代空间不足发生 Full GC。

  • CMS:

    新生代空间不足发生 Minor GC。

    老年代空间不足,若垃圾产生的速度超过 CMS 垃圾收集的速度时,则会终止 CMS,选择 Full GC。

  • G1:

    新生代空间不足发生 Minor GC。

    老年代空间不足,若垃圾生产的速度超过 G1 垃圾收集的速度时,则会终止 G1,选择 Full GC。

6.7 触发 Full GC 的时机

  除直接调用 System.gc() 之外,触发 Full GC 执行的情况话有以下四种:

  • 老年代空间不足:
      当新生代对象进入老年代或为创建的大对象、大数组在老年代分配不到足够的空间时会触发 Full GC。当 Full GC 执行完后空间任然不足时会抛出 OOM:java heap space 异常。为避免以上两种情况引起的 Full GC,调优时应尽量做到对象在 MinorGC 时就被回收、让对象在新生代多存活一段时间、避免创建大对象、大数组。
  • 永久代空间不足:
      永久代中存放一些 class 信息,当系统中加载的类、反射的类和调用的方法过多时,会导致永久代空间不足,此时若没有配置 CMS GC 则会执行 Full GC,若经过 Full GC 内存任然不足则会抛出 OOM PermGen space 异常。为避免 Full GC 执行,可增大 Perm Gen 空间或采用 CMS GC 进行垃圾回收。
  • CMS GC 时的 promotion failed 和 concurrent mode failed:
      对于采用 CMS 进行老年代 GC 时要注意日志中是否出现 promotion failed 和 concurrent mode failed 这两种情况,出现这两种情况时会触发 Full GC。
      promotion failed 是在进行 MinorGC 时,To 区放不下,对象进入老年代,老年代也放不下造成的;concurrent mode failed 是在进行 CMS GC 时,又对象要进入老年代,而老年代放不下造成的。应对措施是增大 survivor 空间、增大老年代空间、调低触发并发 GC 的比率。
  • 统计得到的要晋身到老年代的对象的平均占用空间大于老年代的剩余空间:
      未避免新生代对象进入老年代导致老年代空间不足的情况,在进行 MinorGC 时会先做一个判断,如果之前统计得到的 MinorGC 晋身到老年代的平均大小大于老年代的剩余空间,则会直接触发 Full GC。

7 对象内存分配策略

  内存管理最终要解决的也就是内存分配和内存回收这两个问题。

  对于内存分配,通常是在 jvm 堆上分配的(随着虚拟机技术的发展,某些场景下也会在栈上进行分配),对象主要分配在堆内存的新生代的 Eden 区,少数会分配在来年代,如果开启了本地线程缓冲,则会按照线程,优先在 TLAB 上分配。总的来说,内存分配也不是百分百固定的,其细节取决于哪些垃圾收集器组合以及和虚拟机相关参数有关,尽管如此,但还是会遵守以下几种规则:

  • 对象优先在 Eden 区分配:
      多数情况下,对象都在堆内存的新生代的 Eden 区分配,当 Eden 区没有足够空间时,虚拟机会进行一次 MinorGC,若执行完 MinorGC 仍没有足够空间,则会启动分配担保机制直接在老年代分配。
  • 大对象直接在老年代分配:
      所谓的大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,因为会导致在还有内存空间的情况下触发 GC。正常情况下,创建的对象是在新生代的 Eden 区分配内存的,而新生代采用的是复制算法,若一个长期存活的大对象出生在新生代,则其会在新生代发生大量的内存复制。因此,大对象直接进入老年代。
  • 长期存活的对象进入老年代:
      大部分虚拟机都采用分代收集的思想来管理内存,那么在内存回收时就必须判断那些对象应该进入老年代。对此,虚拟机为每一个对象定义了一个年龄计数器,对象出生在 Eden 区,对象每通过一个 GC 的筛选年龄就 +1,当对象年龄大于 15 时,就会晋身到老年代。
  • 动态判断对象年龄:
      对于新生代的对象,如果 survivor 区相同年龄的所有对象的大小总和大于 survivor 区空间的一半,则大于或等于该年龄的对象将进入老年代。
  • 空间分配担保:
      每次进行 MinorGC 时,jvm 会计算 survivor 区晋身至老年代的对象的平均大小,如果这个值大于老年代剩余空间大小,则会直接触发 Full GC;若小于,则检查 HandlePromotionFailure 设置,若为 true,则进行 MinorGC,若为 false 则进行 Full GC。

8 JVM 调优

8.1 调优三要素

  可以将调优目标、调优基础、调优领域称为 jvm 调优三要素(PS:当然是我自己这么叫的):

  • 调优目标:

    一般情况下会将 低延迟高吞吐量 作为 jvm 调优的目的。

    低延迟的垃圾收集器有 CMS、G1、ZGC,高吞吐量的垃圾收集器有 ParallelGC,这些都是 HotSpot 虚拟机相关的垃圾收集器。

    还有一个貌似贼牛逼的 java 虚拟机 Zing,说是其 GC 可以达到 0 延迟、高吞吐量。

  • 调优基础:

    首先,你得了解相关的调优 jvm 参数,如堆大小调整、GC 设置相关的参数;

    其次,你得了解如果知道程序当前的状态,如堆栈情况、GC 情况等等,可以通过 jmap、jconsole、jvisualvm 等工具来查看;

    最后,调优跟应用程序代码、环境有关,需要通过剖析所有一切信息,准确定位后再作出响应调整。

  • 调优领域:

    调优通常调 内存、锁、CPU占用、io 等。

  总之,最好不要让程序触发 GC,言外之意,尽可能少的制造垃圾(手动滑稽)。

8.1 调优工具

  常用的调优工具分为两类,一类是 jdk 自带的,如位于 jdk bin 目录下的 jconsole 和 jvisualvm;第二类是第三方工具,如 MAT、GChisto。

  • jconsole:用于监控 jvm 中内存、线程和类等。
  • jvisualvm:全能分析工具,可分析内存快照、线程快照、程序死锁、内存变化、gc 变化等。
  • MAT:基于 eclipse 的内存分析工具,可分析 java heap,帮助查找内存泄漏、减少内存消耗。
  • GChisto:主要用于 gc 分析。
8.2 新生代和老年代
8.2.1 新生代调优

  首先针对新生代的调优,得先了解新生代的特点:

  • 所有 new 的对象都会优先在 TLAB 上分配内存(参考 第七节)。
  • 死亡对象的回收代价为零,因为大部分垃圾收集器在新生代都使用标记复制算法。
  • 大部分对象朝生夕死。Minor GC 大概 10ms,Major GC 大概 100ms,Full GC 一般耗时较长。

  根据新生代的特点,新生代调优时一般增大新生代的大小,但并不是越大越好,Oracle 官方建议新生代大小一般占堆空间的 四分之一 到 二分之一之间。同时,对追求吞吐量的服务来说,新生代空间小增一波吞吐量会明显提高,但增幅过大,吞吐量会有所下降。总的来说,可以下规则来调:

  • 新生代大小可调整至堆空间的 四分之一 到 二分之一 之间。
  • 新生代的大小可调整至 并发量 * 单次请求内存占用峰值 的大小。
  • 幸存区 Survious (To) 大小可调整至 当前活跃对象大小 + 要晋升的对象大小。(可通过 参数 -XX:+PrintTenuringDistribution 来查看对象的年龄,以此来估算当前活跃对象和要晋升对象的大小)。
  • 设置对象晋升年龄阈值,让长时间存活的对象尽早晋升,一次来减少新生代每次复制所耗时间。
8.2.2 老年代调优

  老年代嘛,空间越大越好,同时可通过参数设置 Full GC 的触发时机,让其提前发生,通过增加 Full GC 的频率来降低单次 Full GC 的耗时(当然是我自己设想的,可行度还有待商榷)。当然,也可通过设置老年代已用空间的占比,来设置 Full GC 的触发时机。

8.2 调优命令

  jdk 自带的监控和故障命令有 jps、jstat、jmap、jhat、jstack、jinfo。

  • jps:jvm process status,显示系统内所有虚拟机进程。
  • jstat:jvm statistics monitoring,用于监视虚拟机运行时的状态信息,可以显示出虚拟机进程中的类装载、内存、垃圾收集、jit 编译等运行数据。
  • jmap:jvm memory map,用于生成 heap dump 文件。
  • jhat:jvm heap analysis,与 jmap 搭配使用,用来分析 jmap 生成的 dump 文件。jhat 内置了一个 http/html 服务器,生成的 dump 分析结果可在浏览器中查看。
  • jstack:用于生成虚拟机当前线程快照。
  • jinfo:jvm configuration info,主要用来实时查看和调整虚拟机运行参数。
  • jconsole: 查看指定 java 进程的实时信息,包括堆内存、类加载、cpu 占用率、线程等信息。其为图形界面工具、可实时检测、且是多功能的。
  • jvisualvm: jvm 可视化工具。它它娘的啥都能看,可实时检测 jvm 的运行情况。可通过 堆dump 抓取某一时刻堆内存的详细信息,包括对象、对象数量等等。
8.3 调优参数
### 堆栈配置相关 ###
-Xms300m   # 初始化堆大小为 300m
-Xmx2g   # 堆内存最大为 2g
-Xmn400m   # 新生代大小为 400m
-Xss128k   # 每个线程的堆栈大小为 128k
-XX:MaxPermSize=16m   # 永久代大小为 16m
-XX:NewRatio=4   # 新生代与老年代比例为 1:4
-XX:SurvivorRatio=8   # 新生代的 Eden 区与 Survivor 区比例为 8:2
-XX:MaxTenuringThreshold=15   # 对象从新生代晋身到老年代的最大年龄 若超过 15 则进入老年代 若为 0 则对象不进入 survivor 区 直接进入老年代

### 垃圾回收相关 ###
-XX:+UseParNewGC   # 指定新生代使用 ParNew 垃圾收集器
-XX:+UseParallelGC   # 指定使用并行垃圾收集器
-XX:ParallelGCThreads=10   # 设计并行垃圾收集器的线程数
-XX:+UseConcMarkSweepGC   # 指定使用 CMS 垃圾收集器
-XX:CMSFullGCBeforeCompaction=5   # 执行 5 次 CMS GC 后对内存碎片进行整理压缩
-XX:+UseCMSCompactionAtFullGCCollection   # 开启对老年代内存碎片的压缩 可能会影响性能

### 打印 gc 相关 ###
-XX:+PrintGC   # 打印 gc 信息
-XX:+PrintGCDetails   # 打印 gc 详细信息
8.5 调优示例

  栈内存溢出诊断和堆内存溢出诊断。

  • 栈内存溢出诊断:

    当某个 java 程序占用 cpu 资源过高时,如何定位到具体的代码中?
    1、top:使用 top 命令获取到 cpu 占用过高的 java 进程号。
    2、ps:使用 ps 命令可以获取到占用 cpu 过高的 java 线程号。
    	加 H 参数可以打印相关信息;
    	加 -eo 参数可以打印指定的数据项,如 -eo pid, tid, %cpu 则表示打印出进程 id、线程 id、线程对 cpu 的占用情况;
    	加 grep 参数参数可以过滤输出结果,如 grep 1225 则表示只打印进程 1225 相关的信息。
    	如 ps H -eo pid,tid,%cpu | grep 1225 表示打印出进程 1225 中所有线程的 pid、tid、%cpu 这三项信息。
    3、jstack pid:使用 jstack pid 命令可以查看指定进程的所有线程的详细信息。
    	结果中包含了线程名、线程号、线程状态、代码行号以及死锁相关的数据等等。(注:此结果中的线程号为十六进制,第二步获取到的线程号为十进制)。
    	
    通过以上步骤则可从 cpu 占用过高的 java 程序定位到具体的源码中。
    
  • 堆内存溢出诊断:

    1、jps: 查看系统中所有的 java 进程号。
    2、jmap: 查看指定 java 进程某一时刻的堆内存信息。可通过 -heap pid 指定进程号。
    3、jconsole: 查看指定 java 进程的实时信息,包括堆内存、类加载、cpu 占用率、线程等信息。其为图形界面工具、可实时检测、且是多功能的。
    4、jvisualvm: jvm 可视化工具。它它娘的啥都能看,可实时检测 jvm 的运行情况。
    	可通过 堆dump 抓取某一时刻堆内存的详细信息,包括对象、对象数量等等。
    

@XGLLHZ - 陈奕迅 - 《明年今日》.mp3

你可能感兴趣的:(java,基础与中高级,jvm)