深入学习理解java虚拟机

1.初始jvm

1.1 什么是jvm

JVM 全称是 Java Virtual Machine,中文译名 Java虚拟机。JVM 本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件。

Java源代码执行流程如下:

在这里插入图片描述

分为三个步骤:

1、编写Java源代码文件。

2、使用Java编译器(javac命令)将源代码编译成Java字节码文件。

3、使用Java虚拟机加载并运行Java字节码文件,此时会启动一个新的进程。

1.2 jvm的功能

1.2.1 解释和运行

​ 字节码文件中包含了字节码指令,计算器无法直接执行,Java虚拟机会将字节码文件中的字节码指令实时地解释成机器码,机器码是计算机可以运行的指令。

1.2.2 内存管理

​ 自动为对象 , 方法等分配内存空间 , 自动的垃圾回收机制 , 回收不再使用的对象

Java虚拟机会帮助程序员为对象分配内存,同时将不用的对象使用垃圾回收器回收掉,这是对比C和C++这些语言的一个优势。在C/C++语言中,对象的回收需要程序员手动去编写代码完成,如果遗漏了这段删除对象的代码,这个对象就会永远占用内存空间,不会再回收。所以JVM的这个功能降低了程序员编写代码的难度。

1.2.3 即时编译

​ 对热点代码进行优化 , 提升执行效率 . 即时编译可以说是提升Java程序性能最核心的手段。

Java性能低的主要原因和跨平台特性

​ java语言如果不做任何的优化,性能其实是不如C和C++语言的。主要原因是:

在程序运行过程中,Java虚拟机需要将字节码指令实时地解释成计算机能识别的机器码,这个过程在运行时可能会反复地执行,所以效率较低。

java语言 和 c / c++的运行流程对比

在这里插入图片描述

​ Java为什么要选择一条执行效率比较低的方式呢?主要是为了实现跨平台的特性。Java的字节码指令,如果希望在不同平台(操作系统+硬件架构),比如在windows或者linux上运行。可以使用同一份字节码指令,交给windows和linux上的Java虚拟机进行解释,这样就可以获得不同平台上的机器码了。这样就实现了Write Once,Run Anywhere 编写一次,到处运行 的目标。

在这里插入图片描述

但是C/C++语言,如果要让程序在不同平台上运行,就需要将一份源代码在不同平台上分别进行编译,相对来说比较麻烦。

再回到即时编译,在JDK1.1的版本中就推出了即时编译去优化对应的性能。

​ 虚拟机在运行过程中如果发现某一个方法甚至是循环是热点代码(被非常高频调用),即时编译器会优化这段代码并将优化后的机器码保存在内存中,如果第二次再去执行这段代码。Java虚拟机会将机器码从内存中取出来直接进行调用。这样节省了一次解释的步骤,同时执行的是优化后的代码,效率较高。

Java通过即时编译器获得了接近C/C++语言的性能,在某些特定的场景下甚至可以实现超越。

1.3 常见的jvm

1.3.1 java虚拟机的规范

​ Java虚拟机规范》由Oracle制定,内容主要包含了Java虚拟机在设计和实现时需要遵守的规范,主要包含class字节码文件的定义、类和接口的加载和初始化、指令集等内容。

官网地址:https://docs.oracle.com/javase/specs/index.html

1.3.2 虚拟机分类

名称作者支持版本社区活跃度(github star)特性适用场景
HotSpot (Oracle JDK版)Oracle所有版本高(闭源)使用最广泛,稳定可靠,社区活跃JIT支持Oracle JDK默认虚拟机默认
HotSpot (Open JDK版)Oracle所有版本中(16.1k)同上开源,Open JDK默认虚拟机默认对JDK有二次开发需求
GraalVMOracle11, 17,19企业版支持8高(18.7k)多语言支持高性能、JIT、AOT支持微服务、云原生架构需要多语言混合编程
Dragonwell JDK龙井Alibaba标准版 8,11,17扩展版11,17低(3.9k)基于OpenJDK的增强高性能、bug修复、安全性提升JWarmup、ElasticHeap、Wisp特性支持电商、物流、金融领域对性能要求比较高
Eclipse OpenJ9 (原 IBM J9)IBM8,11,17,19,20低(3.1k)高性能、可扩展JIT、AOT特性支持微服务、云原生架构

2.字节码文件详解

2.1 字节码文件的组成

推荐大家通过jclasslib来查看字节码文件信息

Github地址: https://github.com/ingokegel/jclasslib

在这里插入图片描述

字节码文件总共可以分为以下几个部分:

2.1.1基础信息

​ (主要对应jclasslib图中一般信息和接口部分)

​ 魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息

2.1.1.1 Magic魔数

​ 每个Java字节码文件的前四个字节是固定的,用16进制表示就是0xcafebabe。文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改不影响文件的内容。软件会使用文件的头几个字节(文件头)去校验文件的类型,如果软件不支持该种类型就会出错。

比如常见的文件格式校验方式如下:

在这里插入图片描述

Java字节码文件中,将文件头称为magic魔数。Java虚拟机会校验字节码文件的前四个字节是不是0xcafebabe,如果不是,该字节码文件就无法正常使用,Java虚拟机会抛出对应的错误。

2.1.1.2 主副版本号

​ 主副版本号指的是编译字节码文件时使用的JDK版本号,主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46之后每升级一个大版本就加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。1.2之后大版本号计算方法就是 主版本号 - 44 比如主版本52 , 52 - 44 = 8

版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。如果使用较低版本的JDK去运行较高版本JDK的字节码文件,无法使用会显示如下错误:

在这里插入图片描述

2.1.1.3 其他基础信息

​ 访问标识 (public) , 当前类 , 父类,接口的索引等 (通过索引可以找到相关信息)

2.1.2 常量池

​ 保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用

字节码文件中常量池的作用:避免相同的内容重复定义,节省空间。

比如在代码中,编写了两个相同的字符串“我爱北京天安门”,字节码文件甚至将来在内存中使用时其实只需要保存一份,此时就可以将这个字符串以及字符串里边包含的字面量,放入常量池中以达到节省空间的作用。

String str1 = "我爱北京天安门";
String str2 = "我爱北京天安门";

​ 常量池中的数据都有一个编号,编号从1开始。比如“我爱北京天安门”这个字符串,在常量池中的编号假设7。在字段或者字节码指令中通过编号7可以快速的找到这个字符串。

字节码指令中通过编号引用到常量池的过程称之为符号引用。

2.1.3 字段

​ 当前类或接口声明的字段信息 (名字、描述符(字段的类型)、访问标识(public/private static final等)。)

2.1.4 方法

​ 当前类或接口声明的方法信息,核心内容为方法的字节码指令,字节码指令的内容存放在方法的Code属性中。

通过分析方法的字节码指令,可以清楚地了解一个方法到底是如何执行的。先来看一个例子 :

int i = 0;
int j = i + 1;

这段代码编译成字节码指令之后是如下内容 :

0 iconst_0
1 istore_1
2 iload_1
3 iconst_1
4 iadd
5 istore_2
6 return

要理解这段字节码指令是如何执行的,我们需要先理解两块内存区域:操作数栈和局部变量表。

操作数栈是用来存放临时数据的内容,是一个栈式的结构,先进后出。

局部变量表是存放方法中的局部变量,包含方法的参数、方法中定义的局部变量,在编译期就已经可以确定方法有多少个局部变量。

流程分析如下 :

1 . iconst_0,将常量0放入操作数栈。此时栈上只有0。

2、istore_1会从操作数栈中,将栈顶的元素弹出来,此时0会被弹出,放入局部变量表的1号位置。局部变量表中的1号位置,在编译时就已经确定是局部变量i使用的位置。完成了对局部变量i的赋值操作。

3、iload_1将局部变量表1号位置的数据放入操作数栈中,此时栈中会放入0。

4、iconst_1会将常量1放入操作数栈中。

5、iadd会将操作数栈顶部的两个数据相加,现在操作数栈上有两个数0和1,相加之后结果为1放入操作数栈中,此时栈上只有一个数也就是相加的结果1。

6、istore_2从操作数栈中将1弹出,并放入局部变量表的2号位置,2号位置是j在使用。完成了对局部变量j的赋值操作。

7、return语句执行,方法结束并返回。

2.1.5 属性

​ 类的属性,比如源码的文件名、内部类的列表等

2.2 常用的查看字节码工具

2.2.1 javap

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

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

2.2.2 jclasslib idea 插件

​ jclasslib也有Idea插件版本,建议开发时使用Idea插件版本,可以在代码编译之后实时看到字节码文件内容。

下载好后选中要查看的源代码文件,选择 视图(View) - Show Bytecode With Jclasslib

tips:

​ 1、一定要选择文件再点击视图(view)菜单,否则菜单项不会出现。

​ 2、文件修改后一定要重新编译之后,再点击刷新按钮。

2.2.3 Arthas

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

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

在这里插入图片描述

安装方法:

1、将 资料/工具/arthas-boot.jar 文件复制到任意工作目录。

2、使用java -jar arthas-boot.jar 启动程序。

3、输入需要Arthas监控的进程id。

4、输入命令即可使用。(命令列表 : https://arthas.aliyun.com/doc/commands.html)

3.java虚拟机组成

在这里插入图片描述

3.1 类加载器

聊类加载器前需要先了解一下类的声明周期

3.1.1 类的生命周期

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

  • 加载
  • 连接,其中又分为验证、准备、解析三个子阶段
  • 初始化
  • 使用
  • 卸载
3.1.1.1 加载阶段

​ 1 . 加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息,程序员可以使用Java代码拓展的不同的渠道。

  • 从本地磁盘上获取文件
  • 运行时通过动态代理生成,比如Spring框架
  • Applet技术通过网络获取字节码文件

​ 2 . 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中,方法区中生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。Java虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)

在这里插入图片描述

3.1.1.2 连接阶段
验证

​ 验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《Java虚拟机规范》:

1、文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。

2、元信息验证,例如类必须有父类(super不能为空)。

