Effective Java 现代重读
《Effective Java》第三版围绕对象、类型、泛型、异常、并发与序列化给出了 90 条建议,内容覆盖到 Java 9。后来 Java 增加了 record、sealed 类型、模式匹配和虚拟线程,有些手写惯用法可以直接交给语言,有些原则则需要重新划清适用边界。本文沿用 C++ 现代重读的方式,按第三版编号逐条讨论:现在应该怎么写,为什么这样写,以及哪些地方不能机械套用。书籍版本与章节信息见出版社说明。
示例以 Java 21 的正式特性为基线,涉及 Java 25 时明确标注;不要求开启预览特性。代码块是彼此独立的类型、成员或方法片段,省略常用 JDK 类型的 import;成员与方法片段放入外围类中使用,完整项目按 Item 25 拆分文件。比较代码只用于解释行为,不构成性能基准。
- 仍适用:原则与主要做法仍成立,现代语法只是让表达更清楚。
- 需更新:问题仍然存在,但语言或标准库提供了新工具,或者建议需要补充边界。
- 已过时:正文明确指出的旧做法不再适合作为新代码的默认选择;不表示原书的警告失效。
创建与管理对象:Item 1–9
仍适用 Item 1:用静态工厂表达创建意图
构造函数只能沿用类名,工厂可以把单位、来源、缓存或转换语义放进名字。Duration.ofSeconds、List.of 都比“猜这个参数是什么意思”清楚。工厂还能返回接口的不同实现,但只有语义允许共享时才能复用实例;调用方不应依赖工厂结果的对象身份。
1 | |
record 仍可提供工厂,但其规范构造函数不能比 record 本身更不可见。若必须彻底隐藏表示、限制所有创建入口,应使用普通类,而不是依赖 record 加一个工厂来封锁 new。
需更新 Item 2:多参数创建考虑 Builder
多个同类型参数、很多可选项或跨字段约束适合 Builder;只有两三个含义清晰的字段时,record 加规范构造函数通常足够。record 消除了数据载体的样板代码,却没有给 Java 增加命名参数,new Config(3, 5, 8) 仍然难读。
1 | |
把最终校验放在构造入口,避免其他工厂绕过 Builder 时得到非法对象。Builder 本身一般可变且不保证线程安全;构建结果应独立于后续 Builder 修改。
仍适用 Item 3:确实需要单例时,用明确的实例控制
枚举单例能直接处理实例唯一性与原生序列化;普通静态字段也可以,但要额外考虑反射与反序列化。更先要问的是是否需要全局状态:业务服务通常由依赖注入容器管理生命周期,通过接口传入更容易测试。
1 | |
“一个实例”通常只是在一个类加载器加载的该类型范围内成立,并不意味着整个进程或集群唯一。枚举里的可变字段也不会因为枚举是单例而自动线程安全。
仍适用 Item 4:工具类用私有构造函数禁止实例化
只提供静态操作的工具类不需要实例,也不应留出可继承的入口。把类声明为抽象只能阻止直接实例化,仍可能被继承;私有构造函数表达得更准确。
1 | |
仍适用 Item 5:把依赖传进来
依赖注入首先是对象设计,不要求使用框架。时间、随机数、存储和网络客户端都应尽量通过构造函数或工厂传入,让业务逻辑不用自行选择全局实现。
1 | |
测试时传入 Clock.fixed 即可固定时间。还要明确依赖的关闭责任:借用的连接池或客户端一般由创建它的上层关闭,使用它的每个服务不应各自关闭一次。
仍适用 Item 6:避免没有意义的对象创建
复用不可变对象和昂贵、线程安全的辅助对象,例如编译后的 Pattern。不要为了减少分配把普通 DTO 全部放入对象池;这会增加状态重置、并发与内存滞留成本。JIT 可能消除部分分配,是否值得优化要测量。
1 | |
Pattern 可以共享,Matcher 是可变状态,应按调用创建。自动装箱也是对象创建来源:数值累加优先 long,不要在循环中反复对 Long 装箱、拆箱。
仍适用 Item 7:清除失去业务用途的引用
GC 判断的是可达性,不知道对象在业务上是否还有用。长寿命集合、监听器、无界缓存和线程局部变量会把本该回收的对象保留下来。自己实现容器时,删除元素也要清除底层存储里的引用。
1 | |
优先使用已经正确维护内部引用的容器。无需把所有局部变量设为 null;应缩小作用域、取消监听注册、给缓存设置容量与过期策略。弱引用也不等于完整的缓存策略。
需更新 Item 8:终结机制不能承担正常资源释放
GC 回收 Java 对象占用的内存,不保证及时释放文件句柄、连接或本地资源。finalize() 自 Java 18 起已被标记为待移除;新代码不应覆盖它。Cleaner 仍有兜底用途,但执行时机不确定,不能承担正确性所依赖的释放工作。参见 JDK 迁移说明。
需要确定释放时机的对象实现 AutoCloseable,让调用方显式关闭。确实增加 Cleaner 兜底时,清理动作不能强引用被清理对象,否则对象无法满足触发清理的可达性条件;close() 与兜底清理还必须能安全协调。
仍适用 Item 9:资源管理优先 try-with-resources
try-with-resources 不只省去 finally:多个资源按创建的逆序关闭,业务操作失败时,关闭失败会作为 suppressed exception 保留下来,而不是覆盖原始异常。
1 | |
一个资源初始化成功、后续资源初始化失败时,已经成功创建的资源也会被关闭。返回 Stream 的文件 API 同样可能持有资源;Files.lines 的流需要关闭,不能把所有流都当作普通集合流水线。
对象契约与值语义:Item 10–14
需更新 Item 10:先确定相等的含义,再实现 equals
对象身份与业务值相等是两种契约。实体可能按稳定 ID 相等,值对象按组成部分相等,执行器和连接通常保留身份语义。自定义 equals 必须满足自反、对称、传递、一致,以及与 null 比较为假。
1 | |
record 为其组件生成相等性实现,适合这类值对象;数组组件仍按数组对象的相等性处理,并不会自动比较数组内容。给可继承的值类新增参与相等性的状态,很容易破坏对称性或传递性,因此优先 final 值类或组合。record 的生成规则见官方语言说明。
需更新 Item 11:equals 与 hashCode 必须一起设计
相等对象必须有相同哈希值,不相等对象允许碰撞。record 会生成匹配的实现;普通值类则要确保两者使用同一组字段。不要在对象成为 HashMap 的键后修改参与相等性的状态,否则它可能仍在原来的桶里,查询却按新哈希值寻找。
1 | |
Objects.hash 很方便,但可变参数与装箱可能带来成本;只有测到热点再考虑手写组合。哈希值不是持久 ID,也不是用于校验完整性或抵抗攻击的密码学摘要。
需更新 Item 12:toString 应便于诊断,并保护敏感字段
record 的默认 toString 会展示组件,适合普通数据,却可能直接把口令、令牌和个人信息送进日志。敏感对象应显式脱敏,普通对象也应避免遍历巨大对象图或访问数据库。
1 | |
toString 是诊断接口。需要稳定机器格式时,另外定义编码器或 DTO;不要让外部系统靠拆解 toString 获取字段。
已过时 Item 13:新 API 不以 Cloneable 作为复制协议
原书对 clone 的谨慎态度仍成立;已过时的是把它当作新代码默认复制方案。Cloneable 没有声明公开的复制方法,Object.clone() 逐字段浅复制,构造函数也不会按正常创建流程执行,引用字段仍可能指向同一份可变状态。
1 | |
使用复制构造函数、copyOf 工厂或不可变值直接共享,让复制深度成为显式契约。数组的 clone() 仍是合法的浅复制工具;元素也是可变对象时,复制数组并不复制元素。
仍适用 Item 14:自然顺序用 Comparable,其他顺序用 Comparator
类型确实具有稳定自然顺序时才实现 Comparable;按姓名、时间、优先级等不同维度排序用独立 Comparator。通常应保证 compareTo 返回零与 equals 一致,否则 TreeSet 和 HashSet 可能对“重复元素”得出不同结论。
1 | |
不要用 a - b 比较整数,溢出会反转结果。BigDecimal 是值得记住的边界:1.0 与 1.00 的自然排序相等,但 equals 还比较 scale;集合选型要承认这个差异。
类、接口与类型建模:Item 15–25
仍适用 Item 15:把可见性压到契约需要的最小范围
公开一个成员,就增加一个需要长期兼容的承诺。内部实现优先 private 或包可见,公共入口只暴露必要类型。Java 模块系统还能限制包导出,但不能替代类与成员层面的封装。
public static final List<?> 只有在集合及其元素的可变性都受控时才适合作为常量。final 只是禁止重新赋值,公开的 final 数组仍然可以修改元素。模块边界可参考 Java 模块系统。
需更新 Item 16:公开数据通过访问方法暴露
普通类通过方法隐藏字段,使实现可以增加校验、缓存或改变存储方式。record 则明确承诺“组件就是数据模型”,编译器生成的 name() 同样是访问方法;无需为了保持 JavaBean 命名再机械补一套 getter。
1 | |
record 不适合“公开组件会泄露实现、以后还想自由改变表示”的类型。访问方法也不能原样返回内部可变数组;封装不仅看字段是否 private,还看外界能否通过返回值修改状态。
需更新 Item 17:尽量减少可变状态
不可变对象让共享、缓存、相等性与并发推理更简单。record 只保证组件字段 final,不保证组件指向的对象不可变;要在构造时建立副本或选择不可变组件。
1 | |
这里成员是不可变的 String,列表也不再随原列表修改而变化。若元素是可变的 User,List.copyOf 仍然共享这些元素。Collections.unmodifiableList 只是只读视图,原列表的修改仍可见;深不可变性必须沿对象图检查。集合边界见 List API。
仍适用 Item 18:通过组合扩展行为
继承实现会把子类耦合到父类的内部调用路径,例如父类的批量添加是否调用单项添加。组合把依赖放在明确的委托调用上,也能围绕业务能力定义小接口,而不是被迫暴露整个容器 API。
1 | |
真正满足替换原则、且父类专门支持扩展时,继承仍合理。给一个现成类添加计数、审计或重试通常更适合装饰器;重试还要独立确认幂等性。
需更新 Item 19:为继承设计,否则限制继承
扩展点要说明调用时机、调用顺序及状态约束;构造函数不要调用可覆盖方法,子类字段此时可能尚未初始化。普通类用 final 禁止继承,record 自动 final,sealed 类型则限定谁有资格继承。
1 | |
sealed 只关闭直接子类型集合,不自动保证所有分支都不可变或封闭;允许的子类可以声明 non-sealed 再开放扩展。公开扩展点后,内部重构也必须维持扩展契约。
仍适用 Item 20:能力契约优先接口
接口可以让已有类型加入新能力,并与其他接口组合;抽象类适合共享真正必要的状态、构造约束和模板实现。default 方法可以提供简短通用行为,但不能引入每个实例独立的字段。
1 | |
不要为每个只有一个实现、也没有替换需求的类都创建接口。接口的价值来自稳定能力边界与可替换实现,而不是文件数量。
仍适用 Item 21:接口演化仍需要兼容性设计
新增 default 方法避免了部分实现类源码修改,却不保证语义兼容:多个接口可能产生默认方法冲突,现有实现也可能违反新方法假定的不变量。新增抽象方法则会要求实现方补充实现。
发布接口前先验证真实实现与调用方;发布后区分源码、二进制与行为兼容。无法给所有旧实现提供合理默认行为时,应考虑新接口或显式版本迁移,而不是用抛异常的 default 方法掩盖能力缺口。
仍适用 Item 22:接口用来定义类型
“常量接口”把实现细节混进类型层次,调用者还可能通过实现类名称访问那些常量。常量放在所属领域类型或不可实例化的工具类中;有限的业务取值用枚举。
1 | |
需更新 Item 23:用类型分支代替标签加互斥字段
一个 kind 字段加一组“某种 kind 才有效”的字段,把类型约束转移到了运行时。sealed 接口配合 record 可以表达每种分支的数据,Java 21 的模式 switch 则检查穷尽性。
1 | |
示例只展示分支建模;生产构造入口还应校验尺寸与有限数值。封闭领域适合 sealed;第三方需要增加分支时,开放接口加多态方法更合适。模式 switch 也不会默认接受 null,需要明确非空契约或单独处理。
仍适用 Item 24:不需要外部实例时使用静态嵌套类
非静态内部类具有关联的外部实例,可能让外部对象随内部对象一起存活。辅助类型不需要访问外部实例时,声明为 static;嵌套 record 与成员接口本身就具有静态性质。
1 | |
Lambda 也可能捕获 this。注册到长寿命执行器或监听器后,检查捕获的对象以及取消注册的时机,不能只看有没有写内部类。
仍适用 Item 25:一个源文件保持一个主要顶层类型
Java 允许一个文件包含多个包可见顶层类型,但会让类型定位、增量编译和维护变得含混。工程上把主要顶层类型放进同名文件,紧密相关且不独立公开的辅助类型作为嵌套类型。
本文为了对照展示,部分片段把多个小类型放在一起;落地到项目时按可见性与职责拆分文件。不要把“示例省篇幅”当作项目结构规范。
泛型与类型安全:Item 26–33
仍适用 Item 26:不要使用原始类型
List 原始类型会绕开部分泛型检查,让问题推迟到读取元素时以 ClassCastException 出现。List<Object> 表示可存放任意对象的列表,List<?> 表示元素类型未知的列表,两者与原始类型不是一回事。
1 | |
不知道元素类型但只读通用信息时用 <?>;需要写入时表达具体类型或类型参数。兼容旧 API 不得不使用原始类型时,把边界局限在适配层。
仍适用 Item 27:消除 unchecked 警告
unchecked 警告意味着编译器无法完成类型安全证明,不是“代码一定没问题,只是编译器啰嗦”。先改成正确的泛型声明;只有能说明内部不变量时,才在最小作用域上用 @SuppressWarnings("unchecked")。
类型擦除使运行时一般无法区分 List<String> 与 List<Integer>,所以把 Object 强转为 List<String> 并不能验证元素。反序列化或框架返回未经验证的对象时,应逐个校验元素并创建新集合,不能靠压制警告建立信任。
仍适用 Item 28:泛型容器优先列表
数组协变且保留运行时组件类型;泛型不变,类型参数通常被擦除。数组允许 String[] 赋给 Object[],但写入整数时会失败;List<String> 则不能赋给 List<Object>,把这类错误挡在编译阶段。
1 | |
这不意味着一律禁止数组。二进制数据、固定大小数值存储和互操作接口仍适合 byte[]、int[];装箱列表未必适合性能敏感的数值计算。
仍适用 Item 29:让容器本身成为泛型类型
把“这个容器存放什么”写进类型,而不是要求每次取出都强转。使用泛型容器实现内部存储,可以减少自建 Object[] 时的 unchecked 边界。
1 | |
类型参数只约束静态类型,不自动限制容量、保证非空或提供线程安全;这些仍由实现与契约负责。
仍适用 Item 30:独立算法优先泛型方法
工具方法在调用处建立输入与输出的类型关系,比返回 Object 再强转可靠。只有整类状态都围绕某个类型时,才把类型参数提升到类上。
1 | |
getFirst() 在 Java 21 的有序集合 API 中提供统一入口;它不会替你处理空集合。对“正常情况下可能没有结果”的查询,也可按 Item 55 返回 Optional<T>。
仍适用 Item 31:用有界通配符描述输入输出
PECS 的含义是:提供 T 的来源用 ? extends T,消费 T 的目标用 ? super T。它描述的是当前操作方向,不是笼统的“这个对象是否可变”。同时读写同一具体类型的参数,通常直接使用 T。
1 | |
这个方法允许把 List<Integer> 转移到 List<Number>。公开返回值尽量给调用方明确类型,不要为了显得灵活而处处返回带通配符的容器。
仍适用 Item 32:泛型与可变参数混用要证明安全
可变参数在调用处创建数组,而泛型参数不能完整地由数组运行时类型表达;两者混用可能产生堆污染。安全实现应只读取参数数组,不把不兼容值写进去,也不让数组逃逸。
1 | |
@SafeVarargs 是作者作出的保证,不会让危险实现变安全。若 API 不依赖可变参数,接收 List<List<T>> 一类结构往往更容易解释与验证。
仍适用 Item 33:异构容器把类型令牌放在键上
普通 Map<K, V> 只能统一约束值类型;需要按类型保存不同对象时,可以让 Class<T> 建立键与值的关系,并在读写边界使用 cast 校验。
1 | |
Class<List> 不能表达 List<String> 与 List<Integer> 的区别。需要参数化类型令牌时,应采用专门的 Type 描述与相应校验机制;不要把 Class 键方案误认为能绕过类型擦除。
枚举与注解:Item 34–41
仍适用 Item 34:有限取值使用枚举
枚举让编译器阻止不同取值集合混用,也能把行为放到对应常量上。业务中的状态、方向与策略集合有限时,比散落的整数或字符串更可靠。
1 | |
外部协议仍需要显式解码:未知状态应按协议选择拒绝、保留原值或映射到 UNKNOWN,而不是假定所有输入都能交给 Enum.valueOf。
仍适用 Item 35:不要把 ordinal 当业务值
ordinal() 反映声明顺序;插入或重排常量就会改变数值。数据库值、协议编号和显示顺序应使用显式字段。
1 | |
显式编码也需要唯一性检查与未知值处理。枚举名称同样不是天然稳定的协议:如果可能重命名常量,也应独立定义线上的文本编码。
仍适用 Item 36:枚举组合使用 EnumSet
位标志把类型、取值范围和组合操作都压成整数。EnumSet 用类型安全 API 表示枚举集合,同时保留紧凑表示与高效集合运算。
1 | |
EnumSet 本身可变,跨边界返回时需要明确所有权或创建只读副本。它不能替代外部协议既定的位编码;转换应该集中在协议适配层。
仍适用 Item 37:枚举索引用 EnumMap
以枚举为键的映射优先 EnumMap,避免数组下标与 ordinal() 绑定。缺少的映射也能直接通过键表达,而不必猜某个数组位置属于哪个状态。
1 | |
是否每个枚举都必须有值,是业务不变量,需要在构建时验证。EnumMap 不保证线程安全,与不可变发布或同步策略一起使用。
仍适用 Item 38:用接口表达可扩展的枚举能力
枚举的常量集合固定,不能通过继承添加常量。希望多个枚举提供同一种能力时,让它们实现接口;调用方依赖能力,而不是某个枚举的具体常量集合。
1 | |
开放插件适合接口;需要穷尽匹配的封闭数据分支适合 sealed。不能同时期待“第三方任意扩展”和“编译器知道全部实现”。
仍适用 Item 39:使用注解表达元数据
靠方法名以 test 开头、字段名包含某个标记来识别行为,重命名就可能悄悄改变结果。注解把元数据与名字分离,适用位置和保留期也能显式声明。
1 | |
注解本身不会执行审计;编译处理器、框架或反射代码负责解释它。自行设计注解前,应先确认现有框架是否已有合适的契约,避免制造没有消费者的装饰。
仍适用 Item 40:覆盖方法时加 Override
@Override 让编译器检查“覆盖”是否真的发生,能抓出参数类型错误导致的意外重载。实现接口方法时也应使用,避免接口升级后悄悄留下无效方法。
1 | |
仍适用 Item 41:需要类型参与检查时使用标记接口
标记接口定义一个类型,能出现在泛型约束和方法签名中;标记注解主要承载工具读取的元数据,还可以标记方法、字段等。需要调用方在编译阶段只能传入某类对象时,接口更合适。
1 | |
不要为了一个框架扫描功能就创建空接口,也不要用注解代替真正需要编译期约束的类型契约。Serializable 是标记接口的例子,但使用它的风险需单独阅读 Item 85–90。
Lambda、Stream 与执行方式:Item 42–48
仍适用 Item 42:函数对象优先 Lambda
单个抽象方法的行为用 Lambda 更简洁。需要多个方法、独立状态或自己的对象身份时,匿名类或具名类仍有价值。Lambda 中的 this 指向外围实例,匿名类的 this 指向匿名类实例,这个差异会改变捕获与调用行为。
1 | |
Lambda 过长时,把行为提取成具名方法,错误处理和业务含义会更清楚。不要依赖 Lambda 实例是否被缓存,也不要把其实现类当成稳定协议。
仍适用 Item 43:方法引用在更清楚时优先
方法引用能省去只转发参数的 Lambda,但目标方法的名字必须足以说明操作。如果引用让接收者、参数对应或重载选择更难理解,保留 Lambda。
1 | |
绑定接收者的 service::run 会保留该接收者;需要长时间保存函数对象时,要把这份引用纳入生命周期分析。
仍适用 Item 44:优先标准函数式接口
转换用 Function<T, R>,条件用 Predicate<T>,供应用 Supplier<T>,副作用用 Consumer<T>。数值热点考虑 IntFunction、ToLongFunction 等原始类型接口,减少不必要装箱。
1 | |
当行为有重要领域含义、特殊异常契约或标准接口无法表达的参数结构时,可以定义自己的函数式接口并加 @FunctionalInterface。标准接口没有检查所有业务约束的能力。
仍适用 Item 45:审慎使用 Stream
Stream 适合筛选、转换、聚合等数据流水线。涉及复杂控制流、需要多个局部状态、频繁提前返回或受检异常时,普通循环通常更直接。Stream 是一次性、通常惰性执行的计算描述,不是可以重复遍历的容器。
1 | |
String.chars() 流出 UTF-16 代码单元,不是完整 Unicode 码点;按码点处理应使用 codePoints(),用户眼中的字符还可能由多个码点组成。语法整齐不能替代对数据语义的判断。
仍适用 Item 46:流水线函数尽量无副作用
把结果放进 Collector,而不是从 forEach 修改外部列表。无副作用操作更容易重排与并行,也不会依赖流实现具体执行了多少次某个中间操作。
1 | |
peek 适合辅助诊断,不应承担审计、写数据库等必须执行的业务动作;优化可能使部分中间操作不发生。toMap 遇到重复键需要明确冲突策略,不能依赖运行时偶然没有重复。
需更新 Item 47:重复消费的序列优先返回集合
小规模、已经物化、需要反复遍历的数据,返回 List<T> 通常更方便。巨量数据、懒加载或资源驱动的序列则应按访问方式选择 Stream、迭代器或分页接口,并说明一次性消费与关闭责任。
1 | |
Java 16 起 Stream.toList() 返回不可修改列表;Collectors.toList() 不保证可变性。需要明确可变结果时,使用 Collectors.toCollection(ArrayList::new);只读列表也不自动让元素不可变。参见 Stream API 与 Collectors API。
需更新 Item 48:并行化必须匹配工作负载
parallelStream() 不会自动加速任何流。任务要能有效拆分,单个任务有足够计算量,归约满足结合律,且共享状态受到控制;否则拆分、调度与合并成本会超过收益。浮点归约在改变组合顺序后也可能得到不同舍入结果。
大量阻塞 I/O 更适合考虑虚拟线程配合资源限流;并行流默认常用公共 ForkJoinPool,不能作为每个请求的独立并发预算。CPU 密集型工作则仍受核数限制。上线前用真实数据和并发量比较吞吐、尾延迟、分配量,而不是只测一轮墙钟时间。
方法与 API 契约:Item 49–56
仍适用 Item 49:在边界验证参数
公共入口应尽早检查非空、范围和跨字段关系,让失败发生在原因附近。assert 默认可能关闭,适合内部不变量检查,不能替代公开参数校验。
1 | |
这里索引按 UTF-16 代码单元定义,不保证切分点是完整码点。对懒执行 API,还要明确参数是在方法调用时还是消费结果时校验;调用方不能把“创建流成功”理解成“所有元素都有效”。
需更新 Item 50:在可变状态边界做防御性复制
输入与输出两侧都可能泄露可变状态。数组要复制,嵌套可变对象要逐层考虑;对时间优先使用不可变的 Instant、LocalDate 等类型,减少 Date 的复制负担。
1 | |
这个例子只保护数组所有权,record 默认的 equals 与 hashCode 仍按数组对象比较;需要内容相等时,应另行设计值类并使用 Arrays.equals、Arrays.hashCode。复制也不能从另一个线程正在修改的数据中自动得到一致快照,源数据仍需要同步或所有权转移。
仍适用 Item 51:方法签名应该帮助调用者避免混淆
参数数量要少,同类型参数尤其容易颠倒。用值对象表达单位与角色,集合参数依赖所需接口,避免用多个布尔值表达互斥模式。
1 | |
真实入口应校验账号、金额与币种;这里强调不同角色不能互换。不要把所有字段都包装成类型,优先保护单位、权限、身份与业务方向这些高代价误用点。
仍适用 Item 52:谨慎重载,理解编译期选择
重载根据编译期类型选择签名;覆盖才根据运行时接收者选择实现。因此集合里的对象实际是什么,不会使重载自动切到“更具体”的参数版本。
1 | |
避免设计同参数数量、依赖装箱或不相关引用类型区分的重载,尤其是调用方可能传 null、Lambda 或方法引用时。方法名能说明不同动作时,直接命名比让编译器猜分支更清楚。
仍适用 Item 53:可变参数要表达最小参数数量
min(int... values) 允许空调用,若语义要求至少一个值,应把首值单独放进签名。可变参数每次调用通常需要一个数组;高频热点才有必要进一步考虑固定参数重载。
1 | |
可变参数不意味着接管调用方传入的数组。需要保存它时必须明确复制或所有权协议,不能在调用方不知情的情况下保留并修改数组。
仍适用 Item 54:没有元素时返回空容器
查询结果“有零个元素”是正常集合状态,应返回空集合或空数组,使调用方能直接遍历。null 应留给另有明确契约的状态,不能把“没有元素”与“查询未执行”混在一起。
1 | |
空结果也要保持可变性契约一致。若非空结果允许调用方添加元素,空结果也应返回可变空容器,不能只在有数据时支持修改。
仍适用 Item 55:审慎返回 Optional
Optional<T> 适合返回“查询可能没有一个结果”。无结果是预期分支时比抛异常更自然;集合通常直接返回空集合,避免 Optional<List<T>> 把状态增到没有业务意义的三种。
1 | |
orElse 的参数会提前求值,orElseGet 才按需执行供应函数。不要让返回 Optional 的方法再返回 null;参数与实体字段通常用其他方式表达可选性,确实使用 Optional 时要确认框架和序列化边界。数值返回可考虑 OptionalInt 等类型。
仍适用 Item 56:文档写出契约,而不只复述实现
公开方法至少说明参数单位、返回值的可变性与所有权、失败条件,以及阻塞、线程安全和关闭责任。泛型类型参数、继承扩展点与枚举含义也属于契约。
1 | |
这里保留了 JDK 读取方法的空文件语义并写清楚;若业务接口希望消除 null,可以在外层转成 Optional。文档不能靠“永不返回 null”等口号覆盖实际实现。
日常编程与性能:Item 57–68
仍适用 Item 57:缩小局部变量作用域
在即将使用时声明并初始化变量,减少错误复用与跨分支状态。循环变量放在循环头;复杂方法优先按行为拆分,而不是让几十个变量一起存活到方法末尾。
1 | |
var 不改变静态类型,也不要求到处使用。右侧无法清楚说明类型,或接口类型对阅读有帮助时,显式声明更好。
仍适用 Item 58:不需要索引时使用 for-each
只消费元素就用增强 for,避免手工维护索引与迭代器。需要删除当前元素、并行遍历多个序列或依赖位置时,再选专门操作或显式循环。
1 | |
不要在普通 for-each 中直接结构性修改正在遍历的集合。ConcurrentModificationException 是尽力检测误用的机制,不是并发安全保证,代码正确性不能依赖它一定抛出。
需更新 Item 59:熟悉标准库,再决定是否自己实现
日期时间优先 java.time,资源读写考虑 NIO,随机数、集合和并发结构先找已有实现。库通常已经处理边界与平台差异,但仍要读具体契约,例如加法是否检测溢出、比较器如何处理 null。
1 | |
这里随机数适合普通随机选择,不能用于令牌或密钥;那类需求使用 SecureRandom。Java 21 有序集合、Java 24 Stream Gatherers 等工具提供了新选择,采用前确认项目基线与任务是否真的需要。
仍适用 Item 60:精确十进制计算不用 float 或 double
二进制浮点无法精确表示很多十进制小数。金额可以用带币种的最小单位整数,或 BigDecimal 配合明确的 scale、舍入模式与计算规则;BigDecimal 并不会替业务决定如何舍入。
1 | |
字符串构造器表达精确十进制,new BigDecimal(0.1) 会带入该 double 的二进制近似值。除法可能是无限小数,需要明确精度或舍入;最小单位整数也要防溢出,并承认不同币种的小数位可能不同。
仍适用 Item 61:数值运算优先基本类型
包装类型引入可空性、对象身份和装箱成本。比较两个 Integer 的 == 是引用比较,缓存可能让某些小值“看起来正确”;拆箱 null 会抛 NullPointerException。
1 | |
泛型集合需要包装类型,但局部累加器应使用基本类型。确实需要缺失状态时,先决定用 null、Optional 数值类型还是独立业务状态,而不是靠隐式拆箱碰运气。
仍适用 Item 62:不要用字符串代替所有领域类型
字符串适合文本,不适合同时承担状态、数量、身份和结构化协议。把毫秒写成 String、用逗号拼接多个字段,会失去类型检查,并引入转义与格式演化问题。
1 | |
HTTP 与文件里的字符串应在边界解析成领域类型,再进入业务流程。类型包装只有在校验或防混淆上有价值时才引入;字段名本身已经足够说明语义的临时文本无须过度建模。
仍适用 Item 63:循环拼接字符串使用累积器
循环中反复 result = result + part 会不断复制已有内容,输入增长时总复制量可能接近平方级。单个短表达式中的 + 通常清楚且能被编译器优化,无需机械替换。
1 | |
固定分隔符且无复杂格式时也可以用 String.join。输出很大时直接写入 Writer,避免先在内存里生成完整字符串再复制一遍。
仍适用 Item 64:引用类型围绕所需能力声明
变量与参数通常声明为 List、Map 等接口,使实现可以替换。构造时仍选择具体实现;依赖排序、导航或有序首尾操作时,应声明能表达这些能力的接口。
1 | |
HashMap 换成 TreeMap 不是仅换实现:键的相等判定与迭代顺序可能改变。依赖具体能力而标准接口无法表达时,具体类型也是合理选择。
仍适用 Item 65:把反射限制在动态边界
插件发现与框架装配可能需要反射,但完成构造后应尽量回到普通接口调用。反射会把成员名、可见性与类型错误推迟到运行时,也更难与模块封装、静态分析及提前编译协作。
1 | |
服务发现需要配套 provider 注册。模块系统的 opens 与 exports 表达不同授权;不要遇到访问失败就长期对所有包开放深反射。
需更新 Item 66:本地调用用于必要能力,不作为盲目加速手段
JNI 调用增加本地内存、ABI、部署与进程崩溃风险,Java 数组或字符串跨边界还可能需要转换和复制。先用剖析确认热点,再评估算法、数据布局和 JDK 能力;Java 方法并不天然比同功能本地调用慢。
Java 22 已正式提供 Foreign Function & Memory API,可用于本地函数与外部内存互操作。它改善了绑定与生命周期表达,但没有让本地代码天然安全,仍需关注线程访问、资源关闭、目标平台与 native access 配置。版本迁移与限制见 JDK 迁移说明。
仍适用 Item 67:优化从测量和设计开始
先选合适的算法与数据结构,避免无界队列、过量对象图和重复远程调用,再优化被测到的热点。接口一旦暴露可变表示或高成本操作,后续优化会被兼容性约束住。
JFR 适合观察真实应用的 CPU、分配、锁与停顿;JMH 适合控制预热、消除死代码和多轮运行的微基准。微基准胜出不代表请求尾延迟会改善,最终还要在真实负载中检查吞吐、内存和失败行为。
仍适用 Item 68:遵循惯例,让名字携带意义
包名小写,类型用 UpperCamelCase,方法与变量用 lowerCamelCase,常量用 UPPER_SNAKE_CASE。方法名围绕动作或属性,不把实现细节塞进公共名字。
get、find、load 可以在项目内分别表达必有结果、可缺失与需要 I/O,但必须写进契约并一致执行。现代 record 的组件访问器通常叫 name();与 JavaBean 框架集成时再考虑其命名要求。
异常与失败原子性:Item 69–77
仍适用 Item 69:异常用于异常情况
正常结束遍历靠集合长度或迭代协议,不要通过越界异常结束循环。用户未命中查询属于常规分支,非法对象状态、解析失败和 I/O 失败则可以按照接口契约抛异常。
1 | |
库只提供异常式解析入口时,在边界捕获并转换成领域失败是合理适配。避免为了不出现异常而返回合法范围内的魔法值,使真正失败无法区分。
仍适用 Item 70:按调用方恢复义务选择异常类型
受检异常把处理义务放进方法签名,适合调用者必须明确面对的可恢复失败;运行时异常适合违反前置条件、非法状态等错误。是否可恢复取决于调用层,而不是简单地把“网络全部受检、业务全部运行时”作为分类规则。
1 | |
Error 通常不是普通业务恢复入口,不应随手包装成业务失败。异常层次还要能承载诊断信息,避免只留下一句笼统的“操作失败”。
仍适用 Item 71:受检异常应有明确收益
如果调用方只能反复 catch 后原样包装,受检异常可能没有增加处理价值。偶发缺失可用 Optional,有限且需要正常分支处理的业务结果可用 sealed 结果类型;底层 I/O 失败仍应保留原因与清晰错误边界。
1 | |
不要把所有异常都塞进 Result,再要求每层代码机械传递。选择返回值还是异常,核心在于调用方是否应把这个状态作为正常控制流处理。
仍适用 Item 72:优先使用语义匹配的标准异常
参数非法用 IllegalArgumentException,状态不允许用 IllegalStateException,索引非法用 IndexOutOfBoundsException,无元素用 NoSuchElementException,不支持操作用 UnsupportedOperationException。
不能为了省一个类,把所有业务失败都当作 IllegalStateException。只有调用方需要按领域失败分别处理,或需要专门诊断字段时,再引入自己的异常类型;名字与继承层次应帮助处理者区分失败。
仍适用 Item 73:异常跨抽象边界时转换,并保留原因
上层不应被迫理解底层库的每一种异常。转换异常时保留 cause,使调用者看到符合当前抽象的失败,诊断者仍能追到根因。
1 | |
转换前确定调用方是否还需要受检处理义务。日志一般由真正处理失败或终止请求的边界记录,避免每一层“记录后再抛”导致同一次失败刷出多份堆栈。
仍适用 Item 74:写清方法会怎样失败
为受检异常写 @throws,也说明由参数、状态和调用顺序触发的运行时异常。接口尤其要描述调用方可依赖的失败语义,不应把某个实现偶然抛出的全部底层类型列为长期契约。
并发 API 还应说明中断、超时、取消的区别:超时返回是否会取消任务,取消是否会停止底层 I/O,关闭是否等待任务结束。调用方的重试与资源释放往往依赖这些差别。
仍适用 Item 75:异常信息帮助复现,同时避免泄密
信息应包含有用的边界、操作类型和相关标识,而不是只写 invalid。敏感原始输入、凭据、完整请求体和 SQL 参数不应无条件进入异常或日志。
1 | |
机器处理优先独立错误码与结构化字段,不解析自然语言 message。异常消息与对外错误响应可以有不同详细程度,由服务边界进行映射。
仍适用 Item 76:失败后对象应保持有效状态
失败原子性通常要求操作失败后对象保持操作前状态,至少也要明确哪些状态已经提交。先校验与计算,再一次性修改状态,比“改一半再尝试补救”容易推理。
1 | |
溢出发生时赋值尚未执行,余额保持原状。这个类没有并发保证;跨数据库与远程服务的多步操作还需要事务、幂等或补偿协议,局部对象不变不能证明外部副作用没有发生。
仍适用 Item 77:不要丢弃异常的处理义务
捕获异常后要恢复、转换、传播,或在有明确理由时记录并终止;空 catch 会把失败伪装成成功。允许忽略的异常必须有局部说明,并能证明对当前契约无影响,不要求每个 catch 都打印日志。
1 | |
这里不能继续向上抛中断,就恢复标记并让调用方看到失败;调用方也必须根据返回值停止或调整操作。若签名允许,直接传播 InterruptedException 通常更清楚。
并发、发布与任务生命周期:Item 78–84
仍适用 Item 78:共享可变状态需要同步
同步既用于互斥,也用于可见性与顺序。volatile 写与后续读建立相应的 happens-before 关系,但不能把 count++ 这种读、改、写组合变成原子操作。
1 | |
多个字段需要一起保持不变量时,单独把每个字段改成原子类型还不够,应使用同一把锁或不可变快照替换。LongAdder 适合高争用统计,但其 sum() 不是与并发更新严格一致的瞬时快照;不能直接拿它实现精确余额。
正确构造的 final 字段有初始化安全保证,但前提包括构造期间不让 this 逃逸。final 引用指向的可变内容仍需要同步;更换到虚拟线程也不会改变这些规则。
需更新 Item 79:缩小锁范围,不在锁内调用外部代码
锁保护必要的状态变化,不应顺带包住网络 I/O、未知回调或昂贵计算。外部调用可能阻塞、重入或按不同顺序获取其他锁,导致吞吐下降甚至死锁。
1 | |
这个快照允许发布时新注册的监听器等到下一次事件,也未保证并发发布的顺序;回调失败是否阻止后续监听器,需要另外定义策略。读多写少的注册表也可考虑 CopyOnWriteArrayList,但写入会复制数组。
Java 21 中,某些 synchronized 区域内的阻塞会把虚拟线程固定在载体线程上;Java 24 的 JEP 491 已消除这类监视器导致的固定。不要把旧版本的“统一改成 ReentrantLock”当作永久规则:Java 25 仍需留意本地调用等固定情形,同时控制锁持有时间。见 JDK 24 变更与虚拟线程说明。
需更新 Item 80:任务抽象仍优先,执行策略按负载选择
业务描述 Runnable 或 Callable,执行器负责调度与生命周期。CPU 密集型任务使用受核数与队列预算约束的执行策略;大量阻塞 I/O 可以使用 Java 21 正式提供的虚拟线程,每个任务一个虚拟线程,不再通过池化虚拟线程来限制资源。
1 | |
这个例子展示等待超时后发出取消请求;它不保证两秒内返回,因为关闭执行器会等待任务结束,中断也只是协作请求。任务必须支持取消,网络和数据库操作还应配置自己的超时。应用级执行器一般在服务启动时创建、关闭时统一收束,避免每个普通方法自行创建。
虚拟线程减少等待时占用的平台线程,并不增加数据库连接数、远端配额或可用内存。用连接池或信号量约束稀缺资源,再给入口设置总量、排队和拒绝预算,避免无限提交任务。
1 | |
这是资源并发上限,不是完整的流量控制:等待许可的任务仍占内存。CompletableFuture 适合异步结果组合,但 orTimeout 与 cancel 不自动停止底层任务,cancel(true) 也不通过它中断计算;需要在任务协议中单独安排取消传播。相关契约见 CompletableFuture API。
结构化并发进一步把子任务纳入父任务作用域,但 Java 25 的 StructuredTaskScope 仍为预览 API,而且该版本的 API 已不同于早期示例。需要在项目明确接受预览版本后采用,不能直接把旧版 ShutdownOnFailure 代码当作 Java 25 正式接口。参见结构化并发文档。
需更新 Item 81:优先并发工具,不手写等待协议
生产者与消费者用 BlockingQueue,一次性等待用 CountDownLatch,资源预算用 Semaphore,共享映射用 ConcurrentHashMap。这些工具封装了唤醒、可见性和部分状态管理,但业务的多步原子性仍需自己表达。
1 | |
这里假定键集合有业务边界且不会并发移除计数器,否则还需要清理与移除协议。computeIfAbsent 的函数应短小,不进行远程调用,也不递归更新同一映射。多个线程安全方法拼在一起并不自动形成一个原子业务操作。
Java 25 正式提供 ScopedValue,适合沿调用链传递只在词法作用域内绑定的请求上下文。它不是任意可变 ThreadLocal 的通用替代品,也不会把可变引用对象变成不可变。
1 | |
此片段需要 Java 25。 普通执行器提交的任务不能假定自动继承当前绑定;跨任务传播要符合具体并发工具的契约。若使用 ThreadLocal,仍应理解其线程生命周期并在必要时 remove()。参见 ScopedValue API。
仍适用 Item 82:把线程安全级别写进契约
说明对象是不可变、内部同步、需要外部同步,还是仅允许单线程使用;同时说明哪些复合操作原子、迭代看到什么,以及是否允许并发关闭。仅写“线程安全”不足以推断全部组合行为。
Collections.synchronizedList 的单个操作有同步,但遍历通常仍需要按其契约锁住包装对象。服务方法内部使用并发集合,也不代表整个服务业务事务是原子的。虚拟线程数量更多,这些边界反而更重要。
仍适用 Item 83:惰性初始化只在确有收益时采用
普通实例依赖优先在构造时建立,逻辑最简单。静态、昂贵且不总使用的对象,可以通过 holder 惯用法利用类初始化的线程安全保证。
1 | |
实例级双重检查需要 volatile 以及正确的局部引用和锁顺序;没有测出收益时,直接同步或提前初始化更容易维护。类初始化失败也不是每次访问重新尝试,需要可重试加载的配置应另外建模。
仍适用 Item 84:正确性不能依赖调度运气
sleep、yield、线程优先级不能建立“另一线程已经更新状态”的保证。通过 Future、队列、锁、闩锁等显式协调;忙等会浪费 CPU,虚拟线程也不让忙等变廉价。
并发测试等待明确事件并设置超时,避免“睡 100 毫秒以后应该完成”。超时失败只是观察到系统没有及时满足条件,不能单独证明死锁;还需要线程状态和资源等待证据。
序列化与长期兼容:Item 85–90
仍适用 Item 85:优先选择 Java 原生序列化以外的协议
ObjectInputStream 重建的是对象图,还可能触发类中的恢复逻辑,风险不只是读取几个字段。跨服务通信与持久数据优先明确的 DTO 加 JSON、Protobuf 等协议,再做字段、长度、版本和业务校验;替代格式也需要安全配置,特别是多态类型解析。
必须兼容遗留流时,在读取前配置类白名单和对象图限制;不能把过滤器视为允许接收任意不可信对象流的通行证。官方机制见序列化过滤说明。
1 | |
这里的类名只是策略占位,真实白名单必须覆盖经过审查的完整对象图,并验证根对象类型;该方法还接管并关闭输入流。过滤器不是每读一个字节都回调,字符串也有特殊处理,因此 maxbytes 不能代替输入层严格的字节上限。字段校验、传输限制与对象过滤分别处理不同边界。规则语法见 ObjectInputFilter.Config API。
仍适用 Item 86:实现 Serializable 等于增加一份长期契约
implements Serializable 不只是标记;默认形式会把字段布局与类结构绑定到持久字节流,也引入额外对象创建入口。给公共类或可继承类增加这个能力时,必须承担兼容、安全和测试成本。
普通可序列化类显式定义 serialVersionUID,使用 @Serial 检查相关声明;UID 一致只满足部分版本检查,不代表字段变化就业务兼容。不要因为某个容器或框架支持序列化,就给全部领域对象自动添加该接口。
仍适用 Item 87:序列化逻辑状态,不固化偶然实现
缓存、链表节点、锁与派生索引属于实现细节;稳定协议应表达业务数据,恢复时重新建立派生结构。默认字段序列化可能把这些内部结构永久写进兼容约束。
在原生序列化中,transient 可排除字段,但排除后就需要明确重建方案,构造函数初始化的值也不会自动按普通创建流程补齐。自定义 writeObject/readObject 必须成对设计并跨版本测试;对于新持久格式,显式 DTO 和独立版本号通常更容易演化。
需更新 Item 88:把反序列化视为另一个构造入口
普通 Serializable 类的恢复过程不会执行该类的正常构造函数,输入流可以绕过构造时的检查。因此 readObject 要验证不变量,复制可变状态,并避免调用可覆盖方法。
1 | |
record 是重要例外:其原生反序列化通过规范构造函数恢复组件,因此可以复用构造校验;record 的 readObject、writeObject 等定制钩子也不像普通类那样工作。这改善了创建不变量,却不自动消除组件对象图、资源耗尽或协议演化风险。参见 record 序列化规则。
仍适用 Item 89:需要序列化实例控制时优先枚举
普通单例的 readResolve 要处理恢复时的额外实例和对象图引用,设计容易遗漏。枚举由序列化机制特殊处理,可以恢复为对应枚举常量,适合真正需要序列化的固定单例或有限实例集合。
1 | |
它解决的是同一枚举类型中的实例身份,不是分布式唯一性。枚举原生序列化也不会按普通类的方式保存、恢复自定义实例字段;不要把带可变业务状态的枚举当作持久化容器。
仍适用 Item 90:遗留值类考虑序列化代理
代理把字节流对应的状态放在小型专用对象里,再通过目标类正常构造函数恢复;目标类拒绝直接 readObject。这样恢复路径能复用正常构造校验,避免直接写入私有字段。
1 | |
代理适合不支持任意继承的值类型,不适合直接套用到复杂循环对象图。示例只存最小单位金额,真实货币类型还要绑定币种。对于新跨进程协议,优先 Item 85 的显式数据格式;代理是在必须保留原生序列化时降低设计风险的工具。
现代检查清单
90 条建议可以压缩成下面的工程检查入口;遇到边界问题,再回到对应条款阅读机制与例外。
| # | 现在优先检查什么 | 对应条款 |
|---|---|---|
| 1 | 创建入口有语义,复杂参数用 Builder,统一校验不变量 | 1、2、49 |
| 2 | 时间、随机源与外部服务通过依赖传入 | 5 |
| 3 | 资源用 try-with-resources,关闭责任明确 | 8、9 |
| 4 | 相等、哈希与排序契约一致,键状态稳定 | 10、11、14 |
| 5 | record 的可变组件复制,敏感组件脱敏 | 12、17、50 |
| 6 | 封装可变表示,扩展行为优先组合 | 15、16、18、19 |
| 7 | 封闭分支用 sealed,开放能力用接口 | 20、23、38 |
| 8 | 不使用原始类型,unchecked 只留在可证明的窄边界 | 26、27 |
| 9 | 泛型 API 按输入输出使用 PECS | 29–32 |
| 10 | 枚举编码独立于 ordinal,集合用 EnumSet/EnumMap | 34–37 |
| 11 | Stream 无必要副作用,结果可变性写清楚 | 45–47 |
| 12 | 并行化先匹配负载,再测真实吞吐与尾延迟 | 48、67、80 |
| 13 | 空集合表示零元素,Optional 表示可缺失单值 | 54、55 |
| 14 | 金额明确精度、舍入与币种,整数计算考虑溢出 | 60、61 |
| 15 | 异常跨层保留 cause,失败后状态有效,中断不丢弃 | 73、76、77 |
| 16 | 共享状态说明可见性、原子性和发布协议 | 78、82 |
| 17 | 锁内不执行未知回调或长时间 I/O | 79 |
| 18 | 虚拟线程配合资源预算、超时与取消 | 80、81 |
| 19 | 正确性不依赖 sleep、优先级或调度顺序 | 84 |
| 20 | 新协议避免原生序列化,遗留恢复校验每条创建路径 | 85–90 |
版本与资料
本文采用的版本边界如下,迁移时不要把不同 JDK 的正式特性与预览 API 混写:
| 特性 | 正式版本或本文状态 | 主要影响 |
|---|---|---|
集合工厂 List.of 等 | Java 9 | 不可修改、拒绝 null 的集合入口 |
List.copyOf 等 | Java 10 | 与输入集合后续修改隔离的浅副本 |
record、Stream.toList() | Java 16 | 数据载体样板与不可修改流结果 |
| sealed 类型 | Java 17 | 限定直接子类型集合 |
| record 模式、模式 switch | Java 21 | 解构与穷尽分支检查 |
| 有序集合、虚拟线程 | Java 21 | 首尾访问与阻塞 I/O 执行策略 |
| FFM API、Stream Gatherers | Java 22、24 | 本地互操作与流中间操作扩展 |
| synchronized 导致的虚拟线程固定问题修复 | Java 24 | 不再沿用 Java 21 的固定规避规则 |
| ScopedValue | Java 25 | 作用域内的上下文绑定 |
| StructuredTaskScope | Java 25 仍为预览 | API 与采用策略需单独确认 |
条款编号按第三版,版本边界按上述 JDK 的官方规范核对;不根据更高版本推断同名 API 的行为。继续查阅可使用以下入口:
- 《Effective Java》第三版与官方样章:核对书籍版本与条款目录。
- Java 21 语言变化:record、sealed、模式匹配的版本边界。
- Java 21 标准库 API:集合、资源与并发方法的精确契约。
- Java 25 record 说明:组件、生成方法与原生序列化规则。
- Java 25 核心库指南:虚拟线程、ScopedValue、结构化并发与序列化过滤。
现代 Java 减少了需要手写的代码,但不会自动决定对象身份、资源所有权、线程安全和长期兼容。逐条重读的重点,是让语言工具承担它已经能保证的部分,把剩下的业务契约写清楚并验证。





