如何公平比较 C#/C++/Java/Go 的运行速度
· 小村 豪 · Benchmark, Performance, C#, C++, Java, Go
「据说 C++ 很快。」 「Go 在实际生产环境中很轻量。」 「Java 长时间运行后反而相当快。」 「C# 因为有 .NET 的 JIT,其实也意外地强。」
这类话经常能听到。 但这里最不应该做的事情是:把不同人在不同环境下测出来的数字并排放在一起,直接当作语言优劣的结论。
C# 和 Java 容易受到 JIT 与 warm-up 的影响,而 C++ 和 Go 通常是预先编译好的。 GC 的存在与否、特性也各不相同。标准库和周边库的实现差异也有相当大的影响。再加上,即便在同一台机器上,电源设置、发热、后台任务、输入数据的偏差都很容易让结果产生波动。这是一个相当「泥泞」的世界。
本文将整理尽可能公平地比较 C# / C++ / Java / Go 的测量方法。 先说结论:最重要的是不要试图用一个数字来断定「哪个语言最快」。
本文的主题始终是比较方法本身的整理。 把与环境高度相关的数字摆出来展示,很容易变得像是在算命,所以本文不会给出实测排名,而是专注于怎样设计才能让比较真正有价值。
先说结论
在 C# / C++ / Java / Go 的速度比较中,真正重要的有以下 7 点:
-
先明确到底要比较什么样的「速度」 是启动时间、稳态下的吞吐量(throughput)、p95 延迟,还是内存效率,测量方式都会随之改变。
-
不要只靠一个基准测试就下结论 在 CPU 计算、内存分配、并行处理、启动时间等不同场景下,各语言和运行时的强弱表现会有所不同。
-
C# 和 Java 要把 cold 和 warm 分开 如果把包含首次执行的比较,和 warm-up 之后的稳态比较混在一起,讨论就会走偏。
-
用相同的算法、相同的输入、相同的正确性校验来测量 「测的不是更快的实现,而是在解决不同的问题」,是基准测试中常见的坑。
-
把语言内部的微基准测试与跨语言的端到端基准测试分开 各语言专用的工具很方便,但跨语言比较更适合用外层统一的 runner 来执行。
-
不能只看平均值,还要看中位数与分布 哪怕只有一次触发 GC 或后台处理,平均值也会被破坏。
-
不仅要留下数字,还要留下条件 基准测试结果不仅是速度的记录,同时也是实验条件的记录。没有记录条件的结果,事后往往非常痛苦。
首先应该确定的事情
如果只用一个笼统的「快」字来描述,基本上都会出问题。 首先要确定的是把什么定义为「快」。
举例来说,即使是同一个程序,想要观察的角度也可能完全不同。
1. 想看的是启动时间吗
对于 CLI 工具、短生命周期的批处理、启动后很快结束的辅助工具而言,cold start 或 进程启动开销 是关键。 在这个维度上,是否包含 JIT 和类加载的初始化成本,会让结果产生很大差异。
2. 想看的是长时间运行下的吞吐量吗
对于服务器、常驻进程、worker、长时间运行的转换处理而言,稳态下的吞吐量(steady-state throughput) 更为重要。 这种情况下,首次执行较慢本身并不是重点,重点在于 warm-up 之后能稳定提升到什么程度。
3. 想看的是尾部延迟(tail latency)吗
对于 API、UI、偏实时性的处理而言,比起平均值,p95 / p99 有时更重要。 即使平均速度很快,如果偶尔出现明显卡顿,从用户体验或 SLA 的角度来看依然很痛苦。
4. 是否也要包含内存效率
除了 CPU 时间之外,如果不看最大 RSS、分配量、GC 次数、GC pause,就很容易误判实际生产环境中的负载情况。 「快但相当吃内存」和「稍慢但稳定轻量」,在不同场景下的评价可能是相反的。
归根结底,首先应该确定的问题是:
这次比较想知道的并不是哪个语言更快,而是 针对哪种 workload,在什么条件下,用什么指标能处理得更快
在这一点还模糊不清的情况下就开始收集数字,最终往往整理不出结果。
为什么语言比较很难
把 JIT 和 AOT 混在一起,就变成了另一种实验
C# 和 Java 通常会受到 JIT 的影响。 而 C++ 和 Go 通常是预先编译好的。
也就是说,如果只测量首次执行,测到的不只是程序本身的速度,还会连带测到运行时启动、类加载、JIT 准备等开销。 反过来,如果只看充分 warm-up 之后的表现,那测的就变成了稳态优化能发挥到什么程度的比较。
两者都有意义。 但意义并不相同。
实现上的差异往往比语言本身的差异更大
即使同样是「排序」,也可能出现:
- 一方使用标准库
- 一方是自行实现
- 一方存在多余的拷贝
- 一方每次都重新生成输入数据
仅凭这些差异,结果就可能相差很大。
再进一步,涉及 JSON、压缩、加密、正则表达式这类处理时,库的实现往往比语言本身的影响更大。 因此,如果不明确说明测的是什么,「语言比较」很容易变成「库比较」。
C++ 存在「优化导致代码被消除」的陷阱
尤其是在微基准测试中,如果编译器判断「这个计算结果没有任何地方用到」,就可能直接把处理整体消除掉。 结果就变成了一个略带诡异的现象:不是变快了,而是根本什么都没做。
这个问题在 C++ 中尤其容易明显出现,因此让结果被实际使用、输出 checksum,或者利用基准测试框架的优化抑制功能,都相当重要。
GC 的存在既不是「劣势」也不是「优势」,而是一种特性
C#、Java、Go 都带有 GC。 简单地认为「有 GC 所以慢」是过于笼统的说法。
实际上,更关键的因素是:
- 如何处理大量短生命周期对象
- 堆大小设置
- GC 的频率与 pause
- 对象布局
- 库本身的分配习惯
而 C++ 可以通过手动管理或 RAII 进行精细控制,但相应地,设计和实现上的差异也更容易显现出来。 换句话说,管理方式的不同,本身并不代表孰优孰劣。
比较时不应该做的事
1. 把 Debug 和 Release 混在一起
这是绝对不行的。 比较对象必须统一为接近生产环境的优化构建。
2. 解决的并不是同一个问题
输入格式不同、输出不同、只有一方缺少错误处理、内存复用策略不同。 如果放任这些差异不管,测到的就不是速度,而是需求上的差异。
3. 只执行一次就下结论
只执行一次,基本上测到的都是噪声。
- JIT
- 页缓存
- CPU 的 boost
- 发热
- 后台任务
- GC
- 首次文件读取
这些因素都会在一次执行中混在一起。
4. 混用 warm-up
在测量 C# 和 Java 时,如果没有明确「是否包含首次执行」还是「只看 warm-up 之后」,讨论就会站不住脚。 应该把 cold 和 warm 当作两种不同的东西 来处理。
5. 不做正确性校验
基准测试比「快」更先要保证的是「返回相同的结果」。 必须确认所有被比较的实现,在相同输入下得到相同的 checksum 或相同的输出。
6. 只靠一个微基准测试就断定全局结论
即使在紧密循环(tight loop)中获胜,也不代表在整个实际服务中同样占优。 反过来,即使在启动时间上落后,在长时间运行中也可能表现得相当强。
比较 C# / C++ / Java / Go 时的基本方针
这一部分相当重要。 推荐采用两层结构。
1. 语言内部的测量,使用适合该语言的工具
每种语言都有能够帮忙处理该语言特有问题的基准测试工具。
- C#:BenchmarkDotNet
- Java:JMH
- Go:
go test -bench与benchstat - C++:Google Benchmark
这些工具能在一定程度上帮忙处理各自运行时的特性、统计处理,以及测量中的各种陷阱。 对于语言内部的比较或深入挖掘实现细节来说相当有效。
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
然后由外层统一的 runner 负责:
- 随机化执行顺序
- 区分 cold / warm
- 使用相同的数据集
- 校验 checksum
- 记录 wall-clock 与内存
- 把原始数据保存为 CSV / JSON
这样一来,就更容易把各语言内部的最佳实践与跨语言比较的公平性区分开来处理。
具体示例:应该准备哪些基准测试项目
如果被问到「想比较 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 等阶梯设置
相比单纯的紧密循环,这更容易观察到并行执行时的扩展性。
4. startup_noop 或 startup_parse_small
目的: 观察启动时间
noop:启动后立即结束parse_small:只处理一次小规模输入后结束
在这一项中,C# / Java 的 JIT 与初始化成本比较容易显现出来,与 C++ / Go 的表现会有相当大的差异。 反过来说,即使在这里出现差距,也和长时间运行场景下的胜负是两件事。
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 / 内存 / 存储
- 相同的操作系统版本
- 相同的电源条件
- 尽量相近的室温条件
- 相同的输入数据
- 相同的进程优先级
- 相同的核心数条件
- 相同的容器或裸金属条件
特别容易产生影响的因素
电源设置与 CPU 频率
在笔记本电脑上,仅仅是接电源还是用电池,就足以造成完全不同的结果。 如果 CPU governor 或 power mode 不统一,比较结果会产生相当大的波动。
关于 Windows 下的电源条件、通知、后台噪声、发热、执行顺序的统一方法,在另一篇文章 如何比较 Windows 上不同版本程序的执行速度 中有更详细的整理。如果是在 Windows 上进行测量,这部分的影响相当大。
发热
如果只有最初几次执行较快、后面逐渐变慢,就应该怀疑是发热或降频(throttling)的影响。 比起把 A 全部跑完再跑 B,采用 A / B / A / B 这样交替执行的方式,更能减少偏差。
后台处理
系统更新、索引、同步、病毒扫描、浏览器、聊天工具。 这些看起来不起眼,但实际上经常会产生影响。
应该测量什么
在语言比较中,至少建议区分以下 4 个指标来观察。
1. wall-clock time
也就是用户实际等待的时间。 这是首先应该看的指标。
2. CPU time
即「实际使用了多少 CPU」。 如果 wall-clock 很快但 CPU time 没有变化,可能是等待时间或 I/O 的影响。
3. 内存 / 分配情况
- 最大 RSS
- 总分配量
- 分配次数
- GC 次数
- GC pause
观察这些指标,可以看到「速度」背后隐藏的成本。
4. 分布
- 中位数
- p95 / p99
- 最小值 / 最大值
- 标准差或离散程度
如果只谈平均值,就很难看清偶尔出现的异常处理的真实情况。
推荐的执行流程
比较容易落地的流程大致是这样的顺序。
1. 确定 workload
首先要明确想比较的是什么。
- 启动时间
- 稳态吞吐量
- 尾部延迟
- 内存效率
- 并行扩展性
2. 固定公共数据集
输入数据应使用固定 seed 或固定文件来统一。 如果连数据生成过程也包含在内,那么这部分也需要在各语言之间保持相同的条件。
3. 先完成正确性校验
需要确认在小数据和大数据下,所有实现都能返回相同的结果。 让程序输出 checksum 或哈希值会更便于处理。
4. 固定构建条件
为每种语言构建 Release / 优化过的可执行文件,并记录版本和编译选项。
5. 区分 cold 和 warm
尤其是 C# 和 Java,这一点相当重要。
- cold: 包含进程启动瞬间
- warm: 经过若干次执行后的稳定状态
这两者不要混在同一张表里,分开会更清晰。
6. 交替或随机化执行顺序
例如:
cpp -> csharp -> java -> go
go -> java -> cpp -> csharp
csharp -> go -> java -> cpp
...
这样可以减少发热或噪声带来的偏差。
7. 确保足够的执行次数
如果是轻量的微基准测试,次数可以相当多;如果是端到端测试,至少也应该有 10 次以上。 如果差异本身很小而执行次数又不够,解读结果就会变得相当危险。
8. 保存原始数据
不要只保留汇总结果,还要保留每次运行的原始数据。 事后查看时,可以从中发现异常值或 warm-up 的特征。
9. 出现差异后再进行 profile 分析
只有在出现差异之后,才有必要深入挖掘原因。
- CPU profile
- 内存分配 profile
- GC 日志
- flame graph
- 操作系统侧的 trace
做到这个程度,讨论的重点就不再是「快 / 慢」,而是为什么会这样。
如何解读结果
即使数字已经出来,如果解读方式错误,依然会有风险。
只是首次执行时 C# / Java 较慢
应该怀疑是 JIT、类加载、初始化的影响。 在这种情况下:
- 如果启动时间很重要,这是一个有意义的差异
- 如果主题是长时间运行,这个差异应该分到另一张表中
C++ 在紧密循环中表现强劲
可能是底层优化、对象布局、极小的运行时开销起了作用。 但仅凭这一点就断言「因此在实际服务中也最快」,是一种跳跃式的结论。
Go 在启动时间或易于分发方面看起来更有优势
单一可执行文件、相对轻量的启动、易于使用的并行模型,都可能带来这种效果。 但这并不意味着在所有 CPU 类 workload 中都占优势。
C# / Java 在 steady-state 下明显追上甚至反超
很可能是 JIT 优化生效的结果。 这种情况并不罕见。 因此,不要把「包含启动的比较」与「稳态比较」混在一起非常重要。
在分配密集型处理中差异较大
这种情况下,比起语言名称,更值得关注的往往是:
- 内存布局
- 字符串或 map 的处理方式
- GC 的行为
- 多余的拷贝
这些因素往往影响更大。
记录模板
基准测试结果最好至少保留以下这些字段,事后会更省心。
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 可以像这样区分:
micromacrostartupparallel
cold_or_warm 也一定要明确标注是哪一种:
coldwarm
基准测试往往比起测量本身,事后能否正确解读更重要。
总结
C# / C++ / Java / Go 速度比较中真正重要的是,把哪个语言最快这个笼统的问题, 转化为针对哪种 workload、在什么条件下、用什么指标来比较这样的实验形式。
尤其不应忽略以下几点:
- 把启动时间和稳态分开
- 用相同的算法、相同的输入、相同的正确性校验来测量
- 不要只靠一个基准测试就下结论
- 把语言内部的基准测试与跨语言的基准测试分开
- 关注中位数与分布,而不仅仅是平均值
- 保留条件与原始数据
最后,最重要的一点是:不要过度依赖语言名称来判断胜负。 现实中的性能是由语言、运行时、库、构建条件、数据、操作系统、硬件综合作用决定的。
「C++ 快」「Java 强」「Go 轻量」「C# 也足够快」,从某种意义上说,这些说法都没有错。 但如果缺少在什么条件下这样说这一前提,讨论往往就会变成雾里的混战。
统一条件,覆盖多种 workload,区分 cold / warm,并观察分布情况。 虽然看起来不起眼,但归根结底,这才是最有效的方法。
参考资料
-
BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html
-
OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/
-
JMH GitHub Repository / README https://github.com/openjdk/jmh
-
Go
testingpackage https://pkg.go.dev/testing -
Go
benchstathttps://pkg.go.dev/golang.org/x/perf/cmd/benchstat -
Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html
-
如何比较 Windows 上不同版本程序的执行速度 https://comcomponent.com/zh-CN/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/
相关主题
搭配本文一起阅读会更容易理解的页面:
本主题的咨询渠道
性能比较的设计、测量条件的统一、结果的解读、原因的深入挖掘,都是与以下服务相当契合的主题:
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows 应用安全处理子进程的清单 - Job Object、退出传播、标准输入输出、watchdog 最佳实践
在 Windows 应用中安全处理子进程,比起选择启动 API,更重要的是设计进程树的所有权和结束流程。本文整理 Job Object、退出传播、标准输入输出与 watchdog。
共享内存的陷阱与实务最佳实践
整理在实务中使用共享内存时的陷阱,以及涵盖同步、可见性、生命周期、ABI、权限等降低事故率的设计方法。
在 Windows 上正确比较不同版本程序运行速度的方法
本文整理在 Windows 上比较程序版本差异时如何提升可复现性,涵盖 power mode 与 power plan 的区分、散热与背景噪声的控制、测量指标的选择,以及差异较小时如何用 ETW / WPR 深入排查原因。
如何从 C/C++ 调用 C# Native AOT DLL
本文整理如何把 C# 类库通过 Native AOT 发布为原生 DLL,并通过 UnmanagedCallersOnly 的入口点让 C/C++ 调用它,从适用场景、实现模式到注意事项逐一梳理。
把OSI参考模型彻底看懂 ── 拆解一个HTTP请求里的七层结构
本文不靠死记硬背来理解OSI参考模型,而是从实物出发。用C#组装出一个承载HTTP GET请求的Ethernet帧并进行解剖,通过十六进制转储和Wireshark确认L2〜L7如何以字节序列的形式层层物理嵌套在一起。文章从实务角度梳理各层与.NET API(HttpClie...
常见问题
汇总了咨询这一主题时常见的问题。
- 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,并保存原始数据(raw data)。
- 在 C++ 的微基准测试中需要注意什么?
- 需要特别注意「优化导致代码被消除」的陷阱。如果编译器判断「这个计算结果没有任何地方用到」,就可能直接把整段处理消除掉,结果不是变快了,而是根本什么都没执行。因此,让结果被实际使用、输出 checksum,以及利用基准测试框架抑制优化的功能都很重要。此外,C++ 的条件差异会直接体现为很大的结果差异,所以明确记录使用了哪个编译器、哪些编译选项(-O3/O2、LTO、PGO 等)、哪个 STL 实现进行测量,是相当重要的一环。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。