3、验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置。

4、符号引用验证,例如是否访问了其他类中private的方法等。

准备

​ 准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。

数据类型初始值
int0
long0L
short0
char‘\u0000’
byte0
booleanfalse
double0.0
引用数据类型null

如下代码:

public class Student{

	public static int value = 1;

}

在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。(final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值)

解析

​ 解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容。

直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。

3.1.1.3 初始化阶段

​ 初始化阶段会执行字节码文件中clinit(class init 类的初始化)方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。

如下代码编译成字节码文件之后,会生成三个方法:

public class Demo1 {

    public static int value = 1;
    static {
        value = 2;
    }
   
    public static void main(String[] args) {

    }
}

在这里插入图片描述

init : 空参构造

main : 主线程方法

clinit : 类初始化方法 (Class Init , 主要会执行静态方法和以及对静态变量赋值 ,执行顺序和代码编写顺序一致)

clinit 的字节码指令 :

0 iconst_1 //将常量 1 放入操作数栈
1 putstatic #2 <com/changjunkai/ThreadPoolTest/jvm/JVMTest05.value : I> //弹出操作数栈中的值(1)并赋值给堆上静态变量 i 的位置 (这里#2在解析阶段会将符号引用替换为直接引用,所以初始化的时候直接就是内存地址)
4 iconst_2 //将常量 2 放入操作数栈
5 putstatic #2 <com/changjunkai/ThreadPoolTest/jvm/JVMTest05.value : I> //弹出操作数栈中的值(2)并赋值给堆上静态变量 i 
8 return
哪些方式会导致类的初始化
  1. 访问一个类的静态变量或者静态方法 , 注意下变量如果是final修饰的并且等号右边是常量不会触发初始化 (如果等号右边是静态方法就会触发)

     //这种不会进行初始化
     public static final int a = 2;
     //这种则会进行初始化
     public static final int a = Integer.valueOf(1);
    
  2. 调用Class.forName(String className)

  3. new 一个对象的时候

  4. 执行Main方法的当前类

添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类

clinit不会执行的几种情况
  1. 类中没有静态代码块且没有静态变量的赋值操作

  2. 有静态变量的声明 , 但是没有赋值语句

    public static int value;
    
  3. 静态变量的定义使用了final关键字 , 这类变量会在准备阶段直接就初始化好了

  4. 类数组的创建不会导致数组中元素的类进行初始化

    public class JVMTest03 {
        public static void main(String[] args) {
            JVMTest03_A[] arr = new JVMTest03_A[10];
        }
    }
    
    class JVMTest03_A {
        static {
            //这段代码不会运行
            System.out.println("JVMTest03_A的静态代码块运行了");
        }
    }
    

3.1.2 什么是类加载器

​ 类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分。

​ 类加载器会通过二进制流的方式获取到字节码文件的内容,接下来将获取到的数据交给Java虚拟机,虚拟机会在方法区和堆上生成对应的对象保存字节码信息。

3.1.3 类加载器的分类

​ 类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的。

  • 虚拟机底层实现:源代码位于Java虚拟机的源码中,实现语言与虚拟机底层语言一致,比如Hotspot使用C++。主要目的是保证Java程序运行中基础类被正确地加载,比如java.lang.String,Java虚拟机需要确保其可靠性。
  • JDK中默认提供或者自定义:JDK中默认提供了多种处理不同渠道的类加载器,程序员也可以自己根据需求定制,使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。
3.1.3.1 启动类加载器
  • 启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器。
  • 默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。

运行如下代码:

/**
 * 启动程序类加载器案例
 */
public class BootstrapClassLoaderDemo {
    public static void main(String[] args) throws IOException {
        ClassLoader classLoader = String.class.getClassLoader();
        System.out.println(classLoader);

        System.in.read();
    }
}

​ 这段代码通过String类获取到它的类加载器并且打印,结果是null。这是因为启动类加载器在JDK8中是由C++语言来编写的,在Java代码中去获取既不适合也不安全,所以才返回null

加载用户扩展的jar包

如果用户想扩展一些比较基础的jar包,让启动类加载器加载,有两种途径:

  • 放入jre/lib下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载。
  • **使用参数进行扩展。**推荐,使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展,参数中的/a代表新增。
3.1.3.2 拓展类加载器 和 应用程序类加载器
  • 扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。
  • 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。

在这里插入图片描述

  • ClassLoader类定义了具体的行为模式,简单来说就是先从本地或者网络获得字节码信息,然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码。
  • SecureClassLoader提供了证书机制,提升了安全性。
  • URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载,获取字节码的数据。
  • 扩展类加载器和应用程序类加载器继承自URLClassLoader,获得了上述的三种能力。

扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。

/**
 * 扩展类加载器
 */
public class ExtClassLoaderDemo {
    public static void main(String[] args) throws IOException {
        ClassLoader classLoader = ScriptEnvironment.class.getClassLoader();
        System.out.println(classLoader);
    }
}

加载用户拓展的jar包

  • 放入/jre/lib/ext下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容。
  • 使用参数进行扩展使用参数进行扩展。推荐,使用-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录

使用引号将整个地址包裹起来,这样路径中即便是有空格也不需要额外处理。路径中要包含原来ext文件夹,同时在最后加上扩展的路径。

应用程序类加载器会加载classpath下的类文件,默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。

/**
 * 应用程序类加载器案例
 */
public class AppClassLoaderDemo {
    public static void main(String[] args) throws IOException, InterruptedException {
        //当前项目中创建的Student类
        Student student = new Student();
        ClassLoader classLoader = Student.class.getClassLoader();
        System.out.println(classLoader);

        //maven依赖中包含的类
        ClassLoader classLoader1 = FileUtils.class.getClassLoader();
        System.out.println(classLoader1);

        Thread.sleep(1000);
        System.in.read();

    }
}

3.1.5 双亲委派机制

​ 双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,

再由顶向下进行加载。

在这里插入图片描述

双亲委派机制的作用

1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。

2.避免重复加载。双亲委派机制可以避免同一个类被多次加载。

如何指定加载类的类加载器?

在Java中如何使用代码的方式去主动加载一个类呢?

方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类。 (注意,这种方式是加载并且初始化)

方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载。

3.1.6 打破双亲委派机制

打破双亲委派机制历史上有三种方式,但本质上只有第一种算是真正的打破了双亲委派机制:

  • 自定义类加载器并且重写loadClass方法。
  • 线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等。
  • Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。
双亲委派机制源码
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false); //第二个参数为不进行连接阶段
    }
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            //当前if中就是双亲委派机制的核心代码
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    //如果父类加载器不为空则他先看下有没有加载,有则返回,没有则再向上传递
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        //如果传递到了拓展类加载器还没有加载,则找启动类加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
				
                //父类没有找到,子类尝试加载
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            //是否需要执行连接阶段
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
自定义类加载器

ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。

//类加载的入口,提供了双亲委派机制。内部会调用findClass   重要
public Class<?> loadClass(String name)

//由类加载器子类实现,获取二进制数据调用defineClass ,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。重要
protected Class<?> findClass(String name)

//做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中
protected final Class<?> defineClass(String name, byte[] b, int off, int len)

//执行类生命周期中的连接阶段
protected final void resolveClass(Class<?> c)
打破双亲委派机制源码
package com.changjunkai.ThreadPoolTest.jvm;

import org.apache.commons.io.IOUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.regex.Matcher;

/**
 * 打破双亲委派机制 - 自定义类加载器
 */

public class ClassLoaderTest01 extends ClassLoader {

    private String basePath;
    private final static String FILE_EXT = ".class";

    public void setBasePath(String basePath) {
        this.basePath = basePath;
    }

    private byte[] loadClassData(String name)  {
        try {
            String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));
            System.out.println(basePath + tempName + FILE_EXT);
            FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
            try {
                return IOUtils.toByteArray(fis);
            } finally {
                IOUtils.closeQuietly(fis);
            }

        } catch (Exception e) {
            System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
            return null;
        }
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        //如果是java包下,还是走双亲委派机制
        if(name.startsWith("java.")){
            return super.loadClass(name);
        }
        //从磁盘中指定目录下加载
        byte[] data = loadClassData(name);
        //调用虚拟机底层方法,方法区和堆区创建对象
        return defineClass(name, data, 0, data.length);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        return super.findClass(name);
    }

    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
        //第一个自定义类加载器对象
        ClassLoaderTest01 classLoader1 = new ClassLoaderTest01();
        classLoader1.setBasePath("E:\\JVM\\jar\\");

        Class<?> clazz1 = classLoader1.loadClass("com.changjunkai.classloader.A");
        System.out.println(clazz1.getClassLoader());
    }
}
线程上下文类加载器

​ 利用上下文类加载器加载类,比如JDBC和JNDI等。

JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。

DriverManager类位于rt.jar包中,由启动类加载器加载。

依赖中的mysql驱动对应的类,由应用程序类加载器来加载。

DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制

那么问题来了,DriverManager怎么知道jar包中要加载的驱动在哪儿?

在类的初始化代码中使用了SPI机制,去加载所有jar包中实现了Driver接口的实现类。

SPI机制就是在META-INF/service这个位置下存放了一个文件,文件名是接口名,文件里包含了实现类的类名。这样SPI机制就可以找到实现类了。

SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。

Thread.currentThread().getContextClassLoader();

总结 :

  • 启动类加载器加载DriverManager
  • 在初始化DriverManager时,通过spi机制加载jar包中的mysql驱动
  • spi中利用线程上下文类加载器 (应用程序类加载器) 去加载类并创建对象

网上针对这一方法是否打破了双亲委派机制有两个观点

打破了双亲委派

最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。

没有打破双亲委派

但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。

所以我认为这里没有打破双亲委派机制,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载。

3.1.7 jdk9之后的类加载器

​ JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java。

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

1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。

Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。

启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一	。

2、扩展类加载器被替换成了平台类加载器(Platform Class Loader)。

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

3.2 运行时数据区

​ Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用。

在这里插入图片描述

3.2.1 程序计数器

