如何公平比较 C#/C++/Java/Go 的运行速度

· 更新日期: · · Benchmark, Performance, C#, C++, Java, Go

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276859)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615497)
首次发布
引用本文(DOI: 10.5281/zenodo.21615496)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《如何公平比较 C#/C++/Java/Go 的运行速度》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615496 https://comcomponent.com/zh-CN/blog/2026/03/17/000-language-benchmark-csharp-cpp-java-go/

DOI(最新版本)
10.5281/zenodo.21615496
DOI(此版本)
10.5281/zenodo.22282016

“听说 C++ 很快。” “Go 在实际运维中很轻。” “Java 长时间运行下来相当快。” “C# 有 .NET 的 JIT,其实也意外地强。”

这类说法经常出现。 不过,这里最不该做的一件事是:把不同的人在不同环境下测出来的数字并排放在一起,直接当成语言的优劣。

C# 和 Java 容易受到 JIT 与 warm-up 的影响,而 C++ 和 Go 通常是预先编译好的。 GC 的有无与特性也不一样。标准库以及周边库的实现差异同样影响很大。再加上,即便是同一台机器,电源设置、发热、后台处理、输入数据的偏差都会让结果轻易产生波动。这是个相当琐碎又麻烦的领域。

结果产生波动的因素表示 JIT 与 warm-up 的影响、GC 的有无与特性差异、标准库与周边库的实现差异、电源设置与发热与后台处理与输入数据偏差叠加在一起,会让测量结果轻易产生波动的图。JIT 与 warm-up 的影响结果轻易产生波动GC 与库的实现差异电源、发热、噪声、输入偏差不要把不同环境的数字并排来定优劣

图1:正因为波动来源众多,把别人的数字并排放在一起的比较根本不成立。

本文整理的是尽可能公平地比较 C# / C++ / Java / Go 的测量方法。 先说结论:最重要的是不要试图用一个数字来判定“哪个语言最快”。

本文的主题始终是比较方法的梳理。 把依赖环境的数字并排列出来,只要条件不同就很容易反转。因此这里不给出实测排名,而是集中整理怎样设计才能让比较产生价值。

目标读者

本文面向手上有多个语言候选,想从性能角度判断用哪个来实现的开发者与技术负责人,以及希望在公司内部或报告中拿出能够称为“我们做了速度比较”的测量方案的人。主题不是深挖某一种语言,而是跨 4 种语言的比较设计,所以只接触其中一种语言的读者也能读下去。

代码示例中,统一 runner 用 PowerShell,语言内部的工具用 C# 的 BenchmarkDotNet 和 Java 的 JMH。C++ 与 Go 只列出统一条件的方式。

先要掌握的术语

正文中有若干术语会直接以英文出现。为了避免第一次见到时卡住,先在这里汇总。

术语 含义
p95 / p99 百分位数。把全部 run 按从快到慢排列时,位于从下往上 95% / 99% 位置的值。“100 次里有 5 次比这个还慢”对应的就是 p95
RSS (Resident Set Size) 进程实际装载到物理内存中的大小。表示的不是已申请的虚拟内存量,而是“现在占用了多少实际内存”
LTO (Link Time Optimization) 在链接阶段跨编译单元进行优化的机制。GCC / Clang 的 -flto、MSVC 的 /GL 与 /LTCG 属于这一类
PGO (Profile-Guided Optimization) 先执行一次收集分支与调用的 profile,再把它用于下一次构建的优化判断的机制
Tiered Compilation .NET 的 JIT 先用可以快速编译出来的代码运行,之后只对调用频繁的方法重新优化的机制。它是 cold 与 warm 产生差异的主要原因之一
Server GC / Workstation GC .NET 的 GC 工作模式。Server GC 为每个逻辑处理器准备堆和 GC 线程,偏向吞吐量;Workstation GC 偏向响应性
GOMAXPROCS Go 运行时同时执行 Go 代码的操作系统线程数上限。并行基准测试中不固定这个值就无法比较结果
cgo 从 Go 调用 C 代码的机制。启用后调用开销、构建条件、能否静态链接都会改变

先说结论

在 C# / C++ / Java / Go 的速度比较中,真正起作用的是这 7 点。

  1. 先确定想比较的是什么速度 是启动时间,是稳定状态下的 throughput,是 p95 延迟,还是内存效率,测量方式会随之改变。

  2. 不要只用一个基准测试就下结论 在 CPU 计算、内存分配、并行处理、启动时间上,哪种语言和运行时看起来更强会发生变化。

  3. C# 和 Java 要区分 cold 与 warm 把包含首次执行的比较,和 warm-up 之后的稳定状态比较混在一起,讨论就会拧巴。

  4. 用相同的算法、相同的输入、相同的正确性校验来测量 测到的不是更快的实现,而只是在解另一道题,这是基准测试里的常见问题。

  5. 把语言内部的 microbenchmark 与跨语言的 end-to-end 基准测试分开 各语言的专用工具很方便,但跨语言的比较更适合放到外层的统一 runner 里跑。

  6. 不只看平均值,还要看中位数与分布 哪怕只有一次撞上 GC 或后台处理,平均值就会被毁掉。

  7. 不只留下数字,还要留下条件 基准测试结果既是速度的记录,也是实验条件的记录。没有写下条件的结果,事后会相当难受。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 24 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

首先应该确定的事情

用一个“快”字把话说完,基本上都会出事。 首先要确定的是把什么称为快。

举例来说,即便是同一个程序,想看的东西也可能大不相同。

1. 想看的是启动时间吗

CLI 工具、生命周期很短的批处理、启动一次很快就结束的辅助工具,cold start 或 process startup 才是关键。 在这个维度上,是否包含 JIT 与类加载的初始化成本,会让结果差别很大。

