为什么学字节码文件的原因:

image-20230103141407495

image-20230103141317313

image-20230103141306367

image-20230103141338248

java前端编译器

image-20230103142540075

一个程序的性能是否高效,其实跟语言是没太大的关系的,主要是编译器,比如java在早期的时候,没有编译器只有解释器,然后就很慢,后来又了JIT编译器后,就快很多了。

前端编译器 vs 后端编译器

image-20230103144208971

透过字节码指令看代码执行细节:

image-20230103144413285

image-20230103144716615

image-20230103150037755

例子2:

image-20230103150626861

例子3:

image-20230105164434790

1
2
* 成员变量(非静态的)赋值过程:1.默认初始化 - 2.显示初始化 / 代码块中初始化 - 3. 构造器中初始化 -
* 4.有了对象之后,可以“对象.属性” 或 “对象.方法” 的方式对成员变量进行赋值

image-20230105162837668

image-20230105163558835

image-20230105164757916

解读Class文件的三种方式

image-20230106210715710

image-20230106211249422

image-20230106211233536

当你的实体类实现了序列化接口image-20221221005142575,当你去查询数据库的实体类时,有时候会报错,有时候又不会报,我也不知道为什么,报错如下:

java.ang.ClassCastException: lass com.webloq.entity.User cannot be cast to class javaang,String (com.webloq,entity.User is in unnamed module of loader

数据同步

方案一:同步调用

image-20221212170758302

如果用上面这种同步调用方法的话,就会把业务代码形成耦合,业务耦合必定会影响性能。上面一次新增酒店的总耗时就相当于三个步骤的总耗时,显然这样时间会比较长,而且如果有其中的步骤出异常,那么新增酒店这整个业务就出问题了。这就是耦合带来的问题。

方案二:异步通知

image-20221212171310763

利用MQ进行异步通知,解出耦合,提高性能。但这种方案比较依靠MQ的可靠性。

方案三:监听binlog

image-20221212171812077

总结:

image-20221212171713573

Spring Boot事务回滚

前言

我们开发系统的时候经常会遇到一些关于交易的需求,交易的过程大多数都比较繁琐(会包括修改库存、修改余额、记录交易账单等等步骤),这时候我们就不得不考虑其中的潜在风险了,比如我们在交易的过程中修改了库存(库存 -1),接下来需要进行支付操作,但是此时系统突然宕机或者网络突然中断,这也就导致我们无法完成整个交易流程,虽然用户还没付钱,但是我们的库存变少了(商家肯定就不高兴了👿),所以我们就需要用到事务回滚来解决上述的问题。

Spring Boot 事务回滚

我们有两种方式可以实现事务回滚,第一种是自动回滚,第二种是手动回滚,这两种实现方式大同小异,二者都需要使用 @Transactional 注解来实现事务回滚,下面直接上代码,看看二者之间到底哪里不一样。

在接口实现类中有一个插入会员信息的方法,咱们就对这个方法进行改造,分别实现一下自动回滚和手动回滚👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 插入会员信息
*
* @param cashierMember 会员信息
* @return 结果
*/
@Override
public int insertCashierMember(CashierMember cashierMember)
{
cashierMember.setCreateTime(DateUtils.getNowDate());
cashierMember.setCreateBy(ShiroUtils.getLoginName());
SMSUtil.sendCreateMemberMessage(cashierMember.getPhonenumber());
return cashierMemberMapper.insertCashierMember(cashierMember);
}
复制代码

自动回滚

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 插入会员信息
*
* @param cashierMember 会员信息
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertCashierMember(CashierMember cashierMember)
{
cashierMember.setCreateTime(DateUtils.getNowDate());
cashierMember.setCreateBy(ShiroUtils.getLoginName());
SMSUtil.sendCreateMemberMessage(cashierMember.getPhonenumber());
return cashierMemberMapper.insertCashierMember(cashierMember);
}
复制代码

我们可以看到方法上增加了一个注解 @Transactional(rollbackFor = Exception.class) ,通过该注解可以对异常进行捕获,当发生异常时就可以进行回滚,从而撤销本次的入库操作。

很多方法中都会用 try-catch 对异常进行处理,如果此时在 catch 中对可能出现的异常进行了处理,但是并没有再手动抛出(throw)异常,Spring 则会认为该方法成功执行,也就不会进行回滚👇。 在这里插入图片描述 正解如下👇:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 插入会员信息
*
* @param cashierMember 会员信息
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertCashierMember(CashierMember cashierMember)
{
try {
cashierMember.setCreateTime(DateUtils.getNowDate());
cashierMember.setCreateBy(ShiroUtils.getLoginName());
SMSUtil.sendCreateMemberMessage(cashierMember.getPhonenumber());
return cashierMemberMapper.insertCashierMember(cashierMember);
}catch (Exception e){
System.out.println("方法出现异常:" + e);
//手动抛出异常
throw new RuntimeException();
}
}
复制代码

P.S. 如果 try-catch 语句在 finally 语句块中进行了 return 操作,那么 catch 语句块中手动抛出的异常也会被覆盖,同样不会自动回滚。

手动回滚

手动回滚的实现方式也非常简单,只需要添加一句代码即可实现👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 插入会员信息
*
* @param cashierMember 会员信息
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertCashierMember(CashierMember cashierMember)
{
try {
cashierMember.setCreateTime(DateUtils.getNowDate());
cashierMember.setCreateBy(ShiroUtils.getLoginName());
SMSUtil.sendCreateMemberMessage(cashierMember.getPhonenumber());
return cashierMemberMapper.insertCashierMember(cashierMember);
}catch (Exception e){
System.out.println("方法出现异常:" + e);
//实现手动回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
return 0;
}
复制代码

P.S. 这里只是举个例子,手动回滚语句不一定要添加在 catch 代码块中,我们可以在任何一个地方使用手动回滚语句。需要注意的是,我们虽然可以在其他地方增加手动回滚语句,但是手动回滚语句后的代码还会继续执行,所以不建议在非 catch 代码块中使用手动回滚语句。 如果非要这么用的话,就一定要好好斟酌一下自己的业务逻辑是不是会有 BUG 了。

Spring Boot 事务回滚注意事项

这里我们再简单说几句关于 Spring Boot 事务回滚中的注意事项:

  1. 想实现回滚,首先要保证 Spring Boot 开启了事务(在启动类上增加 @EnableTransactionManagement 注解开启事务(其实 Spring Boot 默认就是开启事务的),其次就是实现回滚的方法必须是 public 的。
  2. @Transactional(rollbackFor=Exception.class) 表示的是该方法无论抛出什么异常都会进行自动回滚;如果不加 (rollbackFor=Exception.class) 的话,则代表了默认值,也就是只有当该方法抛出了非检查型异常(RuntimeException)时才会进行回滚。
  3. 由于事务的四大特性(原子性、一致性、隔离性、持久性),所以 @Transactional 一般是要加在业务层(也就是接口实现类)中。
  4. 如果将 @Transactional(rollbackFor=Exception.class) 加在了接口实现类上,那么这个类下的所有方法都将会被加上事务管理,即所有方法都会在自己出现异常时进行回滚操作。

小结

本人经验有限,有些地方可能讲的没有特别到位,如果您在阅读的时候想到了什么问题,欢迎在评论区留言,我们后续再一一探讨🙇‍

Minio设置文件永久访问和下载

docker pull minio/mc

img

docker run -it --entrypoint=/bin/sh minio/mc

img

mc config host add [–api API-SIGNATURE]

mc ls minio

  • ALIAS: 别名就是给你的云存储服务起了一个短点的外号。
  • S3 endpoint,access key和secret key是你的云存储服务提供的。
    • endpoint
    • access key、secret key 到这里大家肯定都知道啦。
  • API签名是可选参数,默认情况下,它被设置为"S3v4"。

例如:

img

  1. 通过下面命令分别设置永久下载和永久分享

mc anonymous set download minio/file

mc anonymous set public minio/file

img

详细说明参考如下博文:

Docker 安装最新Minio Client,还附带解决如何设置永久访问和永久下载链接!!(详图)有需求值得收藏的哈!!!! - 掘金 (juejin.cn)

云服务器docker安装MinIO

  1. 执行命令 docker pull minio/minio 下载稳定版本镜像
1
docker pull minio/minio

img

  1. 创建并启动minio容器

MINIO_ACCESS_KEY是登录的用户名,MINIO_SECRET_KEY是登陆的密码,根据自己的情况来设置登录的用户名和密码

1
docker run -p 9000:9000 -p 9001:9001 -d --name minio -v /opt/docker/minio/data:/data -v /opt/docker/minio/config:/root/.minio -e "MINIO_ROOT_USER=minio" -e "MINIO_ROOT_PASSWORD=minio@123456" minio/minio server /data --console-address ":9000" --address ":9001"

–console-address “:9000” 中的9000 是可视化界面的访问端口

–address “:9001” 中的 9001是api端口,在springboot中整合是用到

  1. 开放9000端口

img

9001端口也是像上面一样开放即可

  1. 访问登录,使用IP+9000 登录即可测试,然后输入自己在启动创建容器时设置的账号和密码

img

img


安装和使用,可以参照这个博文:

SpringBoot 整合 Minio 上传文件 - 掘金 (juejin.cn)

1-class文件结构

[toc]

image-20230108231953467

1. Class 文件结构

1.1. Class 字节码文件结构

类型 名称 说明 长度 数量
魔数 u4 magic 魔数,识别Class文件格式 4个字节 1
版本号 u2 minor_version 副版本号(小版本) 2个字节 1
u2 major_version 主版本号(大版本) 2个字节 1
常量池集合 u2 constant_pool_count 常量池计数器 2个字节 1
cp_info constant_pool 常量池表 n个字节 constant_pool_count - 1
访问标识 u2 access_flags 访问标识 2个字节 1
索引集合 u2 this_class 类索引 2个字节 1
u2 super_class 父类索引 2个字节 1
u2 interfaces_count 接口计数器 2个字节 1
u2 interfaces 接口索引集合 2个字节 interfaces_count
字段表集合 u2 fields_count 字段计数器 2个字节 1
field_info fields 字段表 n个字节 fields_count
方法表集合 u2 methods_count 方法计数器 2个字节 1
method_info methods 方法表 n个字节 methods_count
属性表集合 u2 attributes_count 属性计数器 2个字节 1
attribute_info attributes 属性表 n个字节 attributes_count

image-20230106211846510

image-20230106212954414

image-20230106213322903

image-20230106213238542

image-20230107101026854

image-20230107101258275

image-20230107162311874

1.2. Class 文件数据类型

数据类型 定义 说明
无符号数 无符号数可以用来描述数字、索引引用、数量值或按照 utf-8 编码构成的字符串值。 其中无符号数属于基本的数据类型。 以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节
表是由多个无符号数或其他表构成的复合数据结构。 所有的表都以“_info”结尾。 由于表没有固定长度,所以通常会在其前面加上个数说明。

1.3. 魔数

Magic Number(魔数)

  • 每个 Class 文件开头的 4 个字节的无符号整数称为魔数(Magic Number)
  • 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的 Class 文件。即:魔数是 Class 文件的标识符
  • 魔数值固定为 0xCAFEBABE。不会改变。
  • 如果一个 Class 文件不以 0xCAFEBABE 开头,虚拟机在进行文件校验的时候就会直接抛出以下错误:
1
2
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 1885430635 in class file StringTest
  • 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。

1.4. 文件版本号

紧接着魔数的 4 个字节存储的是 Class 文件的版本号。同样也是 4 个字节。第 5 个和第 6 个字节所代表的含义就是编译的副版本号 minor_version,而第 7 个和第 8 个字节就是编译的主版本号 major_version

它们共同构成了 class 文件的格式版本号。譬如某个 Class 文件的主版本号为 M,副版本号为 m,那么这个 Class 文件的格式版本号就确定为 M.m。

版本号和 Java 编译器的对应关系如下表:

1.4.1. Class 文件版本号对应关系

主版本(十进制) 副版本(十进制) 编译器版本
45 3 1.1
46 0 1.2
47 0 1.3
48 0 1.4
49 0 1.5
50 0 1.6
51 0 1.7
52 0 1.8
53 0 1.9
54 0 1.10
55 0 1.11

Java 的版本号是从 45 开始的,JDK1.1 之后的每个 JDK 大版本发布主版本号向上加 1。

不同版本的 Java 编译器编译的 Class 文件对应的版本是不一样的。目前,高版本的 Java 虚拟机可以执行由低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行由高版本编译器生成的 Class 文件。否则 JVM 会抛出 java.lang.UnsupportedClassVersionError 异常。(向下兼容)

在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发编译的 JDK 版本和生产环境中的 JDK 版本是否一致。

  • 虚拟机 JDK 版本为 1.k(k>=2)时,对应的 class 文件格式版本号的范围为 45.0 - 44+k.0(含两端)。

1.5. 常量池集合

常量池是 Class 文件中内容最为丰富的区域之一。常量池对于 Class 文件中的字段和方法解析也有着至关重要的作用。

随着 Java 虚拟机的不断发展,常量池的内容也日渐丰富。可以说,常量池是整个 Class 文件的基石。

image-20210508233536076

在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项。

常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的无符号数,代表常量池容量计数值(constant_pool_count)。与 Java 中语言习惯不一样的是,这个容量计数是从 1 而不是 0 开始的。

类型 名称 数量
u2(无符号数) constant_pool_count 1
cp_info(表) constant_pool constant_pool_count - 1

由上表可见,Class 文件使用了一个前置的容量计数器(constant_pool_count)加若干个连续的数据项(constant_pool)的形式来描述常量池内容。我们把这一系列连续常量池数据称为常量池集合。

  • 常量池表项中,用于存放编译时期生成的各种字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放

1.5.1. 常量池计数器

constant_pool_count(常量池计数器)

  • 由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值。
  • 常量池容量计数值(u2 类型):从 1 开始,表示常量池中有多少项常量。即 constant_pool_count=1 表示常量池中有 0 个常量项。
  • Demo 的值为:

image-20210508234020104

其值为 0x0016,掐指一算,也就是 22。需要注意的是,这实际上只有 21 项常量。索引为范围是 1-21。为什么呢?

通常我们写代码时都是从 0 开始的,但是这里的常量池却是从 1 开始,因为它把第 0 项常量空出来了。这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值 0 来表示。

1.5.2. 常量池表

constant_pool 是一种表结构,以 1 ~ constant_pool_count - 1 为索引。表明了后面有多少个常量项。

常量池主要存放两大类常量:字面量(Literal)符号引用(Symbolic References)

它包含了 class 文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项都具备相同的特征。第 1 个字节作为类型标记,用于确定该项的格式,这个字节称为 tag byte(标记字节、标签字节)。

类型 标志(或标识) 描述
CONSTANT_Utf8_info 1 UTF-8 编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的符号引用
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 标志方法类型
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点

Ⅰ. 字面量和符号引用

在对这些常量解读前,我们需要搞清楚几个概念。

常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。如下表:

常量 具体的常量
字面量 文本字符串
声明为 final 的常量值
符号引用 类和接口的全限定名
字段的名称和描述符
方法的名称和描述符

全限定名

com/atguigu/test/Demo 这个就是类的全限定名,仅仅是把包名(全类名)的“.“替换成”/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。

简单名称

简单名称是指没有类型和参数修饰的方法或者字段名称,上面例子中的类的 add()方法和 num 字段的简单名称分别是 add 和 num。

描述符

描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 L 加对象的全限定名来表示,详见下表:

标志符 含义
B 基本数据类型 byte
C 基本数据类型 char
D 基本数据类型 double
F 基本数据类型 float
I 基本数据类型 int
J 基本数据类型 long
S 基本数据类型 short
Z 基本数据类型 boolean
V 代表 void 类型
L 对象类型,比如:Ljava/lang/Object;
[ 数组类型,代表一维数组。比如:`double[] is [D

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法 java.lang.String tostring()的描述符为()Ljava/lang/String; ,方法 int abc(int[]x, int y)的描述符为([II)I。

image-20230107105451433

补充说明:

虚拟机在加载 Class 文件时才会进行动态链接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息。因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中

这里说明下符号引用和直接引用的区别与关联:

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。

Ⅱ. 常量类型和结构

常量池中每一项常量都是一个表,J0K1.7 之后共有 14 种不同的表结构数据。如下表格所示:

image-20210509001319088

常量池表数据的解读视频教程:221-常量池表数据的解读2_哔哩哔哩_bilibili

根据上图每个类型的描述我们也可以知道每个类型是用来描述常量池中哪些内容(主要是字面量、符号引用)的。比如:
CONSTANT_Integer_info 是用来描述常量池中字面量信息的,而且只是整型字面量信息。

标志为 15、16、18 的常量项类型是用来支持动态语言调用的(jdk1.7 时才加入的)。

细节说明:

  • CONSTANT_Class_info 结构用于表示类或接口
  • CONSTAT_Fieldref_info、CONSTAHT_Methodref_infoF 和 lCONSTANIT_InterfaceMethodref_info 结构表示字段、方汇和按口小法
  • CONSTANT_String_info 结构用于表示示 String 类型的常量对象
  • CONSTANT_Integer_info 和 CONSTANT_Float_info 表示 4 字节(int 和 float)的数值常量
  • CONSTANT_Long_info 和 CONSTAT_Double_info 结构表示 8 字作(long 和 double)的数值常量
    • 在 class 文件的常最池表中,所行的 a 字节常借均占两个表成员(项)的空问。如果一个 CONSTAHT_Long_info 和 CNSTAHT_Double_info 结构在常量池中的索引位 n,则常量池中一个可用的索引位 n+2,此时常量池长中索引为 n+1 的项仍然有效但必须视为不可用的。
  • CONSTANT_NameAndType_info 结构用于表示字段或方法,但是和之前的 3 个结构不同,CONSTANT_NameAndType_info 结构没有指明该字段或方法所属的类或接口。
  • CONSTANT_Utf8_info 用于表示字符常量的值
  • CONSTANT_MethodHandle_info 结构用于表示方法句柄
  • CONSTANT_MethodType_info 结构表示方法类型
  • CONSTANT_InvokeDynamic_info 结构表示 invokedynamic 指令所用到的引导方法(bootstrap method)、引导方法所用到的动态调用名称(dynamic invocation name)、参数和返回类型,并可以给引导方法传入一系列称为静态参数(static argument)的常量。

解析方法:

  • 一个字节一个字节的解析

image-20210509002525647

  • 使用 javap 命令解析:javap-verbose Demo.class 或 jclasslib 工具会更方便。

总结 1:

  • 这 14 种表(或者常量项结构)的共同点是:表开始的第一位是一个 u1 类型的标志位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型。
  • 在常量池列表中,CONSTANT_Utf8_info 常量项是一种使用改进过的 UTF-8 编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息。
  • 这 14 种常量项结构还有一个特点是,其中 13 个常量项占用的字节固定,只有 CONSTANT_Utf8_info 占用字节不固定,其大小由 length 决定。为什么呢?因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如你定义一个类,类名可以取长取短,所以在没编译前,大小不固定,编译后,通过 utf-8 编码,就可以知道其长度。

总结 2:

  • 常量池:可以理解为 Class 文件之中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用 Class 文件空间最大的数据项目之一。
  • 常量池中为什么要包含这些内容?Java 代码在进行 Javac 编译的时候,并不像 C 和 C++那样有“连接”这一步骤,而是在虚拟机加载 C1ass 文件的时候进行动态链接。也就是说,在 Class 文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态链接的内容,在虚拟机类加载过程时再进行详细讲解

1.6. 访问标志

访问标识(access_flag、访问标志、访问标记)

在常量池后,紧跟着访问标记。该标记使用两个字节表示,用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等。各种访问标记如下所示:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 标志为 public 类型
ACC_FINAL 0x0010 标志被声明为 final,只有类可以设置
ACC_SUPER 0x0020 标志允许使用 invokespecial 字节码指令的新语义,JDK1.0.2 之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
ACC_INTERFACE 0x0200 标志这是一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
ACC_ANNOTATION 0x2000 标志这是一个注解
ACC_ENUM 0x4000 标志这是一个枚举

类的访问权限通常为 ACC_开头的常量。

每一种类型的表示都是通过设置访问标记的 32 位中的特定位来实现的。比如,若是 public final 的类,则该标记为 ACC_PUBLIC | ACC_FINAL。

使用 ACC_SUPER 可以让类更准确地定位到父类的方法 super.method(),现代编译器都会设置并且使用这个标记。

补充说明:

  1. 带有 ACC_INTERFACE 标志的 class 文件表示的是接口而不是类,反之则表示的是类而不是接口。

    • 如果一个 class 文件被设置了 ACC_INTERFACE 标志,那么同时也得设置 ACC_ABSTRACT 标志。同时它不能再设置 ACC_FINAL、ACC_SUPER 或 ACC_ENUM 标志。
    • 如果没有设置 ACC_INTERFACE 标志,那么这个 class 文件可以具有上表中除 ACC_ANNOTATION 外的其他所有标志。当然,ACC_FINAL 和 ACC_ABSTRACT 这类互斥的标志除外。这两个标志不得同时设置。
  2. ACC_SUPER 标志用于确定类或接口里面的 invokespecial 指令使用的是哪一种执行语义。针对 Java 虚拟机指令集的编译器都应当设置这个标志。对于 Java SE 8 及后续版本来说,无论 class 文件中这个标志的实际值是什么,也不管 class 文件的版本号是多少,Java 虚拟机都认为每个 class 文件均设置了 ACC_SUPER 标志。

    • ACC_SUPER 标志是为了向后兼容由旧 Java 编译器所编译的代码而设计的。目前的 ACC_SUPER 标志在由 JDK1.0.2 之前的编译器所生成的 access_flags 中是没有确定含义的,如果设置了该标志,那么 0racle 的 Java 虚拟机实现会将其忽略。
  3. ACC_SYNTHETIC 标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。

  4. 注解类型必须设置 ACC_ANNOTATION 标志。如果设置了 ACC_ANNOTATION 标志,那么也必须设置 ACC_INTERFACE 标志。

  5. ACC_ENUM 标志表明该类或其父类为枚举类型。

1.7. 类索引、父类索引、接口索引

在访问标记后,会指定该类的类别、父类类别以及实现的接口,格式如下:

长度 含义
u2 this_class
u2 super_class
u2 interfaces_count
u2 interfaces[interfaces_count]

这三项数据来确定这个类的继承关系:

  • 类索引用于确定这个类的全限定名
  • 父类索引用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.1ang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 e。
  • 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中。

1.7.1. this_class(类索引)

2 字节无符号整数,指向常量池的索引。它提供了类的全限定名,如 com/atguigu/java1/Demo。this_class 的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为 CONSTANT_Class_info 类型结构体,该结构体表示这个 class 文件所定义的类或接口。

1.7.2. super_class(父类索引)

2 字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是 java/lang/object 类。同时,由于 Java 不支持多继承,所以其父类只有一个。

super_class 指向的父类不能是 final。

1.7.3. interfaces

指向常量池索引集合,它提供了一个符号引用到所有已实现的接口

由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的 CONSTANT_Class(当然这里就必须是接口,而不是类)。

Ⅰ. interfaces_count(接口计数器)

interfaces_count 项的值表示当前类或接口的直接超接口数量。

Ⅱ. interfaces[](接口索引集合)

interfaces[]中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为 interfaces_count。每个成员 interfaces[i]必须为 CONSTANT_Class_info 结构,其中 0 <= i < interfaces_count。在 interfaces[]中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即 interfaces[0]对应的是源代码中最左边的接口。

1.8. 字段表集合

fields

用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量。

字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

它指向常量池索引集合,它描述了每个字段的完整信息。比如字段的标识符、访问修饰符(public、private 或 protected)、是类变量还是实例变量(static 修饰符)、是否是常量(final 修饰符)等。

注意事项:

  • 字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
  • 在 Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。

1.8.1. 字段计数器

fields_count(字段计数器)

fields_count 的值表示当前 class 文件 fields 表的成员个数。使用两个字节来表示。

fields 表中每个成员都是一个 field_info 结构,用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段。

标志名称 标志值 含义 数量
u2 access_flags 访问标志 1
u2 name_index 字段名索引 1
u2 descriptor_index 描述符索引 1
u2 attributes_count 属性计数器 1
attribute_info attributes 属性集合 attributes_count

1.8.2. 字段表

Ⅰ. 字段表访问标识

我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected)、static 修饰符、final 修饰符、volatile 修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否为 public
ACC_PRIVATE 0x0002 字段是否为 private
ACC_PROTECTED 0x0004 字段是否为 protected
ACC_STATIC 0x0008 字段是否为 static
ACC_FINAL 0x0010 字段是否为 final
ACC_VOLATILE 0x0040 字段是否为 volatile
ACC_TRANSTENT 0x0080 字段是否为 transient
ACC_SYNCHETIC 0x1000 字段是否为由编译器自动产生
ACC_ENUM 0x4000 字段是否为 enum

Ⅱ. 描述符索引

描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte,char,double,float,int,long,short,boolean)及代表无返回值的 void 类型都用一个大写字符来表示,而对象则用字符 L 加对象的全限定名来表示,如下所示:

标志符 含义
B 基本数据类型 byte
C 基本数据类型 char
D 基本数据类型 double
F 基本数据类型 float
I 基本数据类型 int
J 基本数据类型 long
S 基本数据类型 short
Z 基本数据类型 boolean
V 代表 void 类型
L 对象类型,比如:Ljava/lang/Object;
[ 数组类型,代表一维数组。比如:`double[][][] is [[[D

Ⅲ. 属性表集合

一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、一些注释信息等。属性个数存放在 attribute_count 中,属性具体内容存放在 attributes 数组中。

1
2
3
4
5
6
// 以常量属性为例,结构为:
ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}

说明:对于常量属性而言,attribute_length 值恒为 2。

1.9. 方法表集合

methods:指向常量池索引集合,它完整描述了每个方法的签名。

  • 在字节码文件中,每一个 method_info 项都对应着一个类或者接口中的方法信息。比如方法的访问修饰符(public、private 或 protected),方法的返回值类型以及方法的参数信息等。
  • 如果这个方法不是抽象的或者不是 native 的,那么字节码中会体现出来。
  • 一方面,methods 表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods 表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法<clinit>()和实例初始化方法<init>())。

使用注意事项:

在 Java 语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个 class 文件中。

也就是说,尽管 Java 语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和 Java 语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同。

1.9.1. 方法计数器

methods_count(方法计数器)

methods_count 的值表示当前 class 文件 methods 表的成员个数。使用两个字节来表示。

methods 表中每个成员都是一个 method_info 结构。

1.9.2. 方法表

methods[](方法表)

methods 表中的每个成员都必须是一个 method_info 结构,用于表示当前类或接口中某个方法的完整描述。如果某个 method_info 结构的 access_flags 项既没有设置 ACC_NATIVE 标志也没有设置 ACC_ABSTRACT 标志,那么该结构中也应包含实现这个方法所用的 Java 虚拟机指令。

method_info 结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法

方法表的结构实际跟字段表是一样的,方法表结构如下:

标志名称 标志值 含义 数量
u2 access_flags 访问标志 1
u2 name_index 方法名索引 1
u2 descriptor_index 描述符索引 1
u2 attributes_count 属性计数器 1
attribute_info attributes 属性集合 attributes_count

方法表访问标志

跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 public,方法可以从包外访问
ACC_PRIVATE 0x0002 private,方法只能本类访问
ACC_PROTECTED 0x0004 protected,方法在自身和子类可以访问
ACC_STATIC 0x0008 static,静态方法

1.10. 属性表集合

方法表集合之后的属性表集合,指的是 class 文件所携带的辅助信息,比如该 class 文件的源文件的名称。以及任何带有 RetentionPolicy.CLASS 或者 RetentionPolicy.RUNTIME 的注解。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无须深入了解

此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息。

属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但 Java 虚拟机运行时会忽略掉它不认识的属性。

1.10.1. 属性计数器

attributes_count(属性计数器)

attributes_count 的值表示当前 class 文件属性表的成员个数。属性表中每一项都是一个 attribute_info 结构。

1.10.2. 属性表

attributes[](属性表)

属性表的每个项的值必须是 attribute_info 结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可。

属性的通用格式

类型 名称 数量 含义
u2 attribute_name_index 1 属性名索引
u4 attribute_length 1 属性长度
u1 info attribute_length 属性表

属性类型

属性表实际上可以有很多类型,上面看到的 Code 属性只是其中一种,Java8 里面定义了 23 种属性。下面这些是虚拟机中预定义的属性:

属性名称 使用位置 含义
Code 方法表 Java 代码编译成的字节码指令
ConstantValue 字段表 final 关键字定义的常量池
Deprecated 类,方法,字段表 被声明为 deprecated 的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClass 类文件 内部类列表
LineNumberTable Code 属性 Java 源码的行号与字节码指令的对应关系
LocalVariableTable Code 属性 方法的局部变量描述
StackMapTable Code 属性 JDK1.6 中新增的属性,供新的类型检查检验器和处理目标方法的局部变量和操作数有所需要的类是否匹配
Signature 类,方法表,字段表 用于支持泛型情况下的方法签名
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 用于存储额外的调试信息
Synthetic 类,方法表,字段表 标志方法或字段为编译器自动生成的
LocalVariableTypeTable 是哟很难过特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类,方法表,字段表 为动态注解提供支持
RuntimeInvisibleAnnotations 类,方法表,字段表 用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotation 方法表 作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象或方法
RuntimeInvisibleParameterAnnotation 方法表 作用与 RuntimeInvisibleAnnotations 属性类似,只不过作用对象或方法
AnnotationDefault 方法表 用于记录注解类元素的默认值
BootstrapMethods 类文件 用于保存 invokeddynamic 指令引用的引导方法限定符

或者(查看官网)

image-20210421235232911

部分属性详解

① ConstantValue 属性

ConstantValue 属性表示一个常量字段的值。位于 field_info 结构的属性表中。

1
2
3
4
5
ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;//字段值在常量池中的索引,常量池在该索引处的项给出该属性表示的常量值。(例如,值是1ong型的,在常量池中便是CONSTANT_Long)
}

② Deprecated 属性

Deprecated 属性是在 JDK1.1 为了支持注释中的关键词@deprecated 而引入的。

1
2
3
4
Deprecated_attribute{
u2 attribute_name_index;
u4 attribute_length;
}

③ Code 属性

Code 属性就是存放方法体里面的代码。但是,并非所有方法表都有 Code 属性。像接口或者抽象方法,他们没有具体的方法体,因此也就不会有 Code 属性了。Code 属性表的结构,如下图:

类型 名称 数量 含义
u2 attribute_name_index 1 属性名索引
u4 attribute_length 1 属性长度
u2 max_stack 1 操作数栈深度的最大值
u2 max_locals 1 局部变量表所需的存续空间
u4 code_length 1 字节码指令的长度
u1 code code_lenth 存储字节码指令
u2 exception_table_length 1 异常表长度
exception_info exception_table exception_length 异常表
u2 attributes_count 1 属性集合计数器
attribute_info attributes attributes_count 属性集合

可以看到:Code 属性表的前两项跟属性表是一致的,即 Code 属性表遵循属性表的结构,后面那些则是他自定义的结构。

④ InnerClasses 属性

为了方便说明特别定义一个表示类或接口的 Class 格式为 C。如果 C 的常量池中包含某个 CONSTANT_Class_info 成员,且这个成员所表示的类或接口不属于任何一个包,那么 C 的 ClassFile 结构的属性表中就必须含有对应的 InnerClasses 属性。InnerClasses 属性是在 JDK1.1 中为了支持内部类和内部接口而引入的,位于 ClassFile 结构的属性表。

⑤ LineNumberTable 属性

LineNumberTable 属性是可选变长属性,位于 Code 结构的属性表。

LineNumberTable 属性是用来描述 Java 源码行号 与 字节码行号之间的对应关系。这个属性可以用来在调试的时候定位代码执行的行数。

  • start_pc,即字节码行号;1ine_number,即 Java 源代码行号。

在 Code 属性的属性表中,LineNumberTable 属性可以按照任意顺序出现,此外,多个 LineNumberTable 属性可以共同表示一个行号在源文件中表示的内容,即 LineNumberTable 属性不需要与源文件的行一一对应。

1
2
3
4
5
6
7
8
9
10
// LineNumberTable属性表结构:
LineNumberTable_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{
u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}

⑥ LocalVariableTable 属性

LocalVariableTable 是可选变长属性,位于 Code 属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息。在 Code 属性的属性表中,LocalVariableTable 属性可以按照任意顺序出现。Code 属性中的每个局部变量最多只能有一个 LocalVariableTable 属性。

  • start pc + length 表示这个变量在字节码中的生命周期起始和结束的偏移位置(this 生命周期从头 e 到结尾 10)
  • index 就是这个变量在局部变量表中的槽位(槽位可复用)
  • name 就是变量名
  • Descriptor 表示局部变量类型描述
1
2
3
4
5
6
7
8
9
10
11
12
13
// LocalVariableTable属性表结构:
LocalVariableTable_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{
u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}

image-20230108220200811

⑦ Signature 属性

Signature 属性是可选的定长属性,位于 ClassFile,field_info 或 method_info 结构的属性表中。在 Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则 Signature 属性会为它记录泛型签名信息。

⑧ SourceFile 属性

SourceFile 属性结构

类型 名称 数量 含义
u2 attribute_name_index 1 属性名索引
u4 attribute_length 1 属性长度
u2 sourcefile index 1 源码文件素引

可以看到,其长度总是固定的 8 个字节。

⑨ 其他属性

Java 虚拟机中预定义的属性有 20 多个,这里就不一一介绍了,通过上面几个属性的介绍,只要领会其精髓,其他属性的解读也是易如反掌。

Class文件结构小结

image-20230109103959417

Quartz学习笔记

image-20221127205106372

image-20221127213810488

image-20221128143023074

image-20221128130326946

image-20221128142414350

image-20221128142313901

quartz 总体架构

image-20221127223745383

Quartz的使用

导包

1
2
3
4
5
<!-- quartz -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

import java.util.Date;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/27
*/
public class MyJop implements Job {
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println("MyJob execute:" + new Date());
}
}

测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package quartz.quartz;

import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/27
*/
public class TestJob {
public static void main(String[] args) {
JobDetail jobDetail = JobBuilder.newJob(MyJop.class)
// name: 任务名称(在调度器里不能重复,唯一的) group : 组
.withIdentity("jop1", "group1")
.build();

// 触发器
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger1", "trigger1")
.startNow()
// 时间间隔 永久重复执行
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever())
.build();

// 调度器
try {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
scheduler.scheduleJob(jobDetail, trigger);
// 启动
scheduler.start();
} catch (SchedulerException e) {
e.printStackTrace();
}
}
}

运行结果图

image-20221127225021946


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/27
*/
public class MyJop implements Job {

/**
* 如果在添加 .usingJobData("name", "trigger3") 的时候,
* key 和 这里定义的属性名一样的话,就会给这里的属性赋值,下面就可以直接用了
*/
private String name;

public void setName(String name) {
this.name = name;
}

@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
// System.out.println("MyJob execute:" + new Date());

JobDataMap jobDetailMap = context.getJobDetail().getJobDataMap();
JobDataMap triggerMap = context.getTrigger().getJobDataMap();
// 获取 JobDetail 和 Trigger 合并,但如果存在相同的键值key,Trigger的会覆盖JobDetail的
JobDataMap mergeMap = context.getMergedJobDataMap();

System.out.println("jobDetailMap:" + jobDetailMap.getString("job"));
System.out.println("triggerMap:" + triggerMap.getString("trigger"));
System.out.println("——————————————————————————————————————————————————————————————————————————");
System.out.println("mergeMap:" + mergeMap.getString("job"));
System.out.println("mergeMap:" + mergeMap.getString("trigger"));
System.out.println("——————————————————————————————————————————————————————————————————————————");
System.out.println( "name : " + name );
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package quartz.quartz;

import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/27
*/
public class TestJob {
public static void main(String[] args) {
JobDetail jobDetail = JobBuilder.newJob(MyJop.class)
// name: 任务名称(在调度器里不能重复,唯一的) group : 组
.withIdentity("jop1", "group1")
.usingJobData("job", "jobDetail1")
.usingJobData("name", "jobDetail2")
.build();

// 触发器
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger1", "trigger1")
.usingJobData("trigger", "trigger")
// 会覆盖上面JobDetail中的name的值
.usingJobData("name", "trigger2")
.startNow()
// 时间间隔 永久重复执行
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever())
.build();

// 调度器
try {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
scheduler.scheduleJob(jobDetail, trigger);
// 启动
scheduler.start();
} catch (SchedulerException e) {
e.printStackTrace();
}
}
}

结果

image-20221128103703359


Job : 封装成JobDetail设置属性

image-20221128132915669

image-20221128104234972

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.quartz.*;
import java.util.Date;

/**
* @DisallowConcurrentExecution : 禁止并发地执行通过一个 job 定义(JobDetail定义的)的多个实例
*/
@DisallowConcurrentExecution
public class MyJop implements Job {

/**
* 如果在添加 .usingJobData("name", "trigger3") 的时候,
* key 和 这里定义的属性名一样的话,就会给这里的属性赋值,下面就可以直接用了
*/
private String name;

public void setName(String name) {
this.name = name;
}

@Override
public void execute(JobExecutionContext context) throws JobExecutionException {

// 会发现,不加注解的情况下(@DisallowConcurrentExecution)
// 下面的输出都是不一样的,证明不是同一个 Job 实例
System.out.println( "jobDetail : " + System.identityHashCode(context.getJobDetail()) );
System.out.println( "job : " + System.identityHashCode(context.getJobInstance()) );


// 不加上注解的话(@DisallowConcurrentExecution),
// 我们想着是每隔次启动 job 实例之后等待sleep 3秒 之后再重复启动的
// 但是实际发现,每隔一秒就又有启动了,证明不是同一个job实例,所以它们之间的启动不需要等待sleep的时间
System.out.println("execute : " + new Date());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

结果:

image-20221128110013728


测试count++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.quartz.*;

import java.util.Date;

/**
* @DisallowConcurrentExecution : 禁止并发地执行通过一个 job 定义(JobDetail定义的)的多个实例
*/
@DisallowConcurrentExecution
/**
* 持久化 JobDetail 中的 JobDataMap (对 trigger 中的 datamap 无效)
* 如果一个任务不是持久化的,则当没有触发器关联它的时候,Quartz会从scheduler中删除它
*/
@PersistJobDataAfterExecution
public class MyJop implements Job {

@Override
public void execute(JobExecutionContext context) throws JobExecutionException {

JobDataMap triggerMap = context.getTrigger().getJobDataMap();
JobDataMap jobDetailMap = context.getJobDetail().getJobDataMap();
triggerMap.put("count", triggerMap.getInt("count") + 1);
jobDetailMap.put("count1", jobDetailMap.getInt("count1") + 1);
System.out.println( "triggerMap count : " + triggerMap.getInt("count") );
System.out.println( "jobDetailMap count : " + jobDetailMap.getInt("count1") );

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/27
*/
public class TestJob {
public static void main(String[] args) {
JobDetail jobDetail = JobBuilder.newJob(MyJop.class)
// name: 任务名称(在调度器里不能重复,唯一的) group : 组
.withIdentity("jop1", "group1")
.usingJobData("job", "jobDetail1")
.usingJobData("name", "jobDetail2")
.usingJobData("count1", 0)
.build();

int count = 0;

// 触发器
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger1", "trigger1")
.usingJobData("trigger", "trigger")
// 会覆盖上面JobDetail中的name的值
.usingJobData("name", "trigger2")
.usingJobData("count", count)
.startNow()
// 时间间隔 永久重复执行
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever())
.build();

// 调度器
try {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
scheduler.scheduleJob(jobDetail, trigger);
// 启动
scheduler.start();
} catch (SchedulerException e) {
e.printStackTrace();
}
}
}

结果图:

image-20221128132552340

Springboot整合Quartz

image-20221128143743504

Springboot整合Quartz

使用Springboot里面的监听器,让项目在启动后也启动了调度器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package quartz.bootquartz;

import org.quartz.*;
import org.springframework.scheduling.quartz.QuartzJobBean;

import java.util.Date;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/28
*/

/**
* @DisallowConcurrentExecution : 禁止并发地执行通过一个 job 定义(JobDetail定义的)的多个实例
*/
@DisallowConcurrentExecution
/**
* 持久化 JobDetail 中的 JobDataMap (对 trigger 中的 datamap 无效)
* 如果一个任务不是持久化的,则当没有触发器关联它的时候,Quartz会从scheduler中删除它
*/
@PersistJobDataAfterExecution
public class QuartzJob extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
Thread.sleep(2000);
System.out.println(context.getScheduler().getSchedulerInstanceId());

System.out.println("taskname : " + context.getJobDetail().getKey().getName() );
System.out.println("执行时间 :" + new Date());
} catch (Exception e) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package quartz.bootquartz;

import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.impl.StdSchedulerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/28
*/
@Configuration
public class SchedulerConfig {

@Bean
public Scheduler scheduler() {
Scheduler scheduler1 = null;
try {
scheduler1 = StdSchedulerFactory.getDefaultScheduler();
} catch (SchedulerException e) {
e.printStackTrace();
}
return scheduler1;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package quartz.bootquartz.listener;

import org.quartz.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import quartz.bootquartz.QuartzJob;

/**
* @author : 其然乐衣Letitbe
* @date : 2022/11/28
*/
@Component
public class StartApplicationListener implements ApplicationListener<ContextRefreshedEvent> {

/**
* 注入调度器
*/
@Autowired
private Scheduler scheduler;

@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
TriggerKey triggerKey = TriggerKey.triggerKey("trigger1", "group1");
try {
Trigger trigger = scheduler.getTrigger(triggerKey);
if (trigger == null) {
trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerKey)
.withSchedule(CronScheduleBuilder.cronSchedule("0/5 * * * * ?"))
.startNow()
.build();
JobDetail jobDetail = JobBuilder.newJob(QuartzJob.class)
.withIdentity("job1", "group1")
.build();

scheduler.scheduleJob(jobDetail, trigger);
scheduler.start();
}
} catch (SchedulerException e) {
e.printStackTrace();
}

}
}

结果:

image-20221128152834236

image-20221217151142631

13. 垃圾回收器

[toc]

13. 垃圾回收器

13.1. GC 分类与性能指标

13.1.1. 垃圾回收器概述

垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。

由于 JDK 的版本处于高速迭代过程中,因此 Java 发展至今已经衍生了众多的 GC 版本。

从不同角度分析垃圾收集器,可以将 GC 分为不同的类型。

13.1.2. 垃圾收集器分类

线程数分,可以分为串行垃圾回收器并行垃圾回收器

image-20210512144253383

串行回收指的是在同一时间段内只允许有一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。

  • 在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的 Client 模式下的 JVM 中
  • 在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。

和串行回收相反,并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。

按照工作模式分,可以分为并发式垃圾回收器独占式垃圾回收器

  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

image-20200713083443486

碎片处理方式分,可分为压缩式垃圾回收器非压缩式垃圾回收器

  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
  • 非压缩式的垃圾回收器不进行这步操作。

工作的内存区间分,又可分为年轻代垃圾回收器老年代垃圾回收器

13.1.3. 评估 GC 的性能指标

(加粗的是比较重要的)

  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java 堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。

吞吐量、暂停时间、内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。

这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。

简单来说,主要抓住两点:吞吐量、暂停时间

吞吐量

吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。比如:虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的

吞吐量优先,意味着在单位时间内,STW 的时间最短:0.2 + 0.2 = 0.4

image-20200713084726176

暂停时间

“暂停时间”是指一个时间段内应用程序线程暂停,让 GC 线程执行的状态。

例如,GC 期间 100 毫秒的暂停时间意味着在这 100 毫秒期间内没有应用程序线程是活动的。

暂停时间优先,意味着尽可能让单次 STW 的时间最短:0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5

image-20200713085306400

吞吐量 vs 暂停时间

高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。

低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序

不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。

  • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。
  • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。

在设计(或使用)GC 算法时,我们必须确定我们的目标:一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。

现在标准:在最大吞吐量优先的情况下,降低停顿时间

13.2. 不同的垃圾回收器概述

垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。这当然也是面试的热点。

13.2.1. 垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是 Garbage Collection,对应的产品我们称为 Garbage Collector。

  • 1999 年随 JDK1.3.1 一起来的是串行方式的 serialGc,它是第一款 GC。ParNew 垃圾收集器是 Serial 收集器的多线程版本
  • 2002 年 2 月 26 日,Parallel GC 和 Concurrent Mark Sweep GC 跟随 JDK1.4.2 一起发布·
  • Parallel GC 在 JDK6 之后成为 HotSpot 默认 GC。
  • 2012 年,在 JDK1.7u4 版本中,G1 可用。
  • 2017 年,JDK9 中 G1 变成默认的垃圾收集器,以替代 CMS。
  • 2018 年 3 月,JDK10 中 G1 垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • 2018 年 9 月,JDK11 发布。引入 Epsilon 垃圾回收器,又被称为 "No-Op(无操作)“ 回收器。同时,引入 ZGC:可伸缩的低延迟垃圾回收器(Experimental)
  • 2019 年 3 月,JDK12 发布。增强 G1,自动返回未用堆内存给操作系统。同时,引入 Shenandoah GC:低停顿时间的 GC(Experimental)。·
  • 2019 年 9 月,JDK13 发布。增强 ZGC,自动返回未用堆内存给操作系统。
  • 2020 年 3 月,JDK14 发布。删除 CMS 垃圾回收器。扩展 ZGC 在 macos 和 Windows 上的应用

13.2.2. 7 种经典的垃圾收集器

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel old
  • 并发回收器:CMS、G1

image-20200713093551365

官方手册:https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

image-20210512145950897

13.2.3. 7 款经典收集器与垃圾分代之间的关系

image-20200713093757644

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

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

  • 整堆收集器:G1;

13.2.4. 垃圾收集器的组合关系

image-20200713094745366

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  2. 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。
  3. (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃(JEP173),并在 JDK9 中完全取消了这些组合的支持(JEP214),即:移除。
  4. (绿色虚线)JDK14 中:弃用 Parallel Scavenge 和 Serialold GC 组合(JEP366)
  5. (绿色虚框)JDK14 中:删除 CMS 垃圾回收器(JEP363)

13.2.5. 不同的垃圾收集器概述

为什么要有很多收集器,一个不够吗?因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器

13.2.6. 如何查看默认垃圾收集器

-XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)

使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID

13.3. Serial 回收器:串行回收

Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3 之前回收新生代唯一的选择。

Serial 收集器作为 HotSpot 中 client 模式下的默认新生代垃圾收集器。

Serial 收集器采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。

除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。

  • Serial old 是运行在 Client 模式下默认的老年代的垃圾回收器
  • Serial 0ld 在 Server 模式下主要有两个用途:① 与新生代的 Parallel scavenge 配合使用 ② 作为老年代 CMS 收集器的后备垃圾收集方案

image-20200713100703799

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)

优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在 Client 模式下的虚拟机是个不错的选择。

在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。

在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用 Serial GC,且老年代用 Serial Old GC

总结

这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核 cpu 才可以用。现在都不是单核的了。

对于交互较强的应用而言,这种垃圾收集器是不能接受的(因为它是串行的,导致用户完全停止了,容易影响用户交互)。一般在 Java web 应用程序中是不会采用串行垃圾收集器的。

13.4. ParNew 回收器:并行回收

如果说 Serial GC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 Serial 收集器的多线程版本。Par 是 Parallel 的缩写,New:只能处理的是新生代

ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制

ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。

image-20200713102030127

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)

由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 serial 收集器更高效?

  • ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
  • 但是在单个 CPU 的环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

因为除 Serial 外,目前只有 ParNew GC 能与 CMS 收集器配合工作

在程序中,开发人员可以通过选项"-XX:+UseParNewGC"手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。

-XX:ParallelGCThreads限制线程数量,默认开启和 CPU 数据相同的线程数。

13.5. Parallel 回收器:吞吐量优先

HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和"Stop the World"机制

那么 Parallel 收集器的出现是否多此一举?

  • 和 ParNew 收集器不同,ParallelScavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
  • 自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。

高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序

Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。

Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-the-World"机制

image-20200713110359441

在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。在 Java8 中,默认是此垃圾收集器。

参数配置

  • -XX:+UseParallelGC 手动指定年轻代使用 Parallel 并行收集器执行内存回收任务。

  • -XX:+UseParallelOldGC 手动指定老年代都是使用并行回收收集器。

    • 分别适用于新生代和老年代。默认 jdk8 是开启的。
    • 上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
  • -XX:ParallelGCThreads 设置年轻代并行收集器的线程数。一般地,最好与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能。

    ParallelGCThreads = \begin{cases} CPU_Count & \text (CPU_Count <= 8) \\ 3 + (5 \* CPU_Count / 8) & \text (CPU_Count > 8) \end{cases}

  • -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即 STw 的时间)。单位是毫秒。

    • 为了尽可能地把停顿时间控制在 MaxGCPauseMills 以内,收集器在工作时会调整 Java 堆大小或者其他一些参数。
    • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合 Parallel,进行控制。
    • 该参数使用需谨慎
  • -XX:GCTimeRatio 垃圾收集时间占总时间的比例(=1/(N+1))。用于衡量吞吐量的大小。

    • 取值范围(0, 100)。默认值 99,也就是垃圾回收时间不超过 1%。
    • 与前一个-XX:MaxGCPauseMillis 参数有一定矛盾性。暂停时间越长,Radio 参数就容易超过设定的比例。
  • -XX:+UseAdaptivesizePolicy 设置 Parallel Scavenge 收集器具有自适应调节策略

    • 在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
    • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。

13.6. CMS 回收器:低延迟

在 JDK1.5 时期,Hotspot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作

CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

  • 目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

CMS 的垃圾收集算法采用标记-清除算法,并且也会"Stop-the-World"

不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS GC。

image-20200713205154007

CMS 整个过程比之前的收集器要复杂,整个过程分为 4 个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GCRoots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快
  • 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“stop-the-World”,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS 收集器的垃圾收集算法采用的是标记清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

image-20200713212230352

有人会觉得既然 Mark Sweep 会造成内存碎片,那么为什么不把算法换成 Mark Compact?

答案其实很简单,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact 更适合“Stop the World” 这种场景下使用

13.6.1. CMS 的优点

  • 并发收集
  • 低延迟

13.6.2. CMS 的弊端

  • 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 FullGC(如果来一次业务高峰,导致提前触发Full GC,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,而单线程的垃圾回收器Serial OId,是性能最差的一个,停顿可能是几秒钟甚至十几秒钟,业务高峰时,停顿时间就很长了,给用户的体验就是很卡)。
  • CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS 收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。

13.6.3. 设置的参数

  • -XX:+UseConcMarkSweepGC 手动指定使用 CMS 收集器执行内存回收任务。

    开启该参数后会自动将-xx:+UseParNewGC打开。即:ParNew(Young 区用)+CMS(Old 区用)+ Serial Old 的组合。

  • -XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。

    • JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68%时,会执行一次 CMS 回收。JDK6 及以上版本默认值为 92%
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低 Ful1Gc 的执行次数。
  • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforeCompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。

  • -XX:ParallelcMSThreads 设置 CMS 的线程数量。

    • CMS 默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

小结

HotSpot 有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 Gc 有什么不同呢?

请记住以下口令:

  • 如果你想要最小化地 使用内存和并行开销,请选 Serial GC;
  • 如果你想要最大化应用程序的吞吐量,请选 Parallel GC;
  • 如果你想要最小化 GC 的中断或停顿时间,请选 CMS GC。

13.6.4. JDK 后续版本中 CMS 的变化

JDK9 新特性:CMS 被标记为 Deprecate 了(JEP291)

  • 如果对 JDK9 及以上版本的 HotSpot 虚拟机使用参数-XX: +UseConcMarkSweepGC来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃。

JDK14 新特性:删除 CMS 垃圾回收器(JEP363)

  • 移除了 CMS 垃圾收集器,如果在 JDK14 中使用 -XX:+UseConcMarkSweepGC的话,JVM 不会报错,只是给出一个 warning 信息,但是不会 exit。JVM 会自动回退以默认 GC 方式启动 JVM

13.7. G1 回收器:区域化分代式

既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1)?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序正常进行,而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。G1(Garbage-First)垃圾回收器是在 Java7 update4 之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给 G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

