4

JVM 锁 bug 导致 G1 GC 挂起问题分析和解决

 2 years ago
source link: https://my.oschina.net/openeuler/blog/5130915
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
JVM 锁 bug 导致 G1 GC 挂起问题分析和解决

编者按:笔者在 AArch64 中遇到一个 G1 GC 挂起,CPU 利用率高达 300%的案例。经过分析发现问题是由 JVM 的锁机制导致,该问题根因是并发编程中没有正确理解内存序导致。本文着重介绍 JVM 中 Monitor 的基本原理,同时演示了在什么情况下会触发该问题。希望通过本文的分析,读者能够了解到内存序对性能、正确性的影响,在并发编程时更加仔细。

本案例是一个典型的弱内存模型案例,大致的现象就是 AArch64 平台上,业务挂死,而进程占用 CPU 持续维持在 300%。配合 top 和 gdb,可以看到是 3 个 GC 线程在 offer_termination 处陷入了死循环:

15282afb-79cf-4ada-8d0b-5e8005100023.png

多个并行 GC 线程在 Minor GC 结束时调用 offer_termination,在 offer_termination 中自旋等待其他并行 GC 线程到达该位置,才说明 GC 任务完成,可以终止。(关于并行任务的中止协议问题,可以参考相关论文,这里不做着重介绍。

简单地说,在并行任务执行时,多个任务之间可能存在任务不均衡,所以 JVM 内部设计了任务均衡机制,同时必须设计任务终止的机制来保证多个任务都能完成,这里的 offer_termination 就是尝试终止任务)。

在该案例中,部分 GC 线程完成自己的任务,等待其他的 GC 线程。此时出现挂起,很有可能是因为发生了死锁。所以问题很可能是由于那些尚未完成任务的 GC 线程上错误地使用锁。所以使用 gdb 观察了一下其他 GC 线程,发现其他 GC 线程全都阻塞在一把 JVM 的锁上:

ca66267d-a054-4e7b-8c2a-7005e09636ba.png

而这把 Monitor 中的情况如下:

  • cxq 上积累了大量 GC 线程
  • OnDeck 记录的 GC 线程已经消失
  • _owner 记录的锁持有者为 NULL

在进一步分析前,首先普及一下 JVM 锁组件 Monitor 的基本原理,Monitor 类主要包含 4 个核心字段:

  1. “Thread * volatile _owner” 字段指向这把锁的持有线程
  2. “SplitWord_LockWord” 字段被设计为 1 个机器字长,目的是为了确保操作时天然的原子性,它的最低位被设计为上锁标记位,而高位区域用来存放 256 字节对齐的竞争队列(cxq)地址
  3. “ParkEvent * volatile_EntryList” 字段指向一个等待队列,跟 cxq 差别不大,个人理解只是为了缓解 cxq 的竞争压力而设计
  4. “ParkEvent * volatile_OnDeck” 字段指向这把锁的法定继承人,同时最低位还充当了内部锁的角色

接下来通过一组流程图来介绍加解锁的具体流程:

eefb9954-b108-41e1-a90a-5345619bd2a5.png

上图是加锁的一个整体流程,大致分为 3 步:

首先走快速上锁流程,主要对应锁本身无人持有的最理想情况

78934b87-dd06-4d52-9f59-c76dace4f4fd.png

接着是自旋上锁流程,这是预期将在短时间内获取锁的情况

a170e3e7-d26b-46f4-ad3e-62c25c03379e.png

最后是慢速上锁流程,申请者将会加入等待队列(cxq),然后进入睡眠,直到被唤醒后发现自己变成了法定继承者,于是进入自旋,直到完成上锁。

42a6ed85-ac52-4582-bee8-2411967220ba.png

而且,基于性能考虑,整个上锁流程中的每一步几乎都做了“插队”的尝试:

7c1b777b-d90a-4205-b7d6-23e44761e836.png

如上图代码中所示,“插队”的意思就是不经过排队(cxq),直接尝试置上锁标志位。

