GC ROOT的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。尽管目标明确,但查找过程需要消耗不少时间。
根节点枚举始终必须在一个能保障一致性的快照中才得以进行。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因。
当用户线程停顿下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机有办法直接得到哪些地方存放着对象引用。
收集线程会在内存进行扫描,看哪些位置存储了Reference类型,而栈上的本地列表里只有一部分数据是Reference类型的,而其他类型的数据我们并不关心。如果通过全栈扫描的方式寻找,将会产生很大浪费。
如果把栈上代表引用的位置全部记录下来,以空间换时间,等到根节点枚举时直接读取。而OopMap的数据结构就是用来记录这些信息。
ordinary object pointer Map 普通对象指针地图。普通对象指针指Java类在JVM中对应的C++实例。
图 虚拟机栈中的OopMap
1)一个栈帧可以有多个OopMap。
2)一个类在加载进内存的时候,空间是“确定的”,即结构是确定的。比如定义了哪些变量,哪些引用,而且一定是连续内存,所以对象中的引用是可以通过地址偏移量计算得到的,随意把这个偏移量放在OopMap中。
可能导致引用关系变化或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量额外存储空间。
实际上HotSpot没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点。有了安全点设定,也就决定了用户程序执行时必须到达安全点后才能暂停,来开始垃圾收集。
基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”最明显的特征是指令序列的复用,如方法调用、循环跳转、异常跳转等属于指令复用。只有具有这些功能的指令才会产生安全点。
抢先式中断 |
需要线程的执行代码主动去配合,当垃圾收集时,系统首先把所有用户线程中断,如果发现存在用户线程中断的地方不在安全点上,则恢复这条线程,让它跑到安全点上再重新中断。现在几乎没有虚拟机使用这种方式。 |
主动式中断 |
不直接对线程操作,当需要垃圾收集时仅仅简单设置一个标志位,各个线程执行过程会不停主动去轮询这个标志,一旦发现中断标志为真时,该线程在最近的安全点上主动挂起。 |
表 达到安全点的两种方式
安全点还需要加在所有创建对象和其他需要在Java堆上分配内存的地方。这是为了检查是否即将要发生垃圾收集,避免没有足够的内存空间。
图 主动式中断
用户程序处于Sleep或者Blocked状态,这时线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,而线程被唤醒的时间不确定,虚拟机不可能持续等待其被重新激活分配处理器时间。
是指能够确保在某段代码片段中,引用关系不会发生变化,因此这个区域任意地方开始垃圾收集都是安全的。
当用户线程执行到安全区域里面的代码时,首先会标志自己已经进入了安全区域,那么当这段时间里虚拟机要发起垃圾收集时就不必管这些已声明自己在安全区域内的线程。当线程要离开安全区域时,它要检查虚拟机是否已经完成根节点枚举,如果完成了,线程继续执行,否则等待,直到收到可以离开安全区域的信号为止。
图 线程进入安全区域
public class Test2 {
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);
}
}
/*
执行结果
主线程开始睡觉
睡醒了, 一共睡了 : 7569 毫秒
打印一下num 看看结果是多少: 400000000
Process finished with exit code 0
*/
线程1和线程2安全点放在循环结束后,Thread.Sleep会触发安全区域,此时其他线程也轮询到了垃圾回收中断标志,主线程在接受休眠后,需要检查垃圾回收是否结束(需等其他线程也完成GC Root检查),否则不能离开安全区域。
卡表元素变脏时间点原则上是发生在引用类型字段赋值那一刻。HotSpot通过写屏幕(Write Barrier)计数维护卡表状态。写屏幕是在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作。
伪共享是处理并发底层细节时一种经常需要考虑的问题。现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会影响彼此(写回、无效化或者同步)而导致性能降低。
图 卡表的“伪共享”场景
解决方案:先进行卡表标记检查,只有当该卡元素未被标记时,才将其标记未1(变脏)。
JDK7之后,HotSpot增加一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新条件判断。开启会增加一次额外判断的开销,但能避免伪共享问题。
可达性分析算法理论上要求全程都基于一个能保障一致性都快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。
图 快照情况下的可达性分析
如果用户线程与收集器并发工作,则可能会产生问题。
图 并发情况下的可达性分析产生异常的两个场景
“对象消失”即原本应该是黑色的对象被误标记为白色,需要同时满足以下两个条件:
1)赋值器插入了一条或多条从黑色到白色的新引用;
2)赋值器删除了全部从灰色到该白色对象的直接引用或间接引用。
解决这个问题,只需破坏这两个条件的任意一个即可。
1)增量更新是破坏第一个条件,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。然后在第一次可达性分析后,再以灰色对象为根,进行重新可达性分析。
2)原始快照是破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个灰色对象及灰色对象与白色对象的引用关系记录。然后再第一次可达性分析后,再以这灰色对象为根,进行可达性分析。