为什么名字叫 Garbage First(G1)呢?

因为 G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。

G1 GC 有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region

由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给 G1 一个名字:垃圾优先(Garbage First)。

G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征。

在 JDK1.7 版本正式启用,移除了 Experimental 的标识,是JDK9 以后的默认垃圾回收器,取代了 CMS 回收器以及 Parallel+Parallel Old 组合。被 Oracle 官方称为“全功能的垃圾收集器”。

与此同时,CMS 已经在 JDK9 中被标记为废弃(deprecated)。在 jdk8 中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。

13.7.1. G1 回收器的特点(优势)

与其他 GC 收集器相比,G1 使用了全新的分区算法,其特点如下所示:

并行与并发

  • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时用户线程 STW
  • 并发性:G1 拥有与应用程序交替执行的能力(就不用考虑STW了),部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1 依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代
  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

image-20200713215105293

image-20200713215133839

空间整合

  • CMS:“标记-清除”算法、内存碎片、若干次 Gc 后进行一次碎片整理
  • G1 将内存划分为一个个的 region。内存的回收是以 region 作为基本单位的。Region 之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。

可预测的停顿时间模型(即:软实时 soft real-time)

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

  • 由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于 CMSGC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。

13.7.2. G1 垃圾收集器的缺点

相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。

