1 JVM概述

1.1 JVM简介

JVM(Java Virtual Machine,Java虚拟机)是Java平台的核心组件之一,它允许Java程序在任何支持JVM的操作系统上运行,而无需修改源代码。JVM是一种抽象机器,它能够执行Java字节码(.class文件),这种字节码是Java编译器编译Java源代码后生成的一种中间代码。

image-20230615095211158
  • JVM实例的创建:每次运行Java程序时,都会启动一个新的JVM实例(在大多数情况下)。这个实例负责执行当前Java程序的字节码。JVM的启动和初始化是自动完成的,无需程序员手动干预。

  • 资源分配:JVM实例在启动时,会向操作系统请求必要的资源(如内存空间),并在其内部进行资源分配和管理。

  • 环境初始化:JVM实例会初始化其内部环境,包括设置堆大小、栈大小、方法区大小等,以及加载必要的系统类和库。

1.2 JVM的架构

JVM主要由以下几个部分组成:

  • 类加载器(Class Loader):负责加载类文件到JVM中。
  • 运行时数据区(Runtime Data Areas)
    • 方法区(Method Area):它存储了已被虚拟机加载的类信息、方法信息、字段信息、常量(final修饰)、静态变量、即时编译器编译后的代码缓存等。
    • 堆(Heap):Java堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,几乎所有的对象实例都在这里分配内存。
    • 栈(Stack):每个线程都有自己的栈,用于存储局部变量和部分计算过程的结果。
    • 程序计数器(Program Counter Register):是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
    • 本地方法栈(Native Method Stacks):与虚拟机栈所发挥的作用非常相似,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
  • 执行引擎(Execution Engine):负责执行字节码,或者将字节码转换为机器码来执行。
  • 本地接口(Native Interface):允许Java代码调用本地代码(通常是C或C++编写的库)。

image-20230615100053819

1.3 JVM的优势

  • 平台无关性(跨平台):由于Java程序运行在JVM上,而JVM可以在任何操作系统上运行,因此Java程序具有跨平台的特性。
  • 安全性:JVM提供了安全机制来防止恶意代码的攻击。
  • 自动内存管理:JVM负责自动管理内存的分配和回收,减轻了程序员的负担。
  • 多线程支持:JVM内置了对多线程的支持,简化了多线程编程的复杂性。

1.4 JVM规范和JVM

JVM(Java Virtual Machine,Java虚拟机)规范是Oracle公司(及其前身Sun Microsystems)制定的一系列规范,用于指导JVM的设计和实现。这些规范定义了JVM如何加载、执行Java字节码,管理内存,处理垃圾回收,以及与其他语言和环境交互的方式。

在Java的发展过程中,出现了多种JVM实现,每种实现都有其特定的优化和特性。

image-20230615101724188

Oracle HotSpot JVM‌:由Oracle公司开发,是目前最常用的JVM之一,也是Java官方推荐的JVM之一。

OpenJDK JVM‌:由Oracle公司主导的开源JVM项目,是Java官方的参考实现之一。

IBM J9 JVM‌:由IBM公司开发的JVM,具有高性能和低内存占用等优点。

Azul Zing JVM‌:由Azul Systems公司开发的JVM,专注于高性能、低延迟和可预测性的优化。

JRockit JVM‌:由BEA Systems公司开发的JVM,具有高性能和低内存占用等优点,目前已被Oracle公司收购。

Excelsior JET JVM‌:由Excelsior公司开发的JVM,可以将Java程序编译成本地机器代码,提高程序的执行效率。

查看当前所使用的JVM:

2 类加载器子系统

2.1 类加载器的作用

负责加载class文件到JVM,然后创建一个与之对应的Class对象(该对象中包含该类的信息,方法信息,字段字段等),存储在方法区。

解释说明:

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

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

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

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

2.2 类加载的过程

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

image-20230615100957275

  1. 加载(Loading):JVM负责加载Java类文件(.class)到其内部的内存中,就是方法区。
  2. 链接(Linking)
    • 验证(Verification):检查加载的类文件是否符合Java语言规范及JVM规范。
    • 准备(Preparation):为类变量分配内存并设置默认的初始值(注意,这里的初始值不是代码中设定的值,而是如0、null等默认值)。
    • 解析(Resolution):将类的符号引用替换为直接引用(例如,将类名、方法名等符号替换为它们在内存中的地址)。
  3. 初始化(Initialization):根据程序员在Java代码中编写的初始化代码(如静态代码块、静态变量初始化等)来初始化类的变量。
  4. 执行(Execution):JVM中的Java解释器(JIT编译器)负责将字节码转换成平台相关的机器码并执行。

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

1
2
3
4
5
6
7
8
9
10
 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.3 类加载器的分类

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

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

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

4、自定义类加载器。

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

image-20241017191749451
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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.4 类加载的机制(双亲委派)

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

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

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

双亲委派机制的好处:

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

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

3 运行时数据区

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

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

image-20230615105135381

3.1 程序计数器

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

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

3.2 JVM栈

3.2.1 简介

