前言:最近浏览了不少分代式GC和G1算法的文章,好多文章的知识点都比较分散,要几篇文章结合起来对比才能明白,特别是R大的文章,很精辟但不好理解,这里我就把我最近收集并结合自己理解的有关分代式GC和G1算法的知识分享一下
这里放一下我感觉写的比较好的文章链接
G1介绍 R大
G1介绍
java GC为什么要分代
GC分代
年轻代(Young Generation)
对象被创建时,内存的分配首先发生在年轻代(大对象可以直接 被创建在年老代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉(weak generational hypothesis 大部分对象die young,而没有die young的对象往往会live long),这个GC机制被称为Minor GC或叫Young GC。
注意:Minor GC并不代表年轻代内存不足,它事实上只表示在Eden区上的GC。
由于绝大部分的对象都是短命的,甚至存活不到Survivor中,所以,Eden区与Survivor的比例较大,HotSpot默认是 8:1,即分别占新生代的80%,10%,10%。如果一次回收中,Survivor+Eden中存活下来的内存超过了10%,则需要将一部分对象分配到老年代。用-XX:SurvivorRatio参数来配置Eden区域Survivor区的容量比值,默认是8,代表Eden:Survivor1:Survivor2=8:1:1.
什么情况会导致YGC的发生?最常见的情况是在年轻代分配内存时,出现空间不足,这里的内存分配,有可能是TLAB(后序讲解),也有可能是一个对象(该对象在TLAB中放不下,但虚拟机不想重新申请TLAB,就在Eden区分配)
年轻代上的内存分配是这样的,年轻代可以分为3个区域:Eden区和两个存活区(Survivor 0 、Survivor 1)
绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;
当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);
此后,每次Eden区满了,就执行一次Minor GC,并将剩余的对象都添加到Survivor0;
当Survivor0也满的时候,将其中仍然活着的对象直接复制到Survivor1,以后Eden区执行Minor GC后,就将剩余的对象添加Survivor1(此时,Survivor0是空白的)。
当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。
Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,之后分配内存时直接覆盖,到下一次GC时,两个Survivor的角色再互换。因此,这种方 式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法
BTP & TLAB
HotSpot虚拟机使用了两种技术来加快内存分配。分别是bump-the-pointer和TLAB(Thread-Local Allocation Buffers),这两种技术的做法分别是:由于Eden区是连续的,因此bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对 象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;而对于TLAB技术是对于多线程而言的,将Eden区分为若干 段,每个线程使用独立的一段,避免相互影响。TLAB结合bump-the-pointer技术,将保证每个线程都使用Eden区的一段,并快速的分配内存。
老年代
老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,老年代用的算法是标记-整理算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。在发生Minor GC时,虚拟机会检查每次晋升进入老年代对象的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC,否则,就查看是否设 置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行Full GC(这代表着如果设置-XX:+HandlePromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。
方法区(永久代):
永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:
- 类的所有实例都已经被回收
- 加载类的ClassLoader已经被回收
- 类对象的Class对象没有被引用(即没有通过反射引用该类的地方)
GC判断是否回收对象是根据该对象有没被GC Roots引用(可达性分析)
所谓“GC roots”,或者说tracing GC的“根集合”,就是一组必须活跃的引用
例如说,这些引用可能包括:
- 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。
- VM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
- JNI handles,包括global handles和local handles
- (看情况)所有当前被加载的Java类
- (看情况)Java类的引用类型静态变量
- (看情况)Java类的运行时常量池里的引用类型常量
- (String或Class类型)
- (看情况)String常量池(StringTable)里的引用
注意,是一组必须活跃的引用,不是对象。
G1算法
从最高层看,G1的collector一侧其实就是两个大部分:
- 全局并发标记(global concurrent marking)
- 拷贝存活对象(evacuation)非并发,暂停拷贝
而这两部分可以相对独立的执行。
初始标记(initial-mark)
暂停阶段。在这个阶段,应用会经历STW,通常初始标记阶段会跟一次新生代收集一起进行,在分代式G1模式中,初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。
在young GC中进行初始标记的工作,会让停顿时间稍微长一点,并且会增加CPU的开销。初始标记做的工作是设置两个TAMS(top-at-mark-start)变量(NTAMS和PTAMS)的值,所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象;young gc只需要扫描young regin的Rset就能知道young regin中哪些对象的引用是活跃的
根分区扫描(root-region-scan)
这个过程不需要暂停应用,在initial-mark或young GC中被拷贝到survivor分区的对象,都需要被看做是根,这个阶段G1开始扫描survivor分区,开始tracing gc root标记所有从根集合可直接到达的对象并将它们的字段压入扫描栈(marking stack)中等待后续扫描
并发标记阶段 concurrent marking
不断从扫描栈(marking stack)取出引用递归扫描整个堆里的对象图。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描SATB write barrier所记录下的引用。
logging write barrier
为了尽量减少write barrier对应用程序(mutator)性能的影响,G1将一部分原本要在barrier里做的事情挪到别的线程上并发执行。实现这种分离的方式就是通过logging形式的write barrier:mutator只在barrier里把要做的事情的信息记(log)到一个队列里,然后另外的线程从队列里取出信息批量完成剩余的动作。
以SATB write barrier为例,每个Java线程有一个独立的、定长的SATBMarkQueue,mutator在write barrier里只把old_object_pointer压入该队列中。一个队列满了之后,它就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理,然后给对应的Java线程换一个新的、干净的队列继续执行下去。
并发标记(concurrent marker)会定期检查全局SATB队列集合的大小。当全局集合中队列数量超过一定阈值后,concurrent marker就会处理集合里的所有队列:把队列里记录的每个oop(旧的对象指针)都标记上,并将其引用字段压入扫描栈(marking stack)等后面做进一步标记。
最终标记阶段 final marking/remarking
暂停阶段。在完成并发标记后,每个Java线程还会有一些剩下的SATB write barrier记录的引用尚未处理,这个阶段就负责处理掉剩下的SATB日志缓冲区和所有更新的引用(pre和post
write barrier记录的引用变化)。(如之前所说,一个线程的SATBMarkQueue要满了之后才会被加入到全局的队列中处理,那么在并发标记结束后,会有线程中的SATBMarkQueue是不满且未清空的)
注意这个暂停与CMS的remark有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描dirty card外加所有线程栈和整个young gen作为根集合,因而CMS remark有可能会非常慢。
清理阶段 cleanup`
清点和重置标记状态。不过不是在堆上sweep实际对象,而是在marking bitmap里统计每个region被标记为活的对象有多少。这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。
Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去,然后回收原本的region的空间。
Evacuation阶段可以自由选择任意多个region来独立收集构成收集集合(collection set,简称CSet),靠per-region remembered set(简称RSet)实现。这是regional garbage collector的特征。Remembered Set是在实现部分垃圾收集(partial GC)时用于记录从非收集部分指向收集部分的指针的集合的抽象数据结构。
在选定CSet后,evacuation其实就跟Parallel Scavenge的young GC的算法类似,采用并行copying(或者叫scavenging)算法把CSet里每个region里的活对象拷贝到新的region里,整个过程完全暂停。从这个意义上说,G1的evacuation跟传统的mark-compact算法的compaction完全不同:前者会自己从根集合遍历对象图来判定对象的生死,不需要依赖global concurrent marking的结果,有就用,没有拉倒;而后者则依赖于之前的mark阶段对对象生死的判定。
分代式G1模式下有两种选定CSet的子模式,分别对应young GC与mixed GC:
- Young GC:选定所有young gen里的region作为Cset。通过控制young gen的region个数来控制young GC的开销。
- Mixed GC:选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region作为Cset。在用户指定的开销目标范围内尽可能选择收益高的old gen region。
可以看到young gen region总是在CSet内。因此分代式G1不维护从young gen region出发的引用涉及的RSet更新。old regin的Rset只存储old regin之间的引用关系,young regin的Rset存储着old -> young之间的引用
- young GC(或者叫minor GC):只收集young gen里的所有region,也就是eden和survivor。控制young GC开销的手段是动态改变young region的个数;
- mixed GC:收集young gen里的所有region,外加若干选定的old gen region。控制mixed GC开销的手段是选多少个、哪几个old gen region。
分代式G1的正常工作流程就是在young GC与mixed GC之间视情况切换,背后定期做做全局并发标记。Initial marking默认搭在young GC上执行;当全局并发标记正在工作时,G1不会选择做mixed GC,反之如果有mixed GC正在进行中G1也不会启动initial marking。 在正常工作流程中没有full GC的概念,old gen
的收集全靠mixed GC
来完成(Cset 由mixed gc来回收)。如果mixed GC实在无法跟上程序分配内存的速度,导致old gen填满无法继续进行mixed GC,就会切换到G1之外的serial old GC来收集整个GC heap
G1只有两件事是并发执行的:(1) 全局并发标记;(2) logging write barrier的部分处理。而“拷贝对象”(evacuation)这个很耗时的动作却不是并发而是完全暂停的。那G1为何还可以叫做低延迟的GC实现呢?
重点就在于G1虽然会mark整个堆,但并不evacuate所有有活对象的region;通过只选择收益高的少量region来evacuate,这种暂停的开销就可以(在一定范围内)可控。每次evacuate的暂停时间应该跟一般GC的young GC类似。所以G1把自己标榜为“软实时”(soft real-time)的GC。
但是毕竟要暂停来拷贝对象,这个暂停时间再怎么低也有限。G1的evacuation pause在几十到一百甚至两百毫秒都很正常。所以切记不要把 -XX:MaxGCPauseMillis 设得太低,不然G1跟不上目标就容易导致垃圾堆积,反而更容易引发full GC而降低性能。通常设到100ms、250ms之类的都可能是合理的。设到50ms就不太靠谱,G1可能一开始还跟得上,跑的时间一长就开始乱来了。
SATB Sanpshot at the beginning
SATB是维持并发GC的正确性的一个手段,G1 GC的并发理论基础就是SATB。SATB的标记优化主要针对标记-清除垃圾收集器(CMS)的并发标记阶段。按照R大的说法:CMS的incremental update设计使得它在remark阶段必须重新扫描所有dirty card,线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satb_mark_queue。
SATB算法创建了一个对象图,它是堆的一个逻辑“快照”。标记数据结构包括了两个位图:previous位图和next位图。previous位图保存了最近一次完成的标记信息,并发标记周期会创建并更新next位图,随着时间的推移,previous位图会越来越过时,最终在并发标记周期结束的时候,next位图会将previous位图覆盖掉。
巨型对象的管理 Humongous
在G1中,如果一个对象的大小超过分区大小的一半,该对象就被定义为巨型对象(Humongous Object)。巨型对象时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的
如果一个巨型对象跨越两个分区,开始的那个分区被称为“开始巨型”,后面的分区被称为“连续巨型”,这样最后一个分区的一部分空间是被浪费掉的,如果有很多巨型对象都刚好比分区大小多一点,就会造成很多空间的浪费,从而导致堆的碎片化。如果你发现有很多由于巨型对象分配引起的连续的并发周期,并且堆已经碎片化(明明空间够,但是触发了FULL GC),可以考虑调整-XX:G1HeapRegionSize参数,减少或消除巨型对象的分配。
关于巨型对象的回收:在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。
GC算法中的三色标记算法
tracing GC将对象分为三类:白色(垃圾收集器未探测到的对象)、灰色(活着的对象,但是还没有被垃圾收集器扫描过)、黑色(活着的对象,并且已经被垃圾收集器扫描过)。垃圾收集器的工作过程,就是通过灰色对象的指针扫描它指向的白色对象,如果找到一个白色对象,就将它设置为灰色,如果某个灰色对象的可达对象已经全部找完,就将它设置为黑色对象。当在当前集合中找不到灰色的对象时,就说明该集合的回收动作完成,然后所有白色的对象的都会被回收。
SATB 和 Incremental update如何保证不漏扫描对象
SATB与incremental update是用不同的方式保证concurrent marking不漏扫描活对象。
回到扫描对象图的基本模型——三色扫描。黑色是自己已标记且字段也全部标记了的对象(collector就不会再访问到它了),灰色是自己已标记但尚有字段未标记的对象(collector正在访问的对象),白色是尚未标记的对象。
黑色和灰色对象都是确定存活的对象。灰色对象的集合构成了当前collector正在扫描的分界面(wavefront)。从分界面的角度看,灰色是正在分界面上,白色是在分界面之前,黑色是在分界面之后。
要不漏扫活对象,最最重要的就是下述两种情况不同时发生:
1、mutator把一个白对象的引用存到黑对象的字段里
2、某个白对象失去所有能从灰对象到达它的引用路径(直接或间接)
黑对象持有了指向白对象的引用。根据定义,collector已经不会再去遍历黑对象的字段,所以发现不了这里还有一个活引用指向这个白对象。如果还有某个灰对象持有直接或间接引用能到达这个白对象,那就没关系;如果从灰对象出发的所有引用到这个白对象的路径都不幸被切断了,那这个白对象就要被漏扫描了。
Incremental update的做法是:只要在write barrier里发现要有一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的(例如说标记并压到marking stack上,或者是记录在类似mod-union table里)。这样就强力杜绝了上述第一种情况的发生。
SATB的做法是:把marking开始时的逻辑快照(snapshot)里所有的活对象在并发周期内都看作是活的。具体做法是在write barrier里把所有旧的引用所指向的对象都变成非白的(已经黑灰就不用管,还是白的就变成灰的)。
这样做的实际效果是:如果一个灰对象的字段原本指向一个白对象,但在concurrent marker能扫描到这个字段之前,这个字段被赋上了别的值(例如说null),那么这个字段跟白对象之间的关联就被切断了。SATB write barrier保证在这种切断发生之前就把字段原本引用的对象变灰,从而杜绝了上述第二种情况的发生。
很明显,incremental update write barrier和SATB write barrier都“过于强力”,不但足以保证所有应该活的对象都被扫描到,还可能把一些可以死掉的对象也给扫描上了。这就是它们的精确度问题,结果就是floating garbage。Yuasa式的SATB write barrier的精度应该是比CMS用的incremental update write barrier低——前者比后者导致的floating garbage更多。
如果把mutator看作一个抽象的对象(里面包含root set),那么mutator也可以用三色抽象来描述:有使用黑色mutator的算法,也有使用灰色mutator的算法。关键在于是否允许mutator在concurrent marking的过程中持有白对象的引用,允许则为灰色mutator,不允许则为黑色mutator。
SATB write barrier是一种黑色mutator做法,而incremental update write barrier是一种灰色mutator做法。
灰色mutator做法要完成marking就需要重新扫描根集合。这就是为什么使用incremental update的CMS在remark的时候要重新扫描整个根集合。
扫描任何region的时候如果碰到指向不在CSet里的region的引用都可以忽略,不只是扫描young gen region可以。
要记住的是,在一次收集中,从非收集区域到收集区域的incoming reference是重要的(需要作为根集合的一部分),而从收集区域到非收集区域的outgoing reference是可忽略的(非收集区域的对象反正不收集,可以当作还活着)。
CMS:incremental update write barrier
G1:SATB write barrier(Snapshot-At-The-Beginning)
#分代式G1的正常工作流程
-> young GC
-> young GC
-> young GC
-> young GC + initial marking
(... concurrent marking ...)
-> young GC (... concurrent marking ...)
(... concurrent marking ...)
-> young GC (... concurrent marking ...)
-> final marking
-> cleanup
-> mixed GC
-> mixed GC
-> mixed GC
...
-> mixed GC
-> young GC + initial marking
(... concurrent marking ...)
...