2. 想看的是长时间运行下的 throughput 吗

服务器、常驻进程、worker、长时间运行的转换处理,steady-state 的 throughput 更重要。 这种情况下,只有首次较慢本身并不是本质,主题在于 warm-up 之后能稳定提升到什么程度。

3. 想看的是 tail latency 吗

在 API、UI、偏实时的处理中,比起平均值,p95 / p99 有时更重要。 即使平均值很快,如果偶尔出现较大的停顿,从用户体验或 SLA 的角度看依然很难受。

4. 是否连内存效率也要一起看

不只看 CPU 时间,如果不看最大 RSS、分配量、GC 次数、GC pause,就会误判实际运维中的负担。 “快但相当吃内存”和“稍慢但稳定又轻”,在不同用途下评价会反过来。

归根到底,最先应该确定的问题是:

这次比较想知道的不是哪个语言更快,而是 针对哪种 workload,在什么条件下,在什么指标上能处理得更快

在这一点还含糊的情况下就去收集数字,最后是收不拢的。

先确定把什么称为快表示想看的是启动时间、稳定状态的 throughput、尾部延迟还是内存效率,测量方式会随之改变,因此在比较之前要先确定针对哪种 workload、在什么条件下、用什么指标来看的图。这次比较想知道的是什么启动时间稳定状态的 throughputp95 / p99 的延迟内存效率是另一条轴每条轴的测量方式都不同

图2:在“快”的定义确定之前,不要开始收集数字。

为什么语言比较很难

把 JIT 与 AOT 混在一起,就变成了另一种实验

C# 和 Java 通常受 JIT 影响。 而 C++ 和 Go 通常是预先编译好的。

也就是说,测量首次执行时,测到的不只是程序本身的速度,还会连带测到运行时启动、类加载、JIT 准备。 反过来,如果只看充分 warm-up 之后的部分,那这次比的就是稳定状态下的优化能发挥到什么程度。

两者都有意义。 但意义并不相同。

JIT 组与 AOT 组的差异表示 C# 与 Java 通常受 JIT 影响,首次执行中混入了运行时启动、类加载与 JIT 准备,而 C++ 与 Go 通常是预先编译好的,因此测量首次执行的比较与测量 warm-up 之后的比较意义并不相同的图。C# 与 Java〔通常 JIT〕首次执行混入了启动与 JIT 准备C++ 与 Go〔通常 AOT〕以预先编译好的形式运行首次比较与稳定状态比较是两个实验

图3:在执行模型不同的语言之间,从哪里开始测量决定了实验的内容。

实现差异比语言差异更大的情况很常见

即便同样是“排序”,也会出现:

  • 一方用的是标准库
  • 一方是自己实现
  • 一方多做了一次拷贝
  • 一方每次都重新生成输入

仅这些就足以让结果相差不少。

再往前一步,涉及 JSON、压缩、加密、正则表达式这类处理时,比起语言本身,库的实现差异影响更大。 因此,不写清楚测的是什么,本来想做的“语言比较”就会变成“库比较”。

语言比较被偷换成库比较表示同样的处理在使用标准库还是自己实现、是否多做拷贝、是否每次重新生成输入上会让结果改变,而 JSON、压缩、加密、正则表达式中库实现的差异比语言本身影响更大,因此不写清楚测的是什么,本想做的语言比较就会变成库比较的图。没有写清楚写清楚了本应相同的各个实现实现与库的差异混在一起是否写清楚了测的是什么本想做语言比较,结果是库比较可以作为比较来解读

图4:只要给测量对象取对名字,大半的误解就会消失。

C++ 存在优化让处理消失的陷阱

尤其是在 microbenchmark 中,编译器一旦判断“这个计算结果没人用啊”,就可能把处理消除掉。 于是测到的不是变快了,而是根本什么都没有做。整个循环消失、执行时间接近零,是典型的表现。

C++ 中这个问题特别容易赤裸裸地出现,所以让结果被使用、输出 checksum,或者使用基准测试框架抑制优化的功能,都相当重要。

优化让处理消失的陷阱表示在 microbenchmark 中如果编译器判断没人使用计算结果,处理本身就会消失,测到的不是变快而是什么都没做,因此让结果被使用、输出 checksum 以及抑制优化的功能变得重要的图。没人使用计算结果编译器消除处理执行时间看起来接近零不是快,而是什么都没做用 checksum 输出和抑制功能来防止

图5:快得异常的结果,首先要怀疑是不是被消除了。

GC 的存在既不是“劣势”也不是“优势”,而是特性

C#、Java、Go 都有 GC。 简单地说成“有 GC 所以慢”就太粗糙了。

实际上,更起作用的是:

  • 如何处理大量短生命周期对象
  • 堆大小设置
  • GC 的频率与 pause
  • 对象布局
  • 库的分配习惯

反过来,C++ 可以用手动管理或 RAII 做精细控制,但相应地,设计与实现的差异也更容易显现。 也就是说,管理方式的不同并不直接等于好坏或优劣。

GC 不是劣势而是特性表示在有 GC 的语言中短生命周期对象的处理方式、堆设置、GC 的频率与 pause、分配习惯更起作用,而 C++ 能精细控制但实现差异更容易显现,因此管理方式的不同并不直接等于优劣的图。有 GC 的语言设置与分配习惯更起作用C++ 的手动管理与 RAII能控制但实现差异容易显现管理方式的不同不等于优劣

图6:为了不用“有 GC 所以慢”一句话糊弄过去而做的梳理。

比较时不该做的事

1. 把 Debug 和 Release 混在一起

这是绝对不行的。 比较对象必须统一为接近生产环境的优化构建。