‌‌JVM栈的主要作用是管理‌Java程序运行时的方法调用参数传递。‌

JVM栈是Java虚拟机为每个线程分配的一块内存区域【线程私有】,用于存储方法的局部变量、‌操作数栈、‌动态链接、‌返回地址等信息。

每个方法在执行时都会创建一个栈帧,并将其压入当前线程对应的JVM栈中。当方法执行完毕后,该栈帧会被弹出并销毁,JVM栈也相应地回收内存空间。‌

  • JVM栈帧的具体功能和结构如下:
    • ‌局部变量表‌:存储方法中定义的局部变量,包括基本类型、对象引用等。每个线程的局部变量表是独立的,因此局部变量是线程安全的。‌
    • ‌操作数栈‌:用于进行方法的计算操作,如加减乘除等。‌
    • ‌动态链接‌:用于链接到其他类或方法的运行时常量池。
    • ‌返回地址‌:用于指示方法的返回位置。‌

此外,JVM栈通过创建和销毁栈帧的方式,实现了方法之间的嵌套调用和返回结果的传递,保护了内存安全。

如果JVM栈的深度超过了预设的阈值,或者当前线程所需要的栈空间已经超过了剩余的可用空间,那么JVM就会抛出StackOverflowError异常,从而保护了整个程序的安全性。

image-20230615110155071

3.2.2 StackOverflowError

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

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

演示代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// -Xss256k:将每个线程的栈大小设置为256KB。
// -Xss1m:将每个线程的栈大小设置为1MB。
// -Xss2m:将每个线程的栈大小设置为2MB。
// 可以根据具体需求来调整栈内存大小。需要注意的是,栈内存的大小应该根据应用程序的需求和系统资源情况进行合理的设置。过小的栈容量可能导致StackOverflowError异常,而过大的栈容量可能导致系统资源的浪费。
public class StackDemo {

// 记录调用了多少次出现了栈内存溢出
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方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

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

1
2
3
4
5
6
7
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

1
-Xlog:gc*

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

1
2
3
4
5
6
[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内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 + "次"); // 输出程序执行的次数
}
}
}
1
2
3
4
5
6
7
8
9
java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3512)
at java.base/java.util.Arrays.copyOf(Arrays.java:3481)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:244)
at java.base/java.util.ArrayList.add(ArrayList.java:454)
at java.base/java.util.ArrayList.add(ArrayList.java:467)
at com.atguigu.jvm.HeapDemo01.main(HeapDemo01.java:11)
总共执行了:157704907次

3.4.2 堆内存大小设定

-XX:NewRatio=2

-XX:SurvivorRatio=8

-Xms512m

-Xmx1024m

-Xmn256m

1
2
3
4
5
6
-XX:NewRatio参数:该参数用于设置新生代和老年代的初始比例。例如,-XX:NewRatio=2表示新生代占堆内存的1/3,老年代占堆内存的2/3。
-XX:SurvivorRatio参数:该参数用于设置Eden区和Survivor区的初始比例。例如,-XX:SurvivorRatio=8表示Eden区占新生代的8/10,每个Survivor区占新生代的1/10。
设置堆的初始大小。例如,-Xms512m表示将堆的初始大小设置为512MB。
设置堆的最大大小。例如,-Xmx1024m表示将堆的最大大小设置为1GB。
设置新生代的大小。例如,-Xmn256m表示将新生代的大小设置为256MB。
通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

3.4.3 对象的分配过程

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

Minor(Young) 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,那么这些对象会被晋升到老年代内存区域。
  5. 大对象直接进入到老年代
1
2
3
4
年龄最多到15的对象会被移动到年老代中,没有达到阈值的对象会被复到“To”区域。

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

(1)案例演示

jvm参数设置:

1
2
3
4
5
6
7
8
-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的数组,查看内存分配情况
1
2
3
// 创建一个4M大小的数组
private static final int _4MB = 4 * 1024 * 1024;
byte[] bytes = new byte[_4MB] ;

image-20240523134759241

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

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

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

  • 案例:直接分配一个8M的内存空间
1
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.5.1 方法区概述

方法区是被所有线程共享,它存储了已被虚拟机加载的类信息、方法信息、字段信息、常量(final修饰)、静态变量、即时编译器编译后的代码缓存等。

image-20240702212653950

3.5.2 方法区演进

‌方法区‌是‌JVM规范中定义的一个内存区域。

在HotSpot虚拟机中,JDK 7之前的方法区由永久代实现,永久代位于JVM的堆内存中;

JDK 8及以后,永久代被移除,元空间被引入作为替代。元空间使用本地内存,不再受JVM内存大小的限制,减少了内存溢出的风险。

永久代满了会抛出OutOfMemoryError: PermGen space;

而元空间满了会抛出OutOfMemoryError: Metaspace。

元空间的引入减少了内存溢出的风险,因为它可以动态地扩展和收缩。

永久代的回收效率较低,而元空间的回收效率较高,因为它使用本地内存而不是JVM内存。‌

参数控制:

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

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

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

image-20240702210750891

3.5.3 变化的原因

