嘘~ 正在从服务器偷取页面 . . .

JVM 八股文


0. JVM

JVM(Java Virtual Machine)是 Java 虚拟机的缩写,它是一个虚拟的计算机,具有与实际计算机相同的功能。JVM 可以执行 Java 字节码文件,这些文件通常由 Java 源代码编译而来。JVM 的主要作用是将 Java 字节码转换为特定于操作系统的机器指令,从而使得 Java 程序能够在不同的操作系统上运行。

JVM 还负责管理内存、垃圾回收和安全检查等任务。它提供了一个独立于硬件和操作系统的运行环境,使得开发人员可以专注于编写跨平台的应用程序。

1. 内存区域

线程私有的运行时数据区::程序计数器、Java 虚拟机栈、本地方法栈。

线程共享的运行时数据区:Java 堆、方法区。

JVM 整体结构

Java 代码执行流程

1.1 类加载子系统

在 Java 程序运行时,所有的 Java 类都需要被加载到 JVM(Java 虚拟机)中才能被执行。Java 类加载器(Class Loader)是 Java 运行时环境的组成部分之一,负责将 Java 类加载到 JVM 中。

Java 类加载器通常会根据类的名称和类文件的位置来查找和加载 Java 类。当一个 Java 程序需要使用某个类时,Java 类加载器会先检查该类是否已经被加载到 JVM 中,如果没有,就会在指定的位置查找该类的字节码文件并将其加载到 JVM 中。

1.1.1 类加载过程

  • 类加载器子系统负责从文件系统或者网络中加载 class 文件,class 文件在文件开头有特定的文件标识;

  • ClassLoader 只负责 class 文件的加载,运行则由 Execution Engine 决定;

  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 class 文件中常量池部分的内存映射)。

类加载子系统结构

/**
 *示例代码
 */
public class HelloLoader {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

示例代码执行过程

加载

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

加载 Class 文件的方式:

  • 本地系统中直接加载;

  • 通过网络获取,典型场景:Web Applet;

  • 从 zip 压缩包中读取,成为日后 jar、war 格式的基础;

  • 运行时计算生成,使用最多的是:动态代理技术(利用反射实现动态类加载);

  • 由其他文件生成,典型场景:JSP 应用;

  • 从专有数据库中提取 .class 文件,比较少见;

  • 从加密文件中获取,典型的防 Class 文件被反编译的保护措施。

链接

验证(Verify)

确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。

主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证:

验证方法的具体内容

一个类方法的字节码没有通过字节码验证,那肯定是有问题。如果一个方法体通过字节码验证,也不能表示一定就是安全的。程序无法校验程序员的代码逻辑问题。

准备(Prepare)

为类变量分配内存并且设置该类变量的默认初始值,即零值。

不包含用 final 修饰的 static 变量,因为 final 在编译的时候就会分配,准备阶段会显式初始化。不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。

深入理解 Java 虚拟机第三版 规定零值

解析(Resolve)

将常量池内的符号引用( 以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可)转换为直接引用(可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。区别于符号引用就是直接引用必须引用的目标已经在内存中存在)的过程;

解析操作往往会伴随着 JVM 在执行完初始化之后再执行。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等。

初始化

初始化阶段就是执行类构造器方法 <clinit>() 的过程。此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。构造器方法中指令按语句在源文件中出现的顺序执行。

<clinit>()不同于类的构造器(关联:构造器是虚拟机视角下的 <init>())。

若该类具有父类,JVM会保证子类的 <clinit>() 执行前,父类的 <clinit>() 已经执行完毕。虚拟机必须保证一个类的 <clinit>() 方法在多线程下被同步加锁。

最后做个总结,JVM 的类加载机制,总共分为 5 部分,加载,验证,准备,解析,初始化。

类加载子系统三步骤总结

1.1.2 类加载器

JVM 支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的类加载器,但是 Java 虚拟机规范将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器

类加载器继承顺序

类加载器加载图示

虚拟机自带类加载器

Java 类加载器通常分为三种类型:启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)和应用程序类加载器(Application Class Loader)。其中启动类加载器是 JVM 自身的一部分,用来加载 Java 运行环境的核心类库,而扩展类加载器和应用程序类加载器则是由 Java 应用程序开发人员自己实现的,用来加载应用程序的类和库。

启动类加载器(引导类加载器,Bootstrap ClassLoader)

使用 C/C++ 语言实现的,嵌套在 JVM 内部。用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类。

并不继承自 java.lang.ClassLoader,没有父加载器。加载扩展类和应用程序类加载器,并指定为他们的父类加载器。出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类。

扩展类加载器(Extension ClassLoader)

Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现,派生于 ClassLoader 类,父类加载器为启动类加载器。

从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/1ib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。主要负责加载 Java 的扩展类库。

应用程序类加载器(系统类加载器,AppClassLoader)

Java 语言编写,由 sun.misc.Launcher$AppClassLoader 实现,派生于 ClassLoader 类,父类加载器为扩展类加载器;

负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库,该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载。通过 ClassLoader.getSystemclassLoader() 方法可以获取到该类加载器。

用户自定义类加载器

在 Java 的日常应用程序开发中,类的加载几乎是由上述 3 种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。 为什么要自定义类加载器?

  • 隔离加载类;

  • 修改类加载的方式;

  • 扩展加载源;

  • 防止源码泄漏。

1.1.3 双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

加载过程:除了启动类加载器,其余所有的类加载器都有父加载器,他们之间为组合关系。

双亲委派加载过程:

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

双亲委派机制的实现图示

优势:

  • 避免类的重复加载;
  • 保护程序安全,防止核心API被随意篡改:
    • 自定义类:java.lang.String;
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)。

沙箱安全机制

自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class)。

报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 String 类。这样可以保证对 Java 核心源代码的保护。

package java.lang;
 
public class String {
    public static void main(String[] args) {
        System.out.println("Hello String");
    }
}
// 程序会有如下报错
Error: Main method not found in class java.lang.String, please define the main method as:
   public static void main(String[] args)

打破双亲委托机制

如果要打破双亲委托机制,可以通过自定义类加载器来实现。

  1. 重写 loadClass 方法:自定义类加载器继承自 ClassLoader 类,可以重写其中的 loadClass 方法来打破双亲委托机制。在重写 loadClass 方法时,可以根据自己的需求来决定是否调用父类的 loadClass 方法。如果不调用父类的 loadClass 方法,那么加载类的过程就完全由自定义类加载器来完成;
  2. 调用 defineClass 方法:在自定义类加载器中,可以调用 defineClass 方法来加载类。这个方法将字节数组转换成一个 Class 对象,并将这个对象添加到 JVM 中。通过调用 defineClass 方法,就可以不使用双亲委托机制加载类。
  3. 覆盖 findClass 方法:ClassLoader 类中有一个 protected 的 findClass 方法,用于查找指定的类。在自定义类加载器中,可以覆盖 findClass 方法,实现自己的类查找机制,从而打破双亲委托机制。

1.1.4 判断两个类相等

在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名;

  • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。

在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。

1.1.5 类的主动/被动使用

Java 程序对类的使用方式分为:主动使用和被动使用。

主动使用,分为七种情况:

  • 创建类的实例;

  • 访问某个类或接口的静态变量,或者对该静态变量赋值;

  • 调用类的静态方法;

  • 反射(比如:Class.forName(”com.atguigu.Test”));

  • 初始化一个类的子类;

  • Java 虚拟机启动时被标明为启动类的类;

  • JDK 7 开始提供的动态语言支持:java.lang.invoke.MethodHandle 实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化。

除了以上七种情况,其他使用 Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化(依然会有加载和连接的过程)。

1.2 运行时数据区

在 Java 虚拟机的运行时数据区中,有些区域是线程私有的,有些区域则是线程共享的。

线程私有的数据区域包括:

  1. 程序计数器(Program Counter Register):每个线程都有一个独立的程序计数器,用于记录当前线程所执行的字节码行号;
  2. Java 虚拟机栈(Java Virtual Machine Stacks):每个线程都有一个独立的虚拟机栈,用于存储该线程执行方法所需的栈帧;
  3. 本地方法栈(Native Method Stacks):与虚拟机栈类似,每个线程都有一个独立的本地方法栈,用于支持 Native 方法的执行。

线程共享的数据区域包括:

  1. 堆(Heap):所有线程共享一个堆空间,用于存储对象实例和数组;
  2. 方法区(Method Area):所有线程共享一个方法区空间,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。

1.2.1 程序计数器

PC 寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

如果学习过汇编,JVM 的程序计数器就是对 CPU 寄存器的一种模拟(比如 IP、SP)。每一次 JVM 的操作都会有计数器,记录每一步的执行。

每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。程序计数器可以忽略不计,运行速度极快。为了能够准确地记录各个线程正在执行的当前字节码指令地址,每一个线程都被分配了一个 PC 寄存器。

简单程序字节码演示

程序计数器表示当前线程所执行的字节码的行号指示器。程序计数器不会产生 StackOverflowError 和 OutOfMemoryError。

由于程序计数器是一块较小的内存区域,并且它所存储的数据都是线程私有的,因此它不会发生 OutOfMemoryError。此外,由于程序计数器只记录了方法执行的位置,而不会在方法调用时保存方法的参数和局部变量等数据,因此也不会发生 StackOverflowError。

CPU 时间片

