本文对Java、Go和Rust之间的对比并非完全是基准测试,更多的是比较输出的可执行文件大小、内存使用情况、CPU使用率、运行时要求,当然会有一个小基准测试用于获取RPS数据,使得更容易理解这些数值。
为了尝试更合理比较这三者,我在这次比较中分别用每种语言写了个Web服务。该Web服务非常简单,提供了3个REST端点。
三个Web服务的存储库托管在GitHub[1]上。
制品大小
介绍下我是如何构建二进制文件的。在Java示例中,我使用maven-shade-plugin[2]插件并使用mvn package命令,Go则使用go build命令,最后是Rust则使用cargo build --release。
制品编译的大小也取决于所选的库/依赖项,因此,如果它们膨胀了,那么编译的程序也会是同样的结果。在此处特定情况下,对于我所选择的库,上图显示是程序编译的大小。
在下面单独的部分中,我将构建所有三个程序并打包成Docker镜像,并列出它们的大小,以及显示每种语言所需的运行时开销。更多细节如下。
内存使用情况
空闲状态
什么?在空闲运行时显示内存占用的Go和Rust版本的条形图在哪?好吧,它们是有的,只是Java在JVM启动程序,处于空闲时,什么都不做的情况下,就消耗了高达160MB的内存。在Go的情况下,程序使用了0.86 MB,在Rust的情况下使用0.36 MB。这是一个非常大的区别!在这里,Java比Go和Rust对应的程序多用了两个数量级的内存,只是空跑内存什么也不做。这是对资源的巨大浪费。
提供REST请求
我们使用wrk[3]来请求API,并观察内存和CPU使用情况,以及三个版本的程序的每个端点在我的机器上的每秒请求数。
wrk -t2 -c400 -d30s http://127.0.0.1:8080/hello
wrk -t2 -c400 -d30s http://127.0.0.1:8080/greeting/Jane
wrk -t2 -c400 -d30s http://127.0.0.1:8080/fibonacci/35
以上wrk命令表示,使用两个线程(用于wrk),并在池中保持400个开启的连接,并反复调用GET端点,持续30秒。此处我只使用两个线程,是因为wrk跟被测试的程序都运行在同一台机器上,因此我不想它们在可用资源,尤其是CPU上相互竞争。
每个Web服务都单独测试,且每次运行测试都会重启Web服务。
以下是每个版本的程序三次运行中的最佳结果。
/hello
该端点返回一个“Hello, World!”的消息。它分配字符串 "Hello, World!",并将其序列化,以JSON格式返回。
/greeting/{name}
该端点接受段路径参数{name},然后将字符串"Hello,{name}"格式化,序列化并返回以JSON格式的问候信息。
/fibonacci/{number}
该端点接受段路径参数{number}并以JSON格式序列化返回输入的数字和斐波那契数。
对于这个特定的端点,我选择用递归的形式来实现它。我知道,毫无疑问,迭代实现可以获得更好的性能结果,而且出于生产目的,应该选择迭代形式,但是在生产代码中,有些情况下必须使用递归(非特指专用于计算斐波那契数)。因此我想让这个实现与CPU堆栈分配密切相关。
在/fibonacci端点测试中,Java实现是唯一一个出现150次请求超时的,wrk输出如下所示:
运行时大小
为了模仿真实世界的云原生应用,并消除"它在我的机器上正常!"这种情况,我为这三个应用分别创建了一个Docker镜像。
Docker源文件包含在存储库中相应程序的文件夹下。
我使用openjdk:8-jre-alpine作为Java应用的基础运行时镜像,它是已知的最小的镜像之一。然而这带来了一些需要注意的事项,可能适用,也可能不适用于你的应用。主要是alpine镜像在处理环境变量名方面不符合posix标准,所以你不能在Docker文件中使用带.(点)的ENV(也不是什么大事),另一个是alpine Linux镜像是用musl libc而不是glibc编译的,这意味着如果你的应用程序依赖于需要glibc存在的东西,它就无法工作。就我而言,alpine很好用。
至于Go和Rust版本的应用,我使用了静态编译,这意味着它们在运行时镜像中不需要任意libc(glibc、musl等等),也意味着它们不需要一个带OS的基础镜像来运行。所以我使用了scratch镜像,这是一个no-op(无操作?)镜像,它托管编译后的可执行文件,零开销。
我使用的Docker镜像命名约定是{lang}/webservice。Java、Go和Rust版本的应用程序的镜像大小分别是113MB、8.68MB和4.24 MB。
结论
在得出任何结论之前,我想指出这三种语言之间的关系(或者说缺乏关系)。Java和Go都是垃圾收集型语言,然而,Java是提前编译(AOT)为在JVM上运行的字节码。当Java应用程序启动时,会调用Just-In-Time(JIT)编译器来优化字节码,随时随地将其编译成本地代码,以提高应用程序的性能。
Go和Rust都是提前编译成原生代码,在运行时不会发生进一步的优化。
Java和Go都是垃圾收集类型语言,存在STW的副作用。意味着每当垃圾回收器运行的时候,它就会停止应用程序,进行垃圾回收,当垃圾回收结束后再从之前的状态中恢复。大部分垃圾回收器需要停止程序,但是也有一些实现不需要这样子。
当Java在90年代诞生时,它最大的卖点之一就是“一次编写,随处运行”。在当时这是非常棒的,因为当时市场上还没有很多虚拟化解决方案。如今,大多数CPU都支持虚拟化,在代码可以在任何地方(无论在任何受支持的平台上)运行的前提下,使用一种语言进行开发的诱惑就消失了。Docker和其他解决方案提供了廉价的虚拟化。
在整个测试过程中,Java版本的应用比Go或Rust对应的应用消耗了更多的内存,在数量级上,前两次测试中,Java使用的内存大约多出8000%。这意味着对于现实世界的应用来说,Java应用的运营成本更高。
在前两项测试中,Go应用程序的CPU使用量比Java少了20%左右,而服务的请求却多了38%。另一方面,Rust版本的CPU使用量比Go少57%,而服务的请求量却多13%。
第三个测试在设计上就是CPU密集型的,我希望尽可能地利用CPU。Go和Rust都比Java多利用了1%的CPU。而且我想如果wrk不是在同一台机器上运行的话,这三个版本的CPU都会达到100%的上限。在内存方面,Java比Go和Rust多利用了2000%以上的内存。Java比Go多服务20%左右的请求,而Rust比Java多服务15%左右的请求。
在写这篇文章的时候,Java编程语言已经存在了近三十年,这使得在市场上找到Java开发者相对容易一些。另一方面,Go和Rust都是相对较新的语言,所以相对于Java来说,自然而然的数量或开发人员就少了。尽管如此,Go和Rust都得到了很多关注,许多开发人员在新项目中采用了它们,并且有许多在生产环境中运行的项目使用Go和Rust,因为简单地说,它们在资源需求方面比Java更高效。(也可能是因为它们是比较新的酷炫语言)
我在写这篇文章的程序时,我学会了Go和Rust。就我而言,Go的学习曲线很短,因为它是一门比较容易上手的语言,而且语法相对于其他语言来说也很小。我只花了几天时间就用Go写好了程序。关于Go,有一点需要注意的是编译速度,我不得不承认,与Java/C/C++/Rust等其他语言相比,Go的编译速度极快。Rust版本的程序我花了一周左右的时间才完成,我不得不说,大部分时间都花在了借用检查器(borrow checker)上。Rust有严格的所有权规则,但一旦掌握了Rust中所有权和借用的概念,编译器的错误信息就会突然变得更有意义。Rust编译器之所以在违反借用检查规则时对你“谆谆教诲”(无情报错),是因为编译器希望在编译时证明分配的内存的生存期和所有权。通过这样做,它保证了程序的安全性(例如:没有悬空指针,除非使用了不安全的代码逃逸), 并且在编译时确定了释放位置,从而消除了对垃圾收集器的需求和运行时成本。当然,这是以学习Rust的所有权系统为代价的。
就竞争而言,在我看来,Go是Java(通常是JVM语言)的直接竞争对手,但不是Rust的竞争对手。另一方面,Rust是Java、Go、C和C++的有力竞争者。
因为它们的效率,我认为我自己将会用Go和Rust写更多的程序,但很可能用Rust写得更多。它们都很适合于Web服务、CLI、系统程序等等的开发。然而,Rust比Go有一个根本的优势。它不是一种垃圾收集的语言,而且与C和C++相比,它的设计是为了安全地编写代码。例如,Go并不特别适合用来写操作系统内核,这也是Rust的优势所在,它与C/C++竞争,因为它们是长期存在的、事实上的写操作系统的语言。Rust与C/C++竞争的另一个层面是在嵌入式世界,我们将在以后再讨论这个。
感谢您的阅读!