从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势。平衡点在 6-8GB 之间。

13.7.3. G1 回收器的参数设置

  • -XX:+UseG1GC:手动指定使用 G1 垃圾收集器执行内存回收任务
  • -XX:G1HeapRegionSize 设置每个 Region 的大小。值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的 1/2000。
  • -XX:MaxGCPauseMillis 设置期望达到的最大 GC 停顿时间指标(JVM 会尽力实现,但不保证达到)。默认值是 200ms(人的平均反应速度)。(不要一味地设置得太小,太小的话,每次能清理的region个数就非常少,如果分配的用户进程占用的region数据进程比较快,最终的结果导致内存使用率越来越高,栈满时就会Full GC,出先Full GC的话,那就效率很低的了)
  • -XX:+ParallelGCThread 设置 STW 工作线程数的值。最多设置为 8(上面说过 Parallel 回收器的线程计算公式,当 CPU_Count > 8 时,ParallelGCThreads 也会大于 8)
  • -XX:ConcGCThreads 设置并发标记的线程数。将 n 设置为并行垃圾回收线程数(ParallelGCThreads)的 1/4 左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触发并发 GC 周期的 Java 堆占用率阈值。超过此值,就触发 GC。默认值是 45。

13.7.4. G1 收集器的常见操作步骤

