第 02 章(节选) 核心结论与深度思考题

来自第 02 章「第 02 章 Java 17 新特性与 Java 8 差异(现代 Java 长什么样)」。 返回:第 02 章索引

02.9 Go ↔ Java 综合对照表

这一张表覆盖本章所有特性,可以当速查表用。

概念GoJava 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
时间 APItime.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/WaitGroupparallelStream(共享公共池,禁放阻塞 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 里最容易踩的五个坑(本章汇总):

  1. 以为 var 是 := —— 它只是类型占位符,而且是有限制的(不能用于字段/方法签名)
  2. 以为 record 是 struct —— 它不可变、无零值、不能加实例字段,且 accessor 不叫 getXxx()
  3. 以为 Text Block 是反引号 —— 它处理转义,对正则没有任何帮助
  4. 以为 Optional 是 (T, error) —— 它只能表达”没有”,不能表达”为什么没有”
  5. 以为 parallelStream 是 go func() —— 它共享一个大小等于 CPU 核数的公共池,放阻塞 IO 会拖垮全 JVM

02.10 本章核心结论

  1. var 不是动态类型,是编译期类型占位符。 能推导的范围,恰好等于”不被外部依赖的信息”的范围 —— 局部变量可以(不进 class 契约),字段和方法签名不行(是二进制契约的一部分)。

  2. record 的 accessor 叫 name() 不是 getName(),是一次主动切割。 JDK 在强制生态表态:“别拿 Java Bean 那套规则套我。” 代价是 Jackson < 2.12、MyBatis 构造器映射这些兼容性地雷。

  3. sealed 的价值不在”限制继承”,在”让编译器能穷举”。 Go 的非导出方法只能做到封闭(且包边界是编译器强制的),Java 做不到封闭(包是开放的),所以才需要这个关键字 —— 顺带拿到了 Go 拿不到的穷举检查。

  4. 模式匹配(instanceof → switch → 记录模式)是一套连贯的演进,Java 21 才算完整。 在 Java 17 上,switch 模式匹配和记录模式都还是预览特性,需要 --enable-preview,不能上生产。

  5. Optional 是容器不是”可空的 T”,只能表达”没有值”,不能表达”为什么没有值”。 它只该作方法返回值;orElse 的参数总是被求值,orElseGet 才是懒的。

  6. Java 8 最大的遗产坑是 Date/Calendar:可变 + 月份 0 基 + SimpleDateFormat 非线程安全。 SimpleDateFormat 的病根是继承了 DateFormat 的 protected Calendar calendar,把它当多步计算的临时工作区。修复路径是 java.time 全套。

  7. parallelStream 用 JVM 全局的 ForkJoinPool.commonPool(),并行度 = CPU 核数 - 1。 里面放阻塞 IO 会拖垮整个 JVM 上所有用公共池的代码 —— 包括第三方 jar 里的。它只适合纯 CPU 密集 + 大数据量。

  8. 国内卡在 Java 8 不是技术问题,是经济问题:Oracle 授权变更 + Spring Boot 3 的断裂点 + 收益无法量化 + JDK 8 确实够用。升级的正确姿势是先只升 JDK(保持 Spring Boot 2.7),跑通再单独做 Jakarta 迁移,两件事拆开。

  9. javax.* → jakarta.* 不是 JDK 的锅,是 Jakarta EE 9(Eclipse 基金会拿不到 javax 命名空间使用权)。但因为”升 17”和”升 Boot 3”通常一起做,所以你一定会遇到。

  10. 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)从提出到落地,要经过:

  1. 起草 → 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 步的两周”,不是”一个三个月的大项目” —— 审批难度完全不一样。


几个实战建议(这些是踩过坑才知道的):

  1. 先升构建用的 JDK,再升运行时的 JDK。 用 JDK 17 编译但 release 设成 8,先确认编译没问题;再改 release 到 17;最后换运行环境的 JDK。三层逐步推进。

  2. 一定要先删掉所有 GC 参数跑一轮。 很多人升级时保留了 Java 8 时代的 -XX:NewRatio=2 -XX:SurvivorRatio=8 ... 一堆参数,结果 G1 在 17 上的默认行为已经完全不同,这些老参数反而成了负优化。先归零,测一轮,再按需加回。

  3. 别在同一个发布里同时做升级和功能开发。 否则出问题你分不清是升级的锅还是新功能的锅。

  4. 找一个”非升不可”的触发点。 比如”要接 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):

  1. 查询类方法(findXxx、getXxx、parseXxx、first、last)返回 Optional<T>,前提是”找不到”是正常业务分支。
  2. 其他任何位置(字段、参数、集合元素、集合本身)都不用 Optional。集合没有元素就返回空集合。
  3. 禁止 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>> 这种签名时,能自己推出它的边界,而不是心慌。


翻到下一章。