JVM性能调优【一】—— 理论篇

JVM性能调优【一】------ 理论篇

文章目录

1-1 JVM调优概述

  • 内存结构
  • 类加载机制
  • 编译器优化
  • 垃圾收集算法
  • 垃圾收集器

1-2 JVM内存结构详解

JVM内存结构

如图,堆和方法区是线程共享的,即指所有线程都会共享堆内存和方法区。虚拟机栈、本地方法栈、程序计数器都是线程独享的,每个线程都有自己独立的。

是JVM内存中最大的内存空间,绝大部分对象都是存储在堆内存里面的,堆内存又可以细分为以下几个部分

如图,JDK1.8开始,持久代被废弃了,由元空间代替,而元空间并不是堆内存的一部分,而是一块本地内存。

ps:持久代也叫永久代,新生代也叫年轻代

虚拟机栈是线程独享的,当创建一个线程的时候就会创建一个虚拟机栈,虚拟机栈由栈帧组成,每一次的方法调用都会创建一个栈帧,然后去压栈,当方法返回的时候则对应着栈帧的出栈操作,栈帧里面又存放则一系列的数据,具体见下图。

首先是局部变量表,方法里面的代码在执行的时候,会从局部变量表或者对象实例的字段里面复制变量或者常量,然后放入操作数栈中,当计算的时候会使用一系列指令往操作数栈中放入数据或者取出数据进行计算。可以认为,操作数栈是用来存放临时数据的地方。

此外,还有指向运行时常量池的引用、方法返回地址、动态链接等

本地方法栈和虚拟机栈功能时类似的,虚拟机栈管理Java方法,本地方法栈管理native方法。

程序计数器,用来记录各个线程之间的字节码的地址。像分支、循环、跳转、异常、线程恢复等操作都需要依赖程序计数器。

为啥需要程序计数器这块空间?因为Java是多线程的语言,当执行的线程数量超过cpu核心时,线程之间会根据时间片争抢cpu资源。

例如某个线程的任务还没有执行完,但是cpu资源被其它线程抢占,之后,又轮到这个线程执行,得知道从哪里继续执行,所以为每一个线程分配一个程序计数器用来记录下一条运行的指令是什么等等。

方法区也是多线程共享的,主要包括四部分,类信息、运行时常量池、字符串常量池、静态变量。方法区主要作用是存放虚拟机加载的类相关的信息。

方法区和堆之间是存在交集的,字符串常量池、静态变量存储在堆里面;类信息、运行时常量池存储在元空间里面

常量池-静态常量池

  • 也叫class文件常量池,主要存放:
    • 字面量:例如文本字符串、final修饰的常量
    • 符号引用:例如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符

常量池-运行时常量池

  • 当类加载到内存中后,JVM就会将静态常量池中的内容存放到运行时常量池中;运行时常量池里面存储的主要是编译期间生成的字面量、符号引用等

常量池-字符串常量池

  • 字符串常量池,也可以理解成运行时常量池分出来的一部分,类加载到内存的时候,字符串会存到字符串常量池里面

注意点

  • 图中是JDK8以及之后版本的内存分布。事实上,不同JDK发行版,甚至JDK版本实现上是有差异的。比如:比较熟悉的oracle jdk是一个HotSpot虚拟机。HotSpot虚拟机在jdk1.7之前,使用永久代来实现方法区。但是其它的虚拟机,比如BEA JRockitIBM J9虚拟机不存在永久代的概念。

  • HotSpot虚拟机到了jdk1.7时,在原来的基础上做了一些改进,把字符串常量池移动到堆里面去了,而符号引用则被放到本地内存里面去了。

  • 之后到了jdk1.8,HotSpot虚拟机直接把永久代给干掉了,用元空间取而代之。

  • 之所以用空间替代永久代,主要有两方面原因

    • 一方面,oracle把HotSpot虚拟机和JRockit虚拟机都收购了。而上面提到JRockit虚拟机没有永久代的概念,为了融合两个虚拟机,就把永久代去掉了。
    • 另一方面,永久代在使用的过程中还是很容易发生问题的,具体见下持久代异常

持久代异常

  • java.lang.OutOfMemoryError: PermGen
    • 我们知道,jdk1.8之前使用持久代实现方法区,而方法区存储的主要是一系列的常量、类以及方法的相关信息。那么这块区域比较难以准确的确定大小。因为每个项目所加载的类、方法都是不一样的。持久代设置的太小就会容易导致这块的内存溢出,设置太大导致内存浪费。于是把持久代去掉,用元空间代替。
    • 元空间代替的好处:元空间是一块本地内存,理论上取决于操作系统可以分配的内存大小,这样就解决了持久代空间难以分配的问题,具体可以看官网这篇文章:https://openjdk.org/jeps/122

案例

  • 案例看看java是如何分配存储的

java 复制代码
package com.imooc.jvm;

public class JVMTest1 {

    public static void main(String[] args) {
        Demo demo = new Demo("aaa");
        demo.printName();
    }

}

class Demo {
    private String name;

    public Demo(String name) {
        this.name = name;
    }

    public void printName() {
        System.out.println(this.name);
    }
}
  • 以上代码的内存分布:

    在启动时,首先将类加载到方法区,这里需要加载两个类,一个是JVMTest1,一个是Demo。当执行Demo demo = new Demo("aaa");首先会创建一个局部变量-demo放入栈里面,这个demo会指向一个引用,真正的demo对象会存放在堆里面,最后再执行printName()方法。

    当然,图中并未画栈针的细节,事实上,执行每一个方法时都会有一个压栈和出栈的操作,即调用printName()方法时会创建一个栈针压到栈中, 当这个方法返回时栈针会从栈里面弹出。

1-3 类加载过程详解

类加载过程详解

我们知道编写的java代码是不可以直接运行的,需要编译成class类,这个编译只需要jdk内置的javac命令即可

java 复制代码
package com.imooc.jvm;

public class JVMTest2 {

    private static final String CONST_FIELD = "AAA";
    private static String staticField;
    private String field;

    public String add() {
        return staticField + field + CONST_FIELD;
    }

    public static void main(String[] args) {
        new JVMTest2().add();
    }

}

例如:使用命令javac JVMTest2.java即可获取一个class文件,当然在实际项目中一般不会人工用javac命令编译,而是借助编译器像idea、maven等工具帮助我们编译

编译出来的class文件并不是一个文本文件,无法直接打开阅读,直接打开会发现一偏乱码

如果想要阅读的话,可以使用javap -v -p JVMTest2 > 1.txt反编译并输出到文本文件中,可以使用idea打开

tex 复制代码
// 描述信息
// class文件位置
Classfile /D:/IdeaProjects/jgs/jvm-demo/src/test/java/com/imooc/jvm/JVMTest2.class
  // 最近修改时间
  Last modified 2023-7-11; size 657 bytes
  // MD5值
  MD5 checksum 9c030f5d199a2ebb66d2aa9c5e999f62
  // 从哪个java类里面编译出来的
  Compiled from "JVMTest2.java"
// 描述信息
public class com.imooc.jvm.JVMTest2
  minor version: 0
  // jdk版本,52表示jdk8
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 常量池
Constant pool:
   #1 = Methodref          #12.#28        // java/lang/Object."<init>":()V
   #2 = Class              #29            // java/lang/StringBuilder
   #3 = Methodref          #2.#28         // java/lang/StringBuilder."<init>":()V
   #4 = Fieldref           #7.#30         // com/imooc/jvm/JVMTest2.staticField:Ljava/lang/String;
   #5 = Methodref          #2.#31         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #6 = Fieldref           #7.#32         // com/imooc/jvm/JVMTest2.field:Ljava/lang/String;
   #7 = Class              #33            // com/imooc/jvm/JVMTest2
   #8 = String             #34            // AAA
   #9 = Methodref          #2.#35         // java/lang/StringBuilder.toString:()Ljava/lang/String;
  #10 = Methodref          #7.#28         // com/imooc/jvm/JVMTest2."<init>":()V
  #11 = Methodref          #7.#36         // com/imooc/jvm/JVMTest2.add:()Ljava/lang/String;
  #12 = Class              #37            // java/lang/Object
  #13 = Utf8               CONST_FIELD
  #14 = Utf8               Ljava/lang/String;
  #15 = Utf8               ConstantValue
  #16 = Utf8               staticField
  #17 = Utf8               field
  #18 = Utf8               <init>
  #19 = Utf8               ()V
  #20 = Utf8               Code
  #21 = Utf8               LineNumberTable
  #22 = Utf8               add
  #23 = Utf8               ()Ljava/lang/String;
  #24 = Utf8               main
  #25 = Utf8               ([Ljava/lang/String;)V
  #26 = Utf8               SourceFile
  #27 = Utf8               JVMTest2.java
  #28 = NameAndType        #18:#19        // "<init>":()V
  #29 = Utf8               java/lang/StringBuilder
  #30 = NameAndType        #16:#14        // staticField:Ljava/lang/String;
  #31 = NameAndType        #38:#39        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #32 = NameAndType        #17:#14        // field:Ljava/lang/String;
  #33 = Utf8               com/imooc/jvm/JVMTest2
  #34 = Utf8               AAA
  #35 = NameAndType        #40:#23        // toString:()Ljava/lang/String;
  #36 = NameAndType        #22:#23        // add:()Ljava/lang/String;
  #37 = Utf8               java/lang/Object
  #38 = Utf8               append
  #39 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #40 = Utf8               toString
{
  // 字段信息
  private static final java.lang.String CONST_FIELD;
    descriptor: Ljava/lang/String;
    flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL
    ConstantValue: String AAA

  private static java.lang.String staticField;
    descriptor: Ljava/lang/String;
    flags: ACC_PRIVATE, ACC_STATIC

  private java.lang.String field;
    descriptor: Ljava/lang/String;
    flags: ACC_PRIVATE

  // 方法信息
  public com.imooc.jvm.JVMTest2();
    // 方法描述符
    descriptor: ()V
    // 方法访问权限
    flags: ACC_PUBLIC
    // 一些指令
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public java.lang.String add();
    descriptor: ()Ljava/lang/String;
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: new           #2                  // class java/lang/StringBuilder
         3: dup
         4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
         7: getstatic     #4                  // Field staticField:Ljava/lang/String;
        10: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        13: aload_0
        14: getfield      #6                  // Field field:Ljava/lang/String;
        17: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        20: ldc           #8                  // String AAA
        22: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        25: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        28: areturn
      LineNumberTable:
        line 10: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: new           #7                  // class com/imooc/jvm/JVMTest2
         3: dup
         4: invokespecial #10                 // Method "<init>":()V
         7: invokevirtual #11                 // Method add:()Ljava/lang/String;
        10: pop
        11: return
      LineNumberTable:
        line 14: 0
        line 15: 11
}
SourceFile: "JVMTest2.java"