​ 程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。

在加载阶段,虚拟机将字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址。每一条字节码指令都会拥有一个内存地址。在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。

程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。不管是分支、跳转、异常,只需要在程序计数器中放入下一行要执行的指令地址即可。

在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。

程序计数器会出现内存溢出吗?

​ 内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。由于每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的。程序员无需对程序计数器做任何处理。

3.2.2 java虚拟机栈

​ Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈.

public class JVMTest06 {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        System.out.println("main执行了");
        A();
    }

    public static void A() {
        System.out.println("A执行了...");
        B();
    }

    public static void B(){
        System.out.println("B执行了...");
        C();
    }

    public static void C(){
        System.out.println("C执行了...");
    }

​ idea断点信息中的栈情况 :

在这里插入图片描述

Java虚拟机栈的栈帧中主要包含三方面的内容:

  • 局部变量表,局部变量表的作用是在运行过程中存放所有的局部变量
  • 操作数栈,操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
  • 帧数据,帧数据主要包含动态链接、方法出口、异常表的引用
3.2.2.1 局部变量表

​ 局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。

我们先来看下字节码文件中的局部变量表:编译成字节码文件时就可以确定局部变量表的内容。

源代码 :

public static void test1(){
    int i = 0;
    long j = 1;
}

字节码指令 :

0 iconst_0
1 istore_0
2 lconst_1
3 lstore_1
4 return

局部变量表信息 :
在这里插入图片描述

Nr : 编号

起始PC : 该变量在字节码指令生效的偏移量

长度 : 该变量可以使用的范围长度

​ 比如i这个变量,它的起始PC是2,代表从lconst_1这句指令开始才能使用i,长度为3,也就是2-4这三句指令都可以使用i。为什么从2才能使用,因为0和1这两句字节码指令还在处理int i = 0这句赋值语句。j这个变量只有等3指令执行完之后也就是long j = 1代码执行完之后才能使用,所以起始PC为4,只能在4这行字节码指令中使用。

接下来看下栈帧中的局部变量表,栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。

分为静态方法和实例方法

静态方法

在这里插入图片描述

i占用数组下标为0的位置,j占用数组下标1-2的位置。

实例方法

​ 方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。

在这里插入图片描述

局部变量槽的复用

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

比如如下代码 :

    public void test01() {
        int a = 0;
        {
            int b = 0;
        }
        int c = 1;
    }

字节码文件中的局部变量表 :

在这里插入图片描述

我们发现没有了b这个变量,因为它已经不再生效了,所以c可以复用它之前的位置

3.2.2.2 操作数栈

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

在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。

3.2.2.3 帧数据

​ 帧数据主要包含动态链接、方法出口、异常表的引用

动态链接

​ 当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。

方法出口

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

异常表引用

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

3.2.2.4 栈内存溢出

​ Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。Java虚拟机栈内存溢出时会出现StackOverflowError的错误。

如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。

linux (x86 64位) : 1MB

windows : 基于操作系统默认值

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

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

例如:

-Xss1048576 
-Xss1024K      
-Xss1m
-Xss1g

注意事项:

1、与-Xss类似,也可以使用 -XX:ThreadStackSize 调整标志来配置堆栈大小。

格式为: -XX:ThreadStackSize=1024

2、HotSpot JVM对栈大小的最大值和最小值有要求:

​ 比如测试如下两个参数,会直接报错:

-Xss1k
-Xss1025m

Windows(64位)下的JDK8测试最小值为180k,最大值为1024m

3、局部变量过多、操作数栈深度过大也会影响栈内存的大小。

一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存。

3.2.3 本地方法栈

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

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

3.2.4 堆内存

​ 一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

堆内存的溢出

​ 堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出OutOfMemory错误。在这段代码中,不停创建1G大小的字节数组并放入ArrayList集合中,最终超过了堆内存的上限。抛出OutOfMemoryError

示例代码 :

public class HeapTest01 {
    public static void main(String[] args) throws IOException {

        ArrayList<Object> objects = new ArrayList<>();

        while(true) {
            objects.add(new byte[1024 *1024 * 1024]);
        }
    }
}

结果 :

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.changjunkai.ThreadPoolTest.jvm.heap.HeapTest01.main(HeapTest01.java:17)
三个重要的值

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

堆内存used total max三个值可以通过dashboard (arthas)命令看到。

在这里插入图片描述

随着堆中的对象增多,当total可以使用的内存即将不足时,java虚拟机会继续分配内存给堆。

如果used达到了total的大小,Java虚拟机会向操作系统申请更大的内存。

但是这个申请过程不是无限的,total最多只能与max相等。

但是不是当used = max = total的时候,堆内存就溢出了,因为和垃圾回收器有关

如果不设置任何的虚拟机参数,max默认是系统内存的1/4,total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值。 Oracle官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

设置堆的大小

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

语法:-Xmx值 -Xms值

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

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

-Xms6291456
-Xms6144k
-Xms6m
-Xmx83886080
-Xmx81920k
-Xmx80m

建议:

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

3.2.5 方法区

方法区是存放基础信息的位置,线程共享,主要包含三部分内容:

  • 类的元信息,保存了所有类的基本信息
  • 运行时常量池,保存了字节码文件中的常量池内容
  • 字符串常量池,保存了字符串常量
类的元信息

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

运行时常量池

​ 方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。

字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。

字符串常量池

​ 方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。

字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池。

public class Demo02 {
    public static void main(String[] args) {
        String s1 = new String("abc");
        String s2 = "abc";
        System.out.println(s1 == s2);
    }
}

上面这个例子中 :

s1变量引用的是在堆上创建的字符串 “abc” 对象

s2变量引用的是在字符串常量池中的 “abc” 对象

所以s1和s2指向的不是一个对象

字符串常量池和运行时常量池的关系

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

jdk版本关系
jdk7之前运行时常量池逻辑上包含字符串常量池 , hotspot虚拟机对方法区的实现为永久代
jdk7字符串常量池被从方法区上拿到了堆上 , 运行池常量池剩下的东西还在方法区 (永久代)上
jdk8以及之后hotspot虚拟机溢出了永久代,使用了元空间(Metaspace)取而代之,字符串常量池还在堆上

​ 很多人认为字符串常量池和运行时常量池没啥关系,因为他们所处的位置不一样,尤其是在JDK 1.7之后,字符审
常量池在堆上,而运行时常量池随着方法区而处于永久代或者元空间。
但是,根据虚拟机规范,字符串常量,需要放在运行时常量池中。所以,我认为字符串池就是运行时常量池的一个
逻辑子区域。即字符串池是运行时常量池的分池!

字符串常量池从永久代移除的原因主要是因为GC回收效率太低了,只有在fullgc的时候才会被执行回收,但是java中往往会有很多字符串是朝生夕死的,将字符串常量池放到堆上,能够更高效的回收字符串内存

字符串常量池练习题 1
public class Demo03 {
    public static void main(String[] args) {
        String a = "1";
        String b = "2";
        String c = "12";
        String d = a + b;
        System.out.println(c == d);
    }
}

字节码信息 :

 0 ldc #2 <1>
 2 astore_1
 3 ldc #3 <2>
 5 astore_2
 6 ldc #4 <12>
 8 astore_3
 9 new #5 <java/lang/StringBuilder>
12 dup
13 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
24 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
27 astore 4
29 getstatic #9 <java/lang/System.out : Ljava/io/PrintStream;>
32 aload_3
33 aload 4
35 if_acmpne 42 (+7)
38 iconst_1
39 goto 43 (+4)
42 iconst_0
43 invokevirtual #10 <java/io/PrintStream.println : (Z)V>
46 return

我们可以在行号为9的地方也就是d变量那里,看到是创建了一个StringBuilder对象,所以结果就很明显了

字符串常量池里面肯定有 : “1” (a) , “2” (b) , “12” ©

堆上有一个StringBuilder对象 (d)

结果肯定为 false

字符串常量池练习题 2
public class Demo03 {
    public static void main(String[] args) {
        String a = "1";
        String b = "2";
        String c = "12";
        String d = "1" + "2";
        System.out.println(c == d);
    }
}

字节码如下 :

 0 ldc #2 <1>
 2 astore_1
 3 ldc #3 <2>
 5 astore_2
 6 ldc #4 <12>
 8 astore_3
 9 ldc #4 <12>
11 astore 4
13 getstatic #5 <java/lang/System.out : Ljava/io/PrintStream;>
16 aload_3
17 aload 4
19 if_acmpne 26 (+7)
22 iconst_1
23 goto 27 (+4)
26 iconst_0
27 invokevirtual #6 <java/io/PrintStream.println : (Z)V>
30 return

