☰
JDK 10新特性深度解析:var、G1并行Full GC与AppCDS实践指南
2026/10/14 7:22:40 网站建设 项目流程

如果有人在 2018 年问我 JDK 10 值不值得升级,我的答案很直接:值得,但你要把它当成一个“承上启下”的版本来看。它的风头确实被前后两个大版本抢走了——前面有 JDK 9 的模块化,后面有 JDK 11 这个 LTS,夹在中间的 JDK 10 显得不够“炸”。可等你真把它跑进生产环境,才会发现 var、G1 的并行 Full GC、AppCDS 这些特性,其实直接影响日常开发体验。

这篇文章我把 JDK 10 里值得关注的新特性拆开讲,适合三种人看:正在为老项目做 JDK 升级评估的工程师、写业务代码但没时间一个个翻 JEP 的开发者、以及想搞懂 Java 版本演变思路的人。我会尽量避免罗列式地念 JEP 清单,重点放在“为什么要这样改”和“到了你的项目里该怎么用”上。

1. 局部变量类型推断 var:好用,但别把它当动态类型

1.1 var 解决了什么问题

JDK 10 最刷存在感的语言特性,就是 JEP 286 带来的局部变量类型推断,关键字var。很多人第一眼看到它,脑子里冒出来的疑问是:Java 不是一向以“显式类型”为卖点吗?怎么也开始走脚本语言的路子了?

先看它解决的实际痛点。Java 8 之后泛型和 lambda 越来越常见,有些变量的完整类型写出来非常冗长。比如下面这种从 URL 打开流的老写法:

URL url = new URL("https://example.com/api"); URLConnection connection = url.openConnection(); Reader reader = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));

左侧的URL url和右侧的new URL(...)重复了一遍类型;BufferedReader和一堆嵌套的InputStreamReader更是把关键信息淹没在样板代码里。用var改写之后:

var url = new URL("https://example.com/api"); var connection = url.openConnection(); var reader = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));

代码少了,而且阅读重点从“是什么类型”转向了“变量叫什么、干了什么”。注意,var不是一个新的运行时类型,也不是动态类型。它只是让编译器根据右侧初始化表达式推断出静态类型,生成的字节码和显式声明类型几乎没有差别。所以别担心它会给 JVM 带来额外开销,这个推断过程发生在编译期,运行期跟写URL url一样。

1.2 var 的适用边界与禁用场景

var的好用是有边界的,它在 JEP 286 里的适用范围非常明确:只能用于带有初始化器的局部变量声明、传统 for 循环的循环变量,以及增强 for 循环的循环变量。

以下场景在 JDK 10 中都不能用:

  • 方法参数:void doSth(var param)编译错误。
  • 方法返回类型:var foo() { ... }编译错误。
  • 成员变量、静态变量:字段类型不能推断。
  • 构造器参数:同样受限。
  • 没有初始化器的局部变量:var x;编译错误,编译器没东西可推断。
  • 数组初始化简写:var arr = {1, 2, 3};这种也不行,得写var arr = new int[]{1, 2, 3};,或者直接用var arr = new int[3];。

为什么限制这么严格?因为推断需要“有明确的右侧类型”作为依据。如果放开了方法参数和返回类型,Java 的静态类型体系就会被破坏,而且会让调用方和实现方之间的契约变得模糊。这个保守设计在后续版本里也没有改变,直到现在 Java 最新版本,var依然没有扩展到成员变量和方法签名上。

实际使用中,我推荐在三个地方优先用var:

// 集合泛型嵌套严重时 var list = new ArrayList<Map<String, List<Integer>>>(); // 增强 for 遍历复杂对象 for (var item : orderService.queryRecentOrders()) { // ... } // try-with-resources 的资源变量 try (var reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { // ... }

用try-with-resources时var是允许的,因为它本质上是局部变量声明,你只需要保证右侧表达式类型确定。

1.3 可读性训练:var 不是偷懒借口

这里说一个我踩过的坑。var会掩盖右侧表达式返回的实际类型,一旦接口返回类型不直观,代码读起来就很痛苦。典型反例:

var result = service.getData();

这个result到底是什么?是String还是List<User>还是Optional<Order>?只看这一行完全猜不出来。在 IDE 里能鼠标悬停看到类型,但代码评审、线上排查日志、打开 GitHub 看代码时,你没法每次都用 IDE。

所以后来我们团队定了一条不成文的规矩:**如果右侧表达式不能“自解释类型”,就不要用 var,或者必须给变量起一个能承载类型信息的名字。**比如:

