PeterNagy和我在2020年8月的OracleGroundbreakersTour2020LATAM会议上发表了一篇论文,标题为《Go Java, Go!》。在本文中,我们提出了一个问题:“Java微服务能否像Go一样快?”为此,我们创建了一系列微服务,对它们进行基准测试,并在会议上展示了我们的结果。但仍有很大的探索空间,因此我们决定在本文中进一步探索。一、背景介绍我们希望通过实验了解Java微服务在运行速度上能否达到Go微服务的水平。目前,软件行业的普遍共识是Java太旧、太慢、太乏味。Go成为快速、新奇和酷的代名词。真的吗?我们想从数据的角度来看这种印象是否成立。我们希望有一个公平的测试,所以我们创建了一个非常简单的微服务,没有外部依赖(例如数据库)和非常短的代码路径(只处理字符串)。我们在其中包含指标和日志记录,因为似乎所有微服务都以某种方式拥有这些。此外,我们使用了小型、轻量级的框架(HelidonforJava和Go-KitforGo),并且可以毫不费力地尝试使用纯JAX-RSforJava。我们还尝试了不同版本的Java和不同的JVM。我们对堆大小和垃圾收集机制进行了基本调整,并在测试运行前预热了微服务。二、Java的发展历史Java由SunMicrosystems开发,后来被Oracle收购。它的1.0版本发布于1996年,目前最新版本是2020年的Java15。Java目前的主要设计目标是实现Java虚拟机和字节码的可移植性,加上带垃圾回收的内存管理机制。如今,Java作为一种开源语言仍然是全球最流行的语言选择之一(根据StackOverflow和TIOBE等来源)。让我们谈谈“Java问题”。人们对其速度慢的印象,其实更多是一种固有的观念,而不是适应当下的事实。今天的Java甚至有相当多的性能敏感区域,包括用于存储对象数据的堆、用于管理堆的垃圾收集器和即时(JIT)编译器。多年来,Java使用了几种不同的垃圾收集算法,包括串行、并行、并发标记/清除、G1和最新的ZGC垃圾收集器。现代垃圾收集器旨在最小化垃圾收集引起的暂停时间。Oracle实验室开发了一个名为GraalVM的Java虚拟机,它是用Java编写的,具有新的编译器以及许多令人兴奋的新特性,包括能够将Java字节码转换为可以运行的原生图像等。3.Go的历史Go语言由Google的RobertGriesemer、RobPike和KenThomson开发。他们中的一些人还是UNIX、B、C、Plan9和UNIX窗口系统等项目的主要贡献者。作为一门开源语言,Go的1.0版本于2012年发布,2020年最新版本为1.15。Go语言的本体、采用率和工具生态系统发展迅速。受C、Python、JavaScript和C++的影响,Go语言已成为一种理想的高性能网络和多处理语言。截至我们发表主题演讲时,在StackOverflow上有27,872个带有“Go”标签的问题,在Java上有1,702,730个问题。Go是一种静态类型的编译语言,具有类似C的语法和特性,例如内存安全、垃圾收集、结构化类型和CSP风格的并发(通信顺序过程)。Go还使用称为goroutines(不是操作系统线程)的轻量级进程,以及用于进程间通信的通道(类型化,FIFO)。Go语言不提供竞争条件保护。Go是许多CNCF项目的首选语言,例如Kubernetes、Istio、Prometheus和Grafana都是(或大部分)用Go编写的。Go语言旨在强调快速构建和快速执行。是两个空格还是四个空格?Go语言说不要打扰,没关系。与Java相比,我将个人体验到的Go语言的优点总结如下:更容易实现功能模式,例如组合、纯函数、不可变状态等。样板代码少得多(但客观上还是太多了)。Go语言还处于生命周期的早期阶段,因此向下兼容的压力不大——改进之路相对平坦。Go代码编译为本地静态链接的二进制文件——没有虚拟机层——包含程序运行所需的一切,使它们更适合“从头开始”的容器。体积更小,启动快,执行快。没有OOP、继承、泛型、断言、指针算法。更少的括号,例如可以像x>3{whatever}强制执行的那样实现,没有循环依赖,没有未使用的变量或导入,没有隐式类型转换。但是Go肯定不是完美的。与Java相比,我认为Go存在以下问题:工具生态不成熟,尤其是在依赖管理方面,虽然有很多选择,但并不完善。在非开源开发方面,Gomodules在依赖管理方面优势明显,但由于一些兼容性问题,其采用率仍然不是特别高。使用新的/更新的依赖项构建代码时非常慢(例如Maven著名的“下载互联网”问题)。将绑定代码导入回购协议,使代码移动非常困难。IDE非常适合编程、文档和自动完成,但很难进行调试、分析等。指针!我以为我们可以在21世纪之前告别这个东西,但Go仍然存在!幸运的是,至少没有更多的指针运算。没有像Java那样的try/catch异常(它总是以iferr!=nil结束),也没有像lists、map函数等函数式风格的原语。一些基本的算法仍然缺失,因此通常留给用户编写他们自己的。最近我写了一些代码来使用sloe比较和转换两个字符串(列表)。在函数式语言中,我们完全可以使用map等内置算法来完成。没有动态链接!如果要在静态链接的代码中使用GPL等许可证,会很不方便。调整执行、垃圾收集、分析或优化算法的选项很少。Java有数百个垃圾收集调整选项,而Go只有一个。4.负载测试方法我们使用JMeter进行负载测试。测试对服务的多次调用并收集有关响应时间、吞吐量(每秒事务数)和内存使用情况的数据。在Go端,我们主要收集驻留集大小,在Java端,我们主要跟踪本机内存。在几个测试中,我们在与被测应用程序相同的计算机上运行JMeter。通过比较,我们发现在其他机器上运行JMeter对结果几乎没有影响。后面在将应用部署到Kubernetes时,我们会考虑在集群外的远程计算机上运行JMeter。在测试之前,我们通过1000次服务调用来预热应用程序。应用本体源码和负载测试定义请参考GitHubrepo:https://github.com/markxnelson/go-java-go5。第一轮测试在第一轮测试中,我们在配备2.5GHz双核IntelCorei7笔记本电脑和16GBRAM运行MacOS的小型机器上运行测试。我们运行100个线程,每个线程10,000个循环,外加额外的10秒启动时间。Java应用程序在带有Helidon2.0.1的JDK11上运行。Go应用程序使用Go1.13.3编译。Thetestresultsareasfollows:Applicationlogwarm-upaverageresponsetime(ms)Transactions/secondmemory(RSS)(start/end)Golangyes5.7915330.605160KB/15188KBGolangnono4.1820364.115164KB/15144KBGolangnoyes3.9721333.3310120KB/15216KBJava(Helidon)是否12.138168.15296376KB/427064KB;提交=169629KB+15976KB(NMT);保留=1445329KB+5148KB(NMT)Java(Helidon)否否5.1317332.82282228KB/430264KB;保留=1444264KB+6280KB;提交=166632KB+15884KBJava(Helidon)NoYes4.8418273.18401228KB/444556KB我们宣布Go成为第一轮测试的赢家!以下是基于这些结果的一些观察结果:日志记录似乎是影响性能的主要问题,尤其是java.util.logging因此,我们在启用和禁用日志记录的情况下进行了测试。我们还注意到Go应用程序性能主要受日志记录的影响。即使对于这样一个小而简单的应用程序,Java版本的内存占用也要大得多。预热对JVM影响很大——我们知道JVM在运行时会进行优化,因此预热对于Java应用程序尤为重要。在这个测试中,我们还比较了不同的执行模型——Go应用程序被编译为原生可执行二进制文件,而Java应用程序被编译为字节码,然后在虚拟机上运行。我们还决定引入GraalVM原生镜像,以确保Java应用程序的执行环境更接近Go应用程序。6.GraalVM原生镜像GraalVM提供原生镜像功能,使您能够获取Java应用程序并将其编译为原生可执行代码。根据GraalVM项目网站:可执行文件包含应用程序类、来自依赖项的类、运行时库类和来自JDK的静态链接本机代码。它并不运行在Java虚拟机之上,而是包含了必要的组件,如内存管理、线程调度以及来自不同运行时系统(也称为“基本虚拟机”)的其他功能。底层虚拟机代表各种运行时组件(如反优化器、垃圾收集器、线程调度器等)。AfteraddingtheGraalVMnativeimage(thenativeimageisbuiltwithGraalVMEE20.1.1-JDK11),theresultsofthefirstroundoftestingareasfollows:Applicationlogwarmupaverageresponsetime(ms)Transactions/secondmemory(RSS)(开始/结束)Golang是否5.7915330.605160KB/15188KBGolang否否4.1820364.115164KB/15144KBGolang否是3.9721333.3310120KB/15216KBJava(Helidon)是否12.138168.15296376KB/427064KB;提交=169629KB+15976KB(NMT);保留=1445329KB+5148KB(NMT)Java(Helidon)nono5.1317332.82282228KB/430264KB;reserved=1444264KB+6280KB;committed=166632KB+15884KBJava(Helidon)否是4.8418273.18401228KB/444556KBNativeImage是否12.017748.2718256KB/347204KBNativeImage否否5.5915753.24169765KB/347100KBNativeImage否是5.2217837.19127436KB/347132KB在这种情况下,与运行在JVM上的应用与程序相比,我们发现使用GraalVM原生镜像并没有带来吞吐量或响应时间的实质性提升,但内存占用确实有所减少。以下是测试时的响应时间图:第一轮响应时间注意,在所有三个Java变体中,第一批请求的响应时间要长得多(蓝线的高度与左轴相比),我们还会在测试中看到一些尖峰,其中可能是由垃圾收集或优化引起的。7.第二轮测试接下来,我们决定在更大的计算机上运行测试。对于这一轮,我们使用了一台具有36个内核(每个内核两个线程)、256GB内存和OracleLinux7.8操作系统的计算机。与第一轮一样,我们仍然使用100个线程,每个线程10000个循环,10秒启动时间,以及相同版本的Go、Java、Helidon和GraalVM。来看看结果:应用日志记录预热的平均响应时间(ms)Transactions/secondmemory(RSS)(start/end)Nativemirror是否5.6114273.4828256KB/1508600KBnativemirrornono0.2582047.9229368KB/1506428KBnativemirror否是0.2582426.641293216KB/1502724KBGolang是4.7218540.49132334KB/72433KBGolang否1.6937949.2212864KB/70716KBGolang否是1.5939227.99KB/Java(HelonKB)否KB/Java(HelonKB)7.3811216.42318545KB/529848KBJava(Helidon)否否0.4074827.90307672KB/489568KBJava(Helidon)否是0.3876306.75398156KB/480460KB我们宣布第二轮测试的获胜者!让我们看一下此轮测试的响应时间图:启用日志记录但没有预热的测试运行的响应时间没有日志记录和没有预热的测试运行的响应时间。响应时间第2轮来自热身、非日志测试运行的观察结果: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集群上,从而模拟一个更自然的微服务运行环境。对于这一轮,我们使用了一个Kubernetes1.16.8集群,该集群具有三个工作节点,每个节点有两个内核(每个内核两个线程)、14GB内存和OracleLinux7.8。在一些测试中,我们在变体上运行一个pod;在其他情况下,我们运行一百个豆荚。应用程序访问是通过Traefik入口控制器实现的,JMeter在Kubernetes集群外运行。在一些测试中,我们还尝试使用ClusterIP并在集群内运行JMeter。与之前的测试一样,我们使用了100个线程,每个线程10,000个循环,外加10秒的启动时间。每个variant的容器大小如下:Go11.6MBJava/Helidon1.41GBJava/HelidonJLinked150MBNativeimage下面25.2MB是本轮的测试结果:Responsetimechart:ResponsetimeinKubernetestest这一轮,你可以看到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实现。我们计划对容器启动时间和内存使用等指标进行更详细的测量。
