JVM入门

本文的目的就是认识JVM

认识JVM要从什么方面开始入手呢?我们可以先试着问问自己,如果没有JVM会怎么样?为社么要有JVM?

接下来,我就先从Java的跨平台特性开始说起

Java的跨平台特性

Java设计的初衷就是为了解决一个问题:程序员编写一次程序,就可以在任何提供Java运行时环境的机器上面运行

也就是Java语言的跨平台特性:一次编译,到处运行

跨平台问题的说明:

任何一个高级编程语言编写的程序,在最终执行前,都会被翻译成计算机可以理解的语言机器码,也就是诸如0101的二进制数。

机器码由操作码和地址码组成,这些机器码就是指令的实际表示,每一条指令明确规定了计算机要进行什么操作,从哪一个地址取数,然后送到什么地址等操作。对于同一个操作,在不同的平台,指令可能是不一样的

例如,“将数据从内存加载到寄存器”的操作,在x86架构和ARM架构中,指令就不一样:

  • 在x86架构中,可能会使用MOV指令将数据加载到寄存器EAX中:

    MOV EAX,[0x12345678]
    
  • 在ARM架构中,可能会使用LDR指令将数据加载到寄存器R0中:

    LDR R0,[0x12345678]
    

所以,在一个平台上面编写的程序要在其他平台上面运行,就需要重新编译,甚至是重写,这就是跨平台问题存在的根本原因。

Java之所以能够实现跨平台特性,离不开JVM的支持,接下来正式讲解JVM。

JVM基本介绍

JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。

JVM是JRE的一部分,安装了JRE就相当于安装了JVM,可以运行Java程序

JVM本质上是一个运行在计算机上面的程序,它的职责是运行Java字节码文件

Java虚拟机(JVM)是一个抽象的计算平台,能够在各种操作系统上提供Java程序的运行环境,而不需要程序员考虑底层系统的架构和具体细节

img

Java源代码的执行流程如下

  1. 编写Java源代码文件
  2. 使用Java编译器(javac命令)把源代码翻译成为Java字节码文件(字节码文件和平台无关)
  3. JVM把字节码文件翻译成对应系统的机器码,然后运行

JVM是运行在具体操作系统之上的虚拟计算机,它和硬件没有直接的交互。当JVM在不同的操作系统上运行时,它会将Java字节码文件翻译为该特定平台的机器指令,从而执行程序。这样,只要在不同平台上安装了相应版本的JVM,Java程序就可以在这些平台上运行,实现了“一次编写,到处运行”。

image-20240812041752196

JVM规范定义了一套标准的字节码指令集、数据类型和架构,使得Java应用程序可以在任何遵循规范的JVM实现上运行。这种标准化使得Java具有极高的可移植性,不论是Windows、Linux还是macOS系统,只要安装了对应的JVM,Java程序便能正常运行。

JVM的功能

  1. 解释和运行
  2. 内存管理
  3. 即时编译

解释和运行

对字节码文件中的指令,实时的解释成机器码,让计算机执行。

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

img

内存管理

  • 自动内存分配和释放:与传统编程语言需要程序员手动管理内存不同,JVM完全自动化内存管理过程。它负责为对象、方法分配内存,并且可以自动回收不再使用的对象。
  • 垃圾回收机制:JVM中的垃圾回收器定期扫描堆内存,识别并清除不再使用的对象。这个机制有效防止了内存泄漏,确保系统稳定运行。

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

即时编译

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

  • 即时编译(JIT):为了提高程序的执行效率,JVM采用即时编译技术,**将频繁执行的字节码转换为本地机器码,**从而提高程序的运行速度。
  • 动态优化:JVM还能根据程序的实际运行情况动态调整和优化其执行路径,进一步提升性能
Java性能低和跨平台特性的主要原因

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

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

img

C和C++语言在执行过程中,只需要将源代码编译成可执行文件,就包含了计算机能识别的机器码,无需在运行过程中再实时地解释,所以性能较高。

img

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

img

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

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

img

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

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

常见JVM

Java虚拟机规范

  • 《Java虚拟机规范》由Oracle制定,内容主要包含了Java虚拟机在设计和实现时需要遵守的规范,主要包含class字节码文件的定义、类和接口的加载和初始化、指令集等内容。
  • 《Java虚拟机规范》是对虚拟机设计的要求,而不是对Java设计的要求,也就是说虚拟机可以运行在其他的语言比如Groovy、Scala生成的class字节码文件之上。
  • 官网地址:https://docs.oracle.com/javase/specs/index.html

常见JVM

  • JCP组织(Java Community Process 开放的国际组织 ):Hotspot虚拟机(Open JDK版),sun2006年开源
  • Oracle:Hotspot虚拟机(Oracle JDK版),闭源,允许个人使用,商用收费
  • BEA:JRockit虚拟机
  • IBM:J9虚拟机
  • 阿里巴巴:Dragonwell JDK(龙井虚拟机),电商物流金融等领域,高性能要求。

平时我们最常用的,就是Hotspot虚拟机。