​ 我们发现第九行,获取的字符串为常量池中(#4)c变量的结果,所以在编译阶段,已经将1和2进行连接,最终生成12的字符串常量池中的结果。所以返回结果就是true,c和d都指向字符串常量池中的对象。

总结一下 :

​ 如果是变量连接会使用StringBuilder

​ 如果是常量,编译阶段直接连接

String.intern()

​ String.intern()方法是可以手动将字符串放入字符串常量池中,分别在JDK6 JDK7以及之后版本执行代码,JDK6 中结果是false false ,JDK8中是true false

public class Demo4 {
    public static void main(String[] args) {
        String s1 = new StringBuilder().append("think").append("123").toString();

        System.out.println(s1.intern() == s1);

        String s2 = new StringBuilder().append("ja").append("va").toString();

        System.out.println(s2.intern() == s2);
    }
}

jdk6中第一个结果为false : s1为堆上创建的对象 , 调用s1.intern方法,会在字符串常量池中创建think123的对象,最后将对象引用返回。所以s1.intern和s1指向的不是同一个对象。打印出false。

jdk6中第二个结果为false : s2为堆上创建的对象 , 这里注意字符串常量池中本来就有一个java字符串对象,这是java虚拟机自身使用的所以启动时就会创建出来, 调用s2.intern发现字符串常量池中已经有java字符串对象了,就将引用返回。所以s2.intern指向的是字符串常量池中的对象,而s2指向的是堆中的对象。打印结果为false。

接下来分析JDK7中,JDK7及之后版本中由于字符串常量池在堆上,所以intern () 方法会把第一次遇到的字符串的引用放入字符串常量池。其实这就是问题的关键 , jdk6之前相当于会把堆上有的字符串也给复制到常量池中 , 然后返回常量池的地址引用 , 但是jdk7及其之后如果发现堆上已经有了该字符串,只会将堆上的引用放到字符串常量池中

jdk7中第一个结果为true: s1为堆上创建的对象 , 调用s1.intern方法,由于字符串常量池中没有think123的字符串,所以直接创建一个引用 , 指向堆中的think123对象。所以s1.intern和s1指向的都是堆上的对象,打印结果为true。

jdk7中第二个结果为false: s1为堆上创建的对象 , 调用s1.intern方法,由于字符串常量池中已经有了 “java” 字符串,所以直接返回在字符串常量池中的引用,所以为false

总结 :

jdk6及其之前字符串常量池在永久代分配内存,永久代和Java堆的内存是物理隔离的,String.intern()方法如果发现当前字符串在字符串常量池上没有 , 则会在字符串常量池上创建对象 , 并返回引用 , 如果有则不创建,直接返回已经有的引用

jdk7及其以后 ,字符串常量池已经在Java堆上分配内存,执行intern方法时,如果常量池已经存在该字符串,则直接返回字符串引用,否则复制该字符串对象的引用到常量池中并返回

方法区的实现

​ 方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:

JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。

JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。

可以通过arthas的memory命令看到方法区的名称以及大小:

  • JDK7及之前的版本查看ps_perm_gen属性。
  • JDK8及之后的版本查看metaspace属性。
方法区的溢出

​ 通过ByteBuddy框架,动态创建类并将字节码数据加载到内存中。通过死循环不停地加载到方法区,观察方法区是否会出现内存溢出的情况。分别在JDK7和JDK8上运行上述代码。

ByteBuddy是一个基于Java的开源库,用于生成和操作Java字节码。

依赖 :

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.12.23</version>
 </dependency>

示例代码 :

public class Demo1 extends ClassLoader {
    public static void main(String[] args) throws IOException {
        System.in.read();
        Demo1 demo1 = new Demo1();
        int count = 0;
        while (true) {
            String name = "Class" + count;
            ClassWriter classWriter = new ClassWriter(0);
            classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null
                    , "java/lang/Object", null);
            byte[] bytes = classWriter.toByteArray();
            demo1.defineClass(name, bytes, 0, bytes.length);
            System.out.println(++count);
        }
    }
}

JDK7上运行大概十几万次,就出现了错误。

在JDK8上运行百万次,程序都没有出现任何错误,但是内存会直线升高。这说明JDK7和JDK8在方法区的存放上,采用了不同的设计。

  • JDK7将方法区存放在堆区域中的永久代空间,永久代的大小由虚拟机参数-XX:MaxPermSize=值来控制。
  • JDK8将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用-XX:MaxMetaspaceSize=值将元空间最大大小进行限制。
静态变量的存储
  • JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
  • JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理。

3.2.6 直接内存

​ 直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。

在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:

1、Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。

2、IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。

现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。

使用方法:

要创建直接内存上的数据,可以使用ByteBuffer

语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);

注意事项: arthas的memory命令可以查看直接内存大小,属性名direct。

/**
 * 直接内存的使用和回收
 */
public class Demo1 {
    public static int size = 1024 * 1024 * 100; //100mb
    public static List<ByteBuffer> list = new ArrayList<ByteBuffer>();
    public static int count = 0;

    public static void main(String[] args) throws IOException, InterruptedException {
        System.in.read();
        while (true) {
            //1.创建DirectByteBuffer对象并返回
            //2.在DirectByteBuffer构造方法中,向操作系统申请直接内存空间
            ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
            //directBuffer = null;

            list.add(directBuffer);
            System.out.println(++count);
            Thread.sleep(5000);
        }

    }
}

如果将Thread.sleep(5000);注释掉,让直接内存快速大量分配。操作系统内存不足时就会报错:

Exception in thread “main” java.lang.OutOfMemoryError: Direct buffer memory

​ 虽然DirectByteBuffer分配的堆外内存不受JVM堆内存的GC直接管理,但HotSpotJVM确实提供了旦
一种机制来间接管理这部分内存的回收。
当我们使用 ByteBuffer.buffer =ByteBuffer.allocateDirect(1024)分配内存时,会在堆外占用1k的内存,同时会在堆上创建一个ByteBuffer对象,当然这个对象只占用一个对象的指针引用的大小。堆上的ByteBuffer在创建时,会注册一个与之关联的清理器(cleaner)。当DirectBuffer对象变成垃圾时,清理器会在垃圾收集过程中被调用,从而释放堆外内存。
​ 也就是说,当一个DirectByteBuffer实例不再有任何强引用指向它时,该实例就会成为垃圾收集的候选对象。在垃圾收集过程中,JVM会检测这些DirectByteBuffer对象。如果DirectByteBuffer对象被垃圾收集器确定为垃圾,它所关联的清理器(cleaner)会被触发。清理器的任务是释放DirectByteBuffer分配的堆外内存。

但是工作中服务器上有可能部署了其他应用,为了避免将内存耗尽,需要设置直接内存的最大值。如果需要手动调整直接内存的大小,可以使用XX:MaxDirectMemorySize=大小

单位k或K表示千字节,m或M表示兆字节,g或G表示千兆字节。默认不设置该参数情况下,JVM 自动选择 最大分配的大小。

以下示例以不同的单位说明如何将 直接内存大小设置为 1024 KB:

-XX:MaxDirectMemorySize=1m
-XX:MaxDirectMemorySize=1024k
-XX:MaxDirectMemorySize=1048576

3.3 垃圾回收

​ 在C/C++这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏。

内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。

在这段代码中,通过死循环不停创建Test类的对象,每一轮循环结束之后,这次创建的对象就不再使用了。但是没有手动调用删除对象的方法,此时对象就会出现内存泄漏。

在这里插入图片描述

这段代码中,手动调用delete删除对象,就不会出现内存泄漏。

在这里插入图片描述

​ 我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收。手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存。缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象。

Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。

垃圾回收器如果发现某个对象不再使用,就可以回收该对象。

  • 自动垃圾回收,自动根据对象是否使用由虚拟机来回收对象
    • 优点:降低程序员实现难度、降低对象回收bug的可能性
    • 缺点:程序员无法控制内存回收的及时性
  • 手动垃圾回收,由程序员编程实现对象的删除
    • 优点:回收及时性高,由程序员把控回收的时机
    • 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题

垃圾回收器主要会对方法区 和 堆内存 两部分区域来进行回收

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

3.3.1 方法区的回收

方法区中能回收的内容主要就是不再使用的类。

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

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

这段代码中就将局部变量对堆上实例对象的引用去除了,所以对象就可以被回收。

Class<?> aClass = Class.forName("com.changjunkai.loadclass.A");
Object o = aClass.newInstance();
o = null;

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

URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("E:\\JVM\\")});
urlClassLoader = null;

这段代码让局部变量对类加载器的引用去除,类加载器就可以回收。

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

Class<?> aClass = Class.forName("com.changjunkai.loadclass.A");
aClass = null;

那么类卸载主要用在什么场景下呢?

开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。

每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

3.3.2 如何判断对象是否可以回收

​ 垃圾回收器要回收对象的第一步就是判断哪些对象可以回收。Java中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。判断对象是否可以被回收主要有两种方法 : 引用计数法 和 可达性分析法

引用计数法

​ 引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。

比如下图中,对象A的计数器初始为0,局部变量a1对它引用之后,计数器加1就变成了1。同样A对B产生了引用,B的计数器也是1。

在这里插入图片描述

引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:

1.每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响

2.存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题。

下面这张图上,由于A和B之间存在互相引用,所以计数器都为1,两个对象都不能被回收。但是由于没有局部变量对这两个代码产生引用,代码中已经无法访问到这两个对象,理应可以被回收。

在这里插入图片描述

如果想要查看垃圾回收的信息,可以使用-verbose:gc参数。语法: -verbose:gc

因为这个方法有问题 , 所以java并没有采用这个方法 , 而是使用的可达性分析

可达性分析法

​ Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。

下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个到GC Root对象是可达的,对象就不可被回收。

在这里插入图片描述

哪些对象被称之为GC Root对象呢?

  • java虚拟机内部的引用 , 比如基本数据类型对应的Class对象 , 一些异常对象 (OutOfMemoryError) , 还有系统类加载器

  • 虚拟机栈中栈帧中的本地表中引用的对象 (正在运行的方法中所使用到的参数,局部变量 , 临时变量)。

  • 方法区中类静态属性引用的对象 , 比如java类中引用类型静态变量

  • 方法区中常量引用的对象 , 比如字符串常量池里的引用

  • 监视器对象,用来保存同步锁synchronized关键字持有的对象。

  • 本地方法调用时使用的全局对象。 (无需考虑)

3.3.3 常见的对象引用

强引用

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

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。

相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。

软引用

​ 软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存中。

​ 这样做有什么好处?如果对象A是一个缓存,平时会保存在内存中,如果想访问数据可以快速访问。但是如果内存不够用了,我们就可以将这部分缓存清理掉释放内存。即便缓存没了,也可以从数据库等地方获取数据,不会影响到业务正常运行,这样可以减少内存溢出产生的可能性。

特别注意:

​ 软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉。

软引用的使用方法

软引用的执行过程如下:

1.将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。

2.内存不足时,虚拟机尝试进行垃圾回收。

3.如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。

4.如果依然内存不足,抛出OutOfMemory异常。

代码 :

添加虚拟机参数,限制最大堆内存大小为200m:-Xmx200m

(注意 : 虽然这里最大内存为200m , 但是可能程序启动之后 , 堆上已经有了一些java自身需要的对象)