CPU 时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。

  • 宏观上:可以同时打开多个应用程序,每个程序并行不悖,同时运行;

  • 微观上:由于单个 CPU 的每个核心一次只能处理程序要求的一部分,为处理公平,引入时间片,每个程序轮流执行。

1.2.2 虚拟机栈

Java 虚拟机栈用来描述 Java 方法执行的内存模型。线程创建时就会分配一个栈空间,线程结束后栈空间被回收。对于不同平台的 CPU 架构不同问题,Java 指令不能设计为基于寄存器的,所以 Java 的指令都是根据栈来设计的。

  • 优点:跨平台,指令集小,编译器容易实现;
  • 缺点:性能下降,实现同样的功能需要更多的指令。

栈是运行时的单位,而堆是存储的单位

Java 虚拟机规范允许 Java 栈的大小是动态的或者是固定不变的,也会抛出两种异常:

  • 如果采用固定大小的 Java 虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个 StackOverflowError 异常;

  • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。

每一次操作都会压入一个栈帧,在接收到 return 指令或者抛出异常时就会弹出一个栈帧

每个栈帧中存储着:

  • 局部变量表(Local Variables);

  • 操作数栈(Operand Stack)(或表达式栈);

  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用);

  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义);

  • 一些附加信息。

栈帧的内部结构

局部变量表

定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及 return Address 类型。局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress 类型的变量。

局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少(递归函数调用过多导致 StackOverflow 问题)。

局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

局部变量表,最基本的存储单元是 Slot(变量槽)。Slot 如同数组或者汇编中的寄存器存储,参数值的存放总是在局部变量数组的 index 0 开始,到数组长度 -1 的索引结束。

在局部变量表里,一个 slot能存储 32 位变量值(包括 returnAddress 类型)。byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0表示 false,非 0 表示 true。如果需要访问局部变量表中一个 64 bit 的局部变量值时,只需要使用前一个索引即可

slot 分配内存

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

和类变量初始化不同,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

// 直接调用 test 没有初始化,所以会报错
public void test(){
    int i;
    System.out.println(i);
}

操作数栈

对于赋值、运算等操作,都会在一个栈中实现操作。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。方法的返回值也会压入栈帧的操作数栈中。

常说的 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

public class ClassTest {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        int c = a + b;
        System.out.println(c);
    }
}

class 文件中出现 push pop 操作

操作栈存储在内存中,频繁的读/写会影响执行速度,HotSpot JVM 的开发者们提出栈顶缓存。将栈顶元素,全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数。

动态链接

动态链接的作用是为了将符号引用转换为调用方法的直接引用。

  • 静态链接:字节码文件装载进 JVM 文件中,目标方法在编译期内可知,在运行期保持不变(早期绑定);
  • 动态链接:被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用(晚期绑定)。

对于编译时就能确定的方法,可以确定的方法被称为非虚方法。静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,其他方法称为虚方法。

class Father {
    public static void print(String str) {
        System.out.println("father " + str);
    }

    private void show(String str) {
        System.out.println("father" + str);
    }
}

class Son extends Father {
    public static class VirtualMethodTest {
        public static void main(String[] args) {
            Son.print("coder");
        }
    }
}

虚拟机提供了几条方法调用指令:

  • 普通调用指令:

    • invokestatic:调用静态方法,解析阶段确定唯一方法版本;

    • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本;

    • invokevirtual:调用所有虚方法;

    • invokeinterface:调用接口方法。

  • 动态调用指令:

    • invokedynamic:动态解析出需要调用的方法,然后执行。动态调用指令是为了实现动态类型语言在 JDK 7 引入,在 JDK 8 Lambda 表达式出现后,在 Java 中才有直接的生成方式。

为了提高执行虚方法性能,JVM 在类的方法区创建一个虚方法表,使用索引表来代替查找。

class Dog {
    public void sayHello() {
    }

    public String tostring() {
        return "Dog";
    }
}

class Cat implements Friendly {
    public void eat() {
    }

    public void sayHello() {
    }

    public void sayGoodbye() {
    }

    protected void finalize() {
    }
}

class Chihuahua extends Dog implements Friendly {
    public void sayHello() {
        super.sayHello();
    }

    public void sayGoodbye() {
    }
}

虚方法执行

方法返回地址

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;

    • 个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
  • 在字节码指令中,返回指令包含ireturn(当返回值是 boolean,byte,char,short 和 int 类型时使用),lreturn(Long 类型),freturn(Float 类型),dreturn(Double 类型),areturn。另外还有一个 return 指令声明为 void 的方法,实例初始化方法,类和接口的初始化方法使用。
  1. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

1.2.3 本地方法栈

虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈为本地方法服务。

本地方法

一个 Native Method 是一个 Java 调用非 Java 代码的接囗。本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。

如果对于效率有要求,单纯使用 Java 就不能满足需求了。

  • 与 Java 环境的交互:有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因;
  • 与操作系统的交互:通过使用本地方法,我们得以用 Java 实现了 jre 的与底层系统的交互,甚至 JVM 的一些部分是用 C 写的;
  • Sun’s Java:Sun 的解释器是用 C 实现的,这使得它能像一些普通的 C 一样与外部交互。

目前本地方法使用越来越少,除非涉及到硬件层面。

本地方法栈

Java 虚拟机栈于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用,也是线程私有。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。

本地方法栈

直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。直接内存是在 Java 堆外的、直接向系统申请的内存区间(不由 JVM 管理,由操作系统管理)。Java 通过存在堆中的 DirectByteBuffer 操作 Native 内存,避免了在 Java 堆和 Native 堆来回复制数据。通常,访问直接内存的速度会优于 Java 堆,即读写性能高。

1.2.4 堆

堆对一个 JVM 进程来说是唯一的,进程中的多个线程共享同一堆空间。《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。Java 的垃圾回收主要针对堆这个区域进行。

堆的大小可以通过 JVM 启动参数来设置,例如 -Xms 和 -Xmx 分别指定了堆的初始大小和最大大小。由于堆是 Java 程序中动态分配对象的主要区域,因此它也是 Java 程序性能优化的重点之一。优化堆的使用可以有效地提高 Java 应用程序的性能。

堆内存

堆被划分为三个部分:新生代、老年代和永久代(在 JDK 8 及以上版本中,永久代被移除,取而代之的是元空间)。

新生代又被划分为 Eden 空间和两个 Survivor 空间(通常称为 From Space 和 To Space),其中 Eden 空间用于存储新创建的对象,Survivor 空间用于存储在 Eden 空间中经过第一次垃圾回收后幸存的对象。当某个 Survivor 空间被占满时,其中还存活的对象将被复制到另一个 Survivor 空间中,同时也会清空当前 Survivor 空间。在对象经过多次垃圾回收后,如果仍然存活,则会被移动到老年代中。

老年代是存放长生命周期的对象的区域。在老年代中存储的对象一般由新生代中晋升过来的对象或直接在老年代中创建的对象。由于老年代中存储的对象生命周期较长,因此垃圾回收频率较低。

永久代(或者元空间)是一块用于存放 JVM 类型信息、常量池、静态变量等的区域。在 JDK 8 及以上版本中,永久代已经被移除,取而代之的是元空间,元空间的实现方式和永久代不同,它是通过 native memory 来实现的。

堆内存结构变化

年轻代、老年代

对于 70%-99% 的临时对象,分代思想能优化 GC 效率,不需要对堆进行全局扫描。

存储在 JVM 中的 Java 对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速;

  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致。

分类划分,大部分对象即用即逝

默认情况下,新生代和老年代内存占比为 1:2,新生代中 Eden 区和两个 Survivor 区(生存者区)的占比为 8:1:1。几乎所有的 Java 对象都是在 Eden 区被 new 出来的。绝大部分的 Java 对象的销毁都在新生代进行了。

对象分配

  1. 新创建的对象放在 Eden 区,有大小限制;
  2. 当 Eden 的空间填满时,JVM 的垃圾回收器将进行垃圾回收(Minor GC),将不再被其他对象所引用的对象进行销毁,新的对象进去进入 Eden 区。Eden 区中的剩余对象移动到幸存者0区;
  3. 如果再次触发垃圾回收,上次幸存下来的放到幸存者 0 区的对象,如果没有被回收,会被放到幸存者 1 区;
  4. 在幸存者区的对象在多次 GC 也没有被消除之后,就会进入老年代(默认为 15 次,可以设置参数:-Xx:MaxTenuringThreshold= N);
  5. 当老年代内存不足时,触发 Major GC,进行老年代的内存清理。若老年代执行了 Major GC之后,发现依然无法进行对象的保存,就会产生 OOM 异常(表示 Heap Space 不够)。

从新生代逐渐到老年代

分配策略

JVM 为了有效地管理内存,采用了不同的内存分配策略。下面是 JVM 分配内存的两种主要策略:

  1. 指针碰撞(Bump the Pointer):

指针碰撞将堆内存分成两个部分:一部分被使用,另一部分未被使用。JVM 将已使用的内存区域的末端设置为当前空闲内存的起始位置,当需要分配新的内存时,JVM 会检查当前空闲内存是否足够,如果足够,就将新对象的引用指向该位置,并更新当前空闲内存的起始位置。

这种策略需要使用连续的内存空间,因此需要考虑垃圾回收的问题。如果分配的对象被释放,内存区域就会产生空洞,为了避免这种情况,JVM 采用了压缩算法来移动已分配对象的位置,以保持内存的连续性。

  1. 空闲列表(Free List):

