JVM详解

文章目录

JVM整体结构

Java虚拟机有很多,HotSpot VM是目前市面上高性能虚拟机的代表作之一。HotSpot 的技术优势就在于热点代码探测技术(名字就从这来的)和准确式内存管理技术。

热点代码探测,指的是,通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译,解释器就可以不再逐行的将字节码翻译成机器码,而是将一整个方法的所有字节码翻译成机器码再执行。

JVM 大致可以划分为三个区域,分别是类加载子系统(Class Loader)、运行时数据区(Runtime Data Areas)和执行引擎(Excution Engine)。下图为 HotSport 虚拟机结构图

  • 类加载子系统:将class文件加载到内存中。具体分为三个步骤:装载,链接,初始化。
  • 运行时数据区:JVM 定义了 Java 程序运行期间需要使用到的内存区域,简单来说,这块内存区域存放了字节码信息以及程序执行过程的数据,垃圾收集器也会针对运行时数据区进行对象回收的工作。包括方法区、堆、Java栈(虚拟机栈)、本地方法栈、程序计数器。其中线程共享,堆和方法区;Java栈、本地方法栈和程序计数器为每个线程独有一份的。
  • 执行引擎:将字节码指令解释/编译为对应平台上的本地机器指令才可以,简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。包括:解释器、JIT及时编译器、GC垃圾回收器。

Java类加载机制

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、卸载、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接。这几个阶段一般是顺序发生的,但在动态绑定的情况下,解析阶段发生在初始化阶段之后。

类加载器只负责class文件的加载,至于它是否可以运行,则由执行引擎决定。被加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量。

加载

加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口。

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。简单来说,加载指的是把从各个来源的class字节码文件,通过类加载器装载入内存中。

类加载器并不需要等到某个类被"首次主动使用"时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。类加载器并不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次。

类加载阶段的作用:

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

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

验证

验证是连接阶段的第一步,这一阶段的目的是确保class文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

大致都会完成以下四个阶段的验证:

  • 文件格式验证;
  • 元数据验证,是否符合Java语言的规范;
  • 字节码验证,确保程序语义合法,符合逻辑;
  • 符号引用验证,确保下一步解析能正常执行;

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这个过程将在方法区中进行分配。举个例子:

java 复制代码
public String var1 = "var1";
public static String var2 = "var2";
public static final String var3 = "var3";

变量var1不会被分配内存,但是var2会被分配。var2会被分配初始值为null而不是var2。这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

这时候进行内存分配的仅包括类变量(Class Variables ,即静态变量,被 static关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在Java堆中。这里不包含final修饰的static,因为final在编译的时候就已经分配了,也就是说var3被分配的值为var3

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。对应常量池中的CONSTANT Class infoCONSTANT Fieldref infoCONSTANT Methodref info等。

  • 符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括了以下三类常量: 类和接口的全限定名 字段的名称和描述符 方法的名称和描述符。符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。
  • 直接引用:直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。直接引用通过对符号引用进行解析,找到引用的实际内存地址。

在编译的时候每个Java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。

初始化

初始化阶段是执行初始化方法<clinit>方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。对于<clinit>方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为<clinit>方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。

<clinit>方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有<clinit>方法。

在准备阶段,静态变量已经被赋过默认初始值,而在初始化阶段,静态变量将被赋值为代码期望赋的值。举个例子:

public static String var2 = "var2";

在准备阶段变量var2的值为null,在初始化阶段赋值为var2

何时初始化:

  • 创建类的实例,也就是new一个对象需要初始化
  • 读取或者设置静态字段的时候需要初始化(但被final修饰的字段,在编译时就被放入静态常量池的字段除外。)
  • 调用类的静态方法
  • 使用反射Class.forName("");对类反射调用的时候,该类需要初始化
  • 初始化一个类的时候,有父类,先初始化父类
    • 接口除外,父接口在调用的时候才会被初始化;
    • 子类引用父类的静态字段,只会引发父类的初始化;
  • 被标明为启动类的类(即包含main方法),需要初始化

类加载器

类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。

典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的"类文件"。每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。

从虚拟机的角度来说,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该类加载器使用C++语言实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),属于虚拟机自身的一部分。另外一种就是自定义类加载器,这些类加载器是由Java语言实现,独立于JVM外部,并且全部继承自抽象类java.lang.ClassLoader

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。

常见类加载器

rt.jar:rt 代表"RunTime",rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。

