在 2021 年 4 月 27 日的 InfoQ 直播中,我探讨了为什么应该考虑升级到 Java 16 或 Java 17(一旦发布),并就如何完成升级提供了一些实用的建议。
直播的内容基于我个人的 GitHub 库 JavaUpgrades,其中有文档和示例介绍了升级到 Java 16 或 Java 17 时常见的难题和异常。其中也有具体的解决方案,你可以用在自己的应用程序中。示例要用 Docker 运行,是用 Maven 构建的,但是你当然也可以设置自己的 Gradle 构建。
本文以及那次直播都是为了让用户可以轻松升级到 Java 16 或 Java 17。大部分常见的升级任务都讨论到了,所以你可以更容易地解决它们,并专注于克服应用程序所特有的挑战。
为什么要升级?
Java 的每个新版本,尤其是大版本,都会解决安全漏洞,提升性能,增加新特性。保持 Java 版本最新有助于保持应用程序的健康,也有助于组织留住现有的开发人员,并有可能吸引来新员工,因为开发人员一般更希望使用比较新的技术。
升级有时会被视为一项挑战
人们认为,升级到 Java 的新版本需要很大的工作量。这是因为代码库需要变更,还需要在所有构建和运行应用程序的服务器中安装 Java 的最新版本。幸运的是,有些公司使用了 Docker,团队可以让它们自己升级这些内容。
许多人将 Java 9 模块系统(即 Jigsaw)视为一项重大的挑战。然而,Java 9 并不需要你显式地使用模块系统。事实上,大多数运行在 Java 9 以及更高版本上的应用程序并没有在代码库中配置 Java 模块。
评估任何升级所需的工作量都是一项挑战。那取决于多种因素,如依赖项数量及其现状。举例来说,如果你使用的是 Spring Boot,那么升级 Spring Boot 可能已经解决大部分升级问题。遗憾的是,由于存在不确定性,大部分开发人员会将升级工作量评估为许多天、周甚或是月。如此一来,考虑成本、时间或其他优先事项,组织或管理层就会推迟升级。我以前见过人们对将 Java 8 应用程序升级到 Java 11 的工作量评估从数周到数月不等。不过,我曾在几天内完成了一次类似的升级。这一部分是因为我之前的经验,不过,这也得益于我没有多想就开始了升级过程。周五下午升级 Java 就很理想,看看会发生什么。我最近将一个 Java 11 应用程序升级到了 Java 16,我唯一需要完成的任务就是升级一个 Lombok 依赖项。
升级可能很困难,评估所需的时间似乎是不可能的,但通常,实际的升级过程不会花那么多时间。在许多应用程序升级中,我都见过同样的问题。我希望帮助团队快速解决重复出现的问题,让他们可以集中精力克服应用程序独有的挑战。
Java 的发版节奏
过去,Java 每两年发布一个新版本。然而,从 Java 9 发布之后,新版本发布变成了每 6 个月一次,长期支持版本(LTS)每 3 年一次。大多数非长期支持版本都通过小版本升级提供大约 6 个月的支持,直到下一个版本发布。另一方面,LTS 版本几年内都会收到小版本升级,至少到下个 LTS 版本发布。实际提供支持的时间可能会更长,这取决于 OpenJDK 的供应商(Adoptium、Azul、Corretto 等)。举例来说,Azul 对于非 LTS 版本提供的支持时间就比较长。
你可能会问自己,“我应该总是升级到最新版本,还是应该停留在一个 LTS 版本上?”保证应用程序使用的是 LTS 版本意味着你可以利用小版本升级带来的各种改进,尤其是与安全相关的那些。另一方面,在使用最新的非 LTS 版本时,你应该每隔 6 个月就升级到一个新的非 LTS 版本,否则就无法利用小版本升级了。
然而,每 6 个月升一次级是一项不小的挑战,因为在升级应用程序之前,你可能不得不等待你所使用的框架完成升级。但是,你应该也不会等待太长时间,因为非 LTS 版本的小版本很快就会不再发布了。在我们公司,我们目前决定停留在 LTS 版本上,因为我们觉得自己没有时间每 6 个月升级一次,这样一个时间窗口太小。不过也不绝对,如果团队真得需要,或者一个非 LTS 版本带来了有趣的 Java 新特性,那么我们也可能改变决定。
升级到什么版本?
一般来说,应用程序由依赖项和你自己的代码(打包后在 JDK 上运行)构成。如果 JDK 中有什么修改,那么依赖项或 / 和你自己的代码就需要修改。在大多数情况下,这是由 JDK 移除了某项特性导致的。如果你的依赖项使用了一项已经移除的 JDK 特性,那么请保持耐心,等待该依赖项的新版本发布。
多 JDK 版本
当升级应用程序时,你可能希望使用 JDK 的不同版本,如最新版本用于实际的升级,老版本用于保持应用程序的运行。用于应用程序开发的当前 JDK 版本可以通过环境变量JAVA_HOME指定,也可以借助包管理工具 SDKMAN! 或 JDKMon。
对于我 GitHub 库中的示例,我使用 Docker 和不同的 JDK 版本来说明特定的特性如何工作或造成破坏。你可以试一下相关特性,而不必安装多个 JDK 版本。遗憾的是,使用 Docker 容器的反馈回路有点长。需要首先构建并运行镜像。所以一般来说,我建议你尽可能从 IDE 内升级。但是,在一个干净的、没有个性化设置的 Docker 容器环境中试验一些东西或构建应用程序或许是一个不错的注意。
为了说明这一点,我们创建了一个标准的 Dockerfile 文件,其中包含下面的内容。该示例使用了 Maven JDK 17 镜像,并将你的应用程序代码复制到里面。RUN 命令会运行所有测试,出错了也不会失败。
FROM maven:3.8.1-openjdk-17-slimADD . /yourprojectWORKDIR /yourprojectRUN mvn test –fail-at-end
要想构建上述镜像,则运行docker build 命令,并通过-t 指定标签(或名称),通过. 配置上下文,在本例中是当前目录。
docker build -t javaupgrade .
准备工作
大多数开发人员都是从升级本地环境开始,然后是构建服务器,最后是各部署环境。不过,我有时候会直接在构建服务器上使用新版本的 Java 进行构建,而不是针对这个特定的项目做好所有配置,然后看看会出什么问题。
一次性从 Java 8 升级到 17 也是可以的。不过,如果你遇到任何问题,可能会很难确定这两个 Java 版本间的哪个新特性导致了问题。小步升级,比如从 Java 8 升级到 Java 11,定位问题会比较容易。而且,在你搜索问题原因时,加上 Java 版本也是有帮助的。
我建议在旧版本的 Java 上升级依赖项。那样你可以专注于让依赖项可以正常工作,而不必同时升级 Java。遗憾的是,有时候没法这样做,因为有些依赖项需要更新的 Java 版本。如果是这样,你就别无选择,只能同时升级 Java 和依赖项了。
Maven 和 Gradle 提供了一些插件,可以显示依赖项的新版本。mvn versions:display-dependency-updates 命令会调用 Maven 版本插件。该插件会列出有新版本可用的依赖项:
[INFO] — versions-maven-plugin:2.8.1:display-dependency-updates (default-cli) @ mockito_broken —[INFO] The following dependencies in Dependencies have newer versions:[INFO] org.junit.jupiter:junit-jupiter ……………….. 5.7.2 -> 5.8.0-M1[INFO] org.mockito:mockito-junit-jupiter ………………. 3.11.0 -> 3.11.2
在build.gradle 文件中配置好插件后,gradle dependencyUpdates -Drevision=release 命令会调用 Gradle 版本插件:
plugins { id “com.github.ben-manes.versions” version “$version” }
升级完依赖项后,就可以升级 Java 了。要想把代码改到在新版本的 Java 上运行,最好是在 IDE 中进行,以确保它支持 Java 的最新版本。最后,将构建工具升级到最新版本,并配置 Java 版本:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <release>17</release> </configuration></plugin>plugins { java { toolchain { languageVersion = JavaLanguageVersion.of(16) } }}compile ‘org.apache.maven.plugins:maven-compiler-plugin:3.8.1’
不要忘了把 Maven 和 Gradle 插件升级到最新版本。
JDK 中移除的特性
JDK 中总是有些元素可能被移除,包括方法、证书、垃圾收集算法、JVM 选项,甚至是整个工具。不过,在大多数情况下,这些被移除的部分在删除之前已经被标记为“已废弃”或“将移除”。举例来说,JAXB 在 Java 9 中已废弃,但最终移除是在 Java 11 中。如果你已经解决了与已废弃的特性相关的问题,那么在特性真正被移除时也就不用担心了。
可以参考 Java Version Almanac 和 Foojay Almanac 对 Java 不同版本的比较,看看增加了哪些项,废弃了哪些项,或者是移除了哪些项。以 Java 增强提案(JEP) 这种形式所做的高级变更可以在 OpenJDK 网站上查看。关于每个 Java 版本的详细信息,可以查阅 Oracle 公布的发布说明。
Java 11
Java 11 移除了多个特性。首先是 JavaFX,它已经不在规范中,也不再捆绑在 OpenJDK 中。不过,有的供应商提供的 JDK 构建包含的内容比规范里的多。例如,ojdkbuild 和 Liberica JDK 的完整 JDK 都包含了 OpenJFX。此外,你也可以使用 Gluon 提供的 JavaFX 构建,或者向应用程序添加 OpenJFX 依赖。
在 JDK 11 之前,有些字体是包含在 JDK 中的。例如,Apache POI 可以把这些字体用于 Word 和 Excel 文档。然而,在 JDK 11 开始,就不再提供那些字体了。如果操作系统也没有提供,那么你可能就会遇到一些奇怪的错误。解决方案是在操作系统上安装字体。根据你在应用程序中使用的字体,你可能需要安装更多的包:
apt install fontconfigOptional: libfreetype6 fontconfig fonts-dejavu
Java Mission Control(JMC)是一个监控和性能分析应用程序,它开销很小,可以在包括生产环境在内的任何环境中对应用程序做性能分析。如果你没用过,我强烈建议你用一下。它不再是 JDK 的一部分,但 AdoptOpenJDK 和 Oracle 给它起了一个新名字 JDK Mission Control,并提供了单独的下载包。Java 11 的最大变化是移除了 Java EE 和 CORBA 模块,如 4 个 Web 服务 API——JAX-WS、JAXB、JAF 和 Common Annotations——因为已经包含在 Java EE 中,所以被认为是多余的。在 2017 年发布后不久,Oracle 就将 Java EE 8 贡献给了 Eclipse 基金会,旨在使 Java EE 开源。考虑到 Oracle 的品牌策略,有必要将 Java EE 重命名为 Jakarta EE,并将命名空间从javax迁移到jakarta。因此,在使用像 JAXB 这样的依赖项时,确保自己使用了比较新的 Jakarta EE 工件。例如,JAXB 工件的 Java EE 8 版本名为javax.xml.bind:jaxb-api ,后续开发于 2018 年停止。JAXB 的 Jakarta EE 版本在新工件jakarta.xml.bind:jakarta.xml.bind-api 下继续开发。务必确保应用程序中所有的导入都已经改为了新命名空间jakarta 。例如,对于 JAXB,将javax.xml.bind.* 改为jakarta.xml.bind.* ,并添加相关依赖项。下图中左边的列是受这项变更影响的模块。右边两列显示了可以用作依赖项的groupId 和artifactId 。请注意,JAXB 和 JAX-WS 都需要两个依赖项:一个用于 API,一个用于实现。官方没有提供 CORBA 的替代方案,但 Glassfish 还是提供了一个可用的工件。
Java 15
Java 15 移除了 JavaScript 引擎 Nashorn,不过,你仍然可以通过添加以下依赖项来使用:
<dependency> <groupId>org.openjdk.nashorn</groupId> <artifactId>nashorn-core</artifactId> <version>15.2</version></dependency>
Java 16
在这个版本中,JDK 开发者封装了一些 JDK 内部构件。他们不希望应用程序再使用 JDK 的底层 API。这主要影响了 Lombok 这样的工具。所幸,Lombok 几个周内就发布了一个新版本,解决了这个问题。
如果你有任何代码或依赖项仍然使用 JDK 内部构件,那么可以尝试使用 JDK 的高级 API 来解决这个问题。如果不行的话,Maven 还提供了一种变通方法:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <fork>true</fork> <compilerArgs> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg> <arg>-J–add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED</arg> </compilerArgs> </configuration></plugin>
我曾尝试使用 Maven Toolchains 通过在pom.xml 文件中指定 JDK 版本来实现 JDK 切换。很遗憾,当使用 Lombok 的旧版本在 Java 16 上运行应用程序时报错了:
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.8.1:compile (default-compile) on project broken: Compilation failure -> [Help 1]
上面就是全部报错信息。我不知道你怎么看,但在我看来,这没什么用,所以我提交了这个问题。如果这个问题修复了,那么使用 Maven Toolchains 切换版本是一种不错的方法。后来,我直接在 Java 16 上运行代码,得到了一个更具描述性的错误,其中提到了我之前展示的部分变通方案:
… class lombok.javac.apt.LombokProcessor (in unnamed module @0x21bd20ee) cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.processing to unnamed module …
Java 17
JDK 维护人员已经就 9 月份要发布的内容 达成了一致。Applet API 将被废弃,因为浏览器停止支持 Applet 已经很长时间了。实验性的 AOT 和 JIT 编译器也将被移除。作为实验性编译器的替代方案,你可以使用 GraalVM。最大的变化是 JEP-403:强封装的 JDK 内部构件。Java 选项–illegal-access 已经无效,如果你仍然试图访问一个内部 API,则会抛出如下异常:
java.lang.reflect.InaccessibleObjectException: Unable to make field private final {type} accessible: module java.base does not “opens {module}” to unnamed module {module}
大多数时候,这可以通过升级依赖项或使用高级 API 来解决。如果不行的话,你可以使用–add-opens 参数来获得对内部 API 的访问。不过,除非不得已不要这样做。注意,有些工具在 Java 17 上还无法运行。例如,Gradle 就无法构建项目,而 Kotlin 不能使用jvmTarget = “17” 。有些框架,如 Mockito,在 Java 17 上也有些小问题。enum 字段中的方法会导致这个特定的问题。不过,我估计大部分问题都会在 Java 17 发布之前或发布之后短期内得到解决。对于任何插件或依赖项,你可能会在构建应用程序时看到这条消息“不支持的类文件主版本 61”。类文件主版本 61 用于 Java 17,60 用于 Java 16。这基本上是说该插件或依赖项不能用于那个 Java 版本。大多数时候,升级到最新版本就可以解决问题。
完 工
在解决了所有挑战之后,你终于可以在 Java 17 上运行应用程序了。经过努力,你现在可以使用令人兴奋的 Java 新特性了,如记录和模式匹配。
小 结
升级 Java 是一项挑战,不过这也要看你的 Java 版本和依赖项有多老,你的环境配置有多复杂。本文旨在帮助你解决 Java 升级时最常见的挑战。一般来说,很难评估实际的升级工作要花费多长时间。我觉得,大多数时候,从 Java 11 升级到 Java 17 要比从 Java 8 升级到 Java 11 简单。对于大多数应用程序,从一个 LTS 版本升级到下一个 LTS 版本需要几个小时到几天的时间。大部分时间都花在了构建应用程序上。重要的是先开始,然后逐步更改。这样可以激励自己、团队和管理层继续努力。
你开始升级应用程序了吗?
Johan Janssen 是 Sanoma Learning 教育部门的一名软件架构师。他特别喜欢分享 Java 相关的知识。他在 Devoxx、Oracle Code One、Devnexus 等会议上做过演讲。他通过参与计划委员会来协助大会组织,发起并组织了 JVMCON。他得过的奖项有 JavaOne Rock Star 和 Oracle Code One Star。他在数字和印刷媒体上撰写了各种文章。他是 Chocolatey 各种 Java JDK/JRE 包的维护者,每月有大约 10 万次下载。
https://www.infoq.com/articles/why-how-upgrade-java17/