204e620d-22a1-4e01-a20d-a4cc100534e0.png

上图就是整个解锁流程了,显然真正的解锁操作在第二步中就已经完成了(意味着接下来时刻有“插队”现象发生),剩下的主要就是选出继承者的过程,大致分为以下几步:

  1. 解锁线程首先需要将内部锁(_OnDeck)标记上锁
  2. 从竞争队列(cxq)抽取所有等待者放入等待队列(_EntryList)
  3. _ EntryList 取出头一个元素,写入_OnDeck 的同时解除内部锁标记,这代表选出了继承者
  4. 唤醒继承者

当然伴随着整个解锁流程每一步的,还有对“插队”行为的处理。

至此,JVM 锁组件 Monitor 的原理就介绍到这里,再回归到问题本身,一个疑问就是_OnDeck 上记录的继承者为何消失?作为继承者,既然已经消失在竞争队列和等待队列里,显然意味着它大概率已经持有锁、然后解锁走人了,所以问题很可能跟继承者选取过程有关。基于这种猜测,我们对相关代码着重进行了梳理,就发现了下图两处红框标记位置存在疑点,那就是在选继承者过程第 3 步中:

fe791678-2121-4344-832b-7ffc6255d15b.png

EntryList 和写_OnDeck 之间没有 barrier 来保证执行顺序,这可能出现_OnDeck 先于EntryList 写入的情况,一旦继承人提前持有锁,后果就可能非常糟糕…

6efcc956-96f5-44a2-b0ae-021ce23dd0ad.png

这里贴了一张可能的问题场景:

  1. 线程 A 处于解锁流程中,由于乱序,先写入了继承者同时解除内部锁
  2. 线程 B 处于上锁流程,发现自己就是法定继承者后,立刻完成上锁
  3. 线程 B 又迅速进入解锁流程,并从_EntryList 中取出头元素(也就是线程 B!)作为继承者写入_OnDeck,完成解锁走人
  4. 线程 A 此时才更新_EntryList,然后唤醒继承者(也就是线程 B!),完成解锁走人
  5. _OnDeck 上的继承者线程 B,实际已经完成加解锁离开,后续等待线程再也无法被唤醒。
正巧在社区的高版本上找到了一个相关的修复记录,这里贴出 2 个关键的代码片段:

7dafa5c9-f2c1-4058-8d6e-5fba99c5ede1.png

上面这段代码位于慢速上锁流程,被唤醒后检查继承者是否是自己,修复后的代码在读_OnDeck 时加了 Load-Acquire 的 barrier。

63cb764d-c7d5-4e31-bf97-84f6f89c0191.png

上面这段代码位于解锁时选继承者流程,从_ EntryList 取出头一个元素,写入_OnDeck 的同时解除内部锁标记,修复后的代码在写_OnDeck 时加了 Store-Release 的 barrier。

显然,围绕_OnDeck 添加的这对 One-way barrier 可以确保:当继承者线程被唤醒时,该线程可以“看”到_EntryList 已经被及时更新。

在 AArch64 这种弱内存模型的平台上(关于内存序更多的知识在接下来的分享中会详细介绍),一旦涉及多线程对公共内存的每一次访问,必须反复确认是否需要通过 barrier 来严格保序,而且除非存在有效的依赖关系,否则 barrier 需要在读写端成对使用。

如果遇到相关技术问题(包括不限于毕昇 JDK),可以通过毕昇 JDK 社区求助(目前毕昇 JDK 最新的官网http://bishengjdk.openeuler.org已经上线,可以点击原文进入官网查找所有相关资源,包括二进制下载、代码仓库、使用教学、安装、学习资料等)。

毕昇JDK社区每双周周二举行技术例会,同时有一个技术交流群讨论GCC、LLVM、JDK和V8等相关编译技术,感兴趣的同学可以添加如下微信小助手,回复Compiler入群。

7f2cad0e-d3be-4efb-b146-d52b903178ad.png


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK