跳转至

JVM

OpenJDK Mercurial Repositories

JVM功能:

  • 一次编写,到处运行
  • 内存管理:自动为对象、方法分配内存;自动垃圾回收机制

  • 解释和执行:对字节码文件中的指令,实时解释为机器码执行

  • 即时编译:对热点代码进行优化,提升效率

JVM组成:

字节码文件

Class文件是一组以8个字节为基础单位的二进制流。Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。

  • 无符号数:无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
  • 表:由多个无符号数或者其他表作为数据项构成的复合数据类型。

Class 文件具体由以下几个构成:

  • 魔数:每个Class文件的头4个字节被称为魔数 0xcafebabe ,它的唯一作用是确定这个文件是否为 一个能被虚拟机接受的Class文件。

  • 版本信息:紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号,第7和第8个字节是主版本号。

Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号)。高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。

  • 常量池:常量池中存放两种类型的常量:字面值常量(程序中定义的字符串、被 final 修饰的值)和符号引用(定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符)

  • 访问标志:这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否被 abstract/final 修饰。

  • 类索引、父类索引、接口索引集合:类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,接口索引集合就用来描述这个类实现了哪些接口。

  • 字段表集合:字段表用于描述接口或者类中声明的变量,包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。字段表集合中不会列出从父类或者父接口中继承而来的字段。

  • 方法表集合:方法表与字段表类似。如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息。

  • 属性表集合

字节码常用工具

javap

javap是JDK自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。

直接输入javap查看所有参数。输入javap -v 字节码文件名称 查看具体的字节码信息。如果jar包需要先使用 jar –xvf 命令解压。

jclasslib插件

使用 jclasslib工具查看字节码文件。 Github地址: https://github.com/ingokegel/jclasslib

jclasslib也有Idea插件版本。选中要查看的源代码文件,选择 视图(View) - Show Bytecode With Jclasslib

Arthas

Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率。

官网:https://arthas.aliyun.com/doc/

dump:可以将字节码文件保存到本地

jad:反编译指定已加载类的源码,用于确认服务器上的字节码文件是否是最新的

类加载机制

类的生命周期/类加载过程

类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为:

  1. 加载

  2. 连接,其中又分为验证、准备、解析三个子阶段

  3. 初始化

  4. 使用

  5. 卸载

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性。

1、加载

在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

2、连接

加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始了。但这两个阶段的开始时间仍然保持着固定的先后顺序。

连接阶段分为三个子阶段:

  • 验证:验证字节码文件内容是否满足《Java虚拟机规范》,包括:文件格式验证、元数据验证、字节码验证、符号引用验证
  • 准备:为静态变量分配内存并设置默认值,赋值在初始化阶段,这些内存都将在方法区中分配实例变量会在对象实例化时随着对象一块分配在Java堆中static final 修饰的常量(编译期已知值)会在此阶段直接赋值。
  • 解析:将常量池中的符号引用(编号)替换成直接引用(内存地址)。

3、初始化

对类的静态变量、静态代码块执行初始化操作。初始化阶段就是执行类构造器 <clinit>() 方法的过程。<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

以下必须立即对类进行初始化

  1. 在遇到 new、putstatic、getstatic、invokestatic 字节码指令时,如果类尚未初始化,则需要先触发其初始化。能够生成这四条指令的典型Java代码场景有:

  2. 使用new关键字实例化对象的时候。

  3. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。

  4. 调用一个类型的静态方法的时候。

  5. 对类进行反射调用时,如果类还没有初始化,则需要先触发其初始化。

  6. 初始化一个类时,如果其父类还没有初始化,则需要先初始化父类。

  7. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  8. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发其初始化。

  9. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

这 6 种场景中的行为称为对一个类进行主动引用,除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

被动引用例子

/**
 * 被动引用 Demo1:
 * 通过子类引用父类的静态字段,不会导致子类初始化。
 */
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
        // SuperClass init!
    }
}

只会输出“SuperClass init!”,而不会输出“SubClass init!”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

/**
 * 被动引用 Demo2:
 * 通过数组定义来引用类,不会触发此类的初始化。
 */

public class NotInitialization {

    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }
}

运行之后发现没有输出“SuperClass init!”,说明并没有触发类 org.fenixsoft.classloading.SuperClass 的初始化阶段。但是这段代码里面触发了 另一个名为 Lorg.fenixsoft.classloading.SuperClass 的类的初始化阶段,对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令newarray触发。

/**
 * 被动引用 Demo3:
 * 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
 */
class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLO_BINGO = "Hello Bingo";

}

public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_BINGO);
    }

}

没有输出“ConstClass init!”。编译通过之后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 之后就没有任何联系了。

4、卸载

判定一个类可以被卸载。需要同时满足下面三个条件:

  1. 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。

  2. 加载该类的类加载器已经被回收。

  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用。

类加载器

类加载器的作用就是将字节码文件加载到JVM中,让Java程序能够运行起来。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

站在Java虚拟机的角度来看,只存在两种不同的类加载器:

  • 一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;

  • 另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。

站在Java开发人员的角度来看,分为启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器。

类加载器的设计JDK8和8之后的版本差别较大,JDK8及之前的版本,这些版本中默认的类加载器有如下几种:扩展类加载器和应用程序类加载器的源码位于 rt.jar 包中的 sun.misc.Launcher.java

由于JDK9引入了module的概念,类加载器在设计上发生了很多变化。

  1. 启动类加载器使用Java编写,位于 jdk.internal.loader.ClassLoaders 类中。Java中的 BootClassLoader 继承自BuiltinClassLoader 实现从模块中找到要加载的字节码资源文件。启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。

  2. 扩展类加载器被替换成了平台类加载器。平台类加载器遵循模块化方式加载字节码文件,所以继承关系从 URLClassLoader 变成了BuiltinClassLoaderBuiltinClassLoader 实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。

启动类加载器

加载 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。

rt.jar:rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.*都在里面,比如java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*

扩展类加载器

在类 sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。

它负责加载<JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。

由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。

应用程序类加载器

sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的getSystemClassLoader() 方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径 (ClassPath)上所有的类库,可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

自定义类加载器

除了启动类加载器的其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,需要继承 ClassLoader抽象类。

ClassLoader 类有两个关键的方法:

  • protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

类加载有三种方式:

  1. 命令行启动应用时候由JVM初始化加载

  2. 通过Class.forName()方法动态加载

  3. 通过ClassLoader.loadClass()方法动态加载

Class.forName()和ClassLoader.loadClass()区别?

  • Class.forName(): 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
  • ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
  • Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象

双亲委派机制

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

