Java 技术
#JVM#HotSpot#C2#arraycopy#intrinsic

JVM C2 的 System.arraycopy 内在函数:内在化条件、指令生成与失效边界

高吞吐消息中间件里,批量字节拷贝常依赖 System.arraycopy 获得接近 memcpy 的性能,但这一收益只在 C2 完成内在化、且地址对齐与类型检查通过时才成立。文章拆解 HotSpot 从 intrinsic 识别到 CPU 指令生成的路径,说明手写循环为何难以匹敌,以及哪些条件会让它退回运行时调用。

一个批量拷贝的热点

设想一个高吞吐消息中间件:消费者线程从网络缓冲区读入一批字节,解析出消息头后,把消息体从接收缓冲区拷进一个可复用的堆内字节数组,再交给下游处理。单条消息不大,但每秒要处理几十万条,拷贝代码位于最内层循环里。

最初团队用 for (int i = 0; i < len; i++) dst[off + i] = src[i]; 手写循环。上线后发现这段循环占用了可观的 CPU 时间,于是改成 System.arraycopy(src, 0, dst, off, len)。在很多机器上,这一改动的效果立竿见影。但换到另一批机器、或调整了某些启动参数后,收益明显缩水,甚至出现拷贝路径的抖动。问题不在于 arraycopy 本身,而在于它能否被 C2 内在化,以及内在化后生成的指令是否真的匹配当前地址与长度。

这篇文章围绕这个场景,讲清楚三件事:C2 如何识别并替换 arraycopy、替换后生成什么样的代码、以及哪些条件会让它退回慢路径。

内在函数解决的是什么问题

在编译器领域,如果一个函数在翻译成机器指令时被编译器特殊处理,用一组预先准备好的指令或中间表示替换其原本实现,这个函数就叫内在函数(intrinsic method)。这里的“特殊”不是指常规的编译优化,而是绕过函数体、直接给出更优实现。

System.arraycopy 而言,它的字节码实现本质上是一个逐元素循环,还要处理源和目标重叠、类型兼容、越界检查等语义。如果让 C2 老老实实编译这个循环,编译器需要先识别出循环结构,再做向量化、边界消除等优化。这条路可行,但不可靠:循环形态稍有变化,优化就可能失败。

内在化的思路更直接:C2 在编译时认出“这是 arraycopy”,直接生成一段针对拷贝语义的专用代码,而不是去优化一段通用循环。资料指出,相较于识别循环再优化,识别函数然后走 intrinsic 版本更加容易,效果也更直接可见。这正是 arraycopy 能在热点路径上接近 memcpy 性能的基础。

需要强调的是,内在函数不是所有 JVM 都实现的行为。资料明确说明,不能假设某个函数在所有 JVM 中都实现了 intrinsic。HotSpot 中的 intrinsic 可以分别通过解释器、C1 或 C2 实现,且 intrinsic 版本与字节码版本行为基本一致;如果 intrinsic 不能执行,必须能回退到字节码版本。这条回退保证是理解后面所有失效场景的前提。

C2 内在化的完整生命周期

识别阶段

C2 在构建中间表示(IR)时,会检查被调用的方法是否匹配已知的内在函数签名。对 arraycopy,它需要确认调用目标是 java.lang.System.arraycopy,并拿到源数组、源偏移、目标数组、目标偏移和长度这几个参数。

识别不是无条件的。C2 需要先确认源和目标都是数组、元素类型已知且兼容。如果类型信息不足,比如目标数组的精确类型无法从 IR 中推断出来,C2 可能放弃内在化。

类型与长度检查

确认是 arraycopy 之后,C2 不会立刻生成拷贝指令。它要先处理语义要求:源和目标是否为同一数组、区间是否重叠、长度是否非负、偏移是否越界。对于基本类型数组,元素大小在编译期已知;对于引用数组,还需要考虑写屏障。

这些检查中,有一部分可以在编译期消除。例如长度是编译期常量、且明显在数组边界内时,越界检查可以被省掉。但大多数生产场景里长度来自运行时,检查必须保留,只是可以合并到一次比较中。

选择实现路径

检查通过后,C2 面临一个选择:是生成内联的拷贝指令序列,还是发出对运行时例程的调用。这个选择取决于长度是否已知、地址对齐情况、以及平台支持。