2. 解的并不是同一个问题

输入格式不同、输出不同、只有一方没有错误处理、内存复用的方针不同。 放着这些不管,测到的就不是速度,而是需求上的差异。

3. 只执行一次就下结论

只执行一次,基本上就是噪声。

  • JIT
  • 页缓存
  • CPU 的 boost
  • 发热
  • 后台任务
  • GC
  • 首次的文件读取

这些会在一次执行里全部混进来。

只执行一次时混进来的东西表示只执行一次时 JIT 与首次文件读取、页缓存与 CPU boost、发热与后台任务、GC 会全部混在一起,因此一次的结果基本上就是噪声的图。JIT 与首次读取一次执行把所有东西混在一起缓存与 CPU boost发热与后台处理只跑一次的结果基本上是噪声

图7:只有一次的数字里,混入的最多的恰恰是不想测的东西。

4. 混用 warm-up

测量 C# 和 Java 时,如果对包含首次执行还是只看 warm-up 之后含糊其辞,讨论就会崩掉。 要把 cold 与 warm 当作两回事来处理。

5. 不做正确性校验

基准测试在“快”之前,先要保证“返回相同的结果”。 必须确认所有被比较的实现,从相同的输入得到相同的 checksum 或相同的输出。

6. 只凭一个 microbenchmark 就决定世界观

在 tight loop 里赢了,不代表在整个实际服务中也能赢。 反过来,在启动时间上输了,长时间运行下也可能足够强。

比较 C# / C++ / Java / Go 时的基本方针

这里相当重要。 推荐的是两层结构。

内层:语言内部的深入挖掘外层:跨语言的统一 runner在语言内部细致地测量同一处理在语言内部细致地测量同一处理在语言内部细致地测量同一处理在语言内部细致地测量同一处理Google BenchmarkBenchmarkDotNetJMHgo test -bench 与 benchstat统一 runner随机化执行顺序 / 分离 cold 与 warm校验 checksum / 保存原始数据bench 可执行文件C++bench 可执行文件C#bench 可执行文件Javabench 可执行文件Go

图8:跨语言的比较交给外层的统一 runner,语言内部的深入挖掘交给专用工具。

外层跑出来的数字是可以跨语言比较的数字,内层跑出来的数字是在该语言内部追踪改进用的数字。要点在于不要把这两者混进同一张表。

1. 语言内部的测量,使用适合该语言的工具

每种语言都有能吸收该语言自身情况的 benchmark 工具。

  • C#:BenchmarkDotNet
  • Java:JMH
  • Go:go test -bench 与 benchstat
  • C++:Google Benchmark

它们会在一定程度上帮你照顾各自的运行时情况、统计处理,以及测量中的陷阱。 对语言内部的比较和实现层面的深入挖掘相当有效。

例如,用 BenchmarkDotNet 测量“对 1000 万个 int32 排序”,最小配置是这样的。

// C# / .NET 8 + BenchmarkDotNet 0.13 系列
//   dotnet add package BenchmarkDotNet
//   dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<SortBench>();

[MemoryDiagnoser]                       // 把分配量和 GC 次数也一并输出
public class SortBench
{
    private int[] _source = Array.Empty<int>();
    private int[] _work = Array.Empty<int>();

    [GlobalSetup]
    public void Setup()
    {
        var rng = new Random(12345);    // 固定 seed,每次使用相同的输入
        _source = new int[10_000_000];
        for (int i = 0; i < _source.Length; i++)
        {
            _source[i] = rng.Next();
        }
        _work = new int[_source.Length];
    }

    [IterationSetup]                    // 每次都还原成未排序的状态
    public void ResetInput() => Array.Copy(_source, _work, _source.Length);

    [Benchmark]
    public long SortInt32()
    {
        Array.Sort(_work);

        long checksum = 0;
        foreach (int v in _work)
        {
            checksum = checksum * 31 + v;
        }
        return checksum;                // 返回结果,避免被优化消除
    }
}

[IterationSetup] 是有限制的。BenchmarkDotNet 的官方文档指出,在 microbenchmark 中使用它会污染结果,因此不推荐;而对于耗时 100ms 以上的 macrobenchmark 则是有用的。1000 万个元素的排序满足这个条件,但测量耗时较短的处理时,请改成在 [GlobalSetup] 一侧把状态带过去的写法。

在 Java 的 JMH 中,要考虑的事情是一样的。

// Java / JMH。在 pom.xml 中加入 jmh-core 与 jmh-generator-annprocess
//   mvn clean verify
//   java -jar target/benchmarks.jar SortBench
package bench;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)                                 // 分开 JVM,抹平 JIT 的运气成分
public class SortBench {

    private int[] source;
    private int[] work;

    @Setup(Level.Trial)
    public void setUp() {
        Random rng = new Random(12345);  // 固定 seed,每次使用相同的输入
        source = new int[10_000_000];
        for (int i = 0; i < source.length; i++) {
            source[i] = rng.nextInt();
        }
        work = new int[source.length];
    }

    @Setup(Level.Invocation)             // 每次都还原成未排序的状态
    public void resetInput() {
        System.arraycopy(source, 0, work, 0, source.length);
    }

    @Benchmark
    public long sortInt32() {
        Arrays.sort(work);

        long checksum = 0;
        for (int v : work) {
            checksum = checksum * 31 + v;
        }
        return checksum;                 // 返回值会被 JMH 消费,因此不会被优化消除
    }
}

Level.Invocation 也有同类的限制。JMH 的 javadoc 明确写着,这个级别只能用于单次 @Benchmark 方法调用超过 1 毫秒的基准测试。因为它会在每次调用时取时间戳,对耗时较短的处理来说,测量本身就成了瓶颈。

