目录
gc root对象有哪些
oopMap
安全点(safe point)
安全区域
卡表
伪共享问题
三色标记法
垃圾收集器
CMS
G1
gc root对象有哪些
- 虚拟机栈中引用的对象(虚拟机栈中的引用的对象可以作为GC Root。我们程序在虚拟机的栈中执行,每次函数调用调用都是一次入栈。在栈中包括局部变量表和操作数栈,局部变量表中的变量可能为引用类型(reference),他们引用的对象即可作为GC Root。不过随着函数调用结束出栈,这些引用便会消失。)
- 方法区中类静态属性引用的对象(简单的说就是我们在类中使用的static声明的引用类型字段)
- 方法区中常量引用的对象(简单的说就是我们在类中使用final声明的引用类型字段)
- 本地方法栈中引用的对象(就是程序中native本地方法引用的对象)
- Java虚拟机内部的引用(类型对应的Class对象,常驻的异常对象等等)
- 所有被synchronized持有的对象
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI中注册的回调、本地代码缓存等等
oopMap
oop (Ordinary Object Pointer) 普通对象指针,oopmap就是存放这些指针的map,OopMap 用于枚举 GC Roots,记录栈中引用数据类型的位置。迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的
一个线程为一个栈,一个栈由多个栈桢组成,一个栈桢对应一个方法,一个方法有多个安全点。GC发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的OopMap,记录栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈桢的OopMap ,通过栈中记录的被引用的对象内存地址,即可找到这些对象(GC Roots)
可以避免全栈扫描,加快枚举根节点的速度;可以帮助HotSpot实现准确式GC
安全点(safe point)
HotSpot JVM不仅在 GC 的时候使用 safepoints,还有许多其他操作也使用 safepoints。特别是当有清理任务要做时,它会周期性地停止 Java 线程。
通过oopMap 可以快速进行GCROOT枚举,但是随着而来的又有一个问题,就是在方法执行的过程中, 可能会导致引用关系发生变化,那么保存的OopMap就要随着变化。如果每次引用关系发生了变化都要去修改OopMap的话,这又是一件成本很高的事情。所以这里就引入了安全点的概念。
并不需要一发生改变就去更新这个映射表。只要这个更新在GC发生之前就可以了。所以OopMap只需要在预先选定的一些位置上记录变化的OopMap就行了。这些特定的点就是SafePoint(安全点)。由此也可以知道,程序并不是在所有的位置上都可以进行GC的,只有在达到这样的安全点才能暂停下来进行GC
安全点太少,会让GC等待的时间太长,太多会浪费性能
我们搞明白了安全点被放置的位置之后,考虑另外一个问题:如何使虚拟机中的线程跑到安全点?
有两种方式可供选择,分别为:
抢先式中断(Preemptive Suspension),直接粗暴的暂停全部的用户线程,如果发现用户线程并不在安全点上,则继续恢复这条线程继续执行,让他一会再重新中断,直到跑到安全点上为止。(现在几乎没有虚拟机采用这种实现)。
主动式中断(Voluntary Suspension),设置一个标志位,用户线程执行过程中,不停的主动轮询这个标志,一旦发现中断标志为真,自己就在最近的安全点上主动中断挂起。这个轮询是否需要进入安全点的动作在每个安全点时发生,这个动作被称之为polling point,polling也有开销,这也是上文中我们提到的HotSpot并没每个字节码指令都放置一个safepoint的原因。
安全点的选取是以是否让程序长时间执行的特征为标准的,“长时间执行”最明显的特征就是指令复用,如:方法调用、循环跳转(非可数循环)、异常跳转等都属于指令复用
这里有一个特别有意思的例子:
// private static int num = 0; public static AtomicInteger num = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Runnable runnable = () -> { for (int i = 0; i < 200000000; i++) { num.getAndAdd(1); } }; Thread t1 = new Thread(runnable, 测试01线程); Thread t2 = new Thread(runnable, 测试02线程); t1.start(); t2.start(); System.out.println(主线程开始睡觉); //记录睡眠时间 long start = System.currentTimeMillis(); Thread.sleep(1000); System.out.println(睡醒了, 一共睡了 : + (System.currentTimeMillis() - start) + 毫秒); System.out.println(打印一下num 看看结果是多少: + num); }
Connected to the target VM, address: '127.0.0.1:54826', transport: 'socket' 主线程开始睡觉 睡醒了, 一共睡了 : 4398 毫秒 打印一下num 看看结果是多少: 400000000 Disconnected from the target VM, address: '127.0.0.1:54826', transport: 'socket'
简单解释一下,当线程1,线程2 是两个十分耗时的循环,并且根据HotSpot放置安全点的位置来说,上一段代码是典型的 counted loop(可数循环),也就是说每次循环回跳前是不会放置安全点的。而Thread.Sleep又触发了安全区域(为了定期清理),最后当Thread.sleep睡醒时(也就是从native方法返回时)又需要检查其他线程是否已经跑到安全点,如果没有跑到安全点,此时它就会继续挂起,这也是这段代码为什么会睡眠长达4秒多的原因了。
怎么进行优化呢,那就需要搞清楚可数循环的定义,把循环中的变量i从int改成long,就不再被视为可数循环,循环会被放置一个安全点,那么主线程也不需要再阻塞等待。
参考:https://mp.weixin.qq.com/s/KDUccdLALWdjNBrFjVR74Q
安全区域
安全点的使用似乎解决了OopMap计算的效率的问题,但是这里还有一个问题。安全点需要程序自己跑过去,那么对于那些已经停在路边休息或者看风景的程序(比如那些处在Sleep或者Blocked状态的线程),他们可能并不会在很短的时间内跑到安全点去。所以这里为了解决这个问题,又引入了安全区域的概念。
安全区域很好理解,就是在程序的一段代码片段中并不会导致引用关系发生变化,也就不用去更新OopMap表了,那么在这段代码区域内任何地方进行GC都是没有问题的。这段区域就称之为安全区域。线程执行的过程中,如果进入到安全区域内,就会标志自己已经进行到安全区域了。那么虚拟机要进行GC的时候,就不会管这些已经运行到安全区域的线程,当线程要脱离安全区域的时候,要自己检查系统是否已经完成了GC或者根节点枚举(这个跟GC的算法有关系),如果完成了就继续执行,如果未完成,它就必须等待收到可以安全离开安全区域的Safe Region的信号为止。
卡表
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在跨代引用,那就标记卡表对应的数组元素为1,称为这个元素变脏(Dirty),没有则标识为0.在垃圾收集发生时,只要筛选出卡表中变脏的元素,将对应卡页内存中的对象加入到gcroots
卡表的更新在写屏障中完成
由于新生代的垃圾收集通常很频繁,gcroot每次都遍历整个堆的话成本很高。所以在younggc过程中发现gcroots指向了老年代就不再向下遍历,从而避免每次YGC时扫描全堆,减少开销。但是又出现另一个问题,如果老年代跨代引用了新生代怎么办?(答案就是卡表);
那么有新生代到老年代的引用卡表么?
假如说要维护新生代到老年代的引用卡表,由于新生代对象引用变化频繁,维护卡表写屏障中更新的成本很大,并且老年代回收频率比较低,每次gc可以根据gcroots扫描整个堆的总体成本反而更低
伪共享问题
cpu缓存是由多一个缓存行组成的,缓存行时cpu缓存的最小缓存单元。目前主流的cpu缓存的缓存行大小都是64Bytes。
也就是说,如果一个512字节(512 / 64 = 8)的一级缓存,就存在8个缓存行组成。如果缓存行存储的是long类型的数据,那么每个缓存行可以存储8个(64 / 8 = 8)long类型的数据(CPU的L1、L2、L3缓存)
当多线程并发修改互相独立的变量时,如果这些变量共享一个缓存行,就会彼此影响(写会、无效化或者同步)而导致性能降低。
处理的的缓存行大小64byte,一个卡表元素占用一个byte,64个卡表元素共享一个缓存行,对应的老年代内存为64x512byte=32kb。
如果多线程更新的对象正好都处于这32kb的内存中,就会导致更新卡表时正好写入同一个缓存行,而导致另一个线程读取的缓存行数据失效,需要重新从主存中读取,影响性能。简单的方法就是写入前判断一下,如果该卡表元素对应的内存区域已经脏了,那么就不需要再次写入,减少更改缓存行造成的数据无效概率。
if (CARD_TABLE [this->address >> 9] != 0) CARD_TABLE [this->address >> 9] = 0;
三色标记法
CMS G1采用三色标记的机制来进行可达性分析和标记对象,三色标记法将对象分成三种类型:
- 黑色:该对象是根对象,或者该对象与它的子对象都被扫描过(对象被标记了,且它的所有引用类型字段也被标记完了)
- 灰色:该对象本身被扫描了,但还没扫描完该对象中的子对象(它的引用类型字段还没有被标记完成)
- 该对象未被扫描。完成所有对象的扫描之后,仍然被标记为白色的对象即为垃圾对象(对象没有被标记到)
CMS G1在进行并发标记阶段没有使用STW,那么就会存在1.2.1小节中可达性分析算法的不足里面所提到的问题:垃圾收集进程将对象标记好颜色后,用户线程又修改了引用关系,这样可能出现把原本存活的对象错误标记为已消亡的情况,这就是非常致命的后果了,程序肯定会因此发生错误。具体如下所示:
出现误标的条件:
- 赋值操作插入一条或多条白色到黑色的新引用
- 赋值操作删除全部从灰色到白色的直接或间接引用
field:引用指针 new_value:指针新指向的值
解决方法:
- 增量更新(Incremental Update)要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了,因此不存在一个赋值操作会插入一条或多条从黑色对象到白色对象的新引用。CMS垃圾处理器采用的是增量更新的机制,使用的是写后屏障,记录新的引用newA到队列
- 原始快照(Snapshot At The Beginning,SATB)要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,因此从逻辑上来说赋值操作无法删除灰色对象到白色对象直接引用关系。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。G1垃圾收集器采取的是原始快照SATB的机制来防止并发错误。
SATB 的过程可以简单理解为:当并发标记阶段引用的关系发生变化时,旧引用所指向的对象就会被标记,同时其子引用对象也会被递归标记,这样快照的完整性就得到保证了。SATB 的记录更新是由 pre_write_barrier 写前屏障触发的,下面是 G1 论文中介绍的 SATB 原始表述,具体实现时,还是由两级的队列结构缓存,再由并发标记线程批量处理进入标记队列satb_mark_queue。
void pre_write_barrier(oop* field) { oop old_value = *field; if (old_value != null) { if ($gc_phase == GC_CONCURRENT_MARK) { $current_thread->satb_mark_queue->enqueue(old_value); } } }
因此,G1 在结束并发标记后还有一个需要使用了STW 的最终标记(Final Marking)阶段就可以理解了,因为如果不引入一个采用了STW的最终标记(Final Marking)的过程,那么新的引用变更会不断产生,永远就无法达成完成标记的条件。最终标记标记阶段,因为有了SATB 的设计,则只需要扫描 satb_mark_queue 队列里的引用变更记录就可以对此次 GC 活动形成完整标记了(可以对比 CMS 的 remark 阶段
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描
被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代
区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC
再深度扫描。
重新标记阶段遍历复杂度:
- 增量更新:写后屏障记录A节点到队列,在重新标记节点A变成灰色,再次走一遍深度遍历(B、C也需要再深度遍历一遍),显然性能很差
- 原始快照SATB:写前屏障记录节点D到队列,在重新标记阶段只需要把D变成灰色,遍历D即可,g1垃圾收集器有很多region每个region在并发标记阶段都会产生很多引用变化,如果用增量更新会增加很多深度遍历的成本
如下图:正常情况下从rootgc节点A遍历到了C之后,D从C断开和A连接,那么对这两种方案的处理比较
并发清楚节点产生的浮动垃圾:(假如C、D断开没有和A关联)
- 增量更新:A不会进入队列,并发回收阶段直接回收
- 原始快照SATB:D会进入队列,重新标记阶段D作为灰色深度遍历,深度遍历过的D就会变成黑色,并发回收阶段回收不了,产生浮动垃圾
总结来说:SATB虽然减少了深度遍历的复杂度,但是会产生更多的浮动垃圾
在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
但在G1中,并没有使用point-out,这是由于一个分区太小,分区数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的分区引用也扫描了。于是G1中使用point-in来解决。point-in的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。
垃圾收集器
CMS
- 初始标记 initial mark: 标记gcroots能直接关联到的对象
- 并发标记 concurrent mark: 从gcroots能直接关联到的对象开始遍历
- 重新标记 remark
- 并发清除 concurrent sweep
缺点:
- 对处理器资源比较敏感,并发阶段需要和用户线程一起运行,占用cpu资源比较高
- 内存利用率不高,并发标记和并发清除阶段会有新的垃圾产生,这部分在本次回收处理不了,只能留在下一次处理,这部分被称为浮动垃圾。同样因为用户线程需要并发运行,所以不能等老年代快要满了再回收,要给用户线程留一部分运行使用。(如果CMS运行期间预留的内存不能满足分配新对象,就会出现并发失败 Concurrent Mode Failure,这时候虚拟机启动后备方案:冻结用户线程启用Serial Old收集器来重新进行老年代垃圾回收,但这样就导致停顿时间就很长了)
- 标记-清除算法会导致内存碎片化,往往会出现老年代还有很对剩余的空间,但无法找到足够大的连续空间来分配对象,不得不提前触发一次Full Gc的情况。CMS提供了一个参数-XX:+UseCMSCompactAtFullCollection开关参数,用于不得不进行FullGc时开启内存碎片合并整理的过程,但是因为要移动存活对象,停顿时间就会变长。因此提供了-XX:+CMSFullGCsBeforeCompaction,要求CMS再经过若干次不整理空间的FullGC之后,下一次进入FullGC前会先进行碎片整理。
G1
收集模型 | 停顿时间模型(Pause Prediction Model)的收集器,在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是Java的软实时垃圾收集器特征了。 |
收集区域 | 可以面向堆内存的任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式 |
区域划分 | 把连续的堆划分成多个大小相等的独立Region,每一个Region可以扮演Eden、Survivor或者老年代空间,收集器能够对不同角色的Region采用不同的策略去处理,无论是新创建的对象还是存活了一段时间、熬过多次收集的旧对象都能够获取很好的收集效果。 |
Humongous区域 | Region中还有一类特殊的Humongous区域,专门存储大对象的,G1认为只要超过Region容量的一半就判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值1MB~32MB,2的N次幂。超过了Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都是把Humongous Region作为老年代的一部分看待。 |
回收优先级 | 优先处理回收价值收益最大的Region,保证了G1收集器在有限的时间内获取尽可能高的收集效率 |
存在的问题:
- 跨代引用
每个Region都维护有自己的记忆集,这些记忆集会记录别的Region指向自己的指针,并记录这些指针分别在那些卡页的范围之内。G1的记忆集在存储结构上本质是一个哈希表,key是Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。由于G1的Region数量非常多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约堆10-20%的额外内存来维持收集器工作。
- 并发标记阶段引用改变
CMS收集器用的增量更新实现,G采用的原始快照SATB。垃圾收集器对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要并发运行肯定会有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发过程中新对象的分配,新对象必须要在这两个指针位置之上。这个范围内的对象是被隐式标记过的,默认存活,不纳入回收范围。与CMS中的Concurrent Mode Failure一样,如果内存回收速度赶不上内存分配速度,G1收集器也要被迫冻结用户线程执行,导致Full GC产生长时间的STW
- 怎么建立可靠的停顿模型
G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准差、置信度等统计信息。衰减均值是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体的平均状态,而衰减平均值更准确地代表最近的平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成的回收集才可以在不超过期望停顿的时间约束下获得最高收益。
收集过程
- 初始标记:仅仅是标记一下GCRoots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短。
- 并发标记:从GCRoots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这个阶段耗时很长,但可以和用户线程并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的并发时有引用变动的对象
- 最终标记:对用户线程短暂停顿,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多Region构成回收集,然后把决定回收的Regions复制到空的Region中,再清理调旧Region空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。