【译】使用 GraalVM 的高性能 Java
共 12594字,需浏览 26分钟
·
2021-07-10 14:53
一、前言
GraalVM Native Image 可以成为 Java 云应用程序的一个引人注目的平台。正如我在 “GraalVM: Native images in containers” 中所写,原生镜像会提前预编译您的应用程序 (AOT)。这显然消除了在运行时开始编译的需要,因此您的应用程序几乎可以立即启动,并且内存占用更少。这为即时 (JIT) 编译器基础结构、类元数据等节省了资源。
除了快速启动之外,开发人员在应用程序中使用 native images 还有不同的原因。这类部署具有云友好性,并且可以混淆代码以提高安全性。
图 1是在谈论性能和 GraalVM 可以运行 Java 应用程序的不同方式时经常提到的图片。你可以看到有很多轴,标有人们在说“更好的性能”时的意思。
有时,更好的性能取决于吞吐量以及一个服务实例可以处理多少个客户端;有时它是关于尽可能快地提供单个响应、内存使用、启动,甚至是部署的大小,因为在某些情况下,这可能会有影响,例如冷启动性能。
重要的是,通过一些相对简单的技巧,再加上使用先进的 GraalVM Native Image 功能,您可以为您的应用程序利用所有的这些优势。
在本文中,我将向您展示如何为您的应用程序充分利用GraalVM Native Image 技术。
二、创建一个应用
假设您有一个简单的示例应用程序,它是一个响应 HTTP 查询并计算素数的Micronaut 微服务。这是一个简单的单控制器应用程序,它通过使用 JavaStream API和 CPU 方便地使用一些临时对象生成垃圾收集器压力来模拟业务逻辑,以非常低效地计算素数序列:尝试将所有数字作为因子,包括偶数大于 2。
如果您安装了 Micronaut 命令行工具,您可以通过以下方式创建此应用程序。
mn create-app org.shelajev.primes
cd primes
cat <<'EOF' > src/main/java/org/shelajev/PrimesController.java
package org.shelajev;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.*;
import java.util.stream.*;
import java.util.*;
@Controller("/primes")
public class PrimesController {
private Random r = new Random();
@Get("/random/{upperbound}")
public List<Long> random(int upperbound) {
int to = 2 + r.nextInt(upperbound - 2);
int from = 1 + r.nextInt(to - 1);
return primes(from, to);
}
public static boolean isPrime(long n) {
return LongStream.rangeClosed(2, (long) Math.sqrt(n))
.allMatch(i -> n % i != 0);
}
public static List<Long> primes(long min, long max) {
return LongStream.range(min, max)
.filter(PrimesController::isPrime)
.boxed()
.collect(Collectors.toList());
}
}
EOF
现在您有了示例应用程序。您可以运行它或立即将其构建到 native 可执行文件中。
./gradlew build
./gradlew nativeImage
然后,您可以运行该应用程序。
java -jar build/libs/primes-0.1-all.jar
./build/native-image/application
要测试应用程序,您可以手动打开页面或运行curl
命令,如下 所示,它将返回一个小于 100 的质数:
curl http://localhost:8080/primes/random/100
但是,为了帮助说明本文的后期阶段,您应该下载并安装hey
,这是一个简单的 HTTP 负载生成器,您将使用它来评估峰值性能。下载二进制文件并将其放在$PATH
上(如果 您不在 Linux 上,请获取适当的二进制文件)。
wget https://hey-release.s3.us-east-2.amazonaws.com/hey_linux_amd64
chmod u+x hey_linux_amd64
sudo mv hey_linux_amd64 /usr/local/bin/hey
hey –version
您可以通过运行以下命令来验证它是否有效并熟悉它提供的输出:
hey -z 15s http://localhost:8080/primes/random/100
输出比此处包含的合理长度要长,但它会打印延迟分布图和摘要,如下所示:
Summary:
Total: 15.0021 secs
Slowest: 0.1064 secs
Fastest: 0.0001 secs
Average: 0.0015 secs
Requests/sec: 33703.8539
Total data: 20062978 bytes
Size/request: 20 bytes
用于度量的最重要的部分是Requests/sec: 33703.8539
行,它显示了应用程序的吞吐量。您将通过将应用程序的堆大小限制为 512 MB 来进行压测,而不是让它无限增长。默认情况下,Native Image 将-Xmx
选项设置为可用内存的 80% 以限制堆大小,对于这个测试应用程序来说,在功能强大的测试虚拟机上,这将是一种过度的做法。
三、更好的内存管理
由于我在谈论内存,因此减少运行时的内存占用是一个重要指标,其中 Native Image 为使用通用 JDK 运行应用程序提供了改进。
这种节省主要是一次性的,因为使用 Native Image 构建的可执行文件包含应用程序中已编译的所有代码和已分析的所有类。这允许您省去类元数据和 JIT 编译器基础结构。
但是,您的应用程序所操作的数据集占用的内存量相似,因为 JVM 和 native image 中的对象内存布局相似。因此,如果一个应用程序在内存中保存了几 GB 的数据,那么本机映像将使用类似的量减去我上面提到的 200 MB 到 300 MB。
Native Image 显然包含一个支持应用程序的运行时,该运行时假定内存是管理的,并且在需要时进行垃圾回收。Native Image(包括垃圾回收)中使用的运行时的实现来自 GraalVM 项目。
上面提到的服务是用 Java 编写的,由于在构建应用程序类的过程中,必须编译依赖项和 JDK 类库类,所以运行时是与应用程序一起编译(串行垃圾收集器是一个简单的串行清除器,它针对吞吐量而不是最小化延迟进行了优化。)
垃圾收集器公开了与 JDK 公开的用于指定堆大小的相同内存配置选项,例如:* -Xmx -
最大堆 大小和* -Xmn -
年轻代大小。如果您觉得需要查看幕后情况或为特定工作负载微调垃圾收集器,那么-XX:+PrintGC
和-XX:+VerboseGC
选项也可用。
如果配置生成大小不是您的首选,那么可以使用多线程 G1 GC 垃圾收集器构建 native image。G1 GC是 GraalVM Enterprise 中包含的一个面向性能的特性,它的配置非常简单。
要启用 G1 GC,请将--gc=G1
参数传递给 Native Image 生成进程。由于您正在使用 Micronaut 应用程序,并依赖其 Gradle plugin 来配置和运行 native image 构建器,因此请在build.gradle
文件中指定该选项。
使用args
行添加nativeImage
配置,如下所示:
n ativeImage {
args("--gc=G1")
}
并再次构建 native image。
./gradlew nativeImage
调用此版本app-ee-g1
可以更轻松地区分结果。在您运行测试之前,我将向您展示一些其他有用的选项,以提高本机图像的性能。
四、更好的整体吞吐量
有几个因素会影响应用程序的吞吐量。当然,其中一些主要因素是工作负载的性质、代码质量、代码处理的数据的数量和特征、输入和输出的延迟等等。但是,更好的运行时或更好的编译器可以显著加快执行速度。
GraalVM Enterprise 附带了一个更强大的编译器——它可以创建一个执行概要文件,类似于 JIT 编译器在应用程序运行时所做的工作。编译器可以使用此配置文件在 AOT 编译期间生成所谓的概要文件引导优化 (PGO)。PGO 可以使生成的可执行文件的吞吐量更接近预热的 JIT 数字。
这里需要注意的一点是 JIT 编译器最好的特性是在运行时运行,这使得它可用的数据始终与当前工作负载相关。如果在运行与生产中类似的工作负载时收集摘要文件,那么使用概要文件的 GraalVM AOT 编译效果最好。这通常很容易通过设计良好的微服务实现。下面是示例应用程序的实现方法。
首先,构建一个检测二进制文件,用于收集 PGO 的摘要文件。启用该选项的选项是--pgo-instrument
。您可以将其添加到build.gradle
配置中,并使用./gradlew nativeImage
正常构建 image,如下所示:
native Image {
args("--gc=G1")
args("--pgo-instrument")
}
现在您可以构建应用程序并运行相同的负载生成工具。
./build/native-image/application
然后运行以下命令:
hey -z 15s http://localhost:8080/primes/random/100
当它停止时,应用程序将在当前目录中创建default.iprof
文件(除非配置没有开启)。现在您可以使用--pgo
选项构建最终 image,同时提供正确的路径。请注意,该路径将向上移动两个目录:Micronaut 在build/native-image
目录中构建本机映像,您将从项目的主 目录中执行检测的二进制文件,如下所示:
nativeImage {
args("--gc=G1")
args("--pgo=../../default.iprof")
}
构建完成并存储具有描述性app-ee-pgo
名称的二进制文件后,您就可以观察结果了。
五、更小的二进制文件
在对性能数据进行最有趣的比较之前,先看看可执行文件的大小。二进制文件很大。你可以把它们变小。
以下是这个简单应用程序中二进制文件的大小,没有对大小进行任何优化。
$ ls -lah app*
-rwxrwxr-x. 1 opc opc 58M May 6 20:41 app-ce
-rwxrwxr-x. 1 opc opc 73M May 6 21:14 app-ee
-rwxrwxr-x. 1 opc opc 99M May 6 21:25 app-ee-g1
-rwxrwxr-x. 1 opc opc 80M May 6 21:47 app-ee-pgo
我没有列出用于收集 PGO 配置文件的二进制文件,因为它实际上并不打算在生产中分发和使用,但为了完整起见,它大约为 250 MB。二进制文件由两个主要部分组成:应用程序的预编译代码和在构建时类初始化期间创建的数据,称为image heap。为了有效地利用 native images,理解两者同样重要。
代码部分。这部分最容易掌握。这部分包含需要包含在图像中的所有类和方法,因为静态分析发现了它们的可能代码路径,或者它们的包含是使用显式配置预先配置的。
代码部分包括您的类、它们的依赖项、依赖项的依赖项等等,直到 JDK 类库类和在构建时生成的类。换句话说,所有的 Java 字节码都将在生成的可执行文件中执行。
注意,代码部分不包括处理字节码加载、验证、解释或 JIT 编译的基础结构。因此,很自然地,如果没有提前编译某些内容,它将在运行时不可用和可执行。
Image heap。这部分是很多开发者比较陌生的概念。将 native image 构建过程视为运行您的应用程序一段时间,初始化一些必要的类及其数据,并保存应用程序的状态以备将来使用。然后,当您在测试或生产中运行可执行文件时,已准备好使用初始化状态并且应用程序启动是即时的。这个状态显然需要写在某处,这就是存储在 image heap 中的内容。
在 native image 构建期间,您可以使用报告选项(-H:+DashboardAll
) 观察应用程序中的类和包对最终可执行文件大小的贡献空间。有了这些信息,您就可以重构应用程序,以消除可能使 image heap 大于所需的代码路径。
您还可以牺牲一点启动速度来换取更好的打包(体验),例如使用UPX,它代表可执行文件的终极打包器。UPX 可以压缩二进制文件,而且效果出奇地好,通常生成的二进制文件的大小是原始文件的 30% 左右。当 GraalVM 团队研究使用 UPX时,我们发现7
左右的中等高的打包级别是一个很好的默认值。
以下是将 UPX 应用于此处的示例二进制文件之一的示例结果:
upx -7 -k app-ee-pgo
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2020
UPX 3.96 Markus Oberhumer, Laszlo Molnar & John Reiser Jan 23rd 2020
File size Ratio Format Name
-------------------- ------ ----------- -----------
83267776 -> 24043764 28.88% linux/amd64 app-ee-pgo
Packed 1 file.
二进制文件大小从 80 MB 减少到 23 MB,并且该应用程序的响应速度仍然非常快。
$ ./app-ee-pgo
__ __ _ _
| \/ (_) ___ _ __ ___ _ __ __ _ _ _| |_
| |\/| | |/ __| '__/ _ \| '_ \ / _` | | | | __|
| | | | | (__| | | (_) | | | | (_| | |_| | |_
|_| |_|_|\___|_| \___/|_| |_|\__,_|\__,_|\__|
Micronaut (v2.5.1)
20:33:44.838 [main] INFO i.m.context.env.DefaultEnvironment - Established active environments: [oraclecloud, cloud]
20:33:44.852 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 20ms. Server Running: http://ol8-demo:8080
这些是制作应用程序小型 native images 的一些方法。当然,您可以分别通过分析报告和更改代码来进一步优化,但是这种类型的优化很快就进入了收益递减的领域。
六、Native Image 能带你走多远?
我已经向您展示了一些优化 Native Image 生成可执行文件性能的不同方法:从使用更复杂和自适应的 G1 GC,因此您不必手动调整内存设置,到启用配置文件引导优化,以更有效地打包可执行文件以减少磁盘或容器占用空间。
您现在应该运行示例负载生成脚本以查看这些优化是否有任何不同。对于本文,我在堆限制为 512 MB 的情况下运行了以下 3 次 15 秒的测试:
GraalVM Enterprise 的默认本机映像和 GC: * app-ee
使用同样的 G1 GC: * app-ee-g1
再加上 PGO: * app-ee-pgo
./app-ee -Xmx512m
Summary:
Total: 15.0023 secs
Slowest: 0.1304 secs
Fastest: 0.0001 secs
Average: 0.0010 secs
Requests/sec: 49770.7845
./app-ee-g1 -Xmx512m
Summary:
Total: 15.0029 secs
Slowest: 0.1388 secs
Fastest: 0.0001 secs
Average: 0.0010 secs
Requests/sec: 51690.8255
./app-ee-pgo -Xmx512m
Summary:
Total: 15.0023 secs
Slowest: 0.1193 secs
Fastest: 0.0001 secs
Average: 0.0007 secs
Requests/sec: 73391.9314
正如您所见,初始开箱即用的构建与使用 G1 GC 和 PGO 的构建之间的差异是惊人的。只是为了好玩,我在 OpenJDK 上运行的同一个应用程序上运行了相同的加载。我使用了基于 JDK 11 的 GraalVM,精确的版本如下。
java -version
java version "11.0.11" 2021-04-20 LTS
Java(TM) SE Runtime Environment GraalVM EE 21.1.0 (build 11.0.11+9-LTS-jvmci-21.1-b05)
Java HotSpot(TM) 64-Bit Server VM GraalVM EE 21.1.0 (build 11.0.11+9-LTS-jvmci-21.1-b05, mixed mode, sharing)
为了进行比较,我从 SDKMAN 中选择了构建在相同版本 11.0.11 上的任意 JDK 发行版!以下是结果。
java -Xmx512m -jar build/libs/primes-0.1-all.jar
Summary:
Total: 15.0019 secs
Slowest: 0.4774 secs
Fastest: 0.0001 secs
Average: 0.0008 secs
Requests/sec: 62991.1439
在这个测试中,性能最好的 native image 比 OpenJDK 快 16%。当然,这对于依赖 JIT 编译并需要预热的运行时来说并不是一个完全公平的测试。再说一次,15 秒和处理一百万个请求是相当长的时间,尤其是在具有大量 CPU 容量的强大机器上,因此 JIT 编译器可以与应用程序代码并行工作。
在任何情况下,您都可以看到 native image 的性能可以与使用 JIT 编译器运行您的应用程序相媲美,同时可以获得更好的启动性能,并且更适合于受限环境或微服务。
七、结论
本文介绍了在不更改任何代码的情况下提高 native images 性能的不同方法。使用自适应 G1 GC,应用 profile-guided 优化,并使用 UPX 打包可执行文件,生成了一个非常高效的微服务,其大小约为 20 MB,在 20 毫秒内启动,并且在服务的前 100 万个请求上优于 OpenJDK。
GraalVM Native Image 是一项非常令人兴奋的技术,适用于云环境中的 Java 工作负载。希望本文向您介绍了在不更改应用程序代码的情况下更有效地使用 Native Image 的一些方法。
译者说:
大家好,我是 如梦技术春哥(Mica 开源作者)感谢深夜还一起参与翻译和校对的张亚东(JustAuth 开源作者)同学。我们已经输出和翻译了多篇 GraalVM 和 Spring Native 的文章:
翻译不易,请帮忙分享给更多的同学,谢谢!!!
链接
GraalVM: Native images in containers: https://blogs.oracle.com/javamagazine/graalvm-native-images-in-containers
简单的 HTTP 负载生成器: https://github.com/rakyll/hey
G1 GC: https://www.graalvm.org/reference-manual/native-image/MemoryManagement/