JVM总结

1 JVM总结

1.1 JVM简介

JVM是Java Virtual Machine(Java虚拟机)的缩写,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟计算机功能来实现的。JVM屏蔽了与具体操作系统平台相关的信息,Java程序只需生成在Java虚拟机上运行的字节码,就可以在多种平台上不加修改的运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

JVM的作用:加载并执行Java字节码文件(.class),并提供了一系列的功能和特性,例如内存管理、线程管理、异常处理等。

JAVA程序的特点:跨平台性。

Java跨平台是在怎么实现的?其实就是通过JVM虚拟机来实现的!但你要知道,Java语言是跨平台的,JVM却不是跨平台的,不同的操作系统有不同版本的JVM!
Java虚拟机可以在不同的硬件和操作系统上运行,从而实现Java程序的跨平台性。

image-20230615095211158

1.2 JVM规范和JVM

Java虚拟机规范(Java Virtual Machine Specification)是一份由Sun Microsystems(现在是Oracle Corporation)制定的文档,它定义了Java虚拟机的结构、指令集、类文件格式、类加载器、字节码执行引擎等方面的内容。

它们的关系如下所示:

image-20230615101724188

1、Oracle HotSpot JVM:Oracle HotSpot是目前最常用的Java虚拟机,也是官方提供的JVM实现。它具有成熟的垃圾回收器和即时编译器,广泛应用于生产环境。

2、OpenJDK JVM:OpenJDK是一个开源的Java开发工具包,其中包含了Java语言的参考实现。OpenJDK中也包含了HotSpot JVM,因此它可以作为一种常见的Java虚拟机使用。

3、IBM J9 JVM:IBM J9是IBM公司开发的Java虚拟机,具有高度优化的垃圾回收器和即时编译器。它在性能和资源利用方面有一定的优势,并且支持多种操作系统和硬件平台。

....

除了以上列举的常见Java虚拟机外,还有一些其他的实现,如BEA JRockit、Excelsior JET等。不同的Java虚拟机在性能、垃圾回收、即时编译等方面可能有所差异,开发者可以根据具体需求选择适合的Java虚拟机。

可以通过如下的命令查看当前所使用的jvm:

java -version

1.3 JVM架构

JVM的整体架构如下所示:

image-20230615100053819

在整个JVM中包含了三个子系统:

1、类加载器子系统:负责将Java类加载到JVM中,并生成对应的Class对象。

2、运行时数据区:是Java虚拟机用于存储和管理程序运行时数据的区域。

3、执行引擎:它负责执行Java程序的字节码指令。

2 类加载器子系统

2.1 类加载器子系统概述

类加载器子系统负责将Java类加载到JVM中,并生成对应的Class对象,Class对象保存在JVM的方法区中。

类的加载过程包含如下阶段:

image-20230615100957275

JVM类加载过程主要分为加载、链接和初始化三个阶段:

  1. 加载:查找并加载类的二进制数据。

  2. 链接(连接):

    • 验证:确保被加载的类的正确性。
    • 准备:将类变量(被static修饰符修饰)在方法区进行内存分配并进行初始化。
    • 解析:将类中的符号引用转换为直接引用。
    所谓符号引用,只是一个符号而已,只是告知jvm,此类需要哪些调用方法,引用或者继承哪些类等等信息.
    但是JVM在使用这些资源的时候,只有这些符号是不行的,必须详细知道这些资源的地址,才能正确地调用相关资源.
    直接引用,就是这样一类指针,它直接指向目标.
    解析过程,就是完成将符号引用转化为直接引用的过程,方便后续资源的调用.
    
  3. 初始化:为类静态变量赋予正确的初始值。

以下是一个简单的Java类加载过程的代码示例:

 public class MyClass {
    static int staticVariable = 10;
    static {
        System.out.println("静态代码块执行");
    }
 
    public static void main(String[] args) {
        System.out.println("静态变量的值: " + staticVariable);
    }
}

当JVM启动并执行这个main方法时,MyClass类将经历以下阶段:

  1. 加载:JVM寻找并加载MyClass的字节码文件。

  2. 链接:

    • 验证:检查MyClass是否符合JVM的要求。
    • 准备:为staticVariable分配内存,并设置默认值0。
    • 解析:如果有依赖其他类或方法等,则解析这些类或方法的符号引用。
  3. 初始化:为staticVariable赋值10,然后执行静态代码块。

2.2 类加载器ClassLoader

2.2.1 类加载器的作用

负责加载class文件到JVM,然后创建一个与之对应的Class对象。

image-20240701195251613

解释说明:

1、通过类加载器将User.class字节码文件加载到 JVM 中,然后创建一个与之对应的Class对象。

2、User字节码文件一旦加入到 JVM 以后,那么此时就可以使用其创建对应的实例对象。

3、可以通过调用实例对象的getClass方法获取字节码文件对象。

4、可以调用字节码文件对象的getClassLoader方法获取加载该类所对应的类加载器。

2.2.2 类加载器的分类