public class SoftReferenceDemo01 {
    public static void main(String[] args) {
        //1.创建一个100m的数组对象将引用赋值给bytes
        byte[] bytes = new byte[1024 * 1024 * 100];
        //2.创建一个软引用对象 , 内部保存了数组对象
        SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
        //3.将bytes强引用关系删除
        bytes = null;
        //4.这里获取软引用中保存的数据
        System.out.println(softReference.get());
		
        //5.进行到这里之后,堆上已经有了100多m的数据了,最大就200m,这时候再申请100m的空间肯定不够,jvm会尝试清理掉软引用中的数		 //据,所以我们再次获取软引用中的数据应该是空的
        byte[] bytes2 = new byte[1024 * 1024 * 100];
        System.out.println(softReference.get());
        
        //6.这里清理完了软引用中的数据之后,又创建了一个100m的对象,此时没有软引用对象了,上面bytes2是强引用,也不能释放,所以只能		//抛出一个内存不足的异常
        byte[] bytes3 = new byte[1024 * 1024 * 100];
    }
}

结果如下 :

[B@61bbe9ba
null
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.changjunkai.ThreadPoolTest.jvm.soft.SoftReferenceDemo01.main(SoftReferenceDemo01.java:20)
软引用对象本身怎么回收呢?

​ 如果软引用对象里边包含的数据已经被回收了,那么软引用对象本身其实也没啥用了 , 也可以被回收了。

SoftReference提供了一套队列机制

1、软引用创建时,通过构造器传入引用队列

2、在软引用中包含的对象被回收时,该软引用对象会被放入引用队列

3、通过代码遍历引用队列,将SoftReference的强引用删除

添加虚拟机参数,限制最大堆内存大小为200m:-Xmx200m

public class SoftReferenceDemo3 {

    public static void main(String[] args) throws IOException {
		//1.创建一个用来存储软引用对象的集合,确保软引用对象不会被回收
        ArrayList<SoftReference> softReferences = new ArrayList<>();
        //2.创建一个软引用对象数据被回收后用来保存软引用对象的一个队列
        ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
        for (int i = 0; i < 10; i++) {
            //3.创建一个100m的数组对象
            byte[] bytes = new byte[1024 * 1024 * 100];
            //4.创建一个软引用对象,内部保存了一个100m的数组对象,以及一个清理后保存当前实例的队列
            SoftReference studentRef = new SoftReference<byte[]>(bytes,queues);
            //5.将软引用对象放入集合中,保证不会被gc回收掉,如果没有这里,软引用对象就不存在了
            softReferences.add(studentRef);
        }
        

        SoftReference<byte[]> ref = null;
        int count = 0;
        //不断从队列中来取回收完成的软引用对象,记录次数
        while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
            count++;
        }
        System.out.println(count);

    }
}

分析 :

因为堆内存的容量限制 , 我们有10次循环 , 第一次循环开始后堆中已经有了100多m的数据 , 第二次循环后发现容量不够 看下有没有软引用 , 发现了第一次循环的软引用对象 , 把它给回收了 , 并将第一次循环的软引用对象给放入队列中 , 这时堆内存充足了 , 第二次循环也会创建一个100m的数据 , 以此类推 , 那么10次循环,每次都把前一个循环创建的软引用对象数据给回收掉 , 最终 , 只有最后一次循环的数据可以保留 , 那么队列中应该有9个元素

结果 :

9

这9个软引用对象中包含的数据已经被回收掉,所以可以手动从ArrayList中去掉,这样就可以释放这9个对象。

弱引用

​ 弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在JDK 1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。

弱引用对象本身也可以使用引用队列进行回收。

代码 :

这里不用设置堆内存大小

public class WeakReferenceDemo01 {
    public static void main(String[] args) {
        byte[] bytes = new byte[1024 * 1024 * 100];
        WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);
        bytes = null;

        System.out.println(weakReference.get());

        System.gc();

        System.out.println(weakReference.get());
    }
}

结果 :