我们常用内置库 java.xxx.*都在里面,比如java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*

  1. 启动类加载器(引导类加载器、Bootstrap ClassLoader):
  • 该类加载器使用C/C++语言实现的,嵌套在JVM内部,可理解为就是JVM的一部分;
  • 它用来加载Java的核心库(JAVAHOME/jre/1ib/rt.jar、resources.jarsun.boot.class.path路径下的内容),用于提供JVM自身需要的类;
  • 并不继承自java.lang.ClassLoader,没有父加载器;
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器;
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类;
  1. 扩展类加载器:
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现;
  • 派生于ClassLoader类;
  • 父类加载器为启动类加载器;
  • java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/1ib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载;
  1. 应用程序类加载器(系统类加载器、AppClassLoader):
  • Java语言编写,由sun.misc.LaunchersAppClassLoader实现;
  • 派生于ClassLoader类;
  • 父类加载器为扩展类加载器;
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库;
  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载;
  • 通过classLoader#getSystemclassLoader方法可以获取到该类加载器;
自定义类加载器

除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码(.class文件)进行加密,加载时再利用自定义的类加载器对其解密。

自定义加载器使用场景:

  1. 隔离加载类
  2. 修改类加载的方式
  3. 扩展加载源
  4. 防止源码泄漏

若要实现自定义类加载器,只需要继承java.lang.ClassLoader类,按需重写相关方法即可。

  • 如果不想打破双亲委派模型,那么只需要重写findClass方法
  • 如果想打破双亲委派模型,那么就重写整个loadClass方法

在JDK1.2之前,类加载尚未引入双亲委派模式,因此实现自定义类加载器时常常重写loadClass方法,提供双亲委派逻辑,从JDK1.2之后,双亲委派模式已经被引入到类加载体系中,自定义类加载器时不需要在自己写双亲委派的逻辑,因此不鼓励重写loadClass方法,而推荐重写findClass方法。

在Java中,任意一个类都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,即比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提之下才有意义,否则,即使这两个类来源于同一个class类文件,只要加载它的类加载器不相同,那么这两个类必定不相等(这里的相等包括代表类的Class对象的equals方法、isAssignableFrom方法、isInstance方法和instanceof关键字的结果)。

双亲委派模型

ClassLoader类使用委托模型来搜索类和资源,每个 ClassLoader实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。这种层次关系称为类加载器的双亲委派模型。 我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在JDK1.2期间被引入并广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者们推荐给开发者的一种类的加载器实现方式。

双亲委派模型工作流程:

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载;
  • 如果子类加载器也无法加载这个类,那么它会抛出一个ClassNotFoundException异常;

为什么使用双亲委派模型?

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。

双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoaderBootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

如何打破双亲委派模型?

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

类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,调用父加载器loadClass方法来加载类。重写 loadClass方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。

类卸载

卸载类即该类的Class对象被 GC。卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象;
  2. 该类没有在其他任何地方被引用;
  3. 该类的类加载器的实例已被 GC;

所以,在 JVM 生命周期内,由 JVM 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。JDK 自带的 BootstrapClassLoaderExtClassLoaderAppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

Java内存区域划分

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。

Java内存主要就是对运行时数据区域进行划分:

  • 程序计数寄存器
  • 虚拟机栈
  • 本地方法栈
  • 方法区

其中:方法区、堆、直接内存(非运行时数据区的一部分)为线程共享。程序计数寄存器、虚拟机栈、本地方法栈 为线程私有。

JDK 1.8 和之前的版本略有不同,这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。

程序计数器

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

特点:

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

程序计数器中既不存在GC又不存在OOM,所以不存在垃圾回收问题。

PC寄存器主要的作用,是用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。由于Java的多线程是通过线程轮流切换完成的,一个线程没有执行完时就需要一个东西记录它执行到哪了,下次抢占到了CPU资源时再从这开始,这个东西就是程序计数器,正是因为这样,所以它也是"线程私有"的内存。

代码演示

public class MainTest {
    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;

        String str = "abc";
        System.out.println(str);
        System.out.println(k);
    }
}

通过javap -verbose MainTest.class命令反编译.class文件,得到如下

// ...
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: bipush        10
         2: istore_1
         3: bipush        20
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: ldc           #2                  // String abc
        12: astore        4
        14: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        17: aload         4
        19: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        22: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        25: iload_3
        26: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
        29: return
// ...

通过PC寄存器,我们就可以知道当前程序执行到哪一步了。

虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的,当然也需要和其他运行时数据区域比如程序计数器配合。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

  • 局部变量表:Local Variables,被称之为局部变量数组或本地变量表。定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
  • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
  • 动态链接:主要服务一个方法需要调用其他方法的场景。class文件的常量池里保存有大量的符号引用比如方法引用的符号引用。
    当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为动态连接。

栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError两种错误。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存,因为还有一些对象是在栈上分配的。