// java.lang.ClassLoader#loadClass()
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
    // 首先,检查请求的类是否已经被加载过了
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
        }
        if (c == null) {
            // 在父类加载器无法加载时
            // 再调用本身的findClass方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}

执行流程:

  1. 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。

  2. 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。

  3. 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

  4. 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

优点

  1. 安全性:比如你写了一个 java.lang.String,不会被加载,避免核心类被篡改。
  2. 避免重复加载:类只会被某个加载器加载一次。
  3. 稳定性:JVM 核心类库优先被父加载器加载,保证系统一致性。

为什么不直接由启动类加载器加载,而是选择从下往上再从上往下?

在生产应用中,95%的类其实都是由应用类加载器加载的,如果类已经被加载过一次,就会被保存在应用类加载器中,下次使用可直接从应用类加载器中获取。但是如果由启动类加载器加载,大部分的类的获取都要走一个 启动类加载器 > 扩展类加载器 > 应用类加载器 的过程,下次使用时也要走一遍上述过程,效率显然比直接从应用类加载器中获取要慢不少,所以为了后续调用节省时间,宁可第一次加载浪费点时间,也要选择应用程序类加载器。

打破双亲委派机制

1、重写loadClass方法

2、SPI机制

SPI 的接口(如 java.sql.Driver)是由 Java 核心库提供的,由BootstrapClassLoader 加载。而 SPI 的实现(如com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的。

但往往在SPI接口中,会经常调用实现者的代码,就需要父类加载器去请求子类加载器完成类加载行为。

Java 的设计者引入了一个不太“优雅”但非常实用的解决方案:线程上下文类加载器

1
2
3
4
5
6
7
// ServiceLoader类 → load()方法
public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取线程上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    // 使用线程上下文类加载器对驱动类进行加载
    return ServiceLoader.load(service, cl);
}

3、热部署、热替换

在复杂的应用容器中(如Tomcat, OSGi),需要实现应用的隔离、模块的动态部署和卸载。如果严格遵循双亲委派,应用A和应用B的类会因为都由应用程序类加载而相互可见,无法隔离。同时,要重启应用才能重新加载类,无法实现热部署。

Tomcat为每个Web应用都创建了一个独立的WebAppClassLoader

  1. 隔离性:为了防止Web应用之间的类库相互污染,Tomcat的类加载器设计修改了双亲委派顺序
  2. 首先自己尝试加载(WEB-INF/classesWEB-INF/lib下的类)。
  3. 如果自己找不到,再委托给父加载器(Common ClassLoader)去加载共享的类库。
  4. 热部署:当检测到WEB-INF下的文件有更新时,Tomcat会创建一个全新的WebAppClassLoader实例来加载新的类,而废弃老的类加载器及其加载的类。由于每个应用使用自己独立的类加载器,替换它就可以实现应用的热部署,而无需重启JVM。

运行时数据区

程序计数器

程序计数器也叫PC寄存器,线程私有,记录当前要执行的的字节码指令的地址

若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined

程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:分支、循环、跳转、异常处理、线程恢复等。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

程序计数器的特点:

  • 是一块较小的内存空间。
  • 线程私有,每条线程都有自己的程序计数器。
  • 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
  • 是唯一一个不会出现 OutOfMemoryError 的内存区域。

Java虚拟机栈

每个线程运行时所需要的内存,称为虚拟机栈。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss 。默认1024k

  • 语法:-Xss栈大小
  • 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)

局部变量表

定义为一个数字数组,主要用于存储方法参数、定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型。

局部变量表容量大小是在编译期确定下来的。最基本的存储单元是槽(slot),32 位类型(byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true、float)占用一个 slot,64 位类型(long 和 double)占用两个 slot。

局部变量表中保存了字节码指令生效的偏移量:

如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this,会存放在 index 为 0 的 slot 处,其余的参数表顺序继续排列。(静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)。

局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。

为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。

所以,上面局部变量表数值的长度为6。这一点在编译期间就可以确定了,运行过程中只需要在栈帧中创建长度为6的数组即可,在方法运行期间不会改变局部变量表的大小。

操作数栈

操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据和中间结果的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。

每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

其他

静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下将调用方的符号引用转为直接引用的过程称为静态链接。

动态链接:如果被调用的方法无法在编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接。

方法出口:指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。

异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

本地方法栈

Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。

在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间

堆是用来存放对象和数组的内存空间,几乎所有的对象都存储在堆中。

由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

堆的特点:

  • 线程共享,整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆。
  • 在虚拟机启动时创建。
  • 是垃圾回收的主要场所。
  • 堆可分为新生代(Eden 区,From SurviorTo Survivor)、老年代。
  • Java 虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • Java7中有一个永久代用来保存类信息、静态变量、常量、编译后的代码,Java8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出。

堆空间有三个需要关注的值,used、total、max。used指的是当前已使用的堆内存,total是java虚拟机已经分配的可用堆内存,max是java虚拟机可以分配的最大堆内存。

如果不设置任何的虚拟机参数,max默认是系统内存的¼,total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值。

设置堆的大小:

要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total)。

语法:-Xmx值 -Xms值

单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)

限制:Xmx必须大于 2 MB,Xms必须大于1MB

Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区是《Java虚拟机规范》中设计的虚拟概念,是一块逻辑区域。

方法区在Hotspot设计如下:

  • JDK8之前的实现叫永久代,是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理,省去专门为方法区编写内存管理代码的工作,但是容易遇到内存溢出问题,极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现)。

  • JDK8及之后的实现叫元空间(metaspace),存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用 -XX:MaxMetaspaceSize=值 将元空间最大大小进行限制。

使用元空间替换永久代的原因:

  1. 提高内存上限:元空间使用的是操作系统内存,而不是JVM内存。如果不设置上限,只要不超过操作系统内存上限,就可以持续分配。而永久代在堆中,可使用的内存上限是有限的。所以使用元空间可以有效减少OOM情况的出现。
  2. 优化垃圾回收的策略:永久代在堆上,垃圾回收机制一般使用老年代的垃圾回收方式,不够灵活。使用元空间之后单独设计了一套适合方法区的垃圾回收机制,而不是使用类的垃圾回收机制。

类的元信息

方法区是用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息。

运行时常量池

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(ConstantPool Table),用于存放编译期生成的各种常量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

字符串常量池

用于存放字符串字面量,位于堆内存中的一块特殊区域。它的目的是为了优化内存使用和提高字符串处理的效率,避免字符串的重复创建。

字符串常量池和运行时常量池有什么关系?

早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分。

字符串常量池从方法区移动到堆的原因:

  1. 垃圾回收优化:字符串常量池的回收逻辑和对象的回收逻辑类似,内存不足的情况下,如果字符串常量池中的常量不被使用就可以被回收;方法区中的类的元信息回收逻辑更复杂一些。移动到堆之后,就可以利用对象的垃圾回收器,对字符串常量池进行回收。
  2. 让方法区大小更可控:一般在项目中,类的元信息不会占用特别大的空间,所以会给方法区设置一个比较小的上限。如果字符串常量池在方法区中,会让方法区的空间大小变得不可控。

案例

对于jvm底层,String str = new String("123")创建对象流程是什么?

  1. 在常量池中查找是否存在"123"这个字符串;若有,则返回对应的引用实例;若无,则创建对应的实例对象;
  2. 在堆中由 new String() 创建对象,并使用常量池中的 "123" 进行初始化。
  3. 将对象地址复制给str,然后创建一个应用。

String str ="ab" + "cd"; 对象个数?

对于 String str3 = "ab" + "cd"; 编译器会给你优化成 String str3 = "abcd";

  1. 字符串常量池:(1个对象)"abcd";
  2. 堆:无
  3. 栈:(1个引用)str

String str = new String("abc"); 对象个数?

若字符串常量池无该字符串对象:

  1. 字符串常量池:(1个对象)"abc";
  2. 堆:(1个对象)new String("abc")
  3. 栈:(1个引用)str