var pendingOrders = orderFetcher.fetchPendingOrders(); var userCount = userStats.countActiveUsers();

变量名已经暗示了类型,读起来就不需要靠 IDE。如果右侧方法名本身就很含糊,那还是老老实实写出完整类型吧。另外,var和final可以组合使用:final var x = ...;,表示这个变量后续不能被重新赋值,在函数式风格里很常用。

注意:var推断的是编译期静态类型,如果右侧是接口类型而实际对象是实现类,那后续只能调用接口方法。例如var list = new ArrayList<String>(); list.add("a");没问题,但如果把它赋值给List<String>变量再传给其他方法,类型始终是ArrayList<String>,这和直接声明List<String> list = new ArrayList<>();是有区别的。

2. 垃圾回收的变化:从内部重构到 Full GC 提速

2.1 统一 GC 接口:给 JVM 装了一个通用底座

JEP 304 引入的是“统一垃圾回收接口”。这个标题听起来很底层,很多业务开发会直接跳过。但你如果持续关注 JVM 演进,一定会发现它的价值。

在 JDK 10 之前,HotSpot 里的垃圾回收实现各自为政,GC 代码和 JVM 内部逻辑耦合得非常深。想新增一个 GC,要么改动大量内部结构,要么只能靠复制粘贴。JDK 10 做了一件类似“把 USB 接口标准化”的事:定义了一套 GC 统一接口,把堆内存管理、回收策略、写屏障等核心逻辑抽象成标准化组件。这样,后续新 GC 只要实现这套接口,就能插进 HotSpot。

为什么要特别提这个?因为后面 Java 版本里出现的 ZGC、Shenandoah 这些新一代垃圾回收器,本质上都是受益于这次统一接口的铺垫。没有 JDK 10 这次重构,它们落地的时间只会更晚。对普通开发者而言,这是一个“当时无感、回头看全是伏笔”的改动。如果你们公司自己维护过 OpenJDK 的定制补丁,应该更能体会这种接口标准化能少踩多少坑。

2.2 G1 的 Full GC 从单线程变成并行

JEP 307 改动看似只是性能优化,但对跑 Java 8 升级上来的服务来说,感受最明显。从 JDK 9 开始 G1 成为默认垃圾回收器,JDK 10 又给 G1 的 Full GC 做了并行化。

你可能觉得:“G1 不是号称可以避免 Full GC 吗?”这是误解。G1 的目标是尽量减少 Full GC,但不能完全消除。在一些情况下仍然会触发 Full GC:并发回收跟不上分配速度、晋升失败、巨型对象分配失败等等。在 JDK 10 之前,一旦触发 Full GC,G1 会退回单线程的 mark-sweep-compact,也就是标记-清除-压缩。堆越大,这个单线程停顿越可怕,几十 GB 的堆可能一次 Full GC 就要暂停好几秒。

JDK 10 将 Full GC 阶段改成了并行处理:多个工作线程同时执行标记、清理、压缩。具体效果取决于机器核数和堆大小。在我在某内部服务上的观察,4 核 8G 堆环境下,Full GC 停顿时间比 JDK 8 时代的单线程降低了 30% 到 50%;堆越大、核数越多,收益越明显。

不过要提醒一句:**并行 Full GC 只是把停顿时间压缩了,不代表 Full GC 消失。**如果你的应用频繁触发 Full GC,根因依然是对象分配速度太快、存活对象太多或者参数配置不合理。遇到这种情况,优先排查的是:

java -XX:+UseG1GC -Xlog:gc* -jar app.jar

JDK 9 之后统一日志系统已经取代了旧的-XX:+PrintGCDetails、-XX:+PrintGCDateStamps,所以调优时直接用-Xlog:gc*就行,输出里能清楚看到何时发生 Full GC、停顿多久、回收了多少内存。

2.3 升级后 GC 调优该关注什么

从 JDK 8 直接跳到 JDK 10 的团队,最需要转变意识的是:G1 是新默认,别再找-XX:+UseParallelGC或-XX:+UseConcMarkSweepGC重新打开老套路了。除非你的服务有非常特殊的需求,否则先用 G1 默认参数跑一段观察期,比随便堆参数靠谱。