BenchmarkDotNet 与 JMH 的 @Warmup / @Measurement / @Fork 的取值,本身就是实验条件。请务必和结果一起留下来。

2. 跨语言的比较,把统一 runner 放在外层

另一方面,把 C# 的 BenchmarkDotNet 结果和 Java 的 JMH 结果直接并排放在一起,是有点危险的。 因为工具本身的做法就不一样。

因此,跨语言时推荐把各个实现做成能用同一套 CLI 约定调用的可执行文件,从外部以相同条件来跑。

例如,为每种语言准备这种形式的可执行文件。

bench --scenario sort_int32 --dataset data/sort_10m.bin --mode warm
bench --scenario group_words --dataset data/words_100mb.txt --mode cold
bench --scenario parallel_hash --dataset data/blob_1gb.bin --threads 8

输出侧也做成约定。约定任何语言的实现都只向标准输出打印这 2 行,runner 一侧用一行正则表达式就能解析完。

checksum=[十六进制字符串]
inner_ms=[小数形式的毫秒]

checksum 用于正确性校验,inner_ms 是实现自己测出来的主体处理时间。runner 一侧会另外测量整个进程的 wall-clock,所以含启动的时间和只含主体的时间都能留下来。

CLI 约定与输出约定表示把各语言的实现做成能用同一套 CLI 约定调用的可执行文件,并约定只向标准输出打印 checksum 与 inner_ms 两行,runner 另外测量整个进程的时间,因此含启动的时间与只含主体的时间都能留下来的图。同一套 CLI 约定的可执行文件输出 checksum 与 inner_ms 两行runner 测量整体的 wall-clock含启动与只含主体的时间都留下来

图9:把调用方式和输出做成约定,4 种语言就能放在同一个擂台上跑。

然后在统一 runner 一侧,走这样的流程:

  • 随机化执行顺序
  • 区分 cold 与 warm
  • 传入相同的数据集
  • 校验 checksum
  • 采集 wall-clock 与内存
  • 把原始数据留在 CSV / JSON 中

只写骨架的话,是这样的。

# run-bench.ps1:跨语言的统一 runner(骨架)
# 在 PowerShell 7.4 上运行。
#   pwsh ./run-bench.ps1 -Scenario sort_int32 -Dataset ./data/sort_10m.bin -Runs 15

param(
    [Parameter(Mandatory = $true)][string]$Scenario,
    [Parameter(Mandatory = $true)][string]$Dataset,
    [int]$Runs = 15,
    [int]$WarmupRuns = 3,
    [string]$OutCsv = "./results/raw.csv"
)

# 各语言的可执行文件。CLI 约定在 4 个实现中完全统一。
# Java 放一个只包一层 java -jar 的薄封装,把调用方式统一起来。
$Implementations = @(
    @{ Language = "cpp";    Exe = "./build/cpp/bench.exe" },
    @{ Language = "csharp"; Exe = "./build/csharp/bench.exe" },
    @{ Language = "java";   Exe = "./build/java/bench.cmd" },
    @{ Language = "go";     Exe = "./build/go/bench.exe" }
)

function Invoke-OneRun {
    param(
        [Parameter(Mandatory = $true)][hashtable]$Impl,
        [Parameter(Mandatory = $true)][string]$Mode,
        [Parameter(Mandatory = $true)][int]$Index
    )

    # $Scenario 与 $Dataset 使用开头 param 块中定义的那些
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $stdout = & $Impl.Exe --scenario $Scenario --dataset $Dataset --mode $Mode
    $sw.Stop()
    $exitCode = $LASTEXITCODE

    # 与实现之间的输出约定,只在这一处解析
    $checksum = ""
    $innerMs = [double]::NaN
    foreach ($line in $stdout) {
        if ($line -match '^checksum=(\S+)$') { $checksum = $Matches[1] }
        if ($line -match '^inner_ms=(\S+)$') { $innerMs = [double]$Matches[1] }
    }

    if ($exitCode -ne 0 -or [string]::IsNullOrEmpty($checksum)) {
        throw "$($Impl.Language) / $Mode / run $Index 失败了。exit=$exitCode"
    }
    # 忘记输出 inner_ms 的实现,会以退出码 0 只返回 checksum。
    # 不在这里拦掉,NaN 就会原样进入 CSV,内层时间的比较会悄无声息地坏掉
    if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
        throw "$($Impl.Language) / $Mode / run $Index 没有返回 inner_ms(值:$innerMs)。" +
              "输出约定是 checksum= 与 inner_ms= 两行"
    }

    return [pscustomobject]@{
        timestamp    = (Get-Date).ToString("o")
        language     = $Impl.Language
        scenario     = $Scenario
        cold_or_warm = $Mode
        run_index    = $Index
        process_ms   = [math]::Round($sw.Elapsed.TotalMilliseconds, 3)
        inner_ms     = $innerMs
        checksum     = $checksum
    }
}

# 1. 先做正确性校验。如果全部实现返回的 checksum 不一致,测速度就没有意义
$expected = $null
foreach ($impl in $Implementations) {
    for ($i = 1; $i -le $WarmupRuns; $i++) {
        $r = Invoke-OneRun -Impl $impl -Mode "warm" -Index $i
        if ($null -eq $expected) {
            $expected = $r.checksum
        }
        elseif ($r.checksum -ne $expected) {
            throw "checksum 不一致。$($impl.Language) 是 $($r.checksum),基准是 $expected"
        }
    }
}

