跨界收费站

「C++ 比 Java 快,把循环全换成 C++ 岂不起飞?」
这台后端服务里真的挂了一段 C++ 共享库。别猜了,发车实测。

正在连接后端…

四条车道依次跑:纯过路费 → Java JIT → C++ 单次过境 → C++ 攒批。每条车道预热后测 5 轮取中位数;车道跑完再把 13 档批量逐档实测成曲线。现场测,不背答案。

JAVA 车道JIT 编译后的裸循环
—
ns / 次操作
 
VS
C++ 车道FFM 调用真实共享库
—
ns / 次操作(攒批过境)
 
四条车道的原始读数
车道总耗时每次操作对比 Java
纯过路费(noop)———
Java JIT 循环——基准
C++ 单次过境———
C++ 攒批过境———
发车之前,先预测一下:哪条车道会赢?
摊薄过路费:交叉点在哪里

横轴回答的就是「一批是多少」:从一批 1 次、3 次、10 次……一直到整轮一批, 每一档都是后端真测的,不是公式画出来的。纵轴是摊到每次操作的耗时。 发车时那轮「攒批过境」用的是最大档——整轮一批。

实测日志
// 等待发车…
被测的 C++,一共 20 行

编译成共享库挂在后端进程里,Java 25 的 FFM(Foreign Function & Memory API)直接调用。没有任何 mock——你测的就是这段机器码。

#include <cstdint>

extern "C" {

int32_t nb_get_at(const int32_t* arr, int32_t index) {
    return arr[index];              // 单次过境:每个元素都要调一次
}

int64_t nb_sum_range(const int32_t* arr, int32_t start, int32_t end) {
    int64_t acc = 0;
    for (int32_t i = start; i < end; i++) {
        acc += arr[i];              // 攒批过境:一次调用,循环在 C++ 里干完
    }
    return acc;
}

void nb_noop() {                    // 纯过路费:什么都不干,专量边界开销
}

}
为什么这个实验回答了那个知乎问题

知乎上的问题是:Java 没有值类型和指针,容器天生写不好,为什么不用 C++ 写一套、 JNI 调用?这张实测台就是答案的第一行:数组读取这种纳秒级操作,JIT 编译后的 Java 每次只要不到 1ns,而跨一次语言边界要几十 ns——单次过境慢几十倍。 C++ 的速度优势要先挣回过路费才轮得到赢。

第二行在攒批车道:把 N 次操作攒成一次过境,过路费摊到接近零, C++ 纯计算的优势才释放出来。交叉点 N* = b / (j − v),b 是过路费、 j 和 v 是两侧每次操作的耗时——全部来自你刚才那次实测。 曲线图上 13 档批量逐档真测,直接检验这个公式:实测交叉点应该落在 公式预言的位置附近;如果整条曲线都压不下去,那就是 JIT 连推演的机会都没给。

所以 Java 标准库的选择说得通:集合操作是纳秒粒度,JVM 的正确姿势是让 JIT 把 Java 代码本身编译成机器码(内联、标量替换)、让 intrinsic 把 arraycopy 这类底层操作直接换成向量化指令——代码不过境,才不用交过路费。 真正粗粒度的活(压缩一块 buffer、读写 socket)才值得过境, java.util.zip 和 NIO 就是这么干的。后来 Panama FFM 把过路费降了下来, Valhalla 在给 Java 补值类型——账本的两端都在改,方向依然是语言自己演化。

你看到的数字跑在后端上, 换台机器重跑数字会变,结论的形状不会:单次过境输,攒批反超, 交叉点之前的 C++ 只是台昂贵的跷跷板。