空闲列表是一种内存分配策略,它维护一个空闲内存块的列表,当需要分配新的内存时,JVM 会在空闲列表中查找足够大的内存块,并将其分配给新对象。为了避免碎片化,JVM 可以对空闲块进行合并或拆分。

这种策略相对于指针碰撞,可以避免垃圾回收后产生的空洞,但是在维护空闲列表时需要耗费额外的开销。JVM 还使用了一些其他的优化技术来提高内存分配的效率,例如对象的重用和线程本地分配缓存等。由于 JVM 的内存分配机制是实现细节的一部分,因此具体的实现方式可能会因 JVM 版本、操作系统和硬件平台等因素而异。

垃圾收集策略

JVM 在进行 GC 时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。

针对 Hotspot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:

    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集(注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收);
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集(目前,只有 CMS GC 会有单独收集老年代的行为);

    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集(目前,只有 G1 GC 会有这种行为)。

  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

Minor GC

在 Eden 区满时,会触发 Minor GC(Survivor 区不会),因为触发及其频繁,所以回收速度一般比较快。

Minor GC 会引发 STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

Major GC/Full GC

出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Paralle1 Scavenge 收集器的收集策略里就有直接进行 MajorGC 的策略选择过程) 。在老年代空间不足时,会先尝试触发 Minor GC。如果之后空间还不足,则触发 Major GC。Major GC 的执行时间更长,STW 时间更长。

快速分配策略

在 Java 中,对象的创建是一个频繁的操作。如果每次对象创建时都需要访问共享的堆内存区域,那么将会造成很大的性能损失。为了减少这种开销,JVM 采用了 TLAB 技术(TLAB,Thread-Local Allocation Buffer,线程本地分配缓冲区。JDK 1.3 引入的一种对象分配优化技术,目的是在多线程环境下提高对象分配的效率),为每个线程分配一个私有的内存区域作为对象分配缓冲区。

当一个线程需要创建对象时,首先会检查 TLAB 是否有足够的空间来分配该对象。如果有足够的空间,那么该线程会直接从 TLAB 中分配对象,否则就需要访问共享的堆内存区域来分配对象。

使用 TLAB 技术的好处是,线程可以在自己的私有缓冲区中快速分配对象,从而减少了线程之间的竞争和锁等机制的开销,提高了对象分配的效率和整个应用程序的性能。同时,TLAB 还可以减少垃圾回收的压力,因为对象在 TLAB 中分配后,可以避免在堆中频繁分配和回收的过程。

使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,将这种内存分配方式称之为快速分配策略

GC 过程中使用 TLAB

逃逸分析

《深入理解 Java 虚拟机》中,随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸;

  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

// 逃逸对象
public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

// 未逃逸对象
public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配;
  2. 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。极大提高并发性和性能;
  3. 分离对象或标量替换:Java 的原始数据类型就是标量。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。

所以对于变量或者其他方法等,尽可能在局部内进行定义,全局变量就会被逃逸分析判定为逃逸。

逃逸分析不成熟

逃逸分析现在仍不成熟。其根本原因就是无法保证逃逸分析的性能消耗一定能高于消耗。经过逃逸分析可以做标量替换、栈上分配、和锁消除。但逃逸分析自身需要进行一系列复杂的分析,过程相对耗时。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

1.2.5 方法区

方法区用于存储被虚拟机加载的类信息、常量、静态变量等数据。

JDK 6 之前使用永久代实现方法区,容易内存溢出。JDK 7 把放在永久代的字符串常量池、静态变量等移出。JDK 8 中抛弃永久代,改用在本地内存中实现的元空间来实现方法区,把 JDK 7 中永久代内容移到元空间。

在《Java 虚拟机规范》中提及,对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。

Java 中的引用对象存入虚拟机栈中,初始化的对象放入 Java 堆,对象对应的类型则在方法区中。

栈、堆、方法区交互关系

《Java 虚拟机规范》中“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”对于 HotSpot JVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。方法区看作是一块独立于 Java 堆的内存空间

在 JDK 8 中取消永久代,使用元空间

方法区内部结构

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

方法区存储信息

类型信息
  • 这个类型的完整有效名称(全名=包名.类名);
  • 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.object,都没有父类);
  • 这个类型的修饰符(public,abstract,final 的某个子集);
  • 这个类型直接接口的一个有序列表。
域/变量(Field)信息

域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient 的某个子集)。

每个全局常量(static final)在编译时就会被分配。

方法(Method)信息

JVM 必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称;
  • 方法的返回类型(或 void);
  • 方法参数的数量和类型(按顺序);
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集);
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外);
  • 异常表(abstract和native方法除外) 。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final 的类变量
  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分;

  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它。

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = new Order();
        order.hello();
        System.out.println(order.count);
    }
}

class Order {
    public static int count = 1;
    public static void hello() {
        System.out.println("hello!");
    }
}

方法区内结构

常量池

一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

常量池是指在编译期被确定,并被保存在已编译的.class文件中的一些数据。在 JDK 6.0 及之前版本,字符串常量池是放在 Perm Gen 区(也就是方法区)中,此时常量池中存储的是对象。 在 JDK 7.0 版本,字符串常量池被移到了堆中了。

运行时常量池(Runtime Constant Pool)是方法区的一部分。常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。

运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。

当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutOfMemoryError 异常。

StringTable

常量与常量的拼接结果在常量池,原理是编译期优化,常量池中不会存在相同内容的变量。只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder。如果拼接的结果调用 intern() 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

public static void test1() {
    // 都是常量,前端编译期会进行代码优化
    // 通过 idea 直接看对应的反编译的 class 文件,会显示 String s1 = "ab"; 说明做了代码优化
    String s1 = "a" + "b";  
    String s2 = "ab"; 

    // true,由上述可知,s1 和 s2 实际上指向字符串常量池中的同一个值
    System.out.println(s1 == s2); 
}

反编译 .class 编译优化结果

字符串拼接

对于字符串中的 +,本质是个语法糖。如果是多个变量相加,相当于调用 StringBuilder 进行拼接。

public static void test5() {
    String s1 = "javaEE";
    String s2 = "hadoop";

    String s3 = "javaEEhadoop";
    String s4 = "javaEE" + "hadoop";    
    String s5 = s1 + "hadoop";
    String s6 = "javaEE" + s2;
    String s7 = s1 + s2;

    System.out.println(s3 == s4); // true 编译期优化
    System.out.println(s3 == s5); // false s1是变量,不能编译期优化
    System.out.println(s3 == s6); // false s2是变量,不能编译期优化
    System.out.println(s3 == s7); // false s1、s2都是变量
    System.out.println(s5 == s6); // false s5、s6 不同的对象实例
    System.out.println(s5 == s7); // false s5、s7 不同的对象实例
    System.out.println(s6 == s7); // false s6、s7 不同的对象实例

    String s8 = s6.intern();
    System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"
}
  • 不使用 final 修饰,即为变量。如 s3 行的 s1 和 s2,会通过 new StringBuilder 进行拼接;

  • 使用 final 修饰,即为常量。会在编译器进行代码优化。在实际开发中,能够使用 final 的,尽量使用(即 String 变量声明 final 之后在编译过程中可以像硬编码字符串一样做优化)。

public void test6(){
    String s0 = "beijing";
    String s1 = "bei";
    String s2 = "jing";
    String s3 = s1 + s2;
    System.out.println(s0 == s3); // false s3指向对象实例,s0指向字符串常量池中的"beijing"
    String s7 = "shanxi";
    final String s4 = "shan";
    final String s5 = "xi";
    String s6 = s4 + s5;
    System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
}

在这之中可能还会有常量折叠。常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";

并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  1. 基本数据类型(byte、boolean、short、char、int、float、long、double)以及字符串常量;
  2. final 修饰的基本数据类型和字符串变量;
  3. 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算。

对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

String str4 = new StringBuilder().append(str1).append(str2).toString();
intern()

当调用 intern 方法时,如果池子里已经包含了一个与这个 String 对象相等的字符串,正如 equals(Object) 方法所确定的,那么池子里的字符串会被返回。否则,这个 String 对象被添加到池中,并返回这个 String 对象的引用。

("a"+"b"+"c").intern() == "abc"
// true

String intern()

JDK1.6 中,将这个字符串对象尝试放入串池:

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址;
  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址。

JDK1.7 起,将这个字符串对象尝试放入串池:

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址;
  • 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址。
class TestB {
    public static void main(String[] args) {
        /*
         * ① String s = new String("1")
         * 创建了两个对象
         * 		堆空间中一个new对象(如果常量池中没有"1",就会只会在堆中创建)
         * 		字符串常量池中一个字符串常量"1"(注意:此时字符串常量池中已有"1")
         * ② s.intern()由于字符串常量池中已存在"1"
         *
         * s  指向的是堆空间中的对象地址
         * s2 指向的是堆空间中常量池中"1"的地址
         * 所以不相等
         */
        String s = new String("1");
        s.intern();
        String s2 = "1";
        System.out.println(s == s2); // jdk1.6 false jdk7/8 false

        /*
         * ① String s3 = new String("1") + new String("1")
         * 等价于 new String("11"),但是,常量池中并不生成字符串"11";
         *
         * ② s3.intern()
         * 由于此时常量池中并无"11",所以把 s3 中记录的对象的地址存入常量池
         * 所以 s3 和 s4 指向的都是一个地址
         */
        String s3 = new String("1") + new String("1");
        s3.intern();
        String s4 = "11";
        System.out.println(s3 == s4); //jdk1.6 false jdk7/8 true
    }
}

