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

RISC-V常用指令总结(附完整PDF) 在设计CPU的过程中,整理了RISC-V指令集,方便在设计和使用CPU的时候查询。 链接:https://pan.baidu.com/s/1q9ttXjjx2mJsTvg2ZJgjiQ 提取码:72xa CONTENT CONTENT Revision History 1 Register File 1.1 GPR 1.2 FGPR 1.3 Vector Registers 1.3.1 Vector CSRs 1.3.1.1 Vector type register (vtype) . 阅读详情

导读

任何复杂的系统都可能因为一个小小的疏漏而无法运转,本文记录了一个困扰作者一个月的 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 多调整了一些诸如 PRODUCT、ASSERT 这类控制调试的宏定义开关之外,还额外定义了 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 + 120xb854706d:     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@23j  java.util.ArrayList.add(Ljava/lang/Object;)Z+20 java.base@23j  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 + 2080x866e623c:     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 + 2560x100:  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 -aLinux 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 的反汇编输出。

  • 解析其中的内容,提取所有函数的名称,以及对应的汇编指令序列。为了降低误报率,我们只提取汇编指令的操作码,也就是诸如 ld,add, 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 指令将 a3 与 a5 进行了比较;而 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

i基准指令集 mips_ARM/MIPS/PowerPC/RISC-V 指令集快速入门 这里所介绍的指令集架构均属于 RISC,相比 CISC 更简单。但是 ARM 比较特殊,因为它作为一个 RISC 架构竟然有 push/pop 指令……ARMv7ARM 指令集从旧到新有 ARMv6、ARMv7、ARMv7s、ARMv8,使用新指令集的芯片也能支持旧的指令集,但是无法发挥出较好的性能。同时要注意,指令集版本不同的静态库是不能一起使用的。ARM 有两种工作状态,ARM 状态的指令长度... 阅读详情

相关推荐

【RISC-V开发学习系列】学习RISC-V官方提供的汇编指南

本文章主要介绍了RISC-V汇编指令的简要指南。

一个专注于嵌入式IoT领域的架构师,深耕IoT领域多年,深度掌握IoT领域的相关技术栈,包括但不限于RTOS内核的实现及其移植、硬件驱动移植开发、网络通讯协议开发、编译构建原理及其实现、底层汇编及编译原理、编译优化及代码重构、嵌入式IoT系统的架构设计等。 1万+

学习RISC-V指令 入门学习

1、RISC-V指令有六种指令格式: 用于寄存器-寄存器操作的 R 类型指令, 用于短立即数和访存 load 操作的 I 型指令, 用于访存 store 操作的 S 型指令, 用于条件跳转操作的 B 类型指令, 用于长立即数的 U 型指令 用于无条件跳转的 J 型指令 所有的指令:对于每一个图,集合标志{}内列举了指令的所有变体,变体用加下划线的字母或下划线字符_表示。 2、所有指令都是32位;RISC-V指令提供三个操作数寄存器,x86只有两个;RISC-V指令的所有的读写寄存器总是出现在指令的同

persimmon_xh的博客 2203

【TI毫米波雷达】IWR6843AOPEVM-G与DCA1000EVM实战:从硬件连接到数据流解析

本文详细解析了TI毫米波雷达开发套件IWR6843AOPEVM-G与DCA1000EVM的硬件连接、模式配置及数据流处理全流程。针对官方手册中的常见配置错误,提供了避坑指南和实测验证的正确设置方法,包括开关配置、网络参数等关键步骤。通过mmWave Studio软件配置雷达参数,并深入解析数据采集与处理流程,帮助开发者高效实现毫米波雷达应用开发。

weixin_30653023的博客 368

RISC-V 汇编程序编程练习

text:代码段,等效于“.section .text”。.globl myfac:定义一个全局符号 myfac。只要一起编译,在另一个文件也可以通过引用”myfac“跳转到myfac代表的地址.type myfac,@function :.type伪操作用于定义符号的类型,将名为myfac的符号定义为一个函数(function)。

