年前在ARM服务器上部署JDK 17跑ZGC,服务启动后GC日志里偶尔能看到不太寻常的记录,查来查去,最后定位到HotSpot源码里一个不起眼的头文件:zSyscall_linux_aarch64.hpp。这个文件几乎没有人会主动打开,但只要你在Linux AArch64平台上跑过ZGC,它就一直默默工作着。这篇文章我想把HotSpot在AArch64 Linux下如何通过syscall协调ZGC并发回收这件事,从头到尾拆开讲一遍。如果你做JVM底层调优、容器环境性能分析,或者对OpenJDK源码感兴趣,这期内容应该能帮你省下不少排查时间。
1. 先搞清楚这个文件到底是什么
很多人第一次看到这个文件路径都会懵一下:src/hotspot/os_cpu/linux_aarch64/gc/z/zSyscall_linux_aarch64.hpp,这么长一串,到底属于哪一层代码?它解决的又是什么问题?
1.1 从文件路径看HotSpot的分层设计
HotSpot的源码目录结构非常讲究。src/hotspot下面是JVM运行时的全部核心代码,再往下分,你会看到os、cpu、os_cpu这几个目录,它们分别代表平台相关的三个层次。
最底层的是os目录,管的是操作系统层面的抽象,比如Linux的内存映射、文件系统操作、线程创建等。cpu目录则负责处理器架构相关的内容,比如x86的指令发射、ARM的缓存操作、寄存器使用规则等。而os_cpu这个目录是两者的交叉点,放的是“在某个操作系统上的某种CPU架构”才需要处理的特殊逻辑。
所以os_cpu/linux_aarch64这个路径,表示的就是Linux操作系统上的AArch64(64位ARM)架构专用代码。这一层的代码不能移植到别的平台,因为它跟具体的硬件指令、系统调用绑定得非常紧。再往下的gc/z目录意味着这是ZGC垃圾收集器私有的代码,和gc/g1、gc/serial是平级的,互不干涉。
zSyscall_linux_aarch64.hpp这个文件名里的z前缀代表ZGC,Syscall说明这文件干的事情与系统调用相关。ZGC是出名的追求低延迟,为什么还需要自己封装一批系统调用,而不是直接用glibc提供的接口?这就是接下来要讲的重点。
1.2 ZGC为什么会需要“自研”系统调用封装
很多人会想:JVM跑在Linux上,跟内核打交道不都是通过syscall()这个C库函数吗?ZGC为什么不直接调?这背后其实牵扯到三个非常现实的问题。
第一是性能。ZGC在并发标记和并发转移阶段,某些关键路径上会高频触发内存屏障操作。如果每次都走glibc的syscall()变参函数,需要把参数压栈、解析变参列表,再通过svc指令陷入内核,这套流程在极端情况下会带来不必要的开销。ZGC的哲学是能省则省,尤其在堆内存很大的场景下,一个屏障指令级别的浪费都会被放大。
第二是降级策略。系统调用不一定每次都成功,比如内核版本太旧、容器Seccomp规则限制、或者CPU不支持某些特性,这时系统调用会返回ENOSYS或者EPERM。ZGC需要一个可控的入口,能在启动阶段快速检测这些失败,并自动选择替代方案。如果直接散落在代码里调用syscall(),逻辑会非常散乱。
第三是平台差异。AArch64架构下,ZGC的某些操作需要特定的系统调用参数和返回码约定,和x86_64并不一致。与其在业务代码里到处写#ifdef,不如把平台相关的系统调用统一收敛到一个文件里,用内联函数封装好。这样代码阅读者一眼就能看到“ZGC在AArch64 Linux上用到了哪些内核能力”。
2. ZGC与底层系统调用的关系
这一章可能是整篇文章技术密度最高的部分,我会尽量把“ZGC为什么需要调用系统调用”“AArch64平台的特殊之处在哪里”讲清楚。
2.1 高并发回收下的内存屏障需求
ZGC与传统的G1、CMS不同,它的标志性设计是“读屏障”(Load Barrier)。在并发转移阶段,如果业务线程要访问一个已经被移动过的对象,需要通过读屏障感知并修正引用。这要求所有线程在访问对象时都执行一系列内存操作,而这些操作在弱内存序的AArch64平台上,必须配合显式的内存屏障才能保证顺序一致。
打个比方,ZGC的并发标记线程标记了对象A,同时业务线程正在修改对象A的字段。在x86这种强内存序的平台上,CPU会自己保证大部分顺序不被乱序执行,程序员通常不需要额外的屏障指令。但在AArch64上,CPU为了性能会激进地重排内存访问指令,硬件不保证你写的代码顺序就是实际执行的顺序,这时候就必须在关键位置插入内存屏障指令,比如dmb ish、dsb ish等。
这里的难点在于,ZGC需要的不是单核上的指令屏障,而是“让进程内所有线程都执行一次屏障”的全局同步。JVM内部可以通过安全点(Safepoint)来暂停所有线程,但ZGC尽量减少STW时间,不能频繁用安全点做全局屏障。那怎么办?只能找内核帮忙。
2.2 从CPU指令屏障到全局线程同步屏障
ZGC在AArch64平台上面临的屏障需求,可以分成两个层次。
第一个层次是单线程视角的CPU指令屏障。这一层相对简单,直接嵌入一条dmb ish汇编指令就能完成,保证当前CPU核心上的内存访问顺序。ZGC在代码里确实也维护了这样的内联汇编实现,但这只能保证“我自己”的视角没问题。
第二个层次是进程内的全局同步。ZGC在做并发转移时,希望所有正在运行的业务线程,在某个时间点之后看到的内存状态是一致的。它不能逐个线程去打断并让它们执行屏障,那样还不如直接STW。它需要一种机制,让内核帮忙在所有线程上执行一次内存屏障操作,业务线程本身甚至感知不到这件事发生。这就要用到Linux提供的membarrier系统调用。
membarrier系统调用的设计思路是,用户态进程向内核请求:“请在所有正在运行的线程上执行内存屏障”。内核在返回之前,会确保所有CPU核心上的相关线程都已经执行了屏障操作。这个机制在ZGC的并发转移、并发引用更新阶段非常关键,它让ZGC避免了大规模的STW,但又获得了全局内存一致性的保证。
2.3 为什么封装层必须放在gc/z目录下
看到这里你可能会问:内存屏障是通用能力啊,为什么不放在os或者cpu目录下,方便整个JVM共用?这里涉及ZGC架构的一个核心原则:“ZGC自己控制自己的所有资源”。
ZGC为了极致的可控性,几乎不过度依赖HotSpot其他模块的封装。它的线程模型、内存分配、屏障逻辑、甚至某些系统调用都是自己管理的。如果把membarrier封装放在通用层,参考者会更多,但设计约束也会更多——通用代码要照顾所有GC的兼容性,可能引入额外的层次和判断逻辑,这对ZGC来说是不可接受的。
所以ZGC在自己的私有目录下维护了zSyscall_linux_aarch64.hpp,专门放那些“ZGC自己才用得上、且需要极致性能”的系统调用封装。这也是为什么你打开HotSpot源码,看到gc/z目录里的代码风格和其他GC模块有明显区别,它更像是一个高度自治的子系统。
3. 核心细节解析与实操要点
这一章聚焦文件本身。虽然我们不打算逐行贴源码,但关键设计的实现思路和参数选择是值得展开讲的。
3.1sys_membarrier的实现与调用路径
这个文件里最核心的一个函数,大概率是sys_membarrier。逻辑上它做的事情并不多:
static inline int sys_membarrier(int cmd, int flags) { return (int)syscall(__NR_membarrier, cmd, flags); }你可能会觉得,这不就是套了一层壳吗?直接调syscall()不也一样?事实上ZGC在调用点还会做更多的判断。比如在ZGC初始化时,它会检测当前内核是否支持membarrier,如果支持,就设置一个全局标志位,后续所有需要全局屏障的地方都走这个快速路径;如果不支持或者权限受限,就退回传统的安全点方式。
这里有个非常关键的选择:cmd参数用哪个值。Linux的membarrier支持MEMBARRIER_CMD_SHARED、MEMBARRIER_CMD_PRIVATE_EXPEDITED_SYNC_CORE等多个命令。ZGC会根据自己的具体需求选择合适的命令,并且在注册阶段用MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED之类的命令先做会话注册。这里涉及一个重要的性能考量:未注册状态下使用某些命令,内核需要做更多额外处理,导致调用变慢;注册之后,内核会把当前进程纳入快速路径,后续调用会显著加速。
3.2 函数指针与动态绑定的降级策略
ZGC在代码里不会写死“永远使用membarrier”这种策略。它会通过类似函数指针或者条件分支的方式,在运行时动态选择屏障实现。实践中最常见的情况有三种。
内核支持membarrier且命令执行成功,这是最理想的路径,所有线程通过membarrier保证全局内存一致。第二种是内核太老,不认识该系统调用,返回ENOSYS,此时ZGC只能改用保守策略,比如通过安全点让所有线程进入内核态后再退回来,利用内核切换时的隐式屏障达到类似效果。第三种是容器环境里被Seccomp拦截,返回EPERM或者EACCES,这种情况比较隐蔽,因为从内核角度看进程是活跃的,但能力却被人为限制住了。
这类降级处理的关键在于,不能只检测一次就永远不变。有的容器编排系统会在运行过程中动态更新Seccomp规则,虽然很少发生,但ZGC的代码逻辑上还是以“当前这次调用的返回值为准”来做判断,保障了运行期的鲁棒性。
3.3 与JIT即时编译器的协同
如果你认为zSyscall_linux_aarch64.hpp只服务解释执行或者C2编译器的慢路径,那还低估了这个文件的地位。ZGC的读屏障必须被C2 JIT编译器内联进业务代码,这才是它极低性能损耗的关键。
在AArch64上,C2编译器会根据ZGC提供的元数据判断:当前容器平台上是否支持高效的全局屏障?如果支持,读屏障的最终形态可能是几条轻量指令加一个条件跳转;如果不支持,编译器会插入对ZGC运行时函数的调用,也就是进入这个系统调用封装层。
我在实践中发现,不同JDK版本对这段逻辑的编译优化差异很大。JDK 17之后,AArch64平台上的ZGC屏障代码已经相当成熟,生成的指令模式很简洁。如果你还在用JDK 11早期版本跑ZGC,AArch64上性能会明显差一截,很大一部分原因就出在“系统调用降级策略不够完善、JIT没有拿到最优化的屏障模板”。
4. 常见问题与排查技巧实录
这一部分我打算直接用真实案例的形式来讲,因为底层问题只有落到具体场景里才容易理解。
4.1 容器环境里ZGC行为异常
之前有个朋友反映,同样的业务镜像,在物理机上跑ZGC,停顿时间只有几毫秒;但在某个容器平台上跑,GC日志里偶尔出现长达几十毫秒的停顿。他怀疑是容器CPU限流导致,但排查下来CPU用量并不高。
后来我在他的容器里手动执行了一段检测membarrier的小程序,发现系统调用返回了EPERM。进一步检查容器的Seccomp配置,确认容器编排层默认的Seccomp规则禁止了部分系统调用,membarrier正好被误伤。JVM在启动时探测到membarrier不可用,直接降级到了安全点方案,任何一次全局屏障都需要短暂STW,停顿自然增加。
这种问题在物理机上很难复现,因为物理机内核没有Seccomp拦截。排查建议是,在一台同样配置的容器里,用strace跟踪启动过程中的系统调用,观察membarrier的返回码。如果看到EPERM或EACCES,基本可以确定是容器安全配置的问题。解决办法是调整容器的Seccomp配置,把membarrier加入白名单。
4.2 老内核上ZGC的性能波动
还有一个案例,是客户坚持使用某款老旧Linux发行版,内核版本比较低。ZGC在启动时能跑起来,但性能数据一直不好看。查看/var/log/messages没有错误,GC日志也正常,就是吞吐上不去。
实际上问题出在ZGC的启动自检阶段:老内核没有__NR_membarrier这个系统调用号,JVM通过syscall(__NR_membarrier, ...)去调用时直接返回ENOSYS。ZGC的代码能处理这个错误,但代价是启用了保守的同步方案。这类情况下,GC似乎正常运行,但并发回收的优势大打折扣。
如果业务对延迟敏感,建议直接升级内核到4.3以上,这是membarrier合入主线的最低版本。如果无法升级,那就只能考虑用G1替代ZGC,虽然G1的停顿略高,但行为可预期,不会出现“看似并发实际隐式STW”的尴尬状况。
4.3 排查速查表
| 现象 | 可能原因 | 排查手段 | 解决路径 |
|---|---|---|---|
| ZGC停顿时间忽高忽低 | membarrier被Seccomp拦截 | 容器内strace查看系统调用返回码 | 调整Seccomp规则 |
| 老内核上ZGC性能差 | 内核不支持membarrier | 检查内核版本,搜索__NR_membarrier | 升级内核或改用G1 |
| 刚迁移到ARM服务器时GC异常 | AArch64平台屏障实现不完善 | 对比同版本JDK在x86和ARM上的GC日志 | 升级JDK版本 |
| 容器里正常但独立部署异常 | 容器启用不同安全配置文件 | 对比两套环境的uname -a与Seccomp配置 | 统一运行环境基准确认 |
4.4 额外避坑技巧
关于zSyscall_linux_aarch64.hpp,我自己在阅读和排查过程中积累了一些经验,这里挑几个容易踩坑的点。
第一,不要只看源码文件名就以为只涉及AArch64。zSyscall_linux_aarch64.hpp只是平台相关部分,ZGC还有一份通用平台版本zSyscall_linux.hpp,里面定义了大部分公共逻辑。排查问题时两个文件要对照着看,尤其是函数名相近但参数不同的接口,很容易看混。
第二,JDK版本升级后记得测试容器的兼容性。新的JDK可能引入新的系统调用,比如ZGC在后续版本里对membarrier的命令选择有变化,旧版Seccomp白名单可能没有包含全部命令,升级后可能突然被拦截。建议每次升级JDK之后,在测试容器环境里跑一遍ZGC的基准测试,对比GC日志和停顿时间。
第三,调试时可以加参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly看JIT生成的屏障指令是否符合预期。这个方法比较硬核,但对确认ZGC是否真正走到快速路径非常有效。如果你想确认当前ZGC用的是哪种屏障实现,在PrintAssembly的输出里搜索membarrier相关的调用或者特征指令,就能一目了然。
5. 写在最后的个人体会
我对zSyscall_linux_aarch64.hpp这个文件最大的感受是:它极小,但承担的责任极重。一个文件里可能只有几十行代码,却决定了ZGC在某个平台上能不能达到设计目标。很多时候我们在生产环境里遇到ZGC异常,第一反应是调堆大小或者改并发线程数,却忽略了下层系统调用是否真的在正常工作。
另一个体会是,JVM与操作系统的解耦并不是越彻底越好。像ZGC这种追求极致低延迟的模块,它其实是在“尽可能不打扰用户线程”和“必须让内核协助同步”之间寻找平衡点。这个平衡点落在哪,往往要看内核能力的边界。了解zSyscall_linux_aarch64.hpp在做的事情,本质上就是在了解“JVM能安全地依赖内核到什么程度”。
如果你也在ARM平台上做JVM调优,我建议碰到诡异问题时先看一眼内核版本和容器安全配置,再深入GC日志。有时候最快的解决方案,就是给内核和容器放行那个本该正常工作的系统调用。