new String(“1”) 常量池已经有1

包装类和常量池

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True Or False

装箱本身是调用 valueOf() 方法,所以跟直接 new 创建不是一个内存地址。

// Integer 缓存内容
public final class Integer extends Number implements Comparable<Integer> {
	@HotSpotIntrinsicCandidate
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
    
    private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer[] cache;
        static Integer[] archivedCache;

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    int i = parseInt(integerCacheHighPropValue);
                    i = Math.max(i, 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

            // Load IntegerCache.archivedCache from archive, if possible
            VM.initializeFromArchive(IntegerCache.class);
            int size = (high - low) + 1;

            // Use the archived cache if it exists and is large enough
            if (archivedCache == null || size > archivedCache.length) {
                Integer[] c = new Integer[size];
                int j = low;
                for(int k = 0; k < c.length; k++)
                    c[k] = new Integer(j++);
                archivedCache = c;
            }
            cache = archivedCache;
            // range [-128, 127] must be interned (JLS7 5.1.7)
            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }
}

去除永久代

在 JDK 8 中,JVM 去除了永久代(PermGen)的概念,取而代之的是元空间(Metaspace)。永久代是用于存储类信息、方法信息、常量池等数据的一块内存区域,它的大小是固定的,无法动态调整,因此在一些复杂的应用程序中容易出现 OutOfMemoryError 错误。永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 oom(占用内存过大)。比如某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

原来的数据被移到了一个与堆不相连的本地内存区域,称为元空间(Metaspace)。

元空间则是用于存储类信息、方法信息等元数据的内存区域,与永久代相比,它具有以下几个特点:

  1. 元空间大小可以动态调整,JVM 会根据应用程序的需要动态调整元空间的大小,从而避免 OutOfMemoryError 错误。
  2. 元空间可以与堆内存一样进行垃圾回收,因此不用担心永久代出现的内存泄漏等问题。
  3. 元空间不再属于 Java 堆内存,因此它可以脱离堆内存进行分配和回收,从而避免了一些问题,比如 GC 堆内存无法释放的情况。

需要注意的是,元空间虽然相对于永久代来说更加灵活和可靠,但也存在一些需要注意的问题。例如,元空间的内存开销可能比永久代更大,因此需要合理调整元空间的大小。同时,由于元空间存储的是元数据,因此一些反射操作等可能会产生较大的内存开销,需要谨慎使用。

方法区 GC

这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型。判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”,需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例;
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的;
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

1.2.6 执行引擎

Java 多平台的关键在于使用 JVM 中的执行引擎能够突破“物理机”的硬件约束,执行不被硬件支持的指令集格式。

方法在执行的过程中,执行引擎可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。每一项指令都依赖于程序计数器。

Java 执行过程

解释执行

  • 解释器:当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行;
  • JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

编译可以细分为两个过程,一个是编译,一个是汇编。编译将高级语言替换成等效的汇编源码,汇编则是将汇编代码变成计算机可以识别的机器码。

编译型语言执行过程

Java 语言的“编译期”其实是一段“不确定”的操作过程:

  • 因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把 .java 文件转变成 .class 文件的过程;
  • 也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程;
  • 还可能是指使用静态提前编译器(AOT 编译器,Ahead of Time Compiler)直接把 .java 文件编译成本地机器代码的过程。
解释器
  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下;

  • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

JIT 编译器

对于效率至上而言,解释器显然不能满足需求。JVM 支持一种叫作即时编译的技术,将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码

HotSpot VM 采用解释器与即时编译器并存的架构。当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

热点代码

机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,可以通过 JIT 编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或称为 OSR(On Stack Replacement)编译。

基于计数器的热点探测,HotSpot VM 为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

方法调用计数器

当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

方法调用计数器

热点衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率。

当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。

回边计数器

统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。建立回边计数器统计的目的就是为了触发 OSR 编译

1.3 对象实例化

1.3.1 创建对象的方式

  • new:最常见的方式、Xxx 的静态方法,XxxBuilder/XxxFactory 的静态方法;

  • Class 的 newInstance 方法:反射的方式,只能调用空参的构造器,权限必须是 public;

  • Constructor 的 newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求;

  • 使用 clone():不调用任何的构造器,要求当前的类需要实现 Cloneable 接口,实现 clone();

  • 使用序列化:从文件中、从网络中获取一个对象的二进制流;

  • 第三方库 Objenesis。

1.3.2 创建对象的步骤

  1. 检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查引用代表的类是否已被加载、解析和初始化,如果没有就先执行类加载;
  2. 通过检查通过后虚拟机将为新生对象分配内存;
  3. 完成内存分配后虚拟机将成员变量设为零值;
  4. 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等;
  5. 执行 init 方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

创建对象步骤

1. 判断对象对应的类是否加载、链接、初始化

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。

如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 key 进行查找对应的 .class 文件;

  • 如果没有找到文件,则抛出 ClassNotFoundException 异常;

  • 如果找到,则进行类加载,并生成对应的 Class 对象。

2. 为对象分配内存

首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。

  • 如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存;
    所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是 Serial ,ParNew 这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带 Compact(整理)过程的收集器时,使用指针碰撞。

  • 如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。
    已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。

选择哪种分配方式由 Java 堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. 处理并发问题

在给 Java 分配内存时有两种策略保证线程安全:

  • 采用 CAS 失败重试、区域加锁保证更新的原子性,该方式效率较低;
  • 每个线程预先分配一块 TLAB:通过设置 -XX:+UseTLAB 参数来设定。每个线程在 Java 堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,一般采用这种策略。

4. 初始化分配到的内存

所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。

5. 设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM 实现。

6. 执行 init 方法进行初始化

在 Java 程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

因此一般来说(由字节码中跟随 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

1.3.3 对象内存布局

public class Customer {
    int id = 1001;
    String name;
    Account acct;

    {
        name = "匿名客户";
    }

    public Customer() {
        acct = new Account();
    }
}

public class CustomerTest {
    public static void main(string[] args){
        Customer cust=new Customer();
    }
}

创建对象图示

内存分配图示

对象头(Header)

对象头包含了两部分,分别是运行时元数据(Mark Word)和类型指针。如果是数组,还需要记录数组的长度

  • 运行时元数据:哈希值(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、翩向时间戳;
  • 类型指针:指向类元数据 InstanceKlass,确定该对象所属的类型。

实例数据(Instance Data)

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

  • 相同宽度的字段总是被分配在一起;

  • 父类中定义的变量会出现在子类之前;

  • 如果 CompactFields 参数为 true(默认为 true):子类的窄变量可能插入到父类变量的空隙。

对齐填充(Padding)

不是必须的,也没有特别的含义,仅仅起到占位符的作用

1.3.4 对象的访问定位

对象定位图示

句柄访问

reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference 本身不需要被修改。

句柄访问图示

直接指针(HotSpot 采用)

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据。

直接指针图示

2. 垃圾回收

在默认情况下,通过 system.gc() 或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。

2.1 垃圾回收算法

对于垃圾回收,需要明确两点:

  • 哪些是垃圾(标记阶段);
  • 怎么清除(清除阶段)。

2.1.1 标记阶段

Java 引用类型

在 Java 中,引用类型指的是与基本数据类型相对应的数据类型,包括类、接口、数组、枚举等类型。Java 中的引用分为四种类型:

  1. 强引用(Strong Reference):如果一个对象具有强引用,那么它就不会被垃圾回收器回收。当程序中某个对象具有强引用时,就意味着该对象在程序运行期间一直存活,直到该对象的所有引用都被释放;
  2. 软引用(Soft Reference):如果一个对象只具有软引用,那么当系统内存不足时,该对象就有可能被垃圾回收器回收。软引用通常用于缓存数据的场景中,例如缓存图片、缓存文件等;
  3. 弱引用(Weak Reference):如果一个对象只具有弱引用,那么当系统垃圾回收器运行时,无论系统内存是否充足,该对象都有可能被回收。弱引用通常用于实现一些数据结构,例如 WeakHashMap 等;
  4. 虚引用(Phantom Reference):如果一个对象只具有虚引用,那么该对象对垃圾回收没有影响。虚引用通常用于跟踪对象被垃圾回收的状态,例如在对象被回收时进行一些清理工作等。
// 强引用,对象不会被垃圾回收器回收
Object obj = new Object(); 

// 软引用,对象可能被垃圾回收器回收
Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<Object>(obj); 

// 弱引用,对象可能被垃圾回收器回收
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<Object>(obj); 

// 虚引用,对象可能被垃圾回收器回收
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<Object>();
PhantomReference<Object> phantomRef = new PhantomReference<Object>(obj, queue); 

在使用软引用、弱引用和虚引用时,需要通过 get() 方法来获取实际的对象。如果对象已经被垃圾回收器回收,get() 方法会返回 null。同时,在使用软引用、弱引用和虚引用时,需要注意及时清理不需要的引用,以避免内存泄漏和性能问题。

标记算法

方法一:引用计数算法

对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加存储空间的开销

  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加时间开销

  • 无法处理循环引用。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。

方法二:可达性分析算法

解决在引用计数算法中循环引用的问题,防止内存泄漏的发生,也叫作追踪性垃圾收集(Tracing Garbage Collection)。

以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链

如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

可达性分析算法图示

包含元素

在Java语言中,GC Roots 包括以下几类元素:

  • 虚拟机栈中引用的对象(比如:各个线程被调用的方法中使用到的参数、局部变量等);

  • 本地方法栈内 JNI(通常说的本地方法)引用的对象;

  • 方法区中类静态属性引用的对象(比如:Java 类的引用类型静态变量);

  • 方法区中常量引用的对象(比如:字符串常量池(String Table)里的引用);

  • 所有被同步锁 synchronized 持有的对象;

  • Java 虚拟机内部的引用(基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器);

  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

2.1.2 finalization

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑(已于 JDK 18 中废弃)

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize() 方法。

finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。在新 JDK 18 中,官方推荐使用 try-resource 或清洁器进行代替。

错误使用

永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize() 时可能会导致对象复活

  • finalize() 方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize() 方法将没有执行机会;

  • 一个糟糕的 finalize() 会严重影响 GC 的性能。

状态

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。

  • 可触及的:从根节点开始,可以到达这个对象;

  • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活;

  • 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize() 只会被调用一次。

以上3种状态中,是由于 finalize() 方法的存在,进行的区分。只有在对象不可触及时才可以被回收

具体过程

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记
  2. 进行筛选,判断此对象是否有必要执行 finalize() 方法
  3. 如果对象 objA 没有重写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的;
    如果对象 objA 重写了finalize()方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法执行。
  4. finalize() 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,一个对象的finalize方法只会被调用一次
public class GCTest {
    // 类变量,属于 GC Roots 的一部分
    public static GCTest canReliveObj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类重写的 finalize() 方法");
        canReliveObj = this;
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建一个新的对象
        canReliveObj = new GCTest();
        // 模拟销毁的情况
        canReliveObj = null;
        System.gc();
        System.out.println("-----------------第一次gc操作------------");
        // 因为 Finalizer 线程的优先级比较低,暂停2秒,以等待它
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

        System.out.println("-----------------第二次gc操作------------");
        canReliveObj = null;
        System.gc();
        // 下面代码和上面代码是一样的,但是 canReliveObj 却自救失败了
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }
    }
}

