Java垃圾回收机制和三色标记算法
一、对象内存回收
对于对象回收,需要先判断垃圾对象,然后收集垃圾。
收集垃圾采用垃圾收集算法和垃圾收集器。
判断垃圾对象,通常采用可达性分析算法。
引用计数法
每个对象设置一个引用计数器。每被引用一次,计数器就加1,取消引用,计数器就减1,任何时候计数器为0的对象就不可能在被使用了,可被回收。
实现简单、效率高,但不能解决循环引用的问题,A引用B、B引用A,但不被其他对象引用,计数器都为1,虽不会在被使用,但不会被回收。
可达性分析算法
从GC Root起搜索引用的对象,并进行标记,被标记的对象即为存活对象,其他未被标记的对象为垃圾对象。
GC Root对象:线程栈的本地对象、静态变量、本地方法栈的变量等。
二、垃圾收集算法
分代收集理论
当代虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分新生代、老年代,根据各个年代的特点使用合适的垃圾收集算法。
理论依据:99%的对象都是朝生夕死。
新生代:每次收集会回收大量对象,所以选择复制算法,复制少量的对象即可完成垃圾收集(其余对象直接清理掉)。
老年代:对象存活几率高,同时没有额外的空间进行分配担保,所以选择标记-清除算法或标记-整理算法(比复制算法慢10倍以上)。
1、标记-复制算法
将内存分为相同的两块,每次使用一块。垃圾回收时将存活的对象复制到另一块,并清理掉该块全部的内容。
**优点:**回收效率高,每次只需要复制极少一部分对象,清除对象的时候直接情况当前内存块的全部内容;
**缺点:**只能在两块内存空间中的一块上进行分配内存。
结合以上优缺点,在hotspot的实现中,设计了eden:s1:s2=8:1:1,即利用了回收效率高的优点,又一定程度上避免了内存使用率低的问题(仅有十分之1的内存块不能同时使用)。
3、标记-清除算法
算法分为标记和清除阶段
两种实现方式:
- 标记存活的对象,回收其他所有未被标记的对象(一般采用这种);
- 标记出垃圾对象,标记完成后统一回收所有被标记的对象。
缺点:
- 空间碎片问题,标记清除后会产生大量不连续的碎片;
- 标记效率不高,如果需要标记的对象太多,效率会低。
4、标记-整理算法
算法分为标记和移动两个阶段,标记阶段同标记-清除算法,标记之后存活的对象移动到内存空间的一端,然后清理掉端边界以外的内存。
最终使对象连续存储,空闲空间在另一侧,不在有内存碎片。
三、垃圾收集器
- 垃圾收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。
- 没有最好和通用的垃圾收集器,结合垃圾收集器的特点,根据应用场景选择合适的垃圾收集器。
1、Serial串行垃圾收集器
-
年轻代使用Serial,jvm开启参数:-XX:+UseSerialGC,采用复制算法
-
老年代使用Serial Old,jvm开启参数:-XX:+UseSerialOldGC,采用标记-整理算法
使用单线程执行垃圾收集工作,进行垃圾收集工作时暂停所有的工作线程。
**优点:**简单、没有线程交互的开销
**缺点:**比较多线程的垃圾收集器,回收效率自然就慢
由图可知:始终仅有一个GC线程,同时不会贺应用程序线程并行。
2、Parallel Scavenge收集器
Parallel垃圾收集器实际就是Serial收集器的多线程版本,相比Serial垃圾收集器使用了多线程进行垃圾收集,其余类似。
是JDK8默认的新生代贺老年代收集器。
默认的收集线程与CPU核数相同,可通过参数-XX:ParallelGCThreads指定收集线程数,但一般不建议修改。
-
年轻代使用Parallel,JVM开启参数:-XX:+UseParallelGC,采用复制算法;
-
老年代使用Parallel Old收集器,JVM开启参数:-XX:+UseParallelOldGC,采用标记-整理算法
优点:Parallel Scavenge收集器关注的是吞吐量(高效率利用CPU);
提供了很多参数供用户找到最合适的停顿时间或最大的吞吐量(可将内存管理优化由JVM自行完成)。
由图可知:Parallel收集器GC线程有多个,可并发进行垃圾收集,但不会应用程序线程并行运行。
3、ParNew收集器
用于新生代的垃圾收集器,采用复制算法,开启参数:-XX:+UseParNewGC;
跟Parallel收集器类似,区别主要是ParNew可配合CMS使用。
只有它能与CMS收集器配合工作。
4、CMS收集器
Concurrent Mark Sweep:并发 标记 清除
用于老年代的垃圾收集器,采用标记-清除算法,JVM开启参数:-XX:+UseConcMarkSweepGC
优点:CMS收集器关注获取最短回收停顿时间,注重用户体验。
真正意义的并发收集器,第一次实现了垃圾收集线程与用户线程同时工作。
工作过程:
- 初始标记:暂停工作线程,只标记由GC Root直接引用的对象,速度很快。
- 并发标记:与工作线程并发运行,从GC Root直接引用的对象开始遍历整个对象图。
- 耗时较长但不需要停顿用户工作线程;
- 由于用户线程继续运行,可能会导致已标记的对象状态发生改变;
- 重新标记:暂停工作线程,做重新标记,修正并发标记期间用户线程运行导致标记产生变动的一部分对象,主要处理漏标问题。用三色标记法的增量更新算法。
- 漏标:说明没有把需要存活的对象标记为存活状态,如果执行清理,程序会崩溃;
- 多标:本可以清理的对象标记为了存活**(浮动垃圾)**,不影响系统运行,最多占点空间,可在下一次垃圾收集过程中清理掉。
- 并发清理:与工作线程并发运行,GC线程对未标记为存活的对象做清理。
- 对于该过程新增的对象标记为黑色,本过程不做任何处理;
- 并发重置:与工作线程并发运行,重置本次GC过程中标记的数据。
由图可知:
CMS一次垃圾收集由5个步骤,分别是:初始标记、并发标记、重新标记、并发清理、并发重置
其中初始标记和重新标记将暂停工作线程,不与工作线程并发运行
优点:并发收集、停顿低,用户体验好;
缺点:
-
会与工作线程抢占CPU资源;
-
无法处理浮动垃圾,因为在并发标记和并发清理过程中可能会产生垃圾,只能等到下一次GC清理;
-
回收算法“标记-清理算法”会产生大量内存碎片
- 可通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理
-
会出现"concurrent mode failure",此时CMS会退化成serial old垃圾收集器。
- 在并发标记和并发清理过程中,由于工作线程在同时运行,可能又导致内存不足触发full GC
CMS的相关核心参数
-
-XX:+UseConcMarkSweepGC:启用cms
-
-XX:ConcGCThreads:并发的GC线程数
-
-XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
-
-XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
-
-XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比):尽早执行垃圾收集,降低出现"concurrent mode failure"发生的可能。
-
-XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整。
-
-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段**(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)**时的开销,一般CMS的GC耗时 80%都在标记阶段。
-
-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW。
-
-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW。
四、优化
很多优化无非就是让短期存活的对象尽量都留在survivor里,不要进入老年代,这样在minor gc的时候这些对象都会被回收,不会进到老年代从而导致full gc。
只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值。
五、垃圾收集底层算法实现-三色标记
在并发标记过程中,因为期间应用线程还在继续跑,对象的引用可能发生变化,会存在多标和漏标的情况。
**多标:**把本该是垃圾的对象标记为存活,产生浮动垃圾。
- 产生情况:在并发标记过程中,由于方法结束导致部分局部变量(GC Root)被销毁,这个GC Root之前又被标记为非垃圾对象,那么本轮GC不会回收这部分对象。
- 不会影响垃圾回收的正确性,只需要等到下一轮垃圾回收。
- 解决办法:当前不用解决,可由下一次进行收集。
**漏标:**把本该存活的对象没有标记为存活,导致本该存活的对象被删除,导致严重bug,必须解决。解决办法:主要引入了三色标记算法解决。
🔺三色标记算法:
三色标记算法把GC Roots可达性分析遍历对象过程中遇到的对象,按照
“是否访问过”标记为三种颜色:
- **黑色:**表示对象已经被垃圾收集器访问过,且这个对象的所有引用的对象都已经扫描。
- 黑色的对象代表已经访问过,且是安全存活的。
- 如果其他对象引用指向了黑色对象,无须重新扫描一遍。
- 黑色的对象不会不经过灰色对象直接引用白色对象。
- **灰色:**表示对象已经被垃圾收集器访问过,但这个对象的所引用的对象至少还有一个未被扫描。
- **白色:**表示对象尚未被垃圾收集器访问过。
- 在可达性分析刚开始阶段所有对象均为白色标记。
- 若可达性分析结束对象仍为白色则表示对象不可达。
处理漏标的两种方案
增量更新**(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)**
增量更新:当黑色对象新增加了指向白色对象的引用关系时,将新插入的引用记录下来。等并发扫描结束之后将记录的引用以黑色对象为根重新扫描一次**(重新标记)**。
- 因为黑色对象一旦插入了引用白色对象,就不能在是黑色了,该变成白色对象了,所以要记录并重新标记。
- 为了使在并发标记过程中新加入的白色对象也被扫描到,否则会被误认为是非存活对象而被删除。
原始快照:当**删除灰色对象指向的白色对象的引用**时,将删除的引用记录下来。在并发扫描结束之后以灰色对象为根,重新扫描一次,这样便能扫描到白色对象。
- 直接将白色对象标记为黑色。(该对象可能确实可以回收,而产生浮动垃圾)
- 如果指向该白色对象的来自灰色对象的引用全部被删除,则该白色对象就不能被扫描到,但如果新增了来自黑色对象的引用,则该白色对象不该被删除。
- 因为没有判断是否新增了来自黑色对象的引用,所以直接标记为黑色对象,让其在下一次GC时扫描判断。
读写屏障
虚拟机对新增引用关系和删除引用关系的记录,都是通过写屏障实现的。
写屏障:就是在赋值操作前后加入一些处理,把引用记录下来。
读屏障:当读取成员变量时,记录读取到的对象。
在HotSpot虚拟机中,并发标记时处理漏标的方案:
- CMS:写屏障+增量更新
- G1:写屏障+SATB
其他功能:
- 写屏障可用于记录跨代/区引用的变化;
- 读屏障可用于支持移动对象的并发执行
思考:并发标记处理漏标的方案中,为什么G1用SATB,CMS用增量更新?
SATB相对增量更新效率更高。
原始快照在重新标记阶段直接把记录的白色对象标记为黑色,不需要再次深度扫描背删除对应对象;增量更新会记录的黑色对象为根重新扫描一次。
而,CMS的老年代区域为一块内存,G1的对象分布在不同的region中,G1重新深度扫描代价会高于CMS。
所以G1选择SATB不做深度扫描,只是简单标记,浮动垃圾对象交给下一次GC。
记忆集和卡表
在新生代的垃圾收集进行GC可达性分析时,需要知道对象有没有被老年代的对象引用。
为了保证垃圾收集的效率,不能把所有老年代的对象扫描一遍,为此引用了**记忆集(Remember Set)**的数据结构,记录非收集区对象指向收集区对象的引用,避免直接扫描老年代。
在垃圾收集的扫描标记过程中,只需要通过记忆集判断对象是否有被非收集区对象引用,如果有标记对象不可回收。
此外,不只是新生代和老年代之间有跨代引用的问题,所有部分收集的垃圾收集器,如G1,都会涉及跨区引用的问题。
🔺hotspot虚拟机使用**卡表(cardtable)**的方式实现记忆集:
卡表使用一个字节数组实现,CARD_TABLE[],每个元素对应着其标识的内存区域一块特定大小的内存块,称为”卡页“。
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表元素标识为1,表示该元素变脏,否则为0,GC时只要筛选本收集区的卡表中变脏的元素加入到GC ROOTs中。(不明白,卡页对应一批对象?一个为脏,全页为脏?)
Hotspot中使用写屏障维护卡表状态。