一款曾让我迷惑一个月的 bug:JVM、编译器和 CPU,究竟谁是卧底?

原文标题:JVM/编译器/CPU,究竟谁是卧底?一个曾经困扰我一个月的 bug

原文作者:阿里云开发者

冷月清谈:

- 问题描述:一个 JVM bug 导致测试挂掉,甚至让 Java 崩溃,通过测试排除问题可能出在 GC 和编译器上。 - 二分查找:通过 git 二分查找第一个出现问题的提交,发现问题源于 GCC 中编译选项的更改;通过文件二分定位到出错的文件。 - 汇编代码对比:对比修改前後的汇编代码,发现问题出在 CAS 操作少了两条 sext.w 指令,导致 RISC-V 上的 GCC 生成了错误的代码。 - 卧底揭晓:问题根源是 GCC 中的 bug,通过提交 bug 报告并使用汇编代码重写 CAS 实现临时解决问题。 - 进一步分析:GCC 生成的错误代码导致线程同步问题,从而引发 JVM 崩溃和测试失败。 - 解决方法:在 JDK 中使用汇编代码重写 CAS 实现,并提交补丁修复 GCC 中的 bug。 - 复盘:通过收集信息、假设验证、定位问题、复现问题、解决问题的科学流程最终定位并解决了问题,表明即使是复杂的系统,通过细致分析也可以逐个击破。

怜星夜思:

1、这个 bug 为何仅在 RISC-V 架构上出现?
2、如何一步步定位到出错的文件?
3、为什么添加条件判断后问题就消失了?

原文内容

阿里妹导读


任何复杂的系统都可能因为一个小小的疏漏而无法运转,本文记录了一个困扰作者一个月的 bug 最终拨云见日的过程。

假设你是一个 Java 程序员,但你早已厌倦了什么 Java 8 什么 CMS GC 什么 SSM。某天你心血来潮,在自己的小破开发机里装了最新版的 JDK,用上了潮到没边的 Shenandoah GC,抄起键盘起手就是 hello world 一把梭,结果发现你写的程序居然跑不了——甚至还把 Java 搞崩溃了,现场只剩下 log、coredump 和一地鸡毛。

你会怀疑是不是自己太长时间没接触 Java 新特性,居然写不出一个能跑的程序……

还是说,你会觉得是 Java 本身,甚至是编译 JVM 的那个编译器、OS、CPU 里出现了更离谱的 bug 呢?

0x00 发生什么事了?

我的日常工作之一是给 OpenJDK 上游修 bug。某天我百无聊赖,用最新 build 出来的 JDK 在 RISC-V 平台跑测试,发现 tier1 测试居然挂了一大片。

给不了解 OpenJDK 的朋友们解释一下:JDK 仓库公开的测试按重要程度分为 tier1 到 tier4 四个级别[1],tier1 是其中优先级最高的。如果 tier1 中有测试挂掉,说明目前的 JDK 即使是跑一些很常规的 Java 程序,也有很大概率出问题。像这种 tier1 一挂一大片的情况,基本说明目前这个 JDK 在某些功能上已经是个残废了。
我之前还从来没见到过这样的现象,只觉得这背后必有蹊跷。
当然,既然我们已经发现了这个问题,不如就地把它解决掉,顺便在等编译的时候带薪摸鱼。

0x01 缩圈

考虑到 OpenJDK 的代码量相当之大,涵盖的内容相当之丰富,构建流程横跨 C、C++、Java 等多种语言,试图通过干瞪眼发现问题基本是不现实的。因此,我们需要从挂掉的测试出发,尝试更多触发错误的方式,通过进一步分析,尽可能缩小问题的范围。

图片

首先,从测试结果来看,所有挂掉的测试都和 Shenandoah GC [2]有关。这其中,最简单的测试是 TestSmallHeap.java[3],它的实现只有短短五行:
public class TestSmallHeap {
public static void main(String[] args) throws Exception {
System.out.println("Hello World!");
}
}

这……这对吗?我没在开玩笑,在某些情况下,目前的这个 JDK 还真就连 hello world 都跑不起来。