/* 执行结果为
-----------------第一次gc操作------------
调用当前类重写的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead
*/

2.1.3 清除阶段

Mark-Sweep Mark-Compact Copying
速率 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的2倍空间(不堆积碎片)
移动对象

以上三种算法各有优缺点,一般垃圾回收器会根据实际情况选择不同的算法来进行垃圾回收。例如,Java 虚拟机中的新生代使用标记复制算法,老年代使用标记清除算法和标记整理算法。

标记清除算法(Mark-Sweep)

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序 STW(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象;

  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

标记清除算法图示

  • 标记清除算法的效率不算高;

  • 在进行 GC 的时候,需要停止整个应用程序,用户体验较差;

  • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表。
指针碰撞

指针碰撞是一种垃圾回收算法,也称为“固定大小分配”。该算法假定内存分配是连续的,堆空间被分为两个部分:已分配部分和未分配部分。已分配部分存储已分配对象,未分配部分则是可用于分配的空闲内存。

当需要分配一个对象时,指针碰撞算法会将已分配部分的结束位置作为分配起点,然后将分配指针向前移动,直到找到一个足够大的未分配内存块。如果没有足够大的内存块可用,就会发生内存溢出。当一个对象被释放时,它所占用的内存块被标记为空闲,这样可以在下一次分配时使用。因此,指针碰撞算法是一种“标记和清除”算法。

指针碰撞算法通常适用于内存分配固定、不频繁释放的应用程序。它简单、高效,但需要堆空间连续。这通常要求堆空间预分配并固定大小,因此不适用于需要频繁分配、释放内存的大型应用程序。

标记复制算法(Copying)

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

  • 需要两倍的内存空间;

  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小;

  • 多半应用于新生代的垃圾回收(需要回收之后对象存活数量不大)。

标记-压缩(整理)算法(Mark-Compact)

标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。

第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象,第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。

标记-压缩(整理)算法

标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销(多用于老年代收集)。

分代收集算法

分代收集算法是目前主流的垃圾回收算法之一。它是建立在一种观察到的事实上:大部分对象的生命周期都比较短暂,只有少量对象的生命周期比较长。

分代收集算法将堆空间划分为不同的代,一般将堆分为老年代和年轻代。年轻代一般分为 Eden 区和两个 Survivor 区。新创建的对象会被分配到 Eden 区中,当 Eden 区满时,会触发 Minor GC,清除掉不再被引用的对象。存活下来的对象会被移动到 Survivor 区中,其中一个 Survivor 区被标记为空闲状态,以便下次 Minor GC 时使用。当一个对象经过多次 Minor GC 后,如果仍然存活,它会被晋升到老年代。

老年代一般包含存活时间比较长的对象,它的大小相对年轻代来说比较稳定。因此,老年代的垃圾回收不如年轻代频繁,一般采用 Full GC 进行回收。

新生代使用:标记复制算法;老年代使用:标记清除或者标记整理算法。

分代收集算法的主要优点是可以根据不同对象的生命周期采用不同的垃圾回收策略,从而提高垃圾回收效率。它可以大大减少全堆垃圾回收的频率,从而减少垃圾回收的开销。但同时,分代收集算法需要维护多个代之间的引用关系,会增加一定的复杂性。

2.2 内存溢出与内存泄露

2.2.1 内存溢出(OOM)

java doc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。

  1. Java 虚拟机的堆内存设置不够:
    比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数 -Xms-Xmx 来调整;
  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space“。
    随着元数据区的引入,方法区内存已经不再那么窘迫,OOM 有所改观,异常信息变成了:“java.lang.OutofMemoryError:Metaspace“(直接内存不足,也会导致 OOM)。

2.2.2 内存泄漏(Memory Leak)

对象不会再被程序用到了,但是GC又不能回收他们的情况,称为内存泄漏。

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。(这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小)

  1. 单例模式:
    单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
  2. 一些提供close的资源未关闭导致内存泄漏:
    数据库连接(dataSourse.getConnection() ),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

2.3 STW

Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。STW 是 JVM 在后台自动发起和自动完成的。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行;

  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上;

  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。

2.4 安全点

程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为安全点(Safepoint)。

Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太多可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?有两种方式:

  • 抢先式中断:首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点;
  • 主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起(有轮询的机制)。

2.4.1 安全区域

Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safe point。

在安全区域内,线程可以自由进行对象的分配和回收操作。安全区域的实现方式是在程序运行时,将线程的执行状态记录在“安全区域表”中,以便垃圾收集器进行垃圾回收。当垃圾收集器需要进行垃圾回收时,会通过安全区域表查找到所有的安全区域,并将线程暂停,以便进行垃圾回收操作。

在 JDK 1.2 之前的版本中,安全点和安全区域的实现方式是通过“全局安全点”来实现的。这种方式会在程序的任意位置进行垃圾回收,从而导致垃圾回收时的性能问题。在 JDK 1.2 之后,JVM 的垃圾收集器使用了“本地安全点”和“本地安全区域”机制,能够更加精细地控制垃圾回收的执行时机,从而避免了性能问题。

2.5 垃圾收集器

截止 JDK 1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于新生代 复制算法 响应速度优先 适用于单 CPU 环境下的 Client 模式
ParNew 并行运行 作用于新生代 复制算法 响应速度优先 多 CPU 环境 Server 模式下与 CMS 配合使用
Parallel 并行运行 作用于新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单 CPU 环境下的 Client 模式
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或 B/S 业务
G1 并发、并行运行 作用于新生代、老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用

2.5.1 Serial

JDK 1.3 之前,垃圾回收器唯一的选择。Serial 收集器采用复制算法、串行回收和 STW 机制的方式执行内存回收,作用于年轻代。Serial Old 收集器作用于老年代,内部采用标记-压缩算法。Serial Old 是运行在 Client 模式下默认的老年代的垃圾回收器。

在进行垃圾回收时,Serial 垃圾回收器会暂停整个应用程序的运行,直到垃圾回收结束才会恢复应用程序的运行。因为它是单线程的,所以垃圾回收期间只会使用一个线程来进行垃圾回收,因此会影响应用程序的响应性能。但是由于它使用的是复制算法,因此可以有效地避免内存碎片的产生。Serial 垃圾回收器在虚拟机启动时就被加载,并且默认是启用的。可以通过在启动参数中使用 -XX:+UseSerialGC 来指定使用 Serial 垃圾回收器。

Serial Old 在 Server 模式下主要有两个用途:

​ ① 与新生代的 Parallel scavenge 配合使用;

​ ② 作为老年代 CMS 收集器的后备垃圾收集方案。

执行图解

在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。

2.5.2 ParNew

ParNew 收集器与 Serial 的区别仅在于是并行收集,是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器((虽然是并行回收,但在回收时仍触发 STW)。

在进行垃圾回收时,ParNew 垃圾回收器会使用多个线程来进行垃圾回收,其中一个线程是主线程,其他线程是辅助线程。主线程负责标记和清除垃圾对象,而辅助线程负责扫描和复制对象。这样可以利用多核 CPU 的优势,提高垃圾回收的效率。

ParNew 垃圾回收器默认启用,并且可以与 CMS 垃圾回收器配合使用,提高垃圾回收的效率和并发能力。可以通过在启动参数中使用 -XX:+UseParNewGC 来指定使用 ParNew 垃圾回收器。

  • 对于新生代,回收次数频繁,使用并行方式高效;

  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)

ParNew 运行图示

2.5.3 Parallel

跟 ParNew 在配置上相同,Parallel 回收器的目标则是达到一个可控制的吞吐量。使用标记-清除算法和标记-整理算法。自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。

Parallel会使用多个线程同时进行垃圾回收,这样可以更快地完成垃圾回收工作。高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。

Parallel 运行图示

在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。在 Java 8 中,默认是此垃圾收集器。

2.5.4 CMS

在 JDK 1.5 时期,CMS(Concurrent-Mark-Sweep)收集器,是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。使用的是标记清除算法,防止在垃圾清除期间影响到用户内存的使用。

CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

CMS 运行图示

CMS 整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为 STW 机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快;

  • 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;

  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;

  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的

标记-清除算法的特点,会产生大量的空间碎片,可能会导致 Concurrent Mode Failure(并发模式失败)而出现 Full GC(全局垃圾回收)。在 JDK 9 中,CMS 被标记成 Deprecate,在 JDK 14 中彻底删除。

2.5.5 G1

官方给 G1(Garbage First)设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。每次根据允许的收集时间,优先回收价值最大的 Region,主要针对配备多核 CPU 及大容量内存的机器。

特点/优势

G1 收集器是 JDK 1.7 版本引入的一款垃圾收集器,在 JDK 9 之后作为默认垃圾回收器,JDK 10 中采用并行化的 mark-sweep-compact 算法,并使用与年轻代回收和混合回收相同数量的线程。从而减少了 Full GC 的发生,以带来更好的性能提升、更大的吞吐量。

并发收集

G1 收集器使用了并发标记整理算法,可以在应用程序运行的同时完成垃圾回收,避免了长时间的停顿。

  • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时用户线程 STW;

  • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。

分代收集

从分代上看,G1 依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。

和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代。

空间整合

CMS:“标记-清除”算法、会产生内存碎片、若干次 GC 后进行一次碎片整理;

G1 将内存划分为 Region。内存的回收以 Region 作为基本单位。Region 之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。

可预测的停顿时间模型

G1 相对于 CMS 的另一大优势,除了追求低停顿外,还能建立可预测的停顿时间模型(也叫软实时,soft real-time),能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率;

比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。

内存模型

G1 分区示意图

分区概念

G1 划分 Region

一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在 TLAB 进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1 内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受 Lab 带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

Region

使用 G1 收集器时,它将整个 Java 堆划分成约2048个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32MB 之间,且为2的 N 次幂,即 1MB,2MB,4MB,8MB,16MB,32MB。可以通过 -XX:G1HeapRegionSize 设定。所有的 Region 大小相同,且在 JVM 生命周期内不会被改变。

每个 Region 都是通过指针碰撞来分配空间。

Region 划分

G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 region,就放到 H 区。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的H区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。

Card

G1 对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

在每个分区内部又被分成了若干个大小为 512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象。每次对内存的回收,都是对指定分区的卡片进行处理。

Remembered Set

无论 G1 还是其他分代收集器,JVM 都是使用 Remembered Set 来避免全局扫描。每个 Region 都有一个对应的 Remembered Set。每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作,然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象)。如果不同,通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中。

