第 02 章(节选) 核心结论与深度思考题
来自第 02 章「第 02 章 Java 17 新特性与 Java 8 差异(现代 Java 长什么样)」。 返回:第 02 章索引
02.9 Go ↔ Java 综合对照表
这一张表覆盖本章所有特性,可以当速查表用。
| 概念 | Go | Java 17(Java 8 的写法) |
|---|---|---|
| 变量声明 | x := 1(声明+推导) | var x = 1(推导,仅局部变量) |
| 不可变数据类 | struct(可变,零值可用) | record(不可变,无零值);Java 8:手写 class + Lombok @Data |
| 数据类方法 | 手写 | record 自动生成 equals/hashCode/toString + accessor |
| accessor 命名 | 字段直接访问 | name();Java Bean 是 getName() |
| 封闭类型 | 非导出接口方法 + 包内实现 | sealed + permits(可穷举) |
| 类型分派 | switch x := v.(type)(只能对接口) | switch 模式匹配(任意对象 + 解构 + when 守卫) |
| 穷举性检查 | 无(要写 default) | sealed + switch:编译期检查 |
| 多返回值 | (T, error) 原生支持 | 无;用 record 模拟,或抛异常,或 Optional |
| ”可能没有值” | nil, nil / (T, bool) | Optional<T>(仅返回值,不能作字段/参数) |
| “出错了” | error 值(可 wrap、errors.Is) | 异常(checked/unchecked) |
| 多行字符串 | 反引号(完全不处理转义) | Text Block """(处理转义,\s 保空格,\ 续行) |
| 字符串插值 | 无(fmt.Sprintf) | 无(formatted() / String.format);Java 21 预览过 String Templates |
| 时间 API | time.Time(不可变值,Format 无状态) | java.time.*(不可变);Java 8 之前:Date/Calendar(可变、坑多) |
| 日期格式化 | t.Format("2006-01-02") | DateTimeFormatter(不可变、线程安全);旧:SimpleDateFormat(非线程安全) |
| 数据转换流水线 | for 循环 | Stream(map/filter/reduce);Java 8 引入 |
| 并行处理 | go func() + channel/WaitGroup | parallelStream(共享公共池,禁放阻塞 IO);Java 21 虚拟线程 |
| 包/模块 | 包 = 目录 = 编译单元 = 封装边界 | 包 = 命名分组(开放);JPMS 模块 = 真封装(可选,少用) |
| 依赖版本 | go.mod + MVS(拍平,唯一版本) | Maven/Gradle + 最近优先/最高优先(运行时寻址,可能冲突) |
| 泛型 | 类型参数(1.18+) | 类型擦除(详见第 03 章) |
| 空值 | nil(零值,slice/map 上可用) | null(毒值,任何操作都 NPE) |
| 编译产物 | 单个静态二进制 | .class + jar(运行时 classpath 寻址) |
| 升级断裂 | 少(Go 1 兼容性承诺) | 有(JDK 版本间、Jakarta EE 9 命名空间) |
Go 程序员在 Java 里最容易踩的五个坑(本章汇总):
- 以为
var是:=—— 它只是类型占位符,而且是有限制的(不能用于字段/方法签名) - 以为
record是 struct —— 它不可变、无零值、不能加实例字段,且 accessor 不叫getXxx() - 以为 Text Block 是反引号 —— 它处理转义,对正则没有任何帮助
- 以为
Optional是(T, error)—— 它只能表达”没有”,不能表达”为什么没有” - 以为
parallelStream是go func()—— 它共享一个大小等于 CPU 核数的公共池,放阻塞 IO 会拖垮全 JVM
02.10 本章核心结论
-
var不是动态类型,是编译期类型占位符。 能推导的范围,恰好等于”不被外部依赖的信息”的范围 —— 局部变量可以(不进 class 契约),字段和方法签名不行(是二进制契约的一部分)。 -
record的 accessor 叫name()不是getName(),是一次主动切割。 JDK 在强制生态表态:“别拿 Java Bean 那套规则套我。” 代价是 Jackson < 2.12、MyBatis 构造器映射这些兼容性地雷。 -
sealed的价值不在”限制继承”,在”让编译器能穷举”。 Go 的非导出方法只能做到封闭(且包边界是编译器强制的),Java 做不到封闭(包是开放的),所以才需要这个关键字 —— 顺带拿到了 Go 拿不到的穷举检查。 -
模式匹配(
instanceof→ switch → 记录模式)是一套连贯的演进,Java 21 才算完整。 在 Java 17 上,switch 模式匹配和记录模式都还是预览特性,需要--enable-preview,不能上生产。 -
Optional是容器不是”可空的 T”,只能表达”没有值”,不能表达”为什么没有值”。 它只该作方法返回值;orElse的参数总是被求值,orElseGet才是懒的。 -
Java 8 最大的遗产坑是
Date/Calendar:可变 + 月份 0 基 +SimpleDateFormat非线程安全。SimpleDateFormat的病根是继承了DateFormat的protected Calendar calendar,把它当多步计算的临时工作区。修复路径是java.time全套。 -
parallelStream用 JVM 全局的ForkJoinPool.commonPool(),并行度 = CPU 核数 - 1。 里面放阻塞 IO 会拖垮整个 JVM 上所有用公共池的代码 —— 包括第三方 jar 里的。它只适合纯 CPU 密集 + 大数据量。 -
国内卡在 Java 8 不是技术问题,是经济问题:Oracle 授权变更 + Spring Boot 3 的断裂点 + 收益无法量化 + JDK 8 确实够用。升级的正确姿势是先只升 JDK(保持 Spring Boot 2.7),跑通再单独做 Jakarta 迁移,两件事拆开。
-
javax.*→jakarta.*不是 JDK 的锅,是 Jakarta EE 9(Eclipse 基金会拿不到javax命名空间使用权)。但因为”升 17”和”升 Boot 3”通常一起做,所以你一定会遇到。 -
Java 17 的
--illegal-access已经过时(JEP 403),写了只打印警告且不生效。 遇到InaccessibleObjectException,正确做法是用--add-opens止血 + 升级那个依赖内部 API 的库治本。
02.11 深度思考题
题 1:为什么 Java 的演进这么慢?从 Java 8(2014)到 Java 17(2021),七年才凑齐这一章讲的这些特性。
参考答案
直接答案:四个原因 —— 向后兼容承诺、企业级用户的保守性、JCP 的标准化流程、以及兼容性成本本身。
第一层:向后兼容是 Java 的生存根基,也是它的枷锁。
Java 的核心承诺从 1995 年延续至今:“Write Once, Run Anywhere” 包含一个隐含条款 —— “Compile Once, Run Forever”。 一份 1999 年编译的 .class 文件,今天在 JDK 21 上应该还能跑。
这个承诺意味着:
- 不能移除任何公开的 API(所以
Vector、Hashtable、StringBuffer这些早该死掉的类还在) - 不能改变任何方法签名(所以
Date.getYear()返回 126 这个 bug 永远修不了) - 不能改变任何字节码语义
- 每一个新特性都必须跟过去 25 年的所有设计共存
对比 Go:Go 1 的兼容性承诺(2012)范围窄得多 —— 只保证语言规范和标准库 API,且 Go 明确保留在未来版本做破坏性变更的权利(虽然有严格流程)。而且 Go 的部署单元是静态二进制,你升级 Go 版本会重新编译整个程序,不存在”用新 JVM 跑旧 class”这个场景。
静态链接 vs 运行时寻址,这个第 00 章讲过的差异,在这里第二次咬人。
第二层:企业级用户的保守性 —— 保守是理性的。
Java 的主要用户是谁?银行、保险、电信、电商、企业 ERP。这些系统的特点是:
- 生命周期以十年计(一套核心交易系统跑 15 年很正常)
- 出故障的成本极高(一笔交易错账可能赔几百万)
- 人员流动大(写这行代码的人早就不在了)
对这样的组织,“新特性”的价值接近零,“稳定”的价值接近无穷大。
所以 Java 的每次演进都要回答一个问题:“这个改动会不会让某个银行的系统炸掉?” 只要答案有一丝不确定,就会被推迟或者加一个过渡期。
--illegal-access 的演进史就是典型:Java 9 引入时默认 permit + 警告(给用户 6 年时间改),Java 16 才默认 deny,Java 17 才彻底废掉这个开关。七年的过渡期,就为了一个”不让你用内部 API”的改动。
第三层:JCP 和 JEP 流程。
Java 的标准化走 JCP(Java Community Process):一个 JEP(JDK Enhancement Proposal)从提出到落地,要经过:
- 起草 → 2. 提交 → 3. 候选 → 4. 成为 JEP(进入某个版本)→ 5. 预览(preview)→ 6. 二次/三次预览 → 7. 正式
看 record 的时间线:JEP 359(Java 14 预览)→ JEP 384(Java 15 二次预览)→ JEP 395(Java 16 正式)。三年。
预览机制(preview)本身就是一个”慢”的设计 —— 它的意思是”我们先放出来让你试,但不承诺二进制兼容,也不承诺语法不变,你反馈了我们再改”。这个机制保证了质量,代价是每个语言特性要占 2-3 个版本。
对比 Go:Go 的提案流程(Go 2 Draft Designs → proposal review)也慢,但因为社区小、决策集中(核心团队几人)、且没有”企业级用户兼容性审查”这个环节,整体快不少。Go 的泛型从讨论到落地(1.18,2022)用了十几年,也不快 —— 说明”语言演进慢”本身不完全是流程问题,复杂特性就是需要时间。
第四层:兼容性成本是真实的工程成本。
每加一个语言特性,要改的东西包括:
- javac 的词法/语法/AST/语义分析
- 字节码生成
- JVM 规范(有时候)
javap、javadoc、JShell- 反射 API(
getRecordComponents()这种新 API) - 序列化协议
- 所有 IDE 的支持
- 所有静态分析工具
- 语言规范文档(JLS)
record 加起来改了 JLS 的 4 个章节,新增了 java.lang.Record 和 java.lang.runtime.ObjectMethods 两个类,改了 javac 和 JVM(为了那个 invokedynamic)。这活儿不小。
更深一层 — 快慢是相对的,要看”改的是什么”:
Java 演进分两条线,速度完全不同:
- 语言特性(慢,JCP + 预览 + 生态迁移):record 三年、sealed 三年、switch 模式匹配四次预览(17→21)
- JVM 和 GC(快得多,用户无感):G1 每年都在改、ZGC 从实验到生产用了 4 年(JEP 333→377)、Shenandoah 同理、逃逸分析持续优化
为什么?因为 JVM 的改动对源码是透明的 —— 你不用改一行代码就能享受 ZGC 的收益。而语言特性要求所有用户改代码、改工具链、改依赖。
这给你一条判断标准:看 Java 的某个东西会不会快速演进,看它是否要求用户改代码。
最后回到 Go 的对比:Go 的极简主义(语言特性总数刻意保持很少)让它在”加东西”这件事上几乎没有历史包袱,但代价是很多需求(泛型、错误处理改进、枚举)要憋十几年才被满足。Java 的”慢”换来的是:它今天能同时承载 COBOL 时代的银行系统和最现代的虚拟线程应用。 这两种”慢”和”快”,服务于完全不同的目标。
题 2:record 能让 Java 代码像 Go struct 一样简洁吗?还差什么?
参考答案
直接答案:在”定义不可变数据”这个点上,record 已经不比 Go struct 差,甚至某些方面更好。但”简洁”不只是定义时的行数,还包括使用时的手感 —— 在这一点上 Java 还差三样东西。
先说 record 已经赢的部分:
// Java 17:一行,且自带 equals/hashCode/toString
record Order(long id, String name, long amount) {}// Go:也是一行,但没有 equals/hashCode/toString,
// 而且可变、有零值
type Order struct {
ID int64
Name string
Amount int64
}在”我要一个不可变的、能正确比较和打印的数据载体”这个需求上,record 写起来比 Go struct 更短(考虑上你要手写的那些方法)。而且 record 强制不可变、强制全参构造,这两条 Go 都做不到(Go 要靠”不导出字段 + 只提供构造函数”的约定,而约定是可以被绕过的)。
还差的三样东西:
第一样:零值和部分构造。
type Config struct {
Host string
Port int
Timeout time.Duration
}
c := Config{Host: "localhost", Port: 8080} // Timeout 留零值
c = Config{Port: 9090} // 只指定一个字段Go 命名字段初始化 + 零值,让你在字段多的时候非常灵活。
Java 的 record 必须全参:
record Config(String host, int port, Duration timeout) {}
new Config("localhost", 8080, null); // 必须把第三个也写上
new Config("localhost", 8080, Duration.ofSeconds(30));Java 的解法是 Builder 模式或者构造器重载,但那又是几十行样板代码 —— 你用一个 record 省下的行数,又用 Builder 还回去了。
(Java 也在想办法:JEP 468「Derived Record Creation」想引入 c with { port = 9090; } 这种语法,但目前仍是预览状态,语法也可能变。今天还用不上。)
第二样:值语义和内存布局。
type Point struct{ X, Y int64 }
points := make([]Point, 1000) // 1000 个 Point 在内存里是连续的 16KB
p := points[0] // 值拷贝,栈上分配,无 GC 压力record Point(long x, long y) {}
Point[] arr = new Point[1000]; // 数组里存的是 1000 个引用
// 真正的对象散落在堆上,每个还有 16 字节对象头这是 JVM 的根本性限制,不是语法能解决的。 一个 Long 字段和一个 long 字段在 Java 里内存差 3-4 倍(对象头 + 引用)。
Project Valhalla 的 value types(JEP 401)就是想解决这个 —— 让 record 能声明成”没有对象头、按值传递”的类型。这个项目从 2014 年做到现在,还没落地。如果它落地了,Java 在数值密集场景的性能会有一个数量级的提升。
第三样:struct tag 那个位置的东西。
type User struct {
Name string `json:"name" db:"user_name" validate:"required"`
Age int `json:"age" db:"age"`
}Go 的 struct tag 让”一份定义,多个框架各取所需”。
Java 用注解,功能更强(结构化、可运行时读取、可继承、可组合),但写起来更啰嗦:
record User(
@JsonProperty("name") @TableField("user_name") @NotBlank String name,
@JsonProperty("age") @TableField("age") @Min(0) Integer age
) {}字段多、注解多的时候,这个 record 的声明会横向拉得很长。这是注解比 tag 强带来的代价。
我的判断(给你一个可操作的结论):
record 让 Java 在**“DTO / 配置 / 多返回值 / Map key / 模式匹配载体”** 这五个场景下,已经达到了 Go struct 的简洁度。在这五个场景外(尤其是需要 Builder、需要值语义、需要大量注解的场合),Java 仍然更啰嗦。
所以:不要试图用 record 去模拟 Go struct 的一切用法。 它是 Java 的一个特定工具(“透明的、不可变的数据载体”),在这个定位上它做得很好;超出这个定位,它就不是合适的工具。
更深一层:这个对比最有价值的地方,是让你看清楚**“简洁”不是一个单一维度的指标**。Go 在”定义 + 初始化”上简洁,Java 在”语义保证”(不可变、无零值、自动方法)上简洁。两种简洁服务于不同的目标,Go 优化了”写”的路径,Java 优化了”读和推理”的路径 —— 这跟第 00 章讲的”Go 的隐式接口 vs Java 的显式接口”是同一条分界线。
题 3:如果让你说服团队从 Java 8 升到 17,你会怎么说服?给一个真实的说服框架。
参考答案
直接答案:不要从”语言特性更好”说服(这个角度在管理者面前毫无说服力),要从”风险、成本、收益”三个可量化的维度说服,并且给出灰度方案。
我给你一个实际用过的框架,分四段。
第一段:把”升级”重新定义成”降低风险”(这是最重要的一步)
管理者不关心 record 好不好用。他们关心:不升级会怎样?
把不升级的真实风险摆出来:
| 风险 | 具体 | 量化 |
|---|---|---|
| 安全补丁 | Oracle JDK 8 的免费公开更新止于 8u202(2019-01)。之后要么买订阅,要么自己跟 CVE 打补丁 | 近两年 JDK 8 的 CVE 修复滞后于 11/17,有些漏洞在 OpenJDK 8 上的 backport 要等数周 |
| 招人 | 现在(2026)校招和社招的 Java 工程师默认写 17/21。让他们写 8,入职体验差,且容易把 17 的写法带进来导致编译错误 | 团队规模 × 每人每年 |
| 生态断供 | 新版本的库(Spring Boot 3.x、Kafka Client 4.x、各种新 SDK)陆续要求 17+ | 你想用某个库时用不了,只能停在老版本,老版本不再修 bug |
| 技术债利息 | 越晚升,要一次性处理的东西越多(更多依赖、更多 javax.*、更多内部 API 依赖) | 每年递增 |
关键话术:不要说”我们要升级”,要说**“我们在技术债上欠了七年,现在开始还,再拖三年利息翻倍”**。
第二段:收益量化(要具体到能写进 OKR 的数字)
不要说”性能更好”,要说具体指标。建议先做一个一周的技术验证(spike),用真实数据说话:
| 指标 | 怎么测 | 典型结果(不同项目差异很大,要自己测) |
|---|---|---|
| GC 停顿 | 对比压测下的 GC 日志(jstat -gcutil 或 JFR) | G1 在 17 上有较多改进(可中止的 mixed collection、NUMA 感知、及时归还内存),大堆场景停顿常有改善 |
| 内存占用 | 同样压力下看 RSS | 容器感知(8u191+ 也有,但 17 更完善)+ 弹性元空间(JEP 387)通常能省 |
| 吞吐量 | 压测 QPS | 主要来自 JIT 和 GC 改进,通常有个位数到十几个百分点的提升 |
| 启动时间 | 冷启动计时 | AppCDS(Java 10+,JEP 310/350)能明显缩短 |
| 代码量 | 把几个 DTO 改成 record,统计行数 | 通常省 60-80% 的样板代码 |
注意:这些数字必须自己测。 我在这里给的是”常见情况”,但你的项目可能因为某个老依赖反而变慢。先测,再拿数据说话,别用别人的数字。
一个新项目可以直接享受的收益(这个对工程师最有感):
// 之前:60 行
// 之后:1 行
record OrderDTO(Long id, String name, long amount) {}第三段:风险清单(主动把风险摆出来,这会让你的方案更可信)
一定要自己先把风险列全,别等别人来问:
| 风险 | 概率 | 影响 | 应对 |
|---|---|---|---|
| 某个二方库不兼容 | 中 | 高 | 提前用 jdeps --jdk-internals 扫,列清单,逐个找升级版本 |
javax.* → jakarta.* 改动面大 | 高(如果同时升 Boot 3) | 中 | 拆成两步:先只升 JDK,再单独做 Jakarta 迁移 |
| 反射访问内部 API 失败 | 中 | 中 | --add-opens 止血 + 升级库治本 |
| GC 参数失效导致启动失败 | 高 | 低 | 启动脚本里有 UseConcMarkSweepGC 就会直接起不来,提前扫一遍 |
| 行为变化导致逻辑错误 | 低 | 高 | 全量回归测试 + 灰度 |
| 性能回退 | 低 | 中 | 压测对比,保留回滚能力 |
第四段:灰度方案(这是让方案通过的关键)
管理者最怕的是”一次性大爆炸式升级”。给他一个可以随时停下的路径:
第 1 步(1-2 周):技术验证 spike
├─ 拉一个分支,把 <maven.compiler.release> 改成 17
├─ 保持 Spring Boot 2.7 不动(2.7 支持 Java 17)
├─ 用 JDK 17 编译、跑通全量单测
├─ 跑 jdeps / jdeprscan 出风险清单
└─ 产出:一份"我们这个项目升 17 需要改什么"的清单 + 压测对比数据
第 2 步(2-4 周):改造 + 全量回归
├─ 升级不兼容的依赖(Lombok、Netty、Mockito、ByteBuddy...)
├─ 处理 --add-opens
├─ 清理 GC 启动参数
└─ 全量回归(单测 + 集成测试 + 手工核心链路)
第 3 步(1 周):灰度上线
├─ 先上一个非核心实例,观察 24 小时(看日志、GC、P99、内存)
├─ 再上 20% 流量,观察 48 小时
├─ 50% → 100%
└─ 每一步都要有明确的回滚条件(比如 P99 涨幅 > 20% 就回滚)
第 4 步(可选,独立项目):Spring Boot 3 + jakarta 迁移
└─ 这是另一件事,另开一个项目周期
每一步都有明确的”停下来”的条件。这样管理者批的是”第 1 步的两周”,不是”一个三个月的大项目” —— 审批难度完全不一样。
几个实战建议(这些是踩过坑才知道的):
-
先升构建用的 JDK,再升运行时的 JDK。 用 JDK 17 编译但
release设成 8,先确认编译没问题;再改release到 17;最后换运行环境的 JDK。三层逐步推进。 -
一定要先删掉所有 GC 参数跑一轮。 很多人升级时保留了 Java 8 时代的
-XX:NewRatio=2 -XX:SurvivorRatio=8 ...一堆参数,结果 G1 在 17 上的默认行为已经完全不同,这些老参数反而成了负优化。先归零,测一轮,再按需加回。 -
别在同一个发布里同时做升级和功能开发。 否则出问题你分不清是升级的锅还是新功能的锅。
-
找一个”非升不可”的触发点。 比如”要接 XX 中台,它的 SDK 要求 17+”、“要升 Spring Boot 3 因为 2.7 的免费支持快结束了”。有一个外部驱动因素,比”我想升”有用一百倍。
更深一层:这道题表面上考技术,实际上考的是**“技术决策怎么变成组织行动”**。优秀的工程师和普通工程师的分界线,往往不在技术深度,在于能不能把一个技术判断翻译成组织能听懂、能批准、能执行的东西。你带着五年的 Go 经验进 Java 团队,如果想推动什么,这个能力比你会多少 Java 特性重要得多。
题 4:Optional 到底该不该用?给出你的判断标准和边界。
参考答案
直接答案:该用,但要严格限制在”查询类方法的返回值”这一个位置。超出这个位置,它带来的问题比解决的多。
先明确一点:这个话题在 Java 社区是有真实分歧的。有影响力的声音(比如一些 Oracle 的 Java 语言架构师)说过”Optional 主要设计意图就是作返回值”。也有人说它带来了伪安全感。我给你一个能落地的判断标准,不是和稀泥。
✅ 该用的场景:查询类方法的返回值
public interface UserRepository {
Optional<User> findById(long id); // ✅ 可能没有
Optional<User> findByEmail(String email); // ✅ 唯一性查询,可能没有
List<User> findByStatus(Status s); // ✅ 列表,没有就是空列表
Optional<Config> findActiveConfig(String key); // ✅
}判断标准:这个方法的”找不到”是一个正常的、调用方必须处理的业务分支吗?
findById—— 是。用户可能确实不存在,调用方要决定返回 404 还是做别的。用Optional。findByEmail—— 是。同上。findByStatus—— 不是”找不到”,是”找到 0 个”。返回空列表。
这个位置的 Optional 价值在哪? 在于它把”可能没有”这件事写进了类型签名。调用方看到返回 Optional<User>,编译器就逼着他面对这件事(不能直接用 .getName())。这比”可能返回 null,你自己看着办”强得多。
❌ 不该用的场景(五个)
1. 字段
class User {
private Optional<String> middleName; // ❌
}理由三条:
Optional没有实现Serializable(这是 JDK 有意为之),字段用它会破坏任何涉及序列化的框架(Java 原生序列化、某些缓存序列化)- 字段出现”两种空”(
null和Optional.empty()),你给自己找了两套语义 - 你的 ORM(JPA/Hibernate)不知道怎么处理
Optional字段(除非用专门的转换器)
2. 方法参数
void process(Optional<String> name) { } // ❌理由:调用方仍然可以传 null,你防不住;而且调用方得写 process(Optional.of("x")),纯粹增加噪音。正确做法:重载,或者接受 null 并在 javadoc 里说清楚。
3. 集合元素 / Optional<List<T>>
List<Optional<User>> users; // ❌
Optional<List<Order>> orders; // ❌理由:空集合已经完美表达了”没有”。Go 程序员在这里要特别注意 —— Go 的 nil slice 是可用的,所以 Go 里返回 nil 切片很自然;Java 的 null List 是毒药,所以要返回空 List,而不是 Optional<List>。
4. 需要表达”为什么失败”的地方
Optional<User> createUser(UserDTO dto); // ❌ 创建失败是有原因的(校验不过?数据库挂了?)理由:创建失败是异常状态,需要错误信息。这时候抛异常(ValidationException、DataAccessException)比返回 Optional.empty() 合适得多。Optional 只能说”没有”,不能说”为什么没有”。
(这个判断标准,恰好对应 Go 里的 nil, nil vs nil, err:前者是”没有”,后者是”出错”。Optional 只有前者的能力。)
5. 高频热路径 / 框架内部 / 序列化层
理由:Optional 每次 map/filter 都产生新对象。JIT 的逃逸分析通常能优化掉,但不是总行(方法太大、Optional 逃逸到字段或集合、内联失败)。判据:这个方法每请求会被调用几千次以上吗?是的话先测再决定。
✅ 使用时必须遵守的三条规则
规则 1:不要写 isPresent() + get()
// ❌ 这跟 if (x != null) 是一样的,白用 Optional
if (opt.isPresent()) {
doSomething(opt.get());
}
// ✅
opt.ifPresent(this::doSomething);规则 2:用 orElseGet 而不是 orElse,除非默认值是廉价的
opt.orElse(new User()); // ✅ 廉价,无所谓
opt.orElse(loadFromRemote()); // ❌ 每次都执行
opt.orElseGet(() -> loadFromRemote()); // ✅规则 3:链式调用时,flatMap 处理”返回 Optional 的函数”
// ❌ 编译出来是 Optional<Optional<Profile>>
opt.map(u -> findProfile(u));
// ✅
opt.flatMap(u -> findProfile(u));⚠️ 一个必须承认的局限:Optional 不是万能的
Optional<User> u = repo.findById(id);
u.orElseThrow(() -> new BizException("用户不存在"));这段代码看起来很 Java,但它丢失了一个重要信息:findById 返回的空,到底是因为”这个人真的不存在”,还是因为”数据库连不上抛异常了”?
Optional 把这两种情况压缩成了一种。所以:
- “查询不到”是业务分支 →
Optional合适 - “查询失败”是系统错误 → 应该抛异常,让上层统一处理
如果你发现自己在 orElseThrow 里抛的异常需要区分”不存在”和”查询失败”,那说明 Optional 不是这个场景的正确工具,考虑抛自定义异常或者返回一个包含状态码的 record。
给团队的可执行规范(三段话,可以直接贴进团队 Wiki):
- 查询类方法(
findXxx、getXxx、parseXxx、first、last)返回Optional<T>,前提是”找不到”是正常业务分支。 - 其他任何位置(字段、参数、集合元素、集合本身)都不用
Optional。集合没有元素就返回空集合。 - 禁止
isPresent()+get();orElse的参数里不允许出现方法调用(用orElseGet)。
更深一层:Optional 的价值不在于”消灭 NPE”(它消灭不了,你 .get() 一下照样 NPE),在于把”可能没有”从”约定”变成”类型”。
这个思路跟 Go 的 error 是同一个内核:把一件原本靠约定的事,变成函数签名的一部分,让编译器能检查。 区别是 Go 用 (T, error) 做了这件事且强制你处理每一层,Java 用 Optional 做了这件事但不强制(Optional 本身能为 null,也能被 .get() 直接炸)。
所以我的最终判断是:Optional 是一个70 分的方案 —— 比返回 null 好得多,比 Kotlin 的 T?(编译期强制、且能静态检查)差一档。在 Java 里你只有这个,所以用,但要知道它的边界。
题 5(开放题,无标准答案):你的项目该用 Java 8、17 还是 21?
这道题没有标准答案,取决于你的具体处境。但给你一个决策树:
先看约束(这些会直接排除选项):
- 有没有二方库/内部 SDK 明确不支持 17+?(有 → 你只能停在 8,除非你能推动那个库升级)
- 要不要用 Spring Boot 3.x?(要 → 必须 17+,且要做
jakarta.*迁移)- 公司的运维体系(基础镜像、监控 agent、发布平台)支持哪些 JDK 版本?(这个经常被忽略,但它是硬约束)
- 有没有必须用的商业软件/中间件,它的 agent 只支持特定 JDK?
再看收益(这些决定你该不该主动推进):
- 你需要虚拟线程吗?(要 → 21+,虚拟线程在 21 才正式,JEP 444)
- 你需要完整的模式匹配(
case Point(int x, int y)、无 default 的穷举 switch)吗?(要 → 21+)- 你只需要 record / sealed / Text Block / var 吗?(17 就够)
- 你的服务是长跑型还是频繁冷启动?(后者考虑 GraalVM Native Image 或者 AppCDS)
最后看成本:
- 新项目:直接 21(最新的 LTS,且虚拟线程是未来)
- 老项目:先看能不能只升 JDK 不升框架(Spring Boot 2.7 支持 17)
- 卡在 8 且短期动不了:至少做两件事 —— (1) 把
SimpleDateFormat全换成DateTimeFormatter;(2) 检查所有parallelStream里有没有阻塞 IO。这两件事不需要升 JDK 就能做,且能避免本章讲的两个真实事故。想清楚这几个问题,你就不是在”追新版本”,而是在做工程决策。
下一章预告
第 03 章讲泛型擦除 —— Java 类型系统最深的那道坎。
你已经在 02.1 看到 var 的推导是编译期的,在 02.2 看到 record 的 accessor 是编译期生成的。这些都在指向同一件事:Java 的泛型信息,在编译完之后就消失了。
第 03 章要回答这些问题:
- 为什么
List<String>和List<Integer>在运行时是同一个类?为什么new ArrayList<String>() instanceof List<Integer>是合法表达式? List<? extends T>和List<? super T>到底该怎么选(PECS 原则的推导过程,不是背结论)- 桥接方法(bridge method)是什么,为什么你
javap一个类的时候会看到两个签名一样的compareTo - Go 的泛型(类型参数 + 单态化/GC shape stenciling)跟 Java 的擦除,在设计上做了什么相反的取舍
- 以及那个最实际的问题:为什么 Spring 的
RestTemplate.exchange()在某些写法下类型推导会失败
02 章给你的是”现代 Java 长什么样”,03 章给你的是”为什么它长这样”。 03 章读完,你看到 Map<String, List<? super Order>> 这种签名时,能自己推出它的边界,而不是心慌。
翻到下一章。