G1 的设计原则就是简化 JVM 性能调优,开发人员只需要简单的三步即可完成调优:

  • 第一步:开启 G1 垃圾收集器
  • 第二步:设置堆的最大内存
  • 第三步:设置最大的停顿时间

G1 中提供了三种垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。

13.7.5. G1 收集器的适用场景

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低 GC 延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约 6GB 或更大时,可预测的暂停时间可以低于 0.5 秒;(G1 通过每次只清理一部分而不是全部的 Region 的增量式清理来保证每次 GC 停顿时间不会过长)。

用来替换掉 JDK1.5 中的 CMS 收集器;在下面的情况时,使用 G1 可能比 CMS 好:

  • 超过 50%的 Java 堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC 停顿时间过长(长于 0.5 至 1 秒)

HotSpot 垃圾收集器里,除了 G1 以外,其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

13.7.6. 分区 Region:化整为零

使用 G1 收集器时,它将整个 Java 堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32MB 之间,且为 2 的 N 次幂,即 1MB,2MB,4MB,8MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的 Region 大小相同,且在 JVM 生命周期内不会被改变。

(之所以要分代,就是要对堆内存进行局部清理,缩短停留提升回收效率。尽管分了代,对于新生代和老年代而言内存空间占用还是比较大,所以G1就使用了分区,通过更细的粒度来回收内存,以控制回收停留时间)

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过 Region 的动态分配方式实现逻辑上的连续。