1、启动类加载器(Bootstrap ClassLoader):它是虚拟机的内置类加载器。负责加载Java核心类库,如rt.jar中的类。启动类加载器是由C++实现的,不是一个Java类,打印该加载器时为null。

2、扩展/平台类加载器(Extension/PlatformClassLoader):扩展类加载器负责加载Java的扩展类库,位于JRE的lib/ext目录下的jar包。

3、应用程序/系统类加载器(Application/System ClassLoader):也称为系统类加载器,它负责加载应用程序的类,即开发者自己编写的类。

4、自定义类加载器。

如下图所示:

image-20230615144621075

类加载器之间是存在逻辑上的继承关系,但是不存在物理上的继承,它们的继承体系如下所示:

image-20230615103406995

代码演示:

public class StudentDemo01 {

    public static void main(String[] args) {

        // 获取加载Student类所对应的类加载器
        ClassLoader classLoader = Student.class.getClassLoader();
        System.out.println(classLoader);

        // 获取classLoader类加载器所对应的父类加载器
        ClassLoader loaderParent = classLoader.getParent();
        System.out.println(loaderParent);

        // 获取loaderParent类加载器所对应的父类加载器
        ClassLoader parentParent = loaderParent.getParent();
        System.out.println(parentParent);       // 引导类加载器,是通过null进行表示
    }

}

2.2.3 双亲委托(派)机制

JVM对class文件采用的是按需加载的方式,当需要使用该类时,JVM才会将它的class文件加载到内存中产生class对象。

在加载类的时候,是采用的双亲委派机制

  • 如果一个类加载器接收到了类加载的请求,它自己不会先去加载,会把这个请求委托给父类加载器去执行。
  • 如果父类还存在父类加载器,则继续向上委托,一直委托到启动类加载器:Bootstrap ClassLoader
  • 如果父类加载器可以完成加载任务,就返回成功结果,如果父类加载失败,就由子类自己去尝试加载,如果子类加载失败就会抛出ClassNotFoundException异常,这就是双亲委派模式

双亲委派机制的好处:

1、避免重复加载:当一个类需要被加载时,首先会委派给父类加载器进行加载。如果父类加载器能够找到并加载该类,就不会再由子类加载器重复加载,避免了重复加载同一个类的问题。

2、确保类的唯一性:通过双亲委派机制,类加载器在加载类时会按照一定的顺序进行查找和加载。这样可以确保同一个类在不同的类加载器中只会被加载一次,保证了类的唯一性。

3、提高安全性:双亲委派机制可以防止恶意代码通过自定义类加载器来替换核心类库中的类。因为在加载核心类库时,会优先委派给启动类加载器进行加载,而启动类加载器是由JVM提供的,具有较高的安全性。

3 运行时数据区

运行时数据区:是Java虚拟机用于存储和管理程序运行时数据的区域。

运行时数据区又可以为划分为如下几个部分:

image-20230615105135381

3.1 程序计数器

作用:是一块较小的内存空间,可以理解为是当前线程所执行程序的字节码文件的行号指示器,存储的是当前线程所执行的行号

特点:线程私有空间 ,唯一一个不会出现内存溢出的内存空间。

3.2 JVM栈

3.2.1 简介

JVM栈(Java Virtual Machine Stack)是Java虚拟机在运行时为每个线程分配的内存区域之一【线程私有空间】。

它用于存储线程执行方法时的局部变量、操作数栈、方法调用和返回信息。

如下所示:

image-20230615110155071

JVM栈以栈帧(Stack Frame)为单位进行管理,每个栈帧对应一个方法的执行。

当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并将其推入当前线程的JVM栈顶。

当方法执行完毕或者遇到异常时,栈帧会被弹出,恢复到上一个栈帧的状态。

3.2.2 StackOverflowError

JVM栈的大小是固定的【通常为1MB】,可以通过命令行参数【-Xss】进行调整。

每个线程都有自己独立的JVM栈,用于支持线程的并发执行。栈太小或者方法调用过深,都将抛出StackOverflowError异常。

演示代码:

// -Xss256k:将每个线程的栈大小设置为256KB。
// -Xss1m:将每个线程的栈大小设置为1MB。
// -Xss2m:将每个线程的栈大小设置为2MB。
// 可以根据具体需求来调整栈内存大小。需要注意的是,栈内存的大小应该根据应用程序的需求和系统资源情况进行合理的设置。过小的栈容量可能导致StackOverflowError异常,而过大的栈容量可能导致系统资源的浪费。
public class StackDemo02 {   
    
    // 记录调用了多少次出现了栈内存溢出
    private static int count = 0 ;

    // 入口方法
    public static void main(String[] args) {
        try {
            show() ;
        }catch (Throwable e) {
            e.printStackTrace();
        }
        System.out.println("show方法被调用了:" + count + "次");
    }
    // 测试方法
    public static void show() {
        count++ ;
        System.out.println("show方法执行了.....");
        show();
    }
}

3.3 本地方法栈

与虚拟机栈作用相似。但它不是为Java方法服务的,而是本地方法,本地方法的实现一般都是通过c语言。

本地方法:被native所修饰的方法

public class Object {
    
