JVM基础篇
基础篇
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 - 内存管理
- 3 - 即时编译
1.2.1 解释和运行
对字节码文件中的指令,实时的解释成机器码,让计算机执行。
字节码文件中包含了字节码指令,计算器无法直接执行,Java虚拟机会将字节码文件中的字节码指令实时地解释成机器码,机器码是计算机可以运行的指令。

1.2.2 内存管理
- 自动为对象、方法等分配内存
- 自动的垃圾回收机制,回收不再使用的对象
Java虚拟机会帮助程序员为对象分配内存,同时将不用的对象使用垃圾回收器回收掉,这是对比C和C++这些语言的一个优势。在C/C++语言中,对象的回收需要程序员手动去编写代码完成,如果遗漏了这段删除对象的代码,这个对象就会永远占用内存空间,不会再回收。所以JVM的这个功能降低了程序员编写代码的难度。
1.2.3 即时编译
对热点代码进行优化,提升执行效率。即时编译可以说是提升Java程序性能最核心的手段。
Java性能低的主要原因和跨平台特性
Java语言如果不做任何的优化,性能其实是不如C和C++语言的。主要原因是:
在程序运行过程中,Java虚拟机需要将字节码指令实时地解释成计算机能识别的机器码,这个过程在运行时可能会反复地执行,所以效率较低。

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

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

但是C/C++语言,如果要让程序在不同平台上运行,就需要将一份源代码在不同平台上分别进行编译,相对来说比较麻烦。
1.3常见的JVM
平时我们最常用的,就是Hotspot虚拟机。
| 名称 | 作者 | 支持版本 | 社区活跃度(github star) | 特性 | 适用场景 |
|---|---|---|---|---|---|
| HotSpot (Oracle JDK版) | Oracle | 所有版本 | 高(闭源) | 使用最广泛,稳定可靠,社区活跃JIT支持Oracle JDK默认虚拟机 | 默认 |
| HotSpot (Open JDK版) | Oracle | 所有版本 | 中(16.1k) | 同上开源,Open JDK默认虚拟机 | 默认对JDK有二次开发需求 |
| GraalVM | Oracle | 11, 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) | IBM | 8,11,17,19,20 | 低(3.1k) | 高性能、可扩展JIT、AOT特性支持 | 微服务、云原生架构 |
2.字节码文件详解
2.1 Java虚拟机的组成
Java虚拟机主要分为以下几个组成部分:

- 类加载子系统:核心组件类加载器,负责将字节码文件中的内容加载到内存中。
- 运行时数据区:JVM管理的内存,创建出来的对象、类的信息等等内容都会放在这块区域中。
- 执行引擎:包含了即时编译器、解释器、垃圾回收器,执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象。
- 本地接口:调用本地使用C/C++编译好的方法,本地方法在Java中声明时,都会带上native关键字,如下图所示。

2.2字节码文件的组成
以正确的姿势打开文件
字节码文件中保存了源代码编译之后的内容,以二进制方式存储,无法用记事本直接打开阅读。
可以通过NotePad++使用十六进制插件查看class文件:

无法解读出文件里包含的内容,推荐使用 jclasslib工具查看字节码文件。 Github地址: https://github.com/ingokegel/jclasslib
字节码文件总共可以分为以下几个部分:
- 基础信息:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息
- 常量池**:** 保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
- 字段: 当前类或接口声明的字段信息
- 方法: 当前类或接口声明的方法信息,核心内容为方法的字节码指令
- 属性: 类的属性,比如源码的文件名、内部类的列表等
1.基本信息
基本信息包含了jclasslib中能看到的两块内容:

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

Java字节码文件中,将文件头称为magic魔数。Java虚拟机会校验字节码文件的前四个字节是不是0xcafebabe,如果不是,该字节码文件就无法正常使用,Java虚拟机会抛出对应的错误。
主副版本号
主副版本号指的是编译字节码文件时使用的JDK版本号,主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46之后每升级一个大版本就加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。
1.2之后大版本号计算方法就是 : 主版本号 – 44,比如主版本号52就是JDK8。

版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。
报下面错误是兼容性出现问题。

方法1:升级IDEA编译的Jdk版本。(容易引发其它的兼容性问题)
方法2:改变依赖的版本,替换包名,降低版本。(工作中推荐选用该方法)
其他基础信息
其他基础信息包括访问标识、类和接口索引,如下:

2.**常量池:**保存了字符串常量,类或接口名,字段名主要在字节码指令中使用。
常量池中的数据都有一个编号,编号从1开始。比如“我爱北京天安门”这个字符串,在常量池中的编号就是7。在字段或者字节码指令中通过编号7可以快速的找到这个字符串。
字节码指令中通过编号引用到常量池的过程称之为符号引用。

3.**字段:**当前类或接口声明的字段信息。
如下图中,定义了两个字段a1和a2,这两个字段就会出现在字段这部分内容中。同时还包含字段的名字、描述符(字段的类型)、访问标识(public/private static final等)。

4.**方法:**当前类或接口声明的方法信息字节码指令。
字节码中的方法区域是存放字节码指令的核心位置,字节码指令的内容存放在方法的Code属性中。

1 | int i = 0; |
这段代码编译成字节码指令之后是如下内容:

我们需要先理解两块内存区域:操作数栈和局部变量表。
操作数栈是用来存放临时数据的内容,是一个栈式的结构,先进后出。
局部变量****表是存放方法中的局部变量,包含方法的参数、方法中定义的局部变量,在编译期就已经可以确定方法有多少个局部变量。
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语句执行,方法结束并返回。

i++与++i


问:int i = 0; i = i++; 最终i的值是多少?
答:答案是0,我通过分析字节码指令发现,i++先把0取出来放入临时的操作数栈中,
接下来对i进行加1,i变成了1,最后再将之前保存的临时值0放入i,最后i就变成了0。
5.**属性:**类的属性,比如源码的文件名内部类的列表等。

字节码文件常见工具使用
1.javap
javap是JDK自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。
直接输入javap查看所有参数。输入javap -v 字节码文件名称 查看具体的字节码信息。如果jar包需要先使用 jar –xvf 命令解压。
2jclasslib插件
3.Arthas
Arthas是一款线上监控诊断产品,通过全局视角实时查看应用的load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查的效率。
使用:
使用java -jar arthas-boot.jar 启动程序。
输入需要Arthas监控的进程id。

输入命令即可使用。
dashboard -i 2000 -n 3 :隔2秒,执行3次。

2.3类的生命周期加载阶段
类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为:
- 加载
- 连接,其中又分为验证、准备、解析三个子阶段
- 初始化
- 使用
- 卸载

2.3.1加载阶段
1.加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道((本地文件,通过网络传输的类,动态代理生成))以二进制流的方式获取字节码信息,程序员可以使用Java代码拓展的不同的渠道。
2.类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中。
3.生成一个InstanceKlass对象,保存类的所有信息,里面还包含实现特定功能比如多态的信息。

4.Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象。作用是在Java代码中获取类的信息以及存储静态字段的数据。(JDK8及之后)

2.3.2 连接阶段
连接阶段分为三个子阶段:
- 验证,验证内容是否满足《Java虚拟机规范》。
- 准备,给静态变量赋初值。
- 解析,将常量池中的符号引用替换成指向内存的直接引用。

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


