什么是OopMap
由于目前几乎所有虚拟机都是用可达性分析算法来判定对象是否存活,即通过选定固定的gc roots作为起始节点,像剥洋葱一样往下溜达,只要存在任意节点从gc roots到该节点不可达,那表示这个对象不被任何对象所引用,这个对象最终就要被当做垃圾回收掉。
问题来了,如何找到这些gc roots呢?
从源代码上看,对象引用不是在类中,就是在方法中,如此,通过扫描所有的对象就可以获取到这些gc roots。但是目前随便一个Java应用相当庞大(低情商叫臃肿),内存中的类,对象,常量数不胜数,每次gc都去扫描一遍,这个性能损耗是不可能接受的,而这只是其一。其二为了保证内存的一致性,获取这些gc roots过程中,必须暂停用户线程。用户线程在这个阶段内不能工作,所以能做的就是尽量要缩短用户线程的停顿时间,也就是要尽快完成gc roots的扫描。惯用套路,既然后期处理遍历耗时,那就前期维护一套数据结构呗,所谓的空间换时间。而OopMap就是这套数据结构,通过OopMap提前记录类、方法的引用信息,查找gc roots时,直接通过OopMap去获取,而不必扫描整个对象。
首先对于一个类在加载进内存的时候,空间是“确定的”,即结构是确定的,比如定义了哪些变量,哪些引用,而且一定是连续内存,所以对象中的引用是可以通过地址偏移量计算得到的,所以把这个偏移量放在OopMap中,需要的时候OopMap去找就可以了
一个线程在运行过程中,有自己的栈空间,每一个方法都是一个栈帧,即时编译过程中会在特定位置记录下栈中和寄存器里哪些位置是引用。
public void test()
{
Person person = new Person();
person.setPhone(new Phone());
Dog dog = new Dog();
//...
}
一个栈帧可以有多个OopMap,这里假设一个栈帧只有一个,且记录状态是在方法返回之前,如下:
oop (ordinary object pointer) 普通对象指针,oopmap就是存放这些指针的map,OopMap 用于枚举 GC Roots,记录栈中引用数据类型的位置。迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的,
收集线程会对栈上的内存进行扫描,看看哪些位置存储了Reference类型。如果发现某个位置确实存的是Reference类型,它所引用的对象这一次不能被回收。问题是,栈上的本地变量表里面只有一部分数据是Reference类型的,那些非Reference类型的数据对我们而言毫无用途,但我们还是不得不堆整个栈全部扫描一遍,这是对时间和资源的一种浪费。
一个很自然的想法时,能不能用空间换时间,把栈上代表的引用的位置全部记录下来,这样到真正gc的时候就可以直接读取,而不用再一点一点的扫描了,Hotspot就是实现的。它使用一种叫做OopMak的数据结构来记录这类信息。
一个线程为一个栈,一个栈由多个栈桢组成,一个栈桢对应一个方法,一个方法有多个安全点。GC发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的OopMap,记录栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈桢的OopMap ,通过栈中记录的被引用的对象内存地址,即可找到这些对象(GC Roots)
总结oopMap的作用
可以避免全栈扫描,加快枚举根节点的速度
可以帮助HotSpot实现准确式GC
安全点(safe point)
虽然OopMap避免了大量扫描内存的消耗,但是内存中对象繁多,对象之间的引用关系也时刻在发生变化,如果每条指令都去记录OopMap,将会消耗大量内存和cpu资源,垃圾回收反而变成了系统的负担,为了解决这个问题,引入了安全点(safe point)的概念。即只在指令流的特定位置记录OopMap,垃圾回收行为发生后,线程如果没有到达安全点,将继续执行,直到到达最近的一个安全点才停下来,等待垃圾回收器完成gc roots的选取。
就像公交车一样,每个乘客到达的地点是不同的,但公交车不会为每一个人去停车,必须等到提前设定的站台才会停下,这个时候乘客才可以下车。
当线程到达安全点后,有两种方式中断线程:
抢占式中断,抢先式中断不需要线程的执行代码
主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地
方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚
拟机实现采用抢先式中断来暂停线程响应GC事件。
而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一
个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最
近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他
需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新
对象。
轮询标志的地方和安全点是重合的,还需要加上所有创建对象和其他需要在对上分配空间的地方,这是为了检查是否要发生gc,避免没有足够的内存分配对象
适合插入安全点的地方:
方法(栈帧)结束前,但并不意味着一个方法只能有一个安全点
非计数循环末尾,避免循环体执行时间太长,导致长时间无法到达安全点
每条Java编译后的字节码边界
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,
把轮询操作精简至只有一条汇编指令的程度。下面代码清单3-4中的test指令就是HotSpot生成的轮询指
令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会
产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指
令便完成安全点轮询和触发线程中断了。
根据以上的概括,我们能总结出一个点,安全点既不能太多,也不能太少,如果安全点过多,会对虚拟机资源产生更多的挤压,如果安全点太少,则会导致垃圾回收器等待时间过长,因此,需要在这两者之间取其平衡。
安全区域
安全区域可以理解是对安全点的存在问题的补充,上边说到线程会执行到附近的安全点停下来等待垃圾回收器介入处理,但如果线程没有执行呢,换句话就是说没有获得cpu执行权,比如某一个线程正在sleep或者等待磁盘输入,那么这个线程是不会走到安全点挂起自己的。
这个时候就要引入安全区概念了,顾名思义,安全区就是一段指令域,在这个域中的指令不会对当前内存中的引用造成修改,当线程进入该区域后,会主动将自己的状态标记为“进入安全区”,这个时候如果发生gc,垃圾回收器发现该线程处于安全区域内,认为该线程不会对内存安全造成影响,便会跳过该线程,不会等待该线程到达安全点。
而线程在到达安全区边界时,同样也会检查当前gc是否在工作,如果gc正在工作,这个时候线程便会主动停下来,等待gc动作完成后再继续执行。
记忆集与卡表
比如只清理年轻代,那么会从需要清理的范围内找出gcRoot,然后过滤出年轻代的root进行可达性分析,但是有一种情况,对象是年轻代对象,但是被老年代引用,那么gcRoot是标记不到的,因为老年代root被过滤了,但不能把所有的老年代root也扫描一遍,成本太高,所以有了记忆集。
为了解决在垃圾回收算法中,无论哪种算法,都需要先对对象进行标记,然后再进行回收操作。标记过程中,存在跨代引用问题,为了完整的标记对象引用链,将不得不对跨代内存中的对象进行遍历,尤其是老年代对象,对象存活率相对高,遍历的性价比极低。于是就引入了记忆集的概念,即将老年代内存划分为若干个小块,同时在新生代特定位置维护一块数据区域用来标记老年代中的哪个小块内存中存在跨代引用,当发生gc时,只需要检查这块数据区域中哪些老年代内存块中存在跨代引用,然后再对这一小块内存进行遍历。
这个过程相当于将老年代整个内存的搜索粒度降低了,从搜索整个老年代到“只搜索其中的某几小块”。
此外,老年代中内存小块具体设置多大,也需要综合考虑,如果设置太小,虽然粒度更低,精度更高,遍历单个块效率会提高,但分块会变多,同时也需要更多新生代空间去维护这个分块信息;如果划分的太大,则会降低老年代搜索效率。
以下是记忆集可供选择的记录精度:
字长精度:每个记录精确到机器字长,64位机下就是8字节空间,该区域存在跨代指针
对象精度:每个记录精确到一个对象,该区域存在跨代指针
卡精度:每个记录精确到一块内存区域,该区域存在跨代指针
而card table可以理解为是Java虚拟机对记忆集的一种实现,hotspot用一个字节数组完成了卡表的记录,字节数组的每一个元素都标识老年代内存区域中的一段特定长度的区域,这个区域称为卡页。
而card table可以理解为是Java虚拟机对记忆集的一种实现,hotspot用一个字节数组完成了卡表的记录,字节数组的每一个元素都标识老年代内存区域中的一段特定长度的区域,这个区域称为卡页。
CARD_TABLE[(this address >> 9)] = 0;
//根据以上的代码可以看出一个卡页的大小是512byte,即2^9 。
//将内存块地址右移9位,即除以512,得到的余数正好对应该内存区域对应的卡表标号
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个
内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可
以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如
果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了
地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块[4],

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代
指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃
圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它
们加入GC Roots中一并扫描。
写屏障
卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的
卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变
脏,即如何在对象赋值的那一刻去更新维护卡表呢?
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切
面[2],在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的
前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值
后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直
至G1收集器出现之前,其他收集器都只用到了写后屏障。下面这段代码清单3-6是一段更新卡表状态
的简化逻辑

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新
卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外
的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
伪共享问题:
众所周知,cpu内部存在高速缓存,缓存中的存储单位是以缓存行为单位的,也就是说一个缓存行可以存储多个数据单位,而这些数据可能来自于不同线程,都知道不同线程的变量是相互隔离的,并不会互相影响。但是在并发场景下,缓存中的数据行尽管是来自不同线程的数据,但是由于存储单位是一个缓存行,此时线程不得不进行同步、或者通过cas算法以达到“有序执行”,从而降低了性能。本来数据实际上是相互独立、相互隔离的,但是在这里线程却认为访问的是共享空间,故名“伪共享”。
同样应用写屏障面临这个问题,如果卡表元素被缓存在一个缓存行中,多个线程对该卡表的更新操作就会出现“伪共享”,为了避免该问题,当内存对象引用发生变化需要更新卡表时,先判断一下该对象所对应的卡表是否已经变脏,如果没有变脏,再进行更新,避免多次重复标记导致的线程同步。
JDK7之后增加了新参数 -XX:+UseCondCardMark用来决定是否开启卡表更新的条件判断
并发的可达性分析
从GC Roots再继续往下遍历对象
图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等
比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时
间的话,那收益也将会是系统性的。
三色标记:
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问
题,即原本应该是黑色的对象被误标为白色:
·赋值器插入了一条或多条从黑色对象到白色对象的新引用;
·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别
产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,
SATB)。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新
插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫
描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象
了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删
除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描
一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来
进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在
HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新
来做并发标记的,G1、Shenandoah则是用原始快照来实现。