image-20200713223244886

一个 region 有可能属于 Eden,Survivor 或者 Old/Tenured 内存区域。但是一个 region 只可能属于一个角色。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 survivor 内存区域,O 表示属于 Old 内存区域。图中空白的表示未使用的内存空间。

(region在整个jvm生命周期里的角色是可以转变的,比如说,当Eden区满的时候,触发YGC,YGC评判它价值比较高,就优先回收它了,回收完后,就一整个空白了,因为数据就会被提升复制到S幸存区了,就把Eden这区清空,那么这块空白的region就会被放到一个空闲列表中(专门用来记录这些空闲的region的),空闲之后,那么下一刻可能就会从这个空闲列表中将它选出来充当Old区了)

G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 region,就放到 H。

设置 H 的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。

每个 Region 都是通过指针碰撞来分配空间

image-20200713223509993

13.7.7. G1 垃圾回收器的回收过程

G1GC 的垃圾回收过程主要包括如下三个环节:

  • 年轻代 GC(Young GC)

  • 老年代并发标记过程(Concurrent Marking)

  • 混合回收(Mixed GC)(涉及到新生代和老年代混合回收)

    可能第四种情况:(如果需要,单线程、独占式、高强度的 Full GC 还是继续存在的。它针对 GC 的评估失败提供了一种失败保护机制,即强力回收。情况如:( -XX:MaxGCPauseMillis 设置期望达到的最大 GC 停顿时间指标(JVM 会尽力实现,但不保证达到)。默认值是 200ms(人的平均反应速度)。(不要一味地设置得太小,太小的话,每次能清理的region个数就非常少,如果分配的用户进程占用的region数据进程比较快,最终的结果导致内存使用率越来越高,栈满时就会Full GC,出先Full GC的话,那就效率很低的了))

image-20200713224113996

顺时针,Young gc -> Young gc + Concurrent mark->Mixed GC 顺序,进行垃圾回收。

应用程序分配内存,当年轻代的 Eden 区用尽时开始年轻代回收过程;G1 的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到 Survivor 区间或者老年区间,也有可能是两个区间都会涉及

当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC 从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的 G1 回收器和其他 GC 不同,G1 的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的 Region 就可以了。同时,这个老年代 Region 是和年轻代一起被回收的。

举个例子:一个 Web 服务器,Java 进程最大堆内存为 4G,每分钟响应 1500 个请求,每 45 秒钟会新分配大约 2G 的内存。G1 会每 45 秒钟进行一次年轻代回收,每 31 个小时整个堆的使用率会达到 45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

13.7.8. Remembered Set

  • 一个对象被不同区域引用的问题

  • 一个 Region 不可能是孤立的,一个 Region 中的对象可能被其他任意 Region 中对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?

  • 在其他的分代收集器,也存在这样的问题(而 G1 更突出)回收新生代也不得不同时扫描老年代?

  • 这样的话会降低 MinorGC 的效率;

解决方法:

  • 无论 G1 还是其他分代收集器,JVM 都是使用 Remembered Set 来避免全局扫描:

  • 每个 Region 都有一个对应的 Remembered Set;

  • 每次 Reference 类型数据写操作时,都会产生一个 Write Barrier (写屏障)暂时中断操作;

  • 然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);

  • 如果不同,通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中;

  • 当进行垃圾收集时,在 GC 根节点的枚举范围加入 Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