分代模型

当进行垃圾收集时,在 GC 根节点的枚举范围加入 Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。

Region 中的 GC Root 存入 RSet

PRT

RSet 在内部使用 Per Region Table(PRT)记录分区的引用情况。由于 RSet 的记录要占用分区的空间,如果一个分区非常”受欢迎”,那么 RSet 占用的空间会上升,从而降低分区的可用空间。G1 应对这个问题采用了改变 RSet 的密度的方式,在 PRT 中将会以三种模式记录引用:

  • 稀少:直接记录引用对象的卡片索引;
  • 细粒度:记录引用对象的分区索引;
  • 粗粒度:只记录引用情况,每个分区对应一个比特位。

粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。

分代模型

分代模型

分代垃圾收集可以将关注点集中在最近被分配的对象上,而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。虽然分区使得内存分配不再要求紧凑的内存空间,但 G1 依然使用了分代的思想。与其他垃圾收集器类似,G1 将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为 Eden 空间和 Survivor 空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM 会分配新的空闲分区加入到年轻代空间。

整个年轻代内存会在初始空间 -XX:G1NewSizePercent(默认整堆 5%)与最大空间(默认 60%)之间动态变化,且由参数目标暂停时间 -XX:MaxGCPauseMillis(默认200ms)、需要扩缩容的大小以 -XX:G1MaxNewSizePercent 及分区的已记忆集合(RSet)计算得到。当然,G1 依然可以设置固定的年轻代大小(参数 -XX:NewRatio、-Xmn),但同时暂停目标将失去意义。

Lab

值得注意的是,由于分区的思想,每个线程均可以”认领”某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和 GC 线程都会独立的使用分区,进而减少同步时间,提升 GC 效率,这个分区称为本地分配缓冲区(Lab,Local allocation buffer)。

其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此 TLAB的分区属于 Eden 空间;而每次垃圾收集时,每个 GC 线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到 Suvivor 空间或老年代空间;对于从 Eden/Survivor 空间晋升(Promotion)到Survivor/老年代空间的对象,同样有 GC 独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

CSet

收集集合(CSet)代表每次 GC 暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集 CSet 只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到 CSet 中。

