垃圾收集算法
分代收集理论
根据对象生命周期的不同将内存划分为几块,例如Java堆分为老年代和年轻代。
- 年轻代每次GC存活的对象很少,使用标记-复制算法只需要复制少量对象就可以完成每次GC;
- 老年代每次GC后存活的对象较多,所以选择标记-清除或者标记-整理算法进行GC。
清除和整理算法比复制慢10以上。
标记-复制算法
是一种以空间换时间的算法,将内存划分成大小相同的两块,每次只使用其中的一块。当一块内存使用完后,将存活的对象复制到另一块内存中,再把原来那块内存清理掉就行了。
效率很高,不过浪费内存空间。
标记-清除算法
标记存活的对象,将所有未标记的对象回收。
缺点:
- 效率问题:如果需要标记的对象太多,效率不高;
- 空间问题:标记清除后会产生大量不连续的内存碎片;
标记-整理算法
标记存活对象,然后让所有存活对象向一端移动,最后清理掉边界以外的内存。
垃圾收集器
垃圾收集器就是对垃圾收集算法的实现。
Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
串行收集器,单线程,垃圾收集时暂停其他所有工作线程(Stop The World,STW),直到垃圾收集完成。
Serial Old是Serial收集器的老年代GC,也是单线程收集器。两个用途:
- 在JDK1.5及之前与Parallel收集器搭配使用;
- 作为CMS收集器的备选方案;
Parallel Scavenge收集器(-XX:UseParallelGC,-XX:UseParallelOldGC)
Parallel收集器是Serial收集器的多线程版本。除了使用多线程收集垃圾外,其他行为与Serial收集器类似。可以使用-XX:ParallelGCThreads指定收集线程数,默认为CPU核数。
Parallel收集器关注吞吐量(CPU用于运行用户代码的时间与CPU总消耗时间的比值),更高效的利用CPU。
CMS等收集器更多的关注用户工作线程的停顿时间,也就是减少STW时间。
新生代采用复制算法,老年代采用标记-整理算法。
Parallel Old收集器是Parallel老年代的收集器,使用标记-整理算法。
在注重吞吐量已经CPU资源的情况下可以优先考虑Parallel与Parallel Old收集器。
JDK8默认的新生代与老年代收集器。
ParNew收集器(-XX:UseParNewGC)
ParNew跟Parallel收集器很相似,主要区别为ParNew可以和CMS配合使用。
新生代采用复制算法,老年代采用标记-整理算法。
运行在Server模式下的虚拟机的首要选择,
除了Serial,只有ParNew能与CMS收集器配合使用。.
CMS收集器(-XX:UseConcMarkSweepGC)
CMS(Concurrent Mark Sweep)是以获取最短回收停顿时间为目标的垃圾收集器。非常符合注重用户体验的场景使用。是HotSpot虚拟机第一款真正意义上的并发收集器,因为它基本上实现了让垃圾收集线程与用户工作线程并行运行。
采用标记-清除算法,垃圾收集过程如下:
-
初始标记
暂停所有其他线程STW,记录GC Root直接引用的对象,速度很快。
-
并发标记
从GC Root直接引用的对象开始遍历所有对象,过程耗时较长,但不暂停用户工作线程。也因为用户线程继续运行,可能会存在已经标记过的对象状态发生改变。
-
重新标记
STW,重新标记那些在并发标记阶段因为用户工作线程继续运行而导致状态发生改变的对象。
这个阶段停顿时间一般比初始标记时间长,但远低于并发标记时间。
使用三色标记的增量更新算法做重新标记。 -
并发清理
清理未标记的对象,此阶段如果有新增对象会被标记为黑色对象,不做任何处理。
-
并发重置
重置本次GC过程中的标记数据。
CMS优点:并发收集、停顿时间短;
缺点:
- 对CPU资源敏感;
- 无法处理浮动垃圾,即在并发标记和并发清理过程中又产生垃圾,这种垃圾只能在下一次GC再清理;
- 使用标记-清除算法会产生大量内存空间碎片,可以通过参数-XX:+UseCMSCompactAtFullCollection开启让JVM在执行完标记清除后再整理内存;
- 执行过程不确定性,有可能发生上一次垃圾收集还没执行完,垃圾收集又被再次触发的情况,也就是“Concurrent mode failure”,此时会STW,用Serial Old收集器代替CMS来收集垃圾。特别是在并发标记和并发清理阶段会出现。
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;
三色标记
在CMS并发标记阶段,因为标记期间用户工作线程也在运行,对象之间的引用关系可能发生变化, 导致已经标记的对象状态可能发生变化,多标和漏标就有可能发生。
在GCRoot可达性分析扫描过程遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
-
黑色:
表示对象已经被垃圾收集器扫描过,且这个对象的所有引用都已被扫描过。非垃圾对象。
如果有其他对象指向黑色对象,不需要再扫描一遍,黑色对象不会不经过灰色对象阶段直接变成白色对象; -
灰色:
表示对象已经被垃圾收集器扫描过,但对象中至少存在一个引用还没被扫描过;
-
白色:
表示对象没有被垃圾收集器扫描过,如果在分析阶段结束之后,仍然是白色对象,那此对象就是不可达,也就是垃圾对象;
多标-浮动垃圾
在并发标记和并发清理过程中,由于方法运行结束导致线程栈本地变量GCRoot被销毁,这个GCRoot引用的对象之前又已经被扫描过,被标记为非垃圾对象,那这些分应该回收但没有回收的对象,就是**“浮动垃圾”**,在本轮GC不会回收这些对象占用的内存,需要等到下一次GC才会被清除。
在并发标记和并发清除过程中产生的新对象,都是直接当成黑色对象,不进行清除。
漏标-读写屏障
漏标的对象会被当成垃圾对象删除,这是不行的。有两种解决方案:
-
增量更新(Incremental Update)
当黑色对象插入新的指向白色对象的引用时,将这个新的引用记录下来,在并发扫描结束后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次。
黑色对象一旦存在新的指向白色对象的引用就变成灰色对象。 -
原始快照(Snapshot At The Beginning, SATB)
当灰色对象要删除指向白色对象的引用时,将要删除的这个引用记录下来,在并发扫描结束后,再以这些记录过的引用关系中的灰色对象为根,重新扫描一次,将扫描的白色对象直接标记为黑色对象。
目的是让这些对象在本轮GC存活下来,在下一轮GC的时候重新扫描,这些对象有可能是浮动垃圾。
以上对引用关系记录的插入和删除,JVM的记录操作都是通过写屏障实现的。
写屏障
写屏障就是指在赋值操作前后,加入一些自定义的处理逻辑,类似于AOP。
JVM将这些操作存放在队列中,异步处理,提高效率。
void oop_field_store(oop* field, oop new_value) {
// 写屏障-写前操作
pre_write_barrier(field);
*field = new_value;
// 写屏障-写后操作
post_write_barrier(field, value);
}
写屏障实现SATB
在对象删除成员变量引用时,利用写屏障,将原来成员变量的引用记录下来。
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;
}
JVM各个垃圾收集器在并发标记时对漏标的处理方案如下:
- CMS:写屏障+增量更新
- G1:写屏障+SATB
- ZGC:读屏障
记忆集与卡表
在YoungGC时,新生代进行GCRoot扫描过程中,可能会碰到新生代对象被老年代对象引用的情况,这时候如果再去老年代去扫描GCRoot,效率就会很低。
为解决这种跨代引用问题,引入记忆集(Remember Set)数据结构,记录从非收集区到收集区的指针集合,也就是老年代到新生代的引用集合,将这个记忆集存放在新生代中。
在垃圾收集时,GC只需要通过新生代的记忆集判断非收集区(老年代)是否存在指向收集区域(新生代)的指针即可。
JVM使用卡表(CardTable) 实现记忆集。将老年代存放对象的内存划分成一个个的内存块,叫卡页。
卡表使用一个字节数组实现:CARD_TABLE[],数组中每个元素对应其标识的内存区域一块特定大小的内存块,称为“卡页”,大小为:2^9=512字节。
一个CardPage存放多个对象,只要有一个对象的字段存在跨代引用,其对应的卡表的元素就标为1,表示该元素变脏,否则为0。
GC时只需要得到本收集区(新生代)的卡表中变脏的元素,将其对应卡页中的对象加入到GCRoot中即可。
维护卡表
利用写屏障,在发生引用字段赋值时,更新卡表对应的标识为1。
G1垃圾收集
当你的服务器具有多颗处理器,并且内存容量很大,这时候就可以使用G1垃圾收集器,以满足低GC停顿时间和高吞吐量的要求。
G1将Java堆划分为2048个大小相等的独立区域(Region)。
每个Region的大小=堆大小/2048,也可以使用参数-XX:G1HeapRegionSize指定Region的大小,不过推荐默认的计算方式。
G1的新生代与老年代
G1依然保留了新生代与老年代的设定,但不再是物理内存上的划分了,它们可以是不连续的Region集合。
默认年轻代站堆大小的1/5,可以通过-XX:G1NewSizePercent设置年轻代的初始占比,在程序运行中,JVM会不断的给你年轻代增加更多的Region,不会超过60%,此值可以通过-XX:G1MaxNewSizePercent设置。
年轻代中的Eden与Survivor的默认比值依然是8:1:1。
一个Region之前可能是年轻代,在Region进行垃圾回收之后,可能会变成老年代。
G1对大对象的处理
对象什么时候会转移到老年代的规则在G1中依然适用,唯一不同的是对大对象的处理。
G1有专门的Humongous区,存放大对象。在G1中,一个对象的大小超过一个Region大小的50%,就被当作大对象处理,放到Humongous中,如果一个Region不够,可以使用多个连续的Region存放。
Humongous专门存放短期的大对象,不让这些对象进入老年代,节省老年代空间,这样就可以避免老年代空间不够而进行的GC开销。
Full GC时会收集年轻代、老年代和Humongous区。
G1垃圾收集步骤
G1垃圾收集的步骤分为以下几步:
-
初始标记(Initial Mark,STW)
暂停所有其他线程,单线程记录GC Roots直接引用的对象,速度很快。
-
并发标记(Concurrent Marking)
同CMS并发标记
-
最终标记(Remark,STW)
同CMS重新标记
-
筛选回收(Cleanup,STW)
此阶段会首先对每个Region的回收价值与成本进行排序,根据用户期望的GC停顿时间(-XX:MaxGCPauseMillis设置,默认200ms)来指定垃圾回收计划。
比如老年代有1500个Region都满了,但期望GC停顿时间只有200ms,通过回收成本计算得知,回收其中的1000个Region正好需要200ms,那么此次GC就会只回收这1000个Region,G1尽量做到把GC停顿时间控制在期望停顿时间范围内。
G1的老年代与年轻代都使用复制算法,将一个Region中存活的对象复制到另一个Region中,不会像CMS那样回收垃圾之后还要整理内存碎片。G1几乎不会有太多的内存碎片。
G1 Garbage-First 名称的由来
G1收集器会维护一个优先列表,每次根据允许的停顿时间,优先选择回收价值最大的Region。
比如一个Region消耗100ms可以回收10M的垃圾,另外一个Region消耗50ms可以回收20M的垃圾,那么在回收时间有限的情况下,G1会优先回收后面这个Region。
这样保证了G1在有限的回收时间内尽可能高的垃圾收集效率。
G1特点
-
并行执行
利用CPU多核并行处理来缩短STW时间。
-
分代收集
G1不需要配合其他垃圾收集器就可以管理整个GC堆,依然保留了分代的概念。
-
空间整合
与CMS的标记-清理算法不同,G1整体来看是标记整理算法,但从局部来看是基于标记复制算法实现的。
-
可预测的停顿时间
可预测的停顿时间是G1最鲜明的一个特点。但停顿时间也不能太低,在过低的停顿时间内G1不能回收较多的Region,随着应用的运行,可能不久就会占满堆内存,引发Full GC,反而降低性能。
G1垃圾收集分类
Young GC
之前的垃圾收集器在Eden区放满之后会立马进行Young GC,但在G1中不会这样。
G1会计算回收现在的Eden区需要多少时间,如果回收时间远低于期望停顿时间-XX:MaxGCPauseMillis设置的值,那么G1会增加年轻代的Region,不会进行Young GC,直到下一次Eden区放满之后,G1计算回收时间接近期望停顿时间,那么就会Young GC。
MixedGC
当老年代堆占用率到达参数-XX:InitiatingHeapOccupancyPercent设置的值则执行MixedGC,回收所有年轻代和部分老年代(根据GC期望停顿时间确定老年代Region回收优先顺序)以及Humongous的Region。
把各个Region中存活的对象复制到其他Region中,复制过程中如果发现没有足够的空闲Region能够容纳这些存活的对象,就会执行一次Full GC。
Full GC
STW,单线程进行标记,清理垃圾对象和压缩整理内存碎片,使空闲出来的Region供下一次MixedGC使用,非常耗时(Shenandoah优化成多线程收集)。
G1核心参数设置
- -XX:+UseG1GC:使用G1收集器
- -XX:ParallelGCThreads:指定GC工作的线程数量
- -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
- -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
- -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
- -XX:G1MaxNewSizePercent:新生代内存最大空间
- -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
- -XX:MaxTenuringThreshold:最大年龄阈值(默认15)
- -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
- -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
- -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
- -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
G1优化
-XX:MaxGCPauseMillis期望停顿时间的值设置太小不行,设置太大也不行。
如果期望停顿时间太大,年轻代可能已经占用堆内存60%了,才进行Young GC:
- 此时存活的对象可能会有很多,Survivor区容纳不下这些对象,就会直接进入老年代中;
- 或者Young GC之后存活的对象过多,导致进入Survivor区的对象触发了对象动态年龄判断机制,达到了Survivor的50%,也会加快一些对象进入老年代的进程。
在保证Young GC不太频繁的同时,还要考虑每次GC后有多少存活对象,避免存活对象太多进入老年代,频繁触发Mixed GC。
使用G1的场景
- 50%以上的堆内存被存活对象占用;
- 对象分配和晋升的速度变化非常大;
- 垃圾回收时间特别长,超过1秒;
- 8G以上的堆内存;
- 停顿时间要求500ms以内;
安全点与安全区域
安全点(Safe Point)
安全点就是指代码中的一些位置,当线程运行到这些位置时,线程的状态是确定的,这时候JVM就可以进行GC。这些安全点有以下几种:
- 方法返回之前;
- 调用某个方法之后;
- 抛出异常的位置;
- 循环的末尾;
当GC时,不直接暂停线程,只设置一个标志位,各个线程在执行过程中会不停的主动轮询这个标志,当发现中断标志为真时就在当前线程最近的安全点上主动挂起。
安全区域(Safe Region)
当一个线程进入Sleep或中断状态,但此线程又不在Safe Point上时,只要此线程在安全区域内,依旧可以进行GC。
安全区域就是指一段代码中,引用关系不会发生改变,在这个区域中任意地方开始GC都是安全的。
垃圾收集器选择
- 优先调整堆大小让服务器自己来选择;
- 如果内存小于100M,使用串行Serial;
- 如果是单核CPU,并且没有停顿时间要求,使用串行Serial或JVM自行选择;
- 如果允许停顿时间超过1秒,选择并行或JVM自行选择;
- 如果响应时间很重要,并且不能超过1秒,使用并发收集器;
- 4G一下使用Parallel,4-8G使用ParNew+CMS,8G以上使用G1;