image-20200713224716715

13.7.9. G1 回收过程一:年轻代 GC

JVM 启动时,G1 先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当 Eden 空间耗尽时,G1 会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收 Eden 区和 Survivor 区。

首先 G1 停止应用程序的执行(Stop-The-World),G1 创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代 Eden 区和 Survivor 区所有的内存分段。

image-20200713225100632

然后开始如下回收过程:

  1. 第一阶段,扫描根。根是指 static 变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。
  2. 第二阶段,更新 RSet。处理 dirty card queue(见备注)中的 card,更新 RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用
  3. 第三阶段,处理 RSet。识别被老年代对象指向的 Eden 中的对象,这些被指向的 Eden 中的对象被认为是存活的对象。
  4. 第四阶段,复制对象。此阶段,对象树被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 区中空的内存分段,Survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加 1,达到阀值会被会被复制到 Old 区中空的内存分段。如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用。处理 Soft,Weak,Phantom,Final,JNI Weak 等引用。最终 Eden 空间的数据为空,GC 停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

13.7.10. G1 回收过程二:并发标记过程

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是 STW 的,并且会触发一次年轻代 GC。
  2. 根区域扫描(Root Region Scanning):G1 GC 扫描 Survivor 区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在 Young GC 之前完成 。
  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 YoungGC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是 STW 的。G1 中采用了比 CMS 更快的初始快照算法:snapshot-at-the-beginning(SATB)。
  5. 独占清理(cleanup,STW):计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是 STW 的。这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。