String str = new String("a" + "b"); 对象个数?

若字符串常量池无该字符串对象:

  1. 字符串常量池:(3个对象)"a","b","ab";
  2. 堆:(1个对象)new String("ab")
  3. 栈:(1个引用)str

String str3 = str1 + str2; 对象个数?

引用的值在程序编译期是无法确定的,编译器无法对其进行优化。对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

1
2
3
String str1 = "ab";
String str2 = "cd";
String str3 = str1 + str2;

若字符串常量池无该字符串对象

  1. 字符串常量池:(3个对象)"ab","cd","abcd";
  2. 堆:无
  3. 栈:(3个引用)str1,str2,str3

intern()

如果常量池中存在当前字符串, 就会直接返回当前字符串的引用。 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回该引用。

深入解析String#intern - 美团技术团队

对于String字符串:

第一,使用双引号声明的字符串对象会保存在字符串常量池中。

第二,使用 new 关键字创建的字符串对象会先从字符串常量池中找,如果没找到就创建一个,然后再在堆中创建字符串对象;如果找到了,就直接在堆中创建字符串对象。

第三,针对没有使用双引号声明的字符串对象来说,就像下面代码中的 s1 那样:

String s1 = new String("ab") + new String("cd");

如果想把 s1 的内容也放入字符串常量池的话,可以调用 intern() 方法来完成。

Java 7 之前,执行 String.intern() 方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象; Java 7 及之后,由于字符串常量池放在了堆中,执行 String.intern() 方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。

1
2
3
String s1 = new String("ab") + new String("cd");
String s2 = s1.intern();
System.out.println(s1 == s2); // java7之前:false  java7之后: true

执行过程:

  1. 创建 "ab" 字符串对象,存储在字符串常量池中。
  2. 创建 "cd" 字符串对象,存储在字符串常量池中。
  3. 执行 new String("ab"),在堆上创建一个字符串对象,内容为 "ab"。
  4. 执行 new String("cd"),在堆上创建一个字符串对象,内容为 "cd"。
  5. 执行 new String("ab") + new String("cd"),会创建一个 StringBuilder 对象,并将 "ab" 和 "cd" 追加到其中,然后调用 StringBuilder 对象的 toString() 方法,将其转换为一个新的字符串对象,内容为 "abcd"。这个新的字符串对象存储在堆上。
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}
// JDK6: false false
// JDK7: true false

在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。

而JDK 7的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。

对str2比较返回false,这是因为“java”这个字符串在执行StringBuilder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,不由JVM管理,是操作系统分配的内存区域。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

用户态 Java 堆      ──(1)──►   JVM 内存    ──(2)──►   内核缓冲区   ──(3)──►   硬件设备
直接内存(DirectBuffer)  ──(1)──►   内核缓冲区   ──(2)──►   硬件设备

HotSpot虚拟机对象

对象的创建

对象的创建又是怎样一个过程呢?

1. 类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2. 分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 空闲列表 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式 :

  • 指针碰撞:
  • 适用场合:堆内存规整(即没有内存碎片)的情况下。
  • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
  • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表:
  • 适用场合:堆内存不规整的情况下。
  • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
  • 使用该分配方式的 GC 收集器:CMS

内存分配并发问题:

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。有两种解决方案:

  • CAS+失败重试:虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。避免了多线程竞争的开销,用于提高对象分配效率。

3. 内存初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5. 执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始--构造函数,即<init>() 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>() 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头包括两部分信息:

  1. 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
  2. 类型指针(Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  3. 数组长度:如果对象是一个数组,那在对象头中还必须有一块用于记录数组长度的数据。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针

  • 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

  • 如果使用直接指针访问,reference 中存储的直接就是对象的地址。

使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用直接指针来进行对象访问。

垃圾回收

哪些内存需要回收?什么时候回收?如何回收?

线程不共享的部分(程序计数器、Java虚拟机栈、本地方法栈),都是伴随着线程的创建而创建,线程的销毁而销毁,方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以这一部分不需要垃圾回收器负责回收。

Java堆和方法区这两个区域则有着很显著的不确定性,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

如果需要手动触发垃圾回收,可以调用System.gc()方法。

语法: System.gc()

注意事项:调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。

如何判断对象可以回收

Java中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。

引用计数法

对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。

引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,缺点是存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收,内存泄漏。

虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。

可达性分析法

基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,遍历所有可达对象,凡是无法通过GC Roots对象到达的对象可以被回收。

固定可作为GC Roots的对象包括:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 本地方法栈中引用的对象。
  • 在方法区中类静态属性引用的对象。
  • 在方法区中常量引用的对象,譬如字符串常量池里的引用。
  • 所有被同步锁(synchronized关键字)持有的对象(即该对象作为锁被使用)。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

Java使用的是可达性分析算法来判断对象是否可以被回收。

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。

如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可

对象引用类型

强引用:

Java里传统引用的定义: 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

强引用指传统引用,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

软引用:

软引用是用来描述一些还有用,但非必须的对象。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。在JDK 1.2版之后提供了SoftReference类来实现软引用。

软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用:

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

弱引用的使用场景:缓存系统、对象池、避免内存泄漏

虚引用:

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

垃圾回收算法

Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用。

判断GC算法是否优秀,可以从三个方面来考虑:

  1. 吞吐量。吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
  2. 最大暂停时间。最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短。
  3. 堆使用效率。不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。

标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

优点:不需要移动对象,简单直接。

缺点:

  1. 执行效率不稳定。如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  2. 内存空间的碎片化问题。

标记-复制算法

标记-复制算法常被简称为复制算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

优点:没有碎片化问题,适合新生代对象

缺点:

  1. 内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
  2. 在对象存活率较高时就要进行较多的复制操作,效率将会降低。

标记-整理算法

标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。

标记整理算法的标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

优点:

  1. 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。

  2. 避免内存碎片问题,适合老年代对象

缺点:对象移动操作必须全程暂停用户应用程序才能进行,性能较低。

分代收集算法

分代收集算法将整个内存区域划分为年轻代和老年代:

  • 新生代:复制算法
  • 老年代:标记-清除算法、标记-整理算法

对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值后,如果对象还存活,那么该对象会进入老年代。

原理流程

  1. 新创建出来的对象会被放入Eden区,survivor的两块空间都为空。

  2. 如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC。Minor GC会把eden和From中需要回收的对象回收,把没有回收的对象放入To区,清空Eden区和From区。

  3. 接下来survivor的两个区From和To对换。当eden区满时再往里放入对象,依然会发生Minor GC。每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。

  4. 如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代(survivor区不足或大对象会提前晋升到老年代)。
  5. 当老年代中空间不足,触发Major GC 甚至 Full GC。如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集,包括Eden区和两个Survivor区(S0和S1);
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。目前只有G1收集器会有这种行为。G1衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

整堆收集 (Full GC):新生代和老年代完整垃圾回收。收集整个 Java 堆和方法区。

Minor GC触发条件:Eden区满时。

注意:在G1中,当新生代区域被用完时,G1首先会大概计算一下回收当前的新生代空间需要花费多少时间,如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么不会触发YoungGC,而是会继续为新生代增加新的Region区用于存放新分配的对象实例。直至某次Eden区空间再次被放满并经过计算后,此次回收的耗时接近-XX:MaxGCPauseMills参数设定的值,那么才会触发YoungGC

Major GC触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发Major GC。

当整个堆中年老代的区域占有率达到参数-XX:InitiatingHeapOccupancyPercent设定的值后触发MixedGC

正常情况下,G1垃圾收集时会先发生MixedGC,主要采用复制算法,在GC时先将要回收的Region区中存活的对象拷贝至别的Region区内,拷贝过程中,如果发现没有足够多的空闲Region区承载拷贝对象,此时就会触发一次Full GC

Full GC触发条件

  1. 调用 System.gc 时,系统建议执行Full GC,但是不必然执行。可以通过 -XX:+ DisableExplicitGC 来禁止调用 System.gc()
  2. 老年代空间不足。
  3. 当永久代(Java 8之前的版本)或元空间(Java 8及以后的版本)空间不足时。JVM 规范中运行时数据区域中的方法区,在 HotSpot 虚拟机中也称为永久代,存放一些类信息、常量、静态变量等数据,当系统要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,会触发 Full GC。
  4. 空间担保失败。Minor GC时,如果存活的对象无法全部放入老年代,或者老年代空间不足以容纳存活的对象,则会触发Full GC,对整个堆内存进行回收。
  5. 新生代晋升失败

对象进入老年代的四种情况

  1. 假如进行Minor GC时发现,存活的对象在To Space区中存不下,那么把存活的对象存入老年代
  2. 大对象直接进入老年代
  3. 长期存活的对象将进入老年代:对象的年龄达到阈值
  4. 动态对象年龄判定:如果在From空间中,相同年龄所有对象的大小总和大于Survivor空间的一半,那么年龄大于等于该年龄的对象就会被移动到老年代,而不用等到15岁(默认)。

为什么大对象直接进入老年代?

大对象通常会直接分配到老年代。

新生代主要用于存放生命周期较短的对象,并且其内存空间相对较小。如果将大对象分配到新生代,可能会很快导致新生代空间不足,从而频繁触发 Minor GC。而每次 Minor GC 都需要进行对象的复制和移动操作,这会带来一定的性能开销。将大对象直接分配到老年代,可以减少新生代的内存压力,降低 Minor GC 的频率。

大对象通常需要连续的内存空间,如果在新生代中频繁分配和回收大对象,容易产生内存碎片,导致后续分配大对象时可能因为内存不连续而失败。老年代的空间相对较大,更适合存储大对象,有助于减少内存碎片的产生。

分代收集算法将堆分成年轻代和老年代主要原因有

  1. 不同对象生命周期不同,管理更加高效

  2. 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。

  3. 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。

  4. 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc), STW时间就会减少。