名称作者支持版本社区活跃度(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特性支持微服务、云原生架构

**JVM的作用:**加载并执行Java字节码文件(.class) - 加载字节码文件、分配内存(运行时数据区)、运行程序

**JVM的特点:**一次编译到处运行、自动内存管理、自动垃圾回收

结构图

运行时数据区

  • 类加载器子系统:将字节码文件(.class)加载到内存中的方法区
  • 运行时数据区:JVM管理的内存,创建出来的对象、类的信息等内容都会放在这块区域。
    • 方法区:存储已被虚拟机加载的类的元数据信息(元空间)。也就是存储字节码信息。
    • 堆:存放对象实例,几乎所有的对象实例都在这里分配内存。
    • 虚拟机栈(java栈):虚拟机栈描述的是Java方法执行的内存模型。每个方法被执行的时候都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息
    • 程序计数器:当前线程所执行的字节码的行号指示器
    • 本地方法栈:本地方法栈则是记录虚拟机当前使用到的native方法
  • 本地方法接口:
    • 虚拟机使用到的native类型的方法,负责调用操作系统类库。(例如Thread类中有很多Native方法的调用)
    • 调用本地使用C/C++编译好的方法,本地方法在Java中声明时,都会带上native关键字
  • 执行引擎:包含解释器、即时编译器和垃圾收集器 ,执行引擎使用解释器把字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾收集器回收不再使用的对象。

总结:

JVM的底层结构

  • 内存组成:运行时数据区
    • 线程共享:方法区,堆
    • 线程私有:虚拟机栈,本地方法栈,程序计数器

注意:

  • 多线程共享方法区和堆;
  • Java栈、本地方法栈、程序计数器是每个线程私有的。

执行引擎Execution Engine

Execution Engine执行引擎负责解释命令,提交操作系统执行。

JVM执行引擎通常由两个主要组成部分构成:解释器和即时编译器(Just-In-Time Compiler,JIT Compiler)。

  1. 解释器:当Java字节码被加载到内存中时,解释器逐条解析和执行字节码指令。解释器逐条执行字节码,将每条指令转换为对应平台上的本地机器指令。由于解释器逐条解析执行,因此执行速度相对较慢。但解释器具有优点,即可立即执行字节码,无需等待编译过程。
  2. 即时编译器(JIT Compiler):为了提高执行速度,JVM还使用即时编译器。即时编译器将字节码动态地编译为本地机器码,以便直接在底层硬件上执行。即时编译器根据运行时的性能数据和优化技术,对经常执行的热点代码进行优化,从而提高程序的性能。即时编译器可以将经过优化的代码缓存起来,以便下次再次执行时直接使用。

image-20240328144553219

JVM执行引擎还包括其他一些重要的组件,如即时编译器后端、垃圾回收器、线程管理器等。这些组件共同协作,使得Java程序能够在不同的操作系统和硬件平台上运行,并且具备良好的性能。

解释器:逐行解释字节码成机器指令,并且执行,在执行的过程中会统计指令的调用次数。如果指令的调用次数到达阈值,就会触发即时编译器。

即时编译器(Just In Time Compiler):当指令解释并执行的次数到达阈值的时候,指令会被升级为热点代码,即时编译器会堆热点代码进行编译,编译以后的指令会被放到JIT代码缓存,下一次执行程序的时候,执行引擎会直接从JIT代码缓存中读取机器指令并执行

前端编译器:把Java程序编译成class字节码文件

后端编译器:即时编译器

程序计数器:指向下一条指令的地址

本地方法接口Native Interface

本地接口的作用是融合不同的编程语言为 Java 所用,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。

例如Thread类中有一些标记为native的方法:

image-20240812051304471

Native Method Stack

本地方法栈存储了从Java代码中调用本地方法时所需的信息。是线程私有的。

PC寄存器(程序计数器)

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,即 将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

Java 虚拟机规范中这样定义:

the pc register contains the address of the Java Virtual Machine instruction currently being executed.

字节码文件

字节码文件保存了源代码编译以后的内容,以二进制的方式存储,不可以直接用记事本打开阅读

推荐使用 jclasslib工具查看字节码文件

img

字节码文件的组成

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

  • 基础信息:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息
  • 常量池:保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
  • 字段: 当前类或接口声明的字段信息
  • 方法: 当前类或接口声明的方法信息,核心内容为方法的字节码指令
  • 属性: 类的属性,比如源码的文件名、内部类的列表等

基础信息

基本信息包含了jclasslib中能看到的两块内容:

img

Magic魔数

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

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

img

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

主副版本号

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

1.2之后大版本号计算方法就是 : 主版本号 – 44,比如主版本号52就是JDK8。

image-20240812210257889

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

img

有两种方案:

1.升级JDK版本,将图中使用的JDK6升级至JDK8即可正常运行,容易引发其他的兼容性问题,并且需要大量的测试。

2.将第三方依赖的版本号降低或者更换依赖,以满足JDK版本的要求。建议使用这种方案

其他基础信息

其他基础信息包括访问标识、类和接口索引,如下:

img

常量池

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

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

package com.pkq.demo1;

/**
 * ClassName: ConstantsDemo
 * Package: com.pkq.demo1
 * Description:
 *
 * @Author pkq
 * @Create 2024-08-12 21:11
 * @Version 1.0
 */
public class ConstantsDemo {
    public static final String s1="皮卡丘";
    public static final String s2="皮卡丘";

    public static void main(String[] args) {
        ConstantsDemo constantsDemo = new ConstantsDemo();
        System.out.println(s1);
        System.out.println(s2);
    }
}

image-20240812212222860

我们可以看出,字符串常量的索引是5,点击索引5,我们可以找到字符串常量在常量池存储的位置是索引30,在常量池30我们就可以看到这个常量了

常量池中的数据都有一个编号,编号从1开始。

image-20240812212412538image-20240812212427054

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

image-20240812213339582

属性

image-20240812220741995属性主要指的是类的属性,比如源码的文件名、内部类的列表等。

字段

字段中存放的是当前类或接口声明的字段信息。

如下图中,定义了两个字段a和b,这两个字段就会出现在字段这部分内容中。同时还包含字段的名字、描述符(字段的类型)、访问标识(public/private static final等)。

package com.pkq.demo1;

/**
 * ClassName: FileldTest
 * Package: com.pkq.demo1
 * Description:
 *
 * @Author pkq
 * @Create 2024-08-12 21:38
 * @Version 1.0
 */
public class FileldTest {
    private static  final int a=5;
    public int b=1;

    public static void main(String[] args) {

    }
}

image-20240812214127038image-20240812214144865

方法

字节码文件中的方法区是存放字节码指令的核心位置,字节码指令的内容存储在方法的Code属性

img

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

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

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

img

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

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

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

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

img

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

img

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

img

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

img

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

img

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

img

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

img

同理,我们可以自行分析下i++和++i的字节码指令执行的步骤。

i++的字节码指令如下,其中iinc 1 by 1指令指的是将局部变量表1号位置增加1,其实就实现了i++的操作。

img

而++i只是对两个字节码指令的顺序进行了更改:

img

面试题:

问:int i = 0; i = i++; 最终i的值是多少?

答:答案是0,我通过分析字节码指令发现,i++先把0取出来放入临时的操作数栈中,

接下来对i进行加1,i变成了1,最后再将之前保存的临时值0放入i,最后i就变成了0。

看字节码文件的工具

javap

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

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

 javap -v .\ConstantsDemo.class

image-20240812220927701

jclasslib插件

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

image-20240812221047868

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

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

Arthas

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

img

安装方法:

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

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

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

image-20240812221525485

4、输入命令即可使用。

dump

命令详解:https://arthas.aliyun.com/doc/dump.html

dump命令可以将字节码文件保存到本地,如下将java.lang.String 的字节码文件保存到了/tmp/output目录下:

$ dump -d /tmp/output java.lang.String

 HASHCODE  CLASSLOADER  LOCATION
 null                   /tmp/output/java/lang/String.class
Affect(row-cnt:1) cost in 138 ms.

jad

命令详解:https://arthas.aliyun.com/doc/jad.html

jad命令可以将类的字节码文件进行反编译成源代码,用于确认服务器上的字节码文件是否是最新的,如下将demo.MathGame的源代码进行了显示。

$ jad --source-only demo.MathGame
/*
 * Decompiled with CFR 0_132.
 */
package demo;

import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

public class MathGame {
    private static Random random = new Random();
    public int illegalArgumentCount = 0;
...

类加载器

jvm整体架构图文详解_jvm 架构图-CSDN博客

类加载器的作用

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

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

image-20231107023154483

简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中。

class文件在文件开头有的文件标识**(CA FE BA BE)**,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

类加载的过程(类的生命周期)

image-20240813000143520

Java-反射机制(超详解)_java反射机制-CSDN博客

简单回顾一下类加载过程。

  • 类加载过程:加载->链接->初始化->使用->卸载
  • 链接过程又可分为三步:验证->准备->解析

image-20240328145700517

加载是类加载过程的第一步,主要完成下面 3 件事情:

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

1562486960334

public class ClassLoaderDemo1 {

    public static void main(String[] args) {

        Car car = new Car();
        Class<? extends Car> aClass = car.getClass(); //由对象得到类
        ClassLoader classLoader = aClass.getClassLoader(); //由类得到类加载器
        System.out.println(car);
        System.out.println(classLoader); //Car的类加载器是AppClassLoader


        //数组类不是通过 ClassLoader 创建的
        int[] arr = {1,2,3};
        System.out.println(arr.getClass().getClassLoader());//null
        String[] s={"a","b","c"};
        System.out.println(s.getClass().getClassLoader());
    }
}

image-20240812144018477

从上面的介绍可以看出:

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
  • 每个 Java 类都有一个引用指向加载它的 ClassLoader
  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

类加载器分类

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

  1. 虚拟机底层实现:源代码位于Java虚拟机的源码中,实现语言和虚拟机底层语言一样,比如Hotspoot使用C++。主要目的是为了保证Java程序运行中的基础类被正确的加载,比如java.lang.String,Java虚拟机需要保证其可靠性。
  2. JDK默认提供或者自定义:JDK中默认提供了多种处理不同渠道的类加载器,程序员也可以自己根据需求定制,使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。

具体分为四种,前三种为虚拟机自带的加载器。

  1. 启动类加载器(BootstrapClassLoader)

    • 主要用来加载Java核心类库,比如java.lang包中的类,Object类就是由根类加载器加载的。它是虚拟机的一部分,通常由C++实现,不是Java类,所以在Java代码中无法直接获取对它的引用。
    • 默认加载jre\lib文件夹下面的jar包的class文件
  2. 扩展类加载器(ExtClassLoader/PlatformClassLoader)

    • 由Java实现,派生自ClassLoader类。主要用来加载Java的扩展类库
    • 负责加载jre\lib\ext⽂件夹下的jar包和class类。ext(extend)目录翻译过来就是扩展,也就是存放扩展的资料,不属于Java自带的,也就是程序要用到的那些jar包
    • image-20240812154146759
    • 如果把我们的jar文件放在这个目录,也会自动由扩展类加载器加载
  3. 应用程序类加载器(AppClassLoader)

    • 它的父加载器是扩展类加载器,由Java编写而成。

    • 也叫应用程序类路径加载器或者系统类加载器。由Java实现,派生自ClassLoader类。

    • 面向我们用户的加载器,是用户自定义的类加载器的默认父加载器。负责加载当前应用 classpath 下的所有 jar 包和类。

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

  4. 自定义加载器 :程序员可以定制类的加载方式,以满足自己的特殊需求。派生自ClassLoader类。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

Java 9之前的ClassLoader

  • Bootstrap ClassLoader加载$JAVA_HOME中jre/lib/rt.jar,加载JDK中的核心类库
  • ExtClassLoader加载相对次要、但又通用的类,主要包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包

Java 9及之后的ClassLoader

  • Bootstrap ClassLoader,使用了模块化设计,加载lib/modules启动时的基础模块类,java.base、java.management、java.xml

  • ExtClassLoader更名为PlatformClassLoader,使用了模块化设计,加载lib/modules中平台相关模块,如java.scripting、java.compiler。

  • image-20240812154716369

查看类加载器的层级关系:

public class ClassLoaderDemo2 {

    public static void main(String[] args) {

        ClassLoaderDemo2 demo2 = new ClassLoaderDemo2();
        
        //AppClassLoader
        System.out.println(demo2.getClass().getClassLoader()); 
        System.out.println(ClassLoader.getSystemClassLoader());
        
        //PlatformClassLoader
        System.out.println(demo2.getClass().getClassLoader().getParent()); 
        
        //null(BootstrapClassLoader)
        System.out.println(demo2.getClass().getClassLoader().getParent().getParent()); 
        
        //null(BootstrapClassLoader)
        String s = new String();
        System.out.println(s.getClass().getClassLoader()); 
    }
}

**注意,**这里的父子关系并不是代码中的extends的关系,而是逻辑上的父子。

我们可以看到第四个和第五个输出语句是null,原因是启动类加载器是用C语言写的,Java代码没法获得类加载器名称

image-20240812153632406

双亲委派模型

双亲委派模型

类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。

在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。

双亲委派模型

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上

  • 1、当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器PlatformClassLoader去完成。
  • 2、当PlatformClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器BootStrapClassLoader去完成。
  • 3、如果BootStrapClassLoader加载失败,会用PlatformClassLoader来尝试加载;
  • 4、若PlatformClassLoader也加载失败,则会使用AppClassLoader来加载
  • 5、如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

其实这就是所谓的双亲委派模型。简单来说:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上

目的:

一,性能,避免重复加载;

二,安全性,避免核心类被修改。

通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。而且双亲委派机制可以避免同一个类被多次加载。

双亲委派模型的源码分析

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoaderloadClass() 中,相关代码如下所示。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        //首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //当父类的加载器不为空,则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    //当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                //当父类加载器无法加载时,则调用findClass方法来加载该类
                //用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                //用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            //对类进行link操作
            resolveClass(c);
        }
        return c;
    }
}