候选老年代分区的 CSet 准入条件,可以通过活跃度阈值 -XX:G1MixedGCLiveThresholdPercent (默认 85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据 CSet 对堆的总大小占比 -XX:G1OldCSetRegionThresholdPercent(默认 10%)设置数量上限。

G1 的收集都是根据 CSet 进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

CSet of Young Collection

应用线程不断活动后,年轻代空间会被逐渐填满。当 JVM 分配对象到 Eden 区域失败(Eden 区已满)时,便会触发一次 STW 式的年轻代收集。在年轻代收集中,Eden 分区存活的对象将被拷贝到 Survivor 分区;原有 Survivor 分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到 PLAB 中,新的 Survivor 分区和老年代分区。而原有的年轻代分区将被整体回收掉。

同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到 Survivor 分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor 尺寸、Survivor 填充容量 -XX:TargetSurvivorRatio(默认 50%)、最大任期阈值 -XX:MaxTenuringThreshold(默认 15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。

CSet of Mixed Collection

年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(默认 45%)时,G1 就会启动一次混合垃圾收集周期。为了满足暂停目标,G1 可能不能一口气将所有的候选分区收集掉,因此 G1 可能会产生连续多次的混合收集与应用线程交替执行,每次 STW 的混合收集与年轻代收集过程相类似。

为了确定包含到年轻代收集集合 CSet 的老年代分区,JVM通过参数混合周期的最大总次数 -XX:G1MixedGCCountTarget(默认8)、堆废物百分比 -XX:G1HeapWastePercent(默认 5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到 CSet 的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到 CSet 的分区,则通过计算得到的 GC 效率进行安排。

活动周期

Young GC -> Young GC + Concurrent mark -> Mixed GC 顺序,进行垃圾回收。

顺时针循环回收

当年轻代的 Eden 区用尽时开始年轻代回收过程,从年轻代区间移动存活对象到 Survivor 区间或者老年区间,也有可能是两个区间都会涉及。G1 的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的 Region 就可以了。同时,这个老年代 Region 是和年轻代一起被回收的。

垃圾收集

年轻代收集和混合收集周期,是 G1 回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认 45%)时,G1 开始着手准备收集老年代空间。

年轻代 GC

每次收集过程中,既有并行执行的活动,也有串行执行的活动,但都可以是多线程的。在并行执行的任务中,如果某个任务过重,会导致其他线程在等待某项任务的处理,需要对这些地方进行优化。

  1. 外部根分区扫描 Ext Root Scanning:此活动对堆外的根(JVM 系统目录、VM 数据结构、JNI 线程句柄、硬件寄存器、全局变量、线程对栈根)进行扫描,发现那些没有加入到暂停收集集合 CSet 中的对象。如果系统目录(单根)拥有大量加载的类,最终可能其他并行活动结束后,该活动依然没有结束而带来的等待时间;
  2. 更新已记忆集合 Update RS:并发优化线程会对脏卡片的分区进行扫描更新日志缓冲区来更新 RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被优化线程处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。为了限制花在更新 RSet 的时间,可以设置暂停占用百分比 -XX:G1RSetUpdatingPauseTimePercent(默认 10%,即 -XX:MaxGCPauseMills / 10)。值得注意的是,如果更新日志缓冲区更新的任务不降低,单纯地减少 RSet 的更新时间,会导致暂停中被处理的缓冲区减少,将日志缓冲区更新工作推到并发优化线程上,从而增加对 Java 应用线程资源的争夺;
  3. RSet 扫描 Scan RS:在收集当前 CSet 之前,考虑到分区外的引用,必须扫描 CSet 分区的 RSet。如果 RSet 发生粗化,则会增加 RSet 的扫描时间。开启诊断模式 -XX:UnlockDiagnosticVMOptions 后,通过参数 -XX:+G1SummarizeRSetStats 可以确定并发优化线程是否能够及时处理更新日志缓冲区,并提供更多的信息,来帮助为 RSet 粗化总数提供窗口。参数 -XX:G1SummarizeRSetStatsPeriod=n 可设置 RSet 的统计周期,即经历多少此 GC 后进行一次统计
  4. 代码根扫描 Code Root Scanning:对代码根集合进行扫描,扫描 JVM 编译后代码 Native Method 的引用信息(nmethod 扫描),进行 RSet 扫描。事实上,只有 CSet 分区中的 RSet 有强代码根时,才会做 nmethod 扫描,查找对 CSet 的引用。
  5. 转移和回收 Object Copy:通过选定的 CSet 以及 CSet 分区完整的引用集,将执行暂停时间的主要部分:CSet 分区存活对象的转移、CSet 分区空间的回收。通过工作窃取机制来负载均衡地选定复制对象的线程,并且复制和扫描对象被转移的存活对象将拷贝到每个 GC 线程分配缓冲区 GCLAB。G1 会通过计算,预测分区复制所花费的时间,从而调整年轻代的尺寸;
  6. 终止 Termination:完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。如果还有其他线程继续工作,空闲的线程会通过工作窃取机制尝试帮助其他线程处理。而单独执行根分区扫描的线程,如果任务过重,最终会晚于终止;
  7. GC 外部的并行活动 GC Worker Other:该部分并非 GC 的活动,而是 JVM 的活动导致占用了 GC 暂停时间(例如 JNI 编译)。

新生代回收

并发标记

CMS 和 G1 在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。

三色标记法

标记过程

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是 STW 的,并且会触发一次年轻代 GC;
  2. 根区域扫描(Root Region Scanning):G1 GC 扫描 Survivor 区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在 Young GC 之前完成;
  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 Young GC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例);
  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是 STW 的。G1 中采用了比 CMS 更快的初始快照算法:snapshot-at-the-beginning(SATB)。
  5. 独占清理(clean up,STW):计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集;
  6. 并发清理阶段:识别并清理完全空闲的区域。

在 remark 过程中,黑色指向了白色,如果不对黑色重新扫描,则会发生漏标问题。会把白色 D 对象当作没有新引用指向从而回收掉。

漏标问题

  • 跟踪黑指向白的增加 incremental update:增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性。CMS采用该方法(黑色对象指向白色对象);

  • 记录灰指向白的消失 SATB snapshot at the beginning:原始快照,关注引用的删除,当灰–>白消失时,要把这个 引用 推到GC的堆栈,保证白还能被 GC 扫描到。G1 采用该方法(灰色对象指向白色对象的引用消失)。

采用 incremental update 把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1 有 RSet 与 SATB 相配合。Card Table 里记录了 RSet,RSet 里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描 RSet 就可以了。

混合回收

当 G1 发起并发标记周期之后,并不会马上开始混合收集。G1 会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的 CSet(Choose CSet)。

当越来越多的对象晋升到老年代 Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器。除了回收整个 Young Region,还会回收一部分的 Old Region(是一部分老年代,而不是全部老年代)。

Full GC

转移失败(Evacuation Failure)是指当 G1 无法在堆空间中申请新的分区时,G1 便会触发担保机制,执行一次 STW 式的、单线程的 Full GC。Full GC 会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。由于 G1 的应用场合往往堆内存都比较大,所以 Full GC 的收集代价非常昂贵,应该避免 Full GC 的发生。

G1 在以下场景中会触发 Full GC,同时会在日志中记录 to-space-exhausted 以及 Evacuation Failure:

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区;
  • 从老年代分区转移存活对象时,无法找到可用的空闲分区;
  • 分配巨型对象时在老年代无法找到足够的连续分区。
Concurrent Marking Threads

并发标记位图过程

要标记存活的对象,每个分区都需要创建位图(Bitmap)信息来存储标记数据,来确定标记周期内被分配的对象。G1 采用了两个位图Previous Bitmap、Next Bitmap,来存储标记数据,Previous 位图存储上次的标记数据,Next 位图在标记周期内不断变化更新,同时Previous位图的标记数据也越来越过时,当标记周期结束后Next位图便替换 Previous 位图,成为上次标记的位图。同时,每个分区通过顶部开始标记(TAMS),来记录已标记过的内存范围。同样的,G1 使用了两个顶部开始标记 Previous TAMS(PTAMS)、Next TAMS(NTAMS),记录已标记的范围。

每个并发标记周期,在初始标记 STW 的最后,G1 会分配一个空的 Next 位图和一个指向分区顶部(Top)的 NTAMS 标记。Previous 位图记录的上次标记数据,上次的标记位置,即 PTAMS,在 PTAMS 与分区底部(Bottom)的范围内,所有的存活对象都已被标记。那么,在 PTAMS 与 Top 之间的对象都将是隐式存活(Implicitly Live)对象。在并发标记阶段,Next 位图吸收了 Previous 位图的标记数据,同时每个分区都会有新的对象分配,则 Top 与 NTAMS 分离,前往更高的地址空间。在并发标记的一次标记中,并发标记线程将找出 NTAMS 与 PTAMS 之间的所有存活对象,将标记数据存储在 Next 位图中。同时,在 NTAMS 与 Top 之间的对象即成为已标记对象。如此不断地更新 Next 位图信息,并在清除阶段与 Previous 位图交换角色。

RSet 维护

由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1 需要一个增量式的完全标记并发算法,通过维护 RSet,得到准确的分区引用信息。在 G1 中,RSet 的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)

Barrier

栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。

Barrier 示意

  • 写前栅栏 Pre-Write Barrrier
    即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么 JVM 就需要在赋值语句生效之前,记录丧失引用的对象。JVM 并不会立即维护 RSet,而是通过批量处理,在将来RSet 更新;
  • 写后栅栏 Post-Write Barrrier
    当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的 RSet 也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet 也不会立即更新,同样只是记录此次更新日志,在将来批量处理。
SATB

增量式完全并发标记算法起始快照算法(SATB,Snapshot at the beginning),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合 G1 的分区块的堆结构,同时解决了 CMS 的主要烦恼:重新标记暂停时间长带来的潜在风险。

SATB 会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么 JVM 需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在 SATB 日志或缓冲区中。每个线程都会独占一个 SATB 缓冲区,初始有 256 条记录空间。当空间用尽时,线程会分配新的 SATB 缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新 RSet。此过程又称为并发标记/SATB 写前栅栏。

Concurrence Refinement Threads

当赋值语句发生后,写后栅栏会先通过 G1 的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与 SATB 类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。

并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新 RSet,线程最大数目可通过 -XX:G1ConcRefinementThreads(默认等于 -XX:ParellelGCThreads)设置。并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值 -X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1 会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则 Mutator 线程(Java 应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。

总结

G1 是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的 GC 暂停目标,就能得到不错的性能;同时,我们也看到 G1 对内存空间的浪费较高,但通过首先收集尽可能多的垃圾(Garbage First)的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。

  • G1 的设计原则是”首先收集尽可能多的垃圾(Garbage First)”。因此,G1 并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时 G1 可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  • G1 采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此 G1 天然就是一种压缩方案(局部压缩);
  • G1 虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的 survivor(to space)堆做复制准备。G1 只有逻辑上的分代概念,或者说每个分区都可能随 G1 的运行在不同代之间前后切换;
  • G1 的收集都是 STW 的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

2.5.6 Epsilon

Epsilon 垃圾回收器的目标是开发一个控制内存分配,但是不执行任何实际的垃圾回收工作。它提供一个完全消极的 GC 实现,分配有限的内存资源,最大限度的降低内存占用和内存吞吐延迟时间。

Java 版本中已经包含了一系列的高度可配置化的 GC 实现。各种不同的垃圾回收器可以面对各种情况。但是有些时候使用一种独特的实现,而不是将其堆积在其他 GC 实现上将会是事情变得更加简单:

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

Epsilon 垃圾回收器和其他 OpenJDK 的垃圾回收器一样,可以通过参数 -XX:+UseEpsilonGC 开启。

Epsilon 线性分配单个连续内存块。可复用现存 VM 代码中的 TLAB 部分的分配功能。非 TLAB 分配也是同一段代码,因为在此方案中,分配 TLAB 和分配大对象只有一点点的不同。Epsilon 用到的 barrier 是空的(或者说是无操作的)。因为该 GC 执行任何的 GC 周期,不用关系对象图,对象标记,对象复制等。引进一种新的 barrier-set 实现可能是该 GC 对 JVM 最大的变化。

2.5.7 ZGC

ZGC 即 Z Garbage Collector(垃圾收集器或垃圾回收器),是一个可伸缩的、低延迟的垃圾收集器。目前已经在 JDK 15 中升级为正式环境可以使用的垃圾回收器。主要为了满足如下目标进行设计:

  • GC 停顿时间不超过 10ms;
  • 即能处理几百 MB 的小堆,也能处理几个 TB 的大堆应用吞吐能力不会下降超过 15%(与 G1 回收算法相比)
  • 方便在此基础上引入新的 GC 特性和利用 colord;
  • 针以及 Load barriers 优化奠定基础。

其中参数: -Xmx 是 ZGC 收集器中最重要的调优选项,大大解决了程序员在 JVM 参数调优上的困扰。ZGC 是一个并发收集器,必须要设置一个最大堆的大小,应用需要多大的堆,主要有下面几个考量:

  • 对象的分配速率,要保证在 GC 的时候,堆中有足够的内存分配新对象;
  • 一般来说,给 ZGC 的内存越多越好,但是也不能浪费内存,所以要找到一个平衡。
-XX:+ UnlockExperimentalVMOptions -XX:+ UseZGC -Xmx10g

3. JVM 参数

3.1 堆内存相关

3.1.1 堆内存

-Xms<heap size>[unit] 
-Xmx<heap size>[unit]
  • heap size 表示要初始化内存的具体大小;
  • unit 表示要初始化内存的单位。单位为“ g”* (GB) 、“ m”(MB)、“ k”(KB)。
-Xms2G -Xmx5G
# 为 JVM 分配最小 2GB 和最大 5GB 的堆内存大小

3.1.2 新生代内存

将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。

-XX:NewSize=<young size>[unit] 
-XX:MaxNewSize=<young size>[unit]

# 为新生代分配最小 256m 的内存,最大 1024m 的内存
-XX:NewSize=256m
-XX:MaxNewSize=1024m
-Xmn<young size>[unit]

# 为新生代分配 256m 的内存(NewSize 与 MaxNewSize 设为一致)
-Xmn256m 

# 设置新生代和老年代内存的比值,新生代与老年代占比为1:1
-XX:NewRatio=1

3.1.3 永久代/元空间

从 Java 8 开始,如果我们没有指定 Metaspace 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)