在方法执行结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。也就是触发了GC的时候,才会进行回收,如果堆中对象马上被回收,那么用户线程就会收到影响,一个方法频繁的调用频繁的回收程序性能会收到影响。所以堆是GC执行垃圾回收的重点区域。

Java7及之前,堆内存划分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区,新生区被划分为又被划分为Eden区和Survivor区;
  • Tenure Generation Space 养老区;
  • Permanent Space 永久区(逻辑上属于方法区);

Java 8及之后,堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space新生区,新生区被划分为又被划分为Eden区和Survivor区;
  • Tenure Generation Space 养老区;
  • Meta Space 元空间;

堆空间内部结构,JDK1.8之后永久代替换成了元空间,元空间使用的是本地内存。

对象分配内存步骤:

  1. 新的对象先放伊甸园区,此区有大小限制,如果对象过大可能直接分配在老年代(元空间)。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行MinorGC,将伊甸园区中的不再被其他对象所引用的对象进行销毁,再将新的对象放到伊甸园区,然后将伊甸园中的剩余对象移动到幸存者0区。
  3. 如果再次触发MinorGC,会首先将没有被回收的对象放到幸存者1区,然后判断幸存者0区中的对象是否能被回收,如果没有回收,就会放到幸存者1区。
  4. 重复步骤3、4,默认情况下如果一个对象被扫描了15次(阈值),都不能被回收,则将该对象晋升到老年代。
  5. 当老年代内存不足时,触发Major GC,进行老年代的内存清理。
  6. 若老年代执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM错误。

可以用 -XX:MaxTenuringThreshold=N 进行设置幸存者区到老年代的GC扫描次数,默认15次,不过,设置的值应该在 0-15,否则会爆出以下错误。

text 复制代码
MaxTenuringThreshold of 20 is invalid; must be between 0 and 15

为什么年龄只能是 0-15?因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。

如果幸存者区满了?如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代。需要特别注意,在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作。

TLAB

堆空间都是共享的么?不是,因为还有TLAB这个概念,在堆中划分出一块区域,为每个线程所独占,以此来保证线程安全。

TLAB全称Thread Local Allocation Buffer译为,线程本地分配缓冲区。因为堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度,使用锁又会影响性能,TLAB应运而生。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

对象首先是通过TLAB开辟空间,如果不能放入,那么需要通过Eden来进行分配。尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。可以通过选项-XX:UseTLAB设置是否开启TLAB空间,默认是开启的。一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集,所以把方法区看作是一块独立于Java堆的内存空间。

方法区和永久代以及元空间是什么关系呢?方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

运行时常量池

运行时常量池是每一个类或接口的常量池的运行时表示形式。每一个运行时常量池都分配在Java虚拟机的方法区之中,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来。当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间,超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

简单说来就是JVM在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。根据JVM规范,JVM内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分,运行时常量池存放在JVM内存模型中的方法区中。在不同版本的JDK中,运行时常量池所处的位置也不一样。以HotSpot为例,JDK1.7之前方法区位于永久代,由于一些原因在JDK1.8时彻底祛除了永久代,用元空间代替。

运行时常量池内容包含了Class常量池中的常量和字符串常量池中的内容。它包含多种不同的常量,也包括编译期就已经明确的数值字面量、到运行期解析后才能够获得的方法或者字段引用。它扮演了类似传统语言中符号表的角色,不过它存储数据范围比通常意义上的符号表要更为广泛。

保存在JVM方法区中的叫运行时常量池,在 class/字节码 文件中的叫Class常量池(静态常量池)。与类文件中的Class常量池不同的是,运行时常量池是在类加载到内存后动态生成的,相对于Class常量池的另一重要特征是具备动态性。运行时常量池的动态性体现在它不仅仅是简单地静态存储常量值,而是支持了Java程序在运行时动态加载类、操作字符串、实现动态代理和利用反射等高级特性。例如,反射机制可以通过运行时常量池中的符号引用动态加载类、获取和调用方法等。

字符串常量池

在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的,会单独开辟一块内存,用于保存字符串常量,这个内存区域被叫做字符串常量池。字符串常量池保存着所有字符串字面量,这些字面量在编译时期就被确定。

字符串常量池不同版本JDK中位置是不一样的,Java6及以前,字符串常量池存放在永久代,Java7将字符串常量池的位置调整到Java堆内。那么字符串常量池为什么要调整位置?

是为了优化内存管理和避免特定情况下的内存溢出问题。在早期的Java版本(如JDK 6及之前),字符串常量池通常是存放在永久代,GC对永久代的回收效率很低,只有在Full GC的时候才会触发,这就导致字符串常量池回收效率不高。而我们开发过程中,使用频率比较高,会有大量的字符串被创建,如果无法及时回收,容易导致永久代内存不足。所以JDK7之后将字符串常量池放到堆里,能及时回收内存,避免出现OOM错误。