major是主版本号,>=常量一般是45,对jdk1.8来说最高版本号是52,对jdk8只能支持45-52之间的主版本号。副版本号不能大于0
2、元信息验证,例如类必须有父类(super不能为空)。
3、验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置。
4、符号引用验证,例如是否访问了其他类中private的方法等。
2.准备:准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。
| 数据类型 | 初始值 |
|---|---|
| int | 0 |
| long | 0L |
| short | 0 |
| char | ‘\u0000’ |
| byte | 0 |
| boolean | false |
| double | 0.0 |
| 引用****数据类型 | null |


3.解析:将常量池中的符号引用替换成指向内存的直接引用。直接引用不再使用编号,而是使用内存中的地址进行访问具体的数据。
2.3.3初始化阶段
初始化阶段会执行字节码文件中clinit(class init 类的初始化)方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。

- init方法,会在对象初始化时执行
- main方法,主方法
- clinit方法,类的初始化阶段执行
关于init和clint的区别:
| 名称 | 全名 | 类型 | 什么时候执行 | 作用 |
|---|---|---|---|---|
<init> |
实例构造方法 | 对象方法 | new 一个对象时(构造器执行) |
初始化“对象” |
<clinit> |
类初始化方法 | 静态方法 | 第一次使用类时自动执行(仅一次) | 初始化“类级别的静态内容” |
<init> 是实例构造器方法,每次创建对象都会执行;而 <clinit> 是类初始化方法,只在类被第一次加载时执行一次,用来处理静态变量初始化和静态代码块。
1️⃣ <init>:构造方法(非静态)
- 对应 Java 中的构造函数(
public Demo2() {}) - 每次创建对象(
new Demo2())时执行一次 - 是 对象级别 的初始化方法
1 | public class Demo { |
编译后会生成 <init> 方法。
2️⃣ <clinit>:类初始化方法(静态)
- 不是你写的代码中显式的方法,而是编译器自动生成的
- 当类中包含:
- 静态变量赋值(如:
public static int i = 10;) - 或静态代码块(如:
static { System.out.println("xxx"); }) - 编译器会把这些放进一个叫
<clinit>的方法中
- 静态变量赋值(如:
- 这个方法在类加载时只执行一次
1 | public class Demo2 { |
编译后,会生成一个 <clinit> 方法,里面包含上面两个静态初始化语句。
继续来看clinit方法中的字节码指令:
1、iconst_1,将常量1放入操作数栈。此时栈中只有1这个数。

2、putstatic指令会将操作数栈上的数弹出来,并放入堆中静态变量的位置,字节码指令中#2指向了常量池中的静态变量value,在解析阶段会被替换成变量的地址。

3、后两步操作类似,执行value=2,将堆上的value赋值为2。
如果将代码的位置互换:
1 | public class Demo1 { |

这样初始化结束之后,最终value的值就变成了1而不是2。
以下几种方式会导致类的初始化:
- 1.访问一个类的静态变量或者静态方法(如果变量时final修饰的并且等号右边是常量不会触发初始化,因为在连接阶段会直接赋常量值)
- 2.调用Class.forName(String className)
- 3.new一个该类的对象时。
- 4.执行Main方法的当前类。
添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类
面试题1:
如下代码的输出结果是什么?
1 | publicclassTest1 { |
分析步骤:
Java 中的执行顺序是:
- 静态代码块(
static{})——类加载时只执行一次 - 执行
main()方法 - 创建对象时:
- 先执行 构造代码块(
{}) - 再执行 构造方法(
Test1())
- 先执行 构造代码块(
ldc #9是从常量池中将字符串D加载到操作数栈中。
invokevirtual是调用Println方法打印操作数栈上的内容。
1、执行main方法之前,先执行clinit指令。

指令会输出D
2、执行main方法的字节码指令。

指令会输出A
3、创建两个对象,会执行两次对象初始化的指令。

这里会输出CB,源代码中输出C这行,被放到了对象初始化的一开始来执行。
所以最后的结果应该是DACBCB
小结:类加载时先执行一次静态代码块,然后每次创建对象时都会先执行构造代码块 {},再执行构造函数。这种顺序是面试中常考的基础知识点。
注意:构造代码块优于构造方法执行,这就是为什么C在B前面执行了
clinit不会执行的几种情况
如下几种情况是不会进行初始化指令执行的:
1.无静态代码块且无静态变量赋值语句。
2.有静态变量的声明,但是没有赋值语句。

3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。

注意:
1.直接访问父类的静态变量,不会触发子类的初始化
2.子类的初始化clinit调用之前,会先调用父类的clinit初始化方法。
面试题2:
如下代码的输出结果是什么?
1 | public class Demo01 { |
分析步骤:
- 1、调用new创建对象,需要初始化B02,优先初始化父类。
- 2、执行A02的初始化代码,将a赋值为1。
- 3、B02初始化,将a赋值为2。
最终答案为2
变化
将new B02();注释掉会怎么样?

分析步骤:
- 1、访问父类的静态变量,只初始化父类。
- 2、执行A02的初始化代码,将a赋值为1。
访问父类的静态变量,只初始化父类,a是A02的变量,属于父类的变量,所以只初始化了类A02
答案为1
验证题,我们验证后发现如下两个结论:


2.3.4类的声明周期总结
1.加载阶段

2,3,4连接阶段

5初始化阶段

6.使用阶段
7.卸载阶段
2.4类加载器的分类
2.4.1什么是类加载器
类加载器ClassLoader:是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分。

类加载器的作用?
类加载器负责在类加载过程中的字节码获取并加载到内存中。通过加载字节码数据放入内存转换成byte[],接下来调用虚拟机底层方法将byte[]转换成方法区和堆中的数据。
2.4.2类加载器的分类
类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的。
- 虚拟机底层实现:源代码位于Java虚拟机的源码中,实现语言与虚拟机底层语言一致,比如Hotspot使用C++。主要目的是保证Java程序运行中基础类被正确地加载,比如java.lang.String,Java虚拟机需要确保其可靠性。
- JDK中默认提供或者自定义:JDK中默认提供了多种处理不同渠道的类加载器,程序员也可以自己根据需求定制,使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。

JDK9之后出现模块化,所以JDK9是分水岭。

类加载器的详细信息可以通过Arthas的classloader命令查看:
classloader - 查看 classloader 的继承树,urls,类加载信息,使用 classloader 去 getResource

- BootstrapClassLoader是启动类加载器,numberOfInstances是类加载器的数量只有1个,loadedCountTotal是加载类的数量1861个。
- ExtClassLoader是扩展类加载器
- AppClassLoader是应用程序类加载器

2.4.3启动类加载器(Bootstrap ClassLoader)
- 启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器。
- 默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。(rt.jar是最核心的jar包。string,integer,long,日期类。)
运行如下代码:
1 | /** * 启动程序类加载器案例 */ |
这段代码通过String类获取到它的类加载器并且打印,结果是null。这是因为启动类加载器在JDK8中是由C++语言来编写的,在Java代码中去获取既不适合也不安全,所以才返回null
在Arthas中可以通过sc -d 类名的方式查看加载这个类的类加载器详细的信息,比如:

通过上图可以看到,java.lang.String类的类加载器是空的,Hash值也是null。
如何让启动类加载器去加载用户jar包:
- 1.把要扩展的类打成jar包,放入jre/lib下进行扩展(不推荐,会要求名称符合规范)。
- 2.使用参数进行扩展。推荐,使用-Xbootclasspath/a:jar包目录/jar包名进行扩展。
如下图,在IDEA配置中添加虚拟机参数,就可以加载D:/jvm/jar/classloader-test.jar这个jar包了。

2.4.4扩展类加载器和应用程序类加载器(Extension ClassLoader 和 Application ClassLoader)
- 扩展类加载器和应用程序类加载器都是JDK提供的,使用Java编写的类加载器。
- 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。
继承关系图:

- ClassLoader类定义了具体的行为模式,简单来说就是先从本地或者网络获得字节码信息,然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码。
- SecureClassLoader提供了证书机制,提升了安全性。
- URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载,获取字节码的数据。
- 扩展类加载器和应用程序类加载器继承自URLClassLoader,获得了上述的三种能力。
扩展类加载器
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。
通过扩展类加载器去加载用户jar包:
- 放入/jre/lib/ext下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容。
- 使用参数进行扩展使用参数进行扩展。推荐,使用-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录

使用引号将整个地址包裹起来,这样路径中即便是有空格也不需要额外处理。路径中要包含原来ext文件夹,同时在最后加上扩展的路径。
应用程序加载器
应用程序类加载器会加载classpath下的类文件,默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。
如下案例中,打印出Student和FileUtils的类加载器:
1 | /** * 应用程序类加载器案例 */publicclassAppClassLoaderDemo { |
输出结果如下:

类加载器的加载路径可以通过classloader –c hash值 查看:

2.5、双亲委派机制
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载
再由顶向下进行加载。

详细流程:
每个类加载器都有一个父类加载器(Java实现的类加载器,内部都保存了一个parent的成员变量,它表示我的父类加载器是谁)。父类加载器的关系如下,启动类加载器没有父类加载器:

在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。
自己没干的就让父类干;自己干了的就直接给你
2.5.1案例1:
比如com.itheima.my.A假设在启动类加载器的加载目录中,而应用程序类加载器接到了加载类的任务。
1、应用程序类加载器首先判断自己加载过没有,没有加载过就交给父类加载器 - 扩展类加载器。

2、扩展类加载器也没加载过,交给他的父类加载器 - 启动类加载器。

3、启动类加载器发现已经加载过,直接返回。

2.5.2案例2:
B类在扩展类加载器加载路径中,同样应用程序类加载器接到了加载任务,按照案例1中的方式一层一层向上查找,发现都没有加载过。那么启动类加载器会首先尝试加载。它发现这类不在它的加载目录中,向下传递给扩展类加载器。

扩展类加载器发现这个类在它加载路径中,加载成功并返回。(注:如果所有类加载器都无法加载这个类,最终抛出 ClassNotFoundException)

如果第二次再接收到加载任务,同样地向上查找。扩展类加载器发现已经加载过,就可以返回了。

2.5.3双亲委派机制的作用
- 1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
- 2.避免重复加载。双亲委派机制可以避免同一个类被多次加载。
2.5.4如何指定加载类的类加载器?
在Java中如何使用代码的方式去主动加载一个类呢?
方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类。
方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载。
例如:

2.5.5三个面试题
1、如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?
启动类加载器加载,根据双亲委派机制,它的优先级是最高的
2、String类能覆盖吗,在自己的项目中去创建一个java.lang.String类,会被加载吗?
不能,会返回启动类加载器加载在rt.jar包中的String类。
3、类的双亲委派机制是什么?(!)
-
1.先描述双亲委派机制的流程。
-
2.然后描述类加载器之间的关系。
-
3.双亲委派机制的好处
-
当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载。
-
应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。
-
双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。
2.6打破双亲委派机制
有三种方式,但本质上只有第一种算是真正的打破了双亲委派机制:
- 自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离,《面试篇》中分享它的做法。
- 线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等。
- Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。
2.6.1打破类的双亲委派机制 自定义类加载器
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。

Tomcat使用了自定义类加载器来实现应用之间类的隔离。 每一个应用会有一个独立的类加载器加载对应的类。

那么自定义加载器是如何能做到的呢?首先我们需要先了解,双亲委派机制的代码到底在哪里,接下来只需要把这段代码消除即可。
ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。
1 | public Class<?> loadClass(String name) |
注:
-
- 自定义类加载器可以重写findClass方法来自定义获取字节码信息的途径。
-
- 如果想通过自定义类加载器来打破双亲委派机制则需要重写loadClass方法,不再实现双亲委派机制。
关于findClass:

我们接着继续看ClassLoader中4个核心方法
1、入口方法:(采用类加载器.loadclass方法默认不会执行连接和初始化。而使用Class.forNmae()会执行连接和初始化。)

2、再进入看下:(synchronized保证多线程条件下同一个类不会被多次加载)

如果查找都失败,进入加载阶段,首先会由启动类加载器加载,这段代码在findBootstrapClassOrNull中。如果失败会抛出异常,接下来执行下面这段代码:

父类加载器加载失败就会抛出异常,回到子类加载器的这段代码,这样就实现了加载并向下传递。
3、最后根据传入的参数判断是否进入连接阶段:

以下代码:通过自定义类加载器来打破 Java 的双亲委派机制,实现对同一个类文件的“重复加载”,从而得到两个互相不等的类对象(类对象层面不同)。
1 | packageclassloader.broken;//package com.itheima.jvm.chapter02.classloader.broken;importorg.apache.commons.io.IOUtils; |
Java 中“类相等”的判断不仅看类名是否相同,还要看是由“哪个类加载器”加载的。同名类如果由不同加载器加载,JVM 会认为它们是两个不同的类。
自定义类加载器父类怎么是AppClassLoader呢?
在不手动指定父加载器的情况下,自定义类加载器的父加载器默认是 应用类加载器(AppClassLoader),也就是加载我们项目代码的那个加载器。


以Jdk8为例,ClassLoader类中提供了构造方法设置parent的内容:

这个构造方法由另外一个构造方法调用,其中父类加载器由getSystemClassLoader方法设置,该方法返回的是AppClassLoader。(ClassLoader 构造器中是如何设置 parent 的?)

getSystemClassLoader()会返回当前 JVM 的系统类加载器(也就是 AppClassLoader);- 所以,如果你不显式写
super(xxx),系统会自动帮你传入AppClassLoader作为父加载器。

想改变默认父加载器怎么办?
可以用如下方式指定:
1 | ClassLoader myLoader = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent()); // 把 parent 指定为 ExtClassLoader |
或者可以重写子类的构造方法,使用 super(自定义parent) 指定想要的父加载器。
两个自定义类加载器加载相同限定名的类,不会冲突吗?
不会冲突,在同一个Java虚拟机中,只有相同类加载器+相同的类限定名才会被认为是同一个类。
在Arthas中使用sc –d 类名的方式查看具体的情况。
如下代码:
1 | public static void main(String[] args) throwsClassNotFoundException, InstantiationException, IllegalAccessException, IOException { |
打印的应该是false,因为两个类加载器不同,尽管加载的是同一个类名,最终Class对象也不是相同的。

通过Arthas看也会出现两个不同的A类。
补充:关于loadClass和findClass
如果要扩展一个类的加载的渠道,应该是重写findClass方法,而不是loadClass去打破双亲委派机制。

loadClass() 方法是整个类加载流程的“入口”,包含了双亲委派的逻辑;
如果你想自己定义怎么加载一个类,应该重写 findClass(),而不是篡改 loadClass() 的流程。

findClass(String name)(子类实现)
你只要重写这个方法,就可以定义自己的“加载策略”:比如从本地文件、网络、数据库读取字节码,然后用 defineClass() 转换为 JVM 类对象。
总结:如果你要“扩展加载逻辑”但 不想破坏双亲委派机制(比如加一个新的加载路径、从加密 JAR 加载),应该 只重写 findClass();
如果你要“打破委派机制”实现“类隔离、插件沙箱”,可以 重写 loadClass() 但要清楚风险。
2.6.2打破类的双亲委派机制 线程上下文类加载器
利用上下文类加载器加载类,比如JDBC和JNDI等。
我们来看下JDBC的案例:
1、JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。
2.DriverManager属于rt.jar是启动类加载器加载的。

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

在类中有初始化代码:

DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。(这点存疑,一会儿再讨论)
那么问题来了,DriverManager怎么知道jar包中要加载的驱动在哪儿?
步骤 1:Driver 类中的 static 块会注册驱动
1 | static { |
当 mysql 驱动类加载时,它自己会注册到 DriverManager 中
2、DriverManager 启动时会调用 loadInitialDrivers()

这个loadInitialDrivers()里用到了 JDK 提供的 SPI 机制,自动扫描 classpath 下所有实现了 java.sql.Driver 接口的类。
3、这里使用了SPI机制,去加载所有jar包中实现了Driver接口的实现类。(spi全称为(Service Provider Interface),是JDK内置的一种服务提供发现机制。)

SPI 机制从哪里找这些“实现类”?
它去找每个 jar 包里的:
1 | META-INF/services/java.sql.Driver |
文件中写的就是实现类名,例如:
1 | com.mysql.cj.jdbc.Drive |
4、SPI 机制从哪里找这些“实现类”?
它去找每个 jar 包里的:
1 | META-INF/services/java.sql.Driver |
文件中写的就是实现类名,例如:
1 | com.mysql.cj.jdbc.Drive |

5、SPI 是用哪个类加载器加载这些类的?
1 | ClassLoader cl = Thread.currentThread().getContextClassLoader(); |
使用当前线程的上下文类加载器,即 AppClassLoader,来加载驱动类!

总结:
背景:JDBC 驱动怎么加载的?
我们在写 JDBC 连接数据库时,通常不需要显式加载驱动类(Class.forName("com.mysql.cj.jdbc.Driver")),JVM 会自动完成加载。那么 JVM 是怎么知道 mysql 驱动在哪的呢?
关键角色:DriverManager + SPI机制 + 线程上下文类加载器
- DriverManager 是谁加载的?
-
DriverManager类在rt.jar中,属于 JDK 自带类。 -
所以它是由 启动类加载器 BootstrapClassLoader 加载的。
-
mysql 驱动类是谁加载的?
-
比如
com.mysql.cj.jdbc.Driver驱动类,是我们自己引入的 jar 包中的类; -
它由 应用程序类加载器(AppClassLoader) 加载。
那么,DriverManager(JDK内部)是怎么找到用户引入的 mysql 驱动类的?
这就涉及到:
⭐ SPI 机制(Service Provider Interface)+ 上下文类加载器

JDBC案例中真的打破了双亲委派机制吗?
最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。
但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。
所以我认为这里没有打破双亲委派机制,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载。
正确理解:
并不是 DriverManager 自己去加载 mysql 驱动,而是:它借助了线程的上下文类加载(AppClassLoader)去加载用户 jar 包中的驱动类。
这是一种 “类加载器协作”而不是“委派破坏”。
综上所述:JDBC 加载驱动的过程,其实是 DriverManager 启动后,通过 SPI + 上下文类加载器让应用类加载器去加载驱动类,从而实现了插件式的扩展,并没有真正破坏双亲委派机制,而是一种巧妙的“绕开但不违背”的做法。

我们可以用生活里的例子来类别这种关系:


2.6.3打破类的双亲委派机制 osgi和类的热部署
Osgi框架的类加载器
历史上,OSGi模块化框架。它存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能。热部署指的是在服务不停止的情况下,动态地更新字节码文件到内存中。

由于这种机制使用已经不多,所以不再过多讨论OSGi,着重来看下热部署在实际项目中的应用。
案例:使用阿里arthas不停机解决线上问题
背景:
小李的团队将代码上线之后,发现存在一个小bug,但是用户急着使用,如果重新打包再发布需要一个多小时的时间,所以希望能使用arthas尽快的将这个问题修复。
思路:
- 在出问题的服务器上部署一个 arthas,并启动。
- jad --source-only 类全限定名 > 目录/文件名.java jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码
- mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录
mc 命令用来编译修改过的代码
- retransform class文件所在目录/xxx.class
用 retransform 命令加载新的字节码
详细流程:
1、这段代码编写有误,在枚举中的类型判断上使用了== 而不是equals。

2、枚举中是这样定义的,1001是普通用户,1002是VIP用户:

3、由于代码有误,导致传递1001参数时,返回的是收费用户的内容。

4、jad --source-only 类全限定名 > 目录/文件名.java 使用 jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码


5、mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录 使用mc 命令用来编译修改过的代码
这里的hashcode要先使用使用sc来查看


6、retransform class文件所在目录/xxx.class 用 retransform 命令加载新的字节码

7、测试:

注意事项:
- 1、程序重启之后,字节码文件会恢复,除非将class文件放入jar包中进行更新。
- 2、使用retransform不能添加方法或者字段,也不能更新正在执行中的方法。
2.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实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
| 对比维度 | JDK 8 及以前 | JDK 9 及以后(模块化系统) |
|---|---|---|
| 🌟 体系结构 | 启动类加载器 → 扩展类加载器 → 应用程序类加载器 | 启动类加载器 → 平台类加载器 → 应用程序类加载器 |
| 🔹 扩展类加载器 | ExtensionClassLoader(加载 jre/lib/ext) |
✅ 被移除,⛳替代为 Platform ClassLoader |
| 🔹 平台类加载器 | ❌ 不存在 | ✅ 新增:PlatformClassLoader,加载标准模块和平台库 |
| 🔹 启动类加载器 | C++ 实现,加载 rt.jar(JVM 内部) |
Java 实现,源码位于 jdk.internal.loader.ClassLoaders 类中 |
| 🔹 应用类加载器 | AppClassLoader(加载 classpath 下用户类) |
✅ 保留,作用不变 |
| 🔹 加载路径来源 | 通过 Launcher.java 中硬编码的路径 |
统一改由 module 系统管理,Boot/Platform/App 各自处理模块 |
| 🔹 模块化支持 | ❌ 无模块化概念,纯基于类路径 classpath | ✅ 强制启用 Java Platform Module System (JPMS) |
| 🔹 类加载粒度 | 基于 .class 文件或 jar |
支持基于模块描述符(module-info.java)的模块化加载 |
| 🔹 启动类加载器是否可获取资源 | 可以加载资源但不可直接用 Java 获取类 | 启动类加载器仍返回 null,对用户隐藏实现(保持一致性) |
| 🔹 加载逻辑统一性 | 加载机制较分散 | Boot / Platform / App 加载逻辑更加统一、模块化 |
| 🔹 类加载器是否继承自 URLClassLoader | Extension 和 AppClassLoader 都继承了 URLClassLoader | JDK9 中 PlatformClassLoader 不再继承 URLClassLoader |
- JDK 9 后最关键的变化是模块化系统 JPMS 引入,类加载器设计围绕模块系统重新架构。
- 扩展类加载器被移除,由 PlatformClassLoader 统一管理平台模块。
- 启动类加载器从 C++ 转向 Java 实现,但对外接口保持一致。
- 类加载器职责更清晰,加载粒度从 class 级别提升到 module 级别。
3、运行时数据区
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。

线程不共享:创建一个线程每个线程里都有一份程序计数器、Java虚拟机栈、本地方法栈对应的数据,自己的数据由自己维护,其它线程不能访问对方线程中的数据。
线程共享:放入任何数据,每个线程都能访问数据,共享。
-
应用场景:Java的内存分成哪几部分?详细介绍一下。
-
Java内存中哪些部分会内存溢出?
-
JDK7和8在内存结构上的区别是什么?
-
工作中的实际问题:内存溢出。
-
内存调优的学习路线:
-
1.了解运行时内存结构,了解JVM运行过程中每一部分的内存结构以及哪些部分容易出现内存溢出。
-
2.掌握内存问题的产生原因,学习代码中常见的几种内存泄露,性能问题的常见原因。
-
3.掌握内存调优的基本方法,学习内存泄露,性能问题等常见JVM问题的常规解决方法。
3.1 程序计数器
程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。



ifne 9 ,意思是将操作数栈中的数与0进行比较,如果相等执行6,如果不相等执行9。
在加载阶段,虚拟机将字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址。每一条字节码指令都会拥有一个内存地址。

在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。这里为了简单起见,使用偏移量代替,真实内存中执行时保存的应该是地址。
比如当前执行的是偏移量为0的指令,那么程序计数器中保存的就是下一条的地址(偏移量1)。

一路向下执行

一直执行到方法的最后一行指令,此时方法执行return语句,当前方法执行结束,程序计数器中会放入方法出口的地址(栈中讲解,简单来说就是这个B方法结束了,A调用了B,那么要回到A方法)

所以,程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。不管是分支、跳转、异常,只需要在程序计数器中放入下一行要执行的指令地址即可。
在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。

程序计数器会出现内存溢出吗?
内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。由于每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的。程序员无需对程序计数器做任何处理。
3.2Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
main方法执行时,会创建main方法的栈帧:

接下来执行study方法,会创建study方法的栈帧

进入eat方法,创建eat方法的栈帧

eat方法执行完之后,会弹出它的栈帧:

然后调用sleep方法,创建sleep方法栈帧

最后study方法结束之后弹出栈帧,main方法结束之后弹出main的栈帧。
Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。如下就有两个线程的虚拟机栈,main线程和线程A。

Java虚拟机栈的栈帧中主要包含三方面的内容:
- 局部变量表:作用是在运行过程中存放所有的局部变量。
- 操作数栈:是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域。
- 帧数据:包含动态链接、方法出口、异常表的引用。
3.2.1局部变量表
局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。
1.我们先来看下字节码文件中的局部变量表:编译成字节码文件时就可以确定局部变量表的内容。
1 | public static voidtest1(){ |
test1方法的局部变量表如下:

Nr表示的是变量的编号,按照生命的顺序,起始PC保存了从哪一行字节码指令开始可以访问这个局部变量,长度以生效那行到销毁那行计算。
局部变量表中保存了字节码指令生效的偏移量:

i在1中就赋值成功了,它可以访问2,3,4 起始PC2,长度3。j在3中赋值成功,起始PC3,长度1
比如i这个变量,它的起始PC是2,代表从lconst_1这句指令开始才能使用i,长度为3,也就是2-4这三句指令都可以使用i。为什么从2才能使用,因为0和1这两句字节码指令还在处理int i = 0这句赋值语句。j这个变量只有等3指令执行完之后也就是long j = 1代码执行完之后才能使用,所以起始PC为4,只能在4这行字节码指令中使用。
2.接下来看下栈帧中的局部变量表,栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。

i占用数组下标为0的位置,j占用数组下标1-2的位置。
刚才看到的是静态方法,实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。

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

test3方法中包含两个参数k,m,这两个参数也会被加入到局部变量表中。
以下代码的局部变量表中会占用几个槽?
1 | publicvoidtest4(intk,intm){ |
为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
1、方法执行时,实例对象this、k、m 会被放入局部变量表中,占用3个槽

2、将1的值放入局部变量表下标为3的位置上,相当于给a进行赋值。

3、将2放入局部变量表下标为4的位置,给b赋值为2。

4、ab已经脱离了生效范围,所以下标为3和4的这两个位置可以复用。此时c的值1就可以放入下标为3的位置。

4、脱离c的生效范围之后,给i赋值就可以复用c的位置。

5、最后放入j,j是一个long类型,占用两个槽。但是可以复用b所在的位置,所以占用4和5这两个位置

所以,局部变量表数值的长度为6

3.2.2操作数栈
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。

为什么是2? 操作数栈中最多会出现两个数字0,1,因此最大深度为2
3.2.3帧数据
帧数据主要包含动态链接、方法出口、异常表的引用。
动态链接
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。

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

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

如下案例:i=1这行源代码编译成字节码指令之后,会包含偏移量2-4这三行指令。其中2-3是对i进行赋值1的操作,4的没有异常就跳转到10方法结束。如果出现异常的情况下,继续执行到7这行指令,7会将异常对象放入操作数栈中,这样在catch代码块中就可以使用异常对象了。接下来执行8-9,对i进行赋值为2的操作。

所以异常表中,异常捕获的起始偏移量就是2,结束偏移量是4,在2-4执行过程中抛出了java.lang.Exception对象或者子类对象,就会将其捕获,然后跳转到偏移量为7的指令。
3.2.4栈内存溢出
Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。Java虚拟机栈内存溢出时会出现StackOverflowError的错误。

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

我们来模拟下栈内存的溢出情况:
1 | public static int count = 0; |
使用递归让方法调用自身,但是不设置退出条件。定义调用次数的变量,每一次调用让变量加1。查看错误发生时总调用的次数。

解决方案-设置栈的大小
要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss 。
- 语法:-Xss栈大小
- 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
例如:
1 | -Xss1048576 |
操作步骤如下,不同IDEA版本的设置方式会略有不同:
1、点击修改配置Modify options
2、点击Add VM options
3、添加参数

调成512k之后,明显发现最大栈帧数量减少了:

对windows来说,JDK8测试最小值为180K,最大值为1024M。
如果局部变量过多,操作数栈的深度过大也会影响栈内存的大小。

Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
3.3本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。(C++)
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。

比如测试下这段代码:
1 | /** * 本地方法栈 */publicclassNativeDemo1 { |
执行之后发生异常,会打印出所有栈帧的名字:

open0是一个本地方法,所以创建了本地方法的栈帧。本地方法和Java虚拟机方法
3.4 堆内存
一般Java程序中堆内存是空间最大的一块内存区域。
创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

这段代码中通过new关键字创建了两个Student类的对象,这两个对象会被存放在堆上。在栈上通过s1和s2两个局部变量保存堆上两个对象的地址,从而实现了引用关系的建立。
3.4.1堆内存的溢出
通过new关键字不停创建对象,放入集合中,模拟堆内存的溢出,观察堆溢出之后的异常信息。
1 | package chapter03.heap; |
堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出OutOfMemory错误。在这段代码中,不停创建100M大小的字节数组并放入ArrayList集合中,最终超过了堆内存的上限。抛出如下错误:

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

可以通过:dashboard 进行访问,如果只能看内存只需要输入:memory
手动指定刷新频率(不指定默认5秒一次):
dashboard –i 刷新频率(毫秒)

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

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

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

那么是不是当used = max = total的时候,堆内存就溢出了呢?
不是,堆内存溢出的判断条件比较复杂,在下一章《垃圾回收器》中会详细介绍。
如果不设置任何的虚拟机参数,max默认是系统内存的1/4,total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值。
3.4.3设置堆的大小
要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total)。
语法:-Xmx值 -Xms值
单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
限制:Xmx必须大于 2 MB,Xms必须大于1MB
1 | -Xms6291456 |
这样可以将max和初始的total都设置为4g,在启动后就已经获得了最大的堆内存大小。运行过程中不需要向操作系统申请。

使用arthas的memory命令同样可以看到:

为什么arthas中显示的heap堆大小与设置的值不一样呢?
arthas中的heap堆内存使用了JMX技术中内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存。
建议:
Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。-Xmx具体设置的值与实际的应用程序运行环境有关,在《实战篇》中会给出设置方案。
3.5 方法区
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
- 类的元信息,保存了所有类的基本信息
- 运行时常量池,保存了字节码文件中的常量池内容
- 字符串常量池,保存了字符串常量
3.5.1类的元信息
方法区是用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息。

3.5.2运行时常量池
方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。

3.5.3方法区的实现
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。

可以通过arthas的memory命令看到方法区的名称以及大小:
- JDK7及之前的版本查看ps_perm_gen属性。
- JDK8及之后的版本查看metaspace属性。
3.5.4方法区的溢出
通过ByteBuddy框架,动态创建类并将字节码数据加载到内存中。通过死循环不停地加载到方法区,观察方法区是否会出现内存溢出的情况。分别在JDK7和JDK8上运行上述代码。

实验发现,JDK7上运行大概十几万次,就出现了错误。
在JDK8上运行百万次,程序都没有出现任何错误,但是内存会直线升高。这说明JDK7和JDK8在方法区的存放上,采用了不同的设计。
- JDK7将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数-XX:MaxPermSize=值来控制。
- JDK8将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用-XX:MaxMetaspaceSize=值将元空间最大大小进行限制。
在JDK8中将最大元空间内存设置为256m,再次测试

这次就出现了MetaSpace溢出的错误:

3.5.5字符串常量池
方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。
字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池。
如下代码执行时,代码中包含abc字符串,就会被直接放入字符串常量池。在堆上创建String对象,并通过局部变量s1引用堆上的对象。

接下来通过s2局部变量引用字符串常量池的abc。

所以s1和s2指向的不是同一个对象,打印出false。
所以s1和s2指向的不是同一个对象,打印出false。
字符串常量池和运行时常量池有什么关系?
早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分。

在 JDK 7 及之前的版本中,静态变量是存储在方法区(永久代)的,而从 JDK 8 开始,随着永久代的移除,静态变量被存储在了堆区,并且位于 Class 对象内部。



StringTable的练习题1:

1、首先将1放入字符串常量池,通过局部变量a引用字符串常量池中的1字符串。

2、同理处理b和c:

3、将a和b指向的字符串进行连接,本质上就是使用StringBuilder进行连接,最后创建了一个新的字符串放入堆中。然后将局部变量d指向堆上的对象。
因为a+b通过stingbuild的append方法,最后new string(),new对象是放在堆中的

4、所以c和d指向的不是同一个对象,打印出的结果就是false。
即c指向了字符串常量池里的"12",d指向了堆内存中的"12"
StringTable的练习题2:


说明在编译阶段,已经将1和2进行连接,最终生成12的字符串常量池中的结果。所以返回结果就是true,c和d都指向字符串常量池中的对象。
总结:

intern方法
String.intern()方法是可以手动将字符串放入字符串常量池中



结论:JDK7以及之后的版本中,我们在堆上创建的字符串,如果调用了intern()方法,它只是在常量池中存放了这个对象的引用,而不再会把字符串搬运到常量池中
3.5.6面试题:静态变量存储在哪里呢?
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理。


3.6 直接内存
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。
在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
- 1、Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
- 2、IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
使用堆创建对象的过程:

如果使用直接内存创建对象的过程,就不需要进行复制对象,数据直接存放在直接内存中:

3.6.1使用方法
要创建直接内存上的数据,可以使用ByteBuffer。
语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
注意事项: arthas的memory命令可以查看直接内存大小,属性名direct。

1 | package chapter03.direct; |
运行之后,用arthas监控发现,每隔5秒直接内存会增长100M:

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

3.6.2设置直接内存
为了避免将内存耗尽,需要设置直接内存的最大值。如果需要手动调整直接内存的大小,可以使用XX:MaxDirectMemorySize=大小
单位k或K表示千字节,m或M表示兆字节,g或G表示千兆字节。默认不设置该参数情况下,JVM 自动选择 最大分配的大小。
以下示例以不同的单位说明如何将 直接内存大小设置为 1024 KB:
1 | -XX:MaxDirectMemorySize=1m |
在Idea中设置直接内存最大值为1g:

4、垃圾回收
在C/C++这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏。
内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。
我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收。手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存。缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象。
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。
垃圾回收器如果发现某个对象不再使用,就可以回收该对象。


- 自动垃圾回收,自动根据对象是否使用由虚拟机来回收对象
- 优点:降低程序员实现难度、降低对象回收bug的可能性
- 缺点:程序员无法控制内存回收的及时性
- 手动垃圾回收,由程序员编程实现对象的删除
- 优点:回收及时性高,由程序员把控回收的时机
- 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题
那么垃圾回收器需要负责对哪些部分的内存进行回收呢?
首先是线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以这一部分不需要垃圾回收器负责回收。

4.1 方法区的回收
方法区中能回收的内容主要就是不再使用的类。
- 了解即可,因为在实际开发中,很难同时满足三个条件。
- 因为,自己编写的类是由应用程序加载器加载,而这个加载器在运行过程中是不会被回收的,所以自己编写的类,只要被加载,就不会被回收
判定一个类可以被卸载。需要同时满足下面三个条件:
1、此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
这段代码中就将局部变量对堆上实例对象的引用去除了,所以对象就可以被回收。

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

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

如果需要手动触发垃圾回收,可以调用System.gc()方法。
调用System.gc()方法并不一定会立即回收垃圾,仅仅向JAVA虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。
那么类卸载主要用在什么场景下呢?
开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。
每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
4.2 如何判断对象可以回收
Java中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
比如下面代码的内存结构图:
第一行代码执行之后,堆上创建了Demo类的实例对象,同时栈上保存局部变量引用堆上的对象。

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

一个更复杂的案例:

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

那么问题来了,A和B互相之间的引用需要去除吗?答案是不需要,因为局部变量都没引用这两个对象了,在代码中已经无法访问这两个对象,即便他们之间互相有引用关系,也不影响对象的回收。
判断对象是否可以回收,主要有两种方式:引用计数法和可达性分析法。

4.2.1引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加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

加上这个参数之后执行代码,发现对象确实被回收了:


通过不同的死循环创建对象,内存并没有上升,一直维持在1000K,说明每轮循环创建的两个对象在垃圾回收之后都被回收了。即没有发生内存泄漏
4.2.2可达性分析法
Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。

哪些对象被称之为GC Root对象呢?
1.线程Thread对象(创建线程之后,整个线程对象),引用线程栈帧中的方法参数、局部变量等。

2.系统类加载器加载的java.lang.Class对象,引用类中的静态变量。
解释一下2:系统类加载器也就是应用程序类加载器,前面说到过,他是不可回收的,这个类加载器不会被回收,所以这个类也就不会被回收,那么这个类里面的静态变量也就不会被回收,所以就可以是GC root对象
Launcher包含应用程序类加载器和GC Root对象。

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

4.本地方法调用时使用的全局对象。

查看GC Root
通过arthas和eclipse Memory Analyzer工具可以查看GC Root,MAT工具是eclipse推出的Java堆内存检测工具。
1.使用arthas的heapdump命令可将内存快照保存到本地磁盘中。
2.使用MAT工具打开堆内存快照文件。
3.选择GC Roots功能查看所有的GC Root。
4.3 常见的引用对象
可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java中还设计了几种其他引用方式:
- 软引用
- 弱引用
- 虚引用
- 终结器引用
4.3.1软引用
如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存中。
软引入的好处?
如果对象A是一个缓存,平时会保存在内存中,如果想访问数据可以快速访问。但是如果内存不够用了,我们就可以将这部分缓存清理掉释放内存。即便缓存没了,也可以从数据库等地方获取数据,不会影响到业务正常运行,这样可以减少内存溢出产生的可能性。
特别注意:
软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉。

软引用的使用方法
软引用的执行过程如下:
1.将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。
2.内存不足时,虚拟机尝试进行垃圾回收。
3.如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
4.如果依然内存不足,抛出OutOfMemory异常。
把最大堆内存设置为200M,无法容纳2个100M的生成,所以当第2个100M生成时内存空间不足,会把软引用释放掉,所以第2次输出软引用的内容是null。


软引用中的对象如果在内存不足时回收,SoftReferece对象本身也需要被回收。如何知道哪些SoftReference对象需要回收呢?(软引用(SoftReference)对象是如何被 GC 回收的,以及如何利用引用队列(ReferenceQueue)监控和清理的完整过程)
SoftReference提供了一套队列机制:
1.软引用创建时,通过构造器传入引用队列。

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

3.通过代码遍历引用队列,将SoftReference的强引用删除。
这个队列记录了内容为空的SoftReference,可以便利队列来释放空的SoftReference
1 | /** * 软引用案例3 - 引用队列使用 */publicclassSoftReferenceDemo3 { |
第一步:创建软引用并绑定引用队列
1 | SoftReference<byte[]> ref = new SoftReference<>(bytes, queues); |
- 你把
byte[]数据包装成了一个软引用; - 同时绑定了一个引用队列
queues,表示一旦这个数据被回收了,你就能通过队列知道。
第二步:GC 回收软引用指向的对象
由于你在 for 循环中分配了很多大内存:
1 | byte[] bytes = new byte[1024 * 1024 * 100]; // 100MB × 10次 |
当 JVM 内存不够时,GC 就会触发,开始尝试回收这些 byte[] 对象——因为它们只是软引用,GC 是可以回收它们的。
一旦 GC 回收了这些 byte[] 对象,对应的 SoftReference 就会被加入 queues 队列!
第三步:程序从引用队列中轮询出已失效引用
1 | while ((ref = (SoftReference<byte[]>) queues.poll()) != null) { |
- 遍历队列,把被回收通知了的软引用拿出来;
- 你可以手动把它们从
ArrayList中删掉,释放 SoftReference 本身(否则软引用对象还会占内存); - 最终打印出有几个对象已经被 GC 回收并放入队列。
最终输出是:9
表示你创建的 10 个软引用中,有 9 个关联的 byte[] 被 GC 回收,通知已经进入队列。
最终展示的结果是:

这9个软引用对象中包含的数据已经被回收掉,所以可以手动从ArrayList中去掉,这样就可以释放这9个对象。
一句话总结:软引用的对象被 GC 回收后,JVM 会自动把对应的 SoftReference 放入你提供的 ReferenceQueue 中,程序就可以从这个队列中感知到该软引用失效,从而主动清除引用本身,释放内存。
软引用的缓存案例
使用软引用实现学生信息的缓存,能支持内存不足时清理缓存。
设计一个 StudentCache 缓存类,用软引用保存大量 Student 对象的缓存。当内存不足时,JVM 可以自动释放缓存中不用的对象,避免内存溢出(OOM)。


1 | packagechapter04.soft; |



一句话总结核心机制:
利用
SoftReference+ReferenceQueue+HashMap组合,实现了一个自动可回收的缓存系统,支持在内存紧张时释放不常用对象,且可感知对象被回收并做清理。
4.3.2弱引用(了解)
弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在JDK 1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。
弱引用对象本身也可以使用引用队列进行回收。
1 | packagechapter04.weak; |
执行之后发现gc执行之后,对象已经被回收了。

4.3.3虚引用和终结器引用(了解)
这两种引用在常规开发中是不会使用的。
-
虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
-
终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。
1 | packagechapter04.finalreference; |

4.4 垃圾回收算法
Java是如何实现垃圾回收的呢?简单来说,垃圾回收要做的有两件事:
- 1、找到内存中存活的对象
- 2、释放不再存活对象的内存,使得程序能再次利用这部分空间

4.4.1垃圾回收算法的评价标准
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用。
如下图,用户代码执行和垃圾回收执行让用户线程停止执行(STW)是交替执行的。

所以判断GC算法是否优秀,可以从三个方面来考虑:
1.吞吐量
吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。

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

3.堆使用效率
不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。

没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景。
4.4.2标记清除算法
标记清除算法的核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.清除阶段,从内存中删除没有被标记也就是非存活对象。
第一个阶段,从GC Root对象开始扫描,将对象A、B、C在引用链上的对象标记出来:

第二个阶段,将没有标记的对象清理掉,所以对象D就被清理掉了。

优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
缺点:1.碎片化问题
由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
如下图,红色部分已经被清理掉了,总共回收了9个字节,但是每个都是一个小碎片,无法为5个字节的对象分配空间。

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

4.4.3复制算法
复制算法的核心思想是:
1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
对象A首先分配在From空间:

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

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

完整的复制算法的例子:
1.将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。

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

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

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

优点:
- 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
- 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:
内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
4.4.4标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.整理阶段,将存活对象移动到堆的一端。是清理非存活对象的内存空间

优点:
- 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
- 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。
4.4.5分代垃圾回收算法
分代垃圾回收算法是现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代:

在JDK8中,添加-XX:+UseSerialGC参数使用分代回收的垃圾回收器,运行程序。

可以通过memory命令来查看内存

Eden + survivor 这两块区域组成了年轻代。
tenured_gen指的是晋升区域,其实就是老年代。
1、分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。

2、随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。
Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。
eden[首次]满时,会触发垃圾回收,根据可达性判断存活的对象,将存活的对象复制到某个幸存区(假设为From区),同时Eden区清空。当后续eden再满时,就会将eden和From区的对象进行回收然后将幸存的对象复制到To区。

3、接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。

此时会回收eden区和S1(from)中的对象,并把eden和from区中剩余的对象放入S0。
注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
4、如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。


当整个新生代的空间都被使用时,无法放入新的对象时,先尝试Minor GC,把未达到年龄阈值但是最老的对象放入老年代,如果老年代空间也被占满了,会触发Full GC(回收整个堆的所有对象)
一个长时间运行的应用可能积累了许多长生命周期的对象,导致老年代逐渐填满,即使新生代并未完全填满,JVM也可能触发Full GC来清理老年代
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
至于为什么不会被回收:因为这里的回收条件依然要满足可达性分析,如果不满足,是不能被回收的


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

- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
- 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
分代GC算法将堆分成年轻代和老年代主要原因有:
- 1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 2、新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 3、分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
-----------------------------------------------------------------------------------------------------------
垃圾回收器是垃圾回收算法的具体实现。
4.5.2垃圾回收器组合关系
由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用。
具体的关系图如下:

4.5.3Serial和Serial Old的组合
年轻代-Serial垃圾回收器
Serial是一种单线程串行回收年轻代的垃圾回收器。


老年代-SerialOld垃圾回收器
SerialOld是Serial垃圾回收器的老年代版本,采用单线程串行回收
-XX:+UseSerialGC 新生代、老年代都使用串行回收器。


4.5.4ParNew和CMS的组合
年轻代-ParNew垃圾回收器
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收
-XX:+UseParNewGC 新生代使用ParNew回收器, 老年代使用串行回收器


老年代- CMS(Concurrent Mark Sweep)垃圾回收器(JDK14废弃)
CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
参数:XX:+UseConcMarkSweepGC


CMS执行步骤:
1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象。

2.并发标记, 标记所有的对象,用户线程不需要暂停。

3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。

4.并发清理,清理死亡的对象,用户线程不需要暂停。

也就是说只有两个阶段会产生STW,一个是初始标记阶段一个是重新标记阶段,但这两个停顿时间会短一些(并发标记时间比较长 重新标记只是找错漏情况 会比较短)
缺点:
1、CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N 参数(默认0)调整N次Full GC之后再整理。
2.、无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。(浮动垃圾(Floating Garbage) 是指:在 CMS 的并发标记阶段,应用线程继续运行期间新产生的、后来变成不可达的对象,但 CMS 没有标记到它们。这些对象在这一轮 GC 中被“遗漏”了,因此不会被回收,直到下一次GC才有机会被清理
3、如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
4.5.5Parallel Scavenge 和Parallel Old的组合(JDK8默认组合)
年轻代-Parallel Scavenge垃圾回收器
Parallel Scavenge是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。


常用参数:
Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
- 最大暂停时间,
-XX:MaxGCPauseMillis=n设置每次垃圾回收时的最大停顿毫秒数 - 吞吐量,
-XX:GCTimeRatio=n设置吞吐量为n(用户线程执行时间 = n/n + 1) - 自动调整内存大小,
-XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小
吞吐量的值默认99(用户线程执行99%时间,垃圾回收线程1%)

最大暂停时间默认值(非常大,基本任何时间都满足,相对于没有限制最大暂停时间)

老年代-Parallel Old垃圾回收器
Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并行收集。
参数: -XX:+UseParallelGC 或
-XX:+UseParallelOldGC可以使用Parallel Scavenge + Parallel Old这种组合。


G1垃圾回收器(JDK9之后默认的垃圾回收器)
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小。CMS关注暂停时间,但是吞吐量方面会下降。
而G1设计目标就是将上述两种垃圾回收器的优点融合:
- 1.支持巨大的堆空间回收,并有较高的吞吐量。
- 2.支持多CPU并行垃圾回收。
- 3.允许用户设置最大暂停时间。
JDK9之后强烈建议使用G1垃圾回收器。
G1出现之前的垃圾回收器,年轻代和老年代一般是连续的,如下图:

G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M。

G1垃圾回收有两种方式:
1、年轻代回收(Young GC)
2、混合回收(Mixed GC)
年轻代回收
年轻代回收(Young GC),回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数
-XX:MaxGCPauseMillis=n(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
1、新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%即占年轻代设置大小的60%),无法分配对象时需要回收时会执行Young GC。

2、标记出Eden和Survivor区域中的存活对象,
3、根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域。


G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。
比如 -XX:MaxGCPauseMillis=n(默认200),每个Region回收耗时40ms,那么这次回收最多只能回收4个Region。
4、后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。


5、当某个存活对象的年龄到达阈值(默认15),将被放入老年代。

6、部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。

7、多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时
(-XX:InitiatingHeapOccupancyPercent默认45%)会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。


混合回收
混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize Marking)、并发清理(cleanup)
G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。最后清理阶段使用复制算法,不会产生内存碎片。

混合回收的步骤:
- 1、初始标记,STW,采用三色标记法标记从GC Root可直达的对象。 STW时间极短。
- 2、并发标记,并发执行,对存活对象进行标记。
- 3、最终标记,STW,处理SATB相关的对象标记。 STW时间极短。
- 4、清理,STW,如果区域中没有任何存活对象就直接清理。 STW时间极短。
- 5、转移,将存活对象复制到别的区域。 STW时间较长。

注意:如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。

4.5.6G1 – Garbage First 垃圾回收器(相关参数)
参数1: -XX:+UseG1GC 打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值 最大暂停的时间


使用以下代码测试g1垃圾回收器,打印出每个阶段的时间:
1 | packagechapter04.gc; |
每个region大小为2m,一共有84个young区,26个幸存者区。

初始标记花了0.0478秒。

并发标记总共耗时10ms,不会产生STW。

4.5.7总结
垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:
JDK8及之前:
ParNew + CMS(关注暂停时间)、Parallel Scavenge + Parallel Old (关注吞吐量)、 G1(JDK8之前不建议,较大堆并且关注暂停时间)
JDK9之后:
G1(默认)
从JDK9之后,由于G1日趋成熟,JDK默认的垃圾回收器已经修改为G1,所以强烈建议在生产环境上使用G1。

对于线程不共享的程序计数器和栈空间来说,它会伴随着线程的创建而创建,跟着线程的回收而回收
对于线程共享的两部分方法区和堆来说,方法区一般不需要回收,在特定的某些技术如JSP等技术会通过回收类加载器去回收方法区的类,但一般对于程序员来说这块很少关注;堆则会有垃圾回收器来负责回收



标记-清除算法:标记之后再清除,容易产生内存碎片
复制算法:从一块区域复制到另一块区域容易造成只能使用一部分内存,对内存的使用率会下降
标记-整理算法:标记之后将存活的对象推到一边,对象会移动,效率不高
分代GC算法:将内存区域划分为年轻代、幸存者区、老年代进行回收、可以使用多种回收算法

Serial和Serial Old的组合:单线程回收,主要适用于单核CPU场景
ParNew和CMS的组合:暂停时间较短,适用于大型互联网应用中与用户交互的部分(创建订单,查询商品信息),但还需注意CMS的缺点
Parallel Scavenge 和Parallel Old的组合:吞吐量高,适用于后台进行大量数据操作
G1垃圾回收器:适用于较大的堆,具有可控的暂停时间




