最近,五哥回忆起4年前在蚂蚁金服三面的经历。关于GC的一个问题,让我记忆深刻。
当聊起来Java GC时,我提到 young gc 和 full gc
都会 Stop the world。
"为什么需要 Stop the world
",蚂蚁面试官问道。
我略微怔住,想了一会,回答道:"如果一边垃圾回收,业务线程一边跑,可能清理的不干净吧,也可能有一些对象被错误回收"。 事实上,我没背过这个八股文,只能凭感觉瞎说。本以为这个问题就这么糊弄过去了。
"你可以举例说明下,为什么GC需要Stop the world吗?举个例子说明下?" ,蚂蚁面试继续追问道。
这个追问让我措手不及,一脸懵逼。想了几分钟后,半天也没放出个屁...... 支支吾吾没答出来,只得坦白这个问题没有想清楚,我不会。
大家都明白,为什么需要Stop the world,但要举一个实际例子来证明这一点有难度,就像大家都了解快速排序的原理,但手写非递归版本的快速排序真的很困难。
后来的我花了很久,才想到两个例子来反证这个结论。在此之前,我先啰嗦一下,故事的背景......
故事的背景
2019年的春天,刚毕业两年的我,在忙着换工作,那时候的求职环境还不像今天这么寒冷,大公司的面试机会有很多,即便如此,我也非常珍惜大公司的面试机会,尤其是蚂蚁金服。那一天,我坐地铁10号线,去朝阳区蚂蚁金服的办公点------环球金融中心
现场面试,在我看来,这个大楼的名字和蚂蚁金服四个字一样,非常高大上。算上电话面试,今天是第三面,如果面试通过,不出意外的话,后面没有技术面了,应该就能拿到Offer。 心想着,终于可以拿到一个满意的Offer了, 于是既兴奋又紧张的走进了蚂蚁金服。
我没有想到,两个小时后,我会灰溜溜的出来~
当时的我准备得十分充分,我先用小公司、中型公司的面试机会练手,像蚂蚁金服类的大公司留到最后再面试,目的是一击必中,不留遗憾。在当时,我已经有十几轮的面试经验了,自我介绍和项目介绍背的滚瓜烂熟,常见八股文,简单leetCode完全难不倒我。甭管实力如何,至少在心理上我十分自信。
然而蚂蚁金服的面试官从浅入深,在各方面盘问我的技术实力。我的印象是:他们从一个点开始问,一直问到底,问到我不会为止,我一度接近崩溃......。这些问题我努力回忆了一下,可以# 点击查看8家大厂后端面试题
为什么需要Stop the world
比较官方的解释如下
分析工作必须在一个能确保一致性的快照中进行
一致性指整个分析期间整个执行系统像被冻结在某个时间点上
如果出现分析过程中对象引用关系还在不断地变化,则分析结果的准确性无法保证。
官方给的解释中,重点在强调,GC的分析要确保在一个一致性的视图之上,否则无法保证垃圾回收的准确性。这和我说的几乎一样,"有一些对象可能会被错误回收",只不过官方的说法更加专业。但是官方并没有给出具体的例子...... 这需要我们自己探索。
垃圾回收算法中的标记工作
在Java堆中,存放着所有Java的对象实例。在进行垃圾收集之前,JVM需要确定哪些对象已经不被使用(即垃圾),哪些对象仍然被使用。为了判断对象是否是"垃圾",JVM采用了可达性分析算法。
可达性分析算法 是指通过指定 GC Root 根对象,从根对象开始搜索引用的对象,通过引用链条,层层遍历链条上的对象,可以到达的对象不可被垃圾回收。而最终没有被搜索遍历到的对象,则为 不可达对象,应该被垃圾回收。
JVM中的 GC Root根对象包括如下:
- 虚拟机栈引用的对象
- 本地方法栈内JNI(本地方法)引用的对象
- 方法区中常量引用的对象(字符串常量池)
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用
垃圾回收必须要先标记出垃圾对象,才可以进行后续的清理工作。无论是采用 标记-整理算法还是标记-清理算法。标记工作都是必不可少的。
如上文指出,JVM 明确标记工作进行时,业务线程必须暂停执行!
下面我提出两个例子说明下,如果业务线程没有被暂停,会造成什么后果!
使用反证法证明,为什么需要Stop the world
接下来,我通过一段代码,证明这个结论: 如果不暂停业务线程,对象会被错误的垃圾回收!
typescript
public class TestGC {
private static Object target = new Context();
public static void main(String[] args) {
Object temp = null;// 第一步
temp = target; // 第二步
target = null;// 第三步
//使用temp 变量
temp.toString();// 第四步
}
}
以上代码中,声明了 target static静态常量,引用了Context类型对象。因为被常量引用,target在GC Root上。也就是说垃圾回收时,会以target为根,开始遍历。正常情况下target引用的对象不会被回收...... 但如果不暂停业务线程后,Context对象会被错误回收!
在main方法中一共有 4 步。
首先 第一步:定义 temp变量为 null;
第二步:将target引用赋值给 temp;
第三步:将target 变量指向null,此时Context对象只被 temp 变量引用。
最后一步第四步,调用temp.toString();
接下里我开始分析,假设开启垃圾回收时,不暂停业务线程,垃圾回收线程和业务线程一起并发执行,会有哪些潜在的坑点!
为了清晰期间,我使用表格来表示时间线。在此例中,两个线程为 main 线程和垃圾回收标记线程。
执行时序 | main线程 | 垃圾回收标记线程 |
---|---|---|
1 | 执行到 temp=null | |
2 | 开始以main线程的虚拟机栈为GC Root,发现虚拟机栈中没有引用到 Context 对象 | |
3 | 执行到 temp = target | |
4 | 执行到 target = null | |
5 | 开始标记,target静态变量为GC Root,但发现target 变量为空,那么原Context对象不可达 | |
6 | 垃圾回收结束,原Context对象,发现不可达,被垃圾回收 | |
7 | 原对象被错误的回收,执行到 temp.toString() 结果不可预知 |
通过上图的分析,我们发现,原Context对象,在被其他变量引用的情况下,被错误的垃圾回收,造成了不可预测的情况发生。
原因是,当垃圾回收线程检查 main 线程时,发现无法通过 main 线程的虚拟机栈引用 Context 对象。这是因为 temp 还未被赋值。于是,垃圾回收线程转而遍历 target 变量。在此时间窗口内,main 线程从 target 变量中获取到 Context 对象的引用,并将 target 变量设置为null。回到垃圾回收线程,它检查到 target 变量为null。在垃圾回收线程看来,无论是main线程还是 target 变量,都没有引用到 Context 对象。因此,垃圾回收器回收了 Context 对象。然而,main 线程中的 temp 变量仍然持有 Context 对象。在这种情况下,对 Context 对象的任何操作都将变得不可预测。
这个过程略微有些混乱,可以通过参考时间线表格来更好地理解。
此时再去理解 JVM官方文档给的原因
分析工作必须在一个能确保一致性的快照中进行,一致性指整个分析期间整个执行系统像被冻结在某个时间点上
正是因为 main 线程和垃圾回收线程同时执行,导致垃圾回收在分析 main 线程的虚拟机栈和target变量时,并没有在一个冻结的时间上,而是在先后的两个时间点分析对象是否可达。在先后时间的窗口期内,Context 对象被赋值给其他对象,但是垃圾回收线程对此毫无感知...... 最终当 Context 被错误回收以后,业务线程访问 Context时,将出现极其诡异且致命的问题......
通过反证法,我们证明出,如果不暂停业务线程,那么无法进行准确的垃圾回收工作。
其他例子
还有其他例子可以佐证。
例如只有两行代码 的一段程序。
Context temp = null;
temp = new Context();`
main 线程中,创建一个新的 Context对象,此时只有 temp 变量持有 Context对象,恰好垃圾回收线程在遍历 main 线程的 虚拟机栈时, temp变量还为null;
于是在垃圾回收标记完成后,发现Context对象不可达,于是被当成垃圾回收了......
如果不暂停业务线程,在垃圾回收期间新创建的对象,有可能会被错误的回收掉,这真的太可怕了。
之所以可能出现这么离谱的现象, 原因就在于,业务线程和垃圾回收线程并行执行,谁也无法预知 垃圾标记的工作和 引用关系的变化 谁先谁后。
只有当业务线程被暂停,才能保证垃圾回收的标记是准确的
假如业务线程被暂停,还会有问题吗?
第二个例子中,假设在 第一行代码后 Context temp = null;
业务线程被暂停。由于 Context对象还未创建,所以不会有对象被回收。
假设在 第二行代码执行后,被暂停。由于 main线程 temp 变量还持有 Context对象引用,所以 Context对象不会被垃圾回收。
回到第一个表格的代码,当main线程被暂停后,无论被暂停在 哪一行代码,垃圾税收标记工作都不会出现任何问题。读者可以自行假设论证一下。
总结
通过 列举两个反例,通过反证法证明 业务线程必须被暂停,才可以进行垃圾回收标记工作。
4年前的面试,我被问到这个问题时,我的破解思路是,有两个业务线程,互相修改引用关系,垃圾回收判断垃圾对象,会出现错误。但是两个业务线程的场景,实在复杂,我无法举出实际的例子。其实 只需要一个main线程 和垃圾线程对比分析,就能说明问题,根本不需要两个业务线程来证明这个问题。
一个线程尚且出问题,由此可见,业务逻辑千奇百怪,当存在上千个业务线程时,如果不暂停业务线程,就进行垃圾回收,该多么可怕!
一般情况下,在聊GC时,没有面试官会深入到 为什么需要Stop the world
这类问题,但是阿里的面试官独辟蹊径,成功的卷到我。
这个问题虽然看起来简单,但真要现场举例说明,还是有难度的!
我当时没有回答上来这个问题,一度以为被挂掉,但最终这轮技术面试还是通过了...... 也是一个惊喜