垃圾收集器
如何判别垃圾?
在介绍垃圾回收器之前,认真思考下,如果你是垃圾回收线程,在那么多的对象堆里如何才能把已经是垃圾的对象找出来?
(一) 引用计数
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
引用计数算法简单效率也高,但是它有个明显的缺陷:如果对象之间存在循环引用,比如对象A引用了对象B,对象B引用了对象C,对象C引用了对象A,但是对象A、B、C去没有被其他任何对象使用,那这三个对象其实就是垃圾对象,但是他们各自都有一个引用,那么引用计数器算法无法将他们识别出来,所以这个算法存在缺陷。
这个算法很重要,必须了解,也是了解垃圾回收算法的基础,比较经典。
(二) 可达性分析
在Java语言中,存在一种根对象,这个算法的关键就在于这些被称为“GC Roots”的根对象上,以根对象为起始节点,向下搜索,所有能搜索到引用的对象组成了一个“引用链”,如果一个对象不在任何GC Roots的引用链上,那么这个对象是不会在被使用的,所有回收这类对象。
对于哪些对象可归类到GC Roots,可以参考书中详细介绍,这里我简单说下我认为的几个熟悉:
所有static修饰的对象
基本数据类型对象、Stirng和一些异常对象
本地方法栈中引用的对象
被synchronized持有的对象
以上是介绍的对象——>垃圾的算法,接下来介绍这些垃圾——>回收的算法,切勿混淆。
垃圾回收算法——如何回收垃圾?
垃圾回收器
这个图一定要刻在脑子里,这张图介绍了垃圾回收器的历史演进,我们要明白各个时期的我垃圾回收器为什么出现,以及它被淘汰的原因。
介绍这些垃圾回收器之前,先介绍垃圾回收算法。
(一) 复制(Copying)
《深入理解JVM》书中将这个算法介绍为标记-复制,我这边只将它称为复制算法,主要是便于我个人理解。
标记算法首先将堆内存分为两个相等的空白块A和B,如果来了对象,先在A中分配区域,直到A中内存分配殆尽,然后将A中还存活的对象复制到B中,再将A清空。
优点:简单高效,不会产生内存碎片
缺点:浪费空间——好好的一块内存分成两块,其中一块特定时间内还不放对象;如果对象绝大数存活,这样会产生大量的内存间复制的开销
(二) 标记-清除(Mark-Sweep)
这是出现最早的算法,这个算法分标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
优点:基础算法,后面出现的很多算法就是对其缺点的优化。这里要强调一下“标记”这个词语,关于标记还有更多需要探讨的内容。
缺点:1、执行效率不稳定,处理大量对象时效率低;2、内存空间碎片,清除后残留的内存空间在整个内存中形成了一块一块的碎片,如果这个时候需要存放大对象,就会出发新一次垃圾回收。
(三) 标记-整理(Mark-Compact)
标记整理算法也是两个过程,首先标记出垃圾对象,然后将所有存活的对象向一边移动,再将这边区域外的所有内存空间清空。
优点:比复制算法更能利用内存空间;不会产生内存碎片
缺点:移动对象需要更新所有引用地址,并且在移动的时候会出现程序暂停的时间(stop-the-world,简称STP)
分代介绍——年轻代、老年代
基本介绍
分代顾名思义就是将堆内存分为:年轻代和老年代。
补充:在G1垃圾回收器出现之前,堆内存都有物理上的分代,G1虽然没有物理上的分代,但是它在实现上也是基于分代这个思想来回收垃圾的。ZGC后就没有分代这周说法。
年轻代
由图中可知,年轻代由分为了三个部分,Eden(伊甸),From-Survivor和To-Survivor。一个对象在如果能从年轻代进入老年代,那么他的活动轨迹就是Eden——>From-Survivor¬——>To-Survivor¬——> From-Survivor——> To-Survivor………——>Old(这个过程中from和to两个Survivor区的角色在互换)。
经过MinorGC,eden存活下来的对象copy到survivor区的from块中;同时survivor中的from块经过MinorGC后,存活下来的对象,到年龄的也即是到了阈值的(-XX:MaxTenuringThreshold)进入老年代,其他的copy到to块,这个时候from和to区就进行了角色互换,这就是年轻代中的GC过程。MinorGC会一直重复这个过程,直到survivor的to区被填满。to区被填满后,to区的所有对象会移到老年代。
由这个过程可以看出,新生代中的垃圾回收算法是Copying算法。Serial、ParNew和Paralle Scavenge属于年轻代中的算法。
老年代
老年代就很简单,没有所谓的分区。老年代中设计的算法,根据不同的垃圾回收器有不同的处理形式,CMS、Serial Old和Paralle Old属于老年代中的算法。
能进入老年代中的对象,说明这类对象比较稳定,因此不会频繁的FullGC(MajorGC),当老年代中内存不够时会抛出OOM(OutofMemory)异常。
下图是新生代和老年代比例关系图:
常见的垃圾收集器
(一) Serial收集器
Serial,中文意思连载,依次的。Serial收集器是一种Copying算法的垃圾收集器,它在进行垃圾回收的时候是单线程工作的,其示意图如下:
从图中可以看出,用户线程执行一段时间后,Serial垃圾收集器的线程开始工作回收垃圾,周而复始形成一个连续的工作模板,因此可以从他的名字中来理解它的工作流程。这里我需要注意的是当GC线程开始回收垃圾的时候,用户线程停止了!对,你看的没错,用户线程停止了!乍一看这个操作真的很难让人相信,就比如你访问一个程序,虽然你耗时100ms,但是如果你访问的过程中发生了一次GC,其实你访问的过程中间可能有50ms是什么事都没做的,在那等待GC线程工作,我们将用户线程等待GC线程工作的这段时间称为Stop The World。这里我引用书中的一段介绍,很有意思:
对于“Stop The World”带给用户的恶劣体验,早期HotSpot虚拟机的设计者们表示完全理解,但也同时表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个工种,但实际上肯定还要比打扫房间复杂得多!
虽然Serial垃圾收集器是最早出现的,但是它依旧有它的优先,直到现在也是可以使用的。它的优点就是简单高效:
对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
(二) ParNew收集器
从图中可以看出,ParNew垃圾收集器器其实就是Serial垃圾收集器的多线程模式。其工作流程、作用范围、收集算法和回收策略等都与Serial收集器一样。它出现的原因就是为了配合老年代的CMS垃圾收集器。CMS只能和Serial、ParNew组合原因是:
这里梳理下各个垃圾收集器的出现版本:
Serial:初代垃圾收集器;
ParNew:JDK1.5伴随CMS出现;
Parallel Scavenge:JDK1.4
CMS:JDK1.5
Parallel Old:JDK1.6
从各个垃圾收集器的出现时间可以看出,Parallel Scavenge比ParNew出现的早,为什么CMS和Parallel Scavenge不能组合起来使用?原因就是两者使用的GC框架不一样。而Serial、Serial Old、ParNew和CMS都是属于一个GC框架中,这个框架中的垃圾收集器可以任意搭配,所有CMS只能和Serial、ParNew搭配起来使用;同样,Parallel Scavenge只能和Parallel Old搭配使用。(还有一种搭配方式,后面说)
(三) Parallel Scavenge收集器
Parallel Scavenge收集器的工作模式和上面的ParNew收集器一样,都是新生代里,多线程的,复制算法垃圾收集器。但是Parallel Scavenge 相比较与ParNew的着重点不一样,在Parallel Scavenge中出现了一个新的要求,就是实现更高的吞吐量。
网上给出的介绍就是说,以高吞吐量为目标,即减少垃圾收集时间(就是每次垃圾收集时间短,但是收集次数多),让用户代码获得更长的运行时间;当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;就是说可以计算完后进行一次长时间的GC。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
这里需要熟悉三个参数:
-XX:MaxGCPauseMillis:最大垃圾收集停顿时间,这个时间不是越小越好,越小可能就会牺牲吞吐量。
-XX:GCTimeRatio:设置吞吐量大小,譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
XX:+UseAdaptiveSizePolicy:开启这个参数后,就不用手工指定一些细节参数,如:
新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等;JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs)
(四) Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它的工作流程和Serial收集器一样,不一样的是Serial Old采用的是标记-整理算法。
其实Serial Old还有另外一种形式,也就是PS MarkSweep收集器,这两个收集器其实是一模一样的,不一样的就是各自使用得到GC框架不一样。上面说的Parallel Scavenge还有一种搭配方式就是Parallel Scavenge+PS MarkSweep。(这里有个坑,我不知道为什么会取PS MarkSweep这个名字,乍一看像是标记-清除算法,但Serial Old是标记-整理算法,所以PS MarkSweep也是标记-整理算法,切勿弄混淆)
(五) Parallel Old收集器
Parallel Old收集器出现之前,Parallel Scavenge只能和PS MarkSweep搭配使用,而且还是唯一的。狗血的是,Parallel Scavenge的关注点是吞吐量,而对于包了层Serial Old皮的PS MarkSweep收集器来说,很明显是达不到要求的,两者的关注点不一样,所以为了继续优化吞吐量这个问题,Parallel Old收集器就诞生了。Parallel Old是使用的标记-整理算法进行垃圾回收的,是多线程工作的垃圾收集器。工作流程如下:
(六) CMS收集器
开始说CMS收集器之前,我们先归纳一下,首先年轻代中使用的收集器都是复制算法,前面提到的两个老年代收集器使用的是标记-整理算法,而CMS使用的是标记-清除算法。CMS相比较于其他收集器有个非常好的地方就是它致力于缩短STW时间,也就是希望给用户带来良好的交互体验。
首先我们先来看CMS的工作流程:
他总共有四个步骤,分别是:初始标记、并发标记、重新标记和并发清理。
初始标记——初始标记只是标记GC Root对象能直接关联到的对象,STP时间很短。
并发标记——就是和用户线程一起工作,标记GC Root直接关联对象下所有引用链上的对象,这个过程比较漫长,但是没有STW时间。
重新标记——重新标记是对并发标记一些漏标或者错标的对象进行修正,比如在并发标记时一开始可能有些对象确实不存在引用,但是用户线程还在运行,就会出现再次引用,因此重新标记就是让系统停下来,再检查一遍的过程,STW时间比初始标记长。
并发清理——我认为这个才是并发-整理和并发-清除最大的区别点。并发清理就是和用户线程一起工作,清理掉垃圾对象,为啥可以和用户线程一起工作,其最大原因时清除垃圾是不需要移动对象的,因此没有更新对象指针这个操作,想想是不是这个原因。
在当前的JDK8中,我们默认使用的垃圾收集器是PS+PO,但是不可否认,CMS是一款优秀的低停顿垃圾收集器,可能是PS+PO与CMS的关注点不一样,两者其实差不多。CMS虽然渐渐的被抛弃了,但是它的并发处理和低停顿是垃圾回收器里具有重大意义的变革。它的缺点也很明显:并发处理,占用系统内存,降低了吞吐量;CMS没法处理“浮动垃圾”,出现“Concurrent Mode Failure”失败导致多次FullGC;还有一个缺点就和它的算法有关,标记-清除,容易产生内存碎片,当没有足够大的空间分配大的对象就会触发FullGC。
以下我引用书中话:
在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
由于其他垃圾回收器都是“stop the world”,内存不够了就执行GC,而CMS垃圾回收器是可以和用户线程一起并发的,所以减少了STW时间。那么对于运行很久的堆内存来说,内存使用率到了50%就收集?还是70%?90%? CMS需要留一些内存给并发的时候用户线程使用。比如说留了10%,假设这个时候用户线程说,10%不够,这个时候怎么办?这个时候就报Concurrent Mode Failure这个时候就会有预备方案,Serial Old垃圾回收器会替代CMS,进行“stop the world”垃圾收集。
在CMS中因为涉及到并发标记,所以涉及到两个知识点——三色标记和Incremental Update(增量更新)算法。可以参考我的另外一个博客——三色标记、增量更新与原始快照
(七) G1收集器——Garbage First
还记得上面那张关于垃圾收集器的关系图吗?G1前面回收垃圾对象都是基于两个垃圾收集器合作完成的,这就在物理内存上有了年轻代和老年代的区分,而进入G1的时代,在物理上已经没有明确的年轻代和老年代区分,但是在G1垃圾收集器的理论上却是存在年轻代和老年代的概念,也仅仅只是概念而已。
在说G1前首先来回顾下上面已经介绍的垃圾收集器,思考一下,如果让你设计一个垃圾收集器,你最想解决是什么?毫无疑问首选肯定是缩短Stop The Time时间,尽可能的在更短的STW时间内回收掉更多的垃圾,而CMS也是为了这个目标而诞生的,但是,CMS有很明显的缺点:堆内存空间碎片以及无法处理“浮动垃圾”,还有个就是吞吐量没有PS+PO优秀。
其实我在看垃圾收集器的时候我也在想,程序一直运行,那垃圾就不断产生,之前是利用分代理念,也就是说,很多对象都是朝生夕灭的,是很难存活到老年代的,所以在年轻代就被回收了(这也是老年代的堆内存大的原因),但是随着时间的运行,老年代就越来越大,越来越复杂,MajorGC处理起来时间就越来越长,而对于CMS这种存在内存碎片的内存空间,后面还会触发一次全堆的FullGC。对于如何更好的收集处理垃圾,以及更好的处理STW时间,那就转换一下思路。既然垃圾不停的在产生,那么就不要想着一下子处理完所有的垃圾,选择一种“尽可能多的收集垃圾并处理”的方式,而G1就是基于这个理念。
先来看下G1的内存模型(堆内存):
G1将整个堆分成大小相等的Region区,每个Region的大小是1M~32M(大小必须是2的幂次方)。每一个Region都扮演了一个角色,分为Eden、Survivor和老年代,每个Region区的角色不固定,三个角色互相切换。对于图中表示为H的内存块,是专门存放大对象的Humongous区域(也是一个Region)。大对象指的是超过一半Region大小的对象,如果对象超过Region大小,那么就存在连续的Humongous区域。
G1垃圾收集器回收的一个个Region,对于回收哪些Region,那就是G1收集器对这些Region做出价值判断,哪个回收价值高就回收哪个,优先处理那些回收价值收益最高的Region,也就是之前提到的“尽可能多的收集垃圾并处理”。
它的回收流程和CMS有些区别:
初始标记:标记一些GC Roots直接关联的对象;然后修改TAMS指针的值,给后面程序运行可以正确地在Region中分配新对象。
并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。(书中原话比较好)
重新标记(Final Marking):处理最后并发阶段少量SATB记录。
筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。(书中原话比较好)
G1 JVM参数列表(引用:G1垃圾回收器参数配置):
选项/默认值 | 说明 |
---|---|
-XX:+UseG1GC | 使用 G1 (Garbage First) 垃圾收集器 |
-XX:MaxGCPauseMillis=n | 设置最大GC停顿时间(GC pause time)指标(target). 这是一个软性指标(soft goal), JVM 会尽量去达成这个目标. |
-XX:InitiatingHeapOccupancyPercent=n | 启动并发GC周期时的堆内存占用百分比. G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示"一直执行GC循环". 默认值为 45. |
-XX:NewRatio=n | 新生代与老生代(new/old generation)的大小比例(Ratio). 默认值为 2. |
-XX:SurvivorRatio=n | eden/survivor 空间大小的比例(Ratio). 默认值为 8. |
-XX:MaxTenuringThreshold=n | 提升年老代的最大临界值(tenuring threshold). 默认值为 15. |
-XX:ParallelGCThreads=n | 设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同. |
-XX:ConcGCThreads=n | 并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同. |
-XX:G1ReservePercent=n | 设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认值是 10. |
-XX:G1HeapRegionSize=n | 使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为 1Mb, 最大值为 32Mb. |