Marvelllll的博客 3100

常见卫星图源下载教程

上面列出的8个常见的亚米级别数据源都可以通过xyz协议或者wmts/wms协议进行下载。这些协议是什么?地理信息数据为了方便网络传输,制定了一系列的地理数据传输标准(wms/wmts/xyz等)。协议特点优点缺点栅格数据支持矢量数据支持WMS通过HTTP请求获取GIS数据,服务器会将数据渲染成图像高度定制的地图;交互性强服务器渲染图像,响应时间较长支持有限支持WMTS提供预渲染的地图瓦片速度快,因为地图瓦片可以预先生成并在请求时快速提供瓦片的内容是预先确定的,无法进行定制支持。

锐多宝的地理空间 1万+

Android逆向分析

Android 逆向开发是指对已发布的 Android 应用进行分析和破解,以了解应用程序的内部工作原理,获取应用程序的敏感信息,或者修改应用程序的行为。Android应用 Java编写的,利用Android SDK编译代码,并并且把所有的数据和资源文件打包成一个APK(Android Package)文件这是一个后缀名为.apk的压缩文件,apk文件中包含了一个Anddroid应用程序的所有内容,是Android平台用于安装应用程序的文件。

qq_51598534的博客 6294

RISC-V 常用汇编指令集

RISC-V 常用汇编指令集

mjLlm的博客 1493

生信人填坑记 orthofinder的安装和基础使用

