Java 做微服务能像 Go 一样快吗?
我们在本文中提出一个问题:“Java 微服务能像 Go 一样快吗?”为此,我们创建了一系列微服务并进行了基准测试,并在会议上展示了我们的成果。但其中还有不少可以探索的空间,因此我们决定将在本文中进一步探讨。
1
背景介绍
我们希望通过实验了解 Java 微服务在运行速度上能否达到 Go 微服务的水平。目前,软件行业普遍认为 Java 已经过于陈旧、缓慢且无聊。而 Go 则成了快速、崭新以及酷炫的代名词。真是这样吗?我们想从数据的角度看看这样的印象是否站得住脚。
我们希望建立一个公平的测试,因此创建了一项非常简单的微服务,其中不含外部依赖项(例如数据库),而且代码路径非常短(仅处理字符串)。我们在其中包含有指标及日志记录,因为似乎一切微服务都或多或少包含这些内容。
另外,我们使用了小型、轻量化的框架(Helidon for Java 以及 Go-Kit for Go),两袖清风尝试了 Java 的纯 JAX-RS。我们也尝试了不同版本的 Java 与不同 JVM。我们对堆大小及垃圾收集机制做出基本调整,并在测试运行前对微服务进行了预热。
2
Java 的发展历史
Java 由 Sun Microsystems 公司开发,后被甲骨文所收购。其 1.0 版本发布于 1996 年,目前的最新版本是 2020 年的 Java 15。Java 当前的主要设计目标,在于实现 Java 虚拟机及字节码的可移植性,外加带有垃圾回收的内存管理机制。
时至今日,Java 作为一种开源语言仍是全球最受欢迎的语言选项之一(根据 StackOverflow 及 TIOBE 等来源)。
下面来聊聊“Java 问题”。人们对于它速度缓慢的印象其实更多是种固有观念,而不再适应当下的事实。
如今的 Java 甚至拥有不少性能敏感区,包括存储对象数据堆、用于管理堆的垃圾收集器,外加准时化(JIT)编译器。
多年以来,Java 曾先后使用多种不同的垃圾收集算法,包括串行、并行、并发标记 / 清除、G1 以及最新的 ZGC 垃圾收集器。现代垃圾收集器旨在尽可能减少垃圾收集造成的暂停时长。
甲骨文实验室开发出一款名为 GraalVM 的 Java 虚拟机,其使用 Java 编写而成,具有新的编译器外加一系列令人兴奋的新功能,包括可以将 Java 字节码转换为无需 Java 虚拟机即可运行的原生镜像等。
3
Go 的发展历史
Go 语言由谷歌的 Robert Griesemer、Rob Pike 以及 Ken Thomson 开发而成。他们几位也是 UNIX、B、C、Plan9 以及 UNIX 视窗系统等项目的主要贡献者。
作为一种开源语言,Go 的 1.0 版本发布于 2012 年,2020 年最新版本为 1.15。Go 语言的本体、采用速度以及工具生态系统的发展都相当迅猛。
Go 语言受到 C、Python、JavaScript 以及 C++ 的影响,已经成为一种理想的高性能网络与多处理语言。
截至我们发布主题演讲时,StackOverflow 上共有 27872 个带有“Go”标签的问题,Java 则为 1702730 个。
Go 是一种静态类型的编译语言,其语法类似于 C,且拥有内存安全、垃圾回收、结构化类型以及 CSP 样式并发(通信顺序过程)等功能特性。
Go 还使用名为 goroutine 的轻量级进程(并非操作系统线程),外加各进程间用于通信的通道(类型化,FIFO)。Go 语言不提供竞态条件保护。
Go 是众多 CNCF 项目的首选语言,例如 Kubernetes、Istio、Prometheus 以及 Grafana 等皆是由 Go 语言编写而成(或者大部分是)。
Go 语言在设计上强调快速构建与快速执行。到底是两个空格还是四个空格?Go 语言表示不用麻烦,无所谓。
与 Java 相比,我将个人体会到的 Go 语言优势整理如下:
更易于实现函数模式,例如复合、纯函数、不可变状态等。
样板代码少得多(但客观上仍然太多)。
Go 语言仍处于生命周期早期,因此没什么向下兼容压力——改进道路较为平坦。
Go 代码可编译为原生静态链接的二进制文件——无虚拟机层——二进制文件中包含程序运行所需要的一切,因此更适合“从零开始”的容器。
体积更小、启动速度快、执行速度快。
无 OOP、继承、泛型、断言、指针算术。
括号较少,例如可以实现为 if x > 3 { whatever }
强制执行,没有循环依赖性,不存在未使用的变量或导入,没有隐式类型转换。
但 Go 当然也不完美。与 Java 相比,我认为 Go 存在以下问题:
工具生态系统还不成熟,特别是依赖项管理方面虽有多种选择,但还都不完美。在非开源开发方面,Go 模块在依赖项管理上优势明显,但由于存在某些兼容性问题,其采用率仍不算特别高。
构建具有新的 / 更新依赖项的代码时非常缓慢(例如 Maven 著称的「下载互联网」问题)。
导入会将代码绑定至 repo,导致代码移动非常困难。
IDE 非常适合编程、文档查找与自动补全等功能,但却难以进行调试及概要分析等。
指针!我以为二十一世纪之前就可以告别这东西了,但 Go 里面还有!好在至少已经没有指针算法了。
没有 Java 那样的 try/catch 异常(最终总是要用到 if err != nil),也没有列表、映射函数等函数风格的原语。
某些基本算法仍然缺失,所以用户往往只能自行编写。最近我就编写了一些代码,用 sloe 对两个字符串(列表)进行比较以及转换。在函数语言中,我们完全可以使用 map 等内置算法完成。
没有动态链接!如果要在静态链接代码当中使用 GPL 等许可,就会很不方便。
用于调整执行、垃圾收集、概要分析或者优化算法的选项很少。Java 拥有数百种垃圾收集调整选项,相比之下,Go 只有一项。
4
负载测试方法
我们使用 JMeter 进行负载测试。测试多次调用服务,并收集关于响应时间、吞吐量(每秒事务)以及内存使用情况的数据。在 Go 方面,我们主要收集常驻集大小,Java 方面则主要跟踪原生内存。
在多项测试中,我们都将 JMeter 与被测应用程序放置在同一台计算机上运行。经过对比,我们发现在其他机器上运行 JMeter 几乎不会对结果造成任何影响。
后续在将应用程序部署到 Kubernetes 中时,我们会考虑将 JMeter 运行在集群之外的远程计算机之上。
在进行测试之前,我们使用 1000 项服务调用对应用程序进行了预热。
应用程序本体的源代码以及负载测试定义请参见 GitHub repo:
https://github.com/markxnelson/go-java-go
5
首轮测试
在第一轮测试中,我们在小型机器上运行测试,搭载了 2.5 GHz 双核英特尔酷睿 i7 的笔记本电脑,具有 16 GB 内存并运行 MacOS。我们运行了 100 个线程,每个线程 10000 个循环,再额外加个 10 秒的启动时间。Java 应用程序运行在 JDK 11 与 Helidon 2.0.1 之上。Go 应用程序则使用 Go 1.13.3 进行编译。
测试结果如下:
应用程序 | 日志记录 | 预热 | 平均响应时间(毫秒) | 事务 /秒 | 内存(RSS)(开始/结束) |
---|---|---|---|---|---|
Golang | 是 | 否 | 5.79 | 15330.60 | 5160KB / 15188KB |
Golang | 否 | 否 | 4.18 | 20364.11 | 5164KB / 15144KB |
Golang | 否 | 是 | 3.97 | 21333.33 | 10120KB / 15216KB |
Java (Helidon) | 是 | 否 | 12.13 | 8168.15 | 296376KB / 427064KB; 提交 = 169629KB +15976KB (NMT); 保留 =1445329KB +5148KB (NMT) |
Java (Helidon) | 否 | 否 | 5.13 | 17332.82 | 282228KB / 430264KB; 保留 =1444264KB +6280KB; 提交 =166632KB +15884KB |
Java (Helidon) | 否 | 是 | 4.84 | 18273.18 | 401228KB / 444556KB |
我们宣布,Go 成为首轮测试的获胜者!
以下为根据这些结果得出的观察结论:
日志记录似乎是影响性能的主要问题,特别是 java.util.logging。因此,我们在启用与禁用日志记录两种条件下进行了测试。我们还注意到,Go 应用程序性能主要受到日志记录的影响。
即使对于如此简单的小型应用程序,Java 版本的内存占用量也明显更大。
预热对 JVM 产生了很大影响——我们知道 JVM 在运行过程中会进行优化,因此预热对 Java 应用程序特别重要。
在此测试中,我们还比较了不同的执行模型——Go 应用程序被编译为原生可执行二进制文件,而 Java 应用程序被编译为字节码,而后虚拟机上运行。我们还决定引入 GraalVM 原生镜像,保证 Java 应用程序的执行环境更接近 Go 应用程序。
6
GraalVM 原生镜像
GraalVM 提供原生镜像功能,使您能够使用 Java 应用程序并在实质上将其编译为原生可执行代码。
根据 GraalVM 项目网站的介绍:
该可执行文件包含应用程序类、依赖项中的类、运行时库类以及 JDK 中的静态链接原生代码。
其并非运行在 Java 虚拟机之上,而是包含必要组件,例如来自不同运行时系统(也被称为「基层虚拟机」)的内存管理、线程调度等功能。基层虚拟机代表的是各运行时组件(例如反优化器、垃圾收集器、线程调度等)。
在添加 GraalVM 原生镜像(原生镜像由 GraalVM EE 20.1.1——JDK 11 构建而成)之后,首轮测试结果如下:
应用程序 | 日志记录 | 预热 | 平均响应时间(毫秒) | 事务 /秒 | 内存(RSS)(开始/结束) |
---|---|---|---|---|---|
Golang | 是 | 否 | 5.79 | 15330.60 | 5160KB / 15188KB |
Golang | 否 | 否 | 4.18 | 20364.11 | 5164KB / 15144KB |
Golang | 否 | 是 | 3.97 | 21333.33 | 10120KB / 15216KB |
Java (Helidon) | 是 | 否 | 12.13 | 8168.15 | 296376KB / 427064KB; 提交 = 169629KB +15976KB (NMT); 保留 =1445329KB +5148KB (NMT) |
Java (Helidon) | 否 | 否 | 5.13 | 17332.82 | 282228KB / 430264KB; 保留 =1444264KB +6280KB; 提交 =166632KB +15884KB |
Java (Helidon) | 否 | 是 | 4.84 | 18273.18 | 401228KB / 444556KB |
Native Image | 是 | 否 | 12.01 | 7748.27 | 18256KB / 347204KB |
Native Image | 否 | 否 | 5.59 | 15753.24 | 169765KB / 347100KB |
Native Image | 否 | 是 | 5.22 | 17837.19 | 127436KB / 347132KB |
在这种情况下,与运行在 JVM 上的应用程序相比,我们发现使用 GraalVM 原生镜像并不会在吞吐量或者响应时间等层面带来任何实质性的改善,但内存占用量确实有所减少。
以下是测试期间的响应时间图表:
首轮响应时间图
请注意,在所有三种 Java 变体当中,第一批请求的响应时间要长得多(蓝线相较于左轴的高度)而且在各项测试中,我们还看到一些峰值,其可能是由垃圾收集或优化所引起。
7
第二轮测试
接下来,我们决定在更大的计算机上运行测试。在本轮中,我们使用台具有 36 个核心(每核心双线程)、256 GB 内存的计算机,并配合 Oracle Linux 7.8 操作系统。
与第一轮一样,我们仍然使用 100 个线程、每线程 10000 个循环,10 秒启动时间以及相同版本的 Go、Java、Helidon 以及 GraalVM。
下面来看结果:
应用程序 | 日志记录 | 预热 | 平均响应时间(毫秒) | 事务/秒 | 内存(RSS)(开始/结束) |
---|---|---|---|---|---|
原生镜像 | 是 | 否 | 5.61 | 14273.48 | 28256KB / 1508600KB |
原生镜像 | 否 | 否 | 0.25 | 82047.92 | 29368KB / 1506428KB |
原生镜像 | 否 | 是 | 0.25 | 82426.64 | 1293216KB / 1502724KB |
Golang | 是 | 否 | 4.72 | 18540.49 | 132334KB / 72433KB |
Golang | 否 | 否 | 1.69 | 37949.22 | 12864KB / 70716KB |
Golang | 否 | 是 | 1.59 | 39227.99 | 16764KB / 76996KB |
Java (Helidon) | 是 | 否 | 7.38 | 11216.42 | 318545KB / 529848KB |
Java (Helidon) | 否 | 否 | 0.40 | 74827.90 | 307672KB / 489568KB |
Java (Helidon) | 否 | 是 | 0.38 | 76306.75 | 398156KB / 480460KB |
我们宣布,GraalVM 原生镜像成为第二轮测试的赢家!
下面来看本轮测试的响应时间图:
启用日志记录,但未经预热的测试运行响应时间
不使用日志记录也未经预热的测试运行响应时间
经过预热,但未使用日志记录的测试运行响应时间
第二轮的观察结果:
Java 变体在本轮测试中的性能表现大幅提升,而且在不使用日志记录的情况下性能远优于 Go。
与 Go 相比,Java 似乎更擅长使用硬件上的多个核心与执行线程——这是因为 Go 本身主要作为系统及网络编程语言存在,而且发展周期相对较短,因此在成熟度及优化水平上不及 Java 也很正常。
有趣的是,Java 诞生之时多核心处理器并不常见,而 Go 诞生时多核处理器已经成为行业标准。
具体来看,Java 似乎成功将日志记录移交给其他线程 / 核心,因此极大减弱了其对性能的影响。
本轮最佳性能来自 GraalVM 原生镜像,其平均响应时间为 0.25 毫秒,每秒可执行 82426 项事务;Go 的最佳结果为 1.59 毫秒外加每秒 39227 项事务,而其内存占用量比前者高出两个数量级!
GraalVM 原生镜像变体的速度要比运行在 JVM 上的同一应用程序快 30% 到 40%。
Java 变体的响应时间更为稳定,但出现的峰值更多——我们猜测这是因为 Go 会把垃圾回收分成更多更小的批次来执行。
8
第三轮测试:Kubernetes
在第三轮中,我们决定在 Kubernetes 集群上运行应用程序,借此模拟更为自然的微服务运行时环境。
在本轮中,我们使用包含三个工作节点的 Kubernets 1.16.8 集群,每个工作节点中包含两个核心(各对应两个线程)、14 GB 内存以及 Oracle Linux 7.8。
在某些测试中,我们在变体上运行一个 Pod;在其他一些测试中,我们则运行一百个 Pod。
应用程序访问通过 Traefik 入口控制器实现,其中 JMeter 运行在 Kubernetes 集群之外。在某些测试中,我们也会尝试使用 ClusterIP 并在集群内运行 JMeter。
与之前的测试一样,我们使用 100 个线程、每线程 10000 个循环,外加 10 秒启动时间。
以下是各个变体的容器大小:
Go 11.6MB
Java/Helidon 1.41GB
Java/Helidon JLinked 150MB
原生镜像 25.2MB
以下为本轮测试结果:
响应时间图表:
Kubernetes 测试中的响应时间
在本轮中,可以看到 Go 有时更快,而 GraalVM 原生镜像也经常取得领先,但二者的差异很小(一般低于 5%)。
9
测试结论
纵观几轮测试与结果,我们得出了以下结论:
Kubernetes 似乎没有快速横向扩展。
Java 似乎比 Go 更关于利用全部可用核心 / 线程,我们发现 Java 测试期间 CPU 的利用率更高。
在核心及内存容量更高的计算机上,Java 性能更好;在较小 / 性能较弱的计算机上,Go 性能更好。
Go 的性能总体上更加一致,这可能是由于 Java 中的垃圾回收机制所致。
在“生产规模”计算机上,Java 的运行速度与 Go 基本相当、甚至更快一点。
日志记录似乎成为 Go 及 Java 中的主要性能瓶颈。
Java 的现代版本以及 Helidon 等新型框架在消除 / 减轻 Java 长期存在的某些重大问题(例如冗长、GC 性能、启动时间等)拥有良好的表现。
10
未来展望
经过这轮有趣的测试,我们打算继续探索,特别是:
我们打算通过 Kubernetes 自动扩展做更多工作,包括引入更复杂的微服务或更高的负载以凸显出性能上的差异。
我们希望研究更复杂的微服务、多种服务类型以及模式,观察网络如何影响性能,以及应如何对微服务网络进行调优。
我们还打算深挖日志记录问题,了解解决此瓶颈的方法。
我们希望查看目标代码并比较当前正在执行的实际指令,看看能否在代码路径中做出进一步优化。
我们希望了解 JMeter 能否在不成为瓶颈的同时产生足够多的负载,但此次测试结果表明 JMeter 并不构成影响,而是能够轻松跟上 Go 与 Java 实现的运行步伐。
我们打算对容器启动时间、内存占用量等指标做出更详细的测量。