我建议的观察侧重点:

  • 先看 GC 日志里 Full GC 出现的频率和停顿,确认并行化有没有真正生效。
  • 关注-XX:InitialHeapSize和-XX:MaxHeapSize是否设置合理,堆太小导致频繁 Full GC,堆太大导致单次 GC 压力高。
  • 遇到晋升失败或者并发模式失败时,可以考虑调整-XX:InitiatingHeapOccupancyPercent(默认 45%),适当提前触发并发标记周期。

G1 在 JDK 10 比在 JDK 8 里成熟不少,默认参数也更合理,但“无脑放大堆”并不是万能的。我们后来发现,很多 Full GC 问题根源是代码里某个高频接口在短时间内创建了大量临时对象,堆再大也只是延缓爆发时间。

3. 应用类数据共享 AppCDS:性价比极高的启动优化

3.1 CDS 到 AppCDS:多了一个类加载器维度

AppCDS 应该是 JDK 10 里被低估程度最高的特性之一。它的全称是 Application Class-Data Sharing,直白说就是:让多个 JVM 进程共享一份预加载的类元数据、字节码和堆快照,从而降低启动时间、减少内存占用。

CDS 这个概念其实从 JDK 5 就有了,但当时只支持启动类加载器加载的核心类(比如java.lang.*),对普通应用没什么用。JDK 10 的 JEP 310 一次性把范围扩大到了平台类加载器和应用类加载器,也就是你打包的 jar 里的类、依赖的第三方库类,都能放进共享归档。

原理并不复杂:JVM 启动时最耗时的工作之一就是解析类的元数据、读取字节码、做各种链接操作。如果把这些处理结果预先打包成一个共享归档文件(.jsa),多个 JVM 启动时就可以通过内存映射直接读,省掉重复解析的开销。因为多个 JVM 进程映射的是同一份物理内存页,所以不光启动变快,内存占用也下降了。

3.2 生成和使用归档文件的实操步骤

AppCDS 在 JDK 10 里还需要手动开启和使用,它不是一个默认行为。大致分三步:

第一步,生成类列表。让应用跑一段“最典型的启动路径”,把 JVM 实际加载的类记录到文件里:

java -XX:+UseAppCDS -XX:DumpLoadedClassList=order.lst -jar app.jar

跑完之后order.lst里就是一行行类名。第二步,根据类列表生成共享归档:

java -XX:+UseAppCDS -XX:SharedClassListFile=order.lst -XX:SharedArchiveFile=app.jsa -Xshare:dump -jar app.jar

这里会加载类列表里记录的类并处理成归档文件。第三步,正常启动应用时指定归档:

java -XX:+UseAppCDS -XX:SharedArchiveFile=app.jsa -jar app.jar

如果你只是小规模试一下,甚至可以跳过手动类列表,直接用动态归档。JDK 10 支持-XX:ArchiveClassesAtExit=app.jsa这种“退出时抓取当前加载类”的方式,生成更简单,但可控性不如先确定类列表。我在生产上更推荐前一种方式,因为类列表是可提交到代码库的,团队里每个人生成的效果都一致。

3.3 AppCDS 的收益、限制和注意事项

收益方面,我实测下来:小服务(几十个类)启动时间可能只降 10% 到 20%,感觉不明显;但中大型 Spring Boot 应用,类数量动辄上万,启动时间降 30% 到 50% 很常见。同时因为归档是共享只读映射,多个实例跑在同一台机器上时,内存占用也能省一截。对于容器里追求快速扩容、快速启动的场景,这个优化相当划算。

限制也很明确:

  • 归档和 JDK 版本严格绑定,升级 JDK 后必须重新生成。
  • 类路径(classpath / module path)变了,归档不再匹配,启动时会自动回退到普通加载并给出警告。所以改了依赖版本后,记得同步重建归档。
  • 动态生成的类、反射创建的代理类,默认不会进入归档。
  • JDK 10 里 AppCDS 不是默认开启的,显式加-XX:+UseAppCDS才行。

到后来 JDK 13 已经把 AppCDS 默认开启了,JDK 12 起 CDS 也是默认打开,但归档仍然需要自己生成或通过动态归档获得。如果你从 JDK 10 开始就熟悉这套流程,后面版本升级就是无缝衔接。

注意:AppCDS 不会提升运行期性能,它优化的是类和元数据的加载过程,属于“启动优化”和“内存优化”的范畴。如果你追求的是接口响应时间变快,那是 JIT 编译器的事,不是 CDS 的事。

4. 线程本地握手与实验性编译器:性能底座的准备工作

4.1 线程本地握手:不再动不动就全局停摆