其中常用的有两个结果,第一个是Orthogroups文件夹下的Orthogroups.GeneCount.tsv,这个文件里保存着每个家族在每个物种里的成员数量。(写到这儿饭点到了,吃饭要紧…在国内使用conda的同学我相信很多人都会遇到-|- -|- -|- -|- -|- -|- -|- -|- 一直转圈。软件的是由很简单,将你要跑的物种的蛋白质文件放在work/data目录下,文件名都用物种缩写,后缀统一就行。当然,很多软件都会自带一个这样的文件,conda装不了的时候可以去软件官网溜达溜达。

qq_43337286的博客 5382

【无标题】

linux实验1

Tog 231

从ATE到ATPG:基于Scan Chain的芯片制造缺陷定位实战解析

本文深入解析了从ATE到ATPG的芯片制造缺陷定位实战技术,重点介绍了Scan Chain的工作原理、异步复位触发器处理技巧以及Design Compiler中的DFT实现方法。通过实际案例展示了如何利用ATE测试数据和ATPG工具进行精确的缺陷定位,并分享了提升测试覆盖率和优化测试成本的专业技巧,为芯片测试工程师提供了宝贵的实战经验。

weixin_42527160的博客 381

VSAN 虚拟机丢失排查与恢复完整方案

VSAN虚拟机(含vCenter)显示无效 检查存储状态:登录ESXi Host Client,确认VSAN存储是否挂载。 若未挂载,通过SSH执行 手动挂载。 验证文件存在:检查虚拟机文件是否完整。 解除文件锁定:删除残留的 .lck 文件 手动注册虚拟机:强制注册虚拟机配置文件。 检查VSAN健康:验证集群节点在线、磁盘组状态正常,并测试节点间网络。 修复元数据:针对损坏对象,谨慎执行修复。 终极恢复:若数据丢失,从备份还原或联系VMware支持,提交日志

sido777的博客 2686

RISC-V from scratch 5:机器模式

RISC-V from scratch 5:机器模式 接上一篇博客,我今天继续写 RISC-V from scratch 系列博客。原本我打算将该英文系列全部翻译成中文,但原作者貌似没有把这一系列完成就咕咕了,因此本文的内容是我自己实践的内容,以及一些自己的想法,放在这里同大家探讨,算是狗尾续貂,弥补遗憾。 简介 欢迎再次来到 RISC-V from scratch ,先快速回顾一下我们之前做过的内容,我们之前已经探索了很多与 RISC-V 及其生态相关的底层概念(例如编译、链接、原语运行时、汇编等)。具体

df12138的博客 2594

麒麟服务器操作系统QT系列软件工具手册

对Qt提供的开发工具进行了一系列的优化之后,在日常的修复bug和维护框架的过程中,桌面研发对Qt的基础软件库也进行了部分优化工作,包括qtbase、qtwayland、qtscript、qt4-x11等Qt基础组件,涉及的Qt框架版本主要涉及到5.9和5.12两个版本。qt 的翻译,是由 ts 文件生成 qm 文件然后进行翻译,国产操作系统上qt 的翻译是由单独的qttranslations包中进行维护,其次部分汉化问题也是qt组件维护工作的一部分,qt的翻译机制如图4-7所示。

太极淘的博客 543

07 Risv 指令集

不同的立即数提取的方式不同,需要通过位运算实现立即数的拼接。SEXT函数的作用,能够进行符号拓展,所有的立即数都通过immI、immU、immS、immB、immJ进行了符号拓展。;uint64_t;上述函数的意思就是通过定义结构体实现符号拓展1ull<<1>>BITMASK1BITS函数能够实现对指定位的提取。

weixin_43684667的博客 1739

医学图像的对抗攻击:模型安全性与鲁棒性分析

医学图像的对抗攻击:模型安全性与鲁棒性分析 ,人工智能,计算机视觉,大模型,AI,本文围绕医学图像的对抗攻击展开,分析了其对AI模型的威胁。介绍了对抗样本定义、攻击原理,以及FGSM、BIM、PGD等常见攻击方法,还给出了相关代码示例。阐述了攻击对模型诊断准确性和可信度的影响,提出对抗训练、输入预处理、模型集成等提升模型安全性与鲁棒性的方法,探讨了评估指标、数据集和流程,分析了实际应用中的挑战与应对策略,最后展望了未来发展趋势。

不踩坑,不知道坑有多深;不总结,踩过的坑白踩。 18万+

pscad IEEE标准系统搭建说明

pscad IEEE标准系统搭建说明,包含典型的IEEE 9节点、14节点、30节点、39节点和118节点系统。内含标准测试系统的原始数据和潮流结果。

JVM Crash的定位和解决办法

JVM的Crash定位及解决办法

sutonline的博客 3264

VC中sqlite的使用

在VC中对sqlite的使用,实现数据库大批数据的快速插入,读取数据库中的数据在ListCtrl中显示

JVM 崩溃不可怕:hs_err_pid 日志分析全攻略

关键词:hs_err_pid、JVM crash、segfault、glibc、SIGSEGV、JIT、libzip、JNI阅读时长:18 min环境:CentOS 7 + OpenJDK 8u342 + gdb 7.6适合:1~6 年 Java 开发、生产崩溃复盘、面试「JVM 宕机怎么办」标准答案。

持续输出Java相关知识 1250

锂电池充电器电路设计 里面有电路

锂电池充电器电路设计 里面有电路 3.7V升压到5V电路设计。

OV9734 DATASHEET,完整规格书

OV9734完整规格书,包括电气参数,尺寸规格,寄存器配置表等

解密JVM崩溃(Crash):如何通过日志分析揭开神秘面纱

目录一、前言二、什么是崩溃?三、一个例子四、崩溃日志详解1. 文件路径2. 信息摘要五、core文件 1. 问题调用栈 2. 帧快照 3. 汇编源码还原 4. 内存映射六、一些经验 1. 虚拟机崩溃的原因分类 2. 留意JNI 3. 敢于怀疑七、写在最后一前言当使用Java来构建一个复杂的软件系统时,系统偶发性崩溃(也会被称为Crash)...

gaoliang1719的专栏 2850

MicroBenchmarks:ld1-sd1

例子汇总 例1 (i>j):不可并行 int main() { 10182: 7139 addi sp,sp,-64 10184: fc06 sd ra,56(sp) 10186: 003c addi a5,sp,8 10188: 1810 addi a2,sp,48 int i, A[10]; for(i = 0; i < 10; i++

HaSAA-Benchmark 430

RISCV指令集分析

RISCV指令集分析 一、RV32I指令格式 RV32I一共有6种基本指令格式,如下图所示 其中S-type指令格式和B-type指令格式可以认为是同一种格式,因为两种格式都是用了立即数的12位,只不过S类型用的是0到11位而B类型用的是1到12位。 同理U指令格式和J指令格式也可以认为是同一种指令格式,U类型用的是立即数的12到31位而J类型用的是1到20位 二、各格式指令的介绍 1、I类型指令 addI rd rs1 immediate 寄存器x[rd] = x[rs1] + immediate。该立

给我加个蛋再走 1万+

基于MIPS指令集的单周期CPU

其中信号说明如下: m2reg:把存储器数据写到寄存器   PCsource:下一条指令的来源 wmem:写存储器信号        aluc:运算器控制信号 Shift:移位信号           aluimm:操作数b的数据来源 Wreg:写寄存器信号        regrt:写到rd/rt sext:符号扩展/0扩展 一、实验方法 1.设计ALU 设计一个3...

weixin_30699741的博客 2019

内存数据库SQLite

本文以“黑板”比喻内存数据库,对比传统硬盘数据库,详解 SQLite 使用 :memory: 实现高速临时存储的原理与优势。通过动态占位符生成、executemany 批量操作等技巧,实现毫秒级数据读写,适用于单元测试、临时计算与隐私保护场景。强调其瞬时性与内存限制,提醒开发者注意数据量控制与安全防护,突出参数化查询防注入的核心价值。

看了都说刑的博客 348

从零学 JVM(一):什么是 JVM?JVM、JDK、JRE 有什么区别?

JVM(Java虚拟机)是执行Java字节码的运行环境,负责类加载、字节码执行与内存管理。Java通过javac编译为平台无关的字节码,由各平台的JVM解释或即时编译执行,实现“一次编译,到处运行”的跨平台特性。JVM并非跨平台,但其规范支持多种语言(如Kotlin、Scala)编译为字节码运行。JDK包含JRE,JRE包含JVM,三者构成完整的开发与运行体系。Java采用编译+解释+JIT优化的混合执行机制,兼顾启动速度与运行效率。

2403_87560480的博客 271

11-01-演进-.NET GC发展史

.NET GC发展史映射硬件演进:从单核STW到多核并发,再到区域化细粒度回收。历经分代管理、ServerGC、BackgroundGC、LOH自动压缩、POH隔离、DATC自适应线程数及Region-based管理等关键革新,代码量从5000行增至4万+,复杂度指数上升。每一步优化均在暂停时间、吞吐量与资源开销间权衡,体现“实用主义”哲学。核心启示:GC演进由硬件驱动,方向明确——更短暂停、更高效率,但代价是工程复杂性持续攀升。

设计 AI 玩游戏🎮 382

从零学 JVM(三):Java 对象是如何创建的?从 new 到对象初始化

本文以 HotSpot JVM 为例,详解 new User("Tom") 的完整创建过程:先进行类加载检查,再分配内存(指针碰撞或空闲列表),随后零值初始化,设置对象头,执行实例初始化 <init>(),最后返回引用。对象在堆中由对象头、实例数据和对齐填充三部分构成。为保证线程安全,JVM 采用 CAS 重试或 TLAB(线程本地分配缓冲区)机制,避免多线程竞争导致的内存分配冲突。

2403_87560480的博客 233

JVM中的GC垃圾回收器

同时,引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)。删除CMS垃圾回收器,并弃用Parallel Scavenge和Serial Old的组合。1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection,对应的产品称为Garbage Collector。2018年3月,JDK 10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。2020年9月,JDK15发布。

记录一点 406
上一篇: 一/亿眼无法丁真,LLM Chat必定会发生世界级安全事故
下一篇: 关于不能提的「那个网站」的后续
IT界那些事儿
博客等级 码龄6年 2万+粉丝 22原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值