大家好,又见面了,我是你们的朋友全栈君。
??博主主页:爪哇贡尘拾Miraitow
?传作时间:?2022年1月9日?
?内容介绍:最近在学习JVM所以会时不时更新有关内容
?参考资料:黑马JVM
码云
?参考链接:?JVM垃圾回收机制
⏳简言以励:列位看官,且将新火试新茶,诗酒趁年华
?内容较多有问题希望能够不吝赐教?
? 欢迎点赞 ? 收藏 ⭐留言 ?
?JVM垃圾回收 ♻
大家都知道的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。
1.1 如何判断对象可以回收♻
1、 引用计数器法
引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。但是他有一个缺点是不能解决循环引用的问题。
我们从上面可以看到有这个过程
A对象引用对象B B
的计数加一
B对象引用对象A A
的计数加一
各自的引用计数不能归零,导致这两个对象不能作为垃圾回收,造成了内存泄漏
2、可达性分析算法
首先要确定一系列根对象,何为根对象?
可以理解为 肯定不能被当成垃圾回收的对象
。 在垃圾回收之前,我们首先会对堆内存中的对象进行扫描,判断每一个对象是不是被 根对象
直接或间接的引用,如果是,那么这个对象就不能被垃圾回收,反之就可以作为垃圾回收。
举个栗子?:
我们夏天吃的葡萄,葡萄向上一提,连?根部的葡萄果,就是不可回收的,落在盘子中的葡萄就可以作为垃圾♻回收
- Java 虚拟机中的垃圾回收机器采用可达性分析来探索所有存活的对象
- 扫描堆中的对象,看是否能够沿着 GC Root 对象 为起点的引用链找到该对象,如果能找到,表示这个对象需要保留,若找不到,表示可以回收
- 哪些对象可以作为 GC Root ?
- 通过 Memory Analyzer ( MAT )工具,可以形象的看到哪些 GC Root 对象
- 下载地址:https://www.eclipse.org/downloads/download.php?file=/mat/1.8/rcp/MemoryAnalyzer-1.8.0.20180604-win32.win32.x86_64.zip&mirror_id=1290
通过使用MAT以后出现下表,可以看到哪些对象是根对象,且其把根对象分为4大类
第一类:System Class :系统类,由启动类加载类加载的类,且肯定不会被垃圾回收(试想系统类没了还怎么跑程序
)
第二类:Native Stack:Java虚拟机在执行时,偶尔需调用操作系统的方法,本地方法栈
第三类:Thread:活动线程。正在运行的线程,能把活动线程中所使用的对象当成垃圾回收吗?显然不行,线程正在运行,这时我们把它正在使用的对象当成垃圾回收了,那就没法继续运行了
每次方法调用都会产生一个栈帧,即栈帧内所使用的对象,可以作为根对象
下图显示的是,主线程栈帧内用到的一些变量情况
注意,要把引用变量和对象分开,就好比下面代码,list 只是一个引用,它存在于活动栈帧中,它是一个局部变量,而 new ArrayList<>()
是存储在 堆 里的
看下图的 ArrayList ,那么它是不是由我们上面代码 list 的引用所引用?它就是一个根对象,在活动线程执行过程中,局部变量所引用的对象,是可以作为根对象的
包括方法参数 String[] args,所引用的字符串数组对象,也是根对象
List<Object> list = new ArrayList<>();
第四类:Busy Monitor:正在加锁的对象,比如同步锁机制,synchronized 关键字,被 synchronized 加锁的对象不能当成垃圾回收,如果被回收,将来谁来解锁?
2.list 置空之前,存储一个快兆名为:b.bin
从下图可以发现,没有 ArrayList 那个对象了,为什么没有了呢?
代码 list = null
,局部变量已经 置为 null 了,也就是它不再引用 ArrayList 对象,而我们执行了 live
所以进行垃圾回收,垃圾回收就会把不再有人引用它的ArrayList对象给回收掉,所以在根对象列表中就找不到它了
1.2 Java 中的五种引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
上图中,所有实线都表示强引用,虚线表示:软、弱、虚、终结器引用。
其实像我们平时用的所有引用,都属于强引用,比如 创建了一个对象,把这个对象通过 “ = ” 赋值给了一个变量,那么这个对象就 强引用 了这个对象。
-
强引用:只要沿着 GC Root 的引用链能够找到它,那么它就不会被垃圾回收。比如上图,沿着 C对象(GC Root) 能找到 A1对象,那么 A1对象 就不能被垃圾回收。只有 GC Root 对象对 A1对象 的引用都断开时,才会被垃圾回收。
-
软引用:还是参照上图,只要 A2对象 没有被直接的 强引用 所引用(上图 A2对象被 B对象 直接引用,不能被回收),那么当发生垃圾回收时,它就有可能被垃圾回收。比如上图中, A2对象 被 C对象(GC Root)间接引用。那么 A2对象 什么时候才能被垃圾回收呢?当发生垃圾回收时,并且内存不够时,就发生垃圾回收,但垃圾回收一次后,发现内存仍不够,这时就会把 软引用 所引用的对象释放掉,它认为 软引用 所引用的对象不够重要。
-
弱引用:只要发生垃圾回收,不管内存够不够,都会把 弱引用 引用的对象回收
软、弱引用还可以配合 引用队列 一起工作,什么意思呢?就是当 软、弱引用 的对象被回收掉后,那么 软、弱引用 其实本身也是一个对象,那如果再创建它们时为其分配了 引用队列 ,那么 当 软、弱引用 的对象被回收掉后,它们就会进入一个 引用队列 。
那么问题来了,为什么要做这么一个处理呢?因为,不管是 软、弱引用 ,它们自身也要占用一定的内存空间,那么如果想对它们占用的内存空间进行 释放,那么就需要用到 引用队列 来找到它们。比如它们可能还被强引用 所引用,那么就可以在 引用队列 中遍历它们,然后释放
-
虚引用:与 软、弱引用不同,虚引用 必须配合 引用队列 使用,也就是创建 虚引用 对象时,它就会关联一个 引用队列。在创建 ByteBuffer 实现对象时,它就会创造一个 Cleaner 的虚引用对象,ByteBuffer 会分配一块 直接内存,并且会把 直接内存 地址传给 虚引用对象,那么为何要做这个操作?将来如果 ByteBuffer 没有强引用所引用它了,那么 ByteBuffer 就可能被垃圾回收,但它被垃圾回收了,它所分配的 直接内存 并不能被 Java 的垃圾回收机制管理,那怎么解决?当 ByteBuffer 被垃圾回收时,让 虚引用 对象进入 引用队列 ,而 虚引用 所在的 引用队列 会由一个叫
Reference Handler
的线程定时去 引用队列 找,看看有无一个 新入队 的Cleaner
,如果有,那么它就会调用Cleaner
对象的clean()
方法,clean()
方法就会根据 直接内存地址调用Unsafe.freeMemory()
方法,把直接内存释放掉,这样就不会由 直接内存 导致的内存泄漏。 -
终结器引用:与 软、弱引用不同,终结器引用 必须配合 引用队列 使用,也就是创建 虚引用 对象时,它就会关联一个 引用队列。我们都知道,所有的 Java 对象,都会继承一个 Object 父类,而 Object 父类都有一个
finalize()
终结方法,当对象 重写了 终结方法,并且没有被 强引用 所引用,那么它就可以被垃圾回收,那么问题来了,这个finalize()
终结方法 什么时候会被调用?其实,你重写了finalize()
终结方法,你就希望这个终结方法将来在这个对象垃圾回收时被调用吧?其实它就是靠这个 终结器引用来达到目的的。如上图,当 A4 对象被垃圾回收时,终结器引用就会被加入 引用队列 ,但注意,此时 A4对象 还没被垃圾回收,即不是立刻回收,而是先将 终结器引用 放入 引用队列,再由一个 优先级很低的线程去查看 引用队列 中是否有 终结器引用,如果有,就会根据这个 终结器引用 找到 要作为垃圾回收的对象 ,并且调用finalize()
方法,等下一次垃圾回收时,就能把这个对象占用的内存垃圾回收掉。效率低
1.3 垃圾回收算法
定义:具体的垃圾回收,其实也依赖于一些 垃圾回收算法,常见的有:标记清除、标记整理、复制。这三种算法
1、标记清除
具体步骤:
-
先标记,看看哪些对象可以是垃圾,把没有被引用的对象标记出来
-
清除垃圾,把被标记的内存空间释放
注意:这里可能会产生一个误区,释放,是不是意味着要把内存的每个字节进行清0操作呢?
其实并不会,只需要把这个被清除的对象的 起始、结束地址 记录下来,放在空闲的地址列表里就可以,下次再分配新对象时,就到这个空闲的地址列表里去找,看看有没有一块足够的空间容纳新对象,并不会把占用的内存做清0操作。
优点:速度快,只需把垃圾对象内存的起始、结束地址做记录就可以,无需额外处理
缺点:产生内存碎片, 即清除后不会再对内存空间进行整理操作,所以当我们再次分配一个较大的对象时
,比如 数组,而 数组 的分配需要一段连续的内存空间,但是清除后的每一个内存空间都不够 数组 存放,而其实总的内存空间却可以容纳我的数组对象,但由于清理后的内存空间不连续,所以造成新对象仍不能有一个有效的内存给新的数组对象用,所以会造成内存溢出问题
2、标记整理
具体步骤:
-
先标记,看看哪些对象可以是垃圾,把没有被引用的对象标记出来
-
为了避免 “标记清除” 算法产生内存碎片。在清理垃圾的过程中,会把可用的对象向前移动,让内存更为紧凑,整理之后,我们发现,内存空间更为紧凑了,这样就不会造成 “标记清除” 算法产生内存碎片
优点:没有内存碎片
缺点:由于清理的过程涉及到 对象的移动,那么效率自然就变低。比如我们有一些局部变量,而这些局部变量引用了这个移动的对象,所以自然需要改变引用的引用地址,涉及到内存区块的拷贝移动,还要把所有引用的地址改变,所以效率低,速度慢
3、复制算法
定义:把内存区划成大小相等的两个区,即下图的 FROM
和 TO
,其中, TO
这个内存区始终空闲,里面一个对象都没有
步骤:
-
先标记,看看哪些对象可以是垃圾,把没有被引用的对象标记出来
-
然后从
FROM
区,把存活的对象(没被垃圾回收的对象)转移到TO
内存区,复制的过程中,会完成碎片的整理,即不会产生内存碎片,复制完,清空FROM
内存区的垃圾 -
交换
FROM
和TO
的位置,原来的TO
变成FROM
, 原来的FROM
变成TO
,即TO
总是空闲的区域
优点:不会产生内存碎片
缺点:会占用双倍的内存空间
1.4 分代垃圾回收
1、定义
前面我们学习了三种垃圾回收算法,但实际上 JVM 虚拟机不会单独采用某一种算法,而是结合三种算法协同工作,具体的实现称为:分代垃圾回收。
把整个堆内存分为两块:新生代、老年代。而新生代又分为3个部分,即:伊甸园、幸存区 From、幸存区 To。
那么问题来了,为什么要做区域划分呢?主要是因为 Java 中有的对象需要长期使用,长时间使用的对象,就把其放到 老年代 中,而那些用完了就可以回收掉的对象,就可以放在 新生代 中,这样就可以根据对象的生命周期不同,进行不同的垃圾回收策略,老年代的垃圾回收机制,就很久触发一次,而新生代垃圾回收触发的几率就多一点,这样针对不同的区域我们采用不同的算法就可以对垃圾回收有一个更好的管理
2、分代垃圾回收机制工作原理
-
当我们创建一个新的对象时,那么这个对象默认就会使用 伊甸园 这块空间,接下来可能会有很多对象被创建,所以也会分配到 伊甸园 中。而随着对象创建,内存逐渐增加,当内存不够时,若再想往 伊甸园 中添加对象,这时就会触发一次 垃圾回收。
-
新生代的垃圾回收一般称为:
Minor GC
,而Minor GC
触发后,就会采用 可达性分析算法 沿着GC Root
引用链去 伊甸园 中查找,看这些对象有用或者可以被当成垃圾回收,即先 标记,标记完成后,就会采用 复制 算法,把存活的对象复制到 幸存区To ,而 复制到幸存区To的对象,寿命就会加1,而至于 伊甸园 中的对象,就可以全部被当成垃圾回收。 -
但我们知道,完成一次 复制 算法后,
From
和To
的位置就会互换,但内存空间不会变,即只是交换位置。这就是第一次垃圾回收产生的效果 -
完成第一次垃圾回收后,此时 伊甸园 内存空间足够了,又可以往里面添加对象了
-
又过了一段时间,此时 伊甸园 的内存空间又满了,又需进行 第二次垃圾回收 ,第二次垃圾回收,除了要把 伊甸园 存活的对象找到以外,还需在 幸存区To 中判断有无需要继续存活的对象,即 幸存区To 中的对象也有可能在第二次垃圾回收中被回收,与第一次垃圾回收类似,把存活的对象复制到 幸存区To ,而 复制到幸存区To的对象,寿命就会加1,而至于 伊甸园 中的对象,就可以全部被当成垃圾回收。且完成一次 复制 算法后
From
和To
的位置需要互换 -
但 幸存区 中的对象不会一直存在,当超过一定寿命时(默认 15 ),就会把该对象存到 老年代
一个冷知识为什么默认为15?
对象的GC年龄肯定和对象相关,信息肯定保存在对象的某块区域,我们平时看不到是因为Java对开发者屏蔽了一些数据。
我们平时写代码,编写的只是对象的实例数据,但其实Java对象除了自身的实例数据外,还包括头信息和对齐字节,如下图所示:
对象的GC年龄就保存在对象的头信息里,除此之外,头信息还记录了对象的
锁标记
,大家常常说的“Java锁的是对象而不是代码”
就是这个道理,上锁修改的是头信息中的锁标记。对象的头信息内存分配不同的JVM实现不一样,一般来说32位占8字节,64位占16字节(开启压缩指针占12字节)。
因为Object Header采用4个bit位来保存年龄,4个bit位能表示的最大数就是15!
-
但当 老年代 与 新生代 内存空间同时不足时,这时就会触发一次
Full GC
,进行 老年代 的垃圾回收,此时就会完成一整轮垃圾回收,从新生代到老年代
注意: 其实,当发生 Minor GC
时,就会发生一次:stop the world。什么意思呢?其实就是在发生垃圾回收时,必须暂停其它用户线程,由垃圾回收线程完成垃圾回收,当把对象从 伊甸园、幸存区From 拷贝到 幸存区To 时,即等垃圾回收的动作做完后,其它的用户线程才能继续运行。
那么问题来了,为什么需要把其它用户线程都暂停呢?
这是因为在垃圾回收的过程中,涉及到对象的复制,也就是对象地址会发生改变,而这种情况下,如果多个用户线程都在运行,就会造成混乱,即对象都在移动,其它的线程再根据原来的地址访问这个对象,就访问不到了。
3、相关 VM 参数
1.5 垃圾回收器
1、类型
- 串行
- 单线程
- 堆内存较小,适合个人电脑
- 吞吐量优先
- 多线程
- 堆内存较大,多核 CPU
- 让单位时间内,STW 的时间最短 ,如 0.2 + 0.2 = 0.4,垃圾回收时间占比最低,这样就称 吞吐量高
- 响应时间优先
- 多线程
- 堆内存较大,多核 CPU
- 尽可能让单次 STW 的时间最短,如 0.1 + 0.1 + 0.1 + 0.1 + 0.1
2、串行
1、需要配置以下信息
-XX:+UseSerialGC
-XX:+UseSerialGC = Serial + SerialOld
Serial:工作在 新生代,采用的回收算法是:复制
SerialOld:工作在 老年代,采用的回收算法是:标记整理
且 新生代和老年代的垃圾回收器是分别运行的
若 新生代的内存不足,会采用 Serial 完成垃圾回收
若 老年代的内存不足,会采用 Serial 完成 Minor GC,SerialOld 完成 Full GC
那么具体它的回收过程是怎样的呢?如上图,假设我们现在有多核CPU,刚开始这些线程都在运行,运行一段时间后,发现堆内存不够了,触发了一次垃圾回收,这时要让这些线程在一个安全点停下来,那为什么要让这些线程停下来呢?因为可能在垃圾回收的过程中,部分对象的地址要发生改变,为了保证安全的使用这些对象地址,则需要所有的用户线程到达一个安全点停下来,这时完成垃圾回收就不会有其它线程干扰了,否则如果移动了对象,地址改变了,其它线程来访问这个对象,就可能找到错误的地址的对象,程序就会出问题。
而 Serial + SerialOld
都是单线程的垃圾回收器,所以只有一个垃圾回收线程在运行,当这个垃圾回收线程在运行时,其它的用户线程就会进入 阻塞 状态,等待垃圾回收线程的结束。完事后再继续运行
3、吞吐量优先
1、需要配置以下信息
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
在 jdk 1.8 默认开启上面两个开关
-XX:+UseParallelGC:新生代并发垃圾回收器,采用复制算法
-XX:+UseParallelOldGC:老年代并发垃圾回收器,采用标记整理算法
-XX:+UseAdaptiveSizePolicy 采用自适应的新生代大小调整策略
-XX:GCTimeRatio=ratio 与 MaxGCPauseMillis 冲突
-XX:MaxGCPauseMillis=ms 最大暂停毫秒数 默认200ms
-XX:ParallelGCThreads=n 控制垃圾回收线程数
2、工作流程
- 刚开始这些线程都在运行,运行一段时间后,发现堆内存不够了,触发了一次垃圾回收,这时要让这些线程在一个安全点停下来。与 串行 不同,垃圾回收器会开启多个垃圾回收线程一起回收,垃圾回收线程数默认与CPU核数相关,但因为同时开启多个垃圾回收线程,所以在回收时,CPU会瞬间飚到100%。
- 但是 垃圾回收线程 数可以通过
-XX:ParallelGCThreads=n
来控制 ParallelGC
比较智能,可以根据设置的参数,调整堆的大小以达到期望目标-XX:GCTimeRatio=ratio
调整垃圾回收时间与总时间占比 1/(1+ratio)-XX:MaxGCPauseMillis=ms
最大暂停毫秒数 默认200ms。
4、响应时间优先
-
相关参数
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld UseConcMarkSweepGC:concurrent(并发)、Mark(标记)、Sweep(清除),一款基于标记清除的并发回收器 -XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=thread -XX:CMSInitiatingOccupancyFraction=percent -XX:+CMSScavengeBeforeRemark 一个开关
-
运行流程
首先多个 CPU 开始并行执行,老年代发生了内存不足,线程在安全点停下来,这时
UseConcMarkSweepGC
垃圾回收器开始工作,执行 初始标记 动作,在这个 初始标记 动作时,仍需要 STW ,即其它用户线程进入 阻塞 ,暂停下来,但是 初始标记 很快,因为只完成标记根对象,完成该动作后,用户线程就可以运行了,与此同时,垃圾回收线程继续 并发标记 ,把剩余的垃圾找出来,但这时与其它用户线程是 并发进行的,当完成 并发标记 后,还需再 重新标记 ,这时又需要 STW ,为什么呢?因为 我们在 并发标记 的同时,用户线程也在工作,工作的时候,现有的对象的引用可能就会改变,所以 并发标记 结束后,仍需再进行 STW,完成后,再进行 并发清理 。整个过程,只有 初始标记 和 重新标记 需要 STW,整个过程时间短,符合 响应时间优先
虽然这种垃圾回收器对CPU的占用没有
UseParallelGC
高,就拿下图的例子,4核的CPU,只用了1核去做垃圾回收,所以对 CPU 的占用并不高,但是,用户线程也在运行,本来用户工作线程可以满负荷工作的,即本来4核CPU都能使用上,但是其中1核被垃圾回收占用了,所以用户工作线程只能占用原来的 3/4 的CPU的数量,所以对整个应用程序的吞吐量有一定影响, -
参数解读
UseConcMarkSweepGC
一款基于标记清除的,工作在老年代并发回收器,与之配合的是UseParNewGC
,是一款工作在 新生代的 基于 复制算法的垃圾回收器,并发,指我们在进行垃圾回收的同时,其它用户线程也能同时进行,即用户线程和垃圾回收器的并发执行,但其在某几个阶段也需要进行 STW 。且有的时候,UseConcMarkSweepGC
会发生并发失败的情况,这时会采取补救措施,让老年代的垃圾回收器,从UseConcMarkSweepGC
并发垃圾回收器退化到SerialOld
单线程垃圾回收器。ParallelGCThreads
并行垃圾回收线程数ConcGCThreads
并发垃圾回收线程数,建议设置为ParallelGCThreads
的 1/4,如4核CPU,设置1个线程去进行垃圾回收,剩下3个留个用户线程去工作CMSInitiatingOccupancyFraction=percent
CMS 垃圾回收器在工作过程中,由于其它用户线程还可以继续运行,这时也可能产生垃圾,但是 并发清理 的同时不能把这些新的垃圾回收掉,所以就得等到下一次垃圾回收时才能清理,我们把这些垃圾称为 浮动垃圾,这时产生了新的问题,因为在垃圾回收时可能产生新的垃圾,它又不能像其它垃圾回收器那样,等到整个堆内存不足了再垃圾回收,那样的话,那些新垃圾就无处可放了,所以得预留一定空间保留这些浮动垃圾。这个参数就是控制我们何时进行垃圾回收的时机,参数类型是百分比,比如设置为:80。表示只有老年代的内存占用到达 80% 时,就执行一次垃圾回收CMSScavengeBeforeRemark
在 重新标记 之前,对 新生代 进行垃圾回收。有可能 新生代的对象会引用老年代的对象,这时在 重新标记会扫描整个堆,然后通过新生代扫描引用老年代做可达性分析,但这样堆性能影响大,因为新生代创建的对象有点多,其中可能有很多都是垃圾对象 ,所以就算找到了,将来也要被回收掉,所以相当于做了一些无用功
-
并发失败
由于该垃圾回收器采用 标记清除算法,所以可能产生较多的垃圾碎片,这样就会造成将来如果 分配对象时,经历一次 Minor GC后不足,由于老年代碎片过多也不足,这样就会造成 并发失败,及由于碎片过多造成并发失败,这时
UseConcMarkSweepGC
老年代垃圾回收器就不能正常工作了,这时UseConcMarkSweepGC
就会退化为SerialOld
,做一次 单线程的、串行的 垃圾回收,清理完碎片才能继续工作。如果发生并发失败了,垃圾回收时间就会邹增,导致本来是 响应时间优先 变成 响应时间过长
5、Garbage First(G1)
1、简介
-
JDK9 默认的垃圾回收器
-
适用场景
- 同时注重 吞吐量(Throughput)和 低延迟(Low latency),默认的暂停目标是 200ms。
- 也可在垃圾回收线程运行的同时,其它用户线程继续运行
- 超大堆内存,会将 堆 划分为多个大小相等的 Region(区域),每个 Region 都可作为独立的 Region,每个Region 都可以作为 伊甸园、幸存区、老年代 。
- 与
CMS
垃圾回收器都属于 并发的垃圾回收器,在堆内存较小的情况下,暂停时间不相上下。但若随着堆内存越来越大,那么G1
的优势就比CMS
明显了
- 与
- 整体上采用了:标记整理算法,避免产生垃圾碎片。两个Region之间是 复制 算法。
- 同时注重 吞吐量(Throughput)和 低延迟(Low latency),默认的暂停目标是 200ms。
-
相关 JVM 参数
-XX:+UseG1GC // jdk 1.8 不是默认的,需做设置 -XX:+G1HeapRegionSize = size //划分区域 -XX:MaxGCPauseMillis = time // 暂停目标时间
2、G1垃圾回收阶段
3、G1-新生代回收
Young Collection
-
会 STW
首先,G1 垃圾回收器会把整个堆内存划分成很多个 Region,每个 Region 都可以独立作为 伊甸园、幸存区、老年代,白色框框 表示 空闲的区域,当执行类加载时新创建的一些对象,就会分配到 E 伊甸园 区,随着 E 伊甸园 区被占满,会触发一次 Young Collection,这时也会 STW,当然这个时间比较短
新生代垃圾回收就会把 幸存的对象,以 复制 算法放入 幸存区S
随着对象的增多,幸存区 内存不足或者幸存区的对象超过一定年龄,又会触发 新生代垃圾回收,这时,幸存区一部分对象就会晋升到 老年代O,而不够年龄的幸存区对象,会继续 复制 到其它幸存区S
4、Young Collection + CM
**定义:**新生代的垃圾回收和并发阶段
-
我们在进行垃圾回收时,需要进行 初始 标记和 并发标记。初始标记,就是要找到那些 GC Root(根对象),而 并发标记 就是从 RC Root(根对象)出发,顺着引用链找到其它对象。
-
初始标记 在 新生代垃圾回收 时就发生了,注意,初始标记并不会占用 并发标记 的时间
-
什么时候进行并发标记呢?当老年代占用堆空间达到一定阈值时,这时就会发生 并发标记(不会 STW),由以下 JVM 参数设置
-
-XX:InitiatingHeapOccupancyPercent=percent(默认 45%)
5、Mixed Collection
会对 伊甸园、幸存区、老年代 进行全面垃圾回收
- 最终标记(Remark)会 STW
- 防止之前 并发标记 中可能会漏掉一些对象,因为在并发标记的同时,其它用户线程也在工作,可能会产生一些新的垃圾,改变对象的引用,所以可能会被结果产生影响,所以需要在 Mixed Collection 阶段,先 STW,然后执行一个 最终标记
- 拷贝存活(Evacuation)会 STW
-XX:MaxGCPauseMillis=ms 最大暂停时间
分析上图:
进行一次垃圾回收后,伊甸园E 的幸存对象 会被 复制 到 幸存区S ,另一些 幸存区S 的对象,不够年龄的也会被复制到 幸存区S,有一些符合晋升条件的就会晋升到 老年代O
经过几轮并发标记后,发现老年代O 里也有一些对象没用了,可以回收了。看上图,为什么没有把所有老年代O箭头都指向右下角那个老年代O呢?那是因为 G1垃圾回收器,会根据 -XX:MaxGCPauseMillis=ms 最大暂停时间
,进行有选择的垃圾回收,怎么理解呢?有时候我们堆内存空间很低,老年代的垃圾回收时间就可能会很长,因为采用的 复制 算法,有大量的对象要从一个 Region 复制到 另一个 Region,这时如果时间长了,就达不到 我们预期设置的 -XX:MaxGCPauseMillis=ms 最大暂停时间
,那怎么办呢?为了达到这个设置的最大暂停时间,G1垃圾回收器就会从所有老年代中挑选回收价值最高的几个 Region,也就是这几个 Region 被回收后能释放更大的空间,所以就只会挑几个 Region,这时 Region 少了,最大暂停时间也就能达到了。当然,如果要复制的对象没那么多,最大暂停时间 这个目标也能达到,那么就会把所有 Region 都复制走,复制,一方面是为了保存 存活对象,另一方面是为了 整理, 减少内存空间。
这时就验证了 为什么要把这个垃圾回收器称为 :G1,即优先回收垃圾最大的 Region。主要目的就是为了达到 最大暂停时间
注意
当 垃圾回收速度 < 垃圾产生速度 ,这时 并发收集 就失败了,这时就会退化为 串行 的收集,这时就称为 Full GC了。
6、Young Collection 跨代引用
-
新生代回收的跨代引用(老年代引用新生代)问题
我们先回忆一下新生代垃圾回收,首先就是找到 GC Root(根对象),然后 GC Root 进行可达性分析,再找到存活对象,存活对象进行 复制,复制到 幸存区。
这时就有一个问题,我们要找 新生代对象的 GC Root,通过 GC Root就行查找,那首先得找 GC Root,而 GC Root 又有一部分来自老年代,但老年代的存活对象又很多,如果我们通过遍历整个老年代找到根对象,显然效率很低,因此,采用了 卡表(card table) 的技术,把老年代的Region,再进行细分,分成一个个的 card,每个 card 分别为 512K ,如果老年代中有一个对象引用了新生代的对象,那么这个对应的 card 就标记为:脏卡。这样做的好处就是做 GC Root 遍历时 不用去遍历整个老年代,而是只需要去关注 脏卡 的区域就好了。
上图中,粉色区域 代表 脏卡,它们都有对象引用新生代中的对象。
新生代中有一个 :Remember Set,会记录外部对新生代区域的引用,也就是记录都有哪些 脏卡。将来对 新生代进行垃圾回收时,就可以通过 Remember Set 去知道有哪些脏卡,然后再到这些脏卡中,遍历 GC Root。这样就减少了 GC Root 的遍历时间。
在进行对象引用创建时,会有一个查找过程,查找该引用是否被其它区域对象所引用,若被引用,则在 Remember Set 集合中标注,也就是 脏卡
但这时又有一个问题,我们需要标记脏卡,这些脏卡其实是通过下面 post-write barrier + dirty card queue //写屏障
,在每次对象的引用发送变更时,都要去更新脏卡,即把卡表中的卡标记为 脏卡。这是一个 异步操作,即不会立刻完成脏卡的更新,会把更新指令放在脏卡的队列中,将来由一个线程完成脏卡的更新操作
产生跨代引用(老年代引用新生代对象)的老年代区域称为脏卡区域
7、Remark(重新标记)
- pre-write barrier+satb_mark_queue
上图表示的是:并发标记阶段,对象的处理状态
- 黑色:已处理完成,且有引用在引用它们,所以结束时会存活下来
- 灰色:正在处理,上图中 灰色 的方框被引用,所以最后还是会存活
- 白色:未处理,上图中右下角的 白色 的方框被引用,所以最后还是会存活,上面那个白色 因为无人引用,最后还是白色,还是会被回收
案例一
假如现在处理到 灰色B ,因为有强引用引用它,所以,就把它变成黑色,将来会存活,当我们处理到 白色C 时,因为是 并发标记 ,就表示 可能会有 用户线程 对 白色C 的引用做修改,比如把 B–>C 的引用断开,所以处理 C 时,发现已经没被引用了,所以等整个 并发标记 完成后,C仍然是白色,最后就会被回收。
案例二
-
在 C 被处理完后,并发标记可能还没有结束,这时用户线程又改变 C 的引用地址,比如把 A—>C 。这时问题就来了,因为之前 C 已经被处理过了,且 A 是黑色的,所以也不会处理 A了,所以等到整个并发标记结束后,C就会漏处理了,但我们仍然认为 C 是白色的,要把其回收掉,但这样就错误了,为什么呢?这时候有一个强引用引用它,若再将其回收掉,这时伤害就大了,所以,需对对象的引用做进一步的检查,怎么做呢?其实就是 Remark,重新标记阶段。就是为了防止这种现象发生,那具体怎么做呢?
-
就是当对象的引用发送改变时,JVM 就会为其加入一个 写屏障。什么叫写屏障? 只要你的对象引用发生改变,写屏障 的代码就会被执行,比如把 C的引用 给 A的一个属性,这说明 C的引用 发生了变化,既然发生变化,写屏障的代码就会被执行,那写屏障的指令做了什么呢?它就会把 A加入队列中,并且把 A 变成灰色,即表示还没处理完,等到整个 并发标记 结束了,接下来进入 重新标记 阶段,重新标记会 STW,让其它用户线程暂停,这时 重新标记 就会把 队列 中的对象一个个取出,再做一次检查,发现是 灰色的,还需进一步判断处理,结果发现有强引用,再把其变成黑色
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/134547.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...