JEP 312 提出的“线程本地握手”是一个内部机制,但它对长期 JVM 性能的影响非常深远。在多线程 JVM 内部,很多操作需要所有线程停止在当前安全点才能进行,比如偏向锁批量撤销、某些 JVM TI 操作、旧版本里的某些调试功能。传统做法是直接触发全局安全点,也就是“Stop The World”。

这种全局停顿在 GC 场景下不可避免,但如果只是想让某个线程执行一个很小的回调,也让全世界停下,代价就有点高。线程本地握手允许 JVM 只对目标线程执行操作,其他线程继续运行。这让 JVM 可以更精细地控制线程状态,减少不必要的全局停顿。

JDK 10 把这项能力默认启用了。对应用开发者来说,它几乎是透明的,但个别依赖线程行为做奇技淫巧的老库可能会出现兼容问题。如果你在升级后遇到诡异的线程挂起问题,排查时可以留意一下 JVM 的-XX:ThreadLocalHandshakes开关,不过这种情况很少见。长远来看,线程本地握手是后续 JVM 其他优化的重要基础,尤其对垃圾回收器的并发化演进意义巨大。

4.2 Graal 与 AOT:实验特性的正确打开方式

JEP 317 把 Graal 作为实验性 JIT 编译器整合进了 JDK 10。Graal 是用 Java 写的编译器,通过 JVMCI(JVM Compiler Interface)接口接入 HotSpot。你可以在启动参数里显式开启:

java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -jar app.jar

它用起来很简单,但我要泼一盆冷水:实验特性意味着不稳定,别在生产环境随便开。Graal 在当时的目标是取代老 C2 编译器,但成熟度还需要时间打磨。如果你只是好奇,完全可以跑一个基准测试对比吞吐量和响应时间;如果项目非常关键,还是等后续 LTS 版本里观察效果再决定。

JDK 10 同期还引入了 JEP 295 AOT 编译,工具名jaotc。它可以把选定的类直接编译成静态链接的共享库,启动时直接加载,省去 JIT 编译的开销:

jaotc --output=app.so --jar app.jar java -XX:AOTLibrary=app.so -jar app.jar

AOT 编译适合那些“启动即高峰”的场景,比如 FaaS 函数、冷启动要求高的应用。但同样,当时它是实验性的,而且 AOT 代码不如 JIT 代码聪明——JIT 会基于运行时 profiling 做激进优化,AOT 做不到。所以除非你有明确痛点,否则不建议一开始就折腾这条路线,知道有这个东西就够了。

5. 值得记住的 API 细节:Optional 与不可变集合

5.1 orElseThrow 让代码意图更直白

JDK 10 给Optional加了一个无参的orElseThrow()方法。可能你会懵:orElseThrow(Supplier<X> exceptionSupplier)不是早就有了吗?确实,带参数版本 Java 8 就有,但很多人不想每次传异常 lambda,于是干脆直接调get()。get()的缺点是语义含糊,如果值不存在会抛NoSuchElementException,看到堆栈时你还得反应一下“哦,是 Optional 没有值”。

orElseThrow()无参版本的行为和get()一样,值不存在时抛NoSuchElementException,但方法名把意图写在了脸上——不存在就抛异常。我自己写代码时,现在基本不直接调get()了,统一用orElseThrow():

var config = configOptional.orElseThrow();

如果需要自定义异常信息,再用带参数版本:

var config = configOptional.orElseThrow(() -> new IllegalStateException("config missing"));

这个小改动对代码可读性的提升很实在,尤其是团队里大家已经习惯用Optional表示“可能有值”之后,用get()的地方总觉得在赌运气。

5.2 不可变集合收集器和 copyOf

JDK 10 给Collectors增加了三个方法:toUnmodifiableList()、toUnmodifiableSet()、toUnmodifiableMap()。它们收集完返回的是真正不可变的集合,任何 add/put 操作都会抛UnsupportedOperationException。

这个 API 的价值在于:以前你想得到一个不可变集合,常规操作是收集完再套Collections.unmodifiableList(new ArrayList<>(list)),既啰嗦又容易忘。现在直接写:

var unmodifiableList = stream.collect(Collectors.toUnmodifiableList()); var unmodifiableMap = stream.collect(Collectors.toUnmodifiableMap(Function.identity(), String::length));