实际上在整个1.txt文件中有各种指令,例如dup、invokespecial、getstatic等,可以参照https://docs.oracle.com/javase/specs/jvms/se8/html/index.html这篇文章的指令表查看含义

那么,jvm是怎样加载class文件的呢?

加载

当一个类被创建实例,或者被引用到时,如果虚拟机发现之前没有加载过这个类,就会通过类加载器即ClassLoader把class文件加载到内存,在加载的过程中,需要做以下三件事

  • 读取类的二进制流
  • 把二进制流转化为方法区的数据结构,并存放到方法区
  • 在Java堆中产生java.lang.Class对象

加载完成后,又会进入链接的步骤,链接的步骤又被细分为验证、准备、解析

链接-验证-1

  • 作用:验证class文件是不是符合规范

  • 文件格式的验证

    • 是否以0xCAFEBABE开头

      • 可以采用十六进制编辑器打开查看,比如beyond Compare.app
      • 例如:
    • 版本号是否合理

链接-验证-2

  • 元数据验证
    • 是否有父类
    • 是否继承了final类(final类不能被继承,如果继承了就有问题)
    • 非抽象类实现了所有抽象方法

链接-验证-3

  • 字节码验证:非常复杂,一个class文件能够通过字节码验证并不代表这个class文件没有问题,但是未通过那么必然是有问题的
    • 运行检查
    • 栈数据类型和操作码操作参数吻合(比如栈空间只有2字节,但其实却需要大于2字节,此时就认为这个字节码是有问题的)
    • 跳转指令指向合理的位置

链接-验证-4

  • 符号引用验证
    • 常量池中描述类是否存在
    • 访问的方法或字段是否存在且有足够的权限

链接-验证-总结

如果事先已经确认代码是安全无误的,可以在idea启动的时候添加-Xverify:none关闭验证,从而加快idea的启动速度

可以发现验证的步骤细节非常多,不用过分关注里面的细节,只要理解为校验Java类是否是正常的步骤即可

如果验证无误,就会进入准备环节

链接-准备

  • 作用:为类的静态变量分配内存,初始化为系统的初始值

    • final static修饰的变量:直接赋值为用户定义的值,比如private final static int value = 123,直接赋值为123

    • private static int value = 123,该阶段的值依然是0

准备完成之后,进入解析

链接-解析

  • 作用:符号引用转换成直接引用

    • 符号引用是指在编译器Java类还不知道所引用的对象的实际地址,所以只能使用符号表示想要引用谁

    tex 复制代码
    // 常量池
    Constant pool:
       #1 = Methodref          #12.#28        // java/lang/Object."<init>":()V
       #2 = Class              #29            // java/lang/StringBuilder
       #3 = Methodref          #2.#28         // java/lang/StringBuilder."<init>":()V
       #4 = Fieldref           #7.#30         // com/imooc/jvm/JVMTest2.staticField:Ljava/lang/String;
       #5 = Methodref          #2.#31         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
       #6 = Fieldref           #7.#32         // com/imooc/jvm/JVMTest2.field:Ljava/lang/String;
       #7 = Class              #33            // com/imooc/jvm/JVMTest2
       #8 = String             #34            // AAA
       #9 = Methodref          #2.#35         // java/lang/StringBuilder.toString:()Ljava/lang/String;
      #10 = Methodref          #7.#28         // com/imooc/jvm/JVMTest2."<init>":()V
      #11 = Methodref          #7.#36         // com/imooc/jvm/JVMTest2.add:()Ljava/lang/String;
      #12 = Class              #37            // java/lang/Object
    • 如以上1.txt文件中存储的这些像Methodref(方法引用)、Fieldref(成员变量引用)都是符号引用,要想真正引用到类、方法、变量,就需要把这些符号转换为能够找到的对象指针,或者地址偏移量。转换之后的引用就是直接引用

初始化-1

  • 执行<clinit>方法,<clinit>方法由编译器自动收集类里面的所有静态变量的赋值动作及静态语句块合并而成,也叫类构造器方法

    • 初始化的顺序和源文件中的顺序一致

      java 复制代码
      package com.imooc.jvm;
      
      public class JVMTest3 {
      
          static int i = 0;
      
          static {
              i = 1;
          }
      
          public static void main(String[] args) {
              System.out.println(i); // 1
          }
      
      }
      java 复制代码
      package com.imooc.jvm;
      
      public class JVMTest3 {
      
          static {
              i = 1;
          }
      
          static int i = 0;
      
          public static void main(String[] args) {
              System.out.println(i); // 0
          }
      
      }
    • 子类的<clinit>被调用前,会先调用父类的<clinit>

    • JVM会保证<clinit>方法的线程安全性

初始化-2

  • 初始化时,如果实例化一个新对象,会调用<init>方法对实例变量进行初始化,并执行对应的构造方法内的代码
例子-1
java 复制代码
package com.imooc.jvm;

public class JVMTest4 {

    static {
        System.out.println("JVMTest4静态代码块");
    }

    {
        System.out.println("JVMTest4构造代码块");
    }

    public JVMTest4() {
        System.out.println("JVMTest4构造方法");
    }

    public static void main(String[] args) {
        System.out.println("main");
        new JVMTest4();
    }

}
  • 输出结果

    tex 复制代码
    JVMTest4静态代码块
    main
    JVMTest4构造代码块
    JVMTest4构造方法
例子-2
java 复制代码
package com.imooc.jvm;

public class JVMTest5 {

    static {
        System.out.println("JVMTest5静态代码块");
    }

    {
        System.out.println("JVMTest5构造代码块");
    }

    public JVMTest5() {
        System.out.println("JVMTest5构造方法");
    }

    public static void main(String[] args) {
        new Sub();
    }

}

class Super {
    static {
        System.out.println("Super静态代码块");
    }

    {
        System.out.println("Super构造代码块");
    }

    public Super() {
        System.out.println("Super构造方法");
    }
}

class Sub extends Super {
    static {
        System.out.println("Sub静态代码块");
    }

    {
        System.out.println("Sub构造代码块");
    }

    public Sub() {
        System.out.println("Sub构造方法");
    }
}
  • 输出结果

    tex 复制代码
    JVMTest5静态代码块
    Super静态代码块
    Sub静态代码块
    Super构造代码块
    Super构造方法
    Sub构造代码块
    Sub构造方法
例子-总结

未调用JVMTest5构造代码块和JVMTest5构造方法是因为没有new JVMTest5();

new Sub();会先Super静态代码块再调用Sub静态代码块,因为子类的<clinit>被调用前,会先调用父类的<clinit>

构造代码块会先于构造方法

使用和卸载

初始化完成后,就可以使用这个类了。当不使用该类,就卸载

总结

以上类加载图只是一个常规的类加载图,事实上,类加载并不完全按照这个流程,比如解析不一定在初始化之前,也可能在初始化之后

1-4 编译器优化机制详解

字节码是如何运行的?

  • 解释执行:由解释器一行一行翻译执行

  • 编译执行:把字节码编译成机器码,直接执行机器码

解释 vs 编译

  • 解释执行
    • 优势在于没有编译的等待时间
    • 性能相对差一些
  • 编译执行
    • 运行效率会高很多,一般认为比解释执行快一个数量级
    • 带来的额外的开销,比如额外的内存开销、CPU开销等

查询运行模式

  • java -version

  • -Xint:设置JVM的执行模式为解释执行模式

    • 如果想让springboot的jar以解释执行模式执行java -Xint -jar xxx.jar
  • -Xcomp:JVM优先以编译模式运行,不能编译的,以解释模式运行

  • -Xmixed:混合模式运行(默认)

mixed mode:表示JVM以混合模式运行,也就是部分代码解释执行、部分代码编译执行

interpreted mode:表示JVM以解释执行模式运行

compiled mode:表示JVM优先以编译模式运行,不能编译的,以解释模式运行

一般情况下

  • 一开始一般由解释器解释执行
  • 当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会认为这些代码是"热点代码 "。为了提高热点代码的执行效率,会用**即时编译器(也就是JIT)**把这些热点代码编译成与本地平台相关的机器码,并进行各层次的优化
    • 本地平台有多种层次的含义:比如操作系统的不同,linux操作系统和windows操作系统平台是不一样的;比如CPU架构的不同,x86CPU架构和armCPU架构也被认为平台不一样

Hotspot的即时编译器-C1

  • C1编译器:
    • 是一个简单快速的编译器
    • 主要关注局部性的优化
    • 适用于执行时间较短或对启动性能有要求的程序。例如:GUI(即带有界面的应用程序,如idea)应用对界面启动速度就有一定要求
    • 也被称为是Client Compiler

Hotspot的即时编译器-C2

  • C2编译器:
    • 是为长期运行的服务器端应用程序做性能调优的编译器
    • 适用于执行时间较长或对峰值性能有要求的程序
    • 也被称为是Server Compiler
    • 例如:springboot程序是长期运行的服务器端的应用程序,就比较适合用C2编译器执行

分层编译

  • jdk7开始引入分层编译概念,可以细分为5种编译级别

  • 0:解释执行

  • 1:简单C1编译:会用C1编译器进行一些简单的优化,不开启Profiling

    • Profiling:jvm的性能监控
  • 2:受限的C1编译:仅执行带方法调用次数 以及循环回边执行次数Profiling的C1编译

  • 3:完全C1编译:会执行带有所有Profiling的C1代码

  • 4:C2编译:使用C2编译器进行优化,该级别会启用一些编译耗时较长的优化,一些情况下会根据性能监控信息进行一些非常激进的性能优化

  • 级别越高,应用启动越慢,优化的开销越高,峰值性能也越高