内存分配与回收策略

1、对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

2、大对象直接进入老年代:大对象是指需要大量连续内存空间的 Java 对象,如很长的字符串或数组。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。

G1 垃圾回收器会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。

Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。

3、长期存活的对象将进入老年代:JVM 给每个对象定义了一个对象年龄计数器,存储在对象头中。当新生代发生一次 Minor GC 后,存活下来的对象年龄 +1,当年龄超过一定值(默认15)时,就将超过该值的所有对象转移到老年代中去。

4、动态对象年龄判定:如果当前新生代的 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

5、空间分配担保:空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

JDK 6 Update 24 之前的规则:

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立,Minor GC 可以确保是安全的; 如果不成立,则虚拟机会查看 -XX:HandlePromotionFailure 值是否设置为允许担保失败, 如果是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的; 如果小于,或者 -XX:HandlePromotionFailure 设置不允许冒险,那此时也要改为进行一次 Full GC。

JDK 6 Update 24 之后的规则变为:

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。不再使用HandlePromotionFailure参数。

HotSpot的算法细节实现

根节点枚举

固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。

当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。

在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GCRoots开始查找。

安全点和安全区域

用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?

所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。

记忆集、卡表

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

卡表就是记忆集的一种具体实现,卡表的具体策略是将老年代的空间分成大小为512B的若干张卡。卡表本身是单字节数组,数组中每一个元素对应一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。之后Minnor GC通过扫描卡表就能很快识别哪些老年代引用新生代,避免全堆扫描。

在HotSpot虚拟机里是通过写屏障技术维护卡表状态的,写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,在赋值后完成卡表更新。

1
2
3
4
5
6
void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

CMS和G1在记忆集的维护上有什么不同?

CMS使用卡表来记录老年代中引用新生代的对象,卡表的维护较为简单,老年代对象指向新生代对象时,会触发写屏障并标记相应的卡。

G1的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面的元素为其他Region中每个引用当前区内对象的地址。

三色标记法

CMS和G1在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。

白色是未标记;灰色自身被标记,引用的对象至少一个未标记;黑色自身与引用对象都已标记。

标记过程:

  • 初始状态:所有对象都是白色

  • 标记阶段:从GC Roots开始,将GC Roots直接引用的对象变为灰色,然后递归扫描所有灰色对象,将其引用的对象变为灰色,当灰色对象所有引用的对象都变为灰色后,它变为黑色。

  • 最终状态:所有存活的对象都被标记为黑色,白色的为垃圾。

并发标记中的对象消失问题:即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用。由于黑色对象不会重新扫描,将导致扫描结束后出现被黑色引用的白色对象,这个对象会消失。
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

对象消失问题解决方案(如何维持并发的正确性):

  • 增量更新:更新引用时记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
  • 原始快照:保留GC开始时的对象图关系,并发标记过程会以最初的对象图关系进行访问,就算并发标记过程中某个对象的引用信息发生了改变,G1会通过写前屏障记录原有的对象引用关系,依旧会按照最初的对象图快照进行标记。

CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

垃圾回收器

垃圾回收器是垃圾回收算法的具体实现。

由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用。

JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):

  • JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK22: G1

Serial/Serial Old

串行垃圾回收器是一个单线程工作的收集器,只会使用一条收集线程去完成垃圾收集工作,它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

适用场景:处理器核心数少、堆内存小的客户端。

ParNew

ParNew收集器实质上是Serial收集器的多线程并行版本。ParNew 追求降低用户停顿时间,适合交互式应用。

适用场景:适用于多处理器核心环境,配合CMS使用,是激活CMS后的默认新生代收集器。

Parallel Scavenge/Parallel Old

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,其他收集器关注STW,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(运行用户代码时间/处理器总消耗时间)。

吞吐量优先收集器是JDK8默认的年轻代垃圾回收器,基于标记-复制算法。

Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。

  • 最大暂停时间,-XX:MaxGCPauseMillis=n 设置每次垃圾回收时的最大停顿毫秒数
  • 吞吐量,-XX:GCTimeRatio=n 设置吞吐量为n(意味着垃圾收集时间占总时间的比例 = 1/n + 1)
  • 自动调整内存大小, -XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

适用场景:适合大规模并行计算场景,高吞吐量要求的任务,及在后台运算而不需要太多交互的分析任务。

CMS (Concurrent Mark Sweep)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短停顿时间(STW)为目标的收集器,是基于标记-清除算法实现。