    public final native Class<?> getClass();		// 获取字节码文件对象的本地方法
    public native int hashCode();					// 获取对象hashCode值的本地方法
    ...
        
}

3.4 JVM堆

3.4.1 堆简介

Java虚拟机堆是Java内存区域中一块用来存放对象实例的区域,新创建的对象,数组等都使用堆内存。

image-20230615141641279

说明:

1、在默认情况下,Java虚拟机堆内存的初始大小为物理内存的1/64,并且最大可达物理内存的1/4。

2、新生代占整个堆内存的1/3、老年代占整个堆内存的2/3。

3、新生代又可以细分为:伊甸区(Eden)、幸存区(from/s0、to/s1),它们之间的比例默认情况下是8:1:1。

4、线程共享区域,因此需要考虑线程安全问题。

5、会产生OOM内存溢出问题。

可以通过如下的命令查看堆内存的内存分配情况:

image-20240523120606970

-Xlog:gc*

打印出来的内存信息如下所示:

[0.011s][info][gc,init] Memory: 16091M
...
[0.011s][info][gc,init] Heap Min Capacity: 8M
[0.011s][info][gc,init] Heap Initial Capacity: 252M       初始堆大小
[0.011s][info][gc,init] Heap Max Capacity: 4024M          最大堆大小
....

演示:OOM内存溢出

public class HeapDemo01 {
    public static void main(String[] args) {
        int count = 0 ;									// 定义一个变量
        ArrayList arrayList = new ArrayList() ;			// 创建一个ArrayList对象
        try {
            while(true) {
                arrayList.add(new Object()) ;
                count++ ;
            }
        }catch (Throwable a) {
            a.printStackTrace();
            System.out.println("总共执行了:" + count + "次");			// 输出程序执行的次数
        }
    }
}

3.4.2 堆内存大小设定

-XX:NewRatio

-XX:NewRatio参数:该参数用于设置新生代和老年代的初始比例。例如,-XX:NewRatio=2表示新生代占堆内存的1/3,老年代占堆内存的2/3。

-XX:SurvivorRatio

-XX:SurvivorRatio参数:该参数用于设置Eden区和Survivor区的初始比例。例如,-XX:SurvivorRatio=8表示Eden区占新生代的8/10,每个Survivor区占新生代的1/10。

-Xms

设置堆的初始大小。例如,-Xms512m表示将堆的初始大小设置为512MB。

-Xmx

设置堆的最大大小。例如,-Xmx1024m表示将堆的最大大小设置为1GB。

-Xmn

设置新生代的大小。例如,-Xmn256m表示将新生代的大小设置为256MB。

通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

3.4.3 对象的分配过程

对象优先在堆中年轻代的Eden分配,当 eden 区 没有足够的空间时,触发 'Minor GC'(新生代的GC) 。

Minor GC的过程大致如下:

  1. 当Eden区满时,会触发一次Minor GC。
  2. 在这个过程开始时,Eden区和From区(S0)中还存活的对象会被复制到To区(S1),并清除Eden区和From区(S0)
  3. 交换From区(S0)和To区(S1)的角色,之前的To区现在是新的From区,而From区则变成To区。
  4. 经过多次Minor GC后,如果存活下来的对象经过足够的次数Minor GC,那么这些对象会被晋升到老年代内存区域。
年龄最多到15的对象会被移动到年老代中,没有达到阈值的对象会被复到“To”区域。

对象在Survivor区(S)中每熬过一次Minor GC,年龄就会增加1岁,年龄最多到15的对象会被移动到年老代中。
可以通过-XX:MaxTenuringThreshold来设置年龄阈值。

(1)案例演示

jvm参数设置:

-XX:+UseSerialGC -verbose:gc -Xlog:gc* -Xlog:gc::utctime -Xlog:gc:./gc.log -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8

# -XX:+UseSerialGC 是指使用 Serial + SerialOld 回收器组合
# -verbose:gc -Xlog:gc* 是指打印 GC 详细信息
# -Xlog:gc::utctime 打印gc日志的时间戳
# -Xloggc:./gc.log 将gc日志输出到一个日志文件中
# -Xms20M -Xmx20M -Xmn10M 是指分配给JVM的最小,最大以及新生代内存
# -XX:SurvivorRatio=8 是指『伊甸园』与『幸存区 From』和『幸存区 To』比例为 8:1:1
  • 案例1:没有创建数组对象,看参数运行情况

image-20240523134502012

  • 案例2:创建一个4M的数组,查看内存分配情况
// 创建一个4M大小的数组
private static final int _4MB = 4 * 1024 * 1024;
byte[] bytes = new byte[_4MB] ;

image-20240523134759241

没有触发GC操作,对象直接在Eden分配;

(2)大对象直接晋升至老年代

当对象太大,伊甸园包括幸存区都存放不下时,这时候老年代的连续空间足够,此对象会直接晋升至老年代,不会发生 GC。

image-20211122221152776

结果

image-20211122221255321
  • 案例:直接分配一个8M的内存空间
byte[] bytes1 = new byte[_8MB] ;