分层编译-JVM参数配置示例

  • 默认情况下,jdk8是开启分层编译的
  • 只想开启C2,不想开启C1:-XX:TieredCompilation(禁用中间编译层(123层))
  • 只想开启C1,不想开启C2:-XX:TieredCompilation -XX:TieredStopAtLevel=1
    • 例如:-XX:TieredCompilation -XX:TieredStopAtLevel=3表示开启分层编译,同时使用0123编译层,不会使用编译层

如何找到热点代码?思路是?

  • 基于采样的热点探测
    • 周期检查各个线程的栈顶,如果发现某些方法老是出现在栈顶,说明这个方法是热点方法。
    • 每一个线程都会有自己独立的栈,进入一个方法时会往栈里面压入一个元素,并且放在顶部,如果经过周期性的检查,发现某些方法老是出现在栈顶,说明很多线程老是执行该方法,说明这个方法是热点方法。
  • 基于计数器的热点探测
    • 采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是"热点方法"。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。
    • HotSpot虚拟机中使用的是这种:基于计数器的热点探测方法,它为每个方法准备了两个计数器:方法调用计数器和回边计数器。

HotSpot内置的两类计数器

  • 方法调用计数器(Invocation Counter)
    • 方法调用计数器用来统计方法调用的次数,在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。
    • 在不开启分层编译的情况下,在C1编译器下的默认阈值是1500次,在C2模式下是10000次。也可以用-XX:CompileThreshold=X指定阈值。达到这个阈值就说明是热点方法
  • 回边计数器(Back Edge Counter)
    • 用于统计一个方法中循环体代码执行的次数(准确地说,应该是回边的次数,因为并非所有的循环都是回边),在字节码中遇到控制流向后跳转的指令就称为"回边"。
    • 在不开启分层编译的情况下,在C1编译器下的默认阈值是13995次,在C2模式下是10700次。也可以用-XX:OnStackReplacePercentage=X指定阈值。达到这个阈值就说明是热点方法
    • 建立回边计数器的主要目的是为了触发OSR(On StackReplacement)编译,参考文档:https://www.zhihu.com/question/45910849/answer/100636125
      • OSR(On StackReplacement)编译是一种在运行时替换正在运行的函数/方法的栈帧的技术
  • 当开启分层编译时,JVM会根据当前待编译的方法数以及编译线程数来动态调整阈值,-XX:CompileThreshold=、-XX:OnStackReplacePercentage都会失效

方法调用计数器流程

如图,描述了方法调用计数器去控制是否要编译运行的流程。图中两计数器是指方法调用计数器和回边计数器,因为默认情况下是开启分层编译的,所以这个阈值是动态调整的。向编译器提交编译请求这里的编译器可能是C1或者C2。提交编译请求完成后,执行引擎并不会同步等待编译请求完成,而是继续以解释执行的方式运行代码。当编译完成后,下次调用该方法,就会使用编译后的代码执行了。

方法调用计数器

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器的热度衰减 ,而这段时间(即这一定的时间限度)就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX: -UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,总有一天会超过阈值,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器流程

如图,描述了回边计数器去控制是否要编译运行的流程。

总结

  • 解释运行和编译运行
  • JVM内置的两款即时编译器
  • 分层编译
  • 如何找到热点代码

出现参数总结

1-5 方法内联讲解

前面已经介绍jvm用编译的方式为代码提速,在这个基础上,即时编译器还做了很多措施,进一步提升性能

编译器优化之一------方法内联

java 复制代码
package com.imooc.jvm.inline;

public class InlineTest1 {

    private static int add1(int x1, int x2, int x3, int x4) {
        return add2(x1, x2) + add2(x3, x4);
    }

    private static int add2(int x1, int x2) {
        return x1 + x2;
    }

}

如上,编译器如何优化上述方法呢?

我们知道,进入方法时,会向栈里面压入一个元素,return的时候会向栈中弹出一个元素。而压栈和出栈操作都是有开销的,压栈会存在内存的开销,同时压栈和出栈都存在时间的开销。如果以上代码调用次数不多,那么这段开销就无所谓了;但是如果这段代码调用非常频繁,20000次/s,那么累积下来的开销还是很大的。此时,有什么办法优化呢?

jvm会自动的识别热点方法并进行方法内联!

方法内联:把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用

以上代码经过方法内联后,就变成

java 复制代码
// 方法内联后
private static int addInline(int x1, int x2, int x3, int x4) {
    return x1 + x2 + x3 + x4;
}

也就是jvm会自动把add1方法和add2方法合并到一起去,从而减少压栈和出栈的操作

需要注意的是,使用方法内联是有条件的,如下:

方法内联的条件

  • 方法体足够小
    • 热点方法:如果方法体小于325字节会尝试内联,可用-XX:FreqInlineSize修改大小
    • 非热点方法:如果方法体小于35字节会尝试内联,可用-XX:MaxqInlineSize修改大小
  • 被调用的方法运行时的实现被可以唯一确定
    • static方法、private方法以及final方法,JIT可以唯一确定具体的实现代码
    • public的实例方法,指向的实现可能是自身、父类、子类的代码,当且仅当JIT能够唯一确定方法的具体实现时,才可能完成内联

通过方法内联的条件,可以总结以下几点方法内联注意点

方法内联注意点

  • 尽量让方法体小一些
  • 尽量使用final、private、static关键字修饰方法,避免因为多态,需要对方法做额外的检查,因为可能检查之后无法唯一确定方法的具体实现,从而无法内联
  • 一些场景下,可通过JVM参数修改热点代码阈值或者修改方法体大小的阈值,从而让更多的方法内联

内联可能带来的问题

首先,方法内联不是万能药,也是有缺点的,内联本质上就是空间换时间的玩法,也就是即时编译器在编译期间把方法调用连接起来从而减少压栈和出栈的开销。但是,经过内联之后的代码会变多,而增加的代码量又取决于方法的调用次数以及方法本身的大小,在一些极端场景下,内联甚至会导致以下问题

  • CodeCache的溢出,导致JVM退化成解释执行模式
    • CodeCache是热点代码的缓存区,即时编译器编译后的代码以及本地方法代码都会存放到CodeCache里面,这段空间是比较有限的,JDK8默认情况下只有240MB。这块空间一旦溢出,甚至会导致JVM编译运行,退化成解释执行模式

内联相关JVM参数

实验

java 复制代码
package com.imooc.jvm.inline;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Random;

public class InlineTest2 {

    private static final Logger LOGGER = LoggerFactory.getLogger(InlineTest2.class);

    public static void main(String[] args) {
        long cost = compute();
        LOGGER.info("执行花费了{}ms", cost);
    }

    private static long compute() {
        long startTime = System.currentTimeMillis();
        int result = 0;
        Random random = new Random();
        for (int i = 0; i < 10000000; i++) {
            int a = random.nextInt();
            int b = random.nextInt();
            int c = random.nextInt();
            int d = random.nextInt();
            result = add1(a, b, c, d);
        }
        long endTime = System.currentTimeMillis();
        return endTime - startTime;
    }

    private static int add1(int x1, int x2, int x3, int x4) {
        return add2(x1, x2) + add2(x3, x4);
    }

    private static int add2(int x1, int x2) {
        return x1 + x2;
    }

}

idea启动添加参数,如下

部分运行结果如下

tex 复制代码
...
@ 56   com.imooc.jvm.inline.InlineTest2::add1 (12 bytes)   inline (hot)
@ 2   com.imooc.jvm.inline.InlineTest2::add2 (4 bytes)   inline (hot)
@ 7   com.imooc.jvm.inline.InlineTest2::add2 (4 bytes)   inline (hot)
...
14:53:40.501 [main] INFO com.imooc.jvm.inline.InlineTest2 - 执行花费了312ms

可以看到方法内联了,而且方法体大小也打印出来了,且执行花费312ms。

设置参数使得方法不内联,对比差异。

设置热点方法的字节码超过1bytes,则无法内联。

部分运行结果如下

tex 复制代码
...
@ 56   com.imooc.jvm.inline.InlineTest2::add1 (12 bytes)
@ 2   com.imooc.jvm.inline.InlineTest2::add2 (4 bytes)
@ 7   com.imooc.jvm.inline.InlineTest2::add2 (4 bytes)
...
15:02:04.680 [main] INFO com.imooc.jvm.inline.InlineTest2 - 执行花费了384ms

可以看到方法不内联时,执行花费了384ms

总结

通过以上实验,不难发现,如果方法频繁的调用,内联对性能的影响还是比较可观的。但是,在实际项目中,不建议随意配置这些jvm参数,正常情况下,使用默认值即可,因为现在jvm已经非常智能了,一般来说,可以忽略掉这些细节,把优化的操作交给jvm完成。但是项目出现性能瓶颈的时候,要知道有种调优机制叫内联,并且要有能力进行相关的调优。

1-6 逃逸分析、标量替换、栈上分配

逃逸分析

  • 分析变量能否逃出它的作用域
  • 细分为四种场景
    • 全局变量赋值逃逸
    • 方法返回值逃逸
    • 实例引用逃逸
    • 线程逃逸
      • 赋值给类变量或可以在其他线程中访问的实例变量

例子说明:

java 复制代码
package com.imooc.jvm.escape;

public class EscapeTest1 {

    public static SomeClass someClass;

    // 全局变量赋值逃逸
    public void globalVariablePointerEscape() {
        // 局部变量赋值给静态变量,局部变量的作用域是在方法内,静态变量的作用域是在类里面,作用域放大了,发生了逃逸
        someClass = new SomeClass();
    }

    // 方法返回值逃逸
    // methodPointerEscape()方法返回了一个对象,这个对象的作用域一开始在methodPointerEscape()方法内部,但是作为返回值返回了,另外一个方法someMethod()方法调用了methodPointerEscape()方法
    // 所以new SomeClass();这个变量的作用域从methodPointerEscape()方法扩张到someMethod()方法,发生了逃逸
//    someMethod() {
//        SomeClass someClass = methodPointerEscape();
//    }
    public SomeClass methodPointerEscape() {
        return new SomeClass();
    }