# 2. 正式测量。每个 run 都打乱执行顺序,抹平发热与时间段带来的偏差
#    第 1 步的校验针对的是会被丢弃的 warm run,所以要记录的 run 也要
#    逐个校验 checksum。省掉这里,中途开始做别的事情的
#    实现的时间就会混进 CSV,比较本身就作废了
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($mode in @("cold", "warm")) {
    for ($i = 1; $i -le $Runs; $i++) {
        $shuffled = $Implementations | Get-Random -Count $Implementations.Count
        foreach ($impl in $shuffled) {
            $r = Invoke-OneRun -Impl $impl -Mode $mode -Index $i
            if ($r.checksum -ne $expected) {
                throw "checksum 不一致。$($impl.Language) 的 $mode run #$i 是 $($r.checksum),基准是 $expected"
            }
            $rows.Add($r)
        }
    }
}

# 3. 一定要留下原始数据。汇总之后从这个 CSV 来做
# 如果传入的是 -OutCsv raw.csv 这种没有父目录的名字,
# Split-Path -Parent 会返回空字符串,New-Item 会拒绝它。
# 在这里倒下是在全部 run 结束之后,会把测量结果整个丢掉
$outDir = Split-Path -Parent $OutCsv
if ($outDir) { New-Item -ItemType Directory -Force -Path $outDir | Out-Null }
$rows | Export-Csv -Path $OutCsv -NoTypeInformation -Encoding utf8
Write-Host "raw data: $OutCsv / $($rows.Count) 行"

这里有 3 件事是刻意为之的。

  • 把正确性校验放在速度测量之前。checksum 还对不上就只比速度,是没有意义的
  • 每个 run 都打乱顺序。目的是不要出现把 A 全部跑完再跑 B 这样的顺序
  • 不做汇总,只写出原始数据。平均值和中位数之后对着 CSV 来算

这样做之后,就更容易把各语言内部的最佳实践与跨语言的公平性分开处理。

统一 runner 的三个意图表示统一 runner 中刻意做了三件事,即把基于 checksum 的正确性校验放在速度测量之前、每个 run 都打乱执行顺序、不做汇总只写出原始数据的图。正确性校验先于速度能够公平比较的测量每个 run 都打乱顺序不汇总,写出原始数据抹平发热与时间段的偏差

图10:runner 的工作不是跑得快,而是消除疑虑。

具体示例:应该准备哪些基准测试项目

被要求“想比较 C# / C++ / Java / Go”时,只做一个的话推荐不容易被误解的单纯 CPU 类项目,要做多个的话推荐准备性质不同的 3~4 种 workload。

推荐的组合

1. sort_int32_10m

目的: 观察 CPU + 内存带宽 + 临时空间的使用方式

  • 输入:用固定 seed 生成的 1000 万个 int32
  • 处理:对数组排序并返回 checksum
  • 注意事项:每次都要还原成相同的未排序输入

这一项比较容易理解。 不过它也包含标准排序实现的差异,所以与其说是语言本身,不如说是含标准库在内的比较。

2. hash_group_count

目的: 观察哈希表、字符串处理、内存分配、GC 的倾向

  • 输入:固定的文本数据
  • 处理:统计每个单词的出现次数
  • 输出:前 N 项与 checksum

它贴近实际业务,但另一方面,字符串库和 map 实现的差异影响也相当大。 正因如此,它是更接近现实的比较。

3. parallel_sha256

目的: 观察并行处理、调度器、工作线程池、同步方面的习性

  • 输入:固定大小的二进制数据块序列
  • 处理:用 N 个线程依次做哈希,返回最终 checksum
  • 条件:把线程数按 1 / 2 / 4 / 8 这样分档

比起单纯的 tight loop,它更容易看出并行执行时的提升幅度。

4. startup_noop 或 startup_parse_small

目的: 观察启动时间

  • noop:启动后立即结束
  • parse_small:只处理一次小规模输入后结束

在这里,C# / Java 的 JIT 与初始化成本比较容易显现,与 C++ / Go 的表现相差很多。 反过来说,即使这里出现差距,也和长时间处理的胜负是两回事。

性质不同的四个基准测试项目表示排序看 CPU 与内存带宽、单词计数看哈希与字符串以及内存分配、并行哈希看并行执行时的提升幅度、启动基准测试看启动时间,各自能看到的东西都不同的四项构成的图。sort_int32_10mCPU、带宽与临时空间hash_group_count字符串、map 与 GC 的倾向parallel_sha256并行执行时的提升幅度startup 系列启动与初始化的成本

图11:一个项目看不到全部,所以按想看的东西分开设项。

JSON 或 HTTP 类基准测试怎么办

JSON 和 HTTP 贴近实际业务,当然是有意义的。 不过这种情况下,与其说是语言比较,不如说是含库、框架、生态系统在内的比较。

这本身并不是坏事。 在实务中,反倒常常是后者更重要。 只是在文章或报告里,最好写明:

这不是语言的比较,而是含标准实现与主要库在内的比较

这样能减少误解。

各语言应该统一的条件

C++

  • 统一为优化构建
  • 固定编译器
  • 固定标准库实现
  • 写明 -O3 / /O2、LTO、PGO 等条件
  • 注意结果不要被优化消除
  • 怀疑是不是因未定义行为才看起来更快

C++ 自由度高,条件差异会直接放大为结果差异。 因此,用哪个编译器、哪些编译选项、哪个 STL 来测量相当重要。

C#

  • 统一为 Release 构建
  • 固定 .NET 版本
  • 记录 Server GC / Workstation GC 等条件
  • 写明是否使用 Tiered Compilation、ReadyToRun、Native AOT
  • 区分 cold 与 warm

C# 的表现会因 .NET 的配置差异而改变。 尤其是 JIT 的 C# 与 Native AOT 的 C#,同样叫“C#”,却是不同的维度。 把这两者混在一起,比较对象就不再是语言,而变成了分发形态。

