如果在搜索引擎里输入“JDK 10 新特性”,你看到的讨论热度大概率会被旁边的JDK 9模块化、JDK 11 ZGC压下去。很多人甚至把JDK 10当成一个“过渡版本”,看了个版本号就翻页。我最早也是这么想的,直到做了一次老项目从JDK 8向新版本迁移,把中间每个版本老老实实跑了一遍之后才反应过来:JDK 10的重要性被低估了。它虽然没有像模块化这种震撼级特性,但很多后来反复出现的技术名词——var、AppCDS、G1并行Full GC、Thread-Local Handshakes、六个月发布节奏——都是在JDK 10这一代定型和落地的。
这篇内容适合三类人:正在研究JDK新特性的同学、准备升级项目并想少踩坑的工程师,以及想搞懂为什么JDK 11/17会有那些变化的开发者。我会把JDK 10里值得记住的改动拆开讲,并附上实际验证过程中会踩到的细节。说白了,你不需要在生产环境长期使用JDK 10,但你值得花一个小时弄明白它给Java带来了什么。
1. 从“1.10”到“10”:JDK 10重新定义了Java版本的发布方式
1.1 一个很多人没意识到的版本号拐点
先聊个基础问题:有很多从JDK 8时代过来的老开发者,第一次看到新版JDK的java -version输出时,会愣一下。以前的输出是java version "1.8.0_202"这种格式,版本号里带了个“1.”前缀。等你拿到JDK 10,输出会变成java version "10.0.2" 2018-07-17这种。很多人以为这是从JDK 10才开始的,其实版本字符串方案在JDK 9就铺了路,但真正引入“时间驱动版本命名”并彻底强化这个模型的是JDK 10的JEP 322。
JEP 322定义了一个非常清晰的四段式版本号:
| 版本组成部分 | 含义 | 举例 |
|---|---|---|
| $FEATURE | 功能版本号,一次大版本增加一位 | 10 |
| $INTERIM | 中间版本号,通常为0,表示没有中间版本 | 0 |
| $UPDATE | 更新版本号,对应安全修复和常规补丁 | 2 |
| $PATCH | 补丁版本号,用于紧急修复的特殊补丁版本 | 0 |
所以10.0.2的意思就是:功能版本是10、中间版本为0、更新版本为2、补丁版本为0。不要小看这个变化,它直接改变了开发者的升级认知。JDK 8时代一个版本能用很多年,大多数人熬到1.8.0_191才慢慢研究新版的差异;而JDK 10之后就变成了“每六个月一个功能版本、每季度一个更新版本”的高频节奏,你根本等不起两三年才升一次级。
1.2 六个月发布节奏对普通开发者的真正影响
JDK 10是第一个严格按“时间点发布模型”发布的版本,这一点比它自身的特性更重要。它的公开生命周期大概从2018年3月到同年9月,很快就被JDK 11取代。这种短命特性决定了它不会成为企业生产环境的长期选项——真正要长期支持的团队会等下一个LTS版本,也就是后来的JDK 11。
但“生命周期短”不等于“不值得了解”。六个月节奏带来的是一种新的知识获取方式:你不会再有耐心翻完一本厚厚的版本手册,而是要习惯每个版本发布后去盯JEP列表,看哪些特性进入主线、哪些只是实验,哪些是下半年LTS版本里会重点沉淀的能力。JDK 10恰好提供了这样一个观察窗口。
1.3 为什么说JDK 10是一个承上启下的版本
从JDK 9搞完模块化大工程之后,JDK 10的主要任务其实是“收拾战场、铺路”。它没有引入那种改变应用架构的东西,而是把基础能力做扎实:新的版本模型定了,G1的Full GC体验优化了,类共享从底层扩展到了应用层,JVM内部的线程同步机制也在换血。这些改进你在业务代码里可能一个字都没见过,但它们是后面JDK 11、17等版本上各种性能优化能成立的前提。所以如果你要理解“Java接下来几年到底往哪走”,JDK 10是绕不开的一站。
2. var局部变量类型推断:最显眼的新特性,也是最容易被用歪的
2.1 var到底做了什么
JDK 10里讨论度最高的特性无疑是JEP 286的局部变量类型推断,也就是var。很多人对它的第一反应是“Java终于有var了,可以少写点类型了”,这个理解没错,但容易跑偏。
var允许你在局部变量声明时省略显式类型,让编译器根据初始化器推断。举个最典型的例子:
var url = new URL("https://example.com"); // 等价于 URL url = new URL("https://example.com"); var list = new ArrayList<String>(); // 等价于 ArrayList<String> list = new ArrayList<String>(); for (var item : list) { System.out.println(item); } for (var i = 0; i < 10; i++) { // ... }这里的关键是:var不是动态类型,不是JavaScript或者Python里那种运行时才确定类型的变量。Java的var在编译期就会固定成静态类型,后面所有方法调用、赋值检查都按这个静态类型来约束。所以你不能说它让Java变成了弱类型语言,它只是让“类型书写”变简洁了,类型检查一样严格。
2.2 边界条件与常见坑
我在实际体验JDK 10时,最容易踩的坑有三个。
第一个坑是var和null的冲突:
var name = null; // 编译报错:无法推断类型编译器没法从null判断出具体类型,所以var声明必须有非空初始化器。另外var name;这种先声明后初始化的写法同样不被允许。
第二个坑是var和菱形运算符一起用。很多人会写出这样的代码:
var list = new ArrayList<>();期望它自动推断成ArrayList<String>或者其它具体类型,但实际上这个表达式在编译时会退化,最终得到的类型是ArrayList<Object>。因为var省掉了目标类型之后,菱形推断失去参考目标,只能按Object处理。结果你往这个list里塞了一个123,编译器也不会报错,因为它是ArrayList<Object>而不是ArrayList<Integer>。这个问题在代码合并或者重构成var风格时很容易埋雷。
第三个坑是var和lambda表达式。JDK 10里的var不能用在lambda参数上,这一点直到JDK 11才通过JEP 323放开。更麻烦的是,如果你想声明一个变量接收lambda,比如var f = x -> x.length();,这个写法在JDK 10里是编译不通过的,因为缺少目标类型,编译器不知道这个lambda到底该匹配哪个函数式接口。所以看到网上一些把var和lambda混在一起写的骚操作,先确认版本,别在JDK 10上盲目照搬。
2.3 什么时候该用var,什么时候别用
以我自己的经验来判断,var适合用在“类型一目了然”的场景。典型情况包括:
- 初始化器右侧的泛型类型特别长得要命,比如
Map<String, List<Map<String, Integer>>>这种嵌套结构,写成var能有效提升可读性。 - 链式方法调用中,你拿到一个中间对象,后面只调用它的单一方法,这种用
var也没问题。 - 工具类里的局部集合、临时变量,类型可以从上下文明确读出来时可以用。
不适合用var的场景也很明确:方法的返回值类型、方法参数类型不能使用var,这是语言层面的限制;而在一些核心业务逻辑里,如果类型本身就是业务信息的一部分,比如金额和数量混在一起时,我宁可把类型写全,让后面接手代码的人少猜一分钟。
这里还涉及一个团队协作问题。var用得好能省阅读成本,用不好就变成“人人猜谜”。我在项目里见过的做法是:代码评审时特别关注两件事,一是var是否被用在短类型变量上,二是var是否掩盖了接口的实现类型。如果一段代码里var满天飞,所有局部变量都写成var,我会建议打回重写,因为局部变量的临时性不能成为牺牲可读性的理由。
2.4 JDK 10里var的一个冷门小技巧
虽然我平时不太推荐,但JDK 10的var确实带了一个隐藏玩法:它可以推断匿名类的类型,从而让你调用匿名类上额外定义的方法。
var obj = new Object() { void hello() { System.out.println("hello"); } }; obj.hello(); // 编译通过这在把类型写全时是做不到的,因为Object obj = new Object() {}之后调用obj.hello()会编译失败。这种写法的使用场景比较窄,我在实际项目里基本不去用它,但理解原理有助于加深对“var推断的其实是编译期精确类型”这个本质的理解。
3. AppCDS应用类数据共享:几分钟让启动时间明显变短
3.1 类数据共享不是新东西,但JDK 10把它做大了
接触过Java服务端启动调优的人应该知道CDS(Class Data Sharing),它很早就存在于JDK中。它的核心思路是:类加载过程本身有成本,JVM每启动一次就要重新读取class文件、解析常量池、做验证。与其每次重复,不如把整理好的类元数据放到一个归档文件里,启动时直接映射到内存,省掉大量重复工作。
老版本JDK的CDS只覆盖核心类库,也就是加载java.*这类基础类。问题是什么呢?应用自己的类和第三方依赖类的加载开销往往才是启动阶段的大头。JDK 10的JEP 310把CDS扩展到了应用类加载器,这就是AppCDS,应用类数据共享。应用类、第三方jar里的类只要出现在归档里,就能享受和核心类一样的“预加载”待遇。
3.2 三步实操:录类、建区、启用
我在用完JDK 10的AppCDS之后,最大的感受是:操作路径不复杂,但很多细节必须对齐。下面的步骤是必须严格照着跑的。
第一步,先运行一次应用,生成类加载列表。假设你的启动类是com.example.Main:
java -XX:+UseAppCDS \ -XX:DumpLoadedClassList=app.lst \ -cp app.jar \ com.example.Main这条命令的执行过程和正常启动没有本质区别,应用会正常跑起来,JVM在运行过程中把实际加载过的类记录到app.lst文件里。这个文件是个纯文本,每一行是一个类名。
第二步,用这个类列表生成共享归档:
java -XX:+UseAppCDS \ -XX:SharedArchiveFile=app.jsa \ -XX:SharedClassListFile=app.lst \ -Xshare:dump \ -cp app.jar注意,这里-cp指定的classpath必须和第一步一致。JDK 10的AppCDS归档和classpath是强绑定的,如果你换了jar包路径、改了依赖版本,哪怕只有一个jar路径变了,生成的归档也无法生效。这个坑我当时反复试了好几次才明白。
第三步,用包含归档的方式正常启动应用:
java -XX:+UseAppCDS \ -XX:SharedArchiveFile=app.jsa \ -cp app.jar \ com.example.Main在JDK 10里,dump和使用归档都需要显式加-XX:+UseAppCDS。后面的版本对参数做过调整,但就这个版本而言,少了它不会生效。
3.3 如何验证AppCDS真的生效了
别只看启动时间,还要确认“类确实来自共享归档”。用-Xlog:class+load=info看类加载日志,如果在某些类的记录后面看到source: shared objects file字样,说明它确实走的是共享归档。如果看到的是source: file:app.jar,说明归档没生效,要去查路径是否一致、UseAppCDS有没有开。
从效果上说,AppCDS对不同应用的提升差异挺大。一个类加载数量很少的命令行工具,收益可以忽略;一个会加载几千个类的框架型应用,启动时间降低百分之二三十并不夸张。我手头一个模拟项目X跑过测试:应用本体加上一批公共依赖,类加载阶段耗时从原先的接近2秒降到1.5秒左右,整体启动时间缩短约两到三成。这只是参考数据,具体收益和机器、类数量强相关。
3.4 AppCDS的适用场景和注意点
AppCDS特别适合微服务批量部署的场景。几十个JVM从同一个归档启动,不只启动更快,由于归档内存是只读共享的,还能降低一定内存占用。但要注意,它不是一劳永逸的优化。每次依赖升级、classpath变动,都必须重新生成归档。比较稳妥的做法是把“记录类列表、生成归档”这两步写进发布脚本,让它成为构建流程的一部分。
另外,如果你用的是一开始就跑起来就不停的重型服务,启动时间本来就不敏感,AppCDS带来的收益不大。这类场景不如把精力放到GC调优上。AppCDS的价值集中在“反复拉起JVM”的场景,比如短任务批处理、开发自测、容器冷启动等。
4. G1并行Full GC与Thread-Local Handshakes:JVM为低延迟做的铺垫
4.1 G1 Full GC是那个年代的真实痛点
JDK 9之后G1逐步成为默认垃圾回收器,但很多人不知道的是,G1最让人头疼的问题之一是Full GC退化。G1本身是分region、并发标记的设计,正常情况下的停顿能控制得很短,但一旦发生Full GC,就需要对整堆做一个完整的标记-清除-压缩,这个过程在JDK 10之前居然是单线程执行的。
怎么理解这个场景?你可以把G1的正常GC想象成一支专业团队分工清扫一个大仓库,各区域分头行动,效率很高。但Full GC发生时,团队解散,只剩下一个人从头到尾把仓库整个翻一遍。堆越大,翻一遍越慢,停顿时间就越难看。对高响应要求的服务来说,一次Full GC就可能导致大量超时。
4.2 JEP 307带来的并行Full GC
JDK 10的JEP 307让G1的Full GC改成并行执行,标记、清除、压缩阶段都充分利用多核,把一次停顿的耗时压下来。它对吞吐和延迟的改善,在堆比较大、Full GC不是特别频繁但一次很痛的系统上感知明显。并行度可以通过-XX:ParallelGCThreads来调整,默认值会根据机器核数和堆配置计算。
要验证这个改进,最直接的方式是观察GC日志。JDK 9之后日志格式变成了-Xlog语法,你可以这么用:
java -Xlog:gc*=info \ -XX:+UseG1GC \ -jar app.jar日志里搜索Pause Full,看它后面记录的耗时。如果你有相同应用在JDK 8和JDK 10上的日志对比,很容易看到Full GC时间的变化。不过我的建议是:看到Full GC变快不值得高兴,真正要做的是减少Full GC次数。并行Full GC是缓解,不是根除,堆规划、对象分配速率、晋升策略才是重点。
4.3 Thread-Local Handshakes:把全局安全点拆细
Thread-Local Handshakes是JDK 10里另一个容易被忽略的底层改动,来自JEP 312。要理解它,得先知道什么是安全点(safepoint)。JVM做GC、偏向锁撤销、某些调试操作时,需要让所有Java线程停在某个安全位置,然后执行VM操作。线程越多、工作越繁忙,全体线程到达安全点的开销就越大。
JEP 312的技术点是:JVM不再总是需要请求“所有线程同时进入安全点”,而是可以在单个线程上执行“握手”操作。你可以理解为以前是全班同学都得停下来等老师说事情,现在是老师能单独找到某个同学说两句。这个能力对偏置锁撤销、JVMTI回调、安全点统计等场景都有价值。
普通开发者在这条特性上基本不会直接感受到API变化,但它会影响GC暂停质量和整个JVM的运行开销。线程数很多、锁竞争不小的服务,升级到JDK 10之后整体稳定性更好,背后就有它的影子。它为后续JDK里一系列减少安全点开销的优化打好了底子,这也是为什么我把它列为JDK 10“隐形但重要”的改动。
4.4 统一日志系统顺手升级
JDK 10继续完善了JDK 9引入的统一日志框架。老开发习惯用-verbose:gc,但如果你想细致排查,新的-Xlog语法好用得多:
java -Xlog:gc*=info,gc+phases=debug:file=gc.log:time,uptime \ -XX:+UseG1GC \ -jar app.jar这种配置可以把GC日志输出到文件,同时带上时间和uptime。排查G1 Full GC时,gc+phases级别的日志能精确到每个子阶段的耗时,比老版本日志好用不少。
5. 容易被忽略的改动:容器感知、备用内存堆、根证书与删除项
5.1 容器里的内存和CPU:JDK 10看得更准了
JDK 10时代正好是容器化部署开始普及的时候。Java在容器里跑有一个老毛病:JVM默认去读宿主机而不是容器的资源配额。比如你给容器限了2个CPU、1GB内存,Java却可能按照机器上是几核几十G内存来设置GC线程数和默认堆大小,结果就是容器频繁被杀死,或者JVM内存配置远超实际限制。
JDK 10前后,JVM在这块做了大量改进,逐步识别cgroup配置,把CPU和内存配额纳入默认参数计算。这一改动对云原生和Docker部署影响非常大。你在容器里跑一个JDK 10后的JVM,它会尽量按容器配额来调整行为。当然这个能力是渐进的,不同小版本覆盖程度有差异,但方向非常明确:Java正在把容器当作“机器”来对待。如果你的服务部署在容器里,升级后可以重点观察一下GC线程数和默认堆大小的变化,这往往是容器感知生效的直接信号。
5.2 把堆分配到备用内存设备
JEP 316是一个听起来很“科幻”的特性:允许JVM把Java堆放到普通DRAM以外的设备上,比如持久内存(NVDIMM)。参数是-XX:AllocateHeapAt,后面指定一个路径:
java -XX:AllocateHeapAt=/mnt/persistent-mem \ -Xmx8g \ -jar app.jar堆被映射到备用内存设备后,理论上可以利用大容量非易失内存,同时降低对普通内存的占用。但这个特性对硬件要求很苛刻,普通SSD、磁盘是不能拿来当堆的,性能会很难看。普通开发者把这个功能当作“了解”即可,它代表JVM在内存层次上的探索方向,短期内不会成为主流玩法。
5.3 根证书的补齐,开发体验的小改善
JDK 10的JEP 319做了一件事:把主流CA根证书直接放进JDK的cacerts文件里。之前的JDK因为缺少完整根证书,开发环境里经常出现HTTPS证书链验证失败,你得手动导入证书才能访问某些服务。升级到JDK 10之后,很多正常外网HTTPS访问不再报错,开发体验顺畅不少。当然,公司内网的自签名证书还是得自己导入,这不算bug,是信任边界问题。
5.4 javah被移除,Graal JIT开始以实验身份登场
JDK 10里还清理了一批老东西。最典型的是JEP 313:移除了javah工具。以前写JNI需要javah生成头文件,后来javac早就有-h选项能完成同样的事,javah就没有存在价值了,JDK 10直接把它删掉。如果你还在参考老文章用javah,需要更新一下命令习惯。
同时,JEP 317把Graal JIT编译器作为实验特性引入JDK 10。它是一套用Java本身写的JIT编译器,你可以通过打开实验选项来启用,但JDK 10里的定位是实验和验证,不推荐生产使用。这条特性是后来GraalVM生态的重要基础,在JDK 10发布时知道有这么个东西就够了。
6. 从JDK8直接跳到JDK11的人,为什么要回头补课JDK10
6.1 升级老项目时最容易撞上的几个坎
如果你今天仍然维护着JDK 8代码,想跳到JDK 10甚至更高版本,大概率会撞上这几类问题。第一是字节码库兼容性,像CGLIB、ASM这类在运行时生成和修改字节码的库,对JDK 10的支持有滞后,版本身份信息不足可能直接报异常。第二是模块化带来的访问限制,JDK 9引入模块系统后,很多用反射操作内部API的老代码会收到InaccessibleObjectException,临时加--add-opens能跑,但要清楚这只是灭火。第三是编译选项的腐蚀性改动,JDK 10删掉了一些过时选项,网上那些老旧的-XX:参数很多已经失效,运行前最好用-XX:+PrintFlagsFinal去核对。
还有一点容易被忽略:团队里有人用了var之后,代码风格会分裂。再遇到var list = new ArrayList<>()这种写法,潜在的类型隐患会悄悄混入代码库。升级之前最好先定一个规约:哪些地方允许用var,哪些地方必须写全类型。
6.2 一条比较稳妥的验证路线
我的建议是别想着一次跳到最终版,而是把升级当作一次技术验收。先在开发环境编一次,跑全量测试,重点看编译错误和运行时反射报错。然后在测试环境同时跑两个版本,用GC日志对比Full GC时间和启动时间。有条件的话顺手把AppCDS的录类、生成归档、验证启用这套流程跑通,观察启动收益。
对生产环境,如果你需要长期支持,JDK 10不该作为停留点,而是作为通向后续LTS版本的一站。但如果你只是想学习,完全可以把它当作实弹练习场:var的边界、AppCDS的生效验证、G1日志的阅读、容器配额观察,这四件事在JDK 10上练会了,后面所有新版本你都能很快上手。
6.3 我的个人体会
我实际做完这一圈验证之后最大的感受是:JDK 10真正厉害的地方不在于某一条特性本身有多惊艳,而在于它集中解决了一批“底层体验”问题。版本节奏清晰了,启动更快了,GC停顿更稳了,容器支持更正常了。这些改进不激动人心,但每天都会影响成千上万跑在Java上的服务。如果你现在还在用JDK 8,或者刚刚开始研究新版JDK,我建议你找个下午,把JDK 10下载下来,亲手跑一遍var、AppCDS和G1日志的验证。亲自撞过var list = new ArrayList<>()这个坑,看过source: shared objects file这条日志,理解会比看十篇综述都深。
最后再分享一个小技巧:学习JDK版本特性时,不要只看那些“新特性预览”文章,要养成直接翻JEP列表的习惯。JDK 10的官方JEP也就十来个,每个读一遍原文,比看任何二手解读都准确。Java的演进一直都很实在,只要你愿意花点时间,它几乎不会让你失望。