总结双亲委派模型的执行流程

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

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

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

方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类。

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

打破双亲委派模型

**注意⚠️:**双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的

ClassLoader 类有两个关键的方法:

  • protected Class<?> loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resove 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

官方 API 文档中写到:

Subclasses of ClassLoader are encouraged to override findClass(String name), rather than this method.

建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。

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

三个面试题

1、如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?

启动类加载器加载,根据双亲委派机制,它的优先级是最高的

2、String类能覆盖吗,在自己的项目中去创建一个java.lang.String类,会被加载吗?

不能,会返回启动类加载器加载在rt.jar包中的String类。

3、类的双亲委派机制是什么?

  • 当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载。
  • 应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。
  • 双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。

方法区Method Area

方法区存储什么

方法区是被所有线程共享。《深入理解Java虚拟机》书中对方法区存储内容的经典描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等:

image-20220810032835274

方法区演进细节

Hotspot中方法区的变化:

image-20220810034317894

虚拟机栈stack

栈溢出

StackOverflowError:栈内存溢出,常见于递归调用无法结束

例如,如下代码:

public class StackRecurrenceDemo {

    public static void main(String[] args) {
        StackRecurrenceDemo.test();
    }

    public static void test(){
        test();
    }
}

通常在递归调用时出现,

image-20220811135813332

Stack 栈是什么?

  • 栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,每个线程都有自己的栈,它的生命周期是跟随线程的生命周期,线程结束栈内存也就释放,是线程私有的
  • 线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)

栈运行原理

public class StackDemo {