Java

  • 固定 JDK 的厂商与版本
  • 写明使用的 GC
  • 固定 warm-up / measurement / fork
  • 记录堆大小与 JVM 选项
  • 区分 cold start 与 steady-state

Java 容易享受到 JIT 的好处,但另一方面,首次执行的表现变化很大。 因此,短生命周期进程的比较与长时间运行的比较必须分开。

Go

  • 固定 Go 的版本
  • 固定 GOMAXPROCS
  • 写明 CGO_ENABLED
  • 如果动了 GOGC,一定要记录下来
  • 可能的话保留 benchmark 格式的输出

Go 相对好处理,但在并行基准测试中,GOMAXPROCS 的影响很大。 另外,用不用 cgo 会让世界完全不同,这一点一定要写进条件里。

执行环境的统一方式

无论哪种语言,不统一环境的比较,基本上比的都是环境。

不统一环境的比较的真面目表示在 CPU、操作系统、电源条件、输入数据、优先级与核心数等环境没有统一的比较中,结果的差异不是语言的差异而是环境的差异的图。如果环境统一了如果没有统一环境没有统一的两次测量结果出现差异这个差异是什么的差异可以读作实现或语言一侧的差异只是在比较环境而已

图12:只有在消除了环境差异之后,才能把差异归到语言头上。

应该统一的项目

  • 相同的 CPU / 内存 / 存储
  • 相同的操作系统版本
  • 相同的电源条件
  • 接近相同室温的条件
  • 相同的输入数据
  • 相同的进程优先级
  • 相同的核心数条件
  • 相同的容器或裸金属条件

特别起作用的因素

电源设置与 CPU 频率

在笔记本电脑上,仅仅是接交流电还是用电池,就已经是两个世界。 如果 CPU governor 或 power mode 没有统一,比较结果会波动很大。

关于 Windows 下的电源条件、通知、后台噪声、发热、执行顺序的统一方式,在另一篇文章 如何比较 Windows 上不同版本程序的执行速度 中有详细整理。如果在 Windows 上测量,这部分影响相当大。

发热

如果只有最初几次快,后半段掉下去,就要怀疑发热或降频。 比起把 A 全部跑完再把 B 全部跑完,像 A / B / A / B 这样交替来跑,更能减少偏差。

发热的偏差与执行顺序表示如果只有最初几次快而后半段掉下去就要怀疑发热或降频,不要把 A 全部跑完再跑 B,而要把 A 与 B 交替来跑以减少偏差的图。只有后半段结果掉下去怀疑发热或降频不要把 A 全部跑完再跑 B把 A 与 B 交替来跑

图13:发热消不掉,但只要在顺序上下功夫,就能公平地分配给双方。

后台处理

更新、索引、同步、病毒扫描、浏览器、聊天工具。 这些看起来不起眼,但确实会捅刀子。

应该测量什么

在语言比较中,至少推荐把这 4 项分开来看。

1. wall-clock time

用户等待的实际时间。 这是最先应该看的指标。

2. CPU time

也就是“实际用了多少 CPU”。 如果只有 wall-clock 变快而 CPU time 没变,那可能是等待时间或 I/O 的影响。

3. memory / allocations

  • 最大 RSS
  • 总分配量
  • alloc 次数
  • GC 次数
  • GC pause

看这些,就能看到快的背后付出的成本。

4. 分布

  • 中位数
  • p95 / p99
  • min / max
  • 标准差或离散程度

只谈平均值,就看不清偶尔飞出去的那次处理到底是什么。

分开来看的四个指标表示语言比较中要分开来看用户等待的实际时间 wall-clock、实际使用的 CPU 时间、最大 RSS 与分配量等内存、中位数与百分位数等分布这四项的图。wall-clock time四项分开来看CPU time内存与分配分布〔中位数与 p95〕也单独看

图14:速度的数字不止一种,把背后的成本一起摆出来才读得懂。

推荐的执行步骤

在实际运维中比较好落地的流程,大致是这个顺序。

否是否是否是1. 确定 workload2. 固定公共数据集3. 先通过正确性校验全部实现的 checksum是否一致4. 固定 build 条件5. 区分 cold 与 warm6. 随机化执行顺序来跑是否达到了需要的次数8. 保存原始数据是否出现了有意义的差异记录条件与次数后结束9. 采集 profile 深挖原因

图15:从确定 workload 到正确性校验、随机化的正式测量、保存原始数据的执行步骤。

1. 确定 workload

首先明确想比较的是什么。

  • 启动时间
  • 稳定状态的 throughput
  • tail latency
  • 内存效率
  • 并行扩展性

2. 固定公共数据集

输入数据用固定 seed 或固定文件来统一。 如果连数据生成也包含进来,那也需要在各语言之间保持相同条件。

3. 先通过正确性校验

用小数据和大数据分别确认,全部实现返回相同的结果。 让程序输出 checksum 或哈希值会比较好处理。

4. 固定 build 条件

为每种语言做出 Release / 已优化的可执行形式,并记录版本与编译选项。

5. 区分 cold 与 warm

尤其是 C# 和 Java,这里很重要。

  • cold: 包含进程启动瞬间
  • warm: 若干次执行之后的稳定状态

把测量范围画成图,这两者是两回事就一目了然了。

作为 warm 测量的范围作为 cold 测量的范围稳定状态下的主体处理运行时初始化类加载进程启动主体处理第 1 次JIT 的首次编译主体处理第 2 次以后Tiered Compilation 推进

图16:cold 包含从进程启动到 JIT 首次编译的部分,warm 只测量稳定状态。