[B@61bbe9ba
null

gc后发现弱引用中的数据被回收了 , 此时堆内存其实是充足的

虚引用和终结器引用

这两种引用在常规开发中是不会使用的。

  • 虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用 (直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现)。

  • 终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。

    public class FinalizeReferenceDemo {
        public static FinalizeReferenceDemo reference = null;
    
        public void alive() {
            System.out.println("当前对象还存活");
        }
    
        @Override
        protected void finalize() throws Throwable {
            try{
                System.out.println("finalize()执行了...");
                //设置强引用自救
                reference = this;
            }finally {
                super.finalize();
            }
        }
    
        public static void main(String[] args) throws Throwable {
            reference = new FinalizeReferenceDemo();
           test();
           test();
        }
    
        private static void test() throws InterruptedException {
            reference = null;
            //回收对象
            System.gc();
            //执行finalize方法的优先级比较低,休眠500ms等待一下
            Thread.sleep(500);
            if (reference != null) {
                reference.alive();
            } else {
                System.out.println("对象已被回收");
            }
        }
    }
    

3.3.4 垃圾回收算法

Java是如何实现垃圾回收的呢?简单来说,垃圾回收要做的有两件事:

1、找到内存中存活的对象

2、释放不再存活对象的内存,使得程序能再次利用这部分空间

1960年John McCarthy发布了第一个GC算法:标记-清除算法

1963年Marvin L. Minsky 发布了复制算法

本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。

垃圾回收算法的评价标准

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

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

1.吞吐量

吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。比如虚拟机总共运行了100分钟,其中GC花掉了1分钟,那么吞吐量就是99%

2.最大暂停时间

最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。比如如下的图中,黄色部分的STW就是最大暂停时间,显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短,用户使用系统时受到的影响就越短。

在这里插入图片描述

3.堆使用效率

不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。

标记清除算法

标记清除算法的核心思想分为两个阶段:

1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。

2.清除阶段,从内存中删除没有被标记也就是非存活对象。

在这里插入图片描述

优点

​ 实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。

缺点

1.碎片化问题

​ 由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。

2.分配速度慢。

​ 由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。 我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长,遍历也会花费较长的时间。

复制算法

​ 复制算法的核心思想是:

1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。

2.在垃圾回收GC阶段,将From中存活对象复制到To空间。

在垃圾回收阶段,如果对象A存活,就将其复制到To空间。然后将From空间直接清空。

3.将两块空间的From和To名字互换。

接下来将两块空间的名称互换,下次依然在From空间上创建对象。

在这里插入图片描述

优点
  • 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
  • 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点

​ 内存使用效率低,每次只能让一半的内存空间来为创建对象使用。

标记整理算法

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

核心思想分为两个阶段:

1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。

2.整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。

在这里插入图片描述

优点
  • 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
  • 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点

​ 整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。

分代垃圾回收算法

​ 现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。

分代垃圾回收将整个内存区域划分为年轻代和老年代:

在这里插入图片描述

一开始对象都是在Eden区来分配的 , 当Eden区空间不足 , 将尝试新生代的GC(Young GC , MinorGC) , 会将Eden区和From区域存活对象标记 , 然后最后将存活的对象转移到to区域上 (新生代这里这也是采用了复制算法) ,之后清空Eden和from区域,最后from 和 to交换, 每次在Eden区和from区域成功存活的对象GC都会为它记录年龄 , 初始值为 0 ,每次GC完加 1 , 如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代 , 这个过程称为晋升 , 年轻代进入老年代的条件有3个,这只是其中一个 , 还有两个

1、躲过15次GC。每次垃圾回收后,存活的对象的年龄就会加1,累计加到15次(jdk8默认的),也就是某个对象躲过了15次垃圾回收,那么JVM就认为这个是经常被使用的对象,就没必要再待在年轻代中了。具体的次数可以通过-XX:MaxTenuringThreshold 来设置在躲过多少次垃圾收集后进去老年代。
2、动态对象年龄判断。规则:如果在Survivor空间中小于等于某个年龄的所有对象大小的总和大
于Survivor空间的一半时,那么就把大于等于这个年龄的对象都晋升到老年代。

3、大对象直接进入老年代。-XX:PretenureSizeThreshold 来设置大对象的临界值,大于该值的
就被认为是大对象,就会直接进入老年代。
(PretenureSizeThreshold默认是0,也就是说,默认情况下对象不会提前进入老年代,而是直接在新生代分配。然后就GC次数和基于动态年龄判断来进入老年代。)

如果Survivor区域空间不够 , 就要分配给老年代 , 也就是说老年代起到一个兜底的作用 , 但是 , 老年代也是有可能空间不足的 , 所以这个时候就需要做一次 空间分配担保 :

空间分配担保

每一次执行YoungGC之前 , 虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间 . 如果大于 , 说明本次Young GC是安全的 , 如果小于 , 那么虚拟机会查看HandlePromotionFailure 参数设置的值判断是否允许担保失败 , 如果值为 true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小 (一共有多少对象在内存回收后存活下来是不可预知的 , 因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考) . 如果大于 , 则尝试一次Young GC,但是这次YoungGC 依然是有风险的 ;如果小于 , 或者HandlePromotionFailure = false , 则会直接触发一次Full GC

但是,需要注意的是,HandlePromotionFailure 这个参数 , 在jdk7中就不支持了…

在jdk代码中移除了这个参数的判断 , 也就是说 , 在后续的版本中 , 只要检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小 , 如果大于则认为担保成功 .

但是需要注意 , 担保的结果可能成功 , 也可能失败 .所以 ,在YoungGC复制阶段执行之后 , 会发生以下三种情况 :

  • 剩余的存活对象大小 , 小于Survivor区 , 那就直接进入Survivor
  • 剩余的存活对象大小 , 大于Survivor区, 小于老年代可用内存 , 那就直接去老年代
  • 剩余的存活对象大小 , 大于Survivor区并且大于老年代 , 触发"Full GC"
跨代引用

​ 跨代引用是指在java堆内存的不同代之间存在引用关系 , 比如新生代到老年代的引用 ,老年代到新生代的引用

跨代引用产生的问题 :

在这里插入图片描述

​ 比如我们jvm堆上是上面这个情况 , 那么进行一次YoungGC的时候 , 会从GCRoots出发 , 然后进行可达性分析,这个时候就会发现只有A , B是可达的 , 就会在垃圾回收时把C给回收掉 , 但是其实C是有引用的 ,只不过不在新生代 , 在老年代中 , 这就发生了跨代引用

解决这个问题有两个办法 :

1. 在做Young GC的时候 ,除了固定的GCRoot以外 , 把老年代的所有对象也额外遍历了,来确保可达性分析结果的正确性,
1. 反之 (这里只是理论上允许 , 实际上除了CMS收集器 , 其他都不存在只针对老年代的收集)

上面这两种做法对垃圾回收过程中会带来很大的性能影响

其实有一个比较不错的办法就是在新生代中定义一个全局的数据结构 : 记忆集 (Remembered Set 这个下面会单独聊)

YoungGC和FullGC的触发条件

YoungGC的触发条件比较简单 , 那就是年轻代中的Eden区域满了就会触发

FullGC触发条件比较复杂也比较多 , 主要有以下几种

  • 老年代空间不足
    • 创建了一个大对象 , 超过指定阈值后直接保存在了老年代中 , 如果老年代空间也不足 , 则会触发FullGC
    • YoungGC之后 , 如果要移动到老年代的对象 , 老年代也存不下 , 也会触发一次FullGC
  • 空间分配担保失败了 (这个上面介绍过了) , 其实主要也是老年代的空间不够了
  • 永久代空间不足
    • 如果有永久代分配空间时没有了足够的空间 , 也会触发FullGC
  • 代码中执行System.gc()
    • 代码中执行System.gc()的时候 , 会触发FullGC , 但是并不保证一定会立即触发
相关JVM启动参数
参数名参数含义示例
-Xms设置堆的最小和初始大小,必须是1024倍数且大于1MB比如初始大小6MB的写法: -Xms6291456 -Xms6144k -Xms6m
-Xmx设置最大堆的大小,必须是1024倍数且大于2MB比如最大堆80 MB的写法: -Xmx83886080 -Xmx81920k -Xmx80m
-Xmn新生代的大小新生代256 MB的写法: -Xmn256m -Xmn262144k -Xmn268435456
-XX:SurvivorRatio伊甸园区和幸存区的比例,默认为8 新生代1g内存,伊甸园区800MB,S0和S1各100MB比例调整为4的写法:-XX:SurvivorRatio=4
-XX:+PrintGCDetailsverbose:gc打印GC日志

3.3.5 垃圾回收器

前言

​ 说具体垃圾回收器之前 , 需要对一些垃圾收集器里使用到的算法细节单独解释一下

根节点枚举

​ 垃圾收集的第一步其实就是得先找到根节点在哪里,然后才能查找下面有哪些对象相连

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

要想进行根节点枚举这个过程 , 首先需要对用户线程暂停,也就是STW , 如果查找的过程一直在变化 , 那分析的结果准确性肯定也就无法保证 , 大家熟悉的对停顿时间可控的 , 或者几乎不会停顿的 CMS , G1 , ZGC这些垃圾回收器在根节点枚举的时候也都需要STW

​ 由于目前主流Java虚拟机使用的都是准确式垃圾收集 (也就是它已经知道某个位置上的某个数据的类型,类型是准确的),所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。这时候只需枚举那些存在地址引用的GCRoot了,进一步减轻了工作量。在HotSpot中是用了一种叫OopMap的结构来存放一个对象内什么偏移量上是什么类型的数据。在类加载过程中就会进行记录。每个方法可能会有好多个OopMap,这是根据特定位置来决定的 , 也就是下面要说的安全点

安全点

​ 在OopMap的协助下,HotSpot可以快速准确的完成GCRoots枚举,实际上HopSpot每一条指令都生成对应的OopMap,只会在特定的位置记录这些信息,这些位置被称为安全点(Safepoint)。

选取安全点基本是以 “是否具有让程序长时间执行的特征” 来进行选取的 , 因为其实我们每条字节码指令都执行的很快 , 程序不太会因为字节码命令长度原因而长时间执行 , “长时间执行” 比较明显的地方就是一些指令会复用 , 主要是以下这几种,只有在这些地方才会产生安全点

1、方法临返回前/调用方法的call指令后

2、循环的末尾

3、可能抛出异常的地方

​ 之所以要在特定的位置才记录OopMap,是因为如果对每条指令都记录一下的话,那就会需要大量的空间,提高了GC的空间成本,所以用一些比较关键的点来记录就能有效的缩小记录所需的空间。

有了安全点的设定,也就规定了用户线程必须达到安全点后才能停止运行,才能进行GC。

安全区域

​ 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把安全区域看做是被扩展了的安全点。在线程执行到安全区域中的代码时,首先标识自己已经进入了安全区域,那样,当在这段时间里JVM要发起GC时,就不用管标识了自己已经在安全区域的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开安全区域的信号为止。

记忆集和卡表

​ 讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建 立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。其实不只是新生代 , 老年代才有跨代引用的问题 , 所有涉及部分区域收集行为垃圾回收器 , 比如G1和ZGC都会面临相同的问题

​ 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。之后如果再发生新生代GC,垃圾回收器不需要扫描整个老年代来确定哪些对象存活 , 只需要扫描Remembered Set中的条目 , 从而就能减少扫描的开销

但其实这种记录了全部包含跨代引用对象的实现方案 , 不论是空间占用还是维护成本都挺高的 , 而在垃圾收集的场景中 , 其实我们只需要知道某一块非收集区域中 , 是否存在有着指向收集区域的指针就可以了 , 不用了解你跨代指针的全部细节 , 这点不考虑 ,

以下三种就是记忆集的记录精度分类 :

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。

千万不要把卡表就单纯的理解为了就是记忆集,记忆集是一种抽象的数据结构 , 这其实有点像java中的接口 (记忆集)和具体实现类(CardTable)的关系

卡表最简单的形式可以只是一个字节数组,它记录了记忆集的记录的精度 , 与堆内存的映射关系等等 , 而HotSpot虚拟机确实也是这样做的。

CARD_TABLE[this address >> 9] = 0;

字节数组CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作**“ 卡页 ” ( Card Page )**。

​ 一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过上面代码可以看出HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512 )。

那如果卡表标识内存区域的起始地址是0x0000 的话,数组 CARD_TABLE 的第 0 、 1 、 2 号元素,分别对应了地址范围为0x0000 ~ 0x01FF (十进制的 0 - 511) 、 0x0200 ~ 0x03FF (十进制的512 - 1023)、 0x0400 ~ 0x05FF (十进制的1024 - 1535) 的卡页内存块

在这里插入图片描述

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏( Dirty ),没有则标识为 0 。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots 中一并扫描。

写屏障

​ 上面已经通过卡表记忆集来缩减GCRoots扫描范围的问题了 , 但是有一个问题还没有解决 , 就是卡表中的元素如何维护 ?

  • 何时变脏 :有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
  • 如何变脏,谁来维护卡表

​ 这里有两种情况 :

  • 如果是解释执行的字节码 , 虚拟机负责每条字节码指令的执行 , 那样就会有充分的介入空间了
  • 如果是编译执行 , 经过编译之后的代码已经是纯粹的机器指令流了,这就必须在机器码层面来把维护卡表的动作放到每一个赋值操作之中

但是java其实算是编译 + 解释…

​ HotSpot通过写屏障(Writer Barrier)技术维护卡表。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值前后都在写屏障的覆盖范围内在赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier)赋值后则叫写后屏障(Post-Write Barrier)。G1垃圾收集器出现之前,其他收集器只用了写后屏障 , 如下代码 :

void oop_field_store(oop* field,oop new_value) {
    //引用字段赋值操作
    *field = new_value;
    //写后屏障,在这里维护卡表更新
    post_write_barrier(field,new_value);
}

​ **应用了写屏障之后 , 虚拟机就会为所有赋值操作生成相应的指令 , 一旦收集器在写屏障中增加了更新卡表的操作,无论更新的是不是老年代对新生代对象的引用 , 每次只要对引用进行更新 , 就会产生额外的开销 , 不过这个开销和YoungGC时扫描整个老年代的代价相比还是低得多的 **

​ 除了写屏障开销以外 , 卡表在高并发场景还面临着 “伪共享” 问题 , CPU的缓存系统是以缓存行 (Cache_Line)为单位存储,一个缓存行一般是32 / 64字节,多线程修改独立变量,这些变量如果恰好共享同一个缓存行,就会彼此影响(写回,无效化或同步)而导致性能降低。

​ 其实我们可以发现更新卡表的时候没有任何前置条件就可以执行的 , 只要引用字段赋完值我就执行,不管你之前有数组元素中有没有脏过 , 解决伪共享的方法就是 : 不采用无条件的写屏障,先检查卡表标记,只有卡表元素未被标记过时才将其标记变脏,即卡表更新逻辑变为:

if(CARD_TABLE[this address >> 9] != 0)
	CARD_TABLE[this address >> 9] = 0;

​ 注意 : 在JDK 7 之后, HotSpot 虚拟机增加了一个新的参数 -XX : +UseCondCardMark ,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

三色标记法

三色标记法是一种JVM中垃圾标记的算法 , 它可以减少JVM在GC过程中的 STW时长 , 它是CMS,G1等比较复杂垃圾收集器中主要使用的标记算法

在出现三色标记算法之前 , JVM中垃圾对象的标记主要采用可达性分析算法以及引用计数器 , 但是这两种算法存在以下问题 :

  • 循环引用问题 : 如果两个对象互相引用 , 就形成了一个环形结构 , 如果采用引用计数器 , 那么这两个对象永远无法回收
  • STW时间长 : 可达性分析的整个过程都需要STW,以避免对象的状态发生改变 , 这就导致GC停顿时长比较久 , 大大影响了应用的整体性能

为了解决上面这些问题 , 就引入了三色标记法

三色标记法将对象分为三种状态 : 白色 , 灰色 , 黑色