    // 实例引用逃逸
    // instancePassPointerEscape()方法中this的作用域原先在当前实例下,但是现在扩张到SomeClass的实例下,发生了逃逸
    public void instancePassPointerEscape() {
        this.methodPointerEscape().printClassName(this);
    }

}

class SomeClass {

    public void printClassName(EscapeTest1 escapeTest1) {
        System.out.println(escapeTest1.getClass().getName());
    }

}

jvm在做逃逸分析的时候,会针对这些场景进行分析,分析完成后,会为对象做一个逃逸状态标记,一个对象主要有三种逃逸状态标记

逃逸状态标记

  • 全局级别逃逸:一个对象可能从方法或者当前线程中逃逸,也就是说,其他的方法或者线程也能访问到这个对象
    • 对象被作为方法的返回值
    • 对象作为静态字段(static field)或者成员变量(field)
    • 如果重写了某个类的finalize()方法,那么这个类的对象都会被标记为全局逃逸状态并且一定会放到堆内存中
  • 参数级别逃逸
    • 对象被作为参数传递给一个方法,但是在这个方法之外无法访问/对其他线程不可见
  • 无逃逸:一个对象不会逃逸

标量和聚合量

  • 标量:不能被进一步分解的量
    • 基础数据类型
    • 对象引用
  • 聚合量:可以进一步分解的量
    • 比如:字符串,因为字符串是用字节数组实现的,可以分解
    • 再比如:我们自定义的变量

标量替换

  • 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是创建它的成员变量来代替

java 复制代码
package com.imooc.jvm.scalar;

public class ScalarTest1 {

    public void someTest() {
        SomeTest someTest = new SomeTest();
        someTest.id = 1;
        someTest.age = 1;

        // 如果开启了标量替换,并不会创建SomeTest实例,而是创建SomeTest的成员变量代替,这样把对象进行标量替换后,原本的对象就不需要分配内存空间了
        // 即开启变量替换之后
        int id = 1;
        int age = 1;
    }

}

class SomeTest {

    // 基础数据类型,是标量
    int id;

    // 基础数据类型,是标量
    int age;

}
  • -XX:+EliminateAllocations开启标量替换(JDK8默认开启)

栈上分配

我们知道在java里面,绝大多数对象都是存放在堆里面的,对象不用时,靠垃圾回收器回收对象

  • 通过逃逸分析,能够确认对象不会被外部访问,就在栈上分配对象
    • 在栈上分配对象,这个对象占用的空间就会在栈帧出栈的时候被销毁掉,可以降低垃圾回收的压力

总结

  • 逃逸分析
  • 标量替换
  • 栈上分配

相关JVM参数

ps基于逃逸分析可以实现锁消除,锁消除也可以认为是即时编译器的一种优化机制

1-7 垃圾回收【重中之重】开篇

前景

我们知道,在开发Java程序时,Java程序员一般是不需要关注对象的回收的,而是又Java的垃圾回收机制帮助我们自动地回收掉没用地对象。但是,JVM提供了多种垃圾回收算法和多种垃圾回收策略,不同的垃圾回收算法和垃圾回收策略有不同的使用场景。如果在项目中使用了不合适的垃圾回收算法或者垃圾回收策略,那么系统的性能将会很难达到最优。在某些业务场景下,不合适的垃圾回收算法或者垃圾回收策略甚至会导致性能地大幅下降,所以垃圾回收的重要性是毋庸置疑的。

探讨话题

  • 什么场景下该使用什么垃圾回收策略?
  • 垃圾回收发生在哪些区域?
  • 对象在什么时候能够被回收?

什么场景下该使用什么垃圾回收策略?

  • 在对内存要求苛刻的场景:想办法提高对象的回收效率,多回收掉一些对象,腾出更多内存
  • 在CPU使用率高的情况下:降低高并发时垃圾回收的频率,让CPU更多地去执行你地业务而不是垃圾回收

垃圾回收发生在哪些区域?

如图,JVM内存结构,虚拟机栈、本地方法栈、程序计数器都是线程独享的,这三个区域随着线程的创建而创建,随着线程的销毁而销毁,而栈里面的栈帧又会随着方法的进入和退出进行入栈和出栈操作,所以这三块区域是不需要考虑垃圾回收的。

堆和方法区时线程共享的,这两块区域才需要关注垃圾回收 ,堆是垃圾回收的主要区域,主要回收的是创建的对象 ,方法区回收废弃的常量以及不需要使用的类

对象在什么时候能够被回收?

目前主要有两种算法判断对象是否能够被回收

引用计数法
  • 通过对象的引用计数器来判断该对象是否被引用
    • 例如:对于一个对象A,只要有一个对象引用了A,那么A的引用计数器就会+1,当引用失效时,A的引用计数器就会-1,如果A的引用计数器值变为0,就说明对象A已经没有引用了,可以回收

如图,A引用B,B引用C,B上可以找到A的引用,B的引用计数是1,C上可以找到B的引用,C的引用计数是1

使用引用计数法还是比较简单的,而且使用引用计数法去判断对象是否能够被回收效率也是很高的

但是如果对象存在循环引用时,使用引用计数法就无能为力了

如图,A引用B,B引用C,C引用D,D引用B,在这个情况下,B上可以找到A和D的引用,B引用计数是2。某一天A不引用B了,理论上,B、C、D都要被回收,但是,B、C、D的引用计数都是1,这种情况下,使用引用计数法就会导致B、C、D都不能回收

由于引用计数法处理循环引用存在问题,所以Java没有使用引用计数法

可达性分析(Java采用)
  • 以根对象(GC Roots)作为起点向下搜索,走过的路径被称为引用链(Reference Chain),如果某个对象到根对象没有引用链相连时,就认为这个对象是不可达的,可以回收
GC Roots包括哪些对象?
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即Native方法)引用的对象
引用
  • 强引用(Strong Reference)

    • 形如Object obj = new Object()的引用
    • 只要强引用存在,永远不会回收被引用的对象,哪怕出现内存溢出
  • 软引用(Soft Reference)

    • 形如SoftReference<String> sr = new SoftReference<>("hello")
    • 是用来描述一些有用但非必需的对象
    • 软引用关联的对象,只有在内存不足的时候才会回收
      • 由于这个特性,软引用适合用来做网页缓存、图片缓存
  • 弱引用(Weak Reference)

    • 形如WeakReference<String> wr = new WeakReference<>("hello")
    • 弱引用也是用来描述非必需的对象的
    • 无论内存是否充足,都会回收被弱引用关联的对象
  • 虚引用(Phantom Reference)

    • 形如

      • ReferenceQueue<String> queue = new ReferenceQueue<>();

        PhantomReference<String> pr = new PhantomReference<>("hello", queue);

    • 不影响对象的生命周期,如果一个对象只有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动,必须和引用队列(ReferenceQueue)配合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入虚引用,来了解被引用的对象是否要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动

可达性分析注意点
  • 一个对象即使不可达,也不一定会被回收

对象不可达,只是给这个对象判处了死缓,要想处死这个对象,完成的流程如下

例子:

java 复制代码
package com.imooc.jvm.gc;

@SuppressWarnings("Duplicates")
public class GCTest1 {

    private static GCTest1 obj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize被调用了");
        obj = this;
    }

    public static void main(String[] args) throws InterruptedException {
        obj = new GCTest1();
        obj = null;
        System.gc(); // 强制触发垃圾回收

        Thread.sleep(1000L);
        if (obj == null) {
            System.out.println("obj == null");
        } else {
            System.out.println("obj可用");
        }

        Thread.sleep(1000L);
        obj = null;
        System.gc();
        if (obj == null) {
            System.out.println("obj == null");
        } else {
            System.out.println("obj可用");
        }
    }

}

输出结果

tex 复制代码
finalize被调用了
obj可用
obj == null

分析

这个类中重写了finalize()方法,并且在finalize()中把this赋值给了objmain()方法中,先new GCTest1();,再置空,去掉了对象的引用,再使用System.gc();强制回收这个对象。按照上图,首先会调用finalize()方法,因为这里重写了finalize()方法,所以走有必要分支,把对象放入F-Queue中。但是在finalize()方法中obj = this,重新创建了对象的引用,就会导致这个对象从F-Queue中移除不回收,因此第一个判断就会打印obj可用。紧接着再次把obj = null;,再次触发System.gc();,这次因为之前执行过finalize()方法了,所以会走无必要分支,于是这次对象才被回收,打印obj == null

通过分析,可以发现这段代码存在一个严重的问题,如果第二次判断之前没有人工执行obj = null;,就会导致obj对象永远无法回收

所以实际项目中建议

finalize()的建议
  • 避免使用finalize()方法,操作不当可能会导致问题
  • finalize()优先级低,何时会被调用无法确定,因为什么时间发生GC不确定
  • 建议使用try...catch...finally来代替finalize()

总结

  • 什么场景下该使用什么垃圾回收策略?
  • 垃圾回收发生在哪些区域?
  • 对象在什么时候能够被回收?

1-8 垃圾回收算法讲解

垃圾回收算法

标记-清除(Mark - Sweep)

  • 标记存活的对象
    • 这个过程就是前面使用可达性分析判断一个对象是否可达的过程
  • 遍历清理掉未标记的对象

如图,先标记哪些对象是存活的,标记完成后,对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中 没有标记为存活对象,则将其回收。

从图中得知,标记-清除存在一定的缺点,垃圾清理后会存在内存碎片。比如,想在内存中分配连续三个单位的内存空间,例如超大的字节数组,在这样的内存结构下就无法做到了

标记-整理(Mark - Compact)

  • 也称标记-压缩(Mark - Compact)
  • 标记存活的对象
  • 把所有的存活对象压缩到内存的一端
  • 清理掉边界外的所有空间

如图,可以避免内存碎片的问题