# 永久代调节方法
-XX:PermSize=N # 方法区 (永久代) 初始大小
-XX:MaxPermSize=N # 方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

# 元空间调节方法
-XX:MetaspaceSize=N # 设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N # 设置 Metaspace 的最大大小,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

3.2 垃圾收集

3.2.1 垃圾收集器

为了提高应用程序的稳定性,选择正确的垃圾收集算法至关重要。

JVM具有四种类型的 GC 实现:

  • 串行垃圾收集器;
  • 并行垃圾收集器;
  • CMS 垃圾收集器;
  • G1 垃圾收集器。
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParNewGC
-XX:+UseG1GC

3.2.2 GC 记录

Java 9 中 ,JVM 有了统一的日志记录系统,可以使用新的命令行选项-Xlog 来控制 JVM 上 所有组件的日志记录。该日志记录系统可以设置输出的日志消息的标签、级别、修饰符和输出目标等。Java 9 移除了在 Java 8 中 被废弃的垃圾回收器配置组合,同时 把 G1 设为默认的垃圾回收器实现。另外,CMS 垃圾回收器已经被声明为废弃。Java 9 也增加了很多可以通过 jcmd 调用的诊断命令。

为了严格监控应用程序的运行状况,我们应该始终检查 JVM 的垃圾回收性能。最简单的方法是以人类可读的格式记录 GC 活动。

-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=< number of log files > 
-XX:GCLogFileSize=< file size >[ unit ]
-Xloggc:/path/to/gc.log

4. Class 文件结构

根据 Java 虚拟机规范,Class 文件通过 ClassFile 定义,有点类似 C 语言的结构体。

ClassFile 的结构如下:

ClassFile {
    u4             magic; // Class 文件的标志
    u2             minor_version; // Class 的小版本号
    u2             major_version; // Class 的大版本号
    u2             constant_pool_count; // 常量池的数量
    cp_info        constant_pool[constant_pool_count-1]; //常量池
    u2             access_flags; //Class 的访问标记
    u2             this_class; // 当前类
    u2             super_class; // 父类
    u2             interfaces_count; // 接口
    u2             interfaces[interfaces_count]; // 一个类可以实现多个接口
    u2             fields_count; // Class 文件的字段属性
    field_info     fields[fields_count]; // 一个类可以有多个字段
    u2             methods_count; // Class 文件的方法数量
    method_info    methods[methods_count]; // 一个类可以有个多个方法
    u2             attributes_count; // 此类的属性表中的属性数
    attribute_info attributes[attributes_count]; // 属性表集合
}

4.1 魔数

每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件

程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其它特殊的含义。

4.2 Class 文件版本号

每当 Java 发布大版本(比如 Java 8,Java 9)的时候,主版本号都会加 1。你可以使用 javap -v 命令来快速查看 Class 文件的版本号信息。

高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。

4.3 常量池

常量池的数量是 constant_pool_count-1常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)。

常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:

  • 类和接口的全限定名;
  • 字段的名称和描述符;
  • 方法的名称和描述符。

常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型

tag 对应类型

.class 文件可以通过 javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名 -> temp.txt :将结果输出到 temp.txt 文件)。

4.4 访问标志

这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。

类访问和属性修饰符

4.5 This Class、Super Class、Interfaces

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。

接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。

4.6 字段表集合

字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。

字段表的结构

  • access_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写);
  • name_index: 对常量池的引用,表示的字段的名称;
  • descriptor_index: 对常量池的引用,表示字段和方法的描述符;
  • attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
  • attributes[attributes_count]: 存放具体属性具体内容。

字段的 access_flag 的取值

4.7 方法表

methods_count 表示方法的数量,而 method_info 表示方法表。

Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。

方法表的结构

方法表的 access_flag 取值

因为 volatile 修饰符和 transient 修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronizednativeabstract 等关键字修饰方法,所以也就多了这些关键字对应的标志。

4.8 属性表

在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。

5. JDK 监控

  • jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;
  • jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;
  • jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;
  • jmap (Memory Map for Java) : 生成堆转储快照;
  • jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;
  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。

5.1 JDK 命令行工具

5.1.1 jps

jps:显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。

  • jps -q:只输出进程的本地虚拟机唯一 ID;
  • jps -l:输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径;
  • jps -v:输出虚拟机进程启动时 JVM 参数;
  • jps -m:输出传递给 Java 进程 main() 函数的参数。

5.1.2 jstat

jstat(JVM Statistics Monitoring Tool) 使用于监视虚拟机各种运行状态信息的命令行工具。 它可以显示本地或者远程(需要远程主机提供 RMI 支持)虚拟机进程中的类信息、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具。

# jstat 使用格式
jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

比如 jstat -gc -h3 31736 1000 10表示分析进程 id 为 31736 的 gc 情况,每隔 1000ms 打印一次记录,打印 10 次停止,每 3 行后打印指标头部。

常见的 option 如下:

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

另外,加上 -t 参数可以在输出信息上加一个 Timestamp 列,显示程序的运行时间。

5.1.3 jinfo

jinfo vmid:输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。

jinfo -flag name vmid :输出对应名称的参数的具体值。比如输出 MaxHeapSize、查看当前 jvm 进程是否开启打印 GC 日志。

使用 jinfo 可以在不重启虚拟机的情况下,可以动态的修改 jvm 的参数。

jinfo  -flag  PrintGC 17340
-XX:-PrintGC

jinfo  -flag  +PrintGC 17340

jinfo  -flag  PrintGC 17340
-XX:+PrintGC

# jinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数。

5.1.4 jmap

jmap(Memory Map for Java)命令用于生成堆转储快照(Dump 文件是进程的内存镜像。可以把程序的执行状态通过调试器保存到 dump 文件中。)。 如果不使用 jmap 命令,要想获取 Java 堆转储,可以使用 “-XX:+HeapDumpOnOutOfMemoryError” 参数,可以让虚拟机在 OOM 异常出现之后自动生成 dump 文件,Linux 命令下可以通过 kill -3 发送进程退出信号也能拿到 dump 文件。

jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalizer 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前使用的是哪种收集器等。和jinfo一样,jmap有不少功能在 Windows 平台下也是受限制的。

C:\Users\SnailClimb>jmap -dump:format=b,file=C:\Users\SnailClimb\Desktop\heap.hprof 17340
Dumping heap to C:\Users\SnailClimb\Desktop\heap.hprof ...
Heap dump file created
# 将指定应用程序的堆快照输出到桌面。后面,可以通过 jhat、Visual VM 等工具分析该堆文件。

5.1.5 jhat

jhat 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。

5.1.6 jstack

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合)。

生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过 jstack 来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。

先通过 jps 检查线程,再通过 jstack 查询方法调用中具体的原因。

5.2 JDK 可视化工具

5.2.1 JConsole

JConsole:基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。

如果需要使用 JConsole 连接远程进程,可以在远程 Java 程序启动时加上下面这些参数:

-Djava.rmi.server.hostname=
# 外网访问 ip 地址 
-Dcom.sun.management.jmxremote.port=60001  
# 监控的端口号
-Dcom.sun.management.jmxremote.authenticate=false
# 关闭认证
-Dcom.sun.management.jmxremote.ssl=false

5.2 Visual VM

VisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序,官方在 VisualVM 的软件说明中写上了“All-in-One”的描述字样,预示着他除了运行监视、故障处理外,还提供了很多其他方面的功能,如性能分析(Profiling)。

VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的。

参考文章

  1. JVM 学习笔记
  2. JVM 的类加载器及类的加载过程
  3. JVM 类的主动使用与被动使用
  4. 成员内部类里面为什么不能有静态成员和方法
  5. Java 新生代、老生代和永久代详解
  6. Java 虚拟机中 STW
  7. TLAB 到底是干什么的
  8. JVM 之逃逸分析
  9. JVM 中的 StringTable
  10. Java 18 拥有 9 个新特性
  11. Dump 文件

文章作者: 陈鑫扬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈鑫扬 !
评论
  目录