白色 : 该对象没有被标记过

灰色 : 该对象已经被标记过了 , 但该对象的引用对象还没标记完

黑色 : 该对象已经标记完了 , 并且它的全部引用对象也都标记完了

初始阶段只有GC Roots是黑色的,其他对象都是白色的,如果没有被黑色对象引用那么最终都会被当做垃圾对象回收。

在这里插入图片描述

初始标记阶段

​ 仅仅将GCROOTS直接能关联到的对象标记为灰色 , 速度很快 , 这个过程需要 STW ,但是很快的

在这里插入图片描述

并发标记阶段

​ 从GCRoots直接关联到的对象开始 , 也就是从灰色对象开始遍历整个对象图 , 将被引用的对象标记为灰色 , 并将已经遍历过的对象标记为黑色 , 这个过程不需要STW,标记线程和用户线程并发的运行 . 在并发标记的过程中 , 应用程序线程可能会修改对象图 , 因此垃圾回收器需要使用写屏障 计数来保证并发标记的正确性 .

在这里插入图片描述

重新标记

​ 重新标记的作用标记在并发标记阶段中被修改的对象以及未被遍历到的对象 . 这个过程中 , 垃圾回收器会从黑色对象重新开始遍历对象图 , 将被引用的对象标记为灰色 , 并将已经遍历过的对象标记为黑色 , 这个过程需要STW

​ 重新标记阶段结束之后 , 垃圾回收器会执行清除操作 , 黑色对象就是存活的对象,即为可达对象,白色对象为不可达对象,垃圾回收阶段就会被回收

多标问题

​ 所谓多标 , 其实就是这个对象原本应该被回收掉的白色对象, 但是被错误的标记为了黑色的存活对象 , 从而导致这个对象没有被GC回收掉 , 这个一般发生在并发标记过程中 , 该对象还是有引用的 , 但是在应用程序执行过程中把它的引用关系删除了 , 导致它变成了一个垃圾对象 , 多标的话 , 会产生浮动垃圾 , 这个问题一般都不太需要解决 , 因为这种垃圾一般都不会太多 , 另外在下一次GC的时候也都能被回收掉

漏标问题

​ 漏标和多标刚好相反 , 就是说一个对象本来应该是黑色存活对象 , 但是没有被正确的标记上 , 导致被错误的垃圾回收了

假如用户线程先断开了C到D的引用,那么D对象就认为是不可达对象,而此时B对象又引用了D对象,但是三色标记又不会重新从B点开始标记到D,那么D就会被认为是垃圾对象,但实际上D是有引用的,那么此时对D进行垃圾回收,之后就一定会产生错误,这就是漏标问题。

在这里插入图片描述

这种情况一旦发生是比较危险的 , 就是说一个正常使用的对象被垃圾回收掉了 , 这对系统来说是灾难性的问题 , 具体的解决方式 , CMS和G1中也不太一样 , CMS采用了 增量更新方式 , G1则采用 原始快照的方式

我们大家也发现了 , 其实漏标的问题想要发生 , 需要同时满足两个条件 :

  • 至少有一个黑色对象在自己被标记之后指向了这个白色对象 (图上B -> D)
  • 所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用 (图上C->D的虚线)

增量更新的方案其实就是破坏了第一个条件 , 而原始快照方案就是破坏了第二个条件

增量更新

​ “至少有一个黑色对象在自己被标记之后指向了这个白色对象” , 这个条件如果破坏了,那么就不会出现漏标的问题 , 所以 :

如果有黑色对象在自己标记之后 ,又重新指向了白色对象 , 那么我就把这个黑色对象的引用记录下来 , 在后续[重新标记阶段]再以这个黑色对象为根 , 对其引用重新进行扫描 , 通过这种方式 , 被黑色引用的白色对象机会变成灰色 , 从而存活下来

举个例子 : 我们清理房间的时候 , 假如已经打扫了某个抽屉 (对象) 里面也都是我们需要的东西 (标记为了黑色) , 但是 , 在你清理其他地方的时候 , 家里人给这个抽屉里放了一些东西 , 增量更新就像是他们给你留了一个小贴纸 , 上面写着 , "xxx抽屉有了变化,情重新检查一下 "确保你不会遗漏掉

​ 这种方式有个缺点 , 就是会重新扫描这部分新增的对象 , 会浪费一些时间 , 但是这种浪费其实也还好 , 虽然没有初始标记阶段快 , 但也远比并发阶段要快 , 而且本来其实漏标的情况就不多 , 所以这部分需要扫描的对象也并不多

原始快照

​ 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后再将这些记录过的引用关系中的灰色对象为根对象再重新扫描一遍。例如漏标问题的途中,C断开E的引用关系时会保存一个快照,然后等扫描结束之后,会把C当作根再重新扫描一遍,假如B没有引用D,那么D对象也会认为是可达对象,这样D就成了浮动垃圾, 也就是这种方式可能会将本来真要取消引用的对象给错误的复活了只能等下次垃圾回收时再回收

为什么分代GC算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:

  • 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
  • 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
  • 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。

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

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

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

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

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

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

在这里插入图片描述

年轻代-Serial垃圾回收器

​ Serial是单线程的串行垃圾回收器,采用了标记-复制算法 , 好处是单CPU处理器下吞吐量非常出色 , 因为单线程的好处就是减少上下文切换 , 减少系统资源的开销 缺点就是在GC过程中必须暂停其他的所有工作线程 , 直至收集器收集完成 (STW) , 多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待 , 主要适合Java编写的客户端程序或者硬件配置有限的场景

老年代-SerialOld垃圾回收器

​ SerialOld是Serial的老年代版本 , 也是个单线程收集器 , 适用于老年代 , 使用的是标记 - 整理算法 , 优缺点和Serial差不多 , 二者主要是回收算法不同

serial 和serial Old组合如下图 :

在这里插入图片描述

年轻代-ParNew垃圾回收器

​ ParNew其实就是Serial的多线程版本 , 在参数 , 回收算法上 , 和Serial是完全一样的 , 所以也是采用的标记-复制算法

ParNew在垃圾回收的时候 , 同样会STW , 但是因为它是多线程并行进行垃圾回收的 , 所以通常情况下时间会比Serial短一些

-XX:+UseParNewGC(JDK9取消了这个参数,所以,ParNew就只能和CMS一起使用,但是官方不建议这样,建议使用G1) 新生代使用ParNew回收器, 老年代默认使用串行回收器

-XX:ParallelGCThreads参数来限制垃圾收集的线程数

老年代- CMS(Concurrent Mark Sweep)垃圾回收器

​ CMS他是一个并发执行的垃圾收集器 , 它更加关注垃圾回收的停顿时间 , 通过他的名字Concurrent Mark Sweep就知道 ,他采用的是耗时更短的标记-清除算法

(JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个,ParNew是激活了CMS收集器默认的新生代收集器)

CMS收集器的工作流程主要有下面4个步骤 :

  1. 初始标记 : 标记所有从GCRoot直接可达的对象 . 这一步骤需要STW , 即暂停所有应用线程 , 但由于只标记直接可达的对象 , 因此这个阶段通常很快
  2. 并发标记 : 从初始化标记阶段的对象出发 , 遍历整个对象图 , 标记所有可达的对象 , 在此阶段 , GC线程与应用线程同时运行 , 不需要STW.
  3. 重新标记 : 这一阶段是为了修正并发标记期间因应用线程继续运行而产生的更改 , 这是另一个需要STW的阶段 , 这个阶段停顿的时间通常会比初始化标记阶段要长一些 , 但远比并发标记阶段时间短
  4. 并发清理 : 清理删除掉标记阶段判断的已经死亡的对象 , 由于不需要移动存活对象 , 所以这个阶段也是可以与用户线程同时并发的.

在这里插入图片描述

缺点 :

  1. 对CPU比较敏感 , 在并发阶段虽然不会导致用户线程停顿 , 但是会因为占用了一部分线程使应用程序变慢
  2. 无法处理浮动垃圾 : 在最后一次并发清理的过程中 , 用户线程依然在运行 ,执行的过程中也会产生垃圾 , 但是这部分垃圾是在标记之后才产生的 , 所以只能等到下一次GC的时候才能清理掉, 这部分垃圾叫做浮动垃圾
  3. CMS使用 “标记-清理算法” ,会产生大量的空间碎片,当碎片过多,将会给大对象的分配带来很多的麻烦,往往会出现老年代还有很多的空间但是无法找到足够大的连续空间来分配对象 , 不得不提前触发一次Full GC,为了解决这个问题CMS提供了一个JVM参数 : -XX:CMSFullGCsBefore-Compaction(此参数从jdk9开始废弃) , 这个参数的作用是要求CMS收集器在执行若干次(数量由参数指定)不整理空间的FullGC之后 , 下次进入FullGC前会先进行碎片整理(默认值为0, 表示每次进入FullGC时都会进行碎片整理)

注意 : CMS由于垃圾收集阶段用户线程还需要持续运行 , 那就还需要预留足够的空间提供给用户线程来使用 , 因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集 , 必须预留一部分空间供并发收集时的程序运作使用 , 在JDK5的默认设置下 , 使用了老年代的68%的空间就会激活 , 这个一个比较保守的设置 , 如果实际应用中老年代增长并不是特别的快,可以适当调高参数 (-XX:CMSInitiatingOccu-pancyFraction)的值来提高CMS的触发百分比 ,降低内存回收的频率 , JDK6时,这个值被默认提高至92%,但这会容易面临另一种风险 , 要是预留的内存无法满足程序分配新对象的需要 ,就会出现一次 “并发失败” , 这个时候虚拟机将不得不启动后备预案 : 冻结用户线程执行,临时启用Serial Old收集器来重新进行老年代的垃圾回收 , 但这样停顿的时间就很长了 , 所以百分比参数设置的很高,虽然可以降低垃圾收集的次数 ,但是也会造成大量的并发失败产生 , 退化为Serial Old之后性能反而下降, 需要经过测试才能使用