复制(Copy)

  • 把内存分为两块,每次只使用其中一块
  • 将正在使用的内存中的存活对象复制到未使用的内存中去,然后清除掉正在使用的内存中的所有对象
  • 交换两个内存的角色,等待下次回收

三种算法对比

标记-清除:具有分配内存速度会受影响的缺点。因为假设现在有一个比较大的对象,由于现在又很多碎片化的内存空间,想找到一块连续的空间,就需要遍历空闲链表,从而查找那一块内存可以存放这个对象。极端场景下,需要把整个链表遍历完,才能分配,又或者根本无法分配,遍历内存链表是需要时间的,所以分配内存的速度会受影响。

标记-整理:具有整理存在开销的缺点。因为标记-整理需要把存活对象集中起来,放到内存的一端,这样过程需要计算以及时间,并且对象越多占用的内存越大,整理的开销也越大

复制:具有性能好的优点。因为它不需要想标记-清除和标记-整理那样,需要标记哪些对象是存活的,哪些对象可回收,只需要存活的对象,然后移动即可,所以性能相对要好;内存使用率低。因为一次只能使用整个内存的一半,即最多只能达到50%

了解完三种基础的垃圾回收算法后,接下来看下Java里面相对综合的垃圾回收算法

分代收集算法

  • 把内存分成多个区域,不同区域使用不同的回收算法回收对象
    • 比较复杂,细节多,后续详细说明

增量算法

如果内存非常大,如果一次收集所有垃圾,那么需要耗费的时间就比较长,有可能会造成系统长时间的停顿

  • 每次只收集一小片区域的内存空间的垃圾

总结

  • 基础垃圾回收算法
    • 标记-清除、标记-整理、复制
  • 综合垃圾回收算法
    • 分代收集算法、增量算法

1-9 垃圾回收分代收集算法讲解

分代收集算法

  • 目前各种商业虚拟机堆内存的垃圾收集算法基本上都采用了分代收集
  • 根据对象的存活周期,把内存分成多个区域,不同区域使用不同的回收算法回收对象

堆内存结构

如图,java将堆分为新生代和老年代,那么经过分代之后,垃圾回收会被分为以下几类

回收类型

  • 新生代回收(Minor GC | Young GC)
  • 老年代回收(Major GC)
  • 清理整个堆(Full GC)
  • 由于进行Major GC的时候一般会伴随一次Minor GC,所以认为Major GC ≈ Full GC

对象分配过程

如上图,堆内存的结构,对象在创建时,会先存放到伊甸园,当伊甸园满了后,就会触发垃圾回收。

这个回收的过程是,把伊甸园里面存活的对象copy到存活区里面的From survivor或者To survivor里面。比如这次回收copy到From survivor,下次回收就会把From survivor存活的对象copy到To survivor里面,再下一次又会把To survivor里面存活的对象copy到From survivor,周而复始。

不难发现,这个回收的过程使用了复制算法,这也是为啥新生代要有两个survivor的原因。

对象每经过一次垃圾回收后还存活的话,它的年龄就会+1,当对象的年龄达到阈值情况下(默认15),就会晋升到老年代,老年代里面的对象存活率一般比较高,因为回收了15次还没回收完成,后面存活的可能性依然很大。

老年代里面的对象一般会使用标记-清除或者标记-整理进行回收。

当老年代和新生代同时空间不足,会触发full GC

需要注意,以上对象分配过程只是一个典型的过程,实际上会存在例外。

对象分配例外

新建的对象不一定分配到伊甸园,也可能直接进入到老年代
  • 对象大于-XX:PretenureSizeThreshold,就会直接分配到老年代
    • -XX:PretenureSizeThreshold的值默认是0,不作限制,所有对象都会优先分配到伊甸园
  • 新生代空间不够,该对象也会直接分配到老年代
    • 主要因为新生代采用复制算法,在伊甸园里面分配大对象,将会导致两个survivor区大量的内存copy
对象不一定要达到年龄阈值才会进入老年代
  • 动态年龄:如果survivor空间中所有相同年龄对象大小的总和大于survivor空间的一半,那么年龄大于等于该年龄的对象就可以直接进入老年代

触发垃圾回收的条件-新生代(Minor GC)

  • 伊甸园空间不足

触发垃圾回收的条件-老年代(Full GC)

  • 老年代空间不足
    • 空间真的不足
    • 内存碎片导致没有连续的内存分配对象
  • 元空间不足
  • 要晋升到老年代的对象所占用的空间大于老年代的剩余空间
  • 显式调用System.gc()
    • 建议垃圾回收器执行垃圾回收
    • 可以采用-XX:+DisableExplicitGC参数,忽略掉System.gc()的调用

总结

分代收集算法是根据对象的生命周期,把内存作了分代,然后在分配对象时,把不同生命周期的对象放入不同代中,不同代上使用合适的回收算法进行回收。

新生代里面的对象,存活周期一般都比较短,每次垃圾回收时,都会有大量的对象死去,有做过研究,98%以上的对象都是会很快消亡的,只有少量的对象能够存活。所以新生代可以使用复制算法来完成垃圾收集。

而老年代里面的对象存活率比较好,所以就采用标记-清除或者标记-整理进行回收。

分代的好处

  • 更有效地清除不再需要的对象
    • 对于生命周期比较短的对象,对象还处于新生代时,就被回收掉了
  • 提升了垃圾回收的效率
    • 如果不做分代,需要扫描整个堆里面的对象,分代只要扫描新生代或者老年代即可

分代收集算法调优原则

  • 合理设置survivor区域的大小,避免内存浪费
    • 因为survivor区域对内存的利用率不高,如果配置过大,内存浪费就比较严重
  • 让GC尽量发生在新生代,尽量减少Full GC的发生

相关JVM参数

1-10 垃圾收集器1-相关术语与新生代收集器

垃圾收集器

  • 垃圾收集算法:为实现垃圾回收提供理论支持
  • 垃圾收集器:利用垃圾收集算法,实现垃圾回收的实践落地

同垃圾收集算法类似,Java也提供了多款垃圾收集器,不同的垃圾收集器有不同的特性和使用场景,甚至,不同的垃圾收集器之前才存在配合使用的关系,使用起来还是比较复杂的

如图,展示了比较主流的垃圾收集器,以及能够作用的内存区域,和相关配合使用情况

术语-Stop The World

  • 简写为STW,也叫全局停顿,Java代码停止运行,native代码继续运行,但不能与JVM进行交互
  • 原因:多半由于垃圾回收导致;也可能由Dump线程、死锁检查、Dump堆等导致
  • 危害:服务停止、请求没有响应;高可用场景下甚至会导致主从切换,危害生产环境
    • 危害还是很大的应该尽量缩短Stop The World时间

为什么在垃圾收集时,会存在Stop The World?

我们的应用在运行时,会产生大量的对象,可以作这样的对比,假设应用里面的对象在聚会,聚会时会产生垃圾,然后垃圾收集器是清洁工,负责打扫垃圾,如果聚会不停止,不断地产生垃圾,清洁工永远都无法打扫干净这个场地,只有让大家都停止活动才能把屋子打扫干净,所以垃圾收集时,会存在Stop The World?

术语-并行收集 vs 并发收集

  • 并行收集:指多个垃圾收集线程并行工作,但是收集的过程中,用户线程(你的业务线程)还是处于等待状态的
  • 并发收集:指用户线程与垃圾收集线程同时工作

术语-吞吐量

  • CPU用于运行用户代码的时间与CPU总消耗时间的比值
  • 公式:运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)
    • 例如:JVM总共运行了100分钟,垃圾收集时间为1分钟,那么吞吐量就是99%

新生代收集器1-Serial收集器

  • 也叫串行收集器
  • 最基本的、发展历史最悠久的收集器
  • 使用复制算法

Serial收集器-执行过程

如图,用户线程运行到一个安全点后,全部暂停,然后由一个垃圾收集线程回收垃圾,回收完成后,用户线程才能继续执行

Serial收集器-特点

  • 单线程
  • 简单、高效
    • 由于Serial收集器是单线程的,它没有和其它线程交互的开销,专心做垃圾收集,因此,相对其它垃圾收集器单线程的工作效率相对高一些
  • 整个垃圾收集过程全程Stop The World
    • Serial收集器回收垃圾时,工作线程全程暂停,直到收集结束

Serial收集器-适用场景

  • 客户端程序,应用以java -client -jar xxx.jar模式运行时,默认使用的就是Serial收集器
  • 适合运行在单核机器上,比如一些嵌入式低性能的机器上

新生代收集器2-ParNew收集器

  • Serial收集器的多线程版,除使用了多线程以外,其它和Serial收集器一样,包括:JVM参数、Stop The World的表现、垃圾收集算法都是一样的

ParNew收集器-执行过程

ParNew收集器-特点

  • 多线程
  • 可使用-XX:ParallelGCThreads设置垃圾收集的线程数
    • 一般设置为CPU核心数即可

ParNew收集器-适用场景

  • 主要用来和CMS收集器配合使用

新生代收集器3-Parallel Scavenge收集器

  • 也叫吞吐量优先收集器
  • 采用复制算法
  • 也是并行的多线程收集器,这一点和ParNew类似

Parallel Scavenge收集器-执行过程

Parallel Scavenge收集器-特点

  • 可以达到一个可控制的吞吐量

    • -XX:MaxGCPauseMillis:控制最大的垃圾收集停顿时间(尽力)
      • 比如配置为100ms,那么垃圾回收器就会尽力保证停顿时间不超过100ms,但是也不保证绝对不超过这个值
    • -XX:GCTimeRatio:设置吞吐量的大小,取值0-100,假设-XX:GCTimeRatio设置为n,那么系统花费不超过1/(1+n)的时间用于垃圾收集
  • 自适应GC策略:可用-XX:+UseAdptiveSizePolicy打开

    • 打开自适应策略后,无需手动设置新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)等参数
    • 虚拟机会自动根据系统的运行状况收集性能监控信息,动态调整这些参数,从而达到最优的停顿时间以及最高的吞吐量

Parallel Scavenge收集器-适用场景

  • 注重吞吐量的场景

1-11 垃圾收集器2-老年代收集器

