JVM垃圾回收
共 9261字,需浏览 19分钟
·
2020-12-25 13:22
看到垃圾回收,首先你会想到什么?
1、什么是垃圾?
2、哪些地方的垃圾需要被回收?
3、如何定位垃圾?
4、如何回收垃圾?
5、什么时候回收垃圾?
下面,我们将带着这5个问题来进行分析。
1、什么是垃圾?
JVM中的垃圾指的是无用的内存,这些内存中的数据若在后续处理过程中不再被使用,那么我们将是为垃圾进行回收,以保证有足够的内存可用。
程序在运行过程中会存在两个问题:
内存溢出:是指内存空间分配不足
内存泄露:是指内存使用完后无法被回收
2、哪些地方的垃圾需要被回收?
线程私有内存区域(程序计数器、虚拟机栈、本地方法栈)会随着线程的结束而释放,栈中的栈帧随着方法的进入和退出有条不紊的执行者进栈和出栈操作,每个栈帧中的分配多少内存在编译期便已确定,因此无需对该区域进行垃圾回收。
线程共享内存区域(方法区和Java堆)需要分配的内存大小只有在运行期才知道,因此这部分的内存是动态分配和回收的,也是我们接下来需要对垃圾内存回收的区域。
3、如何定位垃圾?
因为我们垃圾回收的主要区域是针对于Java堆(又称为GC堆),这部分区域主要存放的是Java对象的实例,因此判断内存是否可以被回收只需要确认实例对象已经不被使用,即对象是否存活。
判断对象是否的方法有两种,一种是引用计数算法,另一种是可达性分析算法。下面我们来介绍一下这两中算法的优缺点。
1)引用计数算法
首先我们来说一下这个算法的实现思想:为每个对象添加一个引用计数器,每当有一个地方引用它时,计数器的值加1,当引用失效时,计数器的值减1。当计数器的值为0时表示该对象没有被引用。
上述实现思想看起了一点毛病都没有,实现起来也是非常的简单,而且效率也比较高,但是既然会有其他的算法被使用,那么该算法也就一定有他的缺点。
优点:实现简单且效率高
缺点:无法处理对象互相循环引用的情况。
对象的互相循环引用?听起来不太容易理解,那我们就来举个例子吧
public class ReferenceCountingGC {
public Object instance = null;
public static void testGC () {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}
上述代码中,对象A和对象B彼此引用,虽然对其句柄赋值为null,但是两个对象的引用计数器的值均为1,因此使用引用计数算法无法对上述两个对象进行回收。
2)可达性分析算法
目前主流的程序语言的实现中都称是通过可达性分析算法来判断对象是否存活。其算法的基本思想是:通过一系列被称为”GC Roots“的对象作为起始点,开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Roots没有任何的引用链时,则证明此对象是不可用的。
上面提到了一个“GC Roots”,那么哪些对方的对象是可以作为GC Roots呢?
在Java语言中,可作为GC Roots的对象包括:
a. 虚拟机栈(栈帧中的本地变量表)中引用的对象
b. 本地方法栈中JNI(native方法)引用的对象
c. 方法区中类静态属性引用的对象
d. 方法区中常量引用的对象
4、如何回收垃圾?
关于如何回收垃圾这一部分我们要分为两部分进行讲解:垃圾收集算法和垃圾收集器
第一部分 垃圾收集算法
垃圾收集算法主要包括三种算法:标记-清除算法、复制算法、标记-整理算法
另外还有分代收集算法,这种算法没有特别的思想,而是根据对象存活周期的不同将内存划分为几块,然后根据对应内存区域的特点采用适当的收集算法。
下面我们分别对上述三种算法进行分析说明。
1)标记-清除算法
实现思路:分为两个阶段,第一个阶段是标记,如何标记(定位)垃圾已经在第3点中介绍过了。第二个阶段是直接清除。
标记-清除算法的执行过程如图所示。它存在两个不足之处:
a. 效率问题,标记和清除两个过程的效率都不高。
b. 空间问题,标记清除之后会产生大量不连续的内存碎片,可能会导致在后续分配较大对象是没有足够的空间导致出发一次内存收集的动作。
2)复制算法
实现思路:将内存按照大小划分为两块,每次只使用其中的一块,当另一块的内存用完了,就将还存活的对象复制到另一块的上面,然后再把当前内存区域一次清理掉。
优点:内存分配时只需要按照顺序分配即可,实现简单,运行效率高。
缺点:牺牲了一半的内存空间
目前商业虚拟机并非将内存空间按照1:1的比例来划分内存,而是将内存分为一块较大空间的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。回收时将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,每次新生代中可用的内存空间为整个新生代容量的90%。这里面临着两个问题:一是什么场景适合使用该方式,二是如果Eden和Survivor存活的对象超过10%该怎么处理?
适合使用的场景:在绝大数情况下,Eden和Survivor在回收后存活的对象小于10%
超过10%的处理方式:内存分配担保。我们将内存空间分为新生代(Eden和Survivor)和老年代,如果Eden和Survivor回收后的存活对象超过10%,则直接进入老年代。
3)标记-整理算法
实现思路:类似于标记-清除算法,不同的是不直接对可回收的对象进行清理,而是让所有存活的对象都想前一端移动,然后清理掉端边界以外的内存。
优点:不会产生大量不连续的内存空间,适用于老年代
4)分代收集算法
将Java堆划分为新生代和老年代。
新生代在每次垃圾收集时会有大批对象死去,适合采用复制算法。
老年代对象存活率高且没有额外空间进行分配担保,因此适合采用标记-整理算法。
第二部分 垃圾收集器
接下来,我们将对HotSpot虚拟机中不同jdk版本中使用过的垃圾收集器的实现进行分析,下图对可以组合使用的垃圾收集器通过联系进行了关联。
新生代的垃圾收集器
Serial
ParNew
Parallel Scavenge
老年代的垃圾收集器
Serial Old
CMS(Concurrent Mark Sweep)
Parallel Old
新生代和老年代均可用:G1垃圾收集器
以及在JDK11中的ZGC垃圾收集器
关于垃圾收集过程的个人理解:
无论是何种收集算法何种收集器,我们在标记、清除、复制、整理这几个过程需要保证一点,不能清理掉正在被引用的对象。在这个基础之上,我们接下来要做的就是两点,一是提升收集效率,二是提升用户体验。
提到对象的标记,我产生了一个疑问:难道当我们需要进行垃圾标记时真的要(从虚拟机栈栈帧中的本地变量表中引用的对象、方法区中类的静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象)一个一个的去遍历吗?想想都觉得这个过程真的好耗时啊.....这一点当前主流的虚拟机已经考虑到了,在 HotSpot 虚拟机中使用了 OopMap 的数组结构来帮我们存放了对象引用。
在类加载时,HotSpot 会将对象内什么偏移量上是什么类型的数据计算出来
在JIT编译时,会在特定未知记录下栈和寄存器中哪些位置是引用
所以GC时直接扫描这里就可以了。
问题又来了....
JIT编译时,我们该不会在执行过程中每一步都维护这个 OopMap 吧?这样的话得又多少个 OopMap .... 我们看到上面提到了在“特定位置”,那接下来我们来看看这个“特定位置”应该是哪里。
现在给你个机会,让你来说说你怎么来设定这个“特定位置” ?
emm...要是能有一个地方,可以让我在更长的时间里使用同一个OopMap那就好了,这个想法不错,哪到底哪里能够长时间或者重复使用一个 OopMap 呢?哈,循环调用的时候!!!没错了,像方法调用、循环跳转、异常跳转这些地方指令是可以重用的。教科书上称这个地方为安全点(saftpoint)。那我们只需要记录这些安全点上对应的 OopMap 就可以咯。
不要太开心,问题是解决不完滴.....接着看
现在你在安全点上挖了坑等着线程往里面跳,但是我该用什么姿势会更帅一点呢?
换句话说:我就问你你喜欢我主动点还是被动点?
好吧,老爷们怎么能这么被动,还得老司机扶你一把。那我们来看一下怎么扶你到点上吧。
抢先式中断:在GC时,首先把所有线程中断,如果发现哪些线程中断的地方不再安全点上,就恢复线程,让它“跑”到安全点上。当前几乎没有虚拟机采用抢先式中断来暂停线程去响应GC了。
不过,单生狗们,下面才是我们的标准动作,看好咯。
主动式中断:当GC需要中断线程的时候,我们不直接中断线程,而是设置一个标志,各个线程执行时主动去轮询这个标志,发现是中断标志时就自己中断挂起。轮询标志的地方和安全点是重合的。
然而我们 GC 妹子还是很担心总有那么一些渣男线程偏偏捣乱,你说该咋整呢?
这个好办,我把哪些贤良淑德的帅哥们给你们安排个专场,你来这里找就行了。
安全区域:安全点貌似已经解决了 GC 的问题,然后有一些线程可能没有分配到 CPU 而无法执行(如线程处于 Sleep 状态或 Blocked 状态),导致这些线程进入安全点需要等待较长的时间。所以,为了能够排除这些无法执行的线程,我们给那些可以执行的线程一个安全区域,在这个区域中任意地方开始 GC都是安全的。
回归主题......
我们通过引用计数算法或可达性分析算法定位到当前这个时刻哪些对象是可以被清理的,但是我们需要保证被标记的对象在被清理之前不会被再次引用,所以这里出现了一个动作stop the world,一个很霸气的名字,意思就是停止所有用户线程的执行。什么时候发生 stop the world ? 当然是要执行 GC 操作的时候咯。线程停在哪里?上面那个专场(安全区域),然后现在就可以安心的清理垃圾了。
在保证了不能清理掉正在被引用的对象这个基础之上,我们接下来再看看我们该提升收集效率和提升用户体验。
提升收集效率,具体化就是提高吞吐量。
提升用户体验,具体化就是缩短回收停顿的时间
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
如何提高吞吐量?
将单线程处理改为多线程处理,充分利用CPU的处理能力
如何缩短回收停顿时间?
在标记期间,可以让用户线程和GC线程同时执行,然后在执行清除前在对标记的内存进行一次确认,尽量缩短回收停顿的时间。
那接下来,我们来看看 HotSpot 在收集器迭代过程中是如何提高收集效率和提升用户体验的。
1)Serial 收集器
jdk1.3.1之前版本
新生代的收集器
采用复制算法
单线程收集器,进行垃圾收集时暂停所有用户线程
优点:在单个CPU环境下运行简单高效
缺点:无法充分利用多个CPU的处理能力
2)ParNew 收集器
可以看做是Serial收集器的多线程版本
新生代收集器
采用复制算法
多线程收集器,进行垃圾收集时暂停所有用户线程
优点:在多核CPU环境下要比Serial收集器效率高。
缺点:CPU个数较少情况下不明显,甚至可能比Serial收集器效率还要低
3)Parallel Scavenge 收集器
新生代收集器
采用复制算法
多线程收集器,进行垃圾收集时暂停所有用户线程
和ParNew收集器的区别:
Paralle Scavenge 收集器的目标是达到一个可控制的吞吐量(CPU用于用户代码的时间与CPU总消耗时间的比值)
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
两个重要参数设置:
-XX:MaxGCPauseMills 参数说明:控制垃圾收集停顿时间,参数值的设定并非越小越好,参数值较小,垃圾收集速度较快,则会牺牲吞吐量和新生代空间
-XX:GCTimeRatio 参数说明:设置垃圾收集时间占总时间的比率,即吞吐量=1-垃圾收集时间占总时间的比率
优点:可以提供良好的响应速度,从而提升用户体验
缺点:需要减少新生代空间来以及降低吞吐量来缩短停顿时间
4)Serial Old收集器
老年代收集器
采用标记-整理算法
单线程收集器,进行垃圾收集时暂停所有用户线程
用于Client模式下的虚拟机使用
两大用途:
JDK1.5 以及之前的版本中与Parallel Scavenge 收集器搭配使用
作为 CMS 收集器的后备预案,在并发收集发生Concurrent Mode Failure 时使用
5)Parallel Old 收集器
JDK 1.6版本提供使用
老年代收集器
采用标记-整理算法
多线程收集器,进行垃圾收集时暂停所有用户线程
优点:可以充分利用服务器多CPU的处理能力
6)CMS 收集器
老年代收集器
采用标记-清除算法
多线程收集器
与 Parallel Old 收集器的区别:
CMS 收集器目标是获取最短回收停顿时间。
处理过程:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
在这里我需要简单说明一下:所有在堆中的对象都是记录的,我们可以暂且看作一个记录表,那么在这个记录表中,如果标记了所有被引用的对象,那么反过来我们就自然知道那些没有被标记的对象就是可回收对象,有时候我们说标记了对象是指标记了可回收对象。
从下面示意图中,有没有发现这样一个问题,就是在标记和清除的过程中,并不是一直都处于 stop the world,当时我产生了一个疑问,那在这个过程中会不会存在错误的把那些被引用的对象当作垃圾对象的情况呢?我们看到标记阶段被分为了三次执行:
第一次标记,仅仅是标记了与 GC Roots 直接关联的对象。且会 stop the world
第二次标记,将第一次标记的对象进行追溯。这个过程不会 stop the world,而是与用户线程并发执行的,所以可能会产生这样两个问题,一是可能有些被标记的对象是被引用了的,二是可能又产生了一些新的垃圾。针对于“可能有些被标记的对象是被引用了的”情况我们可以通过第三次标记来处理。而对于“可能又产生了一些新的垃圾”的情况我们可以在下次在对其清理,并不影响我本次 GC 的准确性,这个阶段产生的垃圾在教科书中被成为“浮动垃圾”。
第三次标记,会修正第二次并发标记过程中因为用户线程执行而导致标记变动的对象。这个阶段耗时比第一次标记要长,但是远比第二次标记耗时要短。
你可能你存在一个疑问:第二次标记为什么会产生“可能有些被标记的对象是被引用了的”?
因为我们通过可达性分析算法追溯那些对象被引用时,只是根据第一次标记时与 GC Roots 直接关联的对象进行追溯的,但是在第二个阶段用户线程也在执行,这个时候 GC Roots中会产生新的对象,也就会产生新的链路,那么我们并没有对这些链路去追溯对象,所以那些对象也就被我们标记而被是为垃圾对象,所以这个时候,需要第三次标记,把这段时间新产生的 GC Roots 在进行一次追溯,修正一下被误认为是垃圾的对象。由于第二次并发标记执行的时间产生新的 GC Roots 是要比第一次少的多的,所以第三次重新标记的时间要短的多。
那么在上面我们已经标记了哪些对象为垃圾对象了,那么回收阶段我们就只管回收就好了,是否与用户线程并发执行并没有关系。好吧,也会你会问为什么会没有关系呢?因为当某一个时刻,这个对象在全局范围内没有被使用,那么后续过程怎么会使用呢?相当于这个引用的传递关系已经就此断开了呀。
通过上面的分析我们也能够知道 CMS 收集器存在哪些缺点了吧。主要有两个缺点:
无法处理“浮动垃圾”,只能等到第二次触发GC时进行清理
CMS既然采用的是标记-清除算法,那么必然会产生大量的空间碎片
7)G1 收集器
面向服务端应用的垃圾收集器。
处理过程:
初始标记
并发标记
最终标记
筛选回收
特点:
并行与并发
分代收集
空间整合
可预测的停顿
接下来,我们来看两个问题,一是 G1 收集器和 CMS 收集器的区别,二是 G1 收集器的特点具体是什么。
问题1:G1 收集器和 CMS 收集器的区别
在处理过程中,G1 收集器和 CMS 收集器的差别并不是很大,仅仅是在回收时,CMS 收集器是并发执行,而 G1 收集器是筛选回收。那我们来看一下它们在回收阶段到底有哪些区别。
CMS 收集器通过并发执行,将标记阶段标记的所有垃圾对象进行了回收。
G1 收集器通过筛选回收,可以选择部分垃圾对象进行回收。
两者的差别就是一个是只能全部垃圾回收,一个可以部分垃圾回收。
那么部分垃圾回收有哪些好处呢?或者我们可以先考虑一下 G1 是如何做到部分垃圾回收的。
G1 收集器和 CMS 收集器的关注点都是追求低停顿,但是 G1 收集器通过将 Java 堆划分为多个大小相等的独立区域,然后有计划的去对各个区域进行收集,从而达到对垃圾收集时间的一个控制。
接踵而来的问题......
划分成大小相等的独立区域,难道区域之间是真的独立而没有任何交集吗?
我们知道 HotSpot 虚拟机将 Java 堆分为新生代和老年代,上面提到的收集器也是分别针对于新生代和老年代进行的处理。但是 G1 收集器可以同时处理新生代和老年代,所以对于 G1 收集器,Java 堆的布局也是发生了变化的。Java 堆被划分为多个大小相等的独立区域,新生代和老年代也变成了只是逻辑上的区分,在物理上不再隔离了。
在回答区域之间是否是真的独立而没有交集这个问题前,我想问一下,新生代和老年代之间交集吗?毋庸置疑,肯定是有交集啊,我只不过是把对象从新生代挪到老年代而已嘛。那好,我再问一个问题,那我们在对新生代进行 GC 时,也就是我们说的 Minor GC 时,我们还要把老年代也给遍历一遍吗?如果真的需要遍历一遍,那估计能把人等死....
其实,新生代和老年代之间的对象引用,虚拟机通过使用 Remembered Set 记录下来了,从而避免了全堆扫描。那现在回到上面那个问题上来,你说 G1 收集器划分的各个区域之间有没有交集呢?如果有它们之间是怎么处理的呢?现在应该不用我再来解释了吧。
问题2: G1 收集器的特点具体是什么?
1)并行与并发
充分利用多CPU、多核的处理能力来缩短 Stop-The-World 停顿时间。
2)分代收集
与其他收集器的概念相同。
3)空间整合
从整体来看是基于标记-整理算法实现的垃圾收集。
从局部(两个Region之间)上来看是基于复制算法实现的垃圾收集。
因此,G1 收集器在垃圾收集期间不会产生内存空间碎片。
4)可预测的停顿
G1会跟踪各个区域里面的垃圾堆积的价值大小(回收所获得空间大小记忆回收所需时间的经验值),在后台维护一个优先列表,根据设定的允许收集时间,优先回收价值最大的区域。
5、什么时候回收垃圾?
什么时候回收垃圾?
换种说法就是什么时候回收内存?
好了,请问你什么时候找你朋友借钱或者还钱?----钱不够用的时候
同样道理,当内存不够用的时候就要进行回收了。
既然提到内存不够用了,那你得直到内存是怎么用了吧,就像是你钱没了你得知道钱怎么花了吧。不过话说回来,这方面得学学人家内存,大部分都花在对象身上了。那我们就说说对象是怎么“花钱”的吧。
内存分配
对象的内存分配主要是在堆上分配。对象优先在新生代的 Eden 区上,少数情况也会直接分配到老年代中,分配规则是可以通过配置参数进行调整。我们接下来看一下通常情况下内存的分配规则。
1)对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区空间不足时触发 Minor GC。
2)大对象直接进入老年代
所谓大对象是指需要大量连续内存空间的对象,比如很长的字符串或数组。
虚拟机提供了 -XX:PretenureSizeThreshold 参数,令大于该参数值的对象直接进入老年代。避免在 Eden 区和两个 Survivor 区之间发生大量的内存复制。
3)长期存活的对象将进入老年代
在内存回收时,虚拟机需要识别哪些对象应该放在新生代,哪些对象应该放在老年代。虚拟机给每个对象定义了一个对象年龄计数器。对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能够被 Survivor 容纳,则被移动到 Survivor 区,且对象年龄设为1。对象在 Survivor 区每经过一次 Minor GC, 年龄加1。当年龄增长到一定程度(默认为15岁),将会被放到老年代。
虚拟机提供了 -XX:MaxTenuringThreshold 参数,设置对象进入老年代的年龄阈值。
4)动态对象年龄判定
如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
5)空间分配担保
在前面我们讲到,新生代采用的是复制算法。如 HotSpot 虚拟机中,将新生代分为 Eden 区和两个 Survivor 区,默认比例是8:1。每次 Minor GC 时会讲 Eden 区和 一个 Survivor 区中存活的对象拷贝到另一个 Survivor 区。那么可能存在这么一种情况,就是另一Survivor 区的空间不足一容纳 Eden 区和 Survivor 区中存活的对象,这个时候该怎么办?这里就是我们要说的空间分配担保,如果上面发生了上面说的那种情况,那么在 Eden 区和 Survivor 区存活的对象将直接进入老年代,即老年代为 Survivor 区进行担保。
而老年代采用的是标记-整理算法,无需其他空间担保。当老年代空间不足时,触发一次 Full GC。
现在来看什么时候进行垃圾回收就比较清晰了,当新生代空间不足以为对象分配空间时,触发一次 Minor GC,当老年代空间不足以为对象分配空间时,触发一次 Full GC。
作者:雨陽
链接:https://zhuanlan.zhihu.com/p/82936943
来源:知乎
end
*版权声明:转载文章和图片均来自公开网络,版权归作者本人所有,推送文章除非无法确认,我们都会注明作者和来源。如果出处有误或侵犯到原作者权益,请与我们联系删除或授权事宜。
长按识别图中二维码
关注获取更多资讯
不点关注,我们哪来故事?
点个再看,你最好看