Spring Boot 3 + AOT + Virtual Threads
序
近来不是很好,全是挫折,水一篇翻译,缓解一下焦虑。正如之前提到的,Java后端的基座这几年就没什么变化,而最近发布的 Java 21 以及 Spring Boot 3 我认为或许就是现如今前后5年最大的变化?打败’谷歌’的一定不是下一个’谷歌’,那是谁呢,quarkus?又或者是别的新事物?这个苗头好像还没出现,Java 8 和 Spring Boot 太舒服了?好像舒服到没人在意了。
正文
现在都到齐了:Spring Boot 3.2,GraalVM native images,Java 21以及Project Loom的虚拟线程
已经等待了太久,但我们终于可以创建使用 Spring Boot(通过 Spring Boot 3.2)和 Java 21 虚拟线程(Project Loom)的 GraalVM 原生镜像了!
为什么说这些很重要呢?Project Loom 和 GraalVM 原生镜像拿其中任意一个单独来说都提供了引人注目的运行时特性。我等了很久才看到它们集成在一起!让我们逐个来讨论它们。
GraalVM Native Images
GraalVM 是一个 OpenJDK 发行版,它提供了一些额外的工具和功能,包括一个名为 native-image
的程序,它可以对代码进行预编译 (AOT) 。我们不会在这里详细介绍它的所有工具功能,但是基本上来说它会获取您的代码,去掉您不需要的东西,然后将其余部分编译成速度极快的、操作系统和架构特定的本地代码。使用GraalVM的结果令人惊叹,您可以得到类似于使用 C 或 Go 编译程序所得到的效果。生成的二进制文件启动速度很快,在运行时占用的 RAM 也少很多。想象一下,您可以在只占用几十MB内存的情况下部署一个现有的Spring Boot应用程序,并且在几百毫秒内启动。现在您只需运行./gradlew nativeCompile
或./mvnw -Pnative native:compile
就能实现这个梦想。自 2022 年 11 月 Spring Boot 3.0 发布以来,Spring Boot 已经在生产中支持 GraalVM 本地映像。
Project Loom
Project Loom 将透明的纤程引入到JVM。目前,在 Java 20 或更早版本中,IO 是阻塞的。调用 int InputStream#read()
时 ,您可能需要等待下一个字节到达。虽然在 java.io.File
的IO 操作中很少有很大的延迟,然而在网络中,你永远不知道会发生什么。客户端可能会断开连接,可能正在穿过隧道。再强调一次,情况很难预测。在此期间,程序流会被阻塞在执行线程上,无法继续进行。在下面的代码片段中,我们无法知道何时会看到单词 after
被打印 。可能是从现在开始的一纳秒,也可能是一周后,总之它正在阻塞。
1 | InputStream in = ... |
这已经够让人头疼了,但 Java 21 之前的 Java 线程架构让情况变得更加糟糕。目前,每个线程或多或少都是映射到一个本机操作系统线程,而创建新的线程很昂贵,需要大约 2 MB 的内存。
当然,有一些方法可以解决这个问题。您可以使用由 Java NIO ( java.nio.*
) 提供的非阻塞 IO。在这种模型中,您请求字节数据然后注册一个回调,该回调仅在实际有可用字节时执行,如此便无需等待,无需阻塞。这种方法的显着好处是,当我们无事可做时可以远离线程,同时允许其他人在此期间使用这些线程。但美中不足的是,它有点乏味和低级。 Spring 对响应式编程有着良好的支持,它在非阻塞 IO 之上提供了一种函数式编程模型,效果很好。但是,它需要改变您编写代码的方式。如果您可以直接采用现有代码,就像上面演示的那样,并且保证执行正确,在没有发生事件时透明地将执行流程移出线程,然后在有事情发生时恢复执行流程,这样岂不妙哉?而这就是 Loom 项目的承诺,采用上述代码,并确保在虚拟线程中执行它(这很简单,您可以使用 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()
),它就可以正常工作了!
SpringBoot 3.2
在典型的 Spring Boot 应用程序中,线程池(Executor
和ExecutorService
实例)随处可见!例如您的 Web 服务、消息传递逻辑等等。现在,在新的 Spring Boot 3.2 里程碑版本(最终版本将于 2023 年 11 月发布),您可以让 Spring Boot 使用虚拟线程执行器,只需一个简单的属性配置: spring.threads.virtual.enabled=true
。
All Together Now
请注意:Spring Boot 3.2 尚未正式发布, Java 21 也尚未正式发布(2023年9月19日),支持 Java 21 的 GraalVM 同样也尚未正式发布(2023年9月19日)。事情有点困难,但我一直渴望尝试将所有内容组合在一起:在 Spring Boot 应用程序中使用带有虚拟线程的 GraalVM 原生镜像。当一切看起来都准备就绪时,我发现 GraalVM 编译器中有一个需要克服的小Bug!当然,这并不是什么难事,由于 GraalVM 团队的出色工作,问题很快就得到了解决。但正如我所说:事情有点困难,不过这绝对值得一试!让我们将所有部件都安装到位,这样您就可以亲自尝试一下了。
Installing GraalVM for Java 21
首先,我们需要安装支持 Java 21 的 GraalVM。我使用的是采用 Apple Silicon / ARM 架构的 Mac,因此我选择了最新版本中的 graalvm-community-java21-darwin-aarch64-dev.tar.gz
(截至撰写本文时)。您只需下载并解压缩它,并确保正确配置 JAVA_HOME
和 PATH
等关键环境变量。我是 SDKMan 项目的粉丝,所以我想用它来管理这个新下载的版本。我将 .tar.gz
解压缩到名为 - ~/bin/graalvm-community-openjdk-21/
的文件夹,然后运行以下命令:
1 | sdk install java graalvm-ce-21 $HOME/bin/graalvm-community-openjdk-21/Contents/Home |
然后,为了确保它适用于所有操作:
1 | sdk default java graalvm-ce-21 |
打开一个新的 shell 并确认它有效:
1 | native-image --version |
译者注:GraalVM 已经正式发布,所以无需手动下载安装包,直接使用sdk install java 21-graalce
即可。
Configuring a Spring Boot project to use Java 21
打开访问 Spring Initializr (start.spring.io),指定版本 3.2.0 (M2)
(或更高版本),添加 GraalVM
和 Web
,然后下载打包文件、打开它并将其加载到您的 IDE 中。我们仍然需要配置构建以使用 Java 21。这还不是理想状态,因为 Gradle 还未真正适配 Java 21,但它可以工作,基本上来说。我几乎没有任何使用 Gradle 的技巧和能力,但这个配置似乎有效:
1 | plugins { |
将其重新导入到 IDE 的构建配置中。将以下属性添加到 application.properties
:
1 | spring.threads.virtual.enabled=true |
然后将您包含 main(String[] args)
的主类更改为如下所示:
1 | package com.example.demo; |
您可以像平常一样运行程序: ./gradlew bootRun
。它正在使用 Project Loom!但真正令人兴奋的点是:让我们构建一个 GraalVM 原生镜像! ./gradlew nativeCompile
,这个操作这可能需要一两分钟的时间..
执行完成后,您就可以在 build 目录中运行本机二进制文件:./build/native/nativeCompile/demo
。现在我们已经在使用 GraalVM 原生镜像了!
我们基本上已经到达了这次小小冒险的终点线,但我需要再次提醒您 - 这还不是 GA 软件!如果一切顺利,那么到 2023 年 11 月底它将会是GA软件,但现在还没有。这就是为什么我认为发布这篇博客非常有价值的原因:我希望您尝试一下全新的内容。即便对于 Project Loom(将在不到两周的时间内即 2023 年 9 月 19 日部分登陆 Java 21),严格来说它也还没有全部完成。我们将在此版本中获得部分支持,但您可以通过预览功能尝试其他两项内容,如果您发现某些问题,那么请这些信息反馈给我们,这非常重要,这样这些问题就能立刻被消除,而不是以后。毕竟,不到两周前我才在 GraalVM 编译器中发现了一个 bug!所以,去尝试一下吧。现在是成为 Java 开发人员的最佳时机,Loom 和 GraalVM 这两个东西就像免费的钱一样。这些内容成功实施后,您将在 Spring Boot 工作运行中获得更好的运行时可扩展性、工作效率、启动时间、内存消耗以及更多优势。升级并尝试一下,我敢打赌您一定会喜欢它。
后记
AOT 编译成本地可执行文件以后,启动速度确实是快,基本上可以达到 100ms 以内,这一点在lambda环境下(也就是所谓的云原生)尤其有用,恰巧我对这一点有实际的应用体会:一个部署在aws lambda 上的 http 转发器,从 java 换成 golang,效率直接大幅飙升,提升原因是 Java 的启动速度慢、需要预热、脉冲式的流量特性(瞬间高峰然后回落)。但是很明显,不是所有场景都需要这种特性,节省的这点启动、预热时间相较于运行时间还是有点微不足道。
目前 GraalVM 的 AOT 编译速度确实是有点感人,我在 Win(13代i5-13500H) 和 Mac(第8代i5) 都做了尝试。编译单个 HelloWorld 文件,Win 耗时差不多要18s左右,可执行文件约7mb;编译一个空的Spring Boot项目,Win 耗时约1m24s左右,Mac耗时约3m左右,可执行文件大小在80mb左右。相比于 golang 这些,真的有点太慢了,很难想象大把代码的线上项目要编译多久,这会非常影响开发部署效率,不知道以后能改进多少。
这几个变化看起来很新奇,但是回归本质来说,好像也就那样。正如上文所说,虚拟线程在使用的时候几乎是对现有代码零修改,而且往往是框架提供一些开关。
目前虚拟线程也不能随便使用,在原文的评论区有提到竞对Quarkus发布的一篇文章,里面提到了几个注意点
- Thread Pining,对于 synchornized 导致持有 monitor lock,虚拟线程在锁等待时会保持对操作系统线程的占有。这听起来是个不太好的东西,不知道以后是否会改进。
- Thread Monopolization,虚拟线程的调度不是抢占式的,如果执行内容都是长时间的cpu占用,还是线程池更合适。
- Carrier thread pool elasticity,不要忽视虚拟线程对应的操作系统线程池,它也有可能出现池大小失控的情况。
- Object pooling,以往没有虚拟线程的情况下,很多重量级对象会被设计为线程绑定的形式,以池化的形式提高效率,但是在使用虚拟线程时情况发生了改变,虚拟线程很便宜,随时创建,这就可能导致频繁的创建重量级对象,比如Jackson的
ObjectMapper
。 - Stressing thread safety,虚拟线程让并发更加方便,不要忽视线程安全。
如果以后这些特性持续存在,并且Java 21趋于流行,那这些一定是八股文常客。
目前这些东西还不是太成熟,AOT对 Win 环境不够友好,IDE不识别等等,我在尝试这些内容的时候遇到了很多烦人的问题,费了我老半天,但是这些东西相信很快会迭代优化。
附录
Win环境下要进行AOT编译,请遵照这篇官博的教程,需要安装visual studio组件,并且要在VS提供的 x64 Native Tools Command Prompt 中执行编译命令才可以(mvn本地编译命令也是如此)。
原文使用Gradle来进行组织和编译,我更偏向使用Maven,Maven也是没问题的。在 Spring Initializr 中选择Maven,然后执行命令mvn -Pnative native:compile -DskipTests
即可。