13.7.11. G1 回收过程三:混合回收

当越来越多的对象晋升到老年代 old region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个 Young Region,还会回收一部分的 Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是 Mixed GC 并不是 Full GC。

image-20200713225810871

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分 8 次(可以通过-XX:G1MixedGCCountTarget设置)被回收

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden 区内存分段,Survivor 区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分 8 次回收,G1 会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行 8 次。有一个阈值-XX:G1HeapWastePercent,默认值为 10%,意思是允许整个堆内存中有 10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于 10%,则不再进行混合回收。因为 GC 会花费很多的时间但是回收到的内存却很少。

13.7.12. G1 回收可选的过程四:Full GC

G1 的初衷就是要避免 Full GC 的出现。但是如果上述方式不能正常工作,G1 会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免 Full GC 的发生,一旦发生需要进行调整。什么时候会发生 Full GC 呢?比如堆内存太小,当 G1 在复制存活对象的时候没有空的内存分段可用,则会回退到 Full GC,这种情况可以通过增大内存解决。

导致 G1 Full GC 的原因可能有两个:

  • Evacuation 的时候没有足够的 to-space 来存放晋升的对象;
  • 并发处理过程完成之前空间耗尽。

13.7.13. 补充

从 Oracle 官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到 G1 只是回一部分 Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了 G1 之后出现的低延迟垃圾收集器(即 ZGC)中。另外,还考虑到 G1 不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

13.7.14. G1 回收器优化建议

年轻代大小

  • 避免使用-Xmn-XX:NewRatio等相关选项显式设置年轻代大小
  • 固定年轻代的大小会覆盖暂停时间目标

暂停时间目标不要太过严苛

  • G1 GC 的吞吐量目标是 90%的应用程序时间和 10%的垃圾回收时间
  • 评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

13.8. 垃圾回收器总结

13.8.1. 7 种经典垃圾回收器总结

截止 JDK1.8,一共有 7 款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于新生代 复制算法 响应速度优先 适用于单 CPU 环境下的 client 模式
ParNew 并行运行 作用于新生代 复制算法 响应速度优先 多 CPU 环境 Server 模式下与 CMS 配合使用
Parallel 并行运行 作用于新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单 CPU 环境下的 Client 模式
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或 B/S 业务
G1 并发、并行运行 作用于新生代、老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用

GC 发展阶段:Serial => Parallel(并行)=> CMS(并发)=> G1 => ZGC

13.8.2. 垃圾回收器组合

不同厂商、不同版本的虚拟机实现差距比较大。HotSpot 虚拟机在 JDK7/8 后所有收集器及组合如下图

image-20200714080151020

  1. 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial + CMS、ParNew + Serial old 这两个组合声明为 Deprecated(JEP 173),并在 JDK 9 中

完全取消了这些组合的支持(JEP214),即:移除。

  1. (绿色虚线)JDK 14 中:弃用 ParallelScavenge 和 SeriaOold GC 组合(JEP 366)

  2. (绿色虚框)JDK 14 中:删除 CMS 垃圾回收器(JEP 363)

13.8.3. 怎么选择垃圾回收器

Java 垃圾收集器的配置对于 JVM 优化来说是一个很重要的选择,选择合适的垃圾收集器可以让 JVM 的性能有一个很大的提升。

怎么选择垃圾收集器?

  1. 优先调整堆的大小让 JVM 自适应完成。

  2. 如果内存小于 100M,使用串行收集器

  3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器

  4. 如果是多 CPU、需要高吞吐量、允许停顿时间超过 1 秒,选择并行或者 JVM 自己选择

  5. 如果是多 CPU、追求低停顿时间,需快速响应(比如延迟不能超过 1 秒,如互联网应用),使用并发收集器

    官方推荐 G1,性能高。现在互联网的项目,基本都是使用 G1

最后需要明确一个观点:

  1. 没有最好的收集器,更没有万能的收集
  2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器

面试

对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。 这里较通用、基础性的部分如下:

  • 垃圾收集的算法有哪些?如何判断一个对象是否可以回收?

  • 垃圾收集器工作的基本流程。

另外,大家需要多关注垃圾回收器这一章的各种常用的参数

13.9. GC 日志分析

通过阅读 Gc 日志,我们可以了解 Java 虚拟机内存分配与回收策略。 内存分配与垃圾回收的参数列表

  • -XX:+PrintGC 输出 GC 日志。类似:-verbose:gc
  • -XX:+PrintGCDetails 输出 GC 的详细日志
  • -XX:+PrintGCTimestamps 输出 GC 的时间戳(以基准时间的形式)
  • -XX:+PrintGCDatestamps 输出 GcC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

打开 GC 日志

1
-verbose:gc

这个只会显示总的 GC 堆的变化,如下:

1
2
3
[GC (Allocation Failure) 80832K->19298K(227840K),0.0084018 secs]
[GC (Metadata GC Threshold) 109499K->21465K(228352K),0.0184066 secs]
[Full GC (Metadata GC Threshold) 21465K->16716K(201728K),0.0619261 secs]

参数解析

1
2
3
4
5
GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代,老年代。
Allocation Failure:GC发生的原因。
80832K->19298K:堆在GC前的大小和GC后的大小。
228840k:现在的堆大小。
0.0084018 secs:GC持续的时间。

打开 GC 日志

1
-verbose:gc -XX:+PrintGCDetails

输入信息如下