整个过程分为四个步骤:

  1. 初始标记:标记 GC Roots 直接关联的对象,速度很快,需要停顿。

  2. 并发标记:标记从 GC Roots 开始所有可达的对象,速度慢,并发运行。

  3. 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。使用到增量更新。

  4. 并发清除:清理掉已经死亡的对象,并发运行。

并发标记与并发清除过程耗时最长,且可以与用户线程一起工作,因此,总体上说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

优点:并发收集、低停顿

缺点:

  1. CMS使用了标记-清除算法,在垃圾收集结束之后会出现内存碎片。
  2. 无法处理“浮动垃圾”。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象只能留待下一次垃圾收集时再清理掉。
  3. 在垃圾收集阶段用户线程还需要持续运行,需要预留足够内存空间提供给用户线程使用。如果预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”,然后冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集。
  4. 对处理器资源敏感。并发阶段会占用一部分处理器资源,降低吞吐量。

适用场景:响应速度要求高的应用

G1 (Garbage First)

JDK9之后默认的垃圾回收器是G1垃圾回收器。G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

G1将整个堆会划分成多个大小相等的独立区域(Region),每一个Region都可以作为Eden、Survivor、Old区。(默认数量限制为2048个,默认新生代对堆内存的初始占比是5%)。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

从整体上看, G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。

G1收集器的工作过程:并发标记、转移

  1. 初始标记:标记 GC Roots 直接关联到的对象,速度很快,需要停顿。

  2. 并发标记:标记从 GC Roots 开始所有可达的对象。

  3. 最终标记:处理并发标记阶段变动的对象,需要停顿。使用到原始快照

  4. 筛选回收:更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis 指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

优点:

  1. 无内存碎片,浮动垃圾
  2. 极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量

缺点:

  1. 占用内存高
垃圾回收器 使用范围 STW 垃圾回收算法 回收过程 浮动垃圾 使用场景
CMS 老年代 以最小的停顿时间为目标 标记-清除 1. 初始标记 2. 并发标记 3. 重新标记 4. 并发清除 产生浮动垃圾 低延迟需求;老年代收集;
G1 新生代和老年代 可预测垃圾回收的停顿时间 复制、标记-整理 1. 初始标记 2. 并发标记 3. 最终标记 4. 筛选回收 没有浮动垃圾 大堆内存;低停顿时间;高吞吐量

细节问题

  1. 跨代引用如何解决?使用记忆集避免全堆作为GC Roots扫描。每个Region都维护有自己的记忆集,记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
  2. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?原始快照;新创建对象的内存分配上,G1把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,G1默认该区域对象是存活的,不纳入回收范围。

Shenandoah GC

能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器。

Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的 Region。

与G1不同的是:

  1. G1的回收阶段是多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能(与用户线程并发回收)
  2. Shenandoah是默认不使用分代收集的,不会有专门的新生代Region或者老年代Region的存在。
  3. Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记。

工作过程:并发标记、并发回收、并发引用更新

  1. 初始标记:与G1一样,首先标记与GC Roots直接关联的对象,需要停顿。
  2. 并发标记:标记出全部可达的对象,这个阶段是与用户线程一起并发的。
  3. 最终标记:处理并发标记阶段变动的对象,同时统计出回收价值最高的Region,将这些Region构成一组回收集。
  4. 并发清理:清理那些整个区域内连一个存活对象都没有找到的Region。
  5. 并发回收:Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。使用读屏障和转发指针
  6. 初始引用更新:并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。初始引用更新时间很短,会产生一个非常短暂的停顿。
  7. 并发引用更新:真正开始进行引用更新操作,这个阶段是与用户线程一起并发的。
  8. 最终引用更新:解决了堆中的引用更新后,还要修正存在于GC Roots 中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
  9. 并发清理:经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

并发回收的难点?

Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。

其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。

Shenandoah 怎么解决的?

Shenandoah通过读屏障和被称为“Brooks Pointers”的转发指针来解决。对象头包含了一个额外的字段 Brooks Pointer,在正常不处于并发移动的情况下,该引用指向对象自己。当垃圾收集器在压缩或整理内存时,可能会将对象从一个位置移动到另一个位置。此时,原对象的 Brooks Pointer 会被更新,指向新位置。

ZGC

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)。

ZGC是一款基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

在ZGC中,也会把堆空间划分为一个个的Region区域,但ZGC中的Region区不存在分代的概念,它仅仅只是简单的将所有Region区分为了大、中、小三个等级。

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。

染色指针其实就是从 64 位的指针中,拿几位来标识对象此时的情况,分别表示 Marked0、Marked1、Remapped、Finalizable。

0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB (理论上可以16TB)的内存,因为就 42 位用来表示地址。也因此 ZGC 不支持 32 位指针,也不支持指针压缩。其实对象只需要两个状态Marked,Remapped,对象被标记和被重新映射,但为什么会有M0、M1两个标识呢?这是因为需要用来区分本次GC标记和上次GC标记。

  1. 在垃圾回收开始前:Remapped
  2. 标记过程:
  3. 标记线程访问:发现对象地址视图是 Remapped 这时候将指针标记为 M0;发现对象地址视图是 M0,则说明这个对象是标记开始之后新分配的或者已经标记过的对象,所以无需处理。
  4. 应用线程:如果创建新对象,则将其地址视图置为 M0。

  5. 标记阶段结束后,ZGC 会使用一个对象活跃表来存储这些对象地址,此时活跃的对象地址视图是 M0。

  6. 并发转移阶段

  7. 转移线程:转移成功后对象地址视图被置为 Remapped(也就是说 GC 线程如果访问到对象,此时对象地址视图是 M0,并且存在或活跃表中,则将其转移,并将地址视图置为 Remapped ) 如果在活跃表中,但是地址视图已经是 Remapped 说明已经被转移了,不做处理。
  8. 应用线程:如果创建新对象,地址视图会设为 Remapped。

  9. 下次标记使用M1。M1 标识本次垃圾回收中活跃的对象;M0 是上一次回收被标记的对象,但是没有被转移,且在本次回收中也没有被标记活跃的对象。

下图展示了Marked,Remapped的过程:

(1)初始化时A,B,C三个对象处于Remapped状态;

(2)第一次GC,A被转移,B未被转移,C无引用将被回收;

(3)第二次GC,由于A被转移过了(Remapped状态),所以被标记M1,此时恰好B为不活跃对象,将被清理;

(4)第三次GC,A又被标记成M0。

ZGC收集器在发生GC时,其实主要操作只有三个:标记、转移与重定位。

  • 标记:从根节点出发标记所有存活对象。
  • 转移:将需要回收区域中的存活对象转移到新的分区中。
  • 重定位:将所有指向转移前地址的指针更改为指向转移后的地址。

ZGC有多种GC触发机制,总结如下:

  • 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
  • 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
  • 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
  • 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。 日志中关键字是“Proactive”。
  • 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
  • 外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
  • 元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。

方法区的回收

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

判定一个类可以被卸载。需要同时满足下面三个条件:

  1. 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。

  2. 加载该类的类加载器已经被回收。

  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

其他

栈上的数据存储

boolean、byte、char、short在栈上是不是存在空间浪费?

是的,Java虚拟机采用的是空间换时间方案,在栈上不存储具体的类型,只根据slot槽进行数据的处理,浪费了一些内存空间但是避免不同数据类型不同处理方式带来的时间开销。