直接内存

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过JNI的方式在本地内存上分配的。直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError错误出现。

JDK1.4中新加入的 NIO,引入了一种基于通道与缓存区的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。

java 复制代码
public class MainTest {
    private static final int BUFFER = 1024 * 1024 * 20;
    public static void main(String[] args) {
        ArrayList<ByteBuffer> list = new ArrayList<>();
        int count = 0;
        try {
            while(true){
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
                list.add(byteBuffer);
                count++;
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        } finally {
            System.out.println(count);
        }
    }
}

通常,访问直接内存的速度会优于Java堆,即读写性能高。因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小。直接内存大小可以通过 MaxDirectMemorySize 设置,如果不指定,默认与堆的最大值-Xmx参数值一致。系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。所以直接内存也有一些缺点,分配回收成本较高和不受JVM内存回收管理。

JVM垃圾回收机制

垃圾回收(Garbage Collection),顾名思义就是释放垃圾占用的空间,防止内存爆掉。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出

垃圾判定算法

在运行程序中,当一个对象已经不再被任何存活的对象引用时,就可以就可以判定该对象已经死亡了,这个对象就是需要被回收的垃圾。一般用这么几种算法,来确定一个对象是否是垃圾:

  • 引用计数算法
  • 可达性分析算法
引用计数算法

引用计数算法是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。在堆中判定新生代中的幸存者区是否可以进老年代,会有一个年龄计数器,这里用的就是引用计数算法。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。当p的指针断开的时候,内部的引用形成一个循环,从而造成内存泄漏。

虽然引用计数算法存在循环引用的问题,但是很多语言的资源回收选择,例如:因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制;具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。

Python如何解决循环引用?

  • 手动解除:很好理解,就是在合适的时机,将引用计数器中的计数属性置为零,解除引用关系。
  • 使用弱引用weakrefweakref是Python提供的标准库,旨在解决循环引用。
可达性分析算法

可达性分析算法是以根对象集合GCRoots为起始点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是,该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。只要你无法与GCRoot建立直接或间接的连接,系统就会判定你为可回收对象。所谓根集合GCRoots就是一组必须活跃的引用,即有在栈中有指针指向堆中的地址,它们是程序运行时的起点,是一切引用链的源头。

在Java中,GCRoots包括以下几种:

  1. 虚拟机栈中引用的对象;例如:各个线程被调用的方法中使用到的参数、局部变量等;
  2. 本地方法栈内,本地方法引用对象方法区中类静态属性引用的对象;例如:Java类的引用类型静态变量;
  3. 方法区中常量引用的对象;例如:字符串常量池里的引用;
  4. 所有被同步锁synchronized持有的对象;
  5. Java虚拟机内部的引用;例如:一些常驻的异常对象、系统类加载器等;
  6. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等;
  7. 除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入,共同构成完整GCRoots集合,比如:分代收集和局部回收;

除了堆空间产生对象的一些结构外,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间的对象的引用,都可以作为GCRoots进行可达性分析。

如何判定是否为GCroot?由于Root采用栈方式存放变量和指针,所以如果一个指针,保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个GCroot。代码演示:

java 复制代码
public class StackReference {
    public void greet() {
        Object localVar = new Object(); // 这里的 localVar 是一个局部变量,存在于虚拟机栈中
        System.out.println(localVar.toString());
    }

    public static void main(String[] args) {
        new StackReference().greet();
    }
}
  • greet方法中localVar是一个局部变量,存在于虚拟机栈中,可以被认为是GCRoots
  • greet方法执行期间,localVar引用的对象是活跃的,因为它是从GCRoots可达的。
  • greet方法执行完毕后,localVar的作用域结束,localVar引用的 Object 对象不再由任何GCRoots引用(假设没有其他引用指向这个对象),因此它将有资格作为垃圾被回收掉。

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,如果这点不满足,分析结果的准确性就无法保证。简单来说就是执行这个算法的时候,要停止程序标记对象,不能一边改变对象的引用一边判定对象是不是垃圾。

这点也是导致GC进行时必须Stop The World的一个重要原因。即使是号称几乎不会发生停顿的CMS收集器,标记根节点时也是必须要停顿的。

STW

Stop-The-World直译为:停止一切,简称STW,指的是垃圾回收发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,所以叫Stop-The-World

在垃圾回收标记阶段,JVM使用可达性分析算法进行标记那些对象是垃圾,如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。所以在垃圾回收的时候要STW,分析工作必须在一个能确保一致性的快照中进行。

STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉。被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。

STW事件和采用哪款GC无关,因为所有的GC都有这个事件。任何垃圾回收器都不能完全避免Stop-The-World情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。因此,在选择和调优垃圾收集器时,需要考虑其停顿时间。Java 中的一些垃圾收集器,如 G1和 ZGC,都会尽可能地减少了STW的时间,通过并发的垃圾收集,提高应用的响应性能。

对象引用

可达性分析是基于引用链进行判断的,在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:

  • 强引用(StrongReference):最传统的"引用"的定义;无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。强引用为JVM内部实现,其他三类引用类型全部继承自Reference父类。

上述引用垃圾回收的前提条件是,对象都是可触及的(可达性分析结果为可达),如果对象不可触及就直接被垃圾回收器回收了。

强引用

在Java程序中,最常见的引用类型是强引用,普通系统99%以上都是强引用,也就是我们最常见的普通对象引用,也是默认的引用类型。当在Java语言中使用new操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。代码演示:

java 复制代码
// 强引用测试
public class MainTest {
    public static void main(String[] args) {
        StringBuffer var0 = new StringBuffer("hello world");
        StringBuffer var1 = var0;

        var0 = null;
        System.gc();
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(var1.toString());
    }
}

强引用所指向的对象在任何时候都不会被系统回收,虚拟机会抛出OOM异常,也不会回收强引用所指向对象,所以强引用是导致内存泄漏的主要原因。

软引用

软引用是一种比强引用生命周期稍弱的一种引用类型。在JVM内存充足的情况下,软引用并不会被垃圾回收器回收,只有在JVM内存不足的情况下,才会被垃圾回收器回收。

软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

这里的第一次回收是指不可达的对象

所以软引用一般用来实现一些内存敏感的缓存,只要内存空间足够,对象就会保持不被回收掉。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。代码演示:

java 复制代码
/**
 * 软引用测试
 * 
 * 虚拟机参数:
 * -Xms10m
 * -Xmx10m
 * -XX:+PrintGCDetails
 */
public class MainTest {
    public static void main(String[] args) {
        //SoftReference<User> softReference = new SoftReference<>(new User("hello"));
        // 上面的一行代码等价于下面的三行代码
        User user = new User("hello");
        SoftReference<User> softReference = new SoftReference<User>(user);
        // 一定要销毁强引用对象 否则创建软引用对象将毫无意义
        user = null;
        System.out.println("创建大对象之前:" + softReference.get());
        try{
            // 模拟堆内存资源紧张 看软引用对象是否会被回收
            byte[] bytes = new byte[1024 * 1024 *7];
        }catch (Throwable e) {
            e.printStackTrace();
        }finally {
            System.out.println("创建大对象之后:" + softReference.get());
        }
    }
}

class User {
    private String name;

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

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                '}';
    }
}
弱引用

弱引用也是用来描述那些非必需对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。

在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。代码演示:

java 复制代码
/**
 * 弱引用测试
 */
public class MainTest {
    public static void main(String[] args) {
        WeakReference<User> weakReference = new WeakReference<>(new User("hello"));
        System.out.println("建议GC之前:" + weakReference.get());
        System.gc();
        System.out.println("建议GC之后:" + weakReference.get());
    }
}

class User {
    private String name;

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

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                '}';
    }
}
虚引用

虚引用也称为"幽灵引用"或者"幻影引用",是所有引用类型中最弱的一个。

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。它不能单独使用,也无法通过虚引用来获取被引用的对象,当试图通过虚引用的get方法取得对象时,总是null

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。虚引用必须和引用队列一起使用。 虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。代码演示:

java 复制代码
/**
 * 虚引用测试
 */
public class MainTest {
    // 当前类对象的声明
    public static MainTest obj;
    // 引用队列
    static ReferenceQueue<MainTest> phantomQueue = null;

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

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while(true) {
                if (phantomQueue != null) {
                    PhantomReference<MainTest> objt = null;
                    try {
                        objt = (PhantomReference<MainTest>) phantomQueue.remove();
                    } catch (Exception e) {
                        e.getStackTrace();
                    }
                    if (objt != null) {
                        System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
                    }
                }
            }
        }, "t1");
        thread.setDaemon(true);
        thread.start();

        phantomQueue = new ReferenceQueue<>();
        obj = new MainTest();
        // 构造了PhantomReferenceTest对象的虚引用,并指定了引用队列
        PhantomReference<MainTest> phantomReference = new PhantomReference<>(obj, phantomQueue);
        try {
            System.out.println(phantomReference.get());
            // 去除强引用
            obj = null;
            // 第一次进行GC,由于对象可复活,GC无法回收该对象
            System.out.println("第一次GC操作");
            System.gc();
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj 是 null");
            } else {
                System.out.println("obj 不是 null");
            }
            System.out.println("第二次GC操作");
            obj = null;
            System.gc();
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj 是 null");
            } else {
                System.out.println("obj 不是 null");
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {

        }
    }
}