1、元空间使用的是直接内存(jvm之外的本地内存),受本机可用内存的限制,降低内存溢出的概率。

2、可以加载更多的类。

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

4 执行引擎

4.1 执行引擎概述

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

image-20230615151955474

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

  • 解释器(Interpreter)

    作用:逐行读取并执行字节码。

    工作原理:

    • 解释器读取程序计数器(PC)指定的字节码指令。
    • 将字节码指令翻译成相应的机器码指令,并立即执行。
    • 执行完一条指令后,更新程序计数器以指向下一条字节码指令。

    解释器的优点是启动速度快,缺点是执行效率较低,因为每次都需要逐行翻译字节码。

  • 即时编译器(JIT Compiler)
    作用:将字节码编译成高效的本地机器码,提高执行效率。

    工作原理:

    • 当某些方法或代码段被多次执行时,JIT编译器将这些热点代码(HotSpot Code)编译成本地机器码。
    • 编译后的本地代码被缓存起来,以便后续直接执行,无需再次解释。
    • JIT编译器还会进行各种优化,例如方法内联(Inlining)、循环展开(Loop Unrolling)等,以进一步提高执行性能。
1
2
将被调用的方法的代码直接嵌入到调用者的方法中,减少方法调用的开销。
循环展开是一种优化,它将循环体复制多次以减少循环开销。
  • 垃圾回收器(Garbage Collector)
    作用:管理内存,自动回收不再使用的对象,防止内存泄漏。

    工作原理:

    • 在程序运行期间,垃圾回收器不断地监视对象的生命周期。
    • 当检测到某些对象不再被引用时,回收这些对象所占用的内存。
    • 垃圾回收策略和算法有多种,如标记-清除(Mark-Sweep)、复制算法(Copying)、标记-整理(Mark-Compact)等。

4.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、方法区中常量引用的对象

4.3 垃圾回收算法

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

4.3.1 标记清除

执行过程:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。当然了,反过来也是可以的,标记存活的对象,统一回收所有未被标记的对象。

image-20230615153126114

优点:速度比较快。

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

4.3.2 标记整理

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

image-20230615153421545

优点:无内存碎片。

缺点:效率较低。

4.3.3 复制

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

image-20230615153506135

优点:无内存碎片

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

4.3.4 分代

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

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

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

4.4 四种引用类型

4.4.1 强引用

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

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

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

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

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 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 软引用

内存够,软引用对象不会被回收;

内存不够,软引用对象会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 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开始GC,弱引用关联的对象都会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 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);
}

}
}

打印全是null。

4.4.4 虚引用

如果一个对象仅持有虚引用,它就和没有任何引用一样,它随时可能会被回收。

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

特点:

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

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

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

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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垃圾收集器可以作用于新生代和老年代; 连线表示垃圾收集器可以搭配使用。

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

1
2
3
4
5
# 查看进程id (先启动一个java项目)
jps -l

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

image-20240704090939245

或者:

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

image-20240704085424365

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

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

参数控制:

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

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

    image-20230615155517135

4.5.4 Parallel

Parallel 收集器 类似 ParNew 收集器,多线程 并行 收集。

更关注吞吐量(吞吐量优先),是JDK8默认的垃圾收集器。

新生代 复制算法、老年代标记整理算法(Parallel Old是Parallel的老年代版本)。

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

image-20240704095452059

image-20240704095740044

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

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

  • 参数

    -XX:+UseParallelGC 新生代启用Parallel GC

    -XX:+UseParallelOldGC 新生代和老年代都启用Parallel GC

    -XX:ParallelGCThreads=<N> 设置用于垃圾收集的线程数

    -XX:GCTimeRatio=<N> 设置垃圾收集时间占程序运行时间的比例,默认为99,即1%的时间用于GC。

4.5.5 CMS收集器

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

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器(响应时间优先)。

CMS收集器主要用于要求低延迟(即:提高响应速度)的互联网项目。

  • CMS垃圾收集的过程:
1
2
3
4
5
6
7
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

  • 参数控制:

    -XX:+UseConcMarkSweepGC 老年代开启CMS

    -XX:MaxGCPauseMillis=200 设置GC暂停等待时间,单位为毫秒

    -XX:UseCMSCompactAtFullCollection 可以让 JVM 在执行标记清除完成后再做整理,避免内存碎片

    -XX:ConcGCThreads 并发的 GC 线程数

4.5.6 G1收集器

4.5.6.1 G1收集器简介

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

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

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

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

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

  • G1将所有Region分为四种类型:Eden、Survivor、Old、Humongous。Region的类型是动态变化的,可能之前是年轻代,经过了垃圾回收之后就变成了老年代。

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

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

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

image-20230615161037545

4.5.6.2 参数控制

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

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

3、-XX:MaxGCPauseMillis:设置期望的最大GC停顿时间指标

4、-XX:ParallelGCThreads:设置并行垃圾回收的线程数

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采用

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

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

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

5 线上问题定位

5.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飙高的位置。

5.2 死锁问题排查

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

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

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

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

5.3 OOM异常

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

1
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