资料 3 揭示了一个关键细节:C2 在发出 arraycopy 运行时调用时,会调用 StubRoutines::select_arraycopy_function,根据源和目标数组地址的对齐情况选择正确的运行时例程。也就是说,即使退回运行时调用,也不是只有一条路,而是按对齐选不同的 stub。

生成代码

如果长度小且已知,C2 可能直接展开成若干条加载/存储指令。资料 1 记录了一项针对小规模基本类型数组拷贝的优化,通过部分内联并使用 AVX-512 掩码指令来处理。这说明小长度拷贝的代码生成是一个被持续优化的方向。

如果长度较大,C2 往往发出对运行时 stub 的调用,由 stub 内部完成批量拷贝。stub 是预先写好的汇编例程,能利用平台的向量指令。

下面的流程图展示这个场景中一次 arraycopy 调用的状态流转:

flowchart TD
    A[消息体拷贝调用 arraycopy] --> B[C2 识别为 intrinsic 候选]
    B --> C{类型与长度检查能否通过}
    C -->|不能| D[放弃内在化 保留字节码循环]
    C -->|能| E{长度是否编译期已知且较小}
    E -->|是| F[展开为内联加载存储序列]
    E -->|否| G[按地址对齐选择运行时 stub]
    G --> H{对齐检查是否包含基址偏移}
    H -->|否| I[选错 stub 性能回退]
    H -->|是| J[选中匹配对齐的 stub]
    F --> K[完成拷贝]
    J --> K
    D --> K

图中的分支对应后文要展开的失效条件。特别注意 H 这一步:对齐判断如果漏掉基址偏移,就会选错 stub。

对齐检查为什么容易出错

资料 3 描述了一个真实缺陷。C2 共享代码中有四处调用 StubRoutines::select_arraycopy_function 来发出 arraycopy 运行时调用,其中三处计算源和目标数组地址对齐时漏掉了基址偏移(base offset)。它们默认基址偏移是 8 字节,但这个假设并不总是成立。

当启用 -XX:+UseCompactObjectHeaders-XX:-UseCompressedClassPointers 时,基址偏移会变成 4 字节。此时对齐判断会得出错误结论,从而选中错误的 arraycopy 运行时例程。资料指出,这会在某些 linux-riscv64 平台上造成性能回退,例如 Sifive Unmatched 或 Premier P550 SBC,这些平台上非对齐内存访问非常慢。

这个缺陷对本文场景的启示是:arraycopy 的性能不仅取决于“是否内在化”,还取决于内在化或 stub 选择时使用的地址假设是否与当前 JVM 配置一致。对象头布局、压缩指针设置这些看似无关的开关,会改变数组数据的起始偏移,进而影响对齐判断。

手写循环为什么难以匹敌

在讨论性能差异时,需要区分两种情形。

第一种是 C2 成功内在化且长度较大。此时生成的代码要么调用高度优化的 stub,要么展开为向量指令。手写循环要达到同等水平,需要 C2 对循环做向量化、边界检查消除、循环展开。这些优化依赖循环形态、数组类型推断、以及编译器的代价模型。循环里多一个条件判断、多一次方法调用、或者类型信息不够精确,都可能让向量化失败。

第二种是 C2 未能内在化。此时 arraycopy 退回字节码版本,本质上就是一个带类型检查和重叠处理的循环,性能未必优于精心手写的循环。

因此,“arraycopy 比手写循环快”这个结论有前提:它成立在 C2 成功内在化、且地址与长度条件合适的时候。把它当成无条件事实,就会在换机器、换参数后遇到预期外的表现。

工程上通常的做法是:在热点路径上优先使用 arraycopy,因为它在多数 HotSpot 配置下能获得内在化收益;同时不要假设它一定快,在关键路径上通过诊断手段确认它确实被内在化了。

用 -XX:+PrintInlining 观察内在化

资料 2 给出了观察 intrinsic 的方法:使用 -XX:+PrintCompilation-XX:+PrintInlining,在 product VM 上需要先加 -XX:+UnlockDiagnosticVMOptions

资料中给出的示例输出里,java.lang.Thread::currentThread 被标注为 (intrinsic),说明该调用被识别为内在函数。对 arraycopy,类似地可以在内联日志中查找它是否被标记为 intrinsic,或者是否被内联展开。