伊甸园总大小只有 8 MB,但新分配的对象大小已经是 8MB,而幸存区都仅有 1MB,也无法容纳这个对象。

可以看到结果并没有发生 GC,大对象直接被放入了老年代「tenured generation total 10240K, used 8192K」

image-20240523140258757)

3.4.4 堆内存分代意义

Java的堆内存分代是指将不同生命周期的对象存储在不同的堆内存区域中,这里的不同的堆内存区域被定义为“代”。

这样做有助于提升垃圾回收的效率,因为这样的话就可以为不同的"代"设置不同的回收策略。

一般来说,Java中的大部分对象都是朝生夕死的,同时也有一部分对象会持久存在。因为如果把这两部分对象放到一起分析和回收,这样效率实在是太低了。通过将不同时期的对象存储在不同的内存中,就可以节省宝贵的时间和空间,从而改善系统的性能。

JVM中的各种GC:

Minor GC(Young Generation Garbage Collection)是指对年轻代(Young Generation)进行的垃圾回收操作。

Major GC专注于回收老年代中(Tenured Generation)的垃圾对象。

Full GC(Full Garbage Collection),它是指对整个堆内存进行回收,包括新生代和老年代。

3.5 方法区

3.6.1 方法区概述

方法区是被所有线程共享,主要就是存放已经被虚拟机加载的类型信息、方法信息、域信息、JIT代码缓存、运行时常量池等。

image-20240702212653950

参数控制:

1、-XX:MetaspaceSize 设置元空间的初始大小

2、-XX:MaxMetaspaceSize 设置元空间的最大大小。

如果加载的类过多,会导致Metaspace方法区的内存溢出,对应的异常内容:java.lang.OutOfMemoryError: Metaspace。

3.6.2 方法区演进

方法区是一个逻辑上的区域,在不同版本的Java虚拟机中,方法区的实现有所差异:

Java7及之前:永久代(Permanent Generation),占用JVM内存。

Java8及之后:永久代被移除,取而代之的是元空间(Metaspace)。元空间使用本地内存,而不是JVM内存,这就意味着它不再受JVM堆大小的限制。

image-20240702210750891

3.6.3 变化的原因

1、元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

2、可以加载更多的类。

3、提高内存的回收效率。

4 执行引擎

4.1 执行引擎概述

执行引擎负责执行Java程序的字节码指令,执行引擎的结构如下所示:

image-20230615151955474

执行引擎主要包含以下几个重要的组成部分:

1、解释器(Interpreter):解释器逐条解释字节码指令,并将其转换为对应的机器码进行执行。

优点:实现简单、快速启动

缺点:由于每次执行都需要解释字节码,因此执行效率相对较低。

2、即时编译器(Just-In-Time Compiler,JIT):即时编译器将热点代码(HotSpot)从字节码直接编译成机器码,以提高执行效率。

热点代码通常是经过多次执行的代码块,即被频繁调用的方法或循环等。

3、垃圾回收器(Garbage Collector):垃圾回收器负责自动回收不再使用的对象,并释放其占用的内存空间。

实际JVM中有三种模式:解释执行模式、编译执行模式、混合模式(默认)
编译执行:编译执行是将源代码一次性编译成机器码,然后直接执行机器码。
解释执行:解释执行则是将源代码逐行解释并执行,每执行一行就将其翻译成机器码。
混合模式(Mixed ):大部分代码都是通过解释执行的方式执行的,只有当某些代码重复执行的次数达到了一定的阈值(即这部分代码成为了“热点代码”),才会被编译成机器码。
C:\Users\Administrator>java -version
java version "17.0.2" 2022-01-18 LTS
Java(TM) SE Runtime Environment (build 17.0.2+8-LTS-86)
Java HotSpot(TM) 64-Bit Server VM (build 17.0.2+8-LTS-86, mixed mode, sharing)

4.2 垃圾对象判定

要进行垃圾回收,那么首先需要找出垃圾,如果判断一个对象是否为垃圾呢?

两种算法:

1、引用计数法

2、可达性分析算法

4.2.1 引用计数法

堆中每个对象实例都有一个引用计数。

当一个对象被创建时,为该对象实例分配给一个变量,该变量计数设置为1。

当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。

特点:实现简单。

缺点:无法解决 循环引用 问题。

4.2.2 可达性分析算法

可达性分析算法又叫做根搜索法,就是通过一系列称之为"GC Roots"的对象作为起始点,从这些节点开始向下搜索。

当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。(类似于葡萄串);

image-20230615152614018

在Java语言中,可以作为GC Roots的对象包括下面几种:

1、虚拟机栈(栈帧中的本地变量表)中的引用对象 ==> 正在运行的方法中的参数、局部变量等

2、方法区中的类静态属性引用的对象 ==> 类的引用类型的静态变量

3、方法区中的常量引用的对象

4、本地方法栈中JNI(Native方法)的引用对象

5、被同步锁synchronized持有的对象。

6、Java虚拟机的内部引用 ==> 基本数据类型对应的Class对象,常驻异常对象(空指针、OOM等),系统类加载器

4.3 垃圾回收算法

