初识 JVM#
什么是 JVM#
JVM 全称为 Java Virtual Machine 本质上是一个运行在计算机上的程序,他的职责是运行 Java 字节码文件
先用 javac 将源代码 .java 编译成 .class 字节码文件,然后利用 java 命令启动 JVM 运行字节码,JVM 会混合使用解释执行和 JIT 编译执行的方式在计算机上最终执行
JVM 功能#
JVM 功能
- 解释和运行:
- 对字节码文件中的指令, 实时的解释成机器码, 让计算机执行
- 内存管理:
- 自动为对象、方法等分配内存
- 自动的垃圾回收机制, 回收不再使用的对象
- 即时编译 Just-In-time JIT:
- 对热点代码进行优化, 提升执行效率
字节码机器无法识别,需要 JVM 实时解释为机器码去执行
观察 C 语言,将 .c 源代码直接编译成机器码,明显差了很多
所以添加了即时编译功能,即将热点字节码指令解释优化后保存在内存中,后续可以直接调用,省去再次重复解释的过程,优化性能
Java 虚拟机规范#
规范了当前版本二次开发的虚拟机需要满足的规范:包含 class 字节码文件的定义、类和接口的加载和初始化、指令集等内容
《Java 虚拟机规范》是对虚拟机设计的要求,而不是对 java 设计的要求,也就是说虚拟机可以运行在其他的语言比如 Groovy、Scala 生成的 class 字节码文件之上
名称 | 作者 | 支持版本 | 社区活跃度(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 特性支持 | 微服务、云原生架构 |
HotSpot 最为广泛
字节码文件详解#
Java 虚拟机的组成#
类加载器 ClassLoader:核心组件类加载器,负责将字节码文件中的内容加载到内存中
运行时数据区域:管理 JVM 使用到的内存,创建出来的对象、类的信息等内容都会放在这块区域中
执行引擎:包含了即时编译器、解释器、垃圾回收器;执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象
本地接口:调用本地使用 C/C++ 编译好的方法,本地方法会在 Java 中带上 native 关键字声明,如 public static native void sleep(long millis) throws InterruptedException;
字节码文件的组成#
查看字节码#
字节码文件查看器 jclasslib
字节码文件组成#
字节码文件组成部分
- 基本信息:魔数、字节码文件对应的 Java 版本号、访问标识 (public final 等)、父类和接口信息
- 常量池:保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
- 字段:当前类或接口声明的字段信息
- 方法:当前类或接口声明的方法信息,核心内容为方法的字节码指令
- 属性:类的属性,比如源码的文件名、内部类的列表等
基本信息#
文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改,不影响文件的内容
软件通过使用文件的头几个字节(文件头)去校验文件的类型,如果软件不支持该种类型就会出错
Java 字节码文件中,将文件头称为 magic 魔数
Java 虚拟机会校验字节码文件的前四个字节是不是 0xcafebabe,如果不是,该字节码文件就无法正常使用,Java 虚拟机会抛出对应的错误
主版本号用于判断当前字节码版本 与 JVM 是否兼容
主副版本号指的是编译字节码文件时使用的 JDK 版本号,主版本号用来标识大版本号,副版本号用于区别不同住版本号的标识
JDK 1.0 - 1.1 使用了 45.0 - 45.3
JDK 1.2 后大版本号计算方法为 主版本号 - 44,如 52 主版本号为 JDK 8
如果出现不兼容情况,比如字节码文件版本 52,但 JVM 版本是 50
- 升级 JDK
- 降低字节码文件需求版本,降低依赖的版本或者更换依赖
一般选择 2,调整依赖,因为升级 JDK 是一个比较大的动作,可能引发兼容问题
常量池#
可以节省字符串字面量占用,只存一份,字符串存储 String 类的常量并指向一个 UTF-8 的字面量常量
情况:a="abc"; abc="abc"
这时候只有一个 UTF-8 的字面量常量,被 String 类常量的饮用,作为变量 abc
的 name
方法#
引入:int i=0;i=i++;
最终 i 的值是多少?
局部变量表根据声明的顺序作为下标,这里传参的 args 为 0,i 和 j 为 1 2,操作数栈用于操作数
int i=0; int j=i+1;
字节码指令解析
-
iconst_0,将常量 0 放入操作数栈,此时栈上只有 0
-
istore_1 弹出操作数栈,并将之存放到局部变量表 1 号位置
-
iload_1 将局部变量表 1 号位置的数放入操作数栈中,即放入 0
-
iconst_1 给操作数栈压入常量 1
-
iadd 将操作数栈中顶部两个数相加,并放回栈中,即操作数栈只剩下常数 1
-
istore_2 弹出操作数栈中顶部元素 1,并将之存放到局部变量表 2 号位置,完成变量 j 的赋值
-
return 语句执行,方法结束并返回
int i=0; i=i++;
方法字节码
int i=0; i=++i;
方法字节码
一般来说字节码指令数越多性能越差,对于下面三种 +1 的性能?
int i=0,j=0,k=0;
i++;
j = j + 1;
k += 1;
三种典型字节码生成,但实际上 JIT 编译器可能会讲这几种都优化成 iinc
i++;
(iinc
)j = j + 1;
(iload
,iconst_1
,iadd
,istore
)k += 1;
(iload
,iconst_1
,iadd
,istore
)
字段#
字段中存放的是当前类或接口声明的字段信息
如下图中,定义了两个字段 a1 和 a2,这两个字段就会出现在字段这部分内容中,同时还包含字段的名字、描述符(字段的类型)、访问标识(public/private static final 等)
属性#
属性主要指的是类的属性,比如源码的文件名、内部类的列表等
字节码常用工具#
javap 是 JDK 自带的反编译工具,可以通过控制台查看字节码文件的内容
直接输入 javap 查看所有参数,输入 javap -v xxx.class
查看具体的字节码信息,如果是 jar 包需要先使用 jar –xvf
命令解压
jclasslib 也有 IDEA 插件版本,可以查看代码编译后的字节码文件的内容
新工具:阿里 Arthas
Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,利用反射
下载 Arthas 文档 jar 包并运行即可
相关命令
dump -d /tmp/output java.lang.String
将字节码文件保存到本地jad --source-only demo.MathGame
将类的字节码反编译成源代码,用于确认代码
类的生命周期#
生命周期概述#
加载 连接 初始化 使用 卸载
加载阶段#
-
加载 Loading 阶段的第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息,程序员可以使用 Java 代码拓展的不同的渠道
- 从本地磁盘上获取文件
- 运行时通过动态代理生成,比如 Spring 框架
- Applet 技术通过网络获取字节码文件
-
类加载器在加载完类之后,JVM 会将字节码中的信息保存到方法区中,方法区中生成一个 InstanceKlass 对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息
-
JVM 同时会在堆上生成与方法区中数据类似的 java.lang.Class 对象,作用是在 Java 代码中去获取类的信息以及存储静态字段的数据 JDK8 及之后
连接阶段#
连接阶段分为三个子阶段:
- 验证,验证内容是否满足《Java 虚拟机规范》
- 准备,给静态变量赋初值
- 解析,将常量池中的符号引用替换成指向内存的直接引用
连接阶段 - 验证#
验证的主要目的是检测 Java 字节码文件是否遵守了《Java 虚拟机规范》中的约束,这个阶段一般不需要程序员参与
- 文件格式验证,比如文件是否以 0xCAFEBABE 开头,主次版本号是否满足当前 Java 虚拟机版本要求,JDK 版本不可小于文件版本
- 元信息验证,例如类必须有父类(super 不能为空)
- 验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置
- 符号引用验证,例如是否访问了其他类中 private 的方法等
连接阶段 - 准备#
准备阶段为静态变量 static 分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值,注意:此处的初值为每种类型的默认值,不是代码中设置的初始值
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用数据类型 | null |
如下示例,在连接阶段 - 准备子阶段中会给 value
分配内存并赋初值 0,在初始化阶段才会将值修改为 1
public class Student {
public static int value = 1;
}
例外是,final 修饰的变量,因为 final 修饰的变量以后不会发生值的变更,所以会在准备阶段将代码中的值赋值
连接阶段 - 解析#
解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容
直接引用即使用内存中的地址访问具体数据
初始化阶段#
初始化阶段会执行字节码文件中 clinit (class init 类的初始化) 方法的字节码指令,包含了为静态变量赋值、执行静态代码块中的代码(按照代码顺序)
clinit 方法的执行顺序与代码顺序一致
putstatic 指令会将操作数栈上的数弹出来,并放入堆中静态变量的位置,字节码指令中 #2 指向了常量池中的静态变量 value,在解析阶段会被替换成变量的地址
以下几种方式会导致类的初始化:
- 访问一个类的静态变量或者静态方法,注意变量是 final 修饰的并且等号右边是常量不会触发类初始化,因为这个变量已经在连接阶段的准备阶段赋值
- 调用 Class.forName (String className) 可用入参控制是否初始化
- new 一个该类的对象时
- 执行 Main 方法的当前类
在 Java 启动参数中添加 -XX:+TraceClassLoading 参数可以打印出加载并初始化的类
示例题
clinit 指令在特定情况下不会出现,比如:
- 无静态代码块且无静态变量赋值语句
- 有静态变量的声明,但是没有赋值语句
- 静态变量的定义使用 final 关键字,这类变量会在连接阶段的准备阶段时直接赋值
继承情况:
- 直接访问父类的静态变量,不会触发子类的初始化
- 子类的初始化 clinit 调用之前,会先调用父类的 clinit 初始化方注
示例题
初始化子类前先初始化父类
直接访问父类静态变量不会触发子类初始化
数组的创建不会导致数组中元素的类进行初始化
public class Test2 {
public static void main(String[] args) {
Test2_A[] arr = new Test2_A[10];
}
}
class Test2_A {
static {
System.out.println("Test2 A的静态代码块运行");
}
}
final 修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行 clinit 方法进行初始化
public class Test4 {
public static void main(String[] args) {
System.out.println(Test4_A.a);
}
}
class Test4_A {
public static final int a = Integer.valueOf(1);
static {
System.out.println("Test4_A的静态代码块运行");
}
}
类加载器#
类加载器 ClassLoader 是 JVM 提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分
类加载器会通过二进制流的方式获取到字节码文件的内容,接下来将获取到的数据交给 JVM,虚拟机会在方法区和堆上生成对应的对象保存字节码信息
类加载器分类#
类加载器分为两类,一类是 Java 代码中实现的,一类是 JVM 底层源码实现
JDK 8 及之前的版本#
类加载器 BootStrap,加载 JRE 内核心 jar 包,在 Java 代码中无法获取到这个底层的 ClassLoader
扩展类加载器 Extension 应用程序类加载器 Application,都位于 sun.misc.Launcher 中,是一个静态内部类,继承自 URLClassLoader,具备通过目录或指定 jar 包方式加载字节码文件到内存中
使用 -Djava.ext.dirs=jar包目录
参数可以拓展使用的扩展 jar 包目录,利用;(windows) :(macos/linux) 进行目录路径分割
应用程序类加载器会加载 classpath 下的类文件,默认加载的是项目中的类以及通过 maven 引入的第三方 jar 包中的类
类加载器的双亲委派机制#
由于 Java 虚拟机中有多个类加载器,双亲委派机制的核心是解决一个类到底由谁加载的问题
机制作用:
- 避免恶意代码替换 JDK 中的核心类库,如 java.lang.String 确保核心类库的完整性和安全性
- 避免重复加载,保证一个类只被一个类加载器加载
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下尝试加载
向下委派加载起到了加载优先级的作用,从启动类加载器向下尝试加载,如果在其加载目录下就成功加载
示例:dev.chanler.my.C 在 classpath 中;从 Application 向上查找,都没有加载过;从 Bootstrap 向下尝试加载,都不在加载目录中,只有 Application 可以加载成功,因为 C 在 classpath 中
题目:
- 如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?
- 启动类加载器加载,根据双亲委派机制,它的优先级是最高的
- String 类能覆盖吗,在自己的项目中去创建一个 java.lang.String 类,会被加载吗?
- 不能,会返回启动类加载器加载在 rt.jar 包中的 String 类
- 类的双亲委派机制是什么?
- 当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载
- 应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器,但在代码中是 null,因为 Bootstrap 无法被获取
- 双亲委派机制的好处有两点:第一是避免恶意代码替换 JDK 中的核心类库,比如 java.lang.String,确保核心类库的完整性和安全性;第二是避免一个类被重复加载
打破双亲委派机制#
打破双亲委派机制有三种方式,但本质上只有第一种算是真正的打破了双亲委派机制:
- 自定义类加载器:自定义类加载器并且重写 loadClass 方法,Tomcat 通过这种方式实现应用之间类隔离
- 线程上下文类加载器:利用上下文类加载器加载类,比如 JDBC 和 JNDI 等
- Osgi 框架的类加载器:历史上 Osgi 框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用
打破双亲委派机制 - 自定义类加载器#
一个 Tomcat 程序可以运行多个 Web 应用,如果这两个应用出现了相同的限定名如 Servlet 类,Tomcat 就要保证这两个类都能加载并且他们应该是不同的类,所以不打破双亲委派机制就无法加载第二个 Servlet 类
Tomcat 使用了自定义类加载器来实现应用之间类的隔离,每一个应用会有一个独立的类加载器加载对应的类
ClassLoader 四个核心方法
public Class<?> loadClass(String name)
类加载的入口,提供了双亲委派机制。内部会调用findClass 重要
protected Class<?> findClass(String name)
由类加载器子类实现,获取二进制数据调用defineClass ,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据 重要
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中
protected final void resolveClass(Class<?> c)
执行类生命周期中的连接阶段,loadClass默认false
loadClass 方法默认 resolve 为 false,不会进行连接阶段和初始化阶段
Class.forName 会进行加载、连接、初始化
要打破双亲委派机制也就是对 loadClass 内的核心逻辑重新实现
自定义类加载器 parent 默认为 AppClassLoader
/**
* 打破双亲委派机制 - 自定义类加载器
*/
public class BreakClassLoader1 extends ClassLoader {
private String basePath;
private final static String FILE_EXT = ".class";
//设置加载目录
public void setBasePath(String basePath) {
this.basePath = basePath;
}
//使用commons io 从指定目录下加载文件
private byte[] loadClassData(String name) {
try {
String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));
FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
try {
return IOUtils.toByteArray(fis);
} finally {
IOUtils.closeQuietly(fis);
}
} catch (Exception e) {
System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
return null;
}
}
//重写loadClass方法
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
//如果是java包下,还是走双亲委派机制
if(name.startsWith("java.")){
return super.loadClass(name);
}
//从磁盘中指定目录下加载
byte[] data = loadClassData(name);
//调用虚拟机底层方法,方法区和堆区创建对象
return defineClass(name, data, 0, data.length);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
//第一个自定义类加载器对象
BreakClassLoader1 classLoader1 = new BreakClassLoader1();
classLoader1.setBasePath("D:\\lib\\");
Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");
//第二个自定义类加载器对象
BreakClassLoader1 classLoader2 = new BreakClassLoader1();
classLoader2.setBasePath("D:\\lib\\");
Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");
System.out.println(clazz1 == clazz2);
Thread.currentThread().setContextClassLoader(classLoader1);
System.out.println(Thread.currentThread().getContextClassLoader());
System.in.read();
}
}
问题:两个自定义类加载器加载相同限定名的类,不会冲突吗?
- 不会冲突,在同一个 JVM 中,只有相同类加载器+相同的类限定名才会被认为是同一个类
- 在 Arthas 中使用 sc-d 类名 的方式查看具体的情况
双亲委派机制在 loadClass 中,而 loadClass 调用了 findClass,而重写 findClass 才是真正实现多种渠道加载字节码文件的合理方式,如加载数据库中的类,转成二进制数组调用 defineClass 存入内存
打破双亲委派机制 - 线程上下文类加载器 JDBC 案例#
JDBC 的 DriverManager 管理不同驱动
DriverManager 类位于 rt.jar 包,那么就是由启动类加载器 Bootstrap 加载,但 DriverManager 又委派 Application 加载驱动 jar 包
问题:DriverManager 怎么知道 jar 包中要加载的驱动在哪儿?
Service Provider Interface SPI 是 JDK 内置的一种服务发现机制
线程上下文加载器其实默认就是应用程序加载器
观点:JDBC 案例中真的打破了双亲委派机制吗?
- 由 Bootstrap 加载的 DriverManager 委派 Application 加载驱动类,打破了双亲委派
- JDBC 只是在 DriverManager 加载完后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制,因为通过 Application 加载依旧是走 loadClass 方法,而这个方法含有双亲委派机制
只能说从宏观上看,是父层级委派给子层级,在微观上,执行层面,子层级的类加载器内部函数逻辑依旧走了双亲委派,只不过父层级都拒绝执行罢了
打破双亲委派机制 - OSGi 模块化框架#
利用 Arthas 热部署解决线上 bug#
注意事项:
- 程序重启之后,字节码文件会恢复,因为只是替换到了内存中,除非将 class 文件放入 jar 包中进行更新
- 使用 retransform 不能添加方法或者字段,也不能更新正在执行中的方法
JDK 9 之后的类加载器#
JDK8 及之前,Extension 和 Application 继承自 rt.jar 包中的 sun.misc.Launcher.java 中的 URLClassLoader
JDK 9 之后,引入了 module 的概念,类加载器的设计变化了很多
启动类加载器使用 Java 编写,位于 jdk.internal.loader.ClassLoaders 类中
Java 中的 BootClassLoader 继承自 BuiltinClassLoader 实现从模块中找到要加载的字节码资源文件
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从 URLClassLoader 变成了 BuiltinClassLoader,BuiltinClassLoader 实现了从模块中加载字节码文件,主要是用于兼容老版本,并没有特殊逻辑
JVM 内存区域 运行时数据区#
运行时数据区负责管理 JVM 使用到的内存,比如创建对象和销毁对象
《Java 虚拟机规范》规定了每一部分的作用,分为两大块:线程不共享和线程共享
线程不共享:程序计数器、Java 虚拟机栈、本地方法栈
线程共享:方法区、堆
程序计数器#
程序计数器 Program Counter Register 也叫 PC 寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址
案例:
在加载阶段,虚拟机将字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址,每一条字节码指令都会拥有一个内存地址
在代码执行过程中,程序计数器会记录下一行字节码指令的地址,执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令,这里为了简单起见,使用偏移量代替,真实内存中执行时保存的应该是地址
一路向下执行到最后一行,return 语句,当前方法执行结束,程序计数器中会放入方法出口的地址,也就是回到调用这个方法的方法
所以,程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑,只需要在程序计数器中放入下一行要执行的指令地址即可
多线程情况下,程序计数器还可以记录 CPU 切换前接下来要解释执行的指令地址,方便切换回后继续解释执行
问题:程序计数器在运行中会出现内存溢出吗?
- 内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限
- 由于每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的
- 程序员无需对程序计数器做任何处理
JVM 栈#
Java 虚拟机栈 Java Virtual Machine Stack 采用栈的数据结构来管理方法调用中的基本数据,先进后出 First In Last Out,每一个方法的调用使用一个栈帧 Stack Frame 来保存
Java 虚拟机栈的栈帧中主要包含三方面的内容:
- 局部变量表,局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈,操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据,帧数据主要包含动态链接、方法出口、异常表的引用
局部变量表#
局部变量表的作用是在方法执行过程中存放所有的局部变量
局部变量表分为两种:
- 一种是字节码文件中的
- 另外一种是栈帧中的,会保存在内存中,栈帧中的局部变量表是根据字节码文件中的内容生成的
生效范围:这个局部变量在字节码中可被访问的有效范围
起始 PC 指从什么偏移量开始,可以访问这个变量,确保变量已经初始化
长度指从起始 PC 开始,这个局部变量生效范围的长度,如 j 可生效范围为第 4 行字节码 return
栈帧中的局部变量表为数组,一个位置为一个 slot,long 和 double 占用两个 slot
实例对象的 this 和方法参数也会在局部变量表的开头,按照定义顺序保存
问题:以下代码占用几个槽 slot?
public void test4(int k,int m){
{
int a = 1;
int b = 2;
}
{
int c = 1;
}
int i = 0;
long j = 1;
}
this、k、m、a、b、c、i、j、j 是 9 个 slot 吗?并非
为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用;此处 a、b、c 后续都不在使用会被 i、j 复用;而实例对象 this 引用和方法参数贯穿整个方法生命周期,它们所占用的槽位不会被复用
所以局部变量表的槽位数应该是运行时最小需要的槽位数,这一点在编译时就可以确定,运行过程中只需要在栈帧中创建相应长度的局部变量表数组即可
操作数栈#
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域,栈式结构
在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小
案例,操作数栈最大深度为 2
帧数据#
帧数据主要包含动态链接、方法出口、异常表的引用
动态链接#
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用 编号 转换成对应的运行时常量池中的内存地址
动态链接就保存了编号到运行时常量池的内存地址的映射关系
方法出口#
方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址,也就是调用者接下来一行的指令地址
异常表#
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置
示例:此异常表中,异常捕获的起始偏移量是 2,结束偏移量是 4,在 2 - 4 执行过程中抛出了 java.lang.Exception
对象或者子类对象,就会将其捕获,然后跳转到偏移量为 7 的指令
栈内存溢出#
JVM 栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出,即错误 StackOverflowError
可以设置虚拟机参数 -Xss1m
-Xss1024K
1M 的虚拟机栈内存可以容纳 10676 个栈帧
每个版本的 JVM 也会对栈大小有要求,HotSpot JVM 在 Windows 64 位下 JDK 8 要求最小 180K 最大 1024M
本地方法栈#
HotSpot JVM 中,Java 虚拟机栈和本地方法栈实现上使用了同一个栈空间,本地方法栈保存了本地方法的参数、局部变量、返回值等信息
本地方法指 native 方法,使用 C 语言编写在 JVM 内部,在 JAVA 代码中公开声明允许调用
堆内存#
一般 Java 程序中堆内存是空间最大的一块内存区域,线程共享
创建出来的对象都存在于堆上,栈上的局部变量表中,可以存放堆上对象的引用,静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享
堆内存溢出#
堆内存大小是有上限的,当一直向堆中放入对象达到上限之后,就会抛出 OutOfMemory OOM 错误,在这段代码中,不停创建 100M 大小的字节数组并放入 ArrayList 集合中,最终超过了堆内存的上限,抛出 OOM 错误
/**
* 堆内存的使用和回收
*/
public class Demo1 {
public static void main(String[] args) throws InterruptedException, IOException {
ArrayList<Object> objects = new ArrayList<Object>();
System.in.read();
while (true){
objects.add(new byte[1024 * 1024 * 100]);
Thread.sleep(1000);
}
}
}
三个重要的值#
堆空间有三个需要关注的值,used、total、max
used 指的是当前已使用的堆内存,total 是 JVM 已经分配的可用堆内存,max 是 JVM 允许分配的最大堆内存,也就是 total 可以最大拓展到 max 的大小
在 Arthas 中可以通过 dashboard -i 刷新频率(5000ms)
命令看到堆内存的这三个值 used、total、max
如果不设置任何的虚拟机参数,max 默认是系统内存的 1/4,total 默认是系统内存的 1/64
随着堆中对象不断增多 used 越大,total 中可以使用的内存不足,就会继续申请内存,上限为 max
问题:那么是不是当 used = max = total 时堆内存就溢出了呢?
不是,堆内存溢出的判断条件比较复杂,在 GC 的讲解中会详细介绍
设置堆大小#
要修改堆的大小,可以使用虚拟机参数 –Xmx
(max 最大值)和 -Xms
(初始的 total)
语法:-Xmx值 -Xms值
单位:字节(默认,必须是 1024 的倍数)、k 或者 K (KB)、m 或者 M (MB)、g 或者 G (GB)
限制:-Xmx
max 必须大于 2 MB,-Xms
total 必须大于 1MB
建议:-Xmx
max 和 -Xms
total 设置为相同的值,这样减少了申请内存和分配内存上的开销,以及内存过剩之后堆收缩的情况
方法区#
方法区是存放基础信息的位置,线程共享,包含:
- 类的元信息,保存了所有类的基本信息
- 运行时常量池,保存了字节码文件中的常量池内容
- 字符串常量池,保存了字符串常量
类的元信息#
方法区存储每个类的基本信息也称元信息 InstanceKlass 对象
在类的加载阶段完成,其中包含类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息
运行时常量池#
方法区除了存储类的元信息之外,还存放了运行时常量池,常量池中存放的是字节码中的常量池内容
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池,当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池
方法区的实现#
方法区是《Java 虚拟机规范》中设计的虚拟概念,每款 Java 虚拟机在实现上都各不相同,Hotspot 设计如下:
- JDK 7 及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制
- JDK 8 及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配
方法区的溢出#
通过 ByteBuddy 工具动态生成字节码数据,加载到内存中,死循环不断加载到方法区,模拟方法区的溢出
字符串常量池#
方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池 StringTable
字符串常量池存储代码中定义的常量字符串内容,如 “123” 的 123 就会被放入字符串常量池
new 出来的对象存放在堆内存中
早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的,字符串常量池和运行时常量池做了拆分;JDK 7 之后,字符串常量池就在堆内存中了
问题:地址是否相等?
/**
* 字符串常量池案例
*/
public class Demo2 {
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = a + b;
System.out.println(c == d);
}
}
指向的并非同一个地址
问题:指向地址是否相同?
package chapter03.stringtable;
/**
* 字符串常量池案例
*/
public class Demo3 {
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = "1" + "2";
System.out.println(c == d);
}
}
查看字节码文件可以得知在编译阶段时就已经将 1 2 连接,所以指向的都是字符串常量池中的对象
两个问题的总结
字符串的变量连接使用 StringBuilder 存放到堆内存中;而用常量连接在编译阶段直接连接
JDK 7 后 string.intern () 会返回字符串常量池中的字符串,没有则会将字符串的引用放入字符串常量池中
这里字符串常量池的 java 时 JVM 自动放的
问题:静态变量存储在哪
- JDK 6 及之前的版本中,静态变量是存放在方法区中的,也就是永久代
- JDK 7 及之后的版本中,静态变量是存放在堆中的 Class 对象中,脱离了永久代
直接内存#
直接内存 Direct Memory 不在《Java 虚拟机规范》中存在,不属于 Java 运行时的内存区域
在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
- Java 堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
- IO 操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到 Java 堆中。
可以将文件放入直接内存中,并在堆上维护直接内存的引用,避免数据的拷贝开销以及对文件对象的创建回收开销
可以通过参数 XX:MaxDirectMemorySize=大小
分配大小
JVM 垃圾回收#
在 C/C++ 这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏,内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出
释放对象的过程称为垃圾回收,Java 为了简化对象的释放,引入了自动的垃圾回收 GC 机制,垃圾回收器主要负责堆上内存的回收
问题:垃圾回收器要负责哪些部分的内存呢?
对于线程不共享部分,都伴随线程创建而创建,线程的销毁而销毁;方法的栈帧在执行完方法后就会自动弹出栈而释放内存,所以不需要;所以需要垃圾回收的就是线程共享的方法区、堆
方法区的回收#
方法区中能回收的内容主要就是不再使用的类
判定一个类可以被卸载,要同时满足:
- 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象
Class<?> clazz = loader.loadClass (name: "com.itheima.my.A");
Object o = clazz.newInstance ();
◎ = nu11;
- 加载该类的类加载器已经被回收
- 该类对应的 java.lang.Class 对象没有在任何地方被引用
两个虚拟机参数 -XX:+TraceClassLoading
-XX:+TraceClassUnloading
可以看到类加载和卸载也就是回收的日志
如果需要手动触发垃圾回收,可以调用 System.gc () 方法,但是不一定会立即回收垃圾,仅仅是向 Java 虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收 Java 虚拟机会自行判断
堆回收#
引用计数法和可达性分析法#
GC 判断对象是否能被回收,要根据这个对象是否被引用决定,如果对象被引用了,说明该对象还在使用,不允许被回收
问题:A B 之间的相互引用需要去除吗?
不需要,因为发昂发中已经没有办法使用引用去访问 A B 对象
常见的对象能否回收的判断方法:引用计数法和可达性分析法
引用计数法#
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加 1,取消引用时减 1
这个情况取消两条引用就可以让引用计数器归 0,使之可以被回收
但是在下面的情况中,A B 对象循环引用,计数器均为 1,但是没有局部变量引用这两个对象,代码中无法访问到这两个对象,理应可以被回收,但根据引用计数器归 0 才回收来说,不对
可达性分析法#
Java 使用的是可达性分析算法来判断对象是否可以被回收
可达性分析将对象分为两类:垃圾回收的根对象 GC Root 和普通对象;对象与对象之间有引用关系
如果从根对象 GC Root 可达某个对象,那么就是不可回收的;GC Root 不可回收
GC Root 根对象:
- 线程 Thread 对象,引用线程栈帧中的方法参数、局部变量等
- 系统类加载器加载的 java.lang.Class 对象
- 监视器对象,用来保存同步锁 synchronized 关键字持有的对象
- 本地方法调用时使用的全局对象
案例:线程 Thread 对象
五种对象引用#
可达性算法中描述的对象引用,一般指的是强引用,即 GC Root 对象对普通对象有引用关系,这时普通对象就无法被回收
Java 设计了 5 种引用方式:
- 强引用
- 软引用
- 弱引用
- 虚引用
- 终结器引用
软引用#
软引用比强引用弱,如果一个对象只有软引用关联到它,当程序内存不足时,就会回收软引用中的数据
软引用的执行过程如下:
- 将对象使用软引用包装起来,
new SoftReference<对象类型>(对象)
- 内存不足时,虚拟机尝试进行垃圾回收
- 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象
- 如果依然内存不足,抛出 OutOfMemory 异常
放入 100M 数据的软引用,其中 bytes = null;
解除了数据的强引用,只剩下了 SoftReference
包裹的软引用,如果设置 -Xmx=200M
虚拟机 max 内存为 200M,第二次获取软引用内的数据就失败,因为在第二次创建 100M 数据时,即时 GC 也内存不足,会尝试回收软引用内的对象,此时回收成功得到足以容纳 100M 新数据的内存空间
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
bytes = null;
System.out.println(softReference.get());
byte[] bytes2 = new byte[1024 * 1024 * 100];
System.out.println(softReference.get());
软引用内的对象都因为内存不足而回收,那 SoftReference 本身也要被回收,SoftReference 提供了一套队列机制:
- 软引用创建时,通过构造器传参引用队列
- 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列
- 通过代码遍历引用队列,将 SoftReference 的强引用删除
利用 ReferenceQueue
强引用保存 SoftReference
对象,当软引用包裹的对象被回收时,SoftReference
本身会被放入构造时传入的引用队列当中,可以弹出遍历一遍,这样 SoftReference
就失去强引用,能被回收
ArrayList<SoftReference> softReferences = new ArrayList<>();
ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
for (int i = 0; i < 10; i++) {
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference studentRef = new SoftReference<byte[]>(bytes,queues);
softReferences.add(studentRef);
}
SoftReference<byte[]> ref = null;
int count = 0;
while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
count++;
}
System.out.println(count);
可以继承 SoftReference
,在构造时用 super(data, queue)
案例:使用软引用实现学生数据缓存,value 使用软引用对象,注意实际对象被回收时,要把 key 和 value 即软引用对象回收;用 StudentRef 继承 SoftReference<student> ,里面存储 _key 即可在回收软引用对象时同时清理 HashMap 中的 key
private void cleanCache() {
StudentRef ref = null;
while ((ref = (StudentRef) q.poll()) != null) {
StudentRefs.remove(ref._key);
}
}
弱引用#
弱引用和软引用相似,区别在于,弱引用不管内存够不够都会被回收,实现类为 WeakReference,主要用在 ThreadLocal,弱引用同样提供引用队列,会将包裹的数据被回收后的弱引用放入队列当中
手动 GC 导致弱引用包裹的数据直接被回收,第二次结果即为 null
byte[] bytes = new byte[1024 * 1024 * 100];
WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);
bytes = null;
System.out.println(weakReference.get());
System.gc();
System.out.println(weakReference.get());
虚引用和终结器引用#
这两种引用在常规开发中都不会用到
虚引用也叫幽灵引用 / 幻影引用,不能通过虚引用对象获取到包含的对象,虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java 中使用 PhantomReference 实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现
终结器引用指的是在对象需要被回收时,对象将会被放置在 Finalizer 类中的引用队列中,并在稍后由一条 finalizerThread 线程从队列中获取对象,然后执行对象的 finalize 方法,在这个过程中可以在 finalize 方法中再自身对象使用强引用关联上,但是不建议这样做,如果耗时过长会影响其他对象的回收
垃圾回收算法#
介绍#
对于垃圾回收来说,只有两步
- 找到内存中存活的对象
- 释放不再存活对象的内存,使得程序能再次利用这部分空间
1960 年 John McCarthy 发布了第一个 GC 算法:标记 - 清除算法
1963 年 Marvin L. Minsky 发布了复制算法
后续所有的垃圾回收算法,如标记 - 整理算法、分代 GC,都是在上述两种算法的基础上优化而来
标准评价#
Java 垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所有的用户线程,这个过程被称之为 Stop The World 简称 STW,如果 STW 时间过长则会影响用户的使用
用户代码执行和垃圾回收执行让用户线程停止执行 STW 是交替执行的,判断一个 GC 算法是否优秀,有三个方面
- 吞吐量:吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC 时间),吞吐量数值越高,垃圾回收的效率就越高
- 最大暂停时间:最大暂停时间指的是所有在垃圾回收过程中的 STW 时间最大值
- 堆使用效率:不同垃圾回收算法,对堆内存的使用方式是不同的,如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存,从堆使用效率上来说,标记清除算法要优于复制算法
一般来说,堆内存越大,最大暂停时间就越长;想要减少最大暂停时间,就会降低吞吐量
且堆使用效率、吞吐量、最大暂停时间无法兼得
标记清除算法#
标记清除算法核心分两个阶段:
- 标记阶段,将所有存活的对象进行标记,Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象
- 清除阶段,从内存中删除没有被标记也就是非存活对象
如对象 D 未被标记,则清除之
优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象
缺点:
- 碎片化问题:内存是连续的,但被删除的对象不一定是连续的,回收之后可能会出现很多细小的可用内存单元,却因过小无法分配,如总共回收了 9 字节空间,但连 5 个字节的对象也无法分配
- 分配速度慢:需要维护一个空闲链表,记录内存碎片,而遍历寻找合适大小的内存碎片耗时过长
复制算法#
复制算法核心思想:
- 准备两块空间 From 空间和 To 空间,每次在对象分配阶段,只能使用 From 空间
- 垃圾回收 GC 阶段,将 From 空间中存活对象复制到 To 空间
- 对换两块空间名字,保证 From 空间是永远分配、使用的空间
优点:
吞吐量高:只需遍历一次存活对象并复制到 To 空间,比标记 - 整理算法少一次遍历,性能好,但不如标记 - 清理算法,因为多一次对象移动
不会发生碎片化:对象是按照顺序放入
缺点:
每次只能用一半内存空间
标记 - 整理算法#
标记 - 整理算法,也称标记压缩算法,解决了标记 - 清理算法容易产生内存碎片的问题
标记 - 整理算法核心思想为:
- 标记阶段,将所有存活的对象进行标记,Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象
- 整理阶段,将存活对象移动到堆的一端,清理掉存活对象的内存空间
优点:
内存使用效率高:整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
不会发生碎片化:在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
整理阶段的效率不高,整理算法有很多种,比如 Lisp2 整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳
分代垃圾回收算法#
分代垃圾回收算法组合使用上述算法思想,将整个内存区域划分为年轻代和老年代
JDK 8 可以使用 JVM 参数 -XX:+UseSerialGC
使用分代 GC 运行程序,在 Arthas 中用 memory 命令查看三个区域
eden_space + survivor_space 组成年轻代;tenured_gen 指晋升区域,即老年代
相关 JVM 参数
参数名 | 参数含义 | 示例 |
---|---|---|
-Xms | 设置堆的最小和初始大小,必须是 1024 倍数且大于 1MB | 比如初始大小 6MB 的写法: -Xms6291456 -Xms6144k -Xms6m |
-Xmx | 设置最大堆的大小,必须是 1024 倍数且大于 2MB | 比如最大堆 80MB 的写法: -Xmx83886080 -Xmx81920k -Xmx80m |
-Xmn | 新生代的大小 | 新生代 256MB 的写法: -Xmn256m -Xmn262144k -Xmn268435456 |
-XX | 伊甸园区和幸存区的比例,默认为 8 新生代 1g 内存,伊甸园区 800MB,S0 和 S1 各 100MB | 比例调整为 4 的写法: -XX=4 |
-XX:+PrintGCDetailsverbose | 打印 GC 日志 | 无 |
heap 指可用堆,而 survivor 区每次只有一块能使用
注意 SurvivorRatio 这个比例,是 eden:s0 = SurvivorRatio:1:1 这个意思
执行流程:
- 分代回收算法下,创建出来的对象,首先会被放入 Eden 伊甸园区
- 随着对象在 Eden 区越来越多,当 Eden 区满,无法再放入新的对象,就会触发年轻代的 GC,称为 Minor GC 或者 Young GC;Minor GC 会把 eden 和 From 中需要回收的对象回收,并把没有回收的对象放入 To 区;这里的 From 和 To 即为两个幸存区,用了复制算法的思想
- 接下来,对换两个幸存区 From 和 To,即 S1 由 To 区变为 From 区;当 eden 区满时,依然发生 Minor GC,此时会回收 eden 和 From S1 中该回收的对象,并将剩余对象放入 To 区,即 S0 空间;注:每次 Minor GC 都会为对象记录年龄,初值为 0,每次 GC 完加 1
- 如果 Minor GC 后对象的年龄达到阈值(最大 15,默认值和垃圾回收器有关),对象就会被晋升至老年代
- 当老年代放满,空间不足以新对象放入,会尝试 Minor GC,还不足,就会触发 Full GC 对整个堆进行 GC,如果 FUll GC 依旧无法回收掉老年代的对象,那么当对象继续放入老年代时就会 OOM OutOfMemory
特殊情况:
当 From 区满了,还有对象无法放入,则没到达老年代阈值也会放入老年代
总共 10M 数据,运行 Minor GC 清除掉 1M 数据,将 9M 数据移入 幸存区 To 区 4M,满了,剩下 5M 数据放入老年代区域
From 区快满了,老年代满了,eden 区接近满,这时分配一个新对象,触发 Minor GC,eden + From 区都满了,移入 To 区,剩余的对象放入老年代,但老年代也满了,触发 Full GC,老年代依旧满的,OOM
为什么分代 GC 算法要把堆分成年轻代和老年代?#
堆内存中对象的特性:
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了
- 老年代中会存放长期存活的对象,比如 Spring 的大部分 bean 对象,在程序启动之后就不会被回收了
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小
分代 GC 算法将堆分成年轻代和老年代的原因:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记 - 清除和标记 - 整理算法,由程序员来选择灵活度较高
- 分代的设计中允许只回收新生代 Minor GC,如果能满足对象分配的要求就不需要对整个堆进行回收 Full GC,减少 STW 时间
垃圾回收器#
垃圾回收器是垃圾回收算法的具体实现
由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用
Serial - Serial Old#
JVM 参数 -XX:+UseSerialGC
使用这对 GC
年轻代 - Serial 垃圾回收器
Serial 是一种单线程串行回收年轻代的垃圾回收器
老年代 - Serial Old 垃圾回收器
SerialOld 是 Serial 垃圾回收器的老年代版本,采用单线程串行回收
ParNew - CMS#
年轻代 - ParNew 垃圾回收器
JVM 参数 -XX:+UseParNewGC
使用 ParNew GC
ParNew 垃圾回收器本质上是对 Serial 在多 CPU 下的优化,使用多线程进行垃圾回收
老年代 - CMS Concurrent Mark Sweep 垃圾回收器
JVM 参数 -XX:+UseConcMarkSweepGC
使用 CMS GC
CMS 垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间
CMS 执行步骤:
- 初始标记,用极短的时间标记出 GC Roots 能直接关联到的对象
- 并发标记,标记所有的对象,用户线程不需要暂停
- 重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记
- 并发清理,清理死亡的对象,用户线程不需要暂停
缺点:
- CMS 使用了标记 - 清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS 会在 Full GC 时进行碎片的整理。这样会导致用户线程暂停,可以使用
-XX:CMSFullGCsBeforeCompaction=N
参数(默认 0)调整 N 次 Full GC 之后再整理 - 无法处理在并发清理过程中产生的 “浮动垃圾”,不能做到完全的垃圾回收
- 如果老年代内存不足无法分配对象,CMS 就会退化成 Serial Old 单线程回收老年代,因为这时候就是阻塞的
Parallel Scavenge - Parallel Old#
年轻代 - Parallel Scavenge 垃圾回收器
Parallel Scavenge 是 JDK 8 默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量,可自动调整堆内存大小
老年代 - Parallel Old 垃圾回收器
JVM 参数 -XX:+UseParallelGC
或 -XX:+UseParallelOldGC
都可以使用 Parallel Scavenge + Parallel Old 组合
Parallel Old 是为 Parallel Scavenge 垃圾回收器设计的老年代版本,利用多线程并发回收
G1 垃圾回收器#
JDK 9 之后默认的垃圾回收器是 G1 Garbage First 垃圾回收器
Parallel Scavenge 关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小 CMS 关注暂停时间,但是吞吐量方面会下降
G1 设计目标就是将上述两种垃圾回收器的优点融合:
- 支持巨大的堆空间回收,并有较高的吞吐量
- 支持多 CPU 并行垃圾回收
- 允许用户设置最大暂停时间
G1 之前的垃圾回收器,年轻代和老年代一般都是连续的
而 G1 将整个堆会被划分成多个大小相等的区域,称之为区 Region;区域不要求是连续的,分为 Eden、Survivor、Old 区;Region 的大小通过堆空间大小 / 2048 计算得到,也可以通过 JVM 参数 -XX:G1HeapRegionSize=32m
指定,Region size 必须是 2 的指数幂,取值范围从 1M 到 32M
G1 垃圾回收有两种方式:
- 年轻代回收 Young GC 指回收年轻代
- 混合回收 Mixed GC 均回收
G1 垃圾回收器 - 年轻代回收 Young GC
年轻代回收 Young GC,回收 Eden 区和 Survivor 区中不用的对象,会导致 STW;可以通过 JVM 参数 -XX:MaxGCPauseMillis=n
默认 200 设置每次垃圾回收时的最大暂停时间毫秒数,G1 垃圾回收器会尽可能地保证暂停时间
G1 垃圾回收器 - 混合回收 Mixed GC
混合回收 Mixed GC 分为:初始标记 initial mark、并发标记 concurrent mark、最终标记 remark 或者 Finalize Marking、并发清理 cleanup
G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1 名称的由来
如果清理过程中发现没有足够的空 Region 存放转移的对象,会触发 Full GC,JDK 9 单线程执行标记 - 整理算法(JDK 10 采用多线程),此时会导致用户线程的暂停
G1 垃圾回收器 - 流程
流程:
- 新创建的对象会存放在 Eden 区,当 G1 判断年轻代区不足,即 Eden 区 + Survivor 区占据堆超过 max 默认 60% 时执行 Young GC
- 标记出 Eden 和 Survivor 区域中的存活对象
- 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的 Survivor 区中(年龄 + 1),并清空选择的区域,即复制算法思想,每次垃圾回收时每个区平均耗时计算出本次最多回收多少个区
- 后续 Young GC 时与之前相同,只不过除了 Eden 区外,Survivor 区中存活对象也会被搬运到另一个 Survivor 区,类似于 S0 S1 对换 From 区 To 区
- 当某个存活对象的年龄到达阈值,默认 15,将会被放入老年代,发生晋升
- 部分对象如果大小超过 Region 的一半,会直接放入老年代,这类老年代被称为 Humongous 区,如堆内存是 4G,每个 Region 为 2M,只要一个大对象超过了 1M 就被放入 Humongous 区,如果对象过大会横跨多个 Region
- 多次回收之后,会出现很多 Old 老年代区,如果此时总堆占有率达到阈值
-XX:InitiatingHeapOccupancyPercent
默认 45%,就会触发混合回收 Mixed GC,回收所有年轻代和部分老年代的对象以及大对象区,采用复制算法完成
G1 垃圾回收器 - 相关
JVM 参数 1:-XX:+UseG1GC
使用 G1 垃圾回收起,JDK 9 后默认使用
JVM 参数 2:-XX:MaxGCPauseMillis=毫秒值
最大暂停的事件
G1 垃圾回收器 - 问题#
问题:当来了一个新对象,如何判断是放入已有的 Eden Region 还是新开一个 Eden Region?
优先使用已有的 Eden Regions
Young GC 之后:在一次 Young GC 清理了年轻代后,G1 会根据应用的分配速率和暂停时间目标,决定下一次年轻代,包括 Eden 和 Survivor 需要多少个 Region,然后从 Free Region 中选取
问题:如果已有的 Eden Regions 都满了,是触发 Young GC 还是把这个新的对象放入到新开的一个 Eden Region 中?
触发 Young GC
当所有当前被指定为 Eden 角色的 Region 都已经被填满,G1 就认为当前的年轻代,主要是 Eden 空间已经耗尽,直接触发一次 Young GC
Young GC 之后:下一次放入新对象时,才从 Free Regions 中选择一批作为新的 Eden 空间
问题:已有一个 Survivor Region,现在 Eden Region 又满了触发 Young GC,这时,如果从 Eden 和 Survivor 中存活下来的对象多到需要两个 Region 大小的空间才能容纳,那么新的 To-Survivor Region 是在什么时候以及如何被选出来的?
选择时机:通常在 Young GC 开始阶段或标记阶段完成之后、实际对象复制操作开始之前,G1 会有规划阶段,提前预估需要多少 Survivor Region,并在 Free Regions 中挑选出 Survivor Region
动态分配:如果在预估或复制过程中发现 Survivor Region 不够,也会尝试分配额外的 Region 扮演本次的 To-Survivor Region
提前晋升:如果 Free Regions 不足以再分配多的 Survivor Region,那么部分本应复制到 To-Survivor Region 的对象就会提前晋升到 Old Region
垃圾回收器 - 总结#
JDK 8 及之前
关注暂停时间:ParNew 多线程 + CMS 标记清除、并行加阻塞
关注吞吐量:Parallel Scavenge 多线程 + Parallel Old 多线程
G1 JDK 8 之前不建议,较大堆并且关注暂停时间
JDK 9 之后:
G1 默认
此文由 Mix Space 同步更新至 xLog
原始链接为 https://blog.chanler.dev/posts/jvm/jvm-1