-XX:+UseConcMarkSweepGC 新生代默认使用ParNew回收器, 老年代使用CMS回收器

年轻代-Parallel Scavenge垃圾回收器

​ Parallel Scavenge是JDK8默认的年轻代垃圾回收器,多线程并行回收,采用标记复制算法,关注的是系统的吞吐量 (吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间))。

​ 如果虚拟机完成某个任务 ,用户代码运行时间 加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉了1分钟,那吞吐量就是99%,停顿时间就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验 , 而高吞吐量则可以有效的利用服务器资源,尽快完成程序的运算任务,主要适合后台运算而不需要太多交互的分析任务,比如 : 大数据的处理 , 大文件导出

具备自动调整堆内存大小的特点

常用参数

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

  • 最大暂停时间,-XX:MaxGCPauseMillis=n 设置每次垃圾回收时的最大停顿毫秒数
  • 吞吐量,-XX:GCTimeRatio=n 设置吞吐量为n(用户线程执行时间 = 1/n + 1),默认值为99,尽可能保证应用程序执行的时间为收集器执行时间的99倍,也即收集器的时间消耗不超过总运行时间的1%
  • 自动调整内存大小, -XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小

大家不要以为设置最大暂停时间小一点的话 , 就能提高GC回收的效率,这个时间的缩短是以牺牲吞吐量和新生代空间来换取的 :

系统把新生代调小一点,收集200MB的新生代肯定比收集500MB的新生代要快一点 , 空间小了,这也直接导致垃圾收集会更加的频繁,原来10秒收集1次 , 每次停顿100毫秒 , 现在5秒收集1次,每次停顿70毫秒,停顿时间确实下降了,但是吞吐量也下降了…

老年代-Parallel Old垃圾回收器

​ Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并行收集 , 采用的算法为标记整理算法

参数: -XX:+UseParallelGC 或

​ -XX:+UseParallelOldGC可以使用Parallel Scavenge + Parallel Old这种组合。

在这里插入图片描述

G1垃圾回收器

​ Garbage First(简称G1)收集器是垃圾收集器技术发展史上里程碑式的成果,它摒弃了传统垃圾收集器的严格的内存划分,而是采用局部回收的设计思路和基于Region的内存布局形式。

​ G1作为CMS收集器的替代者和继承人 , 设计者们希望做出一款能够建立起 “停顿预测模型” (Pause Prediction Model) 的收集器 , 停顿预测模型 : 能够支持在一个长度为M毫秒的时间片段内 , 消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标

​ G1是一款主要面向服务端应用的垃圾收集器,在jdk6 update14时,就有了实验版本。而到了jdk7 update4之后移除了“Experimental”标识。它的目的是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。如今已经完全替代CMS垃圾收集器,CMS收集器在JDK9 中被废弃,在JDK 14中被移除。

G1垃圾收集器的内存划分

在这里插入图片描述

​ 可以看出G1垃圾收集器也是根据分代收集理论设计的 , 但是它的堆内存布局和其他垃圾收集器的布局有比较明显的区别 ,

G1收集器不再按照固定大小以及固定数量的分代区域划分,而是把JAVA堆划分为多个大小相等的独立的Region , 每个Region大小可以通过参数**-XX:G1HeapRegionSize设定,取值范围为1-32MB,且必须为2的N次幂**。每一个Region都可以根据需要充当新生代的Eden区、S0和S1区或者老年代。在一般的垃圾收集中对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。 G1的大多数行为都把H区作为老年代的一部分来看待。当一个对象的大小超过了一个Region容量的一半,即被认为是大对象。

​ 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,而是一系列区域(不需要连续,逻辑连续即可)的动态集合。G1之所以能建立可预测的停顿时间模型 , 是因为它将Region作为单次回收的最小单元 , 即每次收集到的内存空间都是Region大小的正数倍 , 这样可以有计划地避免在整个java堆中进行全区域的垃圾收集 。具体思路是G1会根据每个Region里面垃圾堆积的“价值”的大小价值就是 : 回收所获得的空间大小以及回收所需的时间的经验值 , 在后台维护一个优先级列表,每次根据用户设定的允许收集停顿的时间(-XX:MaxGCPauseMillis,默认为200毫秒)优先处理价值收益最大的Region , 这也是Garbage First 名字的由来

G1垃圾收集器中的细节问题
1. 跨Region访问的问题

​ G1和大部分垃圾回收器一样 ,解决跨代访问的方法是 : 记忆集 (使用记忆集可以避免全堆作为GCRoots扫描)

但其实记忆集在G1中的应用比较复杂 , 它的每个Region都维护了自己的记忆集 , 这些记忆集会记录下别的Region指向自己的指针 , 并标记这些指针在哪些卡页范围 . G1的记忆集在存储结构上本质上是一种哈希表 , Key是别的Region的起始地址 , Value是一个集合 , 里面存储的元素是卡表的索引号 . 这种双向卡表的结构比原来的卡表实现更复杂,也正是因为这种双向卡表和多Region的之间的引用导致G1收集器比其他收集器占用的内存要多。根据经验G1至少占用JAVA堆容量的10%-20%额外内存来维护收集器的工作。

2. 并发标记阶段如何保证gc线程和用户线程的互不干扰

​ 避免并发标记的时候漏标 , CMS收集器采用增量更新算法实现 , G1收集器是通过原始快照 (SATB) 算法来实现的

垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上 , 程序要继续运行就肯定会持续有新对象被创建 , G1为每一个Region设计了两个名为TAMS (Top at Mark Start) 的指针 , 把Region中的一部分空间划分出来用于并发回收过程中新的对象分配 , 并发回收时新分配的对象的地址都必须在这两个指针位置以上,G1默认这个指针位置以上的对象都是隐式标记过的,默认他们是存活对象,不纳入本次回收的范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC 类似,如果回收速度赶不上分配速度,那么G1也要被迫冻结用户线程,进行Full GC而产生长时间的Stop The World。

3. G1是如何建立可靠的停顿预测模型

​ 用户使用-XX:MaxGCPauseMills参数指定停顿时间,这个时间只是期望值,而不是说指定了200毫秒,就会在200毫秒内将垃圾回收完毕。G1收集器的停顿模型是以衰减均值为理论基础来实现的。在垃圾收集过程中,G1会记录每个Region的回收耗时,记忆集中脏卡数量等各个步骤的花费成本,然后统计出平均值,标准偏差、置信度等统计信息。然后通过这些信息预测现在开始回收的话 , 由哪些Region组成的回收集才可以在不超过期望停顿时间的约束下获得最高的收益

收集过程

在这里插入图片描述

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能够在Region上正确的分配对象。这个阶段需要STW,耗时很短

  • 并发标记:从GC Roots 开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象,这个过程耗时较长,但是是与用户线程并发执行的。对象扫描完之后还需要重新处理STAB记录下的在并发时有引用变动的对象。

  • 最终标记:这个阶段也需要STW,用于处理并发阶段结束后仍然遗留下来的最后少量的STAB记录。

  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本排序,根据用户期望的停顿时间来执行回收计划,然后把决定回收的Region里的存活对象复制到空的Region,然后清空旧Region的空间。由于涉及到对象的移动,所以这个阶段也是需要STW的。

从上述可以看出,除了并发标记,其他阶段都是需要STW的,G1收集器不单单是追求低延迟的收集器,也衡量了吞吐量,所以在延迟和吞吐量之间做了一个权衡。

G1收集器的优缺点

优势 :

  • 并发回收 : G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop The World的停顿时间。

  • 因为CMS是基于标记-清除的算法实现的,所以CMS会有空间碎片化的问题。而在G1收集器上是不存在的,G1从整体上来看是基于标记-整理算法实现,从Region之间又是基于标记-复制算法实现的。

  • 由于G1不会产生空间碎片,可以为对象的分配提供更规整的内存。此外还避免了由于分配大对象时找不到连续的内存空间,而不得不提前触发下一次垃圾回收。

  • 可预测的停顿 : 这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

不足:

  • 由于跨Region引用等大量双向卡表的存在,G1收集器比CMS(只需要处理老年代到新生代的引用)占用更多的内存。
  • CMS收集器使用写后屏障来更新维护卡表,而G1收集器除了使用写后屏障维护卡表,为了实现SATB的算法,还需要使用写前屏障来跟踪并发时指针变化情况。所以G1收集器会增加程序运行时的额外负载。
G1收集器适合的场景

1.大型内存环境:G1针对大型内存环境进行了优化,因此对于使用了大量内存的应用程序来说(超过4G),G1是一个更好的选择。

2.对应用程序响应时间敏感的场景:G1通过分配多线程来进行垃圾回收,以最大限度地减少回收时应用程序的
暂停时间。

3.对内存使用效率敏感的场景:G1可以更好地评估哪些内存空间可以释放,以此来提高内存的利用率。

4.动态内存需求的场景:G1支持热插拔,可以在运行时动态调整堆的大小,以适应不同的内存需求。

5.要求回收时间具有可预测性的场景:G1使用固定的内存分配块来管理堆内存,这使得其在回收时间上具有更
高的可预测性。

G1收集器的JVM配置
  • -XX:+UseG1GC : 手动指定使用G1收集器执行内存回收任务(JDK9后不用设置,默认就是G1)。
  • -XX:G1HeapRegionSize : 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
  • -XX:MaxGCPauseMillis : 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms(如果这个值设置很小,如20ms,那么它收集的region会少,这样长时间后,堆内存会满。产生FullGC,FullGC会出现STW,反而影响用户体验)。
  • -XX:G1NewSizePercent 新生代的最小值默认是5%,此参数在实验阶段,如果想使用加-XX:+UnlockExperimentalVMOptions参数。
  • -XX:G1MaxNewSizePercent 新生代的最大值,默认值是60%,此参数在实验阶段,如果想使用-XX:+UnlockExperimentalVMOptions参数。
  • -XX:ParallelGCThreads 设置STW时GC线程数的值。最多设置为8(垃圾回收线程)。
  • -XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触 发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45%。
  • 11
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值