JVM虚拟机
——基础篇——
JVM
什么是JVM
JVM 全称: Java Virtual Machine
本质上是一个运行在计算机上的程序,职责是运行Java字节码文件

JVM的功能
解释和运行
对字节码文件中的指令,实时的解释成机器码,让计算机执行
内存管理
- 自动为对象、方法等分配内存
- 自动的垃圾回收机制,回收不再使用的对象
即时编译(Just-In-Time 简称JIT)
- 对热点代码进行优化,提升执行效率
- Java语言如果不做任何优化,性能不如C、C++等语言
- java是实时解释成机器码、c/c++是直接编译和链接成机器码
- java的实时解释,主要是为了支持跨平台特性
-
由于JVM需要实时解释虚拟机指令,不做任何优化性能不如直接运行机器码的C、C++等语言
- jvm发现一段字节码是热点代码的字节码指令时(很短时间内被多次调用),就会解释并优化这段字节码为汇编和机器码,然后保存至内存中,下次执行可以直接从内存从中调用(空间换时间)
常见的JVM
名称 | 作者 | 支持版本 | 社区活跃度 (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特性支持 | 微服务、云原生架构 |
java虚拟机规范
- 《Java虚拟机规范》由Oracle制定,内容主要包含了Java虚拟机在设计和实现时需要遵守的规范,主 要包含class字节码文件的定义、类和接口的加载和初始化、指令集等内容
- 《Java虚拟机规范》是对虚拟机设计的要求,而不是对Java设计的要求,也就是说虚拟机可以运行在 其他的语言比如Groovy、Scala生成的class字节码文件之上
- 官网 Java SE Specifications (oracle.com)
字节码文件
JVM的组成
本地接口eg:
字节码文件的组成
使用jclasslib打开字节码文件
可以github安装或者idea装插件
Magic 魔数
用来标识文件类型为java字节码文件
- 文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改,不影响文件的内容
- 软件使用文件的头几个字节(文件头)去校验文件的类型,如果软件不支持该种类型就会出错
- Java字节码文件中,将文件头称为magic魔数
文件类型 | 字节数 | 文件头 |
---|---|---|
JPEG (.jpg) | 3 | FFD8FF |
PNG (.png) | 4 | 89504E47(文件尾部也有要求) |
bmp | 2 | 424D |
XML (.xml) | 5 | 3C3F786D6C |
AVI (.avi) | 4 | 41564920 |
Java字节码文件(.class) | 4 | CAFEBABE |
主副版本号
主副版本号指的是编译字节码文件的JDK版本号
- 主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46,之后每升级一个大版本就加1
- 副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号
- 版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容
- 1.2之后大版本号计算方法:==主版本号 – 44==
- 比如主版本号52就是JDK8
🙋♀️🌰主版本号不兼容导致的错误
需求: 解决以下由于主版本号不兼容导致的错误:
类文件具有错误的版本52.0,应为50.0 请删除该文件或确保该文件位于正确的类路径子目录中。
- 方案1:
升级JDK版本(容易引发其他的兼容性问题,并且需要大量的测试)
- 方案2:
将第三方依赖的版本号降低或者更换依赖,以满足JDK版本的要求
常量池
- 字节码文件中常量池的作用:==避免相同的内容重复定义,节省空间==
- 常量池中的数据都有一个编号,编号从1开始。在字段或者字节码指令中通过编号可以快速的找到对应的数据
- 字节码指令中通过编号引用到常量池的过程称之为符号引用
🙋♀️🌰:
|
|
引用了编号为13的内容
13是一个字符串引用14
14存放字面量abc
变量名直接指向字面量
❓为什么13不直接是字面量abc
- 因为有字符串常量池,所以要保留String
- 因为字面量可能是变量名的,可能是字符串的
方法
🙋♀️🌰:
|
|
查看方法
iconst_<i>
Push the int constant <i>
(-1, 0, 1, 2, 3, 4 or 5) onto the operand stack
格式
iconst_<i>
- 操作数栈是临时存放数据的地方,局部变量表是存放方法中的局部变量的位置
istore_<i>
从操作数栈取出(弹出)放入局部变量表1号位置(操作数栈的数据被弹出了)
iload_<i>
将局部变量表1中的数据放入操作数栈(局部变量表中的数据还在)
i=i++
i=++i
Q:int i = 0; i = i++; 最终i的值是多少?
A:答案是0,我通过分析字节码指令发现,i++先把0取出来放入临时的操作数栈中,
接下来对i进行加1,i变成了1,最后再将之前保存的临时值0放入i,最后i就变成了0
常用工具
javap -v命令
- javap是JDK自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内 容
- 直接输入javap查看所有参数
- 输入javap -v 字节码文件名称 查看具体的字节码信息(如果 jar包需要先使用 jar –xvf 命令解压)
|
|
|
|
jclasslib
-
idea下载
-
github下载
阿里arthas
Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修
改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率
功能
使用
- 是一个jar包,通过
java -jar .\arthas-boot.jar
启动
- 打开后会显示当前运行的java程序
- 输入序号
4
,然后enter
- 会自动下载最新版的
arthas home: C:\Users\charmingdaidai\.arthas\lib\3.7.2\arthas
命令
-
清空命令行
cls
-
dashboard
dashboard -i 2000 -n 3
-
dump已加载类的字节码文件到特定目录
dump -d 路径 类名
-
1
dump -d D:\Language\JAVA\LearnJVM\target\classes\part1 part1.test2
-
反编译已加载类的源码
jad 类名
-
1
jad part1.test2
🙋♀️🌰
小李的团队昨天对系统进行了升级修复了某个bug,但是升级完之后发现bug还是存在,小李怀疑是因为没有把最新的字节码文件部署到服务器上,请使用阿里的arthas去确认升级完的字节码文件是不是最新的
思路
在出问题的服务器上部署一个 arthas,并启动
连接 arthas的控制台,使用 jad命令加上想要查看的类名,反编译出源码
确认源码是否是最新的
类的生命周期
高频面试题、大厂笔试题、后续基础知识
类的生命周期描述了一个类加载、使用、卸载的整个过程
加载 -> 连接 -> 初始化 -> 使用 -> 卸载
↓
验证 -> 准备 -> 解析
加载阶段
-
加载(Loading)阶段第一步是==类加载器==根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。
程序员可以使用Java代码拓展的==不同的渠道==
- 本地文件:磁盘上的字节码文件
- 动态代理生成:程序运行时使用动态代理生成
- 通过网络传输的类:早期的Applet技术使用
-
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中
-
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中 生成一个==InstanceKlass==对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息
- 为了和源代码中的class区分,使用了klass
- 不同的JVM或者不同的版本方法区可能不一样
-
同时,Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象。==作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)==
- InstanceKlass一般是C++编写的对象,java不能直接操作
- Java.lang.Class内容比InstanceKlass少
- Java.lang.Class也可以一定程度上保障安全
-
查看内存中的对象
- 推荐使用 JDK自带的hsdb工具查看Java虚拟机内存信息。工具位于JDK安装目录下lib文件夹中的sa-jdi.jar中
- 启动命令:java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
- JDK17的目录下没有😭
-
1 2 3
cd D:\JAVA\JDK1.8\lib java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
- 输入进程ID
- 可以在powershell中输入
jps
获得ID -
1 2 3 4 5 6 7
PS D:\Language\JAVA\LearnJVM\target\classes\part1> jps 27056 test2 51520 RemoteMavenServer36 5936 HSDB 31348 36612 Launcher 54740 Jps
连接阶段
验证:验证内容是否满足《Java虚拟机规范》
这个阶段一般不需要程序员参与
- 文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求
- 元信息验证,例如类必须有父类(super不能为空)默认是
<java/lang/Object>
- 验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置
- 符号引用验证,例如是否访问了其他类中private的方法等
主副版本号的校验
Hotspot JDK8中虚拟机源码对版本号检测的代码如下:
==主版本号不能高于运行环境主版本号,如果主版本号相等,副版本号也不能超过==
JDK12之后副版本号才不为0
准备:给静态变量赋初值
准备阶段为静态变量(static)分配内存并设置初始值
==本章涉及到的内存结构只讨论JDK8及之后的版本,8之前的版本后续章节详述==
==初始化阶段才会进行赋值阶段,准备阶段只赋初始值==
准备阶段只会给静态变量赋初始值,而每一种基本数据类型和引用数据类型都有其初始值
数据类型 | 初始值 |
---|---|
int | 0 |
byte | 0 |
long | 0L |
boolean | false |
short | 0 |
double | 0.0 |
char | \u0000 |
引用数据类型 | null |
Q:为什么要赋初始值?
A:防止之前的内存区域有值,会出现随机值的情况
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值
解析`:将常量池中的符号引用替换成指向内存的直接引用
解析阶段主要是将常量池中的符号引用替换为直接引用
符号引用就是在字节码文件中使用编号来访问常量池中的内容
直接引用不再使用编号,而是使用内存中地址进行访问具体的数据
初始化阶段
- 初始化阶段会执行==静态代码块中的代码==,并==为静态变量赋值==
- 初始化阶段会执行字节码文件中==clinit==(class init)部分的字节码指令
|
|
putstatic
给类中的静态字段赋值
交换一下顺序
|
|
==clinit方法中的执行顺序与Java中编写的顺序是一致的==
什么情况会导致类的初始化
添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类
访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化(final修饰的常量在连接阶段的准备过程中赋值)
|
|
直接运行
|
|
加上final public static final int i = 0;
|
|
原因:final在连接阶段就赋值了,访问Demo2的变量i不会进行初始化阶段的执行
调用Class.forName(String className)
|
|
|
|
new一个该类的对象时
执行Main方法的当前类
🙋♀️🌰题目1

这块代码在每次创建对象时都会执行,代码块的内容会被编译到构造函数中,但是位置靠前
|
|
- clinit指令在特定情况下不会出现,比如:如下几种情况是不会进行初始化指令执行的
- 无静态代码块且无静态变量赋值语句
- 有静态变量的声明,但是没有赋值语句
public static int a;
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化
public final static int a = 10
🙋♀️🌰题目2

- 直接访问父类的静态变量,不会触发子类的初始化
- 子类的初始化clinit调用之前,会先调用父类的clinit初始化方法
去掉 new B02()
🙋♀️🌰题目3
分析如下代码的运行结果
|
|
bipush
Push byte
bipush byte
立即数字节被符号扩展为一个int值。该值被推送到操作数堆栈中
anewarray
Create new array of reference
anewarray indexbyte1 indexbyte2
astore_<n>
将引用存储到局部变量中
<n>
必须是当前帧的局部变量数组的索引。操作数堆栈顶部的objectref必须是returnAddress类型或reference类型。它从操作数堆栈中弹出,<n>
处的局部变量的值设置为objectref
==数组的创建不会导致数组中元素的类进行初始化==
🙋♀️🌰题目4
分析如下代码的运行结果
|
|
|
|
getstatic
从类中获取静态字段
==final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化==
类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术
类加载器只参与加载过程中的字节码获取并加载到内存这一部分
本地接口JNI是Java Native Interface的缩写,允许Java调用其他语言编写的方法。
在hotspot类加载器中,主要用于调用Java虚拟机中的方法,这些方法使用C++编写
应用场景:
企业级应用、面试题、解决线上问题
类加载器的分类
- 类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的
- 类加载器的设计JDK8和8之后的版本差别较大,JDK8及之前的版本中默认的类加载器有如下几种:
启动类加载器
- 启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器
- 默认加载Java安装目录/jre/lib下的类文件,比如rt.jar、tools.jar、resources.jar等
通过启动类加载器去加载用户jar包
- 法一:放入jre/lib下进行扩展(不推荐)
- 尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载
- 法二:使用参数进行扩展(推荐)
- 使用==-Xbootclasspath/a:jar包目录/jar包名== 进行扩展
- a表示追加(append)
扩展类加载器、应用程序类加载器
- 扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器
- 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录 或者指定jar包将字节码文件加载到内存中
- 扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。
- 扩展类加载器默认加载Java安装目录/jre/lib/ext下的类文件
通过扩展类加载器去加载用户jar包
- 法一:放入/jre/lib/ext下进行扩展(不推荐)
- 尽可能不要去更改JDK安装目录中的内容
- 法二:使用参数进行扩展(推荐)
- 使用==-Djava.ext.dirs=“jar包目录”==进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录
Arthas中类加载器相关的功能
类加载器的详细信息可以通过classloader命令查看
- BootstrapClassLoader 加载核心类
- ExtClassLoader 扩展类加载器加载通用的类
- AppClassLoader 加载自己编写的类
sc -d 全类名 可以搜索某个类
类加载器的加载路径可以通过classloader –c hash值 查看
classloader -l
查看hash值
classloader –c hash值
启动类加载器加载了启动类和扩展类
类加载器双亲委派机制
- 双亲是parent翻译过来的,实际上只有一个父类
- 应用程序类加载器的parent父类加载器是扩展类加载器,而扩展类加载器的parent是空
- 启动类加载器使用C++编写,没有上级类加载器
作用
-
保证类加载的安全性
通过双亲委派机制,让顶层的类加载器去加载 核心类,避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性
-
避免重复加载
双亲委派机制可以避免同一个类被多次加载,上层的类加载器如果加载过类,就会直接返回该类,避免重复加载
机制
自底向上查找是否加载过,再由顶向下进行加载
- 在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会 将加载请求委派给父类加载器
- 如果类加载的parent为null,则会提交给启动类加载器处理
- 如果所有的父类加载器都无法加载该类,则由当前类加载器自己尝试加载。所以看上去是自顶向下尝试 加载
- 第二次再去加载相同的类,仍然会向上进行委派,如果某个类加载器加载过就会直接返回
==向下委派的优点:起到了加载优先级的作用==
解决三个问题
重复的类
Q:如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?
A:启动类加载器加载,根据双亲委派机制,它的优先级是最高的
String类能覆盖吗
Q:在自己的项目中去创建一个java.lang.String类,会被加载吗?
A:不能,会交由启动类加载器加载在rt.jar包中的String类,会直接返回
类加载器的关系
Q:这几个类加载器彼此之间存在关系吗?
A:应用类加载器的父类加载器是扩展类加载器,扩展类加载器没有父类加载器,但是会委派给启动类加载器加载
在Java中如何使用代码的方式去主动加载一个类呢?
方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类
方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载
打破双亲委派机制
自定义类加载器
- 自定义类加载器并且重写loadClass方法,就可以将双亲委派机制的代码去除
- Tomcat通过这种方式实现应用之间类隔离,《面试篇》中分享它的做法
问题
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了
解决
Tomcat使用了自定义类加载器来实现应用之间类的隔离。 每一个应用会有一个独立的类加载器加载对应的类
代码
ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中
|
|
入口方法:
再进入看下:
如果查找都失败,进入加载阶段,首先会由启动类加载器加载,这段代码在 findBootstrapClassOrNull
中。如果失败会抛出异常,接下来执行下面这段代码:
父类加载器加载失败就会抛出异常,回到子类加载器的这段代码,这样就实现了加载并向下传递
最后根据传入的参数判断是否进入连接阶段:
实现打破双亲委派机制:
|
|
- 自定义类加载器父类怎么是AppClassLoader呢?
- 默认情况下自定义类加载器的父类加载器是应用程序类加载器
- 以JDK8为例,ClassLoader类中提供了构造方法设置parent的内容
- 这个构造方法由另外一个构造方法调用,其中父类加载器由getSystemClassLoader方法设置,该方法返回的是AppClassLoader
Q:两个自定义类加载器加载相同限定名的类,不会冲突吗?
A:不会冲突,在同一个Java虚拟机中,只有🚩相同类加载器+相同的类限定名🚩才会被认为是同一个类
|
|
sc -d
在Arthas中使用 sc –d 类名
的方式查看具体的情况
search class -detail
- 正确的去实现一个自定义类加载器的方式是重写findClass方法,这样不会破坏双亲委派机制
线程上下文类加载器
利用上下文类加载器加载类,比如JDBC和JNDI等
JDBC的案例:
JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动
|
|
DriverManager类位于rt.jar包中,由启动类加载器加载
依赖中的mysql驱动对应的类,由应用程序类加载器来加载
在类中有初始化代码:
DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。(这点存疑,一会儿再讨论)
DriverManager怎么知道jar包中要加载的驱动在哪儿?
在类的初始化代码中有这么一个方法 LoadInitialDrivers
:
这里使用了SPI机制,去加载所有jar包中实现了Driver接口的实现类
SPI机制
spi全称为(Service Provider Interface),是JDK内置的一种服务提供发现机制
SPI机制就是在这个位置下存放了一个文件,文件名是接口名,文件里包含了实现类的类名。这样SPI机制就可以找到实现类了
SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象
|
|
总结:
JDBC案例中真的打破了双亲委派机制吗?
最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。
但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制
所以这里==没有打破双亲委派机制==,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载
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 来修改源码
直接双击文件使用finalShell编辑:
5、mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录
使用mc 命令用来编译修改过的代码
这里会提示找不到其他的类,因为编译需要依赖其他类
使用 sc -d 全类名
找到类加载器的 hash值
6、retransform class文件所在目录/xxx.class
用 retransform 命令加载新的字节码
7、测试
注意事项:
1、程序重启之后,字节码文件会恢复,除非将class文件放入jar包中进行更新(因为只是将字节码的内容更新到了内存中)
2、使用retransform不能添加方法或者字段,也不能更新正在执行中的方法
JDK9之后的类加载器
JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于 rt.jar
包中的 sun.misc.Launcher.java
由于JDK9引入了module的概念,类加载器在设计上发生了很多变化
-
启动类加载器使用Java编写,位于JDK.internal.loader.ClassLoaders类中
- Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件
- 启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一
-
扩展类加载器被替换成了平台类加载器(Platform Class Loader)
- 平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件
- 平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑
运行时数据区
- Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区
- 内存调优路线
- 了解运行时内存结构 了解JVM运行过程中每一部分的内存结构以及哪些部分容易出现内存溢出
- 掌握内存问题的产生原因 学习代码中常见的几种内存泄漏、性能问题的常见原因
- 掌握内存调优的基本方法 学习内存泄漏、性能问题等常见JVM问题的常规解决方案
程序计数器
程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址
一个程序计数器的具体案例:
在加载阶段,虚拟机将字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址。每一条字节码指令都会拥有一个内存地址
在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。这里为了简单起见,使用偏移量代替,真实内存中执行时保存的应该是地址
比如当前执行的是偏移量为0的指令,那么程序计数器中保存的就是下一条的地址(偏移量1)
❗️❗️❗️程序计数器,实际上保存的是地址,不是图中的偏移❗️❗️❗️
一路向下执行
一直执行到方法的最后一行指令,此时方法执行return语句,当前方法执行结束,程序计数器中会放入方法出口的地址(栈中讲解,简单来说就是这个B方法结束了,A调用了B,那么要回到A方法)
所以,程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。不管是分支、跳转、异常,只需要在程序计数器中放入下一行要执行的指令地址即可
在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行
Q:程序计数器会出现内存溢出吗?
A:内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。
由于每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的。程序员无需对程序计数器做任何处理。
栈
二者使用了一个栈来保存方法信息
Java虚拟机栈
- Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存
|
|
main方法执行时,会创建main方法的栈帧:
接下来执行study方法,会创建study方法的栈帧
进入eat方法,创建eat方法的栈帧
eat方法执行完之后,会弹出它的栈帧:
然后调用sleep方法,创建sleep方法栈帧
最后study方法结束之后弹出栈帧,main方法结束之后弹出main的栈帧
Idea中
打上断点debug之后会出现栈帧内容:
抛出异常时也会打印
顺序和代码顺序相反,因为先进后出
Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。
由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。如下就有两个线程的虚拟机栈,main线程和线程A。
栈帧的组成
- 局部变量表
- 局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈
- 操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据
- 帧数据主要包含动态链接、方法出口、异常表的引用
局部变量表
- 局部变量表的作用是在方法执行过程中存放所有的局部变量
- 局部变量表分为两种,一种是==字节码文件==中的,另外一种是==栈帧==中的也就是保存在==内存==中
- 栈帧中的局部变量表是根据字节码文件中的内容生成的
字节码文件的局部变量表
- 编译成字节码文件时就可以确定局部变量表的内容
|
|
- test1方法的局部变量表如下:
- 局部变量表中保存了字节码指令生效的偏移量:
- 比如
i
这个变量,它的起始PC是2,代表从lconst_1
这句指令开始才能使用i
,长度为3,也就是2-4这三句指令都可以使用i
。为什么从2才能使用,因为0和1这两句字节码指令还在处理int i = 0
这句赋值语句。j
这个变量只有等3指令执行完之后也就是long j = 1
代码执行完之后才能使用,所以起始PC为4,只能在4这行字节码指令中使用
栈帧中的局部变量表
- 栈帧中的局部变量表,栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽
- 一个槽4字节
i
占用数组下标为0的位置,j
占用数组下标1-2的位置- 刚才看到的是静态方法,实例方法中的==序号为0的位置存放的是this==,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址
- 方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。
- test3方法中包含两个参数
k
,m
,这两个参数也会被加入到局部变量表中
Q:以下代码的局部变量表中会占用几个槽?
1 2 3 4 5 6 7 8 9 10 11
public void test4(int k,int m){ { int a = 1; int b = 2; } { int c = 1; } int i = 0; long j = 1; }
A:6
Explain:
- 为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用
- this占用1个,1/1
- k、m占用两个,3/3
- a、b占两个槽,5/5,出代码块后失效,3/5
- c复用,4/5,出代码块后失效,3/5
- i复用,4/5
- j需要两个,6/6
==复用是覆盖,局部变量不生效并没有清空槽,而是接下来的可以覆盖==
局部变量表数值的长度为6。这一点在编译期间就可以确定了,运行过程中只需要在栈帧中创建长度为6的数组即可
操作数栈
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值
- 在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小
- 比如之前的相加案例中,操作数栈最大的深入会出现在这个时刻:
- 0存放的是==this的引用==
- 所以操作数栈的深度会定义为2
帧数据
动态链接
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。==动态链接就保存了编号到运行时常量池的内存地址的映射关系==
方法出口
方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址
异常表
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置
如下案例: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的指令
内存溢出
- Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出
- Java虚拟机栈内存溢出时会出现StackOverflowError的错误
- 如果不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构
测试
|
|
- 使用递归让方法调用自身,但是不设置退出条件。定义调用次数的变量,每一次调用让变量加1。查看错误发生时总调用的次数
要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss
- 语法:
-Xss栈大小
- 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
|
|
操作步骤
验证
发现变化不大,因为最大栈大小由操作系统决定了,在1m左右。但可以调小
- 与-Xss类似,也可以使用 -XX:ThreadStackSize 调整标志来配置堆栈大小。
格式为: -XX:ThreadStackSize=1024
- HotSpot JVM对栈大小的最大值和最小值有要求:
比如测试如下两个参数,会直接报错:
-Xss1k -Xss1025m
Windows(64位)下的JDK8测试最小值为 180k
,最大值为 1024m
- 局部变量过多、操作数栈深度过大也会影响栈内存的大小
|
|
==一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存==
本地方法栈
- Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧
- 在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间
- 本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来
|
|
堆
- 一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上
- 栈上的局部变量表中,可以存放堆上对象的引用
- 静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享
|
|
通过 new
关键字创建了两个 Student
类的对象,这两个对象会被存放在堆上。在栈上通过 s1
和 s2
两个局部变量保存堆上两个对象的地址,从而实现了引用关系的建立
堆内存的溢出
|
|
三个重要的值
- 堆空间有三个需要关注的值,used、total、max
- used指的是当前已使用的堆内存
- total是java虚拟机已经分配的可用堆内存
- max是java虚拟机可以分配的最大堆内存
查看
堆内存used total max三个值可以通过dashboard命令看到
手动指定刷新频率(不指定默认5秒一次):
dashboard –i 刷新频率(毫秒)
只关注内存可以输入 memory
命令:
随着堆中的对象增多,当total可以使用的内存即将不足时,java虚拟机会继续分配内存给堆
此时used达到了total的大小,Java虚拟机会向操作系统申请更大的内存
但是这个申请过程不是无限的,==total最多只能与max相等==
那么是不是当used = max = total的时候,堆内存就溢出了呢
不是,堆内存溢出的判断条件比较复杂,在《垃圾回收器》1中会详细介绍
如果不设置任何的虚拟机参数,max默认是系统内存的1/4,total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值
设置堆的大小
要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total)
语法:-Xmx值 -Xms值
单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
限制:Xmx必须大于2MB,Xms必须大于1MB
|
|
这样可以将max和初始的total都设置为4g,在启动后就已经获得了最大的堆内存大小。运行过程中不需要向操作系统申请
为什么arthas中显示的heap堆大小与设置的值不一样呢?
arthas中的heap堆内存使用了JMX技术中内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存
建议:
Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。-Xmx具体设置的值与实际的应用程序运行环境有关,在《实战篇》2中会给出设置方案
方法区
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
- 类的元信息,保存了所有类的基本信息
- 运行时常量池,保存了字节码文件中的常量池内容
- 字符串常量池,保存了字符串常量
类的元信息
方法区是用来==存储每个类的基本信息(元信息)==,一般称之为==InstanceKlass==对象。在类的加载阶段完成
其中就包含了类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息
运行时常量池
方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池
当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池
方法区的实现
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同
Hotspot设计如下:
- ==JDK7及之前==的版本将==方法区存放在堆区域中的永久代空间==,堆的大小由虚拟机参数来控制
- ==JDK8及之后==的版本将==方法区存放在元空间中==,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配
- JDK7及之前的版本查看
ps_perm_gen
属性
- JDK8及之后的版本查看
metaspace
属性
方法区的溢出
通过ByteBuddy框架,动态创建类并将字节码数据加载到内存中
通过死循环不停地加载到方法区,观察方法区是否会出现内存溢出的情况
分别在JDK7和JDK8上运行上述代码
ByteBuddy是一个基于Java的开源库,用于生成和操作Java字节码
引入依赖
|
|
创建ClassWriter对象
|
|
调用visit方法,创建字节码数据
|
|
测试
|
|
实验发现,JDK7上运行大概十几万次,就出现了错误。
在JDK8上运行百万次,程序都没有出现任何错误,但是内存会直线升高。这说明JDK7和JDK8在方法区的存放上,采用了不同的设计
- JDK7将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数
-XX:MaxPermSize=值
来控制 - JDK8将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用-XX:MaxMetaspaceSize=值将元空间最大大小进行限制
在JDK8中将最大元空间内存设置为256m,再次测试
这次就出现了MetaSpace溢出的错误:
建议设置一个上限,256M不够再扩
字符串常量池
方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)
字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池
如下代码执行时,代码中包含 abc
字符串,就会被直接放入字符串常量池。在堆上创建String对象,并通过局部变量s1引用堆上的对象
接下来通过s2局部变量引用字符串常量池的 abc
所以s1和s2指向的不是同一个对象,打印出 false
字符串常量池和运行时常量池有什么关系?
早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分
🙋♀️🌰1:
|
|
观察字节码
String d=a+b
底层创建了一个StringBuilder对象,调用append方法添加1
、2
- 然后调用
toString
方法 - 所以d创建在了堆区
- ==c!=d==
🙋♀️🌰2:
|
|
观察字节码
- 第7行,序号9的字节码
- 使用ldc指令从常量池中取出了12
- 所以
c==d
左右都是常量 直接拼接
左右都是变量 new StringBuilder
变量 + 常量 new StringBuilder
常量 + 变量 new StringBuilder
intern方法
String.intern()方法是可以手动将字符串放入字符串常量池中
|
|
|
|
分别在JDK6 JDK8下执行代码,JDK6 中结果是false false ,JDK8中是true false
|
|
自己实验:JDK8中
|
|
自己实验:JDK17中
|
|
JDK6中,代码执行步骤如下:
使用StringBuilder将 think
和 123
拼接成 think123
,转换成字符串,在堆上创建一个字符串对象
局部变量 s1
指向堆上的对象
调用s1.intern方法,会在字符串常量池中创建think123的对象,最后将对象引用返回
所以s1.intern和s1指向的不是同一个对象。打印出false
同理,通过StringBuilder在堆上创建java字符串对象
这里注意==字符串常量池中本来就有一个java字符串对象==,这是==java虚拟机自身使用的所以启动时就会创建==出来
调用s2.intern发现字符串常量池中已经有java字符串对象了,就将引用返回
所以s2.intern指向的是字符串常量池中的对象,而s2指向的是堆中的对象。打印结果为false
JDK7中,JDK7及之后版本中由于==字符串常量池在堆上==,所以intern () 方法会把第一次遇到的字符串的引用放入字符串常量池
代码执行步骤如下:
执行第二句代码时,由于字符串常量池中没有think123的字符串,所以直接==创建一个引用==,指向堆中的think123对象
所以s1.intern和s1指向的都是堆上的对象,打印结果为true
s2.intern方法调用时,字符串常量池中已经有java字符串了,所以将引用返回
这样打印出来的结果就是false
后续JDK版本中,如果Java虚拟机不需要使用java字符串,那么字符串常量池中就不会存放
java
。打印结果有可能会出现两个true
静态变量存储在哪里呢?
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理
直接内存
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域
在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
- Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用
- IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销
写文件也是向直接内存写入,省去了从Java堆中复制的开销
使用堆创建对象的过程:
使用直接内存创建对象的过程,不需要进行复制对象,数据直接存放在直接内存中:
创建直接内存上的数据
要创建直接内存上的数据,可以使用 ByteBuffer
语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
注意事项: arthas的memory命令可以查看直接内存大小,属性名direct
|
|
直接内存也会溢出
底层如果使用了NIO,最好设置一下直接内存的上限大小
如果需要手动调整直接内存的大小,可以使用 XX:MaxDirectMemorySize=大小
单位k或K表示千字节,m或M表示兆字节,g或G表示千兆字节
默认不设置该参数情况下,JVM 自动选择 最大分配的大小
以下示例以不同的单位说明如何将 直接内存大小设置为 1024 KB:
|
|
总结
垃圾回收
- 在C/C++这类没有自动垃圾回收机制的语言中,对象如果不再使用,需要手动释放,否则就会出现内存泄漏
- 内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出
- cpp内存泄漏
-
1 2 3 4 5 6 7 8
#include "Test.h" int main() { while(1) Test* test = new Test(); return 0; }
- 手动调用delete删除对象
-
1 2 3 4 5 6 7 8 9 10 11
#include "Test.h" int main() { while(1) { Test* test = new Test(); delete test; } return 0; }
- 称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收
- 手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存
- 缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象
- Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制
- 通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对==堆上的内存==进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器
- 垃圾回收器如果发现某个对象不再使用,就可以回收该对象
- 自动垃圾回收,自动根据对象是否使用由虚拟机来回收对象
- 优点:降低程序员实现难度、降低对象回收bug的可能性
- 缺点:程序员无法控制内存回收的及时性
- 手动垃圾回收,由程序员编程实现对象的删除
- 优点:回收及时性高,由程序员把控回收的时机
- 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题
那么垃圾回收器需要负责对哪些部分的内存进行回收呢?
==线程不共享的部分,伴随着线程的创建而创建,线程的销毁而销毁==
方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存
这一部分不需要垃圾回收器负责回收
方法区的回收
方法区中能回收的内容主要就是不再使用的类
一个类可以被卸载。需要同时满足三个条件:
-
此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象
1 2 3 4 5
// 将局部变量对堆上实例对象的引用去除 Class<?>clazz = loader.loadclass(name:"com.itheima.my.A"); object o = clazz.newInstance(); o = null;
-
加载该类的类加载器已经被回收
1 2 3 4 5
// 局部变量对类加载器的引用去除 URLClassLoader loader = new URLClassLoader( new URL[]{new URL(spec:"file:D:\\1ib\\")}); loader = null;
-
该类对应的 java.lang.Class 对象没有在任何地方被引用
1 2
Class<?>clazz = loader.loadclass(name:"com.itheima.my.A"); clazz = null;
在虚拟机上添加参数
-XX:+TraceClassLoading -XX:+TraceClassUnloading
可以打印类加载和类卸载的过程
|
|
System.gc()
语法: System.gc()
注意事项:
调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断
类卸载主要用在什么场景下呢?
开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中
每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器
重新创建类加载器,重新加载jsp文件
如何判断对象可以回收
==垃圾回收器要回收对象的第一步就是判断哪些对象可以回收==
Java中的对象是否能被回收,是根据对象是否被引用来决定的
如果对象被引用了,说明该对象还在使用,不允许被回收
比如下面代码的内存结构图:
第一行代码执行之后,堆上创建了Demo类的实例对象,同时栈上保存局部变量引用堆上的对象
第二行代码执行之后,局部变量对堆上的对象引用去掉,那么堆上的对象就可以被回收了
一个更复杂的案例:
这个案例中,如果要让对象a和b回收,必须将局部变量到堆上的引用去除
Q:那么问题来了,A和B互相之间的引用需要去除吗?
A:不需要,因为局部变量没引用这两个对象,在代码中已经无法访问这两个对象,即便他们之间互相有引用关系,也不影响对象的回收
判断对象是否可以回收,主要有两种方式:引用计数法和可达性分析法
引用计数法(无法解决循环引用)
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1
引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:
- 每次引用和取消引用都需要维护计数器,对==系统性能==会有一定的影响
- 存在==循环引用==问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题
这张图上,由于A和B之间存在互相引用,所以计数器都为1,两个对象都不能被回收
但是由于没有局部变量对这两个代码产生引用,代码中已经无法访问到这两个对象,理应可以被回收
查看垃圾回收日志
使用 -verbose:gc
参数
加上这个参数之后执行代码,发现对象确实被回收了:
通过不断的死循环创建对象,内存并没有上升,一直维持在1000K,说明每轮循环创建的两个对象在垃圾回收之后都被回收了
可达性分析法(看一个普通对象是否从GC Root可达)
==Java使用可达性分析算法==来判断对象是否可以被回收
可达性分析将对象分为两类:垃圾回收的==根对象==(GC Root)和==普通对象==,对象与对象之间存在引用关系
==GC ROOT对象一般不可被回收==
下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个到GC Root对象是可达的,对象就不可被回收
哪些对象被称之为GC Root对象呢?
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等
- 系统类加载器加载的java.lang.Class对象,引用类中的静态变量
- 监视器对象,用来保存同步锁synchronized关键字持有的对象
- 本地方法调用时使用的全局对象
通过arthas和eclipse Memory Analyzer (MAT) 工具可以查看GC Root,MAT工具是eclipse推出的Java堆内存检测工具
具体操作步骤如下:
1、使用arthas的heapdump命令将堆内存快照保存到本地磁盘中
heapdump 输出路径+文件名+.hprof
2、使用MAT工具打开堆内存快照文件
3、选择GC Roots功能查看所有的GC Root
- 打开资料中提供的MAT工具,如果出现如下错误,将环境变量中的JDK版本升级到17以上
- 选择菜单中的打开堆内存快照功能,并选择文件
- 选择内存泄漏检测报告,并确定
- 通过菜单找到GC Roots
- MAT对4类GC Root对象做了分类
- 找到静态变量
- 找到局部变量
JProfiler分析器
软引用
可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收
软引用相对于强引用是一种比较弱的引用关系,如果==一个对象只有软引用关联==到它,==当程序内存不足时,就会将软引用中的数据进行回收==
在JDK 1.2版之后提供了 SoftReference
类来实现软引用,软引用常用于缓存中
如下图中,对象A被GC Root对象强引用了,同时创建了一个软引用SoftReference对象(它本身也是一个对象),软引用对象中引用了对象A
接下来强引用被去掉之后,对象A暂时还是处于不可回收状态,因为有软引用存在并且内存还够用
如果内存出现不够用的情况,对象A就处于可回收状态,可以被垃圾回收器回收
Q:这样做有什么好处?
如果对象A是一个缓存,平时会保存在内存中,如果想访问数据可以快速访问,但是如果内存不够用了,就可以将这部分缓存清理掉释放内存
即便缓存没了,也可以从数据库等地方获取数据,不会影响到业务正常运行,这样可以减少内存溢出产生的可能性
特别注意:
==软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉==
软引用的使用方法
软引用的执行过程如下:
- 将对象使用软引用包装起来,new SoftReference<对象类型>(对象)
- 内存不足时,虚拟机尝试进行垃圾回收
- 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象
- 如果依然内存不足,抛出OutOfMemory异常
基本使用案例
|
|
添加虚拟机参数,限制最大堆内存大小为200m:_Xmx200m
执行后发现,第二个100m对象创建之后,软引用中包含的对象已经被回收了
|
|
软引用对象本身的回收
如果软引用对象里边包含的数据已经被回收了,那么软引用对象本身其实也可以被回收了
SoftReference提供了一套队列机制:
- 软引用创建时,通过构造器传入引用队列
- 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列
- 通过代码遍历引用队列,将
SoftReference
的强引用删除
|
|
代码解释:
创建存储
SoftReference
对象的ArrayList
:
1
ArrayList<SoftReference> softReferences = new ArrayList<>();
这是一个
ArrayList
,用来保存SoftReference
对象。创建
ReferenceQueue
对象:
1
ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
ReferenceQueue
是一个特殊的队列,用于存放被垃圾回收器回收的引用对象。创建和添加
SoftReference
对象:
1 2 3 4 5
for (int i = 0; i < 10; i++) { byte[] bytes = new byte[1024 * 1024 * 100]; // 分配100MB的数组 SoftReference<byte[]> studentRef = new SoftReference<byte[]>(bytes, queues); softReferences.add(studentRef); }
这里创建了 10 个 100MB 的
byte
数组,并将每个数组包装在一个SoftReference
对象中。然后,将这些SoftReference
对象添加到softReferences
列表中。检查
ReferenceQueue
中的被回收引用:
1 2 3 4 5 6
SoftReference<byte[]> ref = null; int count = 0; while ((ref = (SoftReference<byte[]>) queues.poll()) != null) { count++; } System.out.println(count);
这个循环从
ReferenceQueue
中取出被回收的SoftReference
对象,并计数。queues.poll()
方法返回队列中的下一个引用,如果队列为空,则返回null
。
SoftReference
和ReferenceQueue
的作用:
- SoftReference
SoftReference
对象引用的内存只有在 JVM 内存不足时才会被回收。它们适用于实现内存敏感的缓存。- ReferenceQueue
ReferenceQueue
用于跟踪被垃圾回收的引用对象。每当一个SoftReference
所引用的对象被回收时,该SoftReference
会被加入到与之关联的ReferenceQueue
中。
这9个软引用对象中包含的数据已经被回收掉,所以可以手动从ArrayList中去掉,这样就可以释放这9个对象
|
|
软引用的缓存案例
使用软引用实现学生信息的缓存,能支持内存不足时清理缓存
|
|
StudentCache
类:
- 单例模式实现的缓存器类,用于缓存
Student
对象。- 使用
Map
存储StudentRef
对象,以Integer
作为键,StudentRef
作为值。ReferenceQueue
用于存放被垃圾回收的StudentRef
对象。
StudentRef
类:
- 继承
SoftReference
,并持有一个Integer
类型的键,用于与Map
中的键关联。
cacheStudent
方法:
- 接收一个
Student
对象,以软引用的方式存入缓存。- 调用
cleanCache
方法清除被垃圾回收的StudentRef
对象。
getStudent
方法:
- 通过
Student
对象的ID从缓存中获取相应的Student
对象。- 如果缓存中没有该对象,或者该对象已经被回收,则重新创建一个新的
Student
对象,并存入缓存。
cleanCache
方法:
- 从
ReferenceQueue
中移除所有被垃圾回收的StudentRef
对象,并从Map
中删除相应的键值对。
Student
类:
- 简单的
Student
类,包含ID和姓名属性,以及相应的getter和setter方法。
单例模式
单例模式是一种创建型设计模式,其目的是确保一个类在内存中只有一个实例,并提供一个全局访问点。单例模式的主要目的是控制实例的创建,以避免在整个应用程序中存在多个实例,从而减少内存开销,确保数据的一致性
在Java中,单例模式通常通过以下几种方式实现:
- 饿汉式:在类加载时就创建实例
- 懒汉式:在第一次使用时才创建实例
- 双重检查加锁:结合懒汉式和线程安全的优点,避免多次创建实例
- 静态内部类:利用类加载机制,确保实例的唯一性和线程安全
- 枚举:利用枚举类型的特性,确保线程安全和单一实例
|
|
-
静态变量
cache
:1
private static StudentCache cache = new StudentCache();
- 这是
StudentCache
类的唯一实例。它是静态的,这意味着它与类本身相关联,而不是类的任何实例。它在类加载时就被创建,因此是饿汉式单例模式
- 这是
-
私有构造函数:
1 2 3 4
private StudentCache() { StudentRefs = new HashMap<Integer, StudentRef>(); q = new ReferenceQueue<Student>(); }
- 构造函数被声明为私有的,这意味着外部类无法直接实例化这个类。这确保了不会有其他方式来创建类的实例
-
公共的静态方法
getInstance
:1 2 3
public static StudentCache getInstance() { return cache; }
- 这是一个公共的静态方法,返回唯一的
StudentCache
实例。每次调用这个方法时,都会返回同一个实例
- 这是一个公共的静态方法,返回唯一的
通过以上三点,StudentCache
类实现了单例模式,确保在整个应用程序运行过程中,StudentCache
类只有一个实例。
单例模式的优点
- 控制实例数量:
- 确保一个类只有一个实例,节省内存空间
- 可以避免实例之间的冲突,确保数据一致性
- 全局访问点:
- 提供了一个全局访问点,可以方便地访问唯一实例
单例模式的应用确保了 StudentCache
只有一个实例,这样在缓存学生对象时,不会出现多个缓存器实例,确保了缓存的一致性和可靠性
弱引用
弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,==不管内存够不够都会直接被回收==
在JDK 1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用
弱引用对象本身也可以使用引用队列进行回收
|
|
bytes = [B@1b6d3586 [B@1b6d3586 bytes = null null
虚引用
在常规开发中不会使用
虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象
虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知
Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现
终结器引用
在常规开发中不会使用
终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收
在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做
|
|
垃圾回收算法
简单来说,垃圾回收要做的有两件事:
1、找到内存中存活的对象
2、释放不再存活对象的内存,使得程序能再次利用这部分空间
垃圾回收算法的历史和分类
1960年John McCarthy发布了第一个GC算法:标记-清除算法
1963年Marvin L. Minsky 发布了复制算法
本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来
垃圾回收算法的评价标准
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程
这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用
如下图,用户代码执行和垃圾回收执行让用户线程停止执行(STW)是交替执行的
所以判断GC算法是否优秀,可以从三个方面来考虑:
- 吞吐量
吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即==吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)==
吞吐量数值越高,垃圾回收的效率就越高
- 最大暂停时间
最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值
最大暂停时间越短,用户使用系统时受到的影响就越短
- 堆使用效率
不同垃圾回收算法,对堆内存的使用方式是不同的
如标记清除算法,可以使用完整的堆内存
而复制算法会将堆内存一分为二,每次只能使用一半内存
从堆使用效率上来说,标记清除算法要优于复制算法
==堆使用效率、吞吐量,和最大暂停时间不可兼得==
一般来说,堆内存越大,最大暂停时间就越长
想要减少最大暂停时间,就会降低吞吐量
没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景
标记清除算法
标记清除算法的核心思想分为两个阶段:
-
标记阶段,将所有存活的对象进行标记
- Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象
-
清除阶段,从内存中删除没有被标记也就是非存活对象
第一个阶段,从GC Root对象开始扫描,将对象A、B、C在引用链上的对象标记出来:
第二个阶段,将没有标记的对象清理掉,所以对象D就被清理掉了
优点:==实现简单==,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可
缺点:1.==碎片化问题==
由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元
如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配
如下图,红色部分已经被清理掉了,总共回收了9个字节,但是每个都是一个小碎片,无法为5个字节的对象分配空间
2.==分配速度慢==
由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间
需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够去创建一个对象
如下图,遍历到最后才发现有足够的空间分配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
空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间
缺点:
==内存使用效率低==,只能让一半的内存空间来为创建对象使用
标记整理算法
标记整理算法也叫标记压缩算法,==是对标记清理算法中容易产生内存碎片问题的一种解决方案==
核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记
Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象
2.整理阶段,将存活对象移动到堆的一端
清理掉存活对象的内存空间
优点:
- ==内存使用效率高,整个堆内存都可以使用==,不会像复制算法只能使用半个堆内存
- ==不会发生碎片化==,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
==整理阶段的效率不高==,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳
可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能
分代垃圾回收算法
JVM: GC过程总结(minor GC 和 Full GC)-CSDN博客
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)
将整个内存区域划分为年轻代和老年代:
在JDK8中,添加 -XX:+UseSerialGC
参数开启使用分代回收的垃圾回收器,运行程序
在arthas中使用 memory
命令查看内存,显示出三个区域的内存情况
可以选择的虚拟机参数如下
参数名 | 参数含义 | 示例 |
---|---|---|
-Xms | 设置堆的最小和初始大小,必须是1024倍数且大于1MB | 比如初始大小6MB的写法: -Xms6291456 -Xms6144k -Xms6m |
-Xmx | 设置最大堆的大小,必须是1024倍数且大于2MB | 比如最大堆80 MB的写法: -Xmx83886080 -Xmx81920k -Xmx80m |
-Xmn | 新生代的大小 | 新生代256 MB的写法: -Xmn256m -Xmn262144k -Xmn268435456 |
-XX:SurvivorRatio | 伊甸园区和幸存区的比例,默认为8 新生代1g内存,伊甸园区800MB,S0和S1各100MB | 比例调整为4的写法:-XX:SurvivorRatio=4 |
-XX:+PrintGCDetails``-verbose:gc | 打印详细GC日志``打印GC日志 | 无 |
-XX:+UseSerialGC
-Xms60m 堆最小60M
-Xmn20m 新生代20M
-Xmx60m 最大堆60M
-XX:SurvivorRatio=3 伊甸园区:幸存区的比例=3:1 ⚠️幸存区有S0、S1 所以5x=20,x=4 ==12、4、4==
-XX:+PrintGCDetails
|
|
heap展示的是可用堆,survivor区每次只有一块能使用,所以60 - 4 = 56m
==s0、s1使用复制算法,所以每次只能用一块==
回收执行流程
1、分代回收时,创建出来的对象,首先会被放入Eden伊甸园区
2、随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC
Minor GC会把需要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(回收整个堆的所有对象)
当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常
|
|
垃圾回收器
Q:为什么分代GC算法要把堆分成年轻代和老年代?
A:首先要知道堆内存中对象的特性:
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了
- 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小
分代GC算法将堆分成年轻代和老年代主要原因有:
1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能
2、新生代和老年代使用不同的垃圾回收算法,==新生代一般选择复制算法==,==老年代可以选择标记-清除和标记-整理算法==,由程序员来选择灵活度较高
3、分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少
垃圾回收器是垃圾回收算法的具体实现
由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用
具体的关系图如下:
年轻代-Serial垃圾回收器
Serial是是一种==单线程串行==回收==年轻代==的垃圾回收器
回收年代和算法:
年轻代
==复制算法==
优点
单CPU处理器下吞吐量非常出色
缺点
多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景
Java编写的客户端程序或者硬件配置有限的场景
老年代-SerialOld垃圾回收器
SerialOld是Serial垃圾回收器的==老年代==版本,采用==单线程串行==回收
-XX:+UseSerialGC
新生代、老年代都使用串行回收器
回收年代和算法:
老年代
==标记-整理算法==
优点
单CPU处理器下吞吐量非常出色
缺点
多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景
与Serial垃圾回收器搭配使用,或者在CMS特殊情况下使用
年轻代-ParNew垃圾回收器
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用==多线程==进行垃圾回收
-XX:+UseParNewGC
==新生代使用ParNew回收器==, ==老年代使用串行回收器==
回收年代和算法:
年轻代
==复制算法==
优点
多CPU处理器下停顿时间较短
缺点
吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用
适用场景
JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用
老年代- CMS(Concurrent Mark Sweep)垃圾回收器
CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,==减少了用户线程的等待时间==
参数:XX:+UseConcMarkSweepGC
回收年代和算法:
老年代
==标记清除算法==
优点
系统由于垃圾回收出现的停顿时间较短,用户体验好
缺点
1、内存碎片问题
2、退化问题
3、浮动垃圾问题
适用场景
大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等
CMS执行步骤:
1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象
2.并发标记, 标记所有的对象,用户线程不需要暂停
3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记
4.并发清理,清理死亡的对象,用户线程不需要暂停
缺点:
1、CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N 参数(默认0)调整N次Full GC之后再整理
2、无法处理在并发清理过程中产生的“浮动垃圾”(清理的过程中,创建的新对象,新对象立马也不使用了),不能做到完全的垃圾回收
3、如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代
并发线程数:
在CMS中并发阶段运行时的线程数可以通过-XX:ConcGCThreads参数设置,默认值为0,由系统计算得出。
计算公式为(-XX:ParallelGCThreads定义的线程数 + 3) / 4, ParallelGCThreads是STW停顿之后的并行线程数
ParallelGCThreads是由处理器核数决定的:
1、当cpu核数小于8时,ParallelGCThreads = CPU核数
2、否则 ParallelGCThreads = 8 + (CPU核数 – 8 )*5/8
我的电脑上逻辑处理器有12个,所以ParallelGCThreads = 8 + (12 - 8)* 5/8 = 10,ConcGCThreads = (-XX:ParallelGCThreads定义的线程数 + 3) / 4 = (10 + 3) / 4 = 3
最终可以得到这张图:
并发标记和并发清理阶段,会使用3个线程并行处理。重新标记阶段会使用10个线程处理
由于CPU的核心数有限,并发阶段会影响用户线程执行的性能
年轻代-Parallel Scavenge垃圾回收器
Parallel Scavenge是==JDK8默认的年轻代垃圾回收器==,==多线程并行==回收,关注的是==系统的吞吐量==。具备==自动调整堆内存大小==的特点
回收年代和算法:
年轻代
==复制算法==
优点
吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
缺点
不能保证单次的停顿时间
适用场景
后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出
常用参数:
Parallel Scavenge允许手动设置最大暂停时间和吞吐量
Oracle官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小
- 最大暂停时间,
-XX:MaxGCPauseMillis=n
设置每次垃圾回收时的最大停顿毫秒数 - 吞吐量,
-XX:GCTimeRatio=n
设置吞吐量为n(用户线程执行时间 = n/n + 1) - 自动调整内存大小,
-XX:+UseAdaptiveSizePolicy
设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小
==设置值不一定就是真实值==
老年代-Parallel Old垃圾回收器
Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用==多线程并发==收集
参数: -XX:+UseParallelGC
或 -XX:+UseParallelOldGC
可以使用Parallel Scavenge + Parallel Old这种组合
回收年代和算法:
老年代
==标记-整理算法==
优点
并发收集,在多核CPU下效率较高
缺点
暂停时间会比较长
适用场景
与Parallel Scavenge配套使用
G1垃圾回收器
JDK7
出现
JDK9
之后默认的垃圾回收器是G1(Garbage First)垃圾回收器
- Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小
- CMS关注暂停时间,但是吞吐量方面会下降
而G1设计目标就是将上述两种垃圾回收器的优点融合:
- 支持==巨大的堆空间回收==,并有==较高的吞吐量==
- 支持==多CPU并行==垃圾回收
- 允许用户==设置最大暂停时间==
JDK9之后强烈建议使用G1垃圾回收器
G1出现之前的垃圾回收器,年轻代和老年代一般是连续的,如下图:
- G1的整个堆会被划分成多个大小相等的区域,称之为区
Region
,区域不要求是连续的
分为 Eden
、Survivor
、Old
区
Region的大小通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M
G1垃圾回收有两种方式:
- 年轻代回收(Young GC)
- 混合回收(Mixed GC)
年轻代回收
年轻代回收(Young GC),回收Eden区和Survivor区中不用的对象,会导致STW
G1中可以通过参数 -XX:MaxGCPauseMillis=n
(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间
- 新创建的对象会存放在Eden区
- 当G1判断年轻代区不足(max默认60%,年轻代+Eden+Survival占总堆空间60%),无法分配对象时需要回收时会执行Young GC
- 标记出Eden和Survivor区域中的存活对象
- 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中**(年龄+1)**,清空这些区域
采用的是复制算法,不会产生碎片
G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据
这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了
比如 -XX:MaxGCPauseMillis=n(默认200),每个Region回收耗时40ms,那么这次回收最多只能回收4个Region
- 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区
- 当某个存活对象的年龄到达阈值(默认15),将被放入老年代
- ==部分对象如果大小超过Region的一半,会直接放入老年代==,这类老年代被称为
Humongous
区 - 比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region
- 多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时
(-XX:InitiatingHeapOccupancyPercent
默认45%)会触发混合回收Mixed GC
==回收所有年轻代和部分老年代的对象以及大对象区==
采用==复制算法==来完成
混合回收
混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize Marking)、并发清理(cleanup)
G1对老年代的清理会选择==存活度最低==的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来
最后清理阶段使用复制算法,不会产生内存碎片
注意:如果清理过程中发现没有足够的空Region存放转移的对象,
会出现Full GC 单线程执行标记-整理算法,此时会导致用户线程的暂停
所以尽量保证应该用的堆内存有一定多余的空间
G1 – Garbage First 垃圾回收器
参数1: -XX:+UseG1GC
打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值
最大暂停的时
回收年代和算法:
==年轻代+老年代==
==复制算法==
优点
对比较大的堆如超过6G的堆回收时,==延迟可控==
==不会产生内存碎片==
==并发标记的SATB算法==效率高
缺点
JDK8之前还不够成熟
适用场景
JDK8最新版本、JDK9之后建议默认使用
使用以下代码测试g1垃圾回收器,打印出每个阶段的时间:
|
|
每个region大小为4m,一共有297个young区,256个幸存者区
|
|
初始标记+年轻代回收 耗时0.0218896秒
|
|
并发标记总共耗时40ms,不会产生STW
|
|
完整
|
|
总结
垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:
JDK8及之前:
ParNew + CMS(关注暂停时间)、Parallel Scavenge + Parallel Old (关注吞吐量)、 G1(JDK8之前不建议,较大堆并且关注暂停时间)
JDK9之后:
G1(默认)
从JDK9之后,由于G1日趋成熟,JDK默认的垃圾回收器已经修改为G1,所以强烈建议在生产环境上使用G1
G1的实现原理将在《原理篇》中介绍,更多前沿技术ZGC、GraalVM将在《高级篇》中介绍