1
2
3
4
5
[GC (Allocation Failure) [PSYoungGen:70640K->10116K(141312K)] 80541K->20017K(227328K),0.0172573 secs] [Times:user=0.03 sys=0.00,real=0.02 secs]
[GC (Metadata GC Threshold) [PSYoungGen:98859K->8154K(142336K)] 108760K->21261K(228352K),0.0151573 secs] [Times:user=0.00 sys=0.01,real=0.02 secs]
[Full GC (Metadata GC Threshold)[PSYoungGen:8154K->0K(142336K)]
[ParOldGen:13107K->16809K(62464K)] 21261K->16809K(204800K),[Metaspace:20599K->20599K(1067008K)],0.0639732 secs]
[Times:user=0.14 sys=0.00,real=0.06 secs]

参数解析

1
2
3
4
5
6
7
GC,Full FC:同样是GC的类型
Allocation Failure:GC原因
PSYoungGen:使用了Parallel Scavenge并行垃圾收集器的新生代GC前后大小的变化
ParOldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
Metaspace: 元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
xxx secs:指GC花费的时间
Times:user:指的是垃圾收集器花费的所有CPU时间,sys:花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。

打开 GC 日志

1
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimestamps -XX:+PrintGCDatestamps

输入信息如下

1
2
3
4
5
2019-09-24T22:15:24.518+0800: 3.287: [GC (Allocation Failure) [PSYoungGen:136162K->5113K(136192K)] 141425K->17632K(222208K),0.0248249 secs] [Times:user=0.05 sys=0.00,real=0.03 secs]

2019-09-24T22:15:25.559+0800: 4.329: [GC (Metadata GC Threshold) [PSYoungGen:97578K->10068K(274944K)] 110096K->22658K(360960K),0.0094071 secs] [Times: user=0.00 sys=0.00,real=0.01 secs]

2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [PSYoungGen:10068K->0K(274944K)][ParoldGen:12590K->13564K(56320K)] 22658K->13564K(331264K),[Metaspace:20590K->20590K(1067008K)],0.0494875 secs] [Times: user=0.17 sys=0.02,real=0.05 secs]

说明:带上了日期和实践

如果想把 GC 日志存到文件的话,是下面的参数:

1
-Xloggc:/path/to/gc.log

日志补充说明

  • [GC"和”[Full GC"说明了这次垃圾收集的停顿类型,如果有"Full"则说明 GC 发生了"Stop The World"

  • 使用 Serial 收集器在新生代的名字是 Default New Generation,因此显示的是"[DefNew"

  • 使用 ParNew 收集器在新生代的名字会变成"[ParNew",意思是"Parallel New Generation"

  • 使用 Parallel scavenge 收集器在新生代的名字是”[PSYoungGen"

  • 老年代的收集和新生代道理一样,名字也是收集器决定的

  • 使用 G1 收集器的话,会显示为"garbage-first heap"

  • Allocation Failure

    表明本次引起 GC 的原因是因为在年轻代中没有足够的空间能够存储新的数据了。

  • [PSYoungGen:5986K->696K(8704K) ] 5986K->704K(9216K)

    中括号内:GC 回收前年轻代大小,回收后大小,(年轻代总大小)

    括号外:GC 回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)

  • user 代表用户态回收耗时,sys 内核态回收耗时,rea 实际耗时。由于多核的原因,时间总和可能会超过 real 时间

1
2
3
4
5
6
7
8
9
10
11
Heap(堆)
PSYoungGen(Parallel Scavenge收集器新生代)total 9216K,used 6234K [0x00000000ff600000,0x0000000100000000,0x0000000100000000)
eden space(堆中的Eden区默认占比是8)8192K,768 used [0x00000000ff600000,0x00000000ffc16b08,0x00000000ffe00000)
from space(堆中的Survivor,这里是From Survivor区默认占比是1)1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space(堆中的Survivor,这里是to Survivor区默认占比是1,需要先了解一下堆的分配策略)1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)

ParOldGen(老年代总大小和使用大小)total 10240K, used 7001K [0x00000000fec00000,0x00000000ff600000,0x00000000ff600000)
object space(显示个使用百分比)10240K,688 used [0x00000000fec00000,0x00000000ff2d6630,0x00000000ff600000)

PSPermGen(永久代总大小和使用大小)total 21504K, used 4949K [0x00000000f9a00000,0x00000000faf00000,0x00000000fec00000)
object space(显示个使用百分比,自己能算出来)21504K, 238 used [0x00000000f9a00000,0x00000000f9ed55e0,0x00000000faf00000)

Minor GC 日志

image-20200714082555688

Full GC 日志

image-20210512194815354

举例

1
2
3
4
5
6
7
8
9
10
11
private static final int _1MB = 1024 * 1024;
public static void testAllocation() {
byte [] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 *_1MB];
allocation2 = new byte[2 *_1MB];
allocation3 = new byte[2 *_1MB];
allocation4 = new byte[4 *_1MB];
}
public static void main(String[] args) {
testAllocation();
}

设置 JVM 参数

1
-Xms10m -Xmx10m -XX:+PrintGCDetails

图示

image-20200714083332238

image-20200714083526790

( jdk7是如上图分析所示。但是JDK8和JDK7不一样,大对象来的时候,发现新生代装不下,直接进入老年代 )

可以用一些工具去分析这些 GC 日志

常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat 等

13.X. 垃圾回收器的新发展

GC 仍然处于飞速发展之中,目前的默认选项G1 GC 在不断的进行改进,很多我们原来认为的缺点,例如串行的 Fu11GC、Card Table 扫描的低效等,都已经被大幅改进,例如,JDK10 以后,Fu11GC 已经是并行运行,在很多场景下,其表现还略优于 ParallelGC 的并行 Ful1GC 实现。

即使是 Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是 GC 相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在 Serverless 等新的应用场景下,Serial GC 找到了新的舞台

比较不幸的是 CMSGC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在 JDK9 中已经被标记为废弃,并在 JDK14 版本中移除

13.X.1. JDK11 新特性

Epsilon:A No-Op GarbageCollector(Epsilon 垃圾回收器,"No-Op(无操作)"回收器)http://openidk.iava.net/jeps/318

ZGC:A Scalable Low-Latency Garbage Collector(Experimental)(ZGC:可伸缩的低延迟垃圾回收器,处于实验性阶段)http://openidk.iava.net/jeps/333

image-20210512195426194

现在 G1 回收器已成为默认回收器好几年了。

我们还看到了引入了两个新的收集器:ZGC(JDK11 出现)和 Shenandoah(Open JDK12)。主打特点:低停顿时间

image-20210512195528695

13.X.2. Open JDK12 的 Shenandoash GC

Open JDK12 的 Shenandoash GC:低停顿时间的 GC(实验性)

Shenandoah,无疑是众多 GC 中最孤独的一个。是第一款不由 oracle 公司团队领导开发的 Hotspot 垃圾收集器。不可避免的受到官方的排挤。比如号称 OpenJDK 和 OracleJDK 没有区别的 Oracle 公司仍拒绝在 OracleJDK12 中支持 Shenandoah。

Shenandoah 垃圾回收器最初由 RedHat 进行的一项垃圾收集器研究项目 Pauseless GC 的实现,旨在针对 JVM 上的内存回收实现低停顿的需求.。在 2014 年贡献给 OpenJDK。

Red Hat 研发 Shenandoah 团队对外宣称,Shenandoah 垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为 200MB 还是 200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。

image-20200714090608807

这是 RedHat 在 2016 年发表的论文数据,测试内容是使用 Es 对 200GB 的维基百科数据进行索引。从结果看:

  • 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
  • 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。

总结

  • Shenandoah GC 的弱项:高运行负担下的吞吐量下降。
  • Shenandoah GC 的强项:低延迟时间。
  • Shenandoah GC 的工作过程大致分为九个阶段,这里就不再赘述。在之前 Java12 新特性视频里有过介绍。

【Java12 新特性地址】

http://www.atguigu.com/download_detail.shtml?v=222

https://www.bilibili.com/video/BV1jJ411M7kQ?from=search&seid=12339069673726242866

13.X.3. 令人震惊、革命性的 ZGC

官方地址:https://docs.oracle.com/en/java/javase/12/gctuning/

image-20210512200236647

ZGC 与 Shenandoah 目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。

《深入理解 Java 虚拟机》一书中这样定义 ZGC:ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC 的工作过程可以分为 4 个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。

ZGC 几乎在所有地方并发执行的,除了初始标记的是 STw 的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

测试数据:

image-20200714091201073

image-20200714091401511

在 ZGC 的强项停顿时间测试上,它毫不留情的将 Parallel、G1 拉开了两个数量级的差距。无论平均停顿、95%停顿、99%停顿、99.9%停顿,还是最大停顿时间,ZGC 都能毫不费劲控制在 10 毫秒以内。

虽然 ZGC 还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。 未来将在服务端、大内存、低延迟应用的首选垃圾收集器。

image-20200714093243028

JEP 364:ZGC 应用在 macos 上

JEP 365:ZGC 应用在 Windows 上

JDK14 之前,ZGC 仅 Linux 才支持。

尽管许多使用 zGc 的用户都使用类 Linux 的环境,但在 Windows 和 macos 上,人们也需要 ZGC 进行开发部署和测试。许多桌面应用也可以从 ZGC 中受益。因此,ZGC 特性被移植到了 Windows 和 macos 上。

现在 mac 或 Windows 上也能使用 zGC 了,示例如下:

1
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

13.X.4. 其他垃圾回收器:AliGC

AliGC 是阿里巴巴 JVM 团队基于 G1 算法,面向大堆(LargeHeap)应用场景。指定场景下的对比:

image-20200714093604012

当然,其它厂商也提供了各种别具一格的 GC 实现,例如比较有名的低延迟 GC:Zing,有兴趣可以参考提供的链接 https://www.infoq.com/articles/azul_gc_in_detail