垃圾回收机制
垃圾回收需要考虑的三件事:
哪些内存需要回收;
什么时候回收;
如何回收;
为什么要学习GC呢?当需要排查各种内存溢出。内存泄漏的问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
1. 判断对象是否需要被回收的算法
1. 引用计数法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1;当引用失效时,计数器的值就减1;任何时刻计数器为0的对象就是不可能被使用的。
引用计数法的实现简单,判定效率也很高,但是在主流的JVM中并没有选用引用计数法来管理内存,最主要的原因是它很难解决对象间互相循环引用的问题。比如:
这个时候两个对象已经不会再被使用了,但是由于对象内部还存在对方的引用,因此引用计数器并不是0,无法被标记为回收状态。
2. 可达性分析法
基本思路是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到GC Roots没有任何时(也就是从GC Roots到这个对象不可达),则证明此对象是不可用的。
在java语言中,可以作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象;
方法区中类静态属性引用的对象;
方法区中常量引用的对象;
本地方法栈中JNI引用的对象;
GC Roots实验可以参考:
方法区(HotSpot虚拟机中的永久代)也可以有垃圾回收,主要是回收废弃常量和无用的类,回收常量和回收对象类似,比如常量池中有一个“abc”常量,如果如果没有任何地方引用这个常量,那么在必要的时候就会被清理出常量池。判断一个常量是否是无用比较简单,但是判断一个类是否无用比较苛刻,需要同时满足3个条件:
该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例;
加载该类的ClassLoader已经被回收;
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
2. 浅谈finalize()方法
JVM真正的决定一个对象是否需要回收至少需要两次标记过程,如果一个对象不可达GC Roots,那它会被进行第一个筛选并进行一次过滤,过滤的条件是对象有没有必要执行finalize()
方法。当对象没有覆盖finalize()
方法或者finalize()
方法已经被虚拟机调用过,那么都被视为“没有必须执行”。
如果对象有必要执行finalize()
方法,那么这个对象会被放在一个叫做F-Queue队列中,稍后虚拟机会执行finalize()
方法,这里的执行只是会触发它,并不承诺等待它运行结束,这样做是因为如果一个对象在finalize()
方法中执行缓慢,或者发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()
方法是对象逃脱死亡的最后一次机会。稍后GC会对F-Queue队列中的对象进行第二次标记,如果对象成功在finalize()
方法中拯救自己,那么就会被移除“即将回收”的集合。
从上面的刷选条件可以看出,finalize()
方法只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()
方法不会再被执行。
不鼓励大家使用finalize()
,因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如果需要做“关闭外部资源”之类的工作,使用try-catch或者其他方式会做的更好、更及时。这个方法并不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受所做的一个妥协。
3. 什么时候被回收
Minor GC触发条件:当Eden区满时,触发Minor GC。
Full GC触发条件:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
4. 垃圾收集算法
1. 标记 - 清除算法
标记-清除算法分为标记和清除两个阶段:首先标记处所有需要回收的对象,然后在标记完成后统一回收被标记的对象。它是最基础的收集算法,后面的收集算法都是基于这种思路并对其不足进行改进而得到的。主要不足有两个:
1)效率问题,标记和清除两个过程的效率都不高;
2)空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次的垃圾回收动作。
2. 复制算法
复制算法的出现是为了解决效率问题,它将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次性清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑碎片等复杂情况,只要移动堆顶的指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。代价有点高了。
现在的商业虚拟机都采用这种收集算法来回收“新生代”,因为新生代中的对象大部分都是“朝生夕死”的,所以并不需要按照1:1来划分内存空间。这样就不会浪费50%的那么多的空间了。
3. 标记 - 整理算法
复制算法在存活对象较高时就要进行较多的复制操作,效率将会变低。更关键的时,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。所以老年代一般不能选用这种算法。
标记 - 整理算法,标记的过程和标记 - 清除算法一样,但后续的步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4. 分代收集算法
当前商业虚拟机都采用分代收集算法,将对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死亡,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集(分为一个Eden和两个Survivor,一个Eden和一个Survivor的比例默认8:1,先在Eden分配内存,不够了将Eden存活的移到to Survivor,清理掉Eden和from Survivor,然后from Survivor和to Survivor互换名字。下一次GC循环)。而老年代中因为对象存活率高,没有额外的空间对它进行分配担保,就必须使用标记 - 清除算法或者标记 - 整理算法来进行回收。
5. 什么样的对象在老年代分配内存
对象优先在新生代的Eden区分配内存,但是如果是一个大对象就会直接在老年代分配内存;
对象在新生代如果经过N轮(默认15)次Minor GC没有被回收掉,就会移步到老年代;
虚拟机不是永远要求经过N轮GC的对象才到老年代,如果Survivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进去老年代。
6. 垃圾收集器
如果说垃圾收集算法是内存回收的理论,那么垃圾收集器就是内存回收的具体实现。HotSpot虚拟机提供了以下几个垃圾收集器。每个垃圾收集器都有各自的特性。基本原理和使用场景,没有最好的,只有最适合的,我们要根据具体的应用场景应用最合适的收集器。
适合新生代的收集器:
Serial、ParNew、Parallel Scavenge、GI;
适合老年代的收集器:
CMS、Serial Old(MSC)、Parallel Old、GI;
6.1 Serial收集器
单线程
采用复制算法
简单高效,适合Client模式下的JVM
6.2 ParNew收集器
Serial的多线程版本
适合Server模式下的JVM
除了Serial目前只有ParNew能和CMS收集器配合工作
6.3 Parallel Scavenge收集器
采用复制算法
多线程收集器
吞吐量优先
自适应策略,可以根据系统运行情况收集性能监控,动态调整新生代(Eden,Survivor)和老年代的大小
6.4 Serial Old收集器
单线程
采用标记-整理算法
适合Client模式下的JVM
6.4 Parallel Old收集器
多线程
标记-整理算法
在注重吞吐量和CPU资源敏感的场合,可以优先考虑Parallel Scavenge和Parallel Old组合。
6.5 CMS收集器(Concurrent Mark Sweep)
以STW最短为目标
在重视服务响应速度,希望系统停顿时间短,以给用户带来较好的体验下可以考虑CMS
标记-清除算法
第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
GC过程分为4个步骤:
初始标记:需要STW,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
并发标记:进行GC Roots Tracing;
重新标记:需要STW,为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录
并发清除:清除对象,回收内存。
缺点:
对CPU资源非常敏感:并发阶段虽然不会导致用户进程停顿,但是会占用部分CPU资源而导致应用程序变慢,总吞吐量降低,CMS默认启动的回收线程数(CPU数量+3)/4。
无法处理浮动垃圾:并发清除阶段产生的新垃圾就是浮动垃圾,只能等下次GC进行回收,CMS收集器不能像其他垃圾收集器等老年代几乎快用完了再去收集,他要预留足够的内存给用户线程使用,
-XX:CMSInitiatingOccupancyFraction
参数可以设置触发GC的内存占用比例,如果预留的内存无法满足用户程序,就会出现一次Concurrent Mode Failure
,这是虚拟机启动应急预案,临时启用Serial Old进行GC,所以-XX:CMSInitiatingOccupancyFraction
参数设置的太高容易导致大量`Concurrent Mode Failure
。基于标记-清除就会产生大量的空间碎片:CMS收集器提供了一个
-XX:+UseCMSCompactAtFullCollection
开关参数(默认开启)用于在CMS要进行FullGC时开始内存碎片的合并整理过程。内存整理过程无法并发执行所以导致停顿时间边长。还提供了-XX:CMSFullGCsBeforeCompaction
来设置执行多少次不压缩的Full GC后,来一次带压缩的,默认是0,表示每次进入Full GC时都进行碎片整理。
6.6 G1收集器
目前收集器技术发展的最前沿成果之一
面向服务端应用的收集器
并行与并发
分代收集,采用不同的方式处理新创建的对象和已经存活了一段时间的对象
空间整合,从整体上看是标记-整理算法,从局部(两个Region之间)来看属于复制算法
可预测的停顿,可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
使用G1收集器时,java堆内存与其他收集器有很大不同,它将整个java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。G1之所以能够建立可预测的停顿时间模型,是因为它可以有计划地避免在整个java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
G1的运作大致可以划分为以下几个步骤:
初始标记:需要STW,标记GC Roots能直接关联到的对象
并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活的对象
最终标记:为了修正并发标记期间用户程序继续运作而导致标记产生变动的那一部分标记记录,需要STW,但可以多线程执行
筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,需要STW,多线程执行。
7. 垃圾收集器的适用场景
参数
垃圾收集器
适用场景
-XX:+UseSerialGC
Serial + Serial Old
虚拟机运行在Client模式下的默认值
-XX:+UseParNewGC
ParNew + Serial Old
-XX:+UseConcMarkSweepGC
ParNew + CMS + SerialOld
在重视服务响应速度,希望系统停顿时间短,以给用户带来较好的体验下,如常见WEB、B/S系统的服务器上的应用;
-XX:+UseParallelGC
Parallel Scavenge + Serial Old
虚拟机运行在Server模式下的默认值
-XX:+UseParallelOld
Parallel Scavenge + Parallel Old
高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;
-XX:UseG1GC
G1
参考:
7种垃圾收集器 主要特点 应用场景 https://blog.csdn.net/wusd1256/article/details/79416486
记录自《深入理解Java虚拟机》的笔记,供自己以后复习参考
Last updated