需要提醒的是,编辑说明中提到的 -XX:+PrintIntrinsics 在资料中并未出现,资料只支持 -XX:+PrintInlining 这条路径。因此在生产排查中,应以 -XX:+PrintInlining 为主要手段,并注意它本身会带来输出开销,不适合长期开启。

一个实用的排查步骤是:先用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining 在小流量实例上运行,过滤出包含 arraycopy 的内联记录,确认它是否被标记为 intrinsic。如果日志显示它走了普通内联或未内联,就要检查类型信息、长度来源和编译参数。

与替代方案的权衡

在消息中间件的拷贝路径上,除了 arraycopy,还有几种选择。下表从几个工程维度做定性比较,不涉及具体数字,因为资料未提供可引用的基准数据。

方案触发条件吞吐倾向延迟特征内存与线程开销实现复杂度主要失效模式
System.arraycopyC2 内在化成功稳定,取决于 stub无额外线程未内在化、对齐判断错误
手写 for 循环C2 向量化成功中到高依赖优化结果无额外线程向量化失败、边界检查残留
Unsafe.copyMemory直接调用本地拷贝稳定无额外线程需要精确地址,易越界
直接 ByteBuffer堆外内存拷贝中到高受内存访问模式影响无额外线程堆外分配与回收成本
分块拷贝加复用缓冲任意取决于块大小缓冲区占用堆中到高块大小不当导致缓存失效

这张表的用途是帮助判断:当 arraycopy 表现不及预期时,问题更可能出在内在化条件而非 API 本身。如果确认未内在化,再考虑是否值得改用其他方案,而不是盲目替换。

失败模式与诊断路径

结合资料,可以归纳出几类会让 arraycopy 失去预期性能的情况。

类型信息不足。C2 无法确认源和目标数组的精确类型时,可能放弃内在化。在消息中间件里,如果拷贝代码被泛型或接口调用包裹,类型信息可能在编译期被擦除,导致 C2 拿不到足够信息。

长度未知且地址对齐不利。长度来自运行时且地址未对齐时,C2 发出运行时调用,并按对齐选择 stub。资料 3 的缺陷说明,如果对齐判断漏掉基址偏移,会选错 stub。这在启用 -XX:+UseCompactObjectHeaders-XX:-UseCompressedClassPointers 时尤其需要注意。

平台差异。资料 3 指出,非对齐内存访问在某些 riscv64 平台上非常慢。同一段代码在 x64 上表现正常,换到这些平台可能明显退化。跨平台部署时,不能假设 arraycopy 的性能特征一致。

编译参数与预热。内在化发生在 C2 编译之后。如果方法还没达到编译阈值,或者被 CompileCommand 排除,arraycopy 仍在解释器或 C1 下执行,性能特征不同。资料 2 的示例中就用 -XX:CompileCommand=exclude 排除了 main 方法。

诊断路径可以按这个顺序走:先确认方法是否被 C2 编译,再确认 arraycopy 是否被标记为 intrinsic,然后检查是否启用了影响基址偏移的参数,最后在目标平台上验证实际表现。每一步都依赖可观测信号,而不是猜测。

适用边界

arraycopy 内在化是一条收益明确但条件明确的路。它适合这样的场景:拷贝位于热点路径、长度较大或形态稳定、源和目标类型在编译期可推断、部署平台的 JVM 配置不会破坏对齐假设。

它不适合被无条件信任。当类型信息模糊、长度极小且频繁调用、或者部署在非对齐访问代价高的平台上时,需要重新评估。资料 1 对小规模拷贝的专项优化说明,小长度拷贝本身就是一个需要特殊处理的边界情形,而不是内在化天然覆盖的场景。

回到消息中间件那个场景,最终的判断依据不是“arraycopy 一定快”,而是“在这台机器、这套参数、这段代码形态下,它是否被内在化,以及选中的实现是否匹配地址条件”。把这个判断建立在诊断日志和平台验证上,才能避免把一次偶然的收益当成稳定结论。

资料来源

  1. 8252848: Optimize small primitive arrayCopy operations through partial inlining using AVX-512 masked instructions
  2. Create intrinsic method for Hotspot — miaozhuojun
  3. 8359270: C2: alignment check should consider base offset when emitting arraycopy runtime call