当然,这个测试实际上检查的还是 GC,而不是 hello world。测试会把堆内存改到一个很小的值,比如 4MB,从而触发 GC——是的,Java 连输出 hello world 都需要 GC。如果这个测试崩掉,说明 Shenandoah GC 的实现很大概率是有问题的。为了验证这个猜想,我们可以挑几个挂掉的测试,加些参数重跑一下:
  • 添加 -Xint,禁用 JIT 编译,解释执行 Java 程序:测试还是会挂,说明出问题的地方和 JIT 编译(即 HotSpot C1/C2 相关的实现)无关。

  • 添加 -XX:ParallelGCThreads=1,把 GC 设为单线程:测试还是会挂,说明出问题的地方不在 GC 线程之间(但依然可能在 GC 线程和其他线程之间)。

  • 通过参数指定其他 GC,比如 -XX:+UseG1GC测试正常,问题消失。
这基本确定了出问题的地方就在 Shenandoah GC 相关的实现里。
其次,这个问题在除 RISC-V 之外的其他架构上也存在吗?我尝试在 x86-64 和 aarch64 上使用同样的源码构建了 JDK,并没有复现这个问题,这表明这个问题很可能是 RISC-V 实现特有的。
此外,用来跑测试的是 release mode 构建的 JDK。经过一些尝试,我发现如果换到 fastdebug mode,问题就会消失。这说明可能是 release mode 相比 fastdebug mode 在编译选项上的差异导致生成的 JDK 出现了问题。
那么 fastdebug 和 release 的编译选项有哪些不同呢?直接看 JDK 的编译脚本基本就别想了,因为这个巨型项目里光 Makefile 就有几万行代码。一个最简单的办法是,使用 make -n 直接输出编译命令行,然后检查编译 JVM 核心组件的编译命令之间的差异:
--- release.log   2024-02-14 09:31:04
+++ fastdebug.log 2024-02-14 09:36:25
...
@@ -11,13 +11,13 @@
-fno-lifetime-dse
-Wno-format-zero-length -Wtype-limits -Wuninitialized
--DNDEBUG
--DPRODUCT
+-DASSERT
+-DCHECK_UNHANDLED_OOPS
-DTARGET_ARCH_riscv
-DINCLUDE_SUFFIX_OS=_linux
-DINCLUDE_SUFFIX_CPU=_riscv
...

对比发现,fastdebug 除了比 release 多调整了一些诸如 PRODUCTASSERT 这类控制调试的宏定义开关之外,还额外定义了 CHECK_UNHANDLED_OOPS 宏。后续测试表明,如果我们给 release 也定义这个宏,之前的问题就会消失。

那么问题来了,这个宏会影响哪些实现呢?在代码中搜索,我们发现它主要控制了 oopsHierarchy.hpp [4]中和 oop 相关的实现。在 HotSpot JVM 中,oop 指的是 “ordinary object pointer”[5],实际上就是一个指向 Java 对象的指针——更确切地说,oop 指向的是 Java 对象头部的元数据,其中包含了 mark word、类的描述信息等内容,之后才是对象本身的内容。这其中,mark word 里的某些位会被 GC 用来标记对象,所以这部分实现确实也能和 GC 扯上关系,很合理。
默认情况下,oop 的定义就是简单的 C++ 指针:
typedef class oopDesc* oop;
而开启 CHECK_UNHANDLED_OOPS 之后,原本的指针定义会被换成一个重载了 ->* 运算符的类:
class oop {
oopDesc* _o;
void check_oop() const { if (check_oop_function != nullptr && _o != nullptr) check_oop_function(_o); }
void on_usage() const  { check_oop(); }

public:
oopDesc* obj() const { on_usage(); return _o; }
oopDesc* operator->() const { return obj(); }
operator oopDesc* () const { return obj(); }

};

这么做是为了在解引用指针时,插入额外的检查操作。然而,除非通过参数手动开启,这个检查永远不会执行。

