JDK 13 是 2019 年 9 月发布的版本,放在 Java 整个演进史里算不上主角——它不是 LTS,生命周期只有半年左右,很多人甚至直接跳过了它。但如果你把视野稍微拉长一点,会发现 JDK 13 其实是 Java 语法变革里非常关键的一站:文本块第一次以官方特性形式出现,switch 表达式的最终形态也是在这一版里定型的。换句话说,你可以没在生产环境用过 JDK 13,但只要你弄懂了 JDK 13,再回头去看 17、21 里那些现代写法,会特别有亲切感。
这篇文章我就按实际使用的角度,把 JDK 13 的几个核心新特性逐个拆开讲一遍。内容包括:五个 JEP 分别解决了什么问题、具体怎么用、有哪些隐藏的坑,以及我实际验证过程中踩过的典型问题。适合正在做版本升级评估的人,也适合那些想系统梳理 Java 语法演进、准备技术面试的开发者。先说明一点,JDK 13 不是 LTS,我个人的态度是:生产环境没必要追,但它的语言特性值得认真看。
1. 先盘一盘JDK13的发布背景与整体定位
1.1 版本演进中的位置与节奏
Java 从 9 开始改成半年一个版本的节奏,之后就是 9、10、11、12、13 这么一路往下走,LTS 版本大约隔三年出一个:8 是 2014 年,11 是 2018 年,17 是 2021 年,21 是 2023 年。JDK 13 正好落在 11 和 17 中间,属于典型的非 LTS 中间版本。
中间版本的意义不在于让大家立刻升级,而在于把一些新特性提前放到开发者面前试用。JDK 13 里的文本块和 switch 表达式,在发布时都是 Preview 状态,需要手动打开开关才能用。这种机制带来的好处是:语法可以先收集反馈,再在后续版本里定型。比如文本块在 13 第一次出现,到 15 才转正;switch 表达式在 12 就开始预览,14 才正式落地。如果你直接看 17 或 21 的语法,看到的都是结果;而看 13,你能看到这些语法最初的设计意图。
1.2 JDK13的五个JEP构成
JDK 13 的正式特性可以浓缩成一张表:
| JEP 编号 | 特性名称 | 类型 | 状态 |
|---|---|---|---|
| JEP 350 | 动态 CDS 存档 | 性能/平台 | 正式 |
| JEP 351 | ZGC 取消未使用内存 | 性能/GC | 正式 |
| JEP 353 | 重新实现传统 Socket API | 平台/网络 | 正式 |
| JEP 354 | Switch 表达式 | 语言语法 | 预览 |
| JEP 355 | 文本块 | 语言语法 | 预览 |
这五个特性里,真正每天写代码会碰到的就是后两个。CDS、ZGC、Socket 重构更多是平台层面的增强,普通业务开发大概率感知不到,但它们在特定场景下的价值很大。比如容器化部署越来越普及之后,启动速度和内存占用直接关系到成本和扩容效率,动态 CDS 和 ZGC 的内存回收都是往这个方向使劲的。
学习顺序上,我建议先把文本块和 switch 表达式彻底搞明白,再研究 CDS 和 ZGC 的适用场景,最后了解一下 Socket 重构的存在意义。这样既照顾了日常开发需求,又不会漏掉平台能力层面的重点。
2. 文本块:多行字符串的体验升级
2.1 老写法到底痛点在哪
在文本块出现之前,Java 里写多行字符串是一件非常折磨人的事。比如一段 JSON:
String json = "{\n" + " \"name\": \"example\",\n" + " \"version\": \"1.0\"\n" + "}";代码里全是\n加号拼接和反斜杠转义,数据内容被字符串语法搅得面目全非。更麻烦的是 SQL、HTML 这类对格式有要求的文本,一旦要改其中一个字段,你得在转义和拼接里反复横跳,稍不留神就多一个或少一个引号。我见过不少线上问题就是这种拼接字符串时漏了空格或者换行导致的。
文本块要解决的就是这个核心痛点:让代码里的字符串字面量,能直接以所见即所得的方式书写,不需要为了语言语法而牺牲可读性。
2.2 基本语法与缩进规则
JDK 13 里文本块的写法是用三个双引号作为定界符,直接换行书写内容:
String json = """ { "name": "example", "version": "1.0" } """;注意几个硬性规则:
- 起始的三个双引号后面必须紧跟换行,不能在同一行写内容。
- 文本内容里可以直接写双引号,不需要转义,所以上面 JSON 里的
"name"能原样出现。 - 结束的三个双引号可以单独占一行,也可以放在最后一行的结尾。
文本块的缩进规则是新手最容易搞混的地方。编译器会统计所有非空行的公共最小缩进,然后统一去掉这个缩进量。举个例子:
String sql = """ SELECT * FROM users WHERE name = "John" """;上面这段,每行内容前都有 4 个空格,结束分隔符"""前面也有 4 个空格,编译器会把这 4 个空格作为公共缩进去掉。所以最终输出是:
SELECT * FROM users WHERE name = "John"这里有个小细节:结束分隔符的位置会影响缩进计算。如果你把结束分隔符放在最左侧,不缩进:
String sql = """ SELECT * FROM users """;那公共缩进量就不是 4 个空格了,因为结束分隔符这一行的前导空白是 0,编译器按它来计算,内容行的缩进就会原样保留。实操中我建议统一把结束分隔符和内容行对齐,这样行为最可预期。
2.3 配套的String新方法
配合文本块,JDK 13 里也给 String 增加了一些方法,其中日常最常用的是formatted。它本质上就是String.format的实例方法版本:
String template = """ Hello, %s! Your score is %d. """; String message = template.formatted("小明", 99);以前的写法是String.format(template, ...),现在可以直接在模板字符串后面调formatted,读起来顺很多。另外还有stripIndent()和translateEscapes()两个方法,分别用来去掉公共缩进和把转义序列翻译成实际字符。不过在 JDK 13 里文本块是预览特性,这几个方法的使用场景基本都围绕文本块展开,等文本块在后续版本转正后,它们也跟着成了正式 API。
有一点需要提醒:JDK 13 的文本块只支持原有的转义序列,比如\n、\t。后来版本里新增的\s(空格)和行尾\(续行)在 13 里还没有,不要拿着新习惯往老版本代码里套。
2.4 实际使用中的注意事项
文本块最适合写 JSON、SQL、HTML、XML 这类自带格式的文本。我自己在项目里最常用它写 SQL 模板和测试数据,确实清爽很多。但有几个坑必须注意:
第一,文本块末尾会保留一个换行符。因为开始分隔符之后的内容是一个完整行,结束分隔符前面的最后一个换行会被保留。如果你不想要末尾换行,可以像前面例子那样把结束分隔符放在最后一行内容的后面:""";。
第二,IDE 的自动格式化可能破坏缩进计算。文本块里每行的前导空格是在字符串语义里的,IDE 如果按代码缩进重新对齐,输出内容就会变。后来版本里编译器的规则更精细,但 13 上还是得自己多留个心眼。
第三,文本块是预览特性,编译和运行都必须加开关:
javac --release 13 --enable-preview App.java java --enable-preview App如果只编译不加运行开关,运行阶段会直接报错;反过来如果编译时没开预览,源码直接编译不过。这个开关问题我用过几次之后已经形成肌肉记忆了,先加--enable-preview再跑。
3. Switch表达式:从语句到表达式的关键一步
3.1 老Switch的三大问题
老版 switch 语句是被吐槽最多的地方之一。第一个问题是容易漏写break,导致意外贯穿。Java 的 switch 默认是 fall-through 行为,一旦某个 case 匹配后没有break,后续所有 case 都会执行。这个设计跟大多数现代语言相反,也是很多 bug 的来源。
第二个问题是每个 case 共享作用域。在 switch 里声明变量,如果多个 case 分支都想用同一个变量名,就得额外加大括号,否则编译不过,代码非常啰嗦:
switch (mode) { case "a": int x = 1; break; case "b": int x = 2; // 编译错误 break; }第三个问题是 switch 只能“执行”,不能“返回”。实际开发里我们经常是根据某个值做映射,得到一个结果,老写法只能在外面声明一个变量,在分支里赋值,绕一圈才能拿到最终值。
3.2 箭头语法与yield
JDK 12 给 switch 引入了箭头->语法,JDK 13 在此基础上加了yield关键字,让 switch 能以“表达式”的身份直接产生值。先看一个最简单的版本:
String label = switch (day) { case MONDAY, FRIDAY -> "工作日"; case SATURDAY, SUNDAY -> "休息日"; default -> "中间"; };箭头后面接的是一个表达式或语句块,不需要break,也不会贯穿,多个 case 值可以直接用逗号并列。如果分支需要做多步计算,可以用花括号加yield返回结果:
int result = switch (mode) { case "a" -> 1; case "b" -> { int temp = compute(); yield temp * 2; } default -> 0; };这里的yield和return不一样:return是结束整个方法,yield只是给 switch 表达式产生一个值。JDK 12 的预览版本里,块级分支用的是break value这种写法,到了 JDK 13 改成了yield,语义更清晰。所以如果你在网上看到用break 返回值的代码,那是 12 预览版的写法,在 13 上已经不好使了。
3.3 语句与表达式的对比
| 维度 | 传统 switch 语句 | switch 表达式(JDK13) |
|---|---|---|
| 返回值 | 不能直接返回 | 表达式整体可以赋值 |
| 贯穿 | 默认贯穿,容易漏 break | 箭头语法天然不贯穿 |
| 分支格式 | 只能 case + 冒号 | 支持箭头和 yield |
| 穷尽性 | 不强制 | 必须覆盖所有情况或提供 default |
| 作用域 | 分支间共享 | 箭头分支各自独立 |
穷尽性这一点特别值得注意。switch 表达式要求所有可能的值都有对应分支,否则编译器直接报错。比如枚举类型,如果枚举新增了一个常量,而 switch 表达式没有处理它,就编译不过。从好的方面想,这是编译器在帮你做完整性检查;从坏的方面想,如果枚举经常变化,每次都要记得补分支。我的习惯是对枚举做 switch 表达式时,永远保留一个default,避免一改枚举就编译失败。
3.4 实战场景与搭配技巧
我实际用得最多的是状态映射和策略选择。比如把一个状态码转成可读描述,或者根据类型取不同的处理策略:
boolean canProcess = switch (state) { case PENDING, PROCESSING -> true; case DONE, FAILED -> false; default -> throw new IllegalArgumentException("未知状态: " + state); };另一个实用技巧是,箭头分支右边接的是表达式,所以可以直接调用返回同类型的方法。比如:
String result = switch (type) { case "admin" -> buildAdminLabel(); case "user" -> buildUserLabel(); default -> "unknown"; };代码比原来的 if-else 链干净很多。JDK 14 之后 switch 表达式转正,就不需要预览开关了。如果你现在写的是 17 或 21,语法基本一致,只是不用再加--enable-preview。
4. 动态CDS存档与ZGC内存增强
4.1 CDS到底在做什么
CDS(Class Data Sharing,类数据共享)是一种启动优化技术。它的原理很简单:JVM 启动时需要加载大量类,把类元数据放到堆里,这个过程既耗 CPU 也占内存。CDS 允许把类元数据预先处理成一个共享存档文件,下次启动时直接映射这个存档,省去类加载的解析和初始化工作。
在 JDK 13 之前,使用 CDS 需要手动生成类列表,过程大概是:
- 用一个诊断参数运行应用,把加载过的类记录到文本文件;
- 再用另一个参数配合类列表生成 CDS 存档;
- 最后启动时指定存档文件。
这套操作对普通开发来说太繁琐,所以大部分人根本不会碰。JEP 350 的目标就是把这个过程自动化:应用退出时自动生成存档,不需要你手工维护类列表。
实际操作很简单。第一次运行:
java -XX:ArchiveClassesAtExit=app.jsa -cp app.jar com.example.Main应用正常退出后,当前目录就生成了app.jsa。第二次启动时指定存档:
java -XX:SharedArchiveFile=app.jsa -cp app.jar com.example.Main如果不想分两次操作,JDK 13 还提供了一个-XX:+AutoCreateSharedArchive参数,启动时指定存档路径,JVM 发现存档不存在就自动生成,下次启动自动使用:
java -XX:SharedArchiveFile=app.jsa -XX:+AutoCreateSharedArchive -cp app.jar com.example.Main实测下来,对小应用启动时间可能只有几百毫秒的差异,但类数量大的应用收益明显。不过 CDS 存档有很强的环境绑定:JDK 版本变了、类路径变了、甚至平台不同,存档都可能失效。JVM 检测到不匹配时会忽略存档或者报警告,应用本身不会挂,但优化效果就没了。在容器环境里,我建议在构建镜像的时候就生成好存档,而不是让每个容器启动时现场生成。
4.2 ZGC现在可以把内存还给系统
ZGC 是 JDK 11 引入的一款低延迟垃圾回收器,设计目标是把 GC 停顿时间控制在毫秒级,面向大堆内存场景。不过最初版本的 ZGC 有个毛病:已经向操作系统申请的内存,在堆空闲之后不会归还。长时间运行的 JVM 哪怕实际使用率很低,也会一直占着内存不放手。这在传统物理机部署时代问题不大,但在容器和按内存计费的云环境里就是实打实的浪费。
JEP 351 给 ZGC 加上了取消未使用内存的能力。核心参数是-XX:ZUncommitDelay,单位秒,默认 300 秒。意思是当堆内存出现空闲,并且这种空闲持续超过 300 秒,JVM 才会真正把内存还给操作系统。试想一下:应用早上迎来一波高峰期,内存申请到 8GB,中午低谷期实际只用 2GB,没有这个特性的话 8GB 一直被占用;有了它,空闲部分会在延迟时间后被释放。
用法示例:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:ZUncommitDelay=120 -jar app.jar有一点需要明白:延迟时间不是越小越好。如果应用的内存使用有周期性波动,比如每分钟都有一次小高峰,你把延迟设成 60 秒,内存还回去之后马上又要重新申请,频繁的归还和重新提交反而会带来额外开销。我个人的建议是:容器内存受限或按内存计费的场景,可以适当调低到 120 秒左右;普通场景保持默认就很好。
另外,ZGC 在 JDK 13 里仍然是实验性特性,需要配合-XX:+UnlockExperimentalVMOptions -XX:+UseZGC开启。后续版本逐渐成熟,到低延迟场景里已经是很稳的选择,但这些参数在 13 上是必须加的。
4.3 Socket API底层重构
JEP 353 重构了java.net.Socket和java.net.ServerSocket的底层实现。老实现是很多年前用本地 C 代码写的,从 Java 1.0 时代一路留到现在,维护成本高,而且跟现代并发模型配合得不好。新实现基于 NIO 通道,用纯 Java 代码完成,结构清晰,易维护,也更适应高并发场景。
对业务开发来说,这个重构几乎是透明的,Socket、ServerSocket的公共 API 完全没变,你不需要修改调用代码。但底层变了,总会有一些微妙差异需要留意。如果你的项目里有比较底层的网络通信模块,升级到 JDK 13 之后建议做一轮压测,特别是长连接多、并发量高的场景。
万一压测发现异常,可以用一个系统属性切回老实现排查:
java -Djdk.net.usePlainSocketImpl=true -jar app.jar这个开关很有用,定位问题时先确认是不是新实现导致的行为变化。如果切回老实现后问题消失,基本可以确定是新 Socket 实现的兼容性问题;如果两边行为一致,那就跟这次重构无关,应该从代码层面找原因。
5. 容易被忽略的几个更新
5.1 Unicode 12.1支持
JDK 13 同步升级了对 Unicode 12.1 的支持。对于绝大多数业务开发来说,这个更新是无感的,因为常规的字符处理不会立刻受影响。但如果你做的是国际化、字符分析、文本处理这类工作,升级后某些字符的判断结果和编码转换行为可能发生变化。比如新增的字符、脚本或表情符号,在旧版本里可能被当作普通符号处理,新版本里会有更细的分类。范围比较窄,但属于那种不查文档就容易踩坑的隐性变更。
5.2 预览特性的开启与关闭
JDK 13 里文本块和 switch 表达式都是预览特性,这意味着即使你的源码用了 13 的新语法,编译和运行也得同时加--enable-preview。这里有个非常容易踩的坑:很多人在 IDE 里编译通过了,但命令行运行时报UnsupportedClassVersionError或者preview features are not enabled,就是因为编译时开了预览、运行时没开,或者反过来。
推荐的命令行组合是这样的:
javac --release 13 --enable-preview -d out src/com/example/App.java java --enable-preview -cp out com.example.App注意编译时最好加上--release 13,否则编译器可能按当前默认版本处理,导致二进制版本号不对。如果用的是 IDE,也需要在编译选项里把语言级别设置成 13,并且勾选预览选项。不同 IDE 的配置路径不一样,但核心就是这两个开关要同时打开。
5.3 非LTS版本的升级决策
JDK 13 的生命周期很短,过期之后官方不再提供免费更新。所以我不建议任何生产环境长期停留在 13。如果你所在的项目已经在用 11,想升级到 17 或更后面的版本,完全可以跳过 13;如果你想体验文本块和 switch 表达式的新语法,直接用 17 或 21 更好,因为这些特性已经转正,不用再忍受预览开关的各种麻烦。
那学 JDK 13 还有什么意义?我的理解是,它是一份很好的“语法演进史教材”。看 17 的文本块,你不会知道为什么缩进规则长这样;看了 13 最初的实现,你就知道哪些设计是后来根据反馈改的。对技术面试来说,能把“这个特性在预览阶段长什么样、后来为什么改”讲清楚,比单纯背特性列表要加分得多。
6. 实际踩坑记录与排查速查表
6.1 文本块缩进被“吃掉”的现场
我之前写一个日志模板,代码是这样的:
String log = """ request received begin handle """;看起来一切正常,但输出的时候发现每行前面的空格比预期少了很多。排查下来发现是我编辑器自动缩进把结束分隔符"""顶到了行首,导致公共缩进量计算变成了 0,内容行原有的缩进被原样保留,于是模板的层级结构全乱了。
这个问题在文本块转正后依然存在,因为缩进计算的核心逻辑没变。我的解决办法是:拿到输出后先打印一遍,确认肉眼可见的内容正确,再进代码;不要凭编辑器里的显示判断最终效果。
6.2 Switch表达式漏分支的编译错误
有一次我在代码里用 switch 表达式处理一个枚举,当时只写了几个具体分支和default,后来枚举加了一个新值,项目编译直接失败。错误信息指向 switch 表达式分支不完整。如果你是刚接触这个概念,可能会觉得莫名其妙:我用default兜底了,怎么还报错?
原因在于:当 switch 作为表达式时,编译器要求所有输入值都有对应分支,这是一种穷尽性检查。如果分支包含了default,编译器认为默认情况已经覆盖,所以不会报错;但如果你想着靠枚举当前的值数量来保证穷尽,一旦枚举扩展就会编译失败。我的建议很简单:对枚举做 switch 表达式时,要么显式写出所有值,要么永远挂一个default,否则就是给自己埋雷。
6.3 CDS存档失效的排查
动态 CDS 用起来很顺手,但它有一个隐蔽的问题:如果应用的 jar 包更新了,而存档文件还是旧的,JVM 可能不会报错,而是直接悄悄忽略存档。你以为还在享受启动优化,实际上已经退化成了普通启动。
排查方法是加参数看日志:
java -Xlog:cds -XX:SharedArchiveFile=app.jsa -cp app.jar com.example.Main日志里会输出 CDS 存档是否成功映射。如果显示类似存档不匹配的信息,就说明需要重新生成。在持续集成环境里,我习惯把存档生成放进构建脚本,确保每次发布都会基于最新的 jar 重新生成 CDS 存档,避免手工维护导致过期。
6.4 ZGC内存不释放的确认方法
有朋友问过我:为什么我开了 ZGC 的内存回收,内存还是一直居高不下?这里需要明确一点,ZGC 归还内存是有条件的:既要堆内存空闲比例达到一定要求,也要空闲时间超过ZUncommitDelay的阈值。应用如果一直有合理的内存使用量,那内存当然不会还回来。
确认是否生效可以用 GC 日志:
java -Xlog:gc* -Xlog:gc+heap=debug -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -jar app.jar日志里会显示堆容量调整和内存提交的记录。如果完全没有任何归还迹象,再把-XX:ZUncommitDelay调小一点试试。注意别调到 30 秒这种极端值,要根据应用的实际内存曲线来定。
6.5 常见问题速查表
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 文本块编译失败 | 没有加--enable-preview | 编译和运行都加开关 |
| 运行时提示预览特性未启用 | 只有编译加了开关 | 运行也加java --enable-preview |
| 文本块输出缩进不对 | 结束分隔符位置影响缩进计算 | 结束分隔符与内容行对齐 |
| switch 表达式编译报分支不全 | 枚举新增值或没有 default | 补全分支或加 default |
| CDS 启动优化没生效 | 存档过期或不匹配 | 重建存档并查看-Xlog:cds |
| ZGC 内存不归还 | 未满足空闲时间/比例条件 | 调整-XX:ZUncommitDelay并查日志 |
我在 JDK 13 上验证这些特性时最深的体会是:中间版本的预览特性确实能让人提前看到 Java 的未来,但代价是要花不少时间去跟编译开关和缩进规则较劲。好在这些积累不会白费,等你在新版本里再用同样的语法时,基本就是零成本切换。如果你手里有闲余环境,装一个 13 跑一跑这些例子,感受一下语法演进的过程,比单纯看资料要直观得多。如果是为了生产升级,我建议还是直接看 17 或 21,那些才是当前真正值得长期停留的版本。