ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 21升级指南:安装配置、验证与Spring Boot虚拟线程实战

Java 21升级指南:安装配置、验证与Spring Boot虚拟线程实战 最近一周我连续帮三个项目完成 Java 版本升级都没能绕开同一个问题从哪下载 Java 21、怎么装、装完怎么确认真的在用新版本。更麻烦的是其中一台 Windows 机器上用户之前装过 Java 8等我们配好环境变量后java -version打出来的还是老版本排查了半小时才发现是 PATH 顺序的锅。这篇文章把 Java 21 的下载、安装、环境配置、安装验证以及搭配 Spring Boot 3.5 启用虚拟线程这套完整流程写下来适合刚从 Java 8/11/17 往上升的开发者也适合第一次装 JDK 的新手直接照着操作。1. 为什么是 Java 21LTS 与虚拟线程带来的现实收益1.1 Java 版本节奏与 Java 21 的定位从 Java 9 开始Java 的发布节奏改成每 6 个月一个大版本每两年左右出一个长期支持版本也就是我们常说的 LTS。Java 17 是 2021 年的 LTSJava 21 在 2023 年 9 月发布是继 Java 17 之后的下一个 LTS。对企业项目来说选版本最重要的原则就是跟着 LTS 走因为非 LTS 版本只提供半年左右的支持等新版本一发布老的非 LTS 版本基本就没人管了。Java 21 的官方支持周期至少到 2028 年比 Java 17 的 2027 年更长所以从时间窗口上看现在从 17 迁到 21或者从 8 直接跳到 21都是比较划算的选择。如果你是 Java 8 的老用户中间其实已经隔了 9、11、17 三代 LTS 和一堆中间版本。Java 9 引入了模块系统Java 11 在 LTS 层面正式包含 ZGC 等改进Java 17 又做了很多 API 整理。直接从 8 跳到 21 不是不行但心理上要有个准备很多老写法和旧依赖会在这里集中爆发问题。好在这种长痛不如短痛的迁移方式反而是现在最多团队采用的方式因为没人愿意迁完一次再过两年又迁一次。1.2 虚拟线程解决高并发 IO 的老大难Java 21 最值得关注的功能就是虚拟线程Virtual Threads。在它之前Java 的线程模型是一个线程对应一个操作系统线程每个线程默认要占大约 1MB 的栈内存创建和上下文切换的成本都很高。遇到高并发 IO 场景比如同时来了 5000 个网络请求传统做法要么用线程池限制并发数要么用异步编程把代码拆成一堆回调。线程池会降低吞吐异步代码又难写难调试。虚拟线程的出现把线程变成了一种 JVM 管理的轻量级对象。它不再一对一映射 OS 线程而是由 JVM 调度到一组承载线程Carrier Thread上执行。当你写的代码发生阻塞时JVM 可以自动把虚拟线程挂起把承载线程让给其他虚拟线程去跑。这样创建几十万个虚拟线程都没问题代码却可以继续用最朴素的一个请求一个线程的同步写法。对于大部分业务系统来说IO 等待数据库查询、外部 HTTP 调用、消息队列读写才是性能瓶颈CPU 计算其实只占很小比例。虚拟线程正好命中这个痛点不需要改业务代码的编写方式只要升级 JDK 并在框架里打开开关就能明显提升吞吐。这也是为什么大家一提到 Java 21第一个想到的就是虚拟线程。1.3 除了虚拟线程Java 21 还有哪些实用特性我根据实际使用频率整理了一个表这些特性不一定每次都用得上但面试和排查问题时都容易碰到特性状态实际价值记录模式Record Patterns正式解构 record 类型更简洁配合 instanceof 使用switch 模式匹配正式替代一长串 if-else 类型判断代码更清晰字符串模板预览类似插值表达式简化字符串拼接顺序集合Sequenced Collections正式统一了获取首尾元素的 API分代 ZGC正式降低 GC 停顿大堆场景更稳结构化并发预览让并发任务的取消和异常处理更可控密钥封装机制 API正式安全领域的新标准接口对大多数后端项目来说虚拟线程和 switch 模式匹配是收益最直接的。字符串模板目前还是预览特性不建议在新代码里大量使用等到正式版本再上更稳妥。分代 ZGC 适合堆内存很大、对停顿时间敏感的服务如果你只是默认配置跑一跑感知可能不强。1.4 下载哪个发行版Oracle JDK、OpenJDK 还是 Temurin说到下载 Java 21很多新手会直接去 Oracle 官网。Oracle JDK 本身没有太大问题但它的下载流程需要登录账号而且从收费政策上看虽然 Java 17 之后 Oracle JDK 在No-Fee Terms and Conditions下可以免费商用但高级支持服务是另收费的很多企业为了避免授权上的纠结更愿意选一个纯开源发行版。我个人的建议是优先用 Eclipse TemurinAdoptium 项目。Temurin 是 Eclipse 基金会维护的 OpenJDK 发行版社区活跃度高提供 MSI、ZIP、tar.gz 各种格式下载不需要登录商业使用也很自由。同样在推荐列表里的还有 Amazon Corretto、Azul Zulu、BellSoft Liberica它们本质上都是 OpenJDK 构建版只是构建方和更新节奏略有差别。团队内部只需要统一一个发行版就行没必要每台机器装不同的。2. Windows 上的安装从下载到把 java -version 跑通2.1 选择安装包MSI 还是 ZIPWindows 是很多开发者的主力系统装 Java 21 最常见的两种方式就是 MSI 安装包和免安装的 ZIP 压缩包。MSI 的优点是双击就能跑安装向导能在安装过程中直接把 JAVA_HOME 和 PATH 配好对新手最友好。ZIP 版不需要管理员权限解压到任意目录就能用适合想完全掌控目录结构的同学但环境变量得自己配。如果你只是想在 Windows 上快速跑起来我会推荐 MSI。下载时注意看清两个参数操作系统选 Windows架构选 x64。如果你用的是 ARM 版 Windows要选 ARM 对应的构建版本否则装完可能会有兼容性问题。文件名通常长这样OpenJDK21U-jdk_x64_windows_hotspot_21.0.5_11.msi其中 21.0.5 是小版本号后面是构建号这些都是正常的。拿 Temurin 举例打开 Adoptium 官网的 Temurin 21 版本页面选好 Windows / x64 / JDK下载那个.msi文件。Oracle 官网也有类似的 MSI 安装包但需要先注册账号登录才能下载。2.2 安装过程里的几个关键勾选项运行 MSI 安装包之后向导会要求选择安装组件和功能。有几个选项很多人直接下一步忽略掉了后面出问题才回来找Set JAVA_HOME variable勾上安装器会自动把JAVA_HOME设置为当前安装路径。Add to PATH勾上java和javac命令才能在任意终端直接使用。JavaSoft (Oracle) registry keys勾上或不勾影响不大一般默认就可以。默认安装路径是C:\Program Files\Eclipse Adoptium\jdk-21.0.5.11-hotspot\。这个路径里带着版本号好处是以后想装新版 JDK 不会覆盖旧版本两个版本能在磁盘上共存切换靠环境变量完成。安装完成以后千万不要忘记重新打开一个新的 PowerShell 或 CMD 窗口。老的终端窗口里PATH还是启动时候的旧值直接敲java -version可能找不到命令或者打出旧版本。2.3 JAVA_HOME 与 PATH 到底怎么配如果安装时漏勾了环境变量选项或者你用的是 ZIP 版就需要手动配置。打开系统属性窗口点击环境变量在系统变量区域做两件事新建变量名JAVA_HOME变量值填 JDK 的实际安装路径比如C:\Program Files\Eclipse Adoptium\jdk-21.0.5.11-hotspot。在变量Path中新增一项%JAVA_HOME%\bin。为什么一定要配JAVA_HOME除了让命令行能跑java之外Maven、Gradle、Tomcat、Jenkins 这些工具在启动时都会主动去找JAVA_HOME。如果你只把bin目录加到Path命令行能用了但构建工具可能还是找不到 JDK。JAVA_HOME的值不要带末尾的反斜杠也不要直接填C:\Program Files...里的java.exe要填到 JDK 目录这一层。配置完成后开一个新终端验证java -version javac -version echo %JAVA_HOME%正常情况下java -version会看到类似openjdk version 21.0.5 2024-10-15的输出javac -version输出javac 21.0.5。如果java有输出但javac找不到说明JAVA_HOME指向的目录有问题或者 PATH 里只配了旧版 JDK 的 bin。2.4 Windows 上常见的安装后报错我总结几个 Windows 用户最容易碰到的问题java 不是内部或外部命令说明 PATH 里完全没有 Java 的 bin 目录检查环境变量是否生效或者直接注销重登一次让系统环境变量刷新。java -version显示的是旧版本这是 PATH 里同时存在多个 JDK 导致的。用where java命令查看当前java命令实际来自哪个路径。很常见的情况是系统里装了 Java 8它的java.exe位于C:\Windows\System32下并且路径排在JAVA_HOME前面。解决办法是把%JAVA_HOME%\bin移到 PATH 列表靠前的位置或者直接把C:\Windows\System32下的java.exe删掉。这个文件通常是其他软件塞进去的删掉不影响系统运行。安装了 64 位 JDK但运行 Java 程序报找不到主类或内存不足确认你的项目和启动脚本是否被 32 位 JVM 影响。不要同时装 32 位和 64 位的 JDK 并让它们出现在同一个 PATH 里太容易出问题。3. Linux 和 macOS命令行安装与多版本共存3.1 Ubuntu/Debianapt 直接装最省事Ubuntu 24.04 的官方软件源里已经带了 OpenJDK 21 的包直接执行sudo apt update sudo apt install openjdk-21-jdk装完后验证java -version javac -versionopenjdk-21-jdk是完整开发包里面包含javac。如果只装了openjdk-21-jre只能运行程序没法编译源码。这一点和 Windows 上的 JDK/JRE 区分是一样的逻辑。如果你是 Ubuntu 22.04 或者更老的版本官方源里可能没有 OpenJDK 21这时候别硬找直接用 3.4 节的手动解压方式或者切换到 SDKMAN 安装反而更快。3.2 RHEL/CentOS/Fedoradnf/yum 的包名细节在 Fedora 上装 Java 21 很直接sudo dnf install java-21-openjdk-devel注意包名结尾是-devel不带-devel的java-21-openjdk只有 JRE没有javac。CentOS 7/8 默认源通常没有 Java 21 的包需要先启用了 EPEL 或其他第三方源才搜得到但这类源的质量参差不齐。我建议 CentOS 用户直接下载 tar.gz 解压安装路径可控也不依赖源的质量。3.3 macOSHomebrew 一条命令但要注意路径macOS 上如果装了 Homebrew安装是最简单的brew install openjdk21但装完以后java -version大概率还是找不到因为 Homebrew 为了不干扰系统自带的 JDK默认不会自动做符号链接。安装完成时终端会给出提示要手动把 JDK 目录链接到/Library/Java/JavaVirtualMachines/sudo ln -sfn /opt/homebrew/opt/openjdk21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk这是 Apple Silicon Mac 的路径。Intel 版的路径是/usr/local/opt/openjdk21/...。链接做完后用/usr/libexec/java_home -V可以查看系统识别到的所有 JDK这个命令在 macOS 上排查版本冲突非常有用。3.4 tar.gz 手动安装与 alternatives 管理手动安装其实是所有平台通用的办法。从发行版官网下载 Linux x64 的 tar.gz 包然后sudo mkdir -p /opt/jdk sudo tar -xzf OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz -C /opt/jdk解压出来的目录一般是/opt/jdk/jdk-21.0.511接下来把可执行文件加进系统环境export JAVA_HOME/opt/jdk/jdk-21.0.5 export PATH$JAVA_HOME/bin:$PATH为了开机自动生效把这两行写进~/.bashrc或者~/.zshrc。对于服务器环境我更推荐用update-alternatives来管理sudo update-alternatives --install /usr/bin/java java /opt/jdk/jdk-21.0.5/bin/java 2100 sudo update-alternatives --install /usr/bin/javac javac /opt/jdk/jdk-21.0.5/bin/javac 2100 sudo update-alternatives --config javaupdate-alternatives的作用是给/usr/bin/java这个公共入口建立一个符号链接列表你可以随时切换系统默认 Java。RHEL 系的命令是alternatives --config java思路一样。服务器上通常一个环境可能同时为多个 Java 应用服务这个命令能让我们在重启应用前快速切换版本比反复改 PATH 稳得多。3.5 SDKMAN开发机上管理多版本的利器如果你在个人开发机上想随时切换 JDK 8、17、21SDKMAN 是最舒服的方式。安装 SDKMAN 只需要一条命令curl -s https://get.sdkman.io | bash然后重开终端查看可用的 Java 版本sdk list java | grep 21安装 Temurin 的 Java 21sdk install java 21.0.5-tem切换当前 shell 使用的版本sdk use java 21.0.5-tem设置全局默认版本sdk default java 21.0.5-temSDKMAN 的原理是把所有版本装在~/.sdkman/candidates/java/下然后用环境变量和符号链接实现切换。它不修改系统级配置污染小卸载也简单。唯一要注意的是有些 CI/CD 脚本不加载~/.bashrc会导致 SDKMAN 装好的 Java 在脚本里不可见这种情况还是老老实实用系统级安装。4. 装完之后先跑一遍验证、编译与 jlink 精简运行时4.1 从 Hello World 开始确认编译链很多新手装完 JDK 只跑java -version就以为完事了结果 Maven 一编译就报找不到 javac。所以安装完一定要把编译链路也验证一遍。新建一个Hello.javapublic class Hello { public static void main(String[] args) { System.out.println(Hello Java 21); } }执行javac Hello.java java Hello看到Hello Java 21输出说明 JDK 和 JRE 都在工作。如果javac命令报错多半就是环境变量里只有 JRE或者 PATH 里混着别的 JDK 版本。4.2 用 javap 确认 class 文件的字节码版本编译完之后可以用javap看一眼生成的 class 文件版本号。这个操作在排查为什么这个 class 文件在高版本 JDK 上跑不起来时特别有用javap -v Hello.class | findstr /i majorJava 21 编译出来的 class 文件主版本号是 65。对应的映射关系大概是Java 版本class 文件主版本号Java 852Java 1155Java 1761Java 2165如果你用旧版 JDK 运行新编译的 class会看到UnsupportedClassVersionError那个报错信息里就会带上主版本号。养成看版本号的习惯能少走很多弯路。4.3 写一段虚拟线程代码验证安装效果验证完基础编译链可以顺手跑一个虚拟线程的示例确认 JDK 21 的虚拟线程功能真的可用import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class VirtualThreadDemo { public static void main(String[] args) throws InterruptedException { try (ExecutorService executor Executors.newVirtualThreadPerTaskExecutor()) { for (int i 0; i 10_000; i) { int id i; executor.submit(() - { System.out.println(任务 id 运行在 Thread.currentThread()); }); } } System.out.println(所有虚拟线程任务执行完毕); } }Executors.newVirtualThreadPerTaskExecutor()是 JDK 21 里创建虚拟线程执行器的标准入口。这里用上了 try-with-resources执行器关闭时close()会自动等待所有已提交任务完成所以最后一行所有虚拟线程任务执行完毕一定在全部任务打印完毕后才会输出。跑这个程序时你可能会看到输出里出现了Thread[#...]这样的名字后面可能跟着平台线程。虚拟线程会和承载它的平台线程混在一起显示只看名字有时候分辨不清但如果能一次性创建 1 万个任务并很快跑完比传统线程池快很多就说明虚拟线程生效了。4.4 用 jlink 生成一个精简运行时JDK 9 之后提供了jlink工具用来生成一个只包含目标模块的精简 JRE。部署小型微服务或者打 Docker 镜像时这个功能能把运行时从 300MB 级别压缩到 50MB 左右。比如我们只需要java.base模块jlink --add-modules java.base --output /opt/mini-jre生成目录里有bin/java和lib直接用bin/java运行程序即可。当然真实应用一般会用到java.logging、java.sql、java.naming等模块具体加哪些模块要看应用的jdeps分析结果。这块算是高阶用法但既然已经装了 Java 21值得自己动手跑一次。5. 让 Java 21 真正落地Spring Boot 3.5 启用虚拟线程5.1 Spring Boot 与 Java 21 的版本搭配很多团队升级 JDK 不是为了写实验代码而是为了让现有 Spring Boot 服务跑上虚拟线程。Spring Boot 3.2 开始加入了对虚拟线程的配置支持之后版本逐步完善。到了 Spring Boot 3.5基于 Spring Framework 6.2对 Java 21 的支持已经比较成熟。如果你是新项目直接选择 Spring Boot 3.5.x 加 Java 21 的组合没有任何阻碍。如果是老项目还在 Spring Boot 2.x那要先升级到 3.x这是一个大工程涉及javax到jakarta的包名迁移等问题。我的建议是老项目先评估工作量不要为了虚拟线程强行跨版本升级。5.2 核心配置spring.threads.virtual.enabledSpring Boot 3.5 中启用虚拟线程非常干脆在application.yml里加一行spring: threads: virtual: enabled: true启动时控制台会多出一行日志大概是Using Java 21 virtual threads for request handling之类的信息。这行日志就是确认开关生效的直接证据没看到这行就说明配置没被加载或者 JDK 版本不对。配置背后的原理是Spring Boot 检测到 JDK 21 且该配置为 true 时会创建一个VirtualThreadTaskExecutor并把它交给内嵌的 Tomcat/Jetty 容器使用。Tomcat 在处理每个 HTTP 请求时原本会从线程池里抓一个平台线程开启后改成创建虚拟线程来执行请求处理逻辑。不用改业务代码不用换 Web 容器一个配置项就让服务从线程池限流模式切换成了虚拟线程弹性模式。5.3 启用之后要留意的适配点虚拟线程不是银弹我实际迁移过程中发现有几个坑值得提前注意。第一个是synchronized关键字。虚拟线程在遇到synchronized块或方法时可能会发生 pinning也就是被钉在某个承载线程上导致承载线程无法被其他虚拟线程复用严重时吞吐反而下降。这不是说代码不能写synchronized而是要控制使用范围。能用ReentrantLock或 JDK 并发包里的原子类替代的尽量替换。第二个是ThreadLocal。Spring Boot 默认会开启一些基于 ThreadLocal 的机制比如事务上下文、日志 MDC。虚拟线程数量可以很大如果每个请求都往 ThreadLocal 里塞很大的对象且不清理内存压力会非常明显。老规矩还是用完就 remove在 Filter 里清干净。第三个是连接池配置。以前平台线程受限数据库连接池配 50 个可能就够用了。虚拟线程让并发请求量上限大幅提升连接池如果太小请求会大量阻塞在等连接上。HikariCP 这类连接池的 maxPoolSize 要重新评估不能直接沿用旧值。第四个是第三方库的线程模型。有些老库内部会自己new Thread或者创建固定大小线程池它们不受虚拟线程开关影响。如果你发现服务里某条调用链还是卡多半是第三方库自己用平台线程池处理任务这时要看能不能升级库版本或者把线程池调大。5.4 先做一轮对比压测再决定是否全面开启我推荐的做法是先在测试环境开一个小流量接口验证然后用压测工具做前后对比。简单一点可以用 Apache Benchab -n 50000 -c 500 http://localhost:8080/api/test重点看三个指标吞吐量Requests per second、响应时间百分位比如 p95、p99、错误率。开启虚拟线程后在 IO 密集型接口上吞吐量往往能有显著提升而且在高并发下 CPU 使用率不会像以前那样飙满。如果业务主要是 CPU 密集计算比如图像处理、加密签名虚拟线程的收益就有限这时候不必强上。另外要提一句如果你用的是 Spring WebFlux 加 Netty 的响应式栈上面这个配置项主要影响的是阻塞工具类而不是 Netty 网络模型本身。WebFlux 本来就有一套自己的并发机制虚拟线程开关的意义主要在传统 Servlet 栈别搞混了。6. 升级到 Java 21 过程中的常见坑与排查思路6.1 多版本 JDK 混用先搞清楚当前生效的是哪一个升级过程中最大的坑不是安装 JDK 21而是装完之后发现自己根本没在用 JDK 21。Windows 上我用where javamacOS 和 Linux 上用which java先确认命令实际路径which java如果路径指向/usr/bin/java或C:\Windows\System32\java.exe继续往下查它到底链接到哪个 JDK 目录。Linux 上ls -l $(which java)看符号链接目标macOS 上/usr/libexec/java_home -V列出所有可用 JDKWindows 上打开环境变量面板看顺序。排查原则永远是从命令入口逆推到实际安装路径。还有一类隐蔽问题你终端用的是 Java 21但 IDE 里的 Maven 插件和 Gradle 守护进程还是旧 JDK导致构建结果和运行结果不一致。Maven 默认使用JAVA_HOME启动Gradle 守护进程一旦起来就会占用某个 JVM 版本如果项目要求的 toolchain 变了必须重启守护进程。6.2 IDE 安装和 SDK 设置是两个独立的事很多人在 IntelliJ IDEA 里装了 Java 21 的 JDK 包结果项目编译还是提示Source option 8 is no longer supported这是因为 IDE 的项目 SDK 设置还是旧的。IDEA 里需要到 File - Project Structure - Project 里把 SDK 改成 Java 21同时确认 Language Level 是 21。在 Settings 里的 Build Tools 菜单下还要分别把 Maven 的 JDK for importer 和 Gradle 的 Gradle JVM 都指向新的 JDK。Eclipse 用户则需要在 Window - Preferences - Java - Installed JREs 里添加 JDK 21并在项目构建路径里选中它。IDE 层面的配置其实不难但装完系统 JDK 不等于 IDE 自动使用是新手最容易忽略的认知点。6.3 Maven/Gradle 编译参数用 release 而不是 source/target在pom.xml里很多老项目还写着maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target切换到 Java 21 后我强烈建议改成maven.compiler.release21/maven.compiler.releaserelease参数会同时控制源码版本、class 文件版本和 JDK API 检查而source和target只管前两项不管 API。比如你明明写了21但依赖里引用了一个 Java 8 以后才有的类source/target模式不会报错最终运行时就乱套了。Gradle 这边更简单java { toolchain { languageVersion JavaLanguageVersion.of(21) } }用 toolchain 的好处是 Gradle 可以自动下载对应版本 JDK前提是构建机器能联网访问工具链仓库。6.4 老依赖和注解处理器Lombok 版本是重灾区Java 21 升级时很多编译错误并不是 JDK 本身的问题而是老版本的字节码处理库不认识新版 class 文件。这里点名最常踩的Lombok。如果你的 Lombok 版本低于 1.18.30在 JDK 21 下编译会出现诡异报错比如java.lang.ExceptionInInitializerError或者Caused by: com.sun.tools.javac.processing...。解决办法很简单把 Lombok 升级到 1.18.30 及以上版本。其他容易出问题的还有早期的 ByteBuddy、CGLIB 以及一些动态代理库。排查方法是用-verbose:class启动应用或者看异常堆栈里的类加载器信息定位到具体类后再去查它对应的库版本。别在第一反应里去怀疑自己的代码先把所有编译期注解处理器和字节码增强库版本拉一遍能省不少时间。如果是从 Java 8 跳到 Java 21还需要检查代码里用了sun.misc.Unsafe或者System.setProperty一些旧 API 的用法。JDK 强封装模块系统后部分内部 API 不再默认开放需要加--add-opens参数但这是最后的办法优先还是升级依赖库版本。最后分享一个我个人保留的操作习惯安装完任何 JDK都会新建一个空目录先编译运行上面那个Hello.java再用javap -v确认 class 版本是 65最后再接入 Maven 或 Gradle。这套三步验证虽然简单但能在 5 分钟内把环境层面的问题全部排除掉。碰到 Spring Boot 项目我会在application.yml里把spring.threads.virtual.enabled配好后先在测试环境放一两个低峰接口灰度跑两天观察 GC 日志和吞吐数据再逐步扩大到全量。虚拟线程不是开关一开就万事大吉结合自己业务的实际负载去调参才不会被线上突如其来的性能波动打个措手不及。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进