    public static void main(String[] args) {
        System.out.println("main()开始");
        StackDemo test = new StackDemo();
        test.method2();
        System.out.println("main()结束");
    }

    public void method2(){
        System.out.println("method2()执行...");
        this.method3();
        System.out.println("method2()结束...");
    }

    public void method3() {
        System.out.println("method3()执行...");
        this.method4();
        System.out.println("method3()结束...");
    }

    public void method4() {
        System.out.println("method4()执行...");
        System.out.println("method4()结束...");
    }
}
  • JVM对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”或者“后进先出”原则。
  • 一个线程中只能由一个正在执行的方法(当前方法),因此对应只会有一个活动的当前栈帧

image-20220810131723296

当一个方法1(main方法)被调用时就产生了一个栈帧1 并被压入到栈中,栈帧1位于栈底位置

方法1又调用了方法2,于是产生栈帧2 也被压入栈,

方法2又调用了方法3,于是产生栈帧3 也被压入栈,

……

执行完毕后,先弹出栈帧4,再弹出栈帧3,再弹出栈帧2,再弹出栈帧1,线程结束,栈释放。

栈存储什么?

栈中的数据都是以栈帧(Stack Frame)的格式存在。栈帧是一个内存区块,是一个数据集,包含方法执行过程中的各种数据信息。

栈中存储的是当前执行的方法的栈帧。

image-20220810133839557

局部变量表(Local Variables)

也叫本地变量表。

**作用:**存储方法参数和方法体内的局部变量:8种基本类型变量、对象引用(reference)。

可以用如下方式查看字节码中一个方法内定义的的局部变量,当程序运行时,这些局部变量会被加载到局部变量表中。

public class LocalVariableTableDemo {

    public static void main(String[] args) {
        int i = 100;
        String s = "hello";
        char c = 'c';
        Date date = new Date();
    }
}

查看局部变量:

  • 可以使用javap命令:
类路径> javap -v 类名.class

image-20220811142319027

  • 或者idea中的jclasslib插件:

image-20240102203012822

**注意:**以上方式看到的是加载到方法区中的字节码中的局部变量表,当程序运行时,局部变量表会被动态的加载到栈帧中的局部变量表中

操作数栈(Operand Stack)

**作用:**也是一个栈,在方法执行过程中根据字节码指令记录当前操作的数据,将它们入栈或出栈。用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间。

public class OperandStackDemo {

    public static void main(String[] args) {
        int i = 15;
        int j = 8;
        int k = i + j;
    }
}

image-20240328163805184

0: bipush 15   		//一个8位带符号整数15压入栈,虚拟机将15当作byte类型处理
2: istore_1			//将int类型值15存入局部变量1
3: bipush 8    		//一个8位带符号整数8压入栈,虚拟机将8当作byte类型处理
5: istore_2			//将int类型值8存入局部变量2
6: iload_1			//从局部变量1中装载int类型值15入栈
7: iload_2			//从局部变量2中装载int类型值8入栈
8: iadd				//执行int类型的加法,将操作数栈中的数据出栈并相加,得到结果23,然后将23入栈
9: istore_3			//将操作数栈中int类型值23存入局部变量3
10: return			//返回

image-20220811154245633

动态链接(Dynamic Linking)

**作用:**可以知道当前帧执行的是哪个方法。**指向运行时常量池中方法的符号引用。**程序真正执行时,类加载到内存中后,符号引用会换成直接引用。

//编译以下代码,并查看字节码
public class DynamicLinkingDemo {

    public void methodA(){
        methodB(); //方法A引用方法B
    }

    public void methodB(){

    }
}

image-20220811164717355

方法返回地址(Return Address)

**作用:**可以知道调用完当前方法后,上一层方法接着做什么,即“return”到什么位置去。存储当前方法调用完毕后下一条指令的地址。

image-20230903070857954

一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。

完整的内存结构图如下

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

设置栈的大小

在StackRecurrenceDemo中添加count变量:

public class StackRecurrenceDemo {

    private static long count = 0;
    public static void main(String[] args) {
        StackRecurrenceDemo.test();
    }

    public static void test(){
        System.out.println(count++);
        test();
    }
}

在idea中设置:

image-20240103012102800

在命令行中设置:

java -Xss1m YourClassName
完整的写法是:-XX:ThreadStackSize=1m  

单位可以使用m、k、默认字节
-Xss1m  
-Xss1024k
-Xss1048576

堆heap

JVM - 堆_jvm堆-CSDN博客

堆体系概述

堆、栈、方法区的关系

image-20220812031325678

HotSpot是使用指针的方式来访问对象:

  • 堆内存用于存放对象和数组
  • 堆中会存放指向对象类型数据的地址
  • 栈中会存放指向堆中的对象的地址

image-20240103070913978

堆空间概述

  • 一个Java程序运行起来对应一个进程,一个进程对应一个JVM实例,一个JVM实例中有一个运行时数据区。
  • 堆是Java内存管理的核心区域,在JVM启动的时候被创建,并且一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。

分代空间

堆空间划分

堆内存逻辑上分为三部分:

  • Young Generation Space 新生代/年轻代 Young/New
  • Tenured generation space 老年代/养老代 Old/Tenured
  • Permanent Space/Meta Space 永久代/元空间 Permanent/Meta
  1. 新生代:新生代主要用于存放新创建的对象,这个区域的内存通常会被频繁的回收,因为很多新创建的对象可能很快就不再使用了。
  2. 老年代:老年代主要存放长时间存活的对象。当对象在新生代中经历一定次数的垃圾回收以后仍然存活,它们就会被移动到老年代。老年代的内存回收频率通常比新生代低,但每一次的回收可能需要更长的时间。
  3. 永久代:永久代主要用于存放JVM加载的类信息、常量、静态变量等数据。这个区域的内存回收频率也相对较低,但在进行类卸载或系统需要释放内存时可能会进行回收。

新生代又划分为:

  • 新生代又分为两部分: 伊甸园区(Eden space)和幸存者区(Survivor pace) 。
  • 幸存者区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。From区和To区。

堆内存内部空间所占比例:

  • 新生代与老年代的默认比例: 1:2

  • 伊甸园区与幸存者区的默认比例是:8:1:1

JDK1.7及之前堆空间

image-20240813020547947

JDK1.8及之后堆空间

image-20240813020604441

注意:方法区(具体的实现是永久代和元空间)逻辑上是堆空间的一部分,但是 虚拟机的实现中将方法区和堆分开了,如下图:

image-20220812033018437

分代空间工作流程

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

  • 一类是生命周期较短的对象,创建在新生代,在新生代中被垃圾回收。
  • 一类是生命周期非常长的对象,创建在新生代,在老年代中被垃圾回收,甚至与JVM生命周期保持一致。
  • 几乎所有的对象创建在伊甸园区,绝大部分对象销毁在新生代,大对象直接进入老年代。

新生代

工作过程:

(1)新创建的对象先放在伊甸园区。