C++ 和 Go 既然是预先编译好的,就没有相当于“JIT 首次编译”的环节,运行时初始化也比较轻。cold 的差异大多产生于此。正因为如此,这两者不要混进同一张表会更清爽。

6. 交替或随机化执行顺序

例如:

cpp -> csharp -> java -> go
go -> java -> cpp -> csharp
csharp -> go -> java -> cpp
...

这样能减少发热和噪声带来的偏差。

7. 保证执行次数

轻量的 microbenchmark 要跑得相当多,end-to-end 至少也希望有 10 次以上。 差异本来就小,次数又少的话,解读会变得相当危险。

8. 保存原始数据

不只保存汇总结果,还要留下每次 run 的原始数据。 事后回看时,能读出离群值和 warm-up 的习性。

9. 出现差异后再采集 profile

出现差异时,才开始深挖原因。

  • CPU profile
  • allocation profile
  • GC 日志
  • flame graph
  • 操作系统一侧的 trace

做到这一步,讨论的就不再是“快 / 慢”,而是为什么会这样了。

结果的解读方式

数字出来之后,解读方式错了同样很危险。

只有首次执行时 C# / Java 较慢

要怀疑 JIT、类加载、初始化的影响。 这种情况下,

  • 如果启动时间很重要,这是有意义的差异
  • 如果主题是长时间运行,这是应该分到另一张表里的差异

C++ 在 tight loop 中很强

可能是底层优化、对象布局、极小的运行时开销在起作用。 但只看这一点就说“所以在实际服务中也最快”,是跳跃式的结论。

Go 在启动时间和易于分发上看起来占优

单一可执行文件、相对轻的启动、易于使用的并行模型,都可能起作用。 但这不代表在所有 CPU 类 workload 上都占优。

C# / Java 在 steady-state 下明显追上,甚至反超

可能是 JIT 的优化在起作用。 这也不是什么稀奇的事。 因此,不要把含启动的比较与稳定状态的比较混在一起很重要。

在 allocation-heavy 的处理中差异较大

这种情况下,比起语言名称,往往更起作用的是:

  • 内存布局
  • 字符串和 map 的处理方式
  • GC 的行为
  • 多余的拷贝
出现差异时的解读方式表示只有首次执行时 C# 或 Java 较慢就要怀疑 JIT 与初始化的影响,启动时间重要时它是有意义的差异,主题是长时间运行时它是应该分到另一张表的差异,出现差异后用 profile 深挖原因的图。含启动的差异稳定状态的差异出现了差异是什么条件下的差异主题是启动时间时它有意义作为长时间运行的比较来处理用 profile 深挖原因

图17:比起数字的大小,先确认这个差异属于哪个擂台。

记录模板

基准测试结果里,至少留下这些字段,事后会省心。

timestamp,language,scenario,run_kind,cold_or_warm,elapsed_ms,cpu_ms,max_rss_mb,alloc_bytes,gc_count,checksum
compiler_or_runtime,compiler_version,flags,os,cpu,threads,input_id,notes

例如,run_kind 可以这样区分。

  • micro
  • macro
  • startup
  • parallel

cold_or_warm 一定要写明是哪一种。

  • cold
  • warm

每一列要填什么,事先定好就不会摇摆。

列 填什么 格式示例
timestamp run 的开始时刻。用于事后查看时间段带来的波动 ISO 8601 格式。2026-03-17T10:00:00+09:00
language 实现的标识符。为防止写法不一致,使用固定词表 cpp / csharp / java / go
scenario 基准测试项目名 sort_int32_10m
run_kind 测量的种类 micro / macro / startup / parallel
cold_or_warm 是否包含启动 cold / warm
elapsed_ms wall-clock。保留到小数点后 3 位,事后不会为难 小数形式的毫秒
cpu_ms 进程的 CPU 时间。user 与 system 之和 小数形式的毫秒
max_rss_mb 最大 RSS 整数或小数形式的 MB
alloc_bytes 总分配字节数。取不到的语言就留空,并把留空这件事本身记录下来 整数,或空
gc_count GC 次数。C++ 中始终留空 整数,或空
checksum 用于正确性校验。全部实现是否一致要另行检查 十六进制字符串
compiler_or_runtime 处理系统的种类 msvc / dotnet / temurin / go
compiler_version 处理系统的版本。写到次版本号 处理系统输出的版本字符串
flags 优化条件。/O2、-O3 -flto、Server GC、GOMAXPROCS=8 等 用空格分隔的字符串
os / cpu / threads 执行环境 操作系统名与构建号、CPU 型号、使用的线程数
input_id 数据集的标识符。用文件的哈希值最稳妥 文件名与哈希值
notes 出现异常的 run 的备注 自由填写

要点是:测量值的列允许留空,并把留空这件事本身记录下来。“因为是 C++ 所以没有 gc_count”是一条信息,但整列删掉之后就没法事后判断了。

只看平均值会漏掉什么

汇总时把结果压成一个平均值,信息就飞了。下面是用于说明算法的虚构数字,不是任何语言的实测值。设想它是某一个实现的 10 次 run 按从快到慢排列的 elapsed_ms。

98, 99, 100, 101, 101, 102, 103, 104, 106, 720

从这 10 个数得到的代表值是这样的。

指标 值 解读
平均值 163.4 被最后那一次拖着,比实际的常用区间高出了六成左右
中位数 101.5 接近 10 次里 9 次的实际感受
min / max 98 / 720 相差 7 倍以上,是先去查离群值原因的信号
p95 / p99 算不出来 只有 10 个样本时,95 百分位和 99 百分位都没有意义

也就是说,只登载平均值的表,会把“偶尔大幅停顿的实现”和“稳定地稍慢一点的实现”变成同一张脸。就表的做法而言,下面这些差别很起作用。