老年代收集器1-Serial Old收集器

  • 也叫串行老年代收集器

  • Serial收集器的老年代版

  • 采用标记-整理算法

Serial Old收集器-执行过程

Serial Old收集器-适用场景

  • 可以和Serial/ParNew/Parallel Scavenge这三个新生代的垃圾收集器配合使用
  • CMS收集器出现故障的时候,会用Serial Old作为后备

老年代收集器2-Parallel Old收集器

  • Parallel Scavenge收集器的老年代版本
  • 采用标记-整理算法

Parallel Old收集器-执行过程

Parallel Old收集器-特点

  • 只能和Parallel Scavenge配合使用

Parallel Old收集器-适用场景

  • 注重吞吐量的场景

老年代收集器3-CMS收集器

  • CMS:Concurrent Mark Sweep
    • 含义是并发标记清除
    • 之前介绍的收集器,要么是串行的,比如Serial收集器、Serial Old收集器;要么是并行的,比如ParNew收集器、Parallel Scavenge收集器、Parallel Old收集器
  • 并发收集器
  • 采用标记-清除算法

CMS收集器-执行过程

  • 初始标记(initial mark)

    • 标记GC Roots能直接关联到的对象
    • 这个阶段会Stop The World
    • 因为只会标记GC Roots能直接关联到的对象,所以标记的对象比较少,停顿的时间还是比较短的
  • 并发标记(concurrent mark)

    • 找出所有GC Roots能关联到的对象
    • 垃圾收集线程和用户线程并发执行,无Stop The World
  • 并发预清理(concurrent-preclean)

    • 不一定会执行
    • 重新标记那些在并发标记阶段,引用被更新的对象,比如:晋升到老年代的对象、原先就在老年代的对象,从而减少后面重新标记阶段的工作量
    • 垃圾收集线程和用户线程并发执行,无Stop The World
    • 可使用XX:-CMSPrecleaningEnabled关闭并发预清理阶段,默认打开
  • 并发可中止的预清理阶段(concurrent-abortable-preclean)

    • 不一定会执行

    • 和并发预清理做的事情一样

    • 垃圾收集线程和用户线程并发执行,无Stop The World

    • 当Eden的使用量大于CMSScheduleRemarkEdenSizeThreshold的阈值(默认2M)时,才会执行该阶段

    • 即然有了并发预清理阶段,为什么还要设计一个并发可中止的预清理阶段?

      • 主要作用:允许我们能够控制预清理阶段的结束时机。比如扫描多长时间(CMSMaxAbortablePrecleanTime,默认5秒)或者Eden区使用占比达到一定阈值(CMSScheduleRemarkEdenPenetration,默认50%)就结束本阶段
      • 可以让我们更好地控制CMS的行为
  • 重新标记(remark)

    • 修正并发标记期间,因为用户程序继续执行,导致标记发生变动的那些对象的标记

      • 因为并发标记阶段的标记过程是并发执行的,所以在标记的同时,用户线程可能修改已经标记过的对象的状态。此时就可能导致两种情况
        • 把已经死亡的对象标记为存活,导致部分垃圾不会被回收(可以容忍,不影响业务)
        • 把存活的对象错误标记为死亡,导致程序无法正常执行(不可容忍)
    • 一般来说,重新标记花费的时间会比初始标记阶段长一些,但比并发标记的时间短

    • 存在Stop The World

  • 并发清除(concurrent sweep)

    • 也叫并发清理

    • 基于标记结果,清除掉要清除前面标记出来的垃圾

    • 垃圾收集线程和用户线程并发执行,无Stop The World

    • 直接清除对象会存在内存碎片,为什么不是并发整理呢?

      • 因为并发清除对象是并发执行的,如果整理对象需要移动对象的位置,很难做到在并发情况下一堆线程运行业务,一堆线程做垃圾回收,回收的同时还需要移动对象的位置,这样很难保证应用程序不出问题
  • 并发重置(concurrent reset)

    • 清理本次CMS GC的上下文信息,为下一次GC做准备

CMS收集器-优点

  • Stop The World的时间比较短
    • 只有初始标记和重新标记这两个阶段存在Stop The World,其它阶段都是并发执行的
  • 大多数过程都是并发执行

CMS收集器-缺点

  • CPU资源比较敏感

    • 并发阶段可能导致应用吞吐量的降低
      • 并发执行阶段虽然不会导致Stop The World,用户线程不会停顿,但是由于垃圾收集线程也会占用一部分CPU资源,所以会影响业务流程的执行效率,导致吞吐量降低
  • 无法处理浮动垃圾

    • CMS的并发清除阶段,用户线程还在执行,所以会有新的垃圾产生,这个垃圾叫做浮动垃圾,CMS无法在这一次收集就清理掉这些垃圾,在下一次才能清理
  • 不能等到老年代几乎满了才开始收集

    • 垃圾收集阶段,用户线程也在运行,需要申请内存,于是,必须预留足够的内存给用户线程产生的对象进入老年代使用
    • 预留内存不够->会出现Concurrent Mode Failure异常->使用Serial Old作为后备,收集老年代的垃圾
      • 一旦使用Serial Old作为后备,Stop The World时间就会比较长,因此在实际项目中一定要预留足够的内存
    • 可使用CMSInitiatingOccupancyFraction设置老年代占比达到多少就触发垃圾收集,默认68%
  • 内存碎片

    • 基于标记-清除算法导致碎片的产生

    • 这也是CMS最被人诟病的地方

    • UseCMSCompactAtFullCollection:在完成Full GC后是否要进行内存碎片整理,默认开启

    • CMSFullGCsBeforeCompaction:进行几次Full GC后就进行一次内存碎片整理,默认为0

ps:之前说Major GC≈Full GC,但是对于CMS来说,Major GC和Full GC不是一件事。CMS 是作用在老年代的垃圾回收,并不是 Full GC。

CMS收集器-适用场景

  • 希望系统停顿时间短,响应速度快的场景,比如各种服务器应用程序,如:web应用

CMS收集器-总结

  • 1、初始标记
  • 2、并发标记
  • 3、并发预清理
  • 4、并发可中止的预清理阶段
  • 5、重新标记
  • 6、并发清除
  • 7、并发重置

1-12 垃圾收集器3-G1收集器

G1收集器

  • Garbge First
  • 面向服务器端应用的垃圾收集器
  • 既可以用在新生代,也可以用在老年代

G1收集器带来了很多革命性的变化,如:堆内存布局变化

G1收集器-堆内存布局

如图,G1收集器的堆内存布局和前面所有讲解的垃圾收集器都不一样,G1将整个堆内存划分为若干个大小相等区域,每一个区域叫做一个Region。

Region的大小可以通过参数-XX:G1HeapRegionSize指定Region的大小,取值范围是1MB~32MB,应为2的N次幂

G1收集器为每一个Region分类,分为Eden Region(伊甸园)、Survivor Region(存活区)、Old Region(老年代)、Humongous Region(存储大对象),Eden Region(伊甸园)、Survivor Region(存活区)、Old Region(老年代)是分代的概念,只是在G1收集器中同一个代里面的对象可能是不连续的,如图,四个den Region(伊甸园)就是不连续的。

Humongous Region(存储大对象),某一个对象的大小超过Region的一半,就认为是大对象,存放到Humongous Region中。如果某一个对象超级大,一个Region甚至都存放不下,那么会分配在多个连续的Humongous Region里面。

例如:G1HeapRegionSize指定Region的大小是16MB,那么某个对象只要超过8MB,就会分配到某一个Region中,然后G1收集器会把这个Region标记为Humongous Region,某个对象超级大,30MB,一个Region存储不下,就会分配两个连续的Region存储这个大对象,并且会把这两个Region都标记为Humongous Region。

事实上,G1收集器会把Humongous Region也作为老年代的一部分看待。

G1收集器-设计思想

  • 把整个JVM堆划分成若干个大小相等的内存分块(Region)
  • 跟踪每个Region里面的垃圾堆积的价值大小,比如:回收掉整个Region能获得多少的内存剩余空间
  • 在后台构建一个优先列表,可以认为是根据价值大小作的排序,根据允许的收集时间,优先回收价值高的Region
  • 从而,获得更高的垃圾回收效率

其实,G1的设计思想是化整为零,分而治之的,会根据设置的允许的收集时间阈值,一次只会收集其中一部分Region的垃圾,本质上就是用到了前面垃圾回收算法里面的增量算法的思想

G1收集器-垃圾收集机制

  • Young GC
  • Mixed GC
  • Full GC

这三种垃圾收集机制特性不一样,触发的条件也不一样

G1收集器-Young GC

  • 所有Eden Region都满了时,就会触发Young GC
  • 所有Eden Region里面的对象会转移到Survivor Region中
  • 原先Survivor Region中的对象转移到新的Survivor Region中,或者达到年龄阈值,晋升到Old Region
  • 空闲的Region会被放入空闲列表中,等待下次被使用

可以发现,G1收集器的Young GC和前面的分代回收中的新生代回收从过程来说基本时一样的,只不过G1收集器回收的单位是Region

G1收集器-Mixed GC(最能体现设计思想,巧妙之处)

  • 老年代大小占整个堆的百分比达到一定阈值(可用-XX:InitiatingHeapOccupancyPercent指定,默认45%),就触发Mixed GC

  • Mixed GC会回收所有的Young Region,同时回收部分Old Region

    • Young Region表示新生代 Region,包括Eden Region和Survivor Region

    • 部分Old Region:是通过设置的允许回收时间以及垃圾回收价值去选择

G1收集器-Mixed GC执行过程

  • 初始标记(Initial Marking)

    • 标记GC Roots能直接关联到的对象,和CMS类似
    • 存在Stop The World,时间也比较短
  • 并发标记(Concurrent Marking)

    • 同CMS的并发标记,耗时略长
    • 垃圾收集线程和用户线程并发执行,无Stop The World
  • 最终标记(Final Marking)

    • 修正在并发标记期间引起的变动
    • 存在Stop The World
  • 筛选回收(Live Data Counting and Evacuation)

    • 对各个Region的回收价值和成本进行排序
    • 根据用户所期望的停顿时间(可以使用MaxGCPauseMillis指定)来制定回收计划,并选择一些Region回收
    • 回收过程:
      • 选择一系列的Region构成一个回收集。可以认为构造一个set,然后把需要回收的Region放入该set中
      • 把决定回收的Region中的存活对象复制到空的Region中
      • 删除掉需回收的Region
      • 此回收过程采用复制算法,不存在内存碎片
    • 存在Stop The World