(2)当伊甸园的空间用完时,程序又需要创建新对象,此时,触发JVM的垃圾回收器对伊甸园区进行垃圾回收(Minor GC,也叫Young GC),将伊甸园区中不再被引用的对象销毁。

(3)然后将伊甸园区的剩余对象移动到空的幸存0区。

(4)此时,伊甸园区清空。

(5)被移到幸存者0区的对象上有一个年龄计数器,值是1。

image-20220812222234881

(6)然后再次将新对象放入伊甸园区。

(7)如果伊甸园区的空间再次用完,则再次触发垃圾回收,对伊甸园区和s0区进行垃圾回收,销毁不再引用的对象。

(8)此时s1区为空,然后将伊甸园区和s0区的剩余对象移动到空的s1区。

(9)此时,伊甸园区和s0区清空。

(10)从伊甸园区被移到s1区的对象上有一个年龄计数器,值是1。从s0区被移到s1区的对象上的年龄计数器+1,值是2。

img

(11)然后再次将新对象放入伊甸园区。如果再次经历垃圾回收,那么伊甸园区和s1区的剩余对象移动到s0区。对象上的年龄计数器+1。

(12)当对象上的年龄计数器达到15时(-XX:MaxTenuringThreshold),则晋升到老年代。

image-20220812225858105

总结:

  • 新对象创建在伊甸园区,当伊甸园区满的时候会触发新生代垃圾回收

  • 垃圾回收伊甸园区和from的对象,伊甸园区和from区剩余对象会被移动到to区

  • 针对幸存者s0,s1,复制(复制算法)之后有交换,谁空谁是to。from区和to区交换,当伊甸园区再次满时,触发新生代垃圾回收

  • 被移动到幸存者区的对象的年龄计数器达到15的时候,就会移动到老年代
  • 当老年代满时,会触发老年代垃圾回收和Full GC

老年代

经历多次Minor GC/Young GC仍然存在的对象(默认是15次)会被移入老年代,老年代的对象比较稳定,不会频繁的GC。

若老年代也满了,那么这个时候将产生Major GC(同时触发Full GC),进行老年代的垃圾回收。

若老年代执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常OutOfMemoryError

image-20220812234615119

Full GC(Full Garbage Collection)是Java虚拟机中进行垃圾回收的一种操作,其目标是清理整个Java堆内存,包括年轻代(Young Generation)、年老代(Old Generation或Tenured Generation)以及永久代(在Java 8及之前的版本中,而在Java 8及之后的版本中由Metaspace取代

永久代/元空间

永久代是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。

如果出现 java.lang.OutOfMemoryError:PermGen space/java.lang.OutOfMemoryError:Meta space,说明是Java虚拟机对永久代内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。

尽管方法区在逻辑上属于堆的一部分,对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

对于HotSpot虚拟机,很多开发者习惯将方法区称之为永久代 ,但严格说两者不同,或者说是使用永久代来实现方法区而已。

GC总结

  • 频繁回收新生代
  • 很少回收老年代
  • 几乎不在永久代/元空间回收

部分收集:

  • 年轻代收集(Minor GC / Young GC):伊甸园区 + 幸存者区垃圾收集
  • 老年代收集(Major GC / Old GC):老年代垃圾收集
  • 混合收集(Mixed GC):收集新生代以及老年代。G1垃圾收集器有这种方式

整堆收集(Full GC):

  • 新生代、老年代和方法区的垃圾收集

年轻代GC触发机制(Minor GC ):

年轻代的Eden空间不足,触发Minor GC。

每次Minor GC在清理Eden的同时会清理Survivor From区。

Minor GC非常频繁,回收速度块。

老年代GC触发机制(Major GC 和 Full GC ):

老年代空间不足,触发Major GC。

Major GC比Minor GC速度慢的多。

如果Major GC后,内存还不足,就报OOM。

Full GC触发机制:

Full GC(Full Garbage Collection)是Java虚拟机对堆内存中的所有对象进行全面回收的过程。Full GC的执行时机取决于Java虚拟机的实现和具体的垃圾回收策略。

一般情况下,Full GC发生的情况包括:

  1. 当堆内存空间不足以分配新对象时,会触发一次Full GC。这种情况下,Java虚拟机会先执行一次新生代的垃圾回收(Minor GC),如果仍然无法满足内存需求,则会执行Full GC。
  2. 在某些垃圾回收器中,当老年代空间不足以容纳晋升到老年代的对象时,会执行Full GC。这通常发生在长时间运行的应用程序中,随着对象的逐渐增加,老年代空间可能会变得不足。
  3. 手动调用System.gc()方法或Runtime.getRuntime().gc()方法可以触发Full GC。但值得注意的是,这只是建议Java虚拟机进行垃圾回收的请求,并不能保证立即执行Full GC。

需要注意的是,Full GC是一项资源密集型的操作,会导致应用程序的停顿时间增加**(Stop The World - STW)**,因为在Full GC期间,应用程序的线程会被挂起。因此,在设计和开发应用程序时,应尽量避免频繁触发Full GC,以减少对应用程序性能的影响。

JVM结构总结

image-20211002104543990

4、堆参数

  • -Xms表示堆的起始内存,等价于-XX:InitialHeapSize,默认是物理电脑内存的1/64。
  • -Xmx表示堆的最大内存,等价于-XX:MaxHeapSize,默认是物理电脑内存的1/4。
  • 最佳实现是把-Xms和-Xms设置成一致的值,这样可以避免服务器不断扩缩容内存,影响性能
  • -Xmn:新生代内存大小
  • -XX:SurvivorRatio=1:伊甸园区和幸存者区的比例

查看堆内存大小

/**
 * 查看堆内存大小
 */
public class HeapSpaceInitialDemo {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024;

        //起始内存
        System.out.println("-Xms : " + initialMemory + "K," + initialMemory / 1024 + "M");
        //最大内存
        System.out.println("-Xmx : " + maxMemory + "K," + maxMemory / 1024 + "M");
    }
}
  • -Xms表示堆的起始内存,等价于-XX:InitialHeapSize,默认是物理电脑内存的1/64。
  • -Xmx表示堆的最大内存,等价于-XX:MaxHeapSize,默认是物理电脑内存的1/4。

设置堆内存大小

  • -Xmn 表示新生代堆大小,等价于-XX:NewSize,默认新生代占堆的1/3空间,老年代占堆的2/3空间

使用下面的VM options参数启动HeapSpaceInitialDemo,

-Xms600m -Xmx600m -Xmn200m

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

OOM演示

OOM异常:

JVM启动时,为堆分配起始内存,当堆中数据超过-Xmx所指定的最大内存时,将会抛出java.lang.OutOfMemoryError: Java heap space 异常,此时说明Java虚拟机堆内存不够。

原因有二:

(1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。

(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

OOM演示:

/**
 * -Xms30m -Xmx30m
 */
public class OOMDemo {

    public static void main(String[] args) throws InterruptedException {
        ArrayList<byte[]> list = new ArrayList<>();
        while(true){

            System.out.print("最大堆大小:Xmx=");
            System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");

            System.out.print("剩余堆大小:free mem=");
            System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");

            System.out.print("当前堆大小:total mem=");
            System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");

            list.add(new byte[1024*1024]);

            Thread.sleep(100);
        }
    }
}

Java VisualVM

生成dump文件

生产环境下,需要将项目中的OOM相关信息存储下来,方便后期排错。这里我们将输出dump文件:

**在D盘创建tmp目录,设置JVM启动参数:**启动OOMDemo

-Xms60m -Xmx60m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\tmp 

**执行程序:**生成D:\tmp\java_pidxxxxx.hprof文件

image-20220812020132226

查看dump文件

生成的这个文件怎么打开?jdk自带了该类型文件的解读工具:jvisualvm.exe

image-20220812020510423

双击打开:

image-20220812020759326

选择:文件 => 装入 => 选择要打开的文件

image-20220812021039240

装入后:

image-20220812021211710

分析运行中的程序

OOMDemo

image-20220812025838235

GC演示

/**
 * -Xms600m -Xmx600m -XX:SurvivorRatio=1
 */
public class HeapInstanceTest {

    byte[] buffer = new byte[new Random().nextInt(1024 * 20)];

    public static void main(String[] args) {

        ArrayList<HeapInstanceTest> list = new ArrayList<>();
        while (true) {
            list.add(new HeapInstanceTest());
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在Java Visual中安装Visual GC插件:

image-20231108063518971

监控程序:

image-20220813010045663

JDK自带工具

常用命令行(了解)

查看java进程:jps -l

查看某个java进程所有参数:jinfo 进程号

查看某个java进程总结性垃圾回收统计:jstat -gc 进程号

参数说明:

S0C:第一个幸存区的大小
S1C:第二个幸存区的大小
S0U:第一个幸存区的使用大小
S1U:第二个幸存区的使用大小
EC:伊甸园区的大小
EU:伊甸园区的使用大小
OC:老年代大小
OU:老年代使用大小
MC:方法区大小
MU:方法区使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间

垃圾回收GC

内存管理

C/C++的内存管理

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

内存泄漏。我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收

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

#include "Test.h"
init main(){
    while(true){
        Test* test = new Test();
        delete test; //手动回收内存
    }
    return 0;
}

Java的内存管理

Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃

圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对上的内存进行回收。其他

很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。

1562578551813

线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。因此线程不共享的程序计数器、虚拟机栈、本地方法栈中没有垃圾回收。

2、方法区的垃圾回收

类的生命周期

  • 加载
  • 连接
    • 验证:验证内容是否满足《Java虚拟机规范》
    • 准备:给静态变量赋初值
    • 解析:将常量池中的符号引用替换成指向内存的直接引用
  • 初始化
  • 使用
  • 卸载:在方法区中的类是如何进行垃圾回收的

方法区回收

方法区中能回收的内容主要就是不再使用的类。判定一个类可以被卸载。需要同时满足下面三个条件:

1、此类所有实例对象没有在任何地方被引用,在堆中不存在任何该类的实例对象以及子类对象。

Car car = new Car();
car = null;

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

Car car = new Car(); 
Class<? extends Car> aClass = car.getClass(); 
car = null;
aClass = null;

3、加载该类的类加载器没有在任何地方被引用。

Car car = new Car(); 
Class<? extends Car> aClass = car.getClass(); 
ClassLoader classLoader = aClass.getClassLoader();

car = null;
aClass = null;
classLoader = null;

**总结:**方法区的回收通常情况下很少发生,但是如果通过自定义类加载器加载特定的少数的类,那么可以在程序中释放自定义类加载器的引用,卸载当前类,当前对象置空,垃圾回收会对这部分内容进行回收

如果需要手动触发垃圾回收,可以调用System.gc()方法。

注意事项:

调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。

垃圾判定-对象已死?

如何判断堆上的对象可以回收?

Java中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。

public class Demo {
	public static void main(String[]args){
		Demo demo = new Demo();
        demo = null;        
    }     
}

第一行代码执行之后,堆上创建了Demo类的实例对象,同时栈上保存局部变量引用堆上的对象。

img

第二行代码执行之后,局部变量对堆上的对象引用去掉,那么堆上的对象就可以被回收了。

img

特殊情况:循环引用的对象也可以被回收

public class ReferenceCounting{
    public static void main(String[]args){
    	A a1 = new A();
    	B b1 = new B();
    	a1.b = b1;
    	b1.a = a1;
        
        a1 = null;
        b1 = null;
    }
}


class A{
	B b;
}

class B{
	A a;
}

img

这个案例中,如果要让对象a和b回收,必须将局部变量到堆上的引用去除。

img

那么问题来了,A和B互相之间的引用需要去除吗?答案是不需要,因为局部变量都没引用这两个对象了,在代码中已经无法访问这两个对象,即便他们之间互相有引用关系,也不影响对象的回收。

如何判断堆上的对象没有被引用?

常见的有两种判断方法:引用计数法和可达性分析法。

引用计数法(Reference-Counting)

引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收。

基本思路:

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;

任何时刻计数器为0的对象就是不可能再被使用的。

优点:

  • 简单,高效,现在的objective-c、python等用的就是这种算法。

缺点:

  • 引用和取消引用伴随着加减算法,都需要维护引用计数器,影响性能
  • 很难处理循环引用,相互引用的两个对象则无法释放。

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

Java中循环引用不会导致内存泄漏,因为Java虚拟机根本没有使用引用计数法。

因此目前主流的Java虚拟机都摒弃掉了这种算法

可达性分析算法

实现简单,执行高效,解决引用计数算法中循环引用的问题,是Java和C#选择的算法。

基本思路:

可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系

将一系列GC Root的集合作为起始点,按照从上至下的方式搜索所有能够被该合集引用到的对象(是否可达),并将其加入到该和集中,这个过程称之为标记(mark),被标记的对象是存活对象。 最终,未被探索到的对象便是死亡的,是可以回收的,标记为垃圾对象。

当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象 不可达)时,证明此对象是不可用的

image-20220813065729563

对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为 可回收对象。

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

  1. 静态变量(Static Variables):静态变量属于类,而不是实例对象。它们在整个程序执行期间都存在,并且被认为是 GC Root 对象。
  2. 活动线程(Active Threads):正在运行的线程也被视为 GC Root 对象。因为线程是程序执行的控制流,如果一个线程还在运行,那么它引用的对象也应该被保留。
  3. 栈帧(Stack Frames)中的局部变量和输入参数:栈帧中的局部变量和输入参数也是 GC Root 对象。它们在方法调用期间创建,并且随着方法的结束而销毁。
  4. JNI 引用(JNI References):通过 Java Native Interface (JNI) 在 Java 代码和本地代码之间传递的对象也被视为 GC Root 对象。这些对象的生命周期由本地代码管理。

垃圾回收算法-清除已死对象

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收。

在介绍JVM垃圾回收算法前,先介绍一个概念:Stop-the-World:

Stop-the-world意味着 JVM由于要执行GC而停止了应用程序的执行,并且这种情形会在任何一种GC算法中发生。

当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成

事实上,GC优化很多时候就是指减少Stop-the-world发生的时间,从而使系统具有高吞吐 、低停顿的特点。

吞吐量

吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即

吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。

比如:虚拟机总共运行了 100 分钟,其中GC花掉 1 分钟,那么吞吐量就是 99%

image-20240105125308549

标记清除(Mark-Sweep)

标记-清除算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。正如名字一样,算法分为2个阶段

(1)**标记:**使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。把所有存活的对象进行标记。

(2)**清除:**对堆内存从头到尾进行线性便遍历,如果发现某个对象没有被标记为可达对象,则将其回收。

img

缺点:

  • 效率问题(两次遍历)

  • 空间问题(标记清除后会产生大量不连续的碎片。JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。)

缺点:1.碎片化问题

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

2.分配速度慢。

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

复制算法(Copying)

核心思想:

1.将堆内存分割成两块空间,From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。

2.GC阶段开始,将GC Root搬运到To空间

3.将GC Root关联的对象,搬运到To空间

4.清理From空间,并把名称互换

img

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

对象A首先分配在From空间:

img

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

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

img

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

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

img

完整的复制算法的例子:

1.将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。

img

2.GC阶段开始,将GC Root搬运到To空间

img

3.将GC Root关联的对象,搬运到To空间

img

4.清理From空间,并把名称互换

img

优点:

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

缺点:

  • **内存使用效率低,每次只能让一半的内存空间来为创建对象使用。**代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

  • 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

年轻代中使用的是Minor GC,这种GC算法采用的就是复制算法:

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。一般情况下,新创建的对象都会被分配到Eden区。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法。

标记压缩(Mark-Compact)

也叫标记整理算法。

标记整理算法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存

img

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

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

优点:

  1. 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
  2. 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
  3. 标记整理算法不仅可以弥补标记清除算法中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

缺点:

如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。

老年代一般是由标记清除或者是标记清除与标记整理的混合实现。

1562593217688

难道就没有一种最优算法吗?

回答:无,没有最好的算法,只有最合适的算法。==========>分代收集算法

分代收集算法(Generational-Collection)

内存效率:

复制算法 > 标记清除算法 > 标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

内存整齐度:

复制算法 > 标记整理算法 > 标记清除算法。

内存利用率:

标记整理算法=标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存

为了尽量兼顾上面所提到的三个指标,标记整理算法相对来说更平滑一些,但效率上依然不尽如人意。

比复制算法多了一个标记的阶段,又比标记清除多了一个整理内存的过程

分代回收算法实际上是复制算法和标记整理法、标记清除的结合,并不是真正一个新的算法。

一般分为老年代(Old Generation)和年轻代(Young Generation)

老年代就是很少垃圾需要进行回收的,年轻代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。

年轻代(Young Gen)

年轻代特点是区域相对老年代较小,对像存活率低。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenure Gen)

老年代的特点是区域较大,对像存活率高。

这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现

四种引用

创建一个User类:

public class User {

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

    public int id;
    public String name;

    @Override
    public String toString() {
        return "[id=" + id + ", name=" + name + "] ";
    }
}

强引用

只要有引用就不回收

只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

User user = new User(1, "zhangsan");

强引用(Strong Reference)是Java中最常见的引用类型,也是默认的引用类型。当一个对象被强引用指向时,垃圾回收器不会回收该对象。只有当没有任何强引用指向该对象时,该对象才会被视为垃圾,并可能被垃圾回收器回收。

强引用可以通过以下方式创建:

  1. 直接赋值:将一个对象直接赋值给一个变量,例如 Object obj = new Object();
  2. 数组元素:将对象存储在数组中,例如 Object[] array = new Object[10]; array[0] = new Object();
  3. 方法参数:将对象作为方法的参数传递,例如 void method(Object obj) { /* ... */ }
  4. 静态字段:将对象赋值给类的静态字段,例如 public static Object staticObj = new Object();
  5. 实例字段:将对象赋值给类的实例字段,例如 private Object instanceField;

需要注意的是,即使对象被强引用指向,如果程序不再需要该对象,也应该显式地将其设置为null,以便垃圾回收器可以更快地回收它。例如,obj = null;。这样做有助于避免内存泄漏和提高程序的性能。

  • 案例:
public class StrongReferenceTest {

    public static void main(String[] args) {
        //定义强引用
        User user = new User(1, "zhangsan");
        //定义强引用
        User user1 = user;

        //设置user为null,User对象不会被回收,因为依然被user1引用
        user = null;

        //强制垃圾回收
        System.gc();

         try {
             TimeUnit.SECONDS.sleep(1);
         } catch (InterruptedException e) {
             throw new RuntimeException(e);
         }
        System.out.println(user1);
    }
}

软引用

内存不足就回收

SoftReference 类实现软引用。在系统要发生内存溢出(OOM)之前,才会将这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。软引用可用来实现内存敏感的高速缓存

SoftReference<User> userSoftRef = new SoftReference<>(new User(1, "zhangsan"));
  • 案例:内存空间充足时,不会回收软引用的可达对象。注意测试环境设置为 -Xms10m -Xmx10m
//-Xms10m -Xmx10m
public class SoftReferenceTest {

    public static void main(String[] args) {
        //创建对象,建立软引用
        SoftReference<User> userSoftRef = new SoftReference<>(new User(1, "zhangsan"));

        //上面的一行代码,等价于如下的三行代码
        //User u1 = new User(1,"zhangsan");
        //SoftReference<User> userSoftRef = new SoftReference<>(u1);
        //u1 = null;//如果之前定义了强引用,则需要取消强引用,否则后期userSoftRef无法回收

        //软引用可以通过get()方法,获取到这个对象的强引用对象,可以直接使用
        System.out.println(userSoftRef.get());

        //内存不足测试:让系统认为内存资源紧张
        //测试环境: -Xms10m -Xmx10m
        try {
            //默认新生代占堆的1/3空间,老年代占堆的2/3空间,因此7m的内容在哪个空间都放不下
            byte[] b = new byte[1024 * 1024 * 7]; //7M
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {

            System.out.println("finally");

            //再次从软引用中获取数据
            //在报OOM之前,垃圾回收器会回收软引用的可达对象。
            System.out.println(userSoftRef.get());
        }
    }
}

弱引用

发现即回收

WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集(GC)之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。

WeakReference<User> userWeakRef = new WeakReference<>(new User(1, "zhangsan"));

弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。在JDK 1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。

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

  • 案例:
public class WeakReferenceTest {

    public static void main(String[] args) {
        //构造了弱引用
        WeakReference<User> userWeakRef = new WeakReference<>(new User(1, "zhangsan"));
        //从弱引用中重新获取对象
        System.out.println(userWeakRef.get());

        System.gc();
        
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        
        // 不管当前内存空间足够与否,都会回收它的内存
        System.out.println("After GC:");
        //重新尝试从弱引用中获取对象
        System.out.println(userWeakRef.get());//null
    }
}

虚引用

也叫幽灵引用、幻影引用

对象回收跟踪

PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。它主要用于执行一些清理操作或监视对象的回收状态。

ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<User> obj = new PhantomReference(new User(1, "tom"), phantomQueue);
  • 案例:
public class PhantomReferenceTest {

    public static void main(String[] args) {
        User obj = new User(1, "zhangsan");
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);

        obj = null; // 解除强引用

        // 在这里,对象可能已经被垃圾回收了,但我们无法通过虚引用获取它

        // 判断虚引用是否被回收
        boolean isCollected = false;
        while (!isCollected) {
            System.gc(); // 建议垃圾回收器执行回收操作
            try {
                Thread.sleep(1000); // 等待1秒钟
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (phantomRef.isEnqueued()) { //判断虚引用是否已经被回收。
                isCollected = true;
            }
        }

        // 输出虚引用是否被回收
        System.out.println("虚引用是否被回收:" + isCollected);
    }
}

在上面的示例中,我们创建了一个PhantomReference对象phantomRef,它引用了一个User实例obj。当我们解除obj的强引用后,obj将成为垃圾回收的候选对象。然后,我们通过调用System.gc()方法建议垃圾回收器执行回收操作,并等待一段时间以确保垃圾回收完成。最后,我们使用isEnqueued()方法判断虚引用是否已经被回收。

需要注意的是,由于垃圾回收操作的不确定性,虚引用的回收并不是立即发生的,所以程序中需要等待一段时间才能得出结论。另外,虚引用的主要作用是允许程序员在对象被回收之前进行一些清理操作,而不是直接获取对象的引用。

如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。

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

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

垃圾收集器

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

JVM中的默认垃圾收集器

查看JVM中默认的垃圾收集器

java -XX:+PrintCommandLineFlags -version

image-20231108162318639

  • JDK7:Parallel Scavenge + Serial Old
  • JDK8 及 JDK 7U40之后的版本:Parallel Scavenge + Parallel Old
  • JDK9+:G1

七款经典垃圾收集器

垃圾收集器与分代关系

经典的7种GC

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial Old、Parallel Old、CMS

整堆收集器:G1

垃圾回收器的组合

垃圾收集器组合

新生代GC和老年代GC搭配工作

  • JDK9中Serial GC 不再能够和 CMS GC一起使用
  • JDK9中ParNew GC 也不再和Serial Old GC 一起使用
  • JDK14中Parallel Scavenge 和 Serial Old GC 一起使用被废弃
  • JDK10中CMS GC被废弃
  • Serial Old GC 是 CMS GC的替补 。

6.3、Serial/Serial Old收集器

image-20240106015329216

串行收集器是最古老、最稳定的收集器,垃圾收集的过程中会Stop The World(服务暂停),产生较长停顿,只使用一个线程去回收。

工作过程:

新生代(Serial)使用复制算法、老年代(Serial Old)使用标记整理算法

参数控制:

-XX:+UseSerialGC 串行收集器(单线程收集器)

6.3、ParNew 收集器

image-20240106015439025

ParNew收集器收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The world、对象分配规则、回收策略等都与Serial收集器完全一样,实现上这两种收集器也共用了相当多的代码。

工作过程:

新生代并行,老年代串行;新生代使用复制算法、老年代使用标记整理算法。

参数控制:

-XX:+UseParNewGC ParNew收集器
-XX:ParallelGCThreads 限制线程数量

6.4、Parallel / Parallel Old 收集器

image-20240106015650387

Parallel收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例。

工作过程:

新生代使用复制算法、老年代使用标记整理算法。

参数控制:

-XX:+UseParallelGC 使用Parallel收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,主要用于在多线程或多核处理器环境中并行地进行垃圾回收,使用标记整理算法。这个收集器是在JDK 1.6中才开始提供

参数控制:

-XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行

6.5、CMS收集器(老年代)

image-20240106015238937

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于**“标记-清除”算法**实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。

  • 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
  • 并发标记阶段就是进行GC Roots Tracing的过程,从CG Roots直接能关联到的对象开始遍历整个树对象,这个过程耗时长,但不需要停顿用户线程
  • 而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清理:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,这个阶段也可以与用户线程并发。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

**优点: **

并发收集、低停顿
缺点:

产生大量空间碎片、并发阶段会降低吞吐量

参数控制:

-XX:+UseConcMarkSweepGC 使用CMS收集器
-XX:+UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

CMS是一种预处理垃圾回收器,它不能等到old内存用尽时回收,需要在内存用尽前,完成回收操作,否则会导致并发回收失败。

6.6、G1收集器(区域化分代式)

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

  1. 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。

  2. 分代收集:分代概念在G1中依然得以保留。虽然G1可以不需要其它收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。也就是说G1可以自己管理新生代和老年代了

  3. 空间整合:由于G1使用了独立区域(Region)概念,G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。

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

上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。

img

每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色,其中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。

为了避免全堆扫描,G1使用了Remembered Set来管理相关的对象引用信息。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏了。

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

1、初始标记(Initial Making)

2、并发标记(Concurrent Marking)

3、最终标记(Final Marking)

4、筛选回收(Live Data Counting and Evacuation)

看上去跟CMS收集器的运作过程有几分相似,不过确实也这样。初始阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以用的Region中创建新对象,这个阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这一阶段耗时较长但能与用户线程并发运行。而最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但可并行执行。最后筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这一过程同样是需要停顿线程的,但Sun公司透露这个阶段其实也可以做到并发,但考虑到停顿线程将大幅度提高收集效率,所以选择停顿。下图为G1收集器运行示意图:

img

整堆收集器: G1

回收器算法分类作用域是否多线程类型备注
Serial算法复制新生代单线程串行简单高效,不建议使用
ParNew算法复制新生代多线程并行唯一和CMS搭配的
新生代垃圾回收器
Parallel Scavenge算法复制新生代多线程并行更关注吞吐量
Serial Old标记-整理老年代单线程串行能和所有young gc搭配使用
Parallel Old标记-整理老年代多线程并行搭配Parllel Scavenge
CMS标记-清除老年代多线程并行追求最短的暂停

垃圾回收器选择策略 :

  • 客户端程序 : Serial + Serial Old;
  • 吞吐率优先的服务端程序(比如:计算密集型) : Parallel Scavenge + Parallel Old;
  • 响应时间优先的服务端程序 :ParNew + CMS。
  • G1收集器是基于标记整理算法实现的,不会产生空间碎片,可以精确地控制停顿,将堆划分为多个大小固定的独立区域,并跟踪这些区域的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(Garbage First)。

附录1:Java虚拟机规范

Java Platform, Standard Edition Documentation - Releases (oracle.com)

附录2:JVM参数配置参考

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