要注意两个细节:

  • 这类收集器不允许null元素,流里一旦出现 null 就会在收集阶段抛NullPointerException。原因是很多 Java 内置不可变集合内部用 null 做特殊标记,于是干脆禁止 null。
  • 返回的不可变集合不保证迭代顺序和原始的 stream 顺序完全一致,尤其是toUnmodifiableMap()。

同时,JDK 10 还给List、Set、Map增加了copyOf静态工厂方法:

var snapshot = List.copyOf(originalList);

copyOf会先检查参数是否已经是不可变集合,是的话直接返回,否则复制一份新的不可变集合。这种防御式复制在多线程环境下很实用,可以快速给外部调用方一个稳定快照。

5.3 影响相对较小的性能与平台改动

JDK 10 还有几个不够“上头条”但值得扫一眼的改动:

改动作用
JEP 316 备用存储设备上的堆允许堆内存分配到 NVM 等备用存储设备,适合特殊持久化场景
JEP 315 改进 AArch64 intrinsics提升 ARM 64 平台上加密、数组拷贝等操作性能
JEP 319 根证书认证同步更新 cacerts 根证书,企业内网证书信任更方便
移除 javah 工具编写 JNI 头文件时直接用javac -h,不用再单独调 javah

这里面 AArch64 改进对现在大量使用 ARM 服务器的场景其实很有价值;备用存储堆则偏小众,除非你在折腾持久内存,否则不用深入了解。根证书更新看起来很小,但对处理内部 TLS 证书的公司来说,少了一次“莫名其妙握手失败”的排查过程。

6. 版本号新规则与六个月发布节奏:升级思路要改了

6.1 时间版本号怎么读

JDK 10 引入了 JEP 322,版本号从1.8.0_151这类“经典格式”,彻底改成时间驱动的格式:

$FEATURE.$INTERIM.$UPDATE.$PATCH
  • $FEATURE:功能版本号,比如 10、11、17。每六个月增加一次。
  • $INTERIM:默认是 0,只有兼容性更新的中间版本才非 0,一般不出现。
  • $UPDATE:安全更新和 bug 修复的序号。
  • $PATCH:紧急补丁级别,一般不出现。

所以一个版本号10.0.1的意思就是:功能版本 10,更新版本 1。而不再是“主版本.次版本.修订号”那套语义。从 JDK 10 开始,java -version的输出也变了,老铁们应该记得以前是java version "1.8.0_151",JDK 10 之后直接是java version "10.0.1"这种。

6.2 六个月一个大版本对开发者的实际影响

版本号规则只是表象,背后是发布模式的剧变:从“多年来一个大版本”切换成“每六个月一个大功能版本”,并且每三年出一个 LTS。JDK 9 是这套节奏的第一个版本,JDK 10 是第一个完整吃透新节奏的版本。

这对实际工作的影响非常直接。以前团队可能抱着“等一个稳定大版本再升级”的想法,一等就是好多年。现在如果还这样,你的 JDK 8 就会离新特性越来越远,而安全性更新也无法覆盖。JDK 10 本身还不是 LTS,它的公开更新支持期非常短,所以生产环境若是用了 JDK 10,基本上只是个中转站,后面要尽快跟上 JDK 11 或更高版本。

我的建议是:

  • 非关键项目可以主动体验新功能版本,保持对语言和 JVM 变化的感知。
  • 生产环境优先选 LTS,但在 LTS 发布之前,可以提前在测试环境跑对应的功能版本做兼容性验证。
  • 如果还停留在 JDK 8,直接跳 LTS 是合理的,但新代码里可以尽早熟悉var、不可变集合这些 API,因为它们不是某个版本专属的玩具,而是已经稳定的语言能力。

从发布策略的角度看,JDK 10 用半年时间证明了“快速迭代不会搞乱 Java”。它虽然很快被 JDK 11 接棒,但在 Java 演进史上正好处在一个关键的十字路口:语言特性开始照顾开发者体验,GC 走向更多样化,版本升级越来越像“持续交付”而不是“大爆炸重构”。

最后再分享一个我个人操作上的体会:当时我给几个内部服务做 JDK 8 到 JDK 10 升级时,最花时间的不是改代码,而是清理构建脚本里对javah、老 GC 参数和旧版本号的依赖。var的引入虽然是最显眼的,但真正让升级平滑推进的,反而是 GC 日志格式、CDS 和版本号这些容易被忽略的部分。如果你现在重新走一遍这个过程,我建议你也别只盯着语言特性,把 JDK 10 的底层基础设施改动一起纳入测试范围,这样才能避免“升级一时爽,上线火葬场”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询