总结:经过分析,我们发现G1的Mixed GC除了并发标记以外,其它过程都会Stop The World。但是,由于一次只会回收一部分的Region,这个Stop The World的时间是可控的

G1收集器-Full GC

  • 复制对象内存不够,或者无法分配足够内存(比如巨型对象没有足够的连续分区分配)时,触发Full GC
  • Full GC模式下,使用Serial Old垃圾收集器,是单线程的,一旦触发,就会长时间的Stop The World
  • G1主要优化原则尽量减少Full GC的发生

G1收集器-减少Full GC的思路?

  • 增加预留内存(增大-XX:G1ReservePercent,默认为堆的10%)
  • 更早地回收垃圾(减少-XX:InitiatingHeapOccupancyPercent,老年代达到该值就触发Mixed GC,默认45%)
  • 增加并发阶段使用的线程数(增大-XX:ConcGCThreads)
    • 这样就会有更多的垃圾回收线程工作,但是这个参数会对吞吐量有影响

G1收集器-特点

  • 可以作用在整个堆
  • 可控的停顿
    • 可使用MaxGCPauseMillis=200指定停顿时间
  • 无内存碎片
  • 适合占用内存较大的应用(6G以上)
  • 官网设计初衷为了替换CMS垃圾收集器
    • 从这个角度说,如果应用适合CMS收集器,那么就可以考虑使用G1收集器

G1收集器-总结

  • 堆内存划分若干个Region,化整为零,回收,回收单位不再是新生代、老年代,而是Region
  • 包含Young GC(回收新生代)、Mixed GC(回收新生代和部分老年代)、Full GC(采用Serial Old垃圾收集器回收)
  • 从Mixed GC执行步骤来看,G1和CMS有类似之处,但也有很多差异,主要在于CMS采用标记-清除算法,有内存碎片问题,而G1采用复制算法,无内存碎片问题

G1 or CMS?

在实际项目中,如果项目用G1 or CMS都可以满足需求,必须一个web应用,感觉用G1 or CMS都可以实现目标,因为这两款垃圾收集器都可以实现低停顿的需求,那么该如何选择?

  • 对于JDK8:对于大多数服务器端应用,都可以用,并且一般性能差异并不大
    • 如果机器内存<=6G,建议用CMS,如果内存>6G,考虑使用G1(经验之谈)
      • 主要因为业界测试,低性能的机器使用CMS性能好,高性能的机器使用G1性能好
  • 如果>JDK8:用G1
    • CMS从JDK9已经被废弃了

1-13 垃圾收集器4-其他垃圾收集器:Shenandoah、ZGC、Epsilon

其他垃圾收集器

目前为止,之前已经探讨了7款主流的垃圾收集器了,可以发现,随着时代的发展,垃圾收集器越来越先进,但是也是越来越复杂!

  • Shenandoah
  • ZGC
  • Epsilon

这几款收集器,截止到目前的JDK14依然处于实验的状态,还没有正式发布,还在不断改进中,未来一些特性还在不断变化,目前不作展开讲解。具体见1-16 其他垃圾收集器,可以看一下,但是用在生产环境还是要慎重!

1-14 垃圾收集器5-如何选择垃圾收集器

如何选择垃圾收集器?

之前介绍了10款垃圾收集器,除了还处于实验的状态的三款,还有7款,那么如何选择垃圾收集器?一般来说,可以从三个方面考虑

  • 应用系统关注的主要矛盾点是什么?

    • 例如:某个项目是个数据分析类的业务系统,我们更加希望它能够更快地获得执行结果,那么吞吐量就是主要矛盾点,可以考虑使用Parallel Scavenge垃圾收集器
    • 例如:某个项目是个web应用,Stop The World可能会直接影响服务质量,甚至可能会导致调用超时,业务直接失败等严重的后果,那么低延迟就是主要矛盾点,可以考虑使用CMS或者G1垃圾收集器,将来Shenandoah或者ZGC发展成熟后,也可以使用这两款垃圾收集器
    • 例如:某个项目是个桌面端的应用,目前最大的问题是启动非常慢,如果我们只是希望它启动快一点,那么就可以考虑使用Serial垃圾收集器,另外可以z在启动的时候添加-Xverify:none参数关闭验证,进一步提升启动速度
    • 总而言之,应该找到应用系统关注的主要矛盾点,并结合前面介绍每款垃圾收集器的适用场景,合理的选择
  • 基础设施

    • 例如:应用运行在单核嵌入式机器上,那么使用Parallel Scavenge、Parallel Old等并行收集器或者CMS、G1并发收集器都不是很合适

    • 例如:操作系统是windows,并且使用JDK11,那么就无法使用ZGC,因为JDK14之前,ZGC仅仅支持Linux。基于一些开发部署和测试的需要,ZGC在JDK14中支持在macOS 和windows

    • 例如:项目使用JDK6,就无法使用G1垃圾收集器

    • 例如:使用Oracle JDK,就无法使用Shenandoah垃圾收集器

总而言之,垃圾收集器的选择要考虑的因素非常多,以上都是从理论出发的分析,实际项目中,千万不能纸上谈兵,凭想象力选择,而是要结合实际的测试结果选择,后续实战章节会根据测试去选择垃圾收集器!

1-15 垃圾收集器6-垃圾收集器相关JVM参数

垃圾收集器相关JVM参数

具体见1-17 垃圾收集器相关JVM参数总结,具体实战章节会手把手探讨这些参数,本章主要掌握两点即可

  • 收藏&留个印象

  • 通读参数,理解大致参数的作用,也是为了帮助检验是否真正理解前面各款垃圾收集器的过程

    • 例如:当看到-XX:CMSInitiatingOccupancyFraction=68参数时,要知道参数的作用,另外还应该知道为什么不能内存满了才收集
    • 例如:当看到 -XX:G1HeapWastePercent=5参数时,要知道G1为什么会存在空间浪费
  • 需要注意的是,目前市面上的文章普遍整理的很不全面或者错误,可以采用编译JVM源码,再配合以下博客

    • https://chriswhocodes.com
      • 该博客采集JVM源码注释的方式,获得了JVM参数的描述,里面整理了各个JVM发行版,例如:HotSpot虚拟机、OpenJ9虚拟机、Azul虚拟机等,以及各个版本的JVM参数,非常全面,可以用于参考某个参数的作用

1-16 其他垃圾收集器

其他垃圾收集器

Shenandoah

厂商:RedHat,贡献给了OpenJDK

定位:低延迟垃圾收集器

状态:实验性

限制:Oracle JDK无法使用

适用版本:详见 https://wiki.openjdk.java.net/display/shenandoah

网址:https://wiki.openjdk.java.net/display/shenandoah

和G1对比,相同点:

  • 基于Region的内存布局
  • 有用于存放大对象的Humongous Region
  • 回收策略也同样是优先处理回收价值最大的Region

和G1对比,不同点:

  • 并发的整理算法
  • Shenandoah默认是不使用分代收集的
  • 解决跨region引用的机制不同,G1主要基于Rememberd Set、CardTable,而Shenandoah是基于连接矩阵(Connection Matrix)去实现的。

启用参数:

shell 复制代码
-XX:+UnlockExperimentalVMOptions  -XX:+UseShenandoahGC                                                        

适用场景:

  • 低延迟、响应快的业务场景

工作步骤:

相关论文:https://www.researchgate.net/publication/306112816_Shenandoah_An_open-source_concurrent_compacting_garbage_collector_for_OpenJDK

  1. 初始标记(Initial Marking):

    与G1一样,首先标记与GC Roots直接关联的对象,存在Stop The World

  2. 并发标记(Concurrent Marking)

    与G1一样,标记出全部可达的对象,该阶段并发执行,无Stop The World

  3. 最终标记(Final Marking)

    统计出回收价值最高的Region、构建回收集(Collection Set)。存在Stop The World

  4. 并发清理(Concurrent Cleanup)

    用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)

  5. 并发回收(Concurrent Evacuation)

    并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。**复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。**其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为"Brooks Pointers"的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。

  6. 初始引用更新(Initial Update Reference)

    并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。

  7. 并发引用更新(Concurrent Update Reference)

    真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。主要是按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

  8. 最终引用更新(Final Update Reference)

    解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关

  9. 并发清理(Concurrent Cleanup)

    经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用

TIPS

步骤较多,重点是:并发标记、并发回收、并发引用更新这三个阶段。

性能表现

ZGC

厂商:Oracle

定位:低延迟垃圾收集器

状态:实验性

限制:JDK14之前,无法在Windows、macOS机器上使用,每个版本的特性解介绍详见:https://wiki.openjdk.java.net/display/zgc/Main

核心技术:染色指针技术

适用场景:

  • 低延迟、响应快的业务场景

启用参数:

shell 复制代码
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC                                                      

内存布局:

  • ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage,本章为行文一致继续称为Region)具有动态性,可以动态创建和销毁,以及动态的区域容量大小。region的容量:
    • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象
    • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
    • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作"大型Region",但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)的,因为复制一个大对象的代价非常高昂

工作步骤:

  • 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要"迫切"去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。

性能表现:

吞吐量:

停顿时间:

Epsilon

Epsilon(A No-Op Garbage Collector)垃圾回收器控制内存分配,但是不执行任何垃圾回收工作。一旦java的堆被耗尽,jvm就直接关闭。设计的目的是提供一个完全消极的GC实现,分配有限的内存分配,最大限度降低消费内存占用量和内存吞吐时的延迟时间。一个好的实现是隔离代码变化,不影响其他GC,最小限度的改变其他的JVM代码。

定位:不干活儿的垃圾收集器

状态:实验性

启用参数:

shell 复制代码
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC                                                          