同时,像long型在64位系统中占用2个slot,使用了16字节空间,但实际上在Hotspot虚拟机中,它的高8个字节没有使用,这样就满足了long型使用8个字节的需要。

boolean数据类型保存方式

1、常量1先放入局部变量表,相当于给a赋值为true。

2、将操作数栈上的值和1与0比较(判断a是否为false),相等跳转到偏移量17的位置,不相等继续向下运行。

3、将局部变量表a的值取出来放到操作数栈中,再定义一个常量1,比对两个值是否相等。其实就是判断a == true,如果相等继续向下运行,不相等跳转到偏移量41也就是执行else部分代码

在Java虚拟机中栈上boolean类型保存方式与int类型相同,所以它的值如果是1代表true,如果是0代表false。

栈中的数据要保存到堆上或者从堆中加载到栈上时怎么处理?

  1. 堆中的数据加载到栈上,由于栈上的空间大于或者等于堆上的空间,所以直接处理但是需要注意下符号位。

boolean、char为无符号,低位复制,高位补0

byte、short为有符号,低位复制,高位非负则补0,负则补1

  1. 栈中的数据要保存到堆上,byte、char、short由于堆上存储空间较小,需要将高位去掉。boolean比较特殊,只取低位的最后一位保存。

方法调用的原理

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)。

方法调用的本质是通过字节码指令的执行,能在栈上创建栈帧,并执行调用方法中的字节码执行。以invoke开头的字节码指令的作用是执行方法的调用。

在JVM中,一共有五个字节码指令可以执行方法调用:

  1. invokestatic:调用静态方法。

  2. invokespecial: 调用对象的private方法、构造方法,以及使用 super 关键字调用父类实例的方法、构造方法,以及所实现接口的默认方法。

  3. invokevirtual:调用对象的虚方法(可以被重写的方法)。

  4. invokeinterface:调用接口对象的方法。

  5. invokedynamic:用于调用动态方法,主要应用于lambda表达式中,机制极为复杂了解即可。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。

虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。

类型在方法区中建立一个虚方法表

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。

产生invokevirtual调用时,先根据对象头中的类型指针找到方法区中InstanceClass对象,获得虚方法表。再根据虚方法表找到对应的方法的地址,最后调用方法。

异常捕获的原理

在Java中,程序遇到异常时会向外抛出,此时可以使用try-catch捕获异常的方式将异常捕获并继续让程序按程序员设计好的方式运行。比如如下代码:在try代码块中如果抛出了Exception对象或者子类对象,则会进入catch分支。

异常捕获机制的实现,需要借助于编译时生成的异常表

异常表在编译期生成,存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

  • 起始/结束PC:此条异常捕获生效的字节码起始/结束位置。

  • 跳转PC:异常捕获之后,跳转到的字节码位置。

程序运行中触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。

1、如果匹配,跳转到“跳转PC”对应的字节码位置。

2、如果遍历完都不能匹配,说明异常无法在当前方法执行时被捕获,此方法栈帧直接弹出,在上一层的栈帧中进行异常捕获的查询。

多个catch分支情况下,异常表会从上往下遍历,先捕获RuntimeException,如果捕获不了,再捕获Exception。

finally的处理方式就相对比较复杂一点了,分为以下几个步骤:

1、finally中的字节码指令会插入到try 和 catch代码块中,保证在try和catch执行之后一定会执行finally中的代码。

如下,在i=1i=2两段字节码指令之后,都加入了finally下的字节码指令。

2、如果抛出的异常范围超过了Exception,比如Error或者Throwable,此时也要执行finally,所以异常表中增加了两个条目。覆盖了try和catch两段字节码指令的范围,any代表可以捕获所有种类的异常。

后端编译与优化

JIT

Just-In-Time(即时编译)是一种在运行时将字节码转换为机器码的技术,主要用于提高Java程序的执行性能。运行时发现热点代码,就将这段代码编译成机器码,减少解释执行的开销。

原理:

字节码执行:当Java程序运行时,JVM首先将 .class 文件中的字节码加载到内存,并通过解释器逐行执行这些字节码。

热点代码检测:在执行过程中,JVM会监控代码的执行效率。频繁执行的代码(被多次调用的方法、被多次执行的循环体)被称为热点代码。

JIT编译:一旦某块代码被识别为热点代码,JIT编译器就会将这段字节码编译成本地机器码,并存储到内存中。

优化编译:可以使用方法内联、逃逸分析、循环展开、消除冗余计算等提高性能。

垃圾回收:JIT编译的本地代码会保存在内存(Code Cache,不在堆中)中,直到Java程序运行结束活JVM执行垃圾回收。

AOT

Ahead-Of-Time(预编译)是一种在运行前就将字节码编译成机器码的技术。

优点:减少运行时编译的开销,减少程序启动所需的编译时间,提高启动速度;减少了JVM内存占用

缺点:无法像JIT那样利用运行时的动态信息进行深度优化;缺乏跨平台的灵活性。

编译器优化技术

方法内联

方法内联就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用。

主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等);二是为其他优化建立良好的基础。

逃逸分析

分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸

甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸

如果对象不会逃逸,会进行以下优化:

栈上分配:如果对象没有逃逸出当前线程,JVM可以将对象分配到栈上,而不是堆中,减少堆内存的分配和垃圾回收的开销。

标量替换:如果对象没有逃逸且可以分解,JVM可能将该对象的字段替换为标量(如基本类型),避免对象的内存分配。

同步消除:如果对象只在线程内部使用且不会逃逸,JVM会移除不必要的同步锁,提升性能。

虚拟机性能监控、故障处理工具

基础故障处理工具

jps:虚拟机进程状况工具

可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID

1
2
3
4
5
6
7
jps [ options ] [ hostid ]

# options:
-q:只输出唯一ID
-m:输出虚拟机启动时传递给主类的参数
-l:输出主类的全类名,或JAR包路径
-v:输出虚拟机启动时JVM参数

jstat:虚拟机统计信息监视工具

显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据

jstat [ option vmid [interval[s|ms] [count]] ]

# 参数interval和count代表查询间隔和次数
# option: https://docs.oracle.com/javase/8/docs/technotes/tools/windows/jstat.html

# jstat -gc vmid:显示与 GC 相关的堆信息;
# jstat -gccapacity vmid:显示各个代的容量及使用情况;
# jstat -gcnew vmid:显示新生代信息;
# jstat -gcnewcapcacity vmid:显示新生代大小与使用情况;
# jstat -gcold vmid:显示老年代和永久代的行为统计,从 jdk1.8 开始,该选项仅表示老年代,因为永久代被移除了;
# jstat -gcoldcapacity vmid:显示老年代的大小;
# jstat -gcpermcapacity vmid:显示永久代大小,从 jdk1.8 开始,该选项不存在了,因为永久代被移除了;
# jstat -gcutil vmid:显示垃圾收集信息;

jinfo:Java配置信息工具

实时查看和调整虚拟机各项参数

1
2
3
4
jinfo [ option ] pid

# jinfo vmid :输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。
# jinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数。

jmap:Java内存映像工具

用于生成堆转储快照。