观点 弱的结果表 能用的结果表
代表值 只有平均值 以中位数为主,并列出 min / max 与离散程度
试验次数 没有写 写明 run 数,以及是否剔除离群值的方针
cold / warm 混在一起,或者不加区分 分成不同的表,或者分成不同的行
正确性 没有提及 写明全部实现的 checksum 一致
条件 “在同一台电脑上测的” 写到操作系统、CPU、处理系统的版本、优化选项、线程数
原始数据 只有汇总值 同时写明原始 CSV 的保存位置

想登载 p95 或 p99,本来就需要增加 run 数。说白了就是:要谈分布,就得有能看出分布的样本数。

基准测试有时候比起测量本身,事后能否解读更重要。

一个平均值掩盖了什么表示只要有一次较大的离群值平均值就会大幅偏离常用区间,中位数更接近实际感受,min 与 max 相差很大时是去查离群值原因的信号,因此只有平均值的表很危险的图。只有一次出现较大离群值平均值向上偏离常用区间只有平均值的表掩盖了实态同时列出中位数与 min 和 max离群值是去查原因的信号

图18:平均值不说谎,但它对离群值的存在闭口不谈。

总结

C# / C++ / Java / Go 速度比较中真正重要的, 是把哪个语言最快这个粗糙的问题, 落实为针对哪种 workload,在什么条件下,用什么指标来比较 这样的实验形式。

尤其不容易出错的是这几点。

  • 把启动时间与稳定状态分开
  • 用相同的算法、相同的输入、相同的正确性校验来测量
  • 不要只用一个基准测试就下结论
  • 把语言内部的 benchmark 与跨语言的 benchmark 分开
  • 比起平均值,更看中位数与分布
  • 留下条件与原始数据

而最后最重要的是,不要过于想用语言名称来决定胜负。 现实中的性能,是由语言、运行时、库、构建条件、数据、操作系统、硬件综合作用决定的。

“C++ 快”“Java 强”“Go 轻”“C# 也够快”,这些说法在某种意义上全都是对的。 只是一旦缺了是在什么条件下这么说的,讨论就会在对不上号的状态下结束。

统一条件,用多种 workload,区分 cold 与 warm,一直看到分布。 虽然不起眼,但归根到底这才是最强的做法。

把粗糙的问题落实为实验形式表示把哪个语言最快这个粗糙的问题重新落实为针对哪种 workload、在什么条件下、用什么指标来比较的实验形式,统一条件、用多种 workload、区分 cold 与 warm、一直看到分布才是最强做法的图。哪个语言最快重新落实为实验的形式确定 workload、条件与指标区分 cold 与 warm 来跑看到分布,并与条件一起留存

图19:把问题重新提对,才是本文最重要的结论。

参考资料

  • BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html

  • BenchmarkDotNet Setup and Cleanup(可以使用 [IterationSetup] 的条件) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html

  • OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/

  • JMH Level javadoc(Level.Invocation 的限制与警告) https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Level.html

  • JMH GitHub Repository / README https://github.com/openjdk/jmh

  • Go testing package https://pkg.go.dev/testing

  • Go benchstat https://pkg.go.dev/golang.org/x/perf/cmd/benchstat

  • Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html

  • 如何比较 Windows 上不同版本程序的执行速度 /zh-CN/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/

相关主题

与本文一起阅读会更容易理解的页面。

本主题的咨询渠道

性能比较的设计、测量条件的统一、结果的解读、原因的深挖,都是与下列服务相当契合的主题。

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

C#、C++、Java、Go 到底哪个最快?
没办法用一个数字来决定。因为现实中的性能是由语言、运行时、库、构建条件、数据、操作系统、硬件综合作用决定的。重要的是把“哪个语言最快”这个问题,落实为“针对哪种 workload,在什么条件下,用什么指标来比较”这样的实验形式。在启动时间上,C# 与 Java 的 JIT 和初始化成本比较容易显现;而在 steady-state 下,JIT 优化生效后往往会明显追上,甚至反超,这种情况并不少见。
为什么在 C# 或 Java 的基准测试中要区分 warm-up?
因为 C# 和 Java 通常会受到 JIT 的影响,只测量首次执行时,测到的不仅是程序本身的速度,还会连带测到运行时启动、类加载、JIT 准备。而 C++ 和 Go 通常是预先编译好的。cold 和 warm 都有意义,但意义并不相同,因此应该把包含进程启动瞬间的 cold,与经过若干次执行后进入稳定状态的 warm 当作两回事来处理,不要混进同一张表里。
跨语言的基准测试应该如何设计?
推荐两层结构。语言内部的测量使用适合该语言的工具,例如 BenchmarkDotNet(C#)、JMH(Java)、go test -bench 与 benchstat(Go)、Google Benchmark(C++)。跨语言比较时,直接把各个工具的结果并排放在一起是危险的,比较稳妥的做法是把各个实现做成能用同一套 CLI 约定调用的可执行文件,再由外层的统一 runner 负责随机化执行顺序、区分 cold 与 warm、使用同一数据集、校验 checksum、保存原始数据。
在 C++ 的微基准测试中需要注意什么?
需要注意优化会让处理消失的陷阱。如果编译器判断“这个计算结果没有任何地方用到”,就可能把处理本身消除掉,结果不是变快了,而是根本什么都没有做。因此,让结果被实际使用、输出 checksum,以及基准测试框架抑制优化的功能都很重要。此外,C++ 的条件差异会直接放大为结果差异,所以写清楚用哪个编译器、哪些编译选项(-O3/O2、LTO、PGO 等)、哪个 STL 实现来测量,是相当重要的。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表