当判断一个对象为垃圾以后,那么此时就需要对垃圾进行回收,不同的区域使用的垃圾回收算法是不一样的。

4.3.1 标记清除

执行过程:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

image-20230615153126114

优点:速度比较快。

缺点:会产生内存碎片,碎片过多,仍会使得连续空间少。

4.3.2 标记整理

执行过程:先标记出所有需要回收的对象,在标记完成后统一进行整理,整理是指存活对象向一端移动来减少内存碎片,相对效率较低。

image-20230615153421545

优点:无内存碎片。

缺点:效率较低。

4.3.3 标记复制(复制算法)

内存一分为二,每次只使用其中一块。垃圾回收时,直接把不是垃圾的对象,拷贝到另一块区域,原来的那块清空。

image-20230615153506135

优点:无内存碎片

缺点:空间利用率只有50%;如果对象的存活率较高,复制算法的效率就比较低。

4.3.4 分代回收

概述:根据对象存活周期的不同,将对象划分为几块,比如Java的堆内存,分为新生代和老年代,然后根据各个年代的特点采用最合适的算法;

新生代对象的存活的时间都比较短,因此使用的是【复制算法】;

而老年代对象存活的时间比较长那么采用的就是【标记清除】或者【标记整理】。

4.4 四种引用类型

了为了更灵活地管理对象的生命周期和内存使用,在Java中提供了四种常见的引用类型。

4.4.1 强引用

Java中默认声明的就是强引用,比如:

Object obj = new Object();    //只要obj还指向Object对象,Object对象就不会被回收
obj = null;                   //手动置null

只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM也会直接抛出OutOfMemoryError,不会去回收。

如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为null,这样一来,JVM就可以适时的回收对象了!

示例:

/**
 * JVM参数:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class StrongReferenceDemo01 {

    private static List<Object> list = new ArrayList<Object>() ;
    public static void main(String[] args) {

        // 创建对象
        for(int x = 0 ;  x < 10 ; x++) {
            byte[] buff = new byte[1024 * 1024 * 1];
            list.add(buff);
        }
    }
}

4.4.2 软引用

软引用是用来描述一些非必需但仍有用的对象。

在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常

这种特性常常被用来实现缓存技术,比如网页缓存,图片缓存等。

在 JDK1.2 之后,用java.lang.ref.SoftReference类来表示软引用。

示例代码:

/**
 * JVM参数:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class SoftReferenceDemo01 {

    private static List<Object> list = new ArrayList<>();

    public static void main(String[] args) {
        // 创建数组对象
        for(int x = 0 ; x < 10 ; x++) {
            SoftReference<byte[]> softReference = new SoftReference<byte[]>(new byte[1024 * 1024 * 1]) ;
            list.add(softReference) ;
        }
        System.gc();  // 主动通知垃圾回收器进行垃圾回收
        for(int i=0; i < list.size(); i++){
            Object obj = ((SoftReference) list.get(i)).get();
            System.out.println(obj);
        }
    }
}

我们发现无论循环创建多少个软引用对象,打印结果总是有一些为null,这里就说明了在内存不足的情况下,软引用将会被自动回收。

4.4.3 弱引用

弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收

在 JDK1.2之后,用java.lang.ref.WeakReference来表示弱引用。

示例代码:

/**
 * JVM参数:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class WeakReferenceDemo01 {

    private static List<Object> list = new ArrayList<>();

    public static void main(String[] args) {

        // 创建数组对象
        for(int x = 0 ; x < 10 ; x++) {
            WeakReference<byte[]> weakReference = new WeakReference<byte[]>(new byte[1024 * 1024 * 1]) ;
            list.add(weakReference) ;
        }

        System.gc();  // 主动通知垃圾回收器进行垃圾回收

        for(int i=0; i < list.size(); i++){
            Object obj = ((WeakReference) list.get(i)).get();
            System.out.println(obj);
        }
        
    }
}

4.4.4 虚引用

虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收

在 JDK1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

特点:

1、每次垃圾回收时都会被回收,主要用于监测对象是否已经从内存中删除。

2、虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。

3、程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

示例代码:

public class PhantomReferenceDemo {

    public static void main(String[] args) throws InterruptedException {

        // 创建一个引用队列
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<Object>();
        
        // 创建一个虚引用,指向一个Object对象
        PhantomReference<Object> phantomReference=new PhantomReference<Object>(new Object(),referenceQueue);
        
        // 主动通知垃圾回收器进行垃圾回收
        System.gc();
        
        // 从引用队列中获取元素, 该方法是阻塞方法
        System.out.println(referenceQueue.remove()); 

    }
}

4.5 垃圾收集器

在进行垃圾回收的时候是通过垃圾收集器完成的。

4.5.1 常见的垃圾收集器汇总

image-20230615154804954

上面的 serial , parnew , Paraller Scavenge 是新生代的垃圾回收器;

下面的 CMS , Serial Old ,Paralle Old是老年代的垃圾收集器 ;

G1垃圾收集器可以作用于新生代和老年代; 连线表示垃圾收集器可以搭配使用。

可以通过如下的命令查看垃圾收集器的信息:

# 查看进程id (先启动一个java项目)
jps -l

# 查看某一个进程所对应垃圾收集器
jinfo -flags <进程id>

或者:

# 查看jvm默认的垃圾收集器配置
java -XX:+PrintCommandLineFlags -version

![image-20240704085424365](https://gitee.com/mitu-me/blogimg/raw/master/imgs/image-20240704085424365.png)

4.5.2 Serial/Serial Old

特点:

1、Serial是一个单线程的垃圾收集器。

2、"Stop The World(STW)",它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。在用户不可见的情况下把用户正常工作的线程全部停掉。

3、新生代(Serial)==》复制算法

​ 老年代(Serial Old)==》标记整理算法

4、参数控制: -XX:+UseSerialGC 年轻代和老年代都用串行收集器

image-20230615155258901

使用场景:多用于桌面应用(内存占用较小的应用),Client端的垃圾回收器

4.5.3 ParNew Scavenge

ParNew 是 Serial 的 **多线程 **版本,除了使用多线程进行垃圾收集之外,其余行为与Serial收集器完全一样。

参数控制:

  • -XX:+UseParNewGC , 年轻代使用ParNew,老年代使用 Serial Old

  • -XX:ParallelGCThreads={value} ,控制gc线程数量

    CPU 数量小于8, ParallelGCThreads 的值等于 CPU 数量;
    
    当 CPU 数量大于 8 时,则使用公式(每超出5/8CPU启动一个新的线程):
    ParallelGCThreads = 8 + ((N - 8) * 5/8) 
    

    image-20230615155517135

4.5.4 Parallel/Parallel Old

Parallel 收集器 类似 ParNew 收集器,多线程 并行 收集;相比于ParNew,Parallel具备GC 自适应调节策略

Parallel收集器更关注系统的吞吐量(也被称为吞吐量收集器,达到一个可控制的吞吐量),Parallel是JDK8默认的垃圾收集器。

image-20240704095452059

image-20240704095740044

  • 吞吐量
吞吐量  =  运行用户代码时间  /(运行用户代码时间 + 垃圾收集时间 = cpu总消耗时间)

例如:JVM虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99% 。

  • GC自适应调节策略

Parallel 的 自适应调节策略 是与ParNew收集器最重要的一个区别。

当开启自适应调节策略时,不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会动态设置这些参数,以提供最优的停顿时间和最高的吞吐量

-XX:+UseAdptiveSizePolicy   开启GC自适应调节策略
-XX:ParallelGCThreads=N		设置GC线程数
-XX:MaxGCPauseMillis=N		最大STW暂停时间(毫秒)
-XX:GCTimeRatio=N	        设置吞吐量(垃圾收集花费的时间与实际应用程序执行的时间相比),N=99表示最多 1% 的时间用来垃圾集。

特点:

1、新生代使用 复制算法、老年代使用标记整理算法(Parallel Old是Parallel Scavenge收集器的老年代版本)。

2、参数控制:

  • -XX:+UseParallelGC 年轻代使用Paraller Scavenge,老年代使用Serial Old
  • -XX:+UseParallelOldGC 新生代Paraller Scavenge,老年代使用Paraller Old

3、应用场景:高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

4.5.5 CMS收集器

CMS (Concurrent Mark Sweep 并发-标记-清除),老年代的收集器,基于“标记-清除”算法实现。

CMS更关注的是 响应时间 ,是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

  • CMS垃圾收集的过程:
1、初始标记(CMS initial mark) ,标记一下 GC Roots 能**直接关联**到的对象,速度很快(会 stop the world)

2、并发标记(CMS concurrent mark),对初始标记所标记过的对象,进行trace(进行追踪,得到所有关联的对象,进行标记)不STW

3、重新标记(CMS remark),为了修正并发标记期间因用户程序导致标记产生变动的标记记录(会 stop the world)

4、并发清除(CMS concurrent sweep)  ,不SWT

image-20230615160619603

  • 参数控制:

1、-XX:+UseConcMarkSweepGC,老年代开启CMS,年轻代自动开启ParNew。

​ 如果需要内存碎片整理或使用 CMS 失败,可以使用 Serial Old 兜底。

2、-XX:+UseCMSCompactAtFullCollection,用于指定在执行完 Full GC 后对内存进行压缩整理,以避免内存碎片的产生。

3、-XX:CMSFullGCBeforeCompaction ,设置在执行多少次 Full GC 后对内存空间进行压缩整理

4、-XX:ParallelCMSThreads:设置CMS 线程数(默认情况下,JVM 会根据系统的 CPU 核心数来设置这个值)

5、-XX:CMSInitiatingOccupanyFraction:设置内存使用率阈值,一旦超过该阈值,便开始回收(默认为68,即当年老代的空间使用率达到68%时,会执行一次CMS回收)。

4.5.6 G1收集器

4.5.6.1 G1收集器简介

JDK9及以后的版本中,G1是默认的垃圾收集器。

之前的垃圾收集器的共性问题:在垃圾收集过程中,一定会发生Stop The World,垃圾收集器的发展就是为了能够尽量缩短STW的时间。

G1采用 “局部收集” 设计思路 , 以Region为基本单位的内存布局,将java堆划分成一些大小相等的Region(建议不超过2048个)。

每个Region大小 = 堆总空间 / region个数。

可以通过参数-XX:G1HeapRegionSize来指定Region的大小。

G1抛弃了将新生代和老年代作为整块内存空间的方式,但依然保留了新生代老年代的概念,只是老年代新生代的内存空间不再是物理连续的了,它们都是Region的集合。

G1对应的堆空间的内存布局如下所示:

image-20230615161037545

G1将所有Region分为四种类型:Eden、Survivor、Old、Humongous。

Region的类型是动态变化的,可能之前是年轻代,经过了垃圾回收之后就变成了老年代。

G1中对于大对象的处理有所不同:大对象进入 H类型 的Region。

在G1中,大对象的判断是超过一个Region大小的50%。如果一个对象太大,一个Region放不下,可能会存在跨多个Region来存放。

4.5.6.2 参数控制

1、-XX:+UseG1GC:表示使用G1收集器

2、-XX:G1HeapRegionSize:指定每一个Region的大小。

4.5.6.3 G1垃圾回收过程

image-20230615161238695

大致可划分为以下几个步骤:

1、初始标记:需要暂停所有工作线程(STW),并记录下GC Roots能直接引用的对象,速度很快,与CMS的初始标记一致

2、并发标记:可以与工作线程并发执行,进行可达性分析,与CMS的并发标记一致

3、最终标记:需要暂停所有工作线程(STW),根据指定的算法修复一些引用的状态,与CMS的重新标记是一致的。

4、筛选回收:筛选回收阶段会对各个Region的回收价值成本进行排序,根据用户所期望的GC停顿时间(-XX:MaxGCPauseMillis)来制定回收计划,并采用复制算法进行垃圾回收。将一个Region中的存活对象移动到另一个空的Regin中,然后将之前的Region内存空间清空,不会像CMS会有内存碎片。

4.5.6.4 三色标记算法

(1)三色标算法简介

垃圾收集器在标记的过程,有两种标记方式:串行标记(例如:serial,parallel)、并发标记(例如:cms、G1)。

1、串行标记,会暂停所有用户线程,全面进行标记;

2、并发标记,不会暂停用户工作线程。实现这种并发标记的算法就是===》三色标记法

(2)三种颜色

三色标记算法使用的是三种颜色来区分对象的:

1、白色:本对象还没有被标记线程访问过

2、灰色:本对象已经被访问过,但是本对象引用的其他对象还没有被全部访问

3、黑色:本对象已经被访问过,并且本对象引用的其他对象也都被访问过了

image-20240215121748765

(3)三色标记算法大致流程

​ 1、起初所有对象都是白色

image-20240215133901503

​ 2、三色标记初始阶段,所有GC Roots的直接引用(A、B、E)变成灰色,然后将灰色节点放入到一个队列中,此时GC Roots变成黑色

image-20240215133959578

​ 3、然后从灰色队列中取出队头灰色对象,例如A,将他的直接引用C、D变成灰色,放入队列,A因为已扫描完它的所有直接引用对象,所以A变成黑色

image-20240215134113730

​ 4、继续取出灰色对象,例如B对象,将它的直接引用F标记为灰色,放入队列,B对象此时标记为黑色

image-20240215134210327

​ 5、继续从队列中取出灰色对象E,因为E没有直接引用其他对象,将E直接标记为黑色

image-20240215134242731

​ 6、重复上述步骤,取出C 、D 、F 对象,他们都没有直接引用其他对象,直接变为黑色即可。

image-20240215134320959

​ 7、最后,G对象是白色,说明G对象是一个垃圾对象,可以被清理掉。

(4)三色标记算法弊端

​ 因为并发标记的过程中,用户线程也在运行,那么对象引用关系很可能发生变化,进而就会产生常见的两个问题:

  • 1、浮动垃圾:标记为不是垃圾的对象,变成了垃圾。

    回到如下的状态,此时E已经被标记为黑色,表示不是垃圾,不会被清除。

    image-20240215134651205

    因为并发标记时,同一时刻某个用户线程将GC Root2和E对象之间的关系断开了(objRoot2.e = null;),如图:

    image-20240711161118971

    很显然,E对象变为了垃圾对象,但是由于之前被标记为黑色,就不会被当作垃圾回收,这种问题称之为浮动垃圾。

  • 2、漏标/错杀,标记为垃圾对象,变成了非垃圾。

image-20240215135211861

再回到上面地状态,标记线程分析B对象时,但是刚好发生线程切换,操作系统调度用户线程来运行,当前标记线程(失去cpu时间片)停止了。碰巧,用户线程先执行A.f = F;那么引用关系变成如下:

image-20240215135324440

紧接着执行:B.f = null ;那么关系就变成了:

image-20240215135351550

用户线程做完上述动作,标记线程重新获取到cpu时间片,重新开始运行,按照之前的流程继续走,从队列中取出B对象,发现对象没有直接引用,那么B对象变成了黑色(B处理完成):

image-20240215135434561

接着继续取出 E、C、D 三个灰色对象,他们没有直接引用,那么变为黑色对象,此时分析过程结束。

image-20240215135508448

问题是:F和G是白色,会判断为垃圾,会被回收清理掉,G被清理没问题,如果F被清理,那程序运行时如果需要用到A.F对象,会有空指针异常。

(5)上述弊端的解决方案

  • 对于第一个问题,即使不去处理也无所谓,大不了等下一次GC的时候再清理。

  • 第二个问题就比较严重,会发生空指针异常(F被错杀),出现第二个问题必须满足两个条件:

    1、并发标记过程中黑色对象(A) 新增引用 到 白色对象(F)

    2、灰色对象(B) 断开了(减少引用) 同一个白色对象(F)引用

image-20240705194204146

两种解决方案:

(1)增量更新(Incremental Update)==》CMS采用

(2)原始快照(SATB, Snapshot At The Beginning)===》G1采用

1、增量更新(CMS处理方式)

是站在A对象的角度(新增引用的对象),在赋值操作之前,加个写屏障,用来记录新增的引用(A.f = F)。在 重新标记 阶段,将A变成灰色,重新扫描一次,以保证不会漏标。

2、原始快照 SATB(G1 解决方式 )

是站在B对象的角度(减少引用的对象),在将B.f = F 改成B.f = null 之前,写屏障记录下F,这个F称之为 原始快照。在 最终标记 阶段,直接将F设为黑色。可以保证F不被回收,但是可能成为浮动垃圾。

4.5.7 ZGC收集器(了解)

ZGC(Z Garbage Collector) 是一款性能比G1更加优秀的垃圾收集器。ZGC第一次出现是在JDK11中以实验性的特性引入,这也是JDK11中最大的亮点。在JDK15中 ZGC不再是实验功能,可以正式投入生产使用了,使用–XX:+UseZGC可以启用ZGC。

ZGC特性:

1、暂停时间不会超过10ms

image-20240215174941758

2、最大支持16TB的大堆,最小支持8MB的小堆

3、跟G1相比,对应用程序吞吐量的影响小于15%

4.5.8 常见的垃圾收集器对比

如下所示:

垃圾收集器 主要特点 适用场景
Serial 单线程垃圾回收,暂停应用程序 小型或简单的应用程序,单核处理器或较小的内存环境
Parallel 多线程并行垃圾回收,暂停应用程序 多核处理器,需要高吞吐量的应用程序
CMS 并发标记和并发清除,减少停顿时间 对响应时间要求较高的应用程序
G1 划分堆内存为多个区域,可预测的停顿时间和高吞吐量 大内存、多核处理器的服务端应用程序

5 本地方法接口/本地库

在JVM中,本地方法接口(Native Method Interface,JNI)和本地库(Native Library)是用于与底层系统交互的机制。

本地方法:使用其他编程语言(如C、C++)编写的方法。

本地方法接口(JNI):本地方法是通过JNI接口定义的,并且在Java代码中以native关键字声明。

当Java代码调用本地方法时,JVM会将控制权转移到本地方法实现所在的本地库,通常是为了与底层操作系统或硬件进行交互。

使用本地方法接口和本地库,Java程序可以利用底层系统的功能和性能优势,例如访问硬件设备、调用操作系统特定的API、执行高性能计算等。同时,本地方法也可以用于与现有的C/C++代码进行集成,以便重用现有的代码库。

6 线上问题定位

6.1 CPU飙升问题排查

准备工作:在linux服务器部署应用(java8环境)并启动,访问死循环接口http://192.168.188.128:8080/loop 导致cpu利用率飙高

1、使用top命令,查看cpu负载、内存的使用,找到cpu飙高的进程id

2、使用jdk的jstack工具,打印该进程的堆栈信息 jstack 31462 > 31462.txt

3、top -Hp 31462 ,查看指定进程下各个线程的CPU使用情况,找到线程id。

4、线程id是十进制的,在堆栈文件中是16进制的,因此,将线程id由十进制转成16进制 printf "%x" 1946

5、在堆栈文件中搜索 16进制的线程id,即可定位导致cpu飙高的位置。

6.2 死锁问题排查

访问死锁接口:http://192.168.188.128:8080/deadlock

1、top命令查看cpu飙高的进程id

2、堆栈日志 jstack 1425 > 1425.txt

3、查看日志文件最后,显示死锁信息

6.3 OOM异常

1、创建目录jvm_logs,启动项目

java  -jar  -Xmx10m -Xms10m  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./jvm_logs/    test-jvm-1.0-SNAPSHOT.jar

2、访问oom接口 http://192.168.188.128:8080/oom

3、 检查内存影像文件是否生成 /jvm_logs/java_pid114493.hprof

4、下载到win本地,使用jdk8中的jvisualvm.exe工具载入这个影像文件。

VisualVM工具(jdk8自带的工具):打开jvisualvm工具 ----> 载入文件 ----> 查看类实例数最多的并且和业务相关的对象 ----> 查看线程的报错信息

oom

posted @   LilyFlower  阅读(9)  评论(0编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示