-XX:+HeapDumpOnOutOfMemoryError 参数,可以让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件。

1
2
3
jmap [ option ] vmid

# jmap -dump:format=b file=<文件名XX.hprof> <pid>

jstack:Java堆栈跟踪工具

用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者 javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。

1
2
3
4
5
6
jstack [ option ] vmid

# option:
-F: 正常输出的请求不被响应时,强制输出线程堆栈
-l: 除堆栈外显示关于锁的附加信息
-m: 调用本地方法的话可以显示C/C++的堆栈

可视化故障处理工具

jconsole:用于对JVM的内存、线程、类的监控

VisualVM:能够监控线程,内存情况

Arthas:监控方法调用、SQL查询、分析性能瓶颈

MAT:内存分析工具

调优

JVM调优参数:

JVM参数配置_Serverless 应用引擎(SAE)-阿里云帮助中心 (aliyun.com)

-Xms:初始化堆大小

-Xmx:最大堆大小

-Xmn:年轻代大小

-Xss:栈大小

-XX:MetaSpaceSize:初始化元空间大小

-XX:MaxMetaSpaceSize:最大元空间大小

-XX:+HeapDumpOnOutOfMemoryError:发生内存溢出时生成堆转储

-XX:+PrintGCDetail:打印垃圾回收日志

-XX:+UseG1GC:使用G1垃圾回收器

-XX:+UseConcMarkSweepGC:使用CMS

内存泄漏的排查思路?

  1. 使用jstat监控内存,查看内存是否GC后没有明显减少或频繁触发GC

  2. 获取堆转储文件:使用jmap或者添加虚拟机参数的方式生成dump文件

  3. VisualVM去分析dump文件
  4. 分析代码,查看哪里出现内存泄漏。

CPU飙高排查方案和思路?

  1. 使用top命令查看cpu占用情况并定位进程
  2. 使用ps命令查看进程的线程信息
  3. 使用jstack命令查看进程中哪些线程出现了问题,定位问题

如何对垃圾回收进行调优?

GC调优的核心思路是尽可能使对象在年轻代被回收,减少对象进入老年代。

根据GC日志分析,常见的需要关注的指标是 Young GC和 Full GC触发频率、原因、晋升的速率、老年代内存占用等。

采取的措施包括:增大survior区避免太小导致对象提前进入老年代;增大晋升年龄

GC 问题分类

Java中9种常见的CMS GC问题分析与解决 - 美团技术团队

  • Unexpected GC: 意外发生的 GC,实际上不需要发生,我们可以通过一些手段去避免。
  • Space Shock: 空间震荡问题,参见“场景一:动态扩容引起的空间震荡”。
  • Explicit GC: 显示执行 GC 问题,参见“场景二:显式 GC 的去与留”。
  • Partial GC: 部分收集操作的 GC,只对某些分代/分区进行回收。
  • Young GC: 分代收集里面的 Young 区收集动作,也可以叫做 Minor GC。
    • ParNew: Young GC 频繁,参见“场景四:过早晋升”。
  • Old GC: 分代收集里面的 Old 区收集动作,也可以叫做 Major GC,有些也会叫做 Full GC,但其实这种叫法是不规范的,在 CMS 发生 Foreground GC 时才是 Full GC,CMSScavengeBeforeRemark 参数也只是在 Remark 前触发一次Young GC。
    • CMS: Old GC 频繁,参见“场景五:CMS Old GC 频繁”。
    • CMS: Old GC 不频繁但单次耗时大,参见“场景六:单次 CMS Old GC 耗时长”。
  • Full GC: 全量收集的 GC,对整个堆进行回收,STW 时间会比较长,一旦发生,影响较大,也可以叫做 Major GC,参见“场景七:内存碎片&收集器退化”。
  • MetaSpace: 元空间回收引发问题,参见“场景三:MetaSpace 区 OOM”。
  • Direct Memory: 直接内存(也可以称作为堆外内存)回收引发问题,参见“场景八:堆外内存 OOM”。
  • JNI: 本地 Native 方法引发问题,参见“场景九:JNI 引发的 GC 问题”。

场景一:动态扩容引起的空间震荡

现象:服务刚刚启动时 GC 次数较多,最大空间剩余很多但是依然发生 GC。

原因:在 JVM 的参数中 -Xms-Xmx 设置的不一致,在初始化时只会初始 -Xms 大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次 GC。另外,如果空间剩余很多时也会进行缩容操作,JVM 通过 -XX:MinHeapFreeRatio-XX:MaxHeapFreeRatio 来控制扩容和缩容的比例。

解决:尽量将成对出现的空间大小配置参数设置成固定的,如 -Xms-Xmx-XX:MaxNewSize-XX:NewSize-XX:MetaSpaceSize-XX:MaxMetaSpaceSize 等。

一般来说,我们需要保证 Java 虚拟机的堆是稳定的,确保 -Xms-Xmx 设置的是一个值(即初始值和最大值一致),获得一个稳定的堆,同理在 MetaSpace 区也有类似的问题。不过在不追求停顿时间的情况下震荡的空间也是有利的,可以动态地伸缩以节省空间,例如作为富客户端的 Java 应用。

场景二:显式 GC 的去与留

代码中手动调用了 System.gc 方法触发GC

如果禁用掉的话就会带来另外一个内存泄漏问题,因为为 DirectByteBuffer 分配空间过程中会显式调用 System.gc ,希望通过 Full GC 来强迫已经无用的 DirectByteBuffer 对象释放掉它们关联的 Native Memory。

此时就需要说一下 DirectByteBuffer,它有着零拷贝等特点,被 Netty 等各种 NIO 框架使用,会使用到堆外内存。堆内存由 JVM 自己管理,堆外内存必须要手动释放,DirectByteBuffer 没有 Finalizer,它的 Native Memory 的清理工作是通过 sun.misc.Cleaner 自动完成的,是一种基于 PhantomReference 的清理工具,比普通的 Finalizer 轻量些。

场景三:MetaSpace 区 OOM

现象:JVM 在启动后或者某个时间点开始,MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决

原因:ClassLoader 不停地在内存中 load 了新的 Class ,一般这种问题都发生在动态类加载等情况上。

解决:了解大概什么原因后,如何定位和解决就很简单了,可以 dump 快照之后通过 JProfiler 或 MAT 观察 Classes 的 Histogram(直方图) 即可,或者直接通过命令即可定位, jcmd 打几次 Histogram 的图,看一下具体是哪个包下的 Class 增加较多就可以定位了。

场景四:过早晋升

现象

GC 日志中出现“Desired survivor size 107347968 bytes, new threshold 1(max 6)”等信息,说明此时经历过一次 GC 就会放到 Old 区。

Full GC 比较频繁,且经历过一次 GC 之后 Old 区的变化比例非常大。比如说 Old 区触发的回收阈值是 80%,经历过一次 GC 之后下降到了 10%。

过早晋升的危害:

  • Young GC 频繁,总的吞吐量下降。
  • Full GC 频繁,可能会有较大停顿。

原因

