目前大多数垃圾收集器都是采用的分代收集算法,该算法其实算是一种思想:根据对象存活周期的不同而将内存分为年轻代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法。比如在年轻代中,每次收集都会有绝大多数对象死去(没有被GC root所引用),可以选择复制算法
,只需要付出少量对象的复制成本就可以完成每次垃圾收集;而老年代中的对象存活几率比较高,并且没有额外的空间对其进行分配担保,所以选择标记清除算法
或者标记整理算法
进行垃圾回收;需要注意,通常来讲标记清除算法或标记整理算法会比复制算法慢上许多!
复制算法简单来说就是将内存分为大小相同的两块区域,每次使用其中一块区域,当这一块内存区域使用完后,就将还存活的对象复制到另一块内存区域中去,然后再把之前使用的那块内存区域一次性清理掉,这样就使得每次的内存回收都是对内存区域的一半进行回收。总的来说算是空间换时间,并且复制算法适合应用在年轻代 (年轻代中survivor区的 s1:s2 正好是1:1)
该算法总的来说分为标记
和清除
两个阶段:标记存活的对象,统一回收所有未被标记的对象(一般选择这种);也可以反过来标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记整理算法是根据老年代的特点出的一种垃圾回收算法,分为标记
和整理
两个阶段;标记过程中仍然与标记清理算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。算是一种用时间换空间的算法
复制算法 | 标记清除算法 | 标记整理算法 | |
---|---|---|---|
速度 | 最快 | 中等 | 最慢 |
空间开销 | 通常需要活对象的2倍大小(不堆积碎片) | 少(但会堆积碎片) | 少(不堆积碎片) |
是否移动对象 | 是 | 否 | 是 |
JVM的垃圾回收工作正是靠着这些垃圾回收器实现的,每一种垃圾回收器都有各自的特点,没有完全完美的垃圾回收器,我们需要根据不同的应用场景来选择合适的垃圾收集器。
Serial(串行)收集器是比较早期的垃圾收集器,是一款单线程的垃圾收集器,其垃圾收集过程中只会使用一个线程去完成收集工作,并且在这个过程中必须暂停其它所有的工作线程(也就是"Stop The World"),直到垃圾收集完成后,工作线程才会继续运行。其年轻代采用复制算法,老年代采用标记整理算法
收集器设置参数:-XX:+UseSerialGC -XX:+UseSerialOldGC
补充:Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的候选方案。
Parallel收集器其实就是Serial收集器的多线程版本,不同之处就是使用了多线程进行垃圾收集,其余行为如收集算法和回收策略和Serial收集器相似;默认的收集线程数跟cpu核数相同,也可以通过参数-XX:ParallelGCThreads
设置收集线程数;吞吐量(高效利用cpu)是Parallel收集器的关注点,即:CPU中用于运行用户代码的时间与CPU总消耗时间的比值。其年轻代采用复制算法,老年代采用标记整理算法。
收集器设置参数:-XX:+UseParallelGC -XX:+UseParallelOldGC
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程
和标记整理算法
。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的年轻代和老年代收集器
)。
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。新生代采用复制算法,老年代采用标记整理算法。
它是许多运行Server模式下虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,实现了让垃圾收集线程与用户线程(基本上)同时工作。
收集器设置参数:-XX:+UseConcMarkSweepGC
从其名字不难看出,CMS收集器是一种基于标记清除算法
实现的收集器,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
标记清除算法
会导致收集结束时会有大量的内存碎片,当然这个可以通过设置参数-XX:+UseCMSCompactAtFullCollection
让jvm在执行完标记清除后再做整理;concurrent mode failure
” ,从而进入STW改用Serial old垃圾收集器来处理回收任务。CMS的相关核心参数:
-XX:+UseConcMarkSweepGC:
启用cms;
-XX:ConcGCThreads:
并发的GC线程数;
-XX:+UseCMSCompactAtFullCollection:
FullGC之后做压缩整理(减少碎片);
-XX:CMSFullGCsBeforeCompaction:
多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次;
-XX:CMSInitiatingOccupancyFraction:
当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比);
-XX:+UseCMSInitiatingOccupancyOnly:
只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction
设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整;
-XX:+CMSScavengeBeforeRemark:
在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段;
-XX:+CMSParallellnitialMarkEnabled:
表示在初始标记的时候多线程执行,缩短STW;
-XX:+CMSParallelRemarkEnabled:
在重新标记的时候多线程执行,缩短STW;
在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生,漏标的问题主要引入了三色标记算法
来解决。
三色标记算法是把GC Root可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
例如下图代码:
在并发标记过程中,如果由于方法运行结束导致部分局部变量(GC Root)被销毁,这个GC Root引用的对象之前又被扫描过(被标记为存活对象),那么本轮GC不会回收这部分内存,这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。 浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除
。
另外,针对并发标记 (还有并发清理) 开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标会导致被引用的对象被当成垃圾误删除,这是严重问题,必须解决,有两种解决方案: 增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)
增量更新:当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来
, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。
原始快照:当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来
, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。
所谓的写屏障,说简单点就是在在赋值操作前后,加入一些处理(类似Spring中的AOP概念)
写屏障实现SATB(原始快照)
当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:
写屏障实现增量更新
当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
现代追踪式(基于可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈 & 队列 & 缓存日志等方式进行实现、遍历方式可以是广度 & 深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
此外,读写屏障还有其他功能,比如写屏障可以用于记录跨代 / 区引用的变化,读屏障可以用于支持移动对象的并发执行等;功能之外,还有性能的考虑。
为什么G1收集器使用SATB,而CMS收集器使用增量更新?
个人理解:其实增量更新相比于原始快照最大不同在于在重新标记阶段SATB不需要再次深度扫描被删除的引用对象,而新增更新会对新增引用的根对象做深度扫描,同时G1因为很多对象都位于不同的Region中,而CMS就一块老年代区域,因此重新深度扫描对象对于G1收集器这种结构明显代价会更昂贵,故G1选择SATB不深度扫描对象只是简单标记,等到下一轮GC在深度扫描。
在年轻代做GC Root可达性扫描
过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低
。为此,在新生代可以引入记录集(Remember Set)
的数据结构(记录从非收集区到收集区的指针集合
),避免把整个老年代加入GC Root扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC收集器, 都会面临相同的问题。垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,不需要了解跨代引用指针的全部细节。
Hotspot使用一种叫做卡表(Cardtable)
的方式实现记忆集,也是目前最常用的一种方式。
卡表是使用一个字节数组实现:CARD_TABLE[]
,每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”;HotSpot使用的卡页是29大小,即512字节。
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0;GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。
卡表的维护:
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1,Hotspot使用写屏障维护卡表状态。
参考文献:
《深入理解Java虚拟机》