对象真正死亡

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

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

  • 如果对象在进行可达性分析后发现GCRoots不可达,将会进行第一次标记;
  • 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalized方法;

当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行finalize方法。如果判定结果是有必要执行,此时对象会被放入名为F-Queue的队列,等待Finalizer线程执行其finalized方法。

如果对象在finalized方法中重新将自己与引用链上的任何一个对象进行了关联,如果将自己赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 "即将回收" 的集合,否则该对象就将被真正回收,走向死亡。

垃圾收集算法

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在JVM中比较常见的三种垃圾收集算法是:

  • 标记清除算法(Mark-Sweep
  • 复制算法(Copying
  • 标记整理算法(Mark-Compact
标记清除算法

标记清除算法是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。标记清除算法是最基础的一种垃圾回收算法,它分为两部分,先把内存区域中的这些对象进行标记,哪些属于可回收的标记出来(可达性分析法),然后把这些垃圾拎出来清理掉。当堆中的有效内存空间被耗尽的时候,就会STW,然后进行两项工作,第一项则是标记,第二项则是清除;

  • 标记:垃圾收集器从引用根节点(GCRoots)开始遍历,标记所有被引用的对象,一般是在对象的Header中记录为可达对象。
  • 清除:垃圾收集器对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

标记的是可达对象,不是垃圾对象,清除回收的是垃圾对象,那么什么是清除?

所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。

标记清除算法缺点:

  • 标记清除算法的效率不算高;
  • 在进行GC的时候,需要停止整个应用程序,用户体验较差;
  • 这种方式清理出来的空闲内存是不连续的,会产生内碎片,需要维护一个空闲列表;存放大对象可能会存不下;
复制算法

复制算法是在标记清除算法上演化而来的,为了解决标记清除算法的内存碎片问题,M.L.Minsky于1963年发表了著名的论文:"使用双存储区的Lisp语言垃圾收集器(CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)"。M.L.Minsky在该论文中描述的算法被人们称为复制算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,它也被M.L.Minsky成功地引入到了Lisp语言的一个实现版本中。

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

复制算法虽然解决了标记清除算法带来的问题,实现简单,运行高效,但是也出现了新的问题:

  • 因为用复制的形式,需要两倍的内存空间,或者说只能用分配内存的一半。
  • 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小;简单来说,从From区被复制到To区的可达对象,需要改变之前对象的指针引用,需要内存和时间的开销。

复制算法最坏情况,如果系统中的垃圾对象很少,复制算法需要复制的存活对象数量就会很多,那么大部分对象从一个区域移动到另一个区域,GCRoots需要改变了对象的地址,加大了维护成本。所以复制算法适合,系统中的垃圾对象很多,可复制的存活的对象很少的情况。利用这个特点,在新生代中的幸存者区里面就用到了复制算法的思想。

标记整理算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

标记清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。1970年前后,G.L.Steele、C.J.CheneD.s.Wise等研究者发布标记整理算法。在许多现代的垃圾收集器中,人们都使用了标记整理算法或其改进版本。

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

执行过程:

  • 标记:垃圾收集器从引用根节点(GCRoots)开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
  • 整理:将所有的存活对象压缩到内存的一端,按顺序排放,之后执行清除的步骤。
  • 清除:垃圾收集器对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

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

标记整理算法,消除了标记清除算法当中,内存区域分散的缺点,也规避了复制算法当中,内存减半的高额代价。看起来很美好,但由于多了整理这一步,内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法差很多。

标记整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记清除多了一个整理内存的阶段。

标记清除算法 标记整理算法 复制算法
时间开销 中等 最慢 最快
空间开销 少(会堆积碎片) 少(不堆积碎片) 通常需要存活对象的两倍空间(不堆积碎片)
移动对象

这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法思想应运而生。

分代收集思想

分代收集算法严格来说并不是一种思想或理论,而是融合上述三种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

分代收集算法,是基于不同的对象的生命周期是不一样的这样一个事实。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

分代的思想被现有的虚拟机广泛使用,几乎所有的垃圾回收器都区分新生代和老年代。在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。适合复制算法,复制算法内存利用率不高的问题,通过Hotspot中的两个幸存者区的设计得到缓解。
  • 老年代:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。不适合复制算法,一般是由标记清除或者是标记清除与标记整理的混合实现。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

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

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

Serial GC由于弊端较大,只有放在单核CPU上才能充分发挥其作用,由于现在都是多核CPU已经不用串行收集器了,所以以下内容了解即可。对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。

Serial GC(串行垃圾回收回器)是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。Serial GC作为HotSpot中client模式下的默认新生代垃圾收集器;Serial GC年轻代采用标记-复制算法,老年代采用标记-整理算法、串行回收和STW机制的方式执行内存回收。

Serial GC是一个单线程的收集器,但它的"单线程"的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

Serial GC的优点,简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial GC由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。是运行在client模式下的虚拟机是个不错的选择。

运行任意程序,设置虚拟机参数如下,当设置使用Serial GC时,新生代和老年代都会使用串行收集器。

-XX:+PrintCommandLineFlags -XX:+UseSerialGC

输出

-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseSerialGC 
ParNew GC

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为:控制参数、收集算法、回收策略等等和 Serial 收集器完全一样。

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器配合工作。

Parallel Scavenge GC

Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。

Serial Old GC

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:

  • 在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用;
  • 作为 CMS 收集器的后备方案;
Parallel Old GC

Parallel Scavenge 收集器的老年代版本。使用多线程和"标记-整理"算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS

CMS全称Concurrent Mark Sweep,是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。CMS收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 "标记-清除"算法实现的,以获取最短回收停顿时间为目标,采用"标记-清除"算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW,JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除。

  • 初始标记,指的是寻找所有被 GCRoots 引用的对象,该阶段需要Stop the World。这个步骤仅仅只是标记一下 GCRoots 能直接关联到的对象,并不需要做整个引用的扫描,因此速度很快。
  • 并发标记,指的是对初始标记阶段标记的对象进行整个引用链的扫描,该阶段不需要Stop the World。 对整个引用链做扫描需要花费非常多的时间,因此需要通过垃圾回收线程与用户线程并发执行,降低垃圾回收的时间,所以叫做并发标记。这也是 CMS 能极大降低 GC 停顿时间的核心原因,但这也带来了一些问题,即:并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该回收的垃圾没有被回收)和多标(本不应该回收的垃圾被回收)了。
  • 重新标记,指的是对并发标记阶段出现的问题进行校正,该阶段需要Stop the World。正如并发标记阶段说到的,由于垃圾回收算法和用户线程并发执行,虽然能降低响应时间,但是会发生漏标和多标的问题。所以对于 CMS 来说,它需要在这个阶段做一些校验,解决并发标记阶段发生的问题。
  • 并发清除,指的是将标记为垃圾的对象进行清除,该阶段不需要Stop the World。 在这个阶段,垃圾回收线程与用户线程可以并发执行,因此并不影响用户的响应时间。

CMS 的优点是:并发收集、低停顿,但缺点也很明显:

  • 对 CPU 资源非常敏感,因此在 CPU 资源紧张的情况下,CMS 的性能会大打折扣。默认情况下,CMS 启用的垃圾回收线程数是(CPU数量 + 3)/4,当 CPU 数量很大时,启用的垃圾回收线程数占比就越小。但如果 CPU 数量很小,例如只有 2 个 CPU,垃圾回收线程占用就达到了 50%,这极大地降低系统的吞吐量,无法接受。
  • CMS 采用的是「标记-清除」算法,会产生大量的内存碎片,导致空间不连续,当出现大对象无法找到连续的内存空间时,就会触发一次 Full GC,这会导致系统的停顿时间变长。
  • CMS 无法处理浮动垃圾,当 CMS 在进行垃圾回收的时候,应用程序还在不断地产生垃圾,这些垃圾会在 CMS 垃圾回收结束之后产生,这些垃圾就是浮动垃圾,CMS 无法处理这些浮动垃圾,只能在下一次 GC 时清理掉。
G1

G1(Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。G1有五个属性:分代、增量、并行、标记整理、可预测的停顿。

  1. 分代:
    将堆内存分为多个大小相等的区域(Region),每个区域都可以是 Eden 区、Survivor 区或者 Old 区。

    可以通过 -XX:G1HeapRegionSize=n 来设置 Region 的大小,可以设定为 1M、2M、4M、8M、16M、32M(不能超过)。

    G1有专门分配大对象的 RegionHumongous 区,而不是让大对象直接进入老年代的 Region 中。在 G1中,大对象的判定规则就是一个大对象超过了一个 Region 大小的 50%,比如每个 Region 是 2M,只要一个对象超过了 1M,就会被放入 Humongous 中,而且一个大对象如果太大,可能会横跨多个 Region 来存放。 G1会根据各个区域的垃圾回收情况来决定下一次垃圾回收的区域,这样就避免了对整个堆内存进行垃圾回收,从而降低了垃圾回收的时间。

  2. 增量:G1可以以增量方式执行垃圾回收,这意味着它不需要一次性回收整个堆空间,而是可以逐步、增量地清理。有助于控制停顿时间,尤其是在处理大型堆时。

  3. 并行:G1垃圾回收器可以并行回收垃圾,这意味着它可以利用多个 CPU 来加速垃圾回收的速度,这一特性在年轻代的垃圾回收(Minor GC)中比较明显,因为年轻代的回收通常涉及较多的对象和较高的回收速率。

  4. 标记整理:在进行老年代的垃圾回收时,G1使用标记-整理算法。这个过程分为两个阶段:标记存活的对象和整理(压缩)堆空间。通过整理,G1能够避免内存碎片化,提高内存利用率。

  5. 可预测的停顿:G1也是基于「标记-清除」算法,因此在进行垃圾回收的时候,仍然需要Stop the World。不过,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

G1中存在三种 GC 模式,分别是 Young GC、Mixed GCFull GC

Eden 区的内存空间无法支持新对象的内存分配时,G1会触发 Young GC。当需要分配对象到 Humongous 区域或者堆内存的空间占比超过 -XX:G1HeapWastePercent 设置的 InitiatingHeapOccupancyPercent 值时,G1会触发一次 concurrent marking,它的作用就是计算老年代中有多少空间需要被回收,当发现垃圾的占比达到 -XX:G1HeapWastePercent 中所设置的 G1HeapWastePercent 比例时,在下次 Young GC 后会触发一次 Mixed GCMixed GC 是指回收年轻代的 Region 以及一部分老年代中的 RegionMixed GCYoung GC 一样,采用的也是复制算法。

Mixed GC 过程中,如果发现老年代空间还是不足,此时如果 G1HeapWastePercent 设定过低,可能引发 Full GC-XX:G1HeapWastePercent 默认是 5,意味着只有 5% 的堆是"浪费"的。如果浪费的堆的百分比大于 G1HeapWastePercent,则运行Full GC

在以Region为最小管理单元以及所采用的 GC 模式的基础上,G1建立了停顿预测模型,即 Pause Prediction Model 。这也是 G1非常被人所称道的特性。可以借助-XX:MaxGCPauseMillis来设置期望的停顿时间(默认 200ms),G1会根据这个值来计算出一个合理的 Young GC 的回收时间,然后根据这个时间来制定Young GC 的回收计划。

G1收集垃圾的过程:

  1. 初始标记 (Inital Marking) :标记 GCRoots 能直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在 Reigin 中分配新对象。G1为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围;
  2. 并发标记 (Concurrent Marking) :从 GCRoots 能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理 SATB 记录中变动的对象。SATBsnapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高;
  3. 最终标记 (Final Marking) :对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量的 STAB 记录。虽然并发标记阶段会处理 SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理;
  4. 筛选回收 (Live Data Counting and Evacuation) :负责更新 Regin 统计数据,按照各个 Regin 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个 Regin 构成回收集。

然后将回收集中 Regin 的存活对象复制到空的 Regin 中,再清理掉整个旧的 Regin 。此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行。

ZGC

ZGC(Z Garbage Collector)是 JDK11 推出的一款低延迟垃圾收集器,适用于大内存低延迟服务的内存管理和回收,SPEC jbb 2015 基准测试,在 128G 的大堆下,最大停顿时间为 1.68 ms,停顿时间远胜于 G1CMS

ZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。不过,默认的垃圾回收器依然是 G1。你可以通过java -XX:+UseZGC className启用 ZGC

G1CMS 类似,ZGC 也采用了复制算法,只不过做了重大优化,ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于 10ms 的关键所在。

关键技术在于:

  • 指针染色(Colored Pointer):一种用于标记对象状态的技术。
  • 读屏障(Load Barrier):一种在程序运行时插入到对象访问操作中的特殊检查,用于确保对象访问的正确性。

这两种技术可以让所有线程在并发的条件下,就指针的状态达成一致。因此,ZGC 可以并发的复制对象,这大大的降低了 GC 的停顿时间。

相关推荐
Theodore_10222 小时前
4 设计模式原则之接口隔离原则
java·开发语言·设计模式·java-ee·接口隔离原则·javaee
----云烟----4 小时前
QT中QString类的各种使用
开发语言·qt
lsx2024064 小时前
SQL SELECT 语句:基础与进阶应用
开发语言
开心工作室_kaic4 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
向宇it4 小时前
【unity小技巧】unity 什么是反射?反射的作用?反射的使用场景?反射的缺点?常用的反射操作?反射常见示例
开发语言·游戏·unity·c#·游戏引擎
武子康4 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud
转世成为计算机大神5 小时前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
宅小海5 小时前
scala String
大数据·开发语言·scala
qq_327342735 小时前
Java实现离线身份证号码OCR识别
java·开发语言
锅包肉的九珍5 小时前
Scala的Array数组
开发语言·后端·scala