主要的原因有以下两点:

  • Young/Eden 区过小: 过小的直接后果就是 Eden 被装满的时间变短,本应该回收的对象参与了 GC 并晋升,Young GC 采用的是复制算法,由基础篇我们知道 copying 耗时远大于 mark,也就是 Young GC 耗时本质上就是 copy 的时间(CMS 扫描 Card Table 或 G1 扫描 Remember Set 出问题的情况另说),没来及回收的对象增大了回收的代价,所以 Young GC 时间增加,同时又无法快速释放空间,Young GC 次数也跟着增加。
  • 分配速率过大: 可以观察出问题前后 Mutator 的分配速率,如果有明显波动可以尝试观察网卡流量、存储类中间件慢查询日志等信息,看是否有大量数据被加载到内存中。
  • 动态对象年龄判定:如果当前新生代的 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

解决

如果是 Young/Eden 区过小,我们可以在总的 Heap 内存不变的情况下适当增大 Young 区,具体怎么增加?一般情况下 Old 的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给 Young 区。

如果是分配速率过大:

  • 偶发较大:通过内存分析工具找到问题代码,从业务逻辑上做一些优化。
  • 一直较大:当前的 Collector 已经不满足 Mutator 的期望了,这种情况要么扩容 Mutator 的 VM,要么调整 GC 收集器类型或加大空间。

场景五:CMS Old GC 频繁

现象:Old 区频繁的做 CMS GC,但是每次耗时不是特别长,整体最大 STW 也在可接受范围内,但由于 GC 太频繁导致吞吐下降比较多。

TODO

场景六:单次 CMS Old GC 耗时长

现象:CMS GC 单次 STW 最大超过 1000ms,不会频繁发生,如下图所示最长达到了 8000ms。

TODO

场景七:内存碎片&收集器退化

现象

并发的 CMS GC 算法,退化为 Foreground 单线程串行 GC 模式,STW 时间超长,有时会长达十几秒。其中 CMS 收集器退化后单线程串行 GC 算法有两种:

  • 带压缩动作的算法,称为 MSC,使用标记-清理-压缩,单线程全暂停的方式,对整个堆进行垃圾收集,也就是真正意义上的 Full GC,暂停时间要长于普通 CMS。
  • 不带压缩动作的算法,收集 Old 区,和普通的 CMS 算法比较相似,暂停时间相对 MSC 算法短一些。

原因

CMS 发生收集器退化主要有以下几种情况:

  1. 晋升失败(Promotion Failed):晋升失败就是指在进行 Young GC 时,Survivor 放不下,对象只能放入 Old,但此时 Old 也放不下。直觉上乍一看这种情况可能会经常发生,但其实因为有 concurrentMarkSweepThread 和担保机制的存在,发生的条件是很苛刻的,除非是短时间将 Old 区的剩余空间迅速填满,例如上文中说的动态年龄判断导致的过早晋升(见下文的增量收集担保失败)。另外还有一种情况就是内存碎片导致的 Promotion Failed,Young GC 以为 Old 有足够的空间,结果到分配时,晋级的大对象找不到连续的空间存放。
  2. 增量收集担保失败:分配内存失败后,会判断统计得到的 Young GC 晋升到 Old 的平均大小,以及当前 Young 区已使用的大小也就是最大可能晋升的对象大小,是否大于 Old 区的剩余空间。只要 CMS 的剩余空间比前两者的任意一者大,CMS 就认为晋升还是安全的,反之,则代表不安全,不进行Young GC,直接触发Full GC。
  3. 显示GC
  4. 并发模式失败(Concurrent Mode Failure):发生概率较高的一种,在 GC 日志中经常能看到 Concurrent Mode Failure 关键字。这种是由于并发 Background CMS GC 正在执行,同时又有 Young GC 晋升的对象要放入到了 Old 区中,而此时 Old 区空间不足造成的。

为什么 CMS GC 正在执行还会导致收集器退化呢?

主要是由于 CMS 无法处理浮动垃圾(Floating Garbage)引起的。CMS 的并发清理阶段,Mutator 还在运行,因此不断有新的垃圾产生,而这些垃圾不在这次清理标记的范畴里,无法在本次 GC 被清除掉,这些就是浮动垃圾,除此之外在 Remark 之前那些断开引用脱离了读写屏障控制的对象也算浮动垃圾。所以 Old 区回收的阈值不能太高,否则预留的内存空间很可能不够,从而导致 Concurrent Mode Failure 发生。

解决

分析到具体原因后,我们就可以针对性解决了,具体思路还是从根因出发,具体解决策略:

  • 内存碎片: 通过配置 -XX:UseCMSCompactAtFullCollection=true 来控制 Full GC的过程中是否进行空间的整理(默认开启,注意是Full GC,不是普通CMS GC),以及 -XX: CMSFullGCsBeforeCompaction=n 来控制多少次 Full GC 后进行一次压缩。
  • 增量收集: 降低触发 CMS GC 的阈值,即参数 -XX:CMSInitiatingOccupancyFraction 的值,让 CMS GC 尽早执行,以保证有足够的连续空间,也减少 Old 区空间的使用大小,另外需要使用 -XX:+UseCMSInitiatingOccupancyOnly 来配合使用,不然 JVM 仅在第一次使用设定值,后续则自动调整。
  • 浮动垃圾: 视情况控制每次晋升对象的大小,或者缩短每次 CMS GC 的时间,必要时可调节 NewRatio 的值。另外就是使用 -XX:+CMSScavengeBeforeRemark 在过程中提前触发一次 Young GC,防止后续晋升过多对象。

场景八:堆外内存 OOM

现象:内存使用率不断上升,甚至开始使用 SWAP 内存,同时可能出现 GC 时间飙升,线程被 Block 等现象,通过 top 命令发现 Java 进程的 RES 甚至超过了 -Xmx 的大小。出现这些现象时,基本可以确定是出现了堆外内存泄漏。

原因

JVM 的堆外内存泄漏,主要有两种的原因:

  • 通过 UnSafe#allocateMemoryByteBuffer#allocateDirect 主动申请了堆外内存而没有释放,常见于 NIO、Netty 等相关组件。
  • 代码中有通过 JNI 调用 Native Code 申请的内存没有释放。

解决

  • 原因一:主动申请未释放:JVM 使用 -XX:MaxDirectMemorySize=size 参数来控制可申请的堆外内存的最大值。在 Java8 中,如果未配置该参数,默认和 -Xmx 相等。
  • 原因二:通过 JNI 调用的 Native Code 申请的内存未释放:可以通过 Google perftools + Btrace 等工具,帮助我们分析出问题的代码在哪里。

场景九:JNI 引发的 GC 问题

由于 Native 代码直接使用了 JVM 堆区的指针,如果这时发生 GC,就会导致数据错误。因此,在发生此类 JNI 调用时,禁止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。

可能导致的不良后果有:

  • 如果此时是 Young 区不够 Allocation Failure 导致的 GC,由于无法进行 Young GC,会将对象直接分配至 Old 区。
  • 如果 Old 区也没有空间了,则会等待锁释放,导致线程阻塞。
  • 可能触发额外不必要的 Young GC,JDK 有一个 Bug,有一定的几率,本来只该触发一次 GCLocker Initiated GC 的 Young GC,实际发生了一次 Allocation Failure GC 又紧接着一次 GCLocker Initiated GC。是因为 GCLocker Initiated GC 的属性被设为 full,导致两次 GC 不能收敛。