适用场景:

  • Performance testing,什么都不执行的GC非常适合用于差异性分析。no-op GC可以用于过滤掉GC诱发的新能损耗,比如GC线程的调度,GC屏障的消耗,GC周期的不合适触发,内存位置变化等。此外有些延迟者不是由于GC引起的,比如scheduling hiccups, compiler transition hiccups,所以去除GC引发的延迟有助于统计这些延迟。
  • Memory pressure testing, 在测试java代码时,确定分配内存的阈值有助于设置内存压力常量值。这时no-op就很有用,它可以简单地接受一个分配的内存分配上限,当内存超限时就失败。例如:测试需要分配小于1G的内存,就使用-Xmx1g参数来配置no-op GC,然后当内存耗尽的时候就直接crash。
  • VM interface testing, 以VM开发视角,有一个简单的GC实现,有助于理解VM-GC的最小接口实现。它也用于证明VM-GC接口的健全性。
  • Extremely short lived jobs, 一个短声明周期的工作可能会依赖快速退出来释放资源,这个时候接收GC周期来清理heap其实是在浪费时间,因为heap会在退出时清理。并且GC周期可能会占用一会时间,因为它依赖heap上的数据量。
  • Last-drop latency improvements, 对那些极端延迟敏感的应用,开发者十分清楚内存占用,或者是几乎没有垃圾回收的应用,此时耗时较长的GC周期将会是一件坏事。
  • Last-drop throughput improvements, 即便对那些无需内存分配的工作,选择一个GC意味着选择了一系列的GC屏障,所有的OpenJDK GC都是分代的,所以他们至少会有一个写屏障。避免这些屏障可以带来一点点的吞吐量提升。
参考文档

1-17 垃圾收集器相关JVM参数总结

垃圾收集器相关JVM参数总结

TIPS

本文参数基于JDK 8整理

各款垃圾收集器相关的JVM参数总结如下表。如有疑问或需勘误,可在慕课网问答区提出。

收集器 参数及默认值 备注
Serial -XX:+UseSerialGC 虚拟机在Client模式下的默认值,开启后,使用 Serial + Serial Old 的组合
ParNew -XX:+UseParNewGC 开启后,使用ParNew + Serial Old的组合
-XX:ParallelGCThreads=n 设置垃圾收集器在并行阶段使用的垃圾收集线程数,当逻辑处理器数量小于8时,n的值与逻辑处理器数量相同;如果逻辑处理器数量大于8个,则n的值大约为逻辑处理器数量的5/8,大多数情况下是这样,除了较大的SPARC系统,其中n的值约为逻辑处理器的5/16。
Parallel Scavenge -XX:+UseParallelGC 虚拟机在Server模式下的默认值,开启后,使用 Parallel Scavenge + Serial Old的组合
-XX:MaxGCPauseMillis=n 收集器尽可能保证单次内存回收停顿的时间不超过这个值,但是并不保证不超过该值
-XX:GCTimeRatio=n 设置吞吐量的大小,取值范围0-100,假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集
-XX:+UseAdaptiveSizePolicy 开启后,无需人工指定新生代的大小(-Xmn)、 Eden和Survisor的比例(-XX:SurvivorRatio)以及晋升老年代对象的年龄(-XX:PretenureSizeThreshold)等参数,收集器会根据当前系统的运行情况自动调整
Serial Old Serial Old是Serial的老年代版本,主要用于 Client 模式下的老生代收集,同时也是 CMS 在发生 Concurrent Mode Failure时的后备方案
Parallel Old -XX:+UseParallelOldGC 开启后,使用Parallel Scavenge + Parallel Old的组合。Parallel Old是Parallel Scavenge的老年代版本,在注重吞吐量和 CPU 资源敏感的场合,可以优先考虑这个组合
CMS -XX:+UseConcMarkSweepGC 开启后,使用ParNew + CMS的组合;Serial Old收集器将作为CMS收集器出现 Concurrent Mode Failure 失败后的后备收集器使用
-XX:CMSInitiatingOccupancyFraction=68 CMS 收集器在老年代空间被使用多少后触发垃圾收集,默认68%
-XX:+UseCMSCompactAtFullCollection 在完成垃圾收集后是否要进行一次内存碎片整理,默认开启
-XX:CMSFullGCsBeforeCompaction=0 在进行若干次Full GC后就进行一次内存碎片整理,默认0
-XX:+UseCMSInitiatingOccupancyOnly 允许使用占用值作为启动CMS收集器的唯一标准,一般和CMSFullGCsBeforeCompaction配合使用。如果开启,那么当CMSFullGCsBeforeCompaction达到阈值就开始GC,如果关闭,那么JVM仅在第一次使用CMSFullGCsBeforeCompaction的值,后续则自动调整,默认关闭。
-XX:+CMSParallelRemarkEnabled 重新标记阶段并行执行,使用此参数可降低标记停顿,默认打开(仅适用于ParNewGC)
-XX:+CMSScavengeBeforeRemark 开启或关闭在CMS重新标记阶段之前的清除(YGC)尝试。新生代里一部分对象会作为GC Roots,让CMS在重新标记之前,做一次YGC,而YGC能够回收掉新生代里大多数对象,这样就可以减少GC Roots的开销。因此,打开此开关,可在一定程度上降低CMS重新标记阶段的扫描时间,当然,开启此开关后,YGC也会消耗一些时间。PS. 开启此开关并不保证在标记阶段前一定会进行清除操作,生产环境建议开启,默认关闭。
CMS-Precleaning -XX:+CMSPrecleaningEnabled 是否启用并发预清理,默认开启
CMS-AbortablePreclean -XX:CMSScheduleRemark EdenSizeThreshold=2M 如果伊甸园的内存使用超过该值,才可能进入"并发可中止的预清理"这个阶段
CMS-AbortablePreclean -XX:CMSMaxAbortablePrecleanLoops=0 "并发可终止的预清理阶段"的循环次数,默认0,表示不做限制
CMS-AbortablePreclean -XX:+CMSMaxAbortablePrecleanTime=5000 "并发可终止的预清理"阶段持续的最大时间
-XX:+CMSClassUnloadingEnabled 使用CMS时,是否启用类卸载,默认开启
-XX:+ExplicitGCInvokesConcurrent 显示调用System.gc()会触发Full GC,会有Stop The World,开启此参数后,可让System.gc()触发的垃圾回收变成一次普通的CMS GC。
-XX:+UseG1GC 使用G1收集器
-XX:G1HeapRegionSize=n 设置每个region的大小,该值为2的幂,范围为1MB到32MB,如不指定G1会根据堆的大小自动决定
-XX:MaxGCPauseMillis=200 设置最大停顿时间,默认值为200毫秒。
-XX:G1NewSizePercent=5 设置年轻代占整个堆的最小百分比,默认值是5,这是个实验参数。需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数。
-XX:G1MaxNewSizePercent=60 设置年轻代占整个堆的最大百分比,默认值是60,这是个实验参数。需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数。
-XX:ParallelGCThreads=n 设置垃圾收集器在并行阶段使用的垃圾收集线程数,当逻辑处理器数量小于8时,n的值与逻辑处理器数量相同;如果逻辑处理器数量大于8个,则n的值大约为逻辑处理器数量的5/8,大多数情况下是这样,除了较大的SPARC系统,其中n的值约为逻辑处理器的5/16。
-XX:ConcGCThreads=n 设置垃圾收集器并发阶段使用的线程数量,设置n大约为ParallelGCThreads的1/4。
-XX:InitiatingHeapOccupancyPercent=45 老年代大小达到该阈值,就触发Mixed GC,默认值为45。
-XX:G1MixedGCLiveThresholdPercent=85 Region中的对象,活跃度低于该阈值,才可能被包含在Mixed GC收集周期中,默认值为85,这是个实验参数。需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数。
-XX:G1HeapWastePercent=5 设置浪费的堆内存百分比,当可回收百分比小于浪费百分比时,JVM就不会启动Mixed GC,从而避免昂贵的GC开销。此参数相当于用来设置允许垃圾对象占用内存的最大百分比。
-XX:G1MixedGCCountTarget=8 设置在标记周期完成之后,最多执行多少次Mixed GC,默认值为8。
-XX:G1OldCSetRegionThresholdPercent=10 设置在一次Mixed GC中被收集的老年代的比例上限,默认值是Java堆的10%,这是个实验参数。需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数。
-XX:G1ReservePercent=10 设置预留空闲内存百分比,虚拟机会保证Java堆有这么多空间可用,从而防止对象晋升时无空间可用而失败,默认值为Java堆的10%。
-XX:-G1PrintHeapRegions 输出Region被分配和回收的信息,默认false
-XX:-G1PrintRegionLivenessInfo 在清理阶段的并发标记环节,输出堆中的所有Regions的活跃度信息,默认false
Shenandoah -XX:+UseShenandoahGC 使用UseShenandoahGC,这是个实验参数,需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数;另外该参数只能在Open JDK中使用,Oracle JDK无法使用
ZGC -XX:+UseZGC 使用ZGC,这是个实验参数,需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数;
Epsilon -XX:+UseEpsilonGC 使用EpsilonGC,这是个实验参数,需用-XX:+UnlockExperimentalVMOptions解锁试验参数后,才能使用该参数;

参考文档

相关推荐
Query*4 小时前
JVM性能调优【二】—— 工具篇
jvm
Han.miracle4 小时前
Java 8 Lambda 表达式与方法引用的语法优化及实战应用研究
java·开发语言·jvm
why1511 天前
面经整理——操作系统
java·开发语言·jvm
沉迷技术逻辑1 天前
JVM从浅入深
jvm
.生产的驴1 天前
泛微E10二开 组织架构、人员信息、分部信息基本操作
java·jvm·spring·架构·tomcat·intellij-idea·hibernate
是一个Bug1 天前
中高级Java开发岗位 技术框架
java·jvm·面试
alien爱吃蛋挞1 天前
【JavaEE】万字详解JVM
java·jvm·java-ee
不会写程序的未来程序员1 天前
JVM 运行时内存模型
java·开发语言·jvm
yong99901 天前
C# 入门级库存管理系统
jvm·oracle·c#