首先我们需要知道常见的垃圾标记算法
如下图所示,常见的垃圾收集算法有以下几种,其实这其中都是依赖于分代收集理论
,各个不同的垃圾收集器也是采用不同的那即是收集算法
分代收集理论
根据对象存活周期的不同将内存分为几块,一般是堆内存中的新生代和老年代。新生代中每次收集垃圾对象大概占90%,所以一般采用标记复制算法,而老年代中一般采用标记清除或标记整理算法。
注意,标记-清除或标记-整理算法会比复制算法慢10倍以上。
标记-复制算法
将存活的对象移动到另一边,就比如新生代中的Survivor区。缺点是始终有一块内存区域不可用
标记-清理算法
直接清理垃圾对象,缺点是会造成内存碎片化
标记-整理算法
清理完垃圾对象后,会将存活的对象往一端移动,避免内存碎片化
常见的垃圾收集器如下图所示
JDK8中默认使用的垃圾回收器是Parallel+Parallel Old。如果堆内存不大在2G以内可以使用这种默认的垃圾收集器。堆内存如果在2~8G建议使用ParNew + CMS,如果对内存大于8G则可以考虑使用G1
-XX:+UseSerialGC -XX:+UseSerialOldGC
是早期版本的垃圾收集器,是串行的, 并且只有一个GC线程,回收堆内存一般是十M~百M左右
年轻代使用的是Serial垃圾收集器,采用的是标记复制算法
老年代使用的是Serial Old垃圾收集器,采用的是标记整理算法。它还有一个作用是作为CMS垃圾收集器的后备方案
-XX:+UseParallelGC(年轻代) -XX:+UseParallelOldGC(老年代)
和Serial垃圾回收器相比,Parallel垃圾收集器有多个GC线程同时工作,可以通过-XX:ParallelGCThreads
参数配置GC线程的数量,默认是CPU的核数,一般不建议更改。
Parallel Scavenge收集器是Parallel的老年代收集器,它和CMS垃圾收集器的着重点不一样,Parallel Scagenge更关注吞吐量,而CMS垃圾收集器更关注如何缩短用户线程的等待时长。
JDK8默认使用的垃圾回收器是Parallel+Parallel Old
新生代采用标记复制算法,老年代采用标记整理算法
-XX:+UseParNewGC
因为Parallel垃圾收集器不能很好的和CMS垃圾收集器结合使用,所以才出现了一个ParNew来和CMS垃圾收集器结合使用
新生代采用标记复制算法
-XX:+UseConcMarkSweepGC
它只能用于老年代
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从Mark Sweep名字可以看出来它采用的是标记清理算法
CMS垃圾收集器的工作流程如下图所示:
初始标记阶段
此时只有GC线程工作,仅仅标记GCRoot对象下的直接引用对象,耗时非常短
并发标记阶段
和用户线程同时进行,从上一步标记的对象开始一直向下找引用。这个过程中很有可能会导致已经标记过的对象发生改变
这个过程中产生的新对象会被直接标记为黑色
重新标记阶段
此时只有GC线程工作,主要作用是修复并发标记阶段与用户线程并行执行产生的一些并发问题,主要是解决漏标问题,使用的是三色算法中的增量更新算法做重新标记。多标产生的浮动垃圾CMS垃圾收集器无法解决,只能等下一次GC去清理。
并发清理阶段
与用户线程同时进行,开始进行垃圾清理操作。此时如果出现了MinorGC有新对象移动到了老年代,这个新对象会被直接标记为黑色。
并发重置阶段
与用户线程同时进行,重置本次GC过程中为对象的标记信息
优点是用户线程阻塞时间短
缺点:
-XX:+UseCMSCompactAtFullCollection
让JVM清除完垃圾之后进行整理操作# 启用CMS垃圾收集器
-XX:+UseConcMarkSweepGC# GC并发线程数
-XX:ConcGCThreads# 标记清理之后 做压缩整理,减少内存碎片化
-XX:+UseCMSCompactAtFullCollection# 多少次FullGC之后才进行一次压缩整理,与上一个参数结合使用,默认0 表示每一次进行清理之后都进行整理
-XX:CMSFullGCsBeforeCompaction# 当老年代使用多少内存之后进行FullGC,默认是92%,主要目的是在并发标记和并发清除阶段减少并发处理失败的情况
-XX:CMSInitiatingOccupancyFraction# 如果不指定,JVM仅在第一次使用上一个参数的设定值,后续则会自动调整
-XX:+UseCMSInitiatingOccupancyOnly# 在CMS FullGC前启动一次minor gc,降低CMS FullGC标记阶段时的开销,一般CMS的FullGC耗时 80%都在标记阶段
# 降低CMS FullGC标记阶段时的开销:会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间
-XX:+CMSScavengeBeforeRemark# 表示在初始标记阶段也和用户线程同步进行,缩短STW
-XX:+CMSParallellnitialMarkEnabled# 表示在重新标记阶段也和用户线程同步进行,缩短STW
-XX:+CMSParallelRemarkEnabled
在CMS垃圾收集器的并发标记和并发清除过程中,因为GC线程和用户线程都在运行,那么就有可能产生多标和漏标问题。漏标的问题主要是靠三色标记算法来解决
从GCRoot对象向下遍历对象过程中,按照“对象是否访问过”这个条件标记成以下三种颜色:
黑色
当前对象被引用,并且已经遍历完了该对象的直接引用对象。
如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。
黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
灰色
当前对象被引用,但是还未遍历完该对象的直接引用对象。至少存在一个引用还没有被扫描过。
白色
当前对象没有被引用。显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
漏标产生案例:
public class ThreeColorRemark {public static void main(String[] args) {A a = new A();// 此时产生Full GC ,完成了初始标记阶段,开始做并发标记D d = a.b.d; // 1.读a.b.d = null; // 2.写a.d = d; // 3.写}
}class A {B b = new B();D d = null;
}class B {C c = new C();D d = new D();
}class C {
}class D {
}
在标记阶段的某一个时刻中,还未完成标记阶段,这个时候完成了A对象的标记,刚完成标记B对象引用C对象,还没进行D对象的引用标记判断。
这个时候用户线程执行下面两行代码
// 先用一个临时变量保存d对象
D d = a.b.d;
// 这个时候把B对象中引用D对象置为null
a.b.d = null;
此时D对象就没有引用了,就会标记为白色
然后用户程序又继续运行,让A对象引用到了D对象,那么就造成漏标问题,如下图所示
多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标-读写屏障
漏标会导致被引用的对象被当成垃圾对象。解决方案有两种:
增量更新 Incremental Update
当黑色对象被插入新的引用白色对象时,会将这个引用保存下来,等到并发标记结束后,重新标记阶段中再将这些引用记录中以黑色对象为根对象,重新扫描一次
原始快照 Snapshot At The Beginning,SATB
当灰色对象要删除与白色对象的引用关系时,这个删除被保存下来,在并发表姐结束后,再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。
CMS垃圾回收器使用的是增量更新,G1垃圾回收器使用的是原始快照STAB
写屏障
给某个对象的成员变量赋值时,其底层代码大概长这样:
/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) { *field = new_value; // 赋值操作
}
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
void oop_field_store(oop* field, oop new_value) { pre_write_barrier(field); // 写屏障-写前操作*field = new_value; post_write_barrier(field, value); // 写屏障-写后操作
}
我们可以利用写屏障-写前操作实现原始快照的功能
void pre_write_barrier(oop* field) {oop old_value = *field; // 获取旧值remark_set.add(old_value); // 记录原来的引用对象
}
我们可以利用写屏障-写后操作实现增量更新功能
void post_write_barrier(oop* field, oop new_value) { remark_set.add(new_value); // 记录新引用的对象
}
读屏障
oop oop_field_load(oop* field) {pre_load_barrier(field); // 读屏障-读取前操作return *field;
}
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
void pre_load_barrier(oop* field) { oop old_value = *field;remark_set.add(old_value); // 记录读取到的对象
}
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
为什么CMS使用增量更新,但G1要使用STAB嘞?
我的理解是:STAB要比增量更新效率更高,因为不需要在重新标记阶段再次深度扫描被删除的对象,而CMS对增量引用的跟对象会做深度扫描。G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。
为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。
hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。
卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。
hotSpot使用的卡页是2^9大小,即512字节
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。
卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。
Hotspot使用写屏障维护卡表状态。