那就奇怪了,这岂不是相当于,我们只要在每次 oop 解引用之前,都插入一个条件判断,这个问题就会消失?我们可以分别验证:
  • 开启 CHECK_UNHANDLED_OOPS,但删除其中的所有检查:问题出现。

  • 开启 CHECK_UNHANDLED_OOPS,只在解引用操作中添加检查:问题消失。
我去,还真是,这也太怪了。那会是什么导致了这个问题呢?由于 include oopsHierarchy.hpp 的文件少说也有一百多个,每个文件里更是出现了不止一次解引用操作,此后的一段时间里,我始终一头雾水,同时一无所获。
好在,虽然还是没能找到问题的原因,但通过之前的尝试,我们已经可以确定,这个问题:
  • 和 JIT 无关,只和 Shenandoah GC 相关,且不涉及多线程 GC 的部分。

  • 只和 RISC-V 架构相关。

  • oop 的实现相关。

  • 不像是个常规问题,因为多加一些条件判断,问题就会消失。

0x02 coredump 最没用的一集

除了分析测试结果和源码实现,还有一个更重要的工具我们目前没有用到——coredump。
JVM 在崩溃时,会输出错误日志,同时生成问题现场的 coredump。通过分析 coredump,我们可以检查程序里每个线程的调用栈,以及程序执行时的寄存器、内存状态。一般来说,对于简单的问题,只需要看一眼 coredump,一切都会迎刃而解。
但坏就坏在这个问题不简单。
从错误日志来看,导致 JVM 崩溃的直接原因几乎全是 SIGSEGV,即段错误[6],也就是说 JVM 此时访问了一个无效的内存地址。几个 coredump 都可以印证这一点,比如其中的一个:
#22 <signal handler called>
#23 Klass::is_instance_klass (this=0xb8547061) at src/hotspot/share/oops/klass.hpp:643
#24 oopDesc::is_instance (this=0xffcc0c08) at src/hotspot/share/oops/oop.inline.hpp:205
#25 ShenandoahMark::do_task<ShenandoahMarkRefsClosure, (StringDedupMode)0> ...
at src/hotspot/share/gc/shenandoah/shenandoahMark.inline.hpp:73
...

backtrace 表明 JVM 在 Klass::is_instance_klass 里崩掉了,尝试看一下发生了什么:

(gdb) x/i $pc
=> 0x3fa0ac1fc4 <_ZN14ShenandoahMark7do_task...+108>:    lw      a4,12(a2)
(gdb) x/g $a2 + 12
0xb854706d:     Cannot access memory at address 0xb854706d

看起来确实是段错误,出问题的内存地址也和错误日志记录的地址相符。但很奇怪的是:这明明是一条 lw 指令,内存地址的结尾为什么是 d

在 RISC-V 中,lw 指令表示从内存中读出一个 4 字节的数据,而地址 0xb854706d 显然没有对齐 4 字节的边界。虽然 RISC-V 的 ISA 手册提到[7],处理器在实现上可以选择支持非对齐访存,但通常为了提升性能和兼容性,编译器(包括 JVM)不会生成非对齐的访存。
而在另一个 coredump 中,出问题的位置却和上一个完全不同。
#23 <signal handler called>
#24 0x0000003f87bdce7c in ?? ()

通常,如果 JVM 的 coredump 里出现了问号,这说明程序正在执行 Java 方法在运行时生成的代码,而不是 C/C++ 代码。错误日志里的 stack trace 表明确实如此:

Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
j  java.util.ArrayList.add(Ljava/lang/Object;[Ljava/lang/Object;I)V+0 java.base@23
j  java.util.ArrayList.add(Ljava/lang/Object;)Z+20 java.base@23
j  java.lang.invoke.InvokerBytecodeGenerator.classData(Ljava/lang/Object;)Ljava/lang/String;+165 java.base@23
...

检查当前指令所做的操作:

(gdb) x/i $pc
=> 0x3f87bdce7c:        ld      a0,208(a0)
(gdb) x/g $a0 + 208
0x866e623c:     Cannot access memory at address 0x866e623c

和前一个 coredump 类似,程序使用 ld 访问了一个无效的内存地址,同时这个地址也没有对齐 8 字节的边界。

如果说前两个 coredump 中,程序访问的地址看起来还像那么回事(虽然我们一眼就能看出这些地址显然不符合 64-bit Linux 通常的虚拟地址格式),那第三个 coredump 中,程序访问的地址就非常奇怪了:
(gdb) x/i $pc
=> 0x3fb7385926 <_ZN7oopDesc4sizeEv+118>:       ld      a5,256(a5)
(gdb) x/g $a5 + 256
0x100:  Cannot access memory at address 0x100

居然是 0x100。黑色幽默的是,这次的地址倒是按照 8 字节对齐了。

不过,我们还是能发现一些端倪。这个 coredump 的 backtrace 如下:
#8  <signal handler called>
#9  0x0000003fb7385926 in oopDesc::size_given_klass ... at src/hotspot/share/oops/oop.inline.hpp:196
#10 oopDesc::size_given_klass ... at src/hotspot/share/oops/oop.inline.hpp:156
...
#13 0x0000003fb78c3ac0 in ShenandoahMark::mark_loop_work<ShenandoahMarkRefsClosure, true, (StringDedupMode)0> ...
at src/hotspot/share/gc/shenandoah/shenandoahMark.cpp:178
...

虽然出问题的地方依然和前两个 coredump 完全不同,但我们注意到,其中出现了对 ShenandoahMark 类方法的调用,而第一个 coredump 的调用栈里也出现了类似的内容——这说明,JVM 正在执行 Shenandoah GC 的 concurrent marking 阶段。

作为一种低暂停时间的 GC,Shenandoah GC 工作流程里的大部分阶段都可以并行于 Java 应用执行,concurrent marking 阶段也不例外:在这个阶段中,GC 会从根集合(root set)出发,并行地扫描堆内存,标记可达的对象,为后续的内存清理工作收集信息。
因此,从 coredump 里,我们只能得出以下的结论:
  • JVM 崩溃的直接原因是访问了无效的内存地址。

  • JVM 每次崩溃的位置都不相同。

  • 崩溃时,Shenandoah GC 正在并行标记 Java 对象。
以上种种迹象表明,我们遇到了一个比较奇怪的多线程同步问题。
江湖上有这样一个传说:左转的红灯最难等,多线程的问题最难调。不过,江湖上还流传着这样的故事:一个 bug,只要你能稳定复现,你就已经成功 de 出了一半。脑海中不断回想这两个似乎有些矛盾的金句,我却站在原地,不知从何下手。
至此,我们彻底走进了一个死胡同。

0x03 谁是卧底?先排除几个

分析到这里,我隐约觉得,这个问题除了和 JVM 相关,还很有可能和编译 JVM 的 GCC、运行 JVM 的操作系统,甚至于运行所有软件的那个 RISC-V CPU 有关系。
为什么呢?从前两节我们可以得知:
  1. 只要多加一个条件判断,问题就会消失。这说明,要么是 JVM 的代码里出现了 undefined behavior[8],导致 GCC 的某些优化对程序作出了非预期的更改;要么是 GCC 自己出了问题。

  2. 这个诡异的多线程问题只在 RISC-V 架构存在。RISC-V 的生态目前还不成气候,各种软硬件实现鱼龙混杂,同时 ISA 标准飞速演进——这导致一些实现使用了旧特性,一些实现夹带了私货,还有一些实现和标准相左。这些软硬件实现组合在一起,出一些奇怪的 bug,反而是最不奇怪的事情。

  3. 我急了,想甩锅。
这些猜想,有的很好验证,有的则相对比较难。比如 OS 和 CPU 的问题,我们只需要找一些其他的 RISC-V CPU 实现,再跑一些其他版本的 Linux,看能否复现同样的问题即可。
不过说实话,硬件实现不是那么好找,主要是还得多花钱,于是我把目光投向了 QEMU[9]。作为一个老牌 CPU 模拟器,QEMU 的维护者数以千计,RISC-V target 更是备受关注,所以它的实现质量应该不会太差。参考网上随处可见的教程[10],我们很快就可以在最新的 QEMU 上跑起一个相对较新 RISC-V Debian:
debian@debian:~$ uname -a
Linux debian 6.6.13-riscv64 #1 SMP Debian 6.6.13-1 (2024-01-20) riscv64 GNU/Linux

然后,只要在这个环境里用 JDK 跑之前挂掉的测试即可。需要注意的是,QEMU 模拟执行的速度非常慢,跑个测试还凑合,但千万别在里面构建 JDK。

最后,果然不出所料:之前挂掉的测试,在 Debian + QEMU 里依然会挂。
虽然从逻辑上讲,不同的 OS + CPU 的组合如果不能复现问题,则一定可以说明这个问题和特定 OS + CPU 有关;反之,如果可以复现,则不能说明这个问题和特定实现一定无关,只能说无关的概率比较大。即便如此,这个现象依然给我们提供了一个参考方向:相比于 OS 和 CPU 出问题的可能,此后我们应该更专注于检查 JVM 和 GCC 的问题。
Man, what can I say? OS and CPU out!

0x04 二分,启动!

接下来,如何确定 JVM 和 GCC 到底谁出了问题呢?在 HotSpot JVM 的几百万行 C++ 代码里一行一行找,无异于大海捞针。
当我们不知道问题到底出在什么地方的时候,我们可以用一个手段快速缩小问题的范围——二分,前提是我们假设问题只和某一个关键点相关。在这个问题上,二分有两种思路:
  1. 二分 git 的提交记录,找到第一个出现这个问题的提交。

  2. 二分 include oopsHierarchy.hpp 的那一百多个源代码文件,找到问题出在哪个文件里。
反正现在也没什么别的好办法,不如先这么做试试看。
二分提交记录很简单,git 为我们提供了 git bisect 命令,直接用即可。一通操作后,我们发现,引入问题的提交是 287b243[11],这个提交从 JVM 的编译选项中删掉了 -fno-delete-null-pointer-checks。尝试在最新的 JDK 中添加这个编译选项,问题消失。
好起来了,看起来确实很像 undefined behavior。但我们不能通过简单地把这个选项加回去来修复这个 bug,因为我们还没有找到这个 bug 的具体原因——到底是哪段代码导致了这个问题?
这就需要二分确定出问题的文件。首先,我们知道,开启 CHECK_UNHANDLED_OOPS 宏时,问题消失,关闭宏,问题出现。而通过编译选项定义宏,会同步影响所有依赖 oopsHierarchy.hpp 的实现,进而影响依赖这些实现的其他实现……以此类推。

这不是我们希望看到的。因此,我们可以手动修改代码,只在依赖 oopsHierarchy.hpp 的文件中定义 CHECK_UNHANDLED_OOPS 宏,二分确定唯一的那个开了会导致问题的文件(并祈祷真的只有一个文件会导致问题)。然后,二分所有依赖这个文件的其他文件……直到找到最后的那个文件。
这个过程比较机械,也比较折磨。不过好在每次都可以排除一半的选项,JDK 也可以快速地增量编译,最主要的是每次你都会离真相更进一步,以及等编译的时候可以刷一会手机。
终于,经过一通乱刨,我们发现,出问题的文件是 shenandoahHeap.cpp。太好了,胜利的曙光就在前方……吗?
看着眼前这个 2000 多行代码的 C++ 文件,我陷入了沉默。

0x05 不是办法的办法

之前提到的二分定位法,只能确定出问题的文件,而没办法精确到文件里的特定函数,甚至特定的行。
当然,你可以说,把这个文件里每个函数都分别拆分到不同的文件里,然后继续二分,不就能定位到函数级别了吗?确实,但这有点麻烦,而且你也不好说你这么改之后会不会出别的问题,搞不好这样还需要修改编译脚本。
不过,还有另一种思路:既然我们已经能把问题的影响范围限制在一个文件里,那我们干脆直接对比修改这个文件前后生成的汇编代码,看哪些指令发生了变化,不就能一步到位,确定到出问题的行了吗?反正变化的地方只有这个文件,理论上不会影响太多东西。
说干就干,我们可以写一个脚本(或者偷懒让 AI 写):
  • 读取 JVM 的核心实现,也就是 libjvm.so 的反汇编输出。
  • 解析其中的内容,提取所有函数的名称,以及对应的汇编指令序列。为了降低误报率,我们只提取汇编指令的操作码,也就是诸如 ldadd, beq 等,而忽略所有的操作数。
  • 比较修改前后反汇编的差异,找出变化了的函数和指令序列。
最后的最后,我们发现,问题出在 ShenandoahHeap::conc_update_with_forwarded [12]方法中。有问题的实现是这样的:
0000000000ab3200 <_ZN14ShenandoahHeap26conc_update_with_forwardedI9narrowOopEEvPT_>:
...
ab3254: 0ff0000f    fence
ab3258: 00c7d7b3    srl     a5,a5,a2
ab325c: 00c75733    srl     a4,a4,a2
ab3260: 1005a6af    lr.w    a3,(a1)
ab3264: 00f69563    bne     a3,a5,ab326e
ab3268: 18e5a62f    sc.w    a2,a4,(a1)
ab326c: fa75        bnez    a2,ab3260
ab326e: 0ff0000f    fence
ab3272: 6422        ld      s0,8(sp)
...

而正确的实现是这样的:

0000000000ab2ec0 <_ZN14ShenandoahHeap26conc_update_with_forwardedI9narrowOopEEvPT_>:
...
ab2d08: 00c7d7b3    srl     a5,a5,a2
ab2d0c: 00c75733    srl     a4,a4,a2
ab2d10: 2781        sext.w  a5,a5
ab2d12: 2701        sext.w  a4,a4
ab2d14: 0ff0000f    fence
ab2d18: 1005a6af    lr.w    a3,(a1)
ab2d1c: 00f69563    bne     a3,a5,ab2d26
ab2d20: 18e5a62f    sc.w    a2,a4,(a1)
ab2d24: fa75        bnez    a2,ab2d18
ab2d26: 0ff0000f    fence
ab2d2a: 6422        ld      s0,8(sp)
...

发现了吗?正确实现里多了两条 sext.w 指令!如果我们用 16 进制编辑器修改正确的 libjvm.so,把这两个 sext.w 替换成空操作,然后再跑测试,就会出现相同的问题!

从这段代码要做的事情来看,少两条 sext.w 确实会出问题。这个循环实际上要做的是一个典型的 CAS(Compare-and-Swap)操作[13],也就是读出内存里的值,和一个给定值进行比较,如果一致,则将另一个新值写入内存,整个过程要原子地完成,不能被其他线程打断。
事实上,以上那个错误的实现和 RISC-V ISA 手册里提供的示例[14]如出一辙,但其中忽略了一个重要的步骤:
ab3258: 00c7d7b3    srl     a5,a5,a2
ab325c: 00c75733    srl     a4,a4,a2
ab3260: 1005a6af    lr.w    a3,(a1)
ab3264: 00f69563    bne     a3,a5,ab326e

根据 ISA 手册,lr.w 返回的结果,即 a3 里的内容,是符号扩展的;之后的 bne 指令将 a3a5 进行了比较;而 a5 的值又来自于之前的 srl 指令,即逻辑右移——众所周知,逻辑右移会将移动后多出来的高位全部写成 0。

也就是说,如果很不幸,内存里数据的第 31 位是 1,则 a3 里的高 32 位全为 1。而在 a2 不为 0 的情况下,a5 的最高几位永远不可能为 1,此时,bne 指令的条件一定会成立,这和 CAS 操作的初衷相悖。当 CAS 操作实现错误时,线程之间自然会出现同步问题,这与之前我们观测到的现象相符。

0x06 卧底居然是……?

找到了问题所在的位置,我们就可以尝试在 GCC 上复现问题了。HotSpot JVM 中,出问题的代码简化后如这个链接[15]所示。或者,我们还可以进一步简化:
void foo(uint32_t *p) {
uintptr_t x = *(uintptr_t *)p;
uint32_t e = !p ? 0 : (uintptr_t)p >> 1;
uint32_t d = x;
__atomic_compare_exchange(p, &e, &d, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED);
}

对应的 Compiler Explorer 链接请看这里[16]

令人意外的是,这段代码中并没有出现 undefined behavior,但 GCC 生成的结果却出现了问题。与之相比,Clang 生成的结果看起来非常正常,并没有出现少一条 sext.w 的情况。此外,根据我们之前的排查,在添加 -fno-delete-null-pointer-checks 选项之后,GCC 也可以生成正确的代码。
因此,这个问题很明显是 GCC 编译器中的 bug 导致的,卧底居然是 GCC!想不到你个浓眉大眼的也叛变了。
至于具体是什么原因导致 GCC 出现了这样的 bug,因为我对 GCC 的代码并不熟悉,所以没法给出一个确切的解释,估计是后端代码生成的逻辑出了什么问题。
专业的事情就要交给专业的人来解决,于是,我火速给 GCC 提交了一个 bug,见 Bug 114130[17]。GCC 这边也很给力,转眼就把 bug 修好了,前后只用了一天多点。
皆大欢喜,接下来我们看看 JDK 这边的问题要怎么修。

0x07 又水一个 patch

在 HotSpot 的代码中,CAS 操作的实现和操作系统与 CPU 都相关;具体到 Linux + RISC-V 的情况,这部分实现位于 atomic_linux_riscv.hpp[18] 文件的 142-166 行。这个实现基本就是 GCC 内置函数 __atomic_compare_exchange[19] 的封装,篇幅所限,具体代码这里就不贴了。
在 GCC 的 __atomic_compare_exchange 出 bug 的情况下,我们需要用内联汇编重新写一个相同的实现,核心代码如下:
__asm__ __volatile__ (
"1:  lr.w      %0, %2      \n\t"
"    bne       %0, %3, 2f  \n\t"
"    sc.w      %1, %4, %2  \n\t"
"    bnez      %1, 1b      \n\t"
"2:                        \n\t"
: /*%0*/"=&r" (old_value), /*%1*/"=&r" (rc_temp), /*%2*/"+A" (*dest)
: /*%3*/"r" ((int64_t)(int32_t)compare_value), /*%4*/"r" (exchange_value)
: "memory" );
可以看到,核心的四条汇编代码和之前我们反汇编得到的结果一致,但这里的重点在于对 compare_value 的处理:我们首先把它强制转换到 int32_t,接着立即将其转换到 int64_t。这样处理之后,GCC 会自行生成一个符号扩展操作,对应到最终的汇编代码里,就是多了一个 sext.w。此后,bne 将其与同样进行了符号扩展的 lr.w 的结果进行比较时,就不会出问题了。
修改完成,编译测试通过,愉快地给 OpenJDK 提交一个 pull request:JDK-8326936/PR18039[20]。和 reviewer 进行简短而友好的交流之后,这个 patch 就被成功 merge 到了 JDK 中。
完美谢幕!

0x08 复盘

在知道了问题的原因后,我们可以用最开始观察到的现象一一验证,发现一切都是能说得通的:
  • 问题只和 Shenandoah GC 相关:确实,问题出在 concurrent marking 阶段的同步操作上。

  • 只和 RISC-V 架构相关:确实,GCC 的 RISC-V 后端在实现上有问题。

  • oop 的实现相关:确实,进行 CAS 之前,必须先解码 compressed oop,得到正确的内存地址。

  • 多加一些条件判断,问题就会消失:确实,一旦分支变得复杂起来,GCC 的某些优化的结果就会发生变化,绕过这个 bug。

  • JVM 崩溃只因访问了无效的内存地址:确实,conc_update_with_forwarded 方法的目的是更新 forward 指针,这一步出问题,必然会导致后续步骤访问到一个无效的地址。

  • 每次崩溃的位置都不相同:确实,这个问题涉及线程同步,发生的时机并不确定。
而我们能在迷雾中一步步前行,最终发掘问题的真相,则少不了科学的方法论。我面对这个问题,采取了如下思路:
  1. 首先,尽可能收集故障现场的信息,确定问题的大方向。

  2. 其次,大胆假设,小心求证,在验证猜想中不断缩小问题的范围。

  3. 定位到问题之后,用尽可能短的实现去复现问题,最终找到问题的原因。
当然,上述思路仅供参考,如果你有更好的想法,欢迎在评论区讨论。
经此一役,我们明白了,任何复杂的系统都可能因为一个小小的疏漏而无法运转,任何复杂的问题都可以通过抽丝剥茧进而各个击破,任何我们常规认知里总是正确的软件也都难免在一些犄角旮旯里存在致命问题。
然而,关关难过关关过,步步难行步步行,至少从软件工程的角度出发,任何问题,经过一通昏天黑地的 debug 之后,最终几乎都能被妥善解决。
最后,祝大家每次遇到奇怪的 bug 时,都能一眼看穿事情的真相——我们通常把这种人叫做,一眼盯真。

参考链接:

[1]https://github.com/openjdk/jdk/blob/4dd6c44cbdb0b5957414fa87b6c559fa4d6f2fa8/doc/testing.md
[2]https://wiki.openjdk.org/display/shenandoah
[3]https://github.com/openjdk/jdk/blob/4dd6c44cbdb0b5957414fa87b6c559fa4d6f2fa8/test/hotspot/jtreg/gc/shenandoah/TestSmallHeap.java
[4]https://github.com/openjdk/jdk/blob/4dd6c44cbdb0b5957414fa87b6c559fa4d6f2fa8/src/hotspot/share/oops/oopsHierarchy.hpp
[5]https://wiki.openjdk.org/display/HotSpot/CompressedOops
[6]https://en.wikipedia.org/wiki/Segmentation_fault
[7]https://github.com/riscv/riscv-isa-manual/blob/main/src/rv32.adoc
[8]https://en.wikipedia.org/wiki/Undefined_behavior
[9]https://en.wikipedia.org/wiki/QEMU
[10]https://gist.github.com/apivovarov/98120ffb2d92f9dfce39925801271606
[11]https://github.com/openjdk/jdk/commit/287b24322135b54641f013970c4545ce069c4350
[12]https://github.com/openjdk/jdk/blob/4dd6c44cbdb0b5957414fa87b6c559fa4d6f2fa8/src/hotspot/share/gc/shenandoah/shenandoahHeap.inline.hpp
[13]https://en.wikipedia.org/wiki/Compare-and-swap
[14]https://github.com/riscv/riscv-isa-manual/blob/main/src/a-st-ext.adoc
[15]https://godbolt.org/z/eqrs18fcE?spm=ata.21736010.0.0.350816eaLREIYu
[16]https://godbolt.org/z/EPc35KsrG?spm=ata.21736010.0.0.350816eaLREIYu
[17]https://gcc.gnu.org/bugzilla/show_bug.cgi
[18]https://github.com/openjdk/jdk/blob/4dd6c44cbdb0b5957414fa87b6c559fa4d6f2fa8/src/hotspot/os_cpu/linux_riscv/atomic_linux_riscv.hpp
[19]https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html
[20]https://github.com/openjdk/jdk/pull/18039

RISC-V 架构的某些特性可能导致 GCC 生成的汇编代码出现问题,而在其他架构上不会出现。

通过手动修改代码,只在依赖 oopsHierarchy.hpp 的文件中定义 CHECK_UNHANDLED_OOPS 宏,然后二分确定导致问题的文件。

条件判断增加了执行路径的复杂度,可能导致编译器生成的代码发生变化,绕过了 bug。

条件判断确保了特定的代码路径不会被执行,从而避免了触发 bug。

因为 RISC-V 的生态系统还不够成熟,软硬件实现鱼龙混杂,包括 GCC 的 RISC-V 后端,可能存在问题。

分析错误日志和 coredump,结合对源码实现的理解,逐步缩小问题的范围,直到找到出错的文件。

RISC-V 的 ISA 手册和某些实现存在差异,编译器生成的代码可能无法正确处理符号扩展。

条件判断改变了数据流,导致编译器生成了不同的汇编代码,巧合地避开了 bug。

使用脚本或工具提取汇编代码并比较前后差异,从而定位到出错的文件。