"这个版本有新语法,我们用一下"是最容易做出、也最难回退的技术决策之一。语言特性不是库:它一旦写进代码,就成为全团队的阅读负担、工具链的解析前提、框架反射的兼容对象。判断一个特性该不该进产线,问的不是"它好不好",而是三条成本:团队认知成本、生态兼容性、回退难度。
1. 三条判据
| 判据 | 要问的问题 | 特性越新,风险方向 |
|---|---|---|
| 团队认知成本 | 半数成员能否在 review 时读懂、改对? | 新人上手期变长,review 变成语法教学 |
| 生态兼容性 | 序列化、ORM、AOP、Mock、字节码工具是否已支持? | 支持滞后 → 运行时才发现,且是静默行为差异 |
| 回退难度 | 用错了能否在一个迭代内退回旧写法? | record/sealed 会改变类型契约,回退是重构不是改语法 |
三条里任何一条是"不行",结论就是不采用——不是"先小范围试试"。语言特性的试用边界比库模糊,一段 record 传进公共模块,就等于全仓库都要跟着升。
2. record 的适用边界
| 场景 | 结论 | 理由 |
|---|---|---|
| 值对象、DTO、事件载荷、查询结果 | 适合 | 语义即数据,equals/hashCode/toString 手写易错 |
| 需要可变状态的实体 | 不适合 | 组件字段 final,改状态要整对象替换 |
| 框架要求无参构造 + setter | 不适合 | JSON/ORM 反序列化依赖可写属性 |
| 需要继承扩展的模型 | 不适合 | record 隐式 final |
| 作为 map key / 缓存 key | 适合 | 值语义相等正是所需 |
// 适合:不可变查询结果
public record OrderView(long id, String status, long amountCents) {
public OrderView { // 紧凑构造器做校验
if (amountCents < 0) throw new IllegalArgumentException("amount");
}
}
// 不适合:需要被 ORM 反射填充实体
// record 无无参构造、字段 final → 多数 ORM 无法实例化
"用 record 替代一切 POJO"的代价是具体的:序列化框架需要额外模块注册、ORM 需要构造器映射或直接不支持、Bean Validation 的字段注入需要改到构造器参数上、AOP 代理对 final 类失效。收益是几十行样板代码,代价是一轮框架适配调研——这笔账通常不划算,除非该模块本来就不碰持久化。
3. sealed:收益前提与它锁住的扩展性
public sealed interface PaymentResult permits Success, Declined, Retryable {}
// 收益前提:调用方需要穷尽性——编译器帮你证明分支无遗漏
String describe(PaymentResult r) {
return switch (r) {
case Success s -> "ok:" + s.txnId();
case Declined d -> "declined:" + d.code();
case Retryable t -> "retry:" + t.after();
}; // 无 default:新增子类型时编译失败,而不是运行到未知分支
}
| 前提 | 说明 |
|---|---|
| 类型集合真的封闭 | 只在内部流转、可枚举的结果类型 |
| 变更是受控的 | 加子类型时能接受全仓库编译报错 |
| 团队在同一个发布单元 | 否则下游编译失败变成阻塞 |
sealed 的代价是扩展性被写进语言:第三方、插件、下游服务都不能再加子类型。把这套类型放进公共 SDK,等于给所有使用方加了一个"升级即编译失败"的约束。它是"封闭领域"的强表达,不是默认选择。
4. 虚拟线程:收益边界与三类陷阱
真实收益只出现在一种形态:任务数远超核数、时间几乎全花在等待(HTTP 调用、JDBC、文件、队列)。CPU 密集型任务不会因虚拟线程变快;受限并发(信号量、连接池)依然是天花板。
| 陷阱 | 机制 | 症状 | 规避 |
|---|---|---|---|
synchronized 钉住载体线程 |
块内阻塞会占住平台线程 | 吞吐不升、ForkJoinPool 线程被打满 |
改 ReentrantLock,或用 jcmd Thread.dump_to_file 确认 pinning |
| 复用线程池 | 池化本就为复用昂贵线程 | 虚拟线程被排队,收益归零 | 每任务新建,用信号量限并发 |
ThreadLocal 膨胀 |
每任务一份,数量随并发线性增长 | 堆上涨、GC 压力 | 改作用域传参或 ScopedValue |
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> remoteA.call()); // 不要提交到固定线程池
var b = scope.fork(() -> remoteB.call());
scope.join().throwIfFailed();
return merge(a.get(), b.get());
}
判断是否需要虚拟线程,看两个数:峰值并发任务数与任务中等待时间占比。两者都高才值得换执行模型;只有其中一个高,先解决连接池或下游延迟更有效。
5. switch 模式匹配与旧写法的可读性对比
// 旧写法:类型判断 + 强转 + 手写 default 兜底
if (msg instanceof Text t) { return t.body(); }
else if (msg instanceof Image i) { return i.url(); }
else { throw new IllegalStateException("unknown: " + msg.getClass()); }
// 新写法:分支即类型,配合 sealed 由编译器保证穷尽
return switch (msg) {
case Text t -> t.body();
case Image i -> i.url();
};
可读性收益来自"分支与类型一一对应",而不是语法更短。若类型集合开放(非 sealed),仍要写 default,收益就退化为一点点样板消除——此时优先级应当降低。
6. 文本块、格式化与 var
String sql = """
SELECT id, status FROM orders
WHERE created_at >= ? AND status = ?
"""; // 文本块:保留换行,去除公共缩进
String line = "id=%d status=%s".formatted(id, status); // 具名占位比拼接安全
var list = repo.findByStatus(status); // var:右侧类型明确时才用
var 的团队约束建议写成规则而不是偏好:右侧构造器可见时可用;方法返回值、泛型集合、链式调用一律写显式类型。理由是 var 把类型信息移到 IDE 里,code review 与 grep 都失去线索。
7. 升级节奏与灰度
| 阶段 | 动作 | 通过标准 |
|---|---|---|
| 编译 | 全模块 --release 对齐、开 preview 只做实验分支 |
全量编译 + 静态检查无新增告警 |
| 测试 | 单元 + 集成 + 契约测试全跑 | 覆盖率不降,序列化往返用例补齐 |
| 灰度 | 单实例、单机房、低流量入口 | 错误率、P99、GC 与基线同量级 |
| 铺开 | 按模块推进,保留旧写法分支 | 出问题能按模块回退 |
节奏的关键是每一步都有回退点。语言特性的回退粒度是模块级,不是开关级——所以铺开前要确认模块边界干净。
8. 框架层支持的滞后
| 关注点 | 常见滞后表现 | 验证手段 |
|---|---|---|
| JSON 序列化 | record 需显式注册或参数名模块 | 往返测试:序列化→反序列化→equals |
| ORM | 无无参构造导致实例化失败 | 启动期跑一次真实查询 |
| AOP 代理 | final 类/record 无法 CGLIB 代理 | 该 Bean 上打一次切面验证 |
| Mock 框架 | 无法 mock final 类型 | 单测里实际 mock 一次 |
| 字节码增强 | 不认识新 class 文件版本 | 全链路跑一次增强点 |
这些问题不会在编译期暴露,只在运行时以"字段为 null""代理没生效""注入失败"的形式出现。采用前的验证清单应该以运行时行为为准。
9. 落地阻力与最小可行约定
阻力通常不是技术,是三类现实:老代码风格统一性、review 成本上升、以及"两套写法并存"的维护负担。最小可行约定可以只有四条:新模块可用、老模块不动;公共 SDK 不用新语法;框架边界(序列化/ORM/AOP)不用新语法;每个特性配一个两行注释说明为什么用它。
反向信号很明确:为了用新特性而重构本来能跑的代码。这类重构的收益是审美,风险是全量回归。判断标准是"这段代码最近半年是否改过、是否有 bug 记录"——没有,就不动。
10. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
| 反序列化后字段为 null | record 缺少参数名信息或注册模块 | 补 -parameters 或框架侧注册 |
| ORM 启动报无法实例化 | 无无参构造 | 该类改回普通类,或改用构造器映射 |
| 切面未生效 | 目标是 final 类(record/sealed 实现类) | 改接口代理或移除 final |
| 虚拟线程吞吐未提升 | pinning 或线程池复用 | 换 ReentrantLock、取消池化 |
| 下游编译失败 | sealed 新增子类型 | 与下游同批发布,或去掉 sealed |
11. 边界
| 场景 | 结论 | 理由 |
|---|---|---|
| 老项目、JDK 8/11 长期维护 | 不升级语法 | 迁移成本落在业务窗口外 |
| 强依赖旧版框架 | 不用新语法 | 框架不支持,等于自建适配层 |
| 团队小、无明确收益 | 不升级 | 认知成本摊不薄,收益不可量化 |
| 纯内部工具、生命周期短 | 可用 | 回退成本低,试错空间大 |
12. 自测
- 三条判据中,哪一条的失败最容易在运行时才暴露?
- 为什么"record 替代一切 POJO"在持久化模块里通常不划算?
- sealed 给调用方带来的收益,前提条件是什么?
- 虚拟线程在什么形态的任务上才有真实收益?
var的团队约束应该按什么标准划线?- 判断"该不该为新特性重构老代码"的第一问是什么?