《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
2
3
4
5
6
7
8
9
10
11
record Port(int value) {
Port {
if (value < 1 || value > 65_535) {
throw new IllegalArgumentException("invalid port");
}
}

static Port fromText(String text) {
return new Port(Integer.parseInt(text));
}
}

record 仍可提供工厂,但其规范构造函数不能比 record 本身更不可见。若必须彻底隐藏表示、限制所有创建入口,应使用普通类,而不是依赖 record 加一个工厂来封锁 new。

需更新 Item 2:多参数创建考虑 Builder

多个同类型参数、很多可选项或跨字段约束适合 Builder;只有两三个含义清晰的字段时,record 加规范构造函数通常足够。record 消除了数据载体的样板代码,却没有给 Java 增加命名参数,new Config(3, 5, 8) 仍然难读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
record ClientConfig(String host, Duration timeout) {
ClientConfig {
Objects.requireNonNull(host, "host");
Objects.requireNonNull(timeout, "timeout");
if (host.isBlank() || !timeout.isPositive()) {
throw new IllegalArgumentException("invalid config");
}
}

static final class Builder {
private final String host;
private Duration timeout = Duration.ofSeconds(3);

Builder(String host) {
this.host = host;
}

Builder timeout(Duration value) {
timeout = value;
return this;
}

ClientConfig build() {
return new ClientConfig(host, timeout);
}
}
}

把最终校验放在构造入口,避免其他工厂绕过 Builder 时得到非法对象。Builder 本身一般可变且不保证线程安全;构建结果应独立于后续 Builder 修改。

仍适用 Item 3:确实需要单例时,用明确的实例控制

枚举单例能直接处理实例唯一性与原生序列化;普通静态字段也可以,但要额外考虑反射与反序列化。更先要问的是是否需要全局状态:业务服务通常由依赖注入容器管理生命周期,通过接口传入更容易测试。

1
2
3
4
5
6
7
enum DefaultClock {
INSTANCE;

Instant now() {
return Instant.now();
}
}

“一个实例”通常只是在一个类加载器加载的该类型范围内成立,并不意味着整个进程或集群唯一。枚举里的可变字段也不会因为枚举是单例而自动线程安全。

仍适用 Item 4:工具类用私有构造函数禁止实例化

只提供静态操作的工具类不需要实例,也不应留出可继承的入口。把类声明为抽象只能阻止直接实例化,仍可能被继承;私有构造函数表达得更准确。

1
2
3
4
5
6
7
8
9
final class Texts {
private Texts() {
throw new AssertionError("no instances");
}

static boolean isBlank(String text) {
return text == null || text.isBlank();
}
}

仍适用 Item 5:把依赖传进来

依赖注入首先是对象设计,不要求使用框架。时间、随机数、存储和网络客户端都应尽量通过构造函数或工厂传入,让业务逻辑不用自行选择全局实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
record ExpiryPolicy(Clock clock, Duration ttl) {
ExpiryPolicy {
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(ttl, "ttl");
if (!ttl.isPositive()) {
throw new IllegalArgumentException("invalid ttl");
}
}

Instant expiresAt() {
return clock.instant().plus(ttl);
}
}

测试时传入 Clock.fixed 即可固定时间。还要明确依赖的关闭责任:借用的连接池或客户端一般由创建它的上层关闭,使用它的每个服务不应各自关闭一次。

仍适用 Item 6:避免没有意义的对象创建

复用不可变对象和昂贵、线程安全的辅助对象,例如编译后的 Pattern。不要为了减少分配把普通 DTO 全部放入对象池;这会增加状态重置、并发与内存滞留成本。JIT 可能消除部分分配,是否值得优化要测量。

1
2
3
4
5
6
private static final Pattern IDENTIFIER =
Pattern.compile("[a-z][a-z0-9_]*");

static boolean isIdentifier(String text) {
return IDENTIFIER.matcher(text).matches();
}

Pattern 可以共享,Matcher 是可变状态,应按调用创建。自动装箱也是对象创建来源:数值累加优先 long,不要在循环中反复对 Long 装箱、拆箱。

仍适用 Item 7:清除失去业务用途的引用

GC 判断的是可达性,不知道对象在业务上是否还有用。长寿命集合、监听器、无界缓存和线程局部变量会把本该回收的对象保留下来。自己实现容器时,删除元素也要清除底层存储里的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static final class Stack<E> {
private final List<E> elements = new ArrayList<>();

void push(E value) {
elements.add(value);
}

E pop() {
if (elements.isEmpty()) {
throw new NoSuchElementException();
}
return elements.remove(elements.size() - 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
2
3
4
5
6
static String firstLine(Path path) throws IOException {
try (var reader = Files.newBufferedReader(
path, StandardCharsets.UTF_8)) {
return reader.readLine();
}
}

一个资源初始化成功、后续资源初始化失败时,已经成功创建的资源也会被关闭。返回 Stream 的文件 API 同样可能持有资源;Files.lines 的流需要关闭,不能把所有流都当作普通集合流水线。

对象契约与值语义:Item 10–14

需更新 Item 10:先确定相等的含义,再实现 equals

对象身份与业务值相等是两种契约。实体可能按稳定 ID 相等,值对象按组成部分相等,执行器和连接通常保留身份语义。自定义 equals 必须满足自反、对称、传递、一致,以及与 null 比较为假。

1
2
3
4
5
6
7
8
record CustomerId(String value) {
CustomerId {
Objects.requireNonNull(value, "value");
if (value.isBlank()) {
throw new IllegalArgumentException("blank id");
}
}
}

record 为其组件生成相等性实现,适合这类值对象;数组组件仍按数组对象的相等性处理,并不会自动比较数组内容。给可继承的值类新增参与相等性的状态,很容易破坏对称性或传递性,因此优先 final 值类或组合。record 的生成规则见官方语言说明。

需更新 Item 11:equals 与 hashCode 必须一起设计

相等对象必须有相同哈希值,不相等对象允许碰撞。record 会生成匹配的实现;普通值类则要确保两者使用同一组字段。不要在对象成为 HashMap 的键后修改参与相等性的状态,否则它可能仍在原来的桶里,查询却按新哈希值寻找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final class TenantId {
private final String value;

TenantId(String value) {
this.value = Objects.requireNonNull(value);
}

@Override
public boolean equals(Object other) {
return other instanceof TenantId id
&& value.equals(id.value);
}

@Override
public int hashCode() {
return value.hashCode();
}
}

Objects.hash 很方便,但可变参数与装箱可能带来成本;只有测到热点再考虑手写组合。哈希值不是持久 ID,也不是用于校验完整性或抵抗攻击的密码学摘要。

需更新 Item 12:toString 应便于诊断,并保护敏感字段

record 的默认 toString 会展示组件,适合普通数据,却可能直接把口令、令牌和个人信息送进日志。敏感对象应显式脱敏,普通对象也应避免遍历巨大对象图或访问数据库。

1
2
3
4
5
6
record Credentials(String user, String token) {
@Override
public String toString() {
return "Credentials[user=" + user + ", token=***]";
}
}

toString 是诊断接口。需要稳定机器格式时,另外定义编码器或 DTO;不要让外部系统靠拆解 toString 获取字段。

已过时 Item 13:新 API 不以 Cloneable 作为复制协议

原书对 clone 的谨慎态度仍成立;已过时的是把它当作新代码默认复制方案。Cloneable 没有声明公开的复制方法,Object.clone() 逐字段浅复制,构造函数也不会按正常创建流程执行,引用字段仍可能指向同一份可变状态。

1
2
3
4
5
6
7
8
9
record Labels(List<String> values) {
Labels {
values = List.copyOf(values);
}

static Labels copyOf(Labels source) {
return new Labels(source.values());
}
}

使用复制构造函数、copyOf 工厂或不可变值直接共享,让复制深度成为显式契约。数组的 clone() 仍是合法的浅复制工具;元素也是可变对象时,复制数组并不复制元素。

仍适用 Item 14:自然顺序用 Comparable,其他顺序用 Comparator

类型确实具有稳定自然顺序时才实现 Comparable;按姓名、时间、优先级等不同维度排序用独立 Comparator。通常应保证 compareTo 返回零与 equals 一致,否则 TreeSet 和 HashSet 可能对“重复元素”得出不同结论。

1
2
3
4
5
6
7
8
9
10
11
record Version(int major, int minor)
implements Comparable<Version> {
private static final Comparator<Version> ORDER =
Comparator.comparingInt(Version::major)
.thenComparingInt(Version::minor);

@Override
public int compareTo(Version other) {
return ORDER.compare(this, other);
}
}

不要用 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
2
3
4
5
record Position(double x, double y) {
Position translated(double dx, double dy) {
return new Position(x + dx, y + dy);
}
}

record 不适合“公开组件会泄露实现、以后还想自由改变表示”的类型。访问方法也不能原样返回内部可变数组;封装不仅看字段是否 private,还看外界能否通过返回值修改状态。

需更新 Item 17:尽量减少可变状态

不可变对象让共享、缓存、相等性与并发推理更简单。record 只保证组件字段 final,不保证组件指向的对象不可变;要在构造时建立副本或选择不可变组件。

1
2
3
4
5
6
record Team(String name, List<String> members) {
Team {
Objects.requireNonNull(name, "name");
members = List.copyOf(members);
}
}

这里成员是不可变的 String,列表也不再随原列表修改而变化。若元素是可变的 User,List.copyOf 仍然共享这些元素。Collections.unmodifiableList 只是只读视图,原列表的修改仍可见;深不可变性必须沿对象图检查。集合边界见 List API。

仍适用 Item 18:通过组合扩展行为

继承实现会把子类耦合到父类的内部调用路径,例如父类的批量添加是否调用单项添加。组合把依赖放在明确的委托调用上,也能围绕业务能力定义小接口,而不是被迫暴露整个容器 API。

1
2
3
4
5
6
7
8
9
10
11
interface Sender {
void send(String message);
}

record PrefixingSender(Sender delegate, String prefix)
implements Sender {
@Override
public void send(String message) {
delegate.send(prefix + message);
}
}

真正满足替换原则、且父类专门支持扩展时,继承仍合理。给一个现成类添加计数、审计或重试通常更适合装饰器;重试还要独立确认幂等性。

需更新 Item 19:为继承设计,否则限制继承

扩展点要说明调用时机、调用顺序及状态约束;构造函数不要调用可覆盖方法,子类字段此时可能尚未初始化。普通类用 final 禁止继承,record 自动 final,sealed 类型则限定谁有资格继承。

1
2
3
sealed interface Outcome permits Accepted, Rejected {}
record Accepted(String id) implements Outcome {}
record Rejected(String reason) implements Outcome {}

sealed 只关闭直接子类型集合,不自动保证所有分支都不可变或封闭;允许的子类可以声明 non-sealed 再开放扩展。公开扩展点后,内部重构也必须维持扩展契约。

仍适用 Item 20:能力契约优先接口

接口可以让已有类型加入新能力,并与其他接口组合;抽象类适合共享真正必要的状态、构造约束和模板实现。default 方法可以提供简短通用行为,但不能引入每个实例独立的字段。

1
2
3
4
5
6
7
8
9
interface Named {
String name();

default String displayName() {
return name().strip();
}
}

record User(String name) implements Named {}

不要为每个只有一个实现、也没有替换需求的类都创建接口。接口的价值来自稳定能力边界与可替换实现,而不是文件数量。

仍适用 Item 21:接口演化仍需要兼容性设计

新增 default 方法避免了部分实现类源码修改,却不保证语义兼容:多个接口可能产生默认方法冲突,现有实现也可能违反新方法假定的不变量。新增抽象方法则会要求实现方补充实现。

发布接口前先验证真实实现与调用方;发布后区分源码、二进制与行为兼容。无法给所有旧实现提供合理默认行为时,应考虑新接口或显式版本迁移,而不是用抛异常的 default 方法掩盖能力缺口。

仍适用 Item 22:接口用来定义类型

“常量接口”把实现细节混进类型层次,调用者还可能通过实现类名称访问那些常量。常量放在所属领域类型或不可实例化的工具类中;有限的业务取值用枚举。

1
2
3
4
5
6
enum Priority { LOW, NORMAL, HIGH }

final class Limits {
private Limits() {}
static final int MAX_BATCH_SIZE = 500;
}

需更新 Item 23:用类型分支代替标签加互斥字段

一个 kind 字段加一组“某种 kind 才有效”的字段,把类型约束转移到了运行时。sealed 接口配合 record 可以表达每种分支的数据,Java 21 的模式 switch 则检查穷尽性。

1
2
3
4
5
6
7
8
9
10
11
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height)
implements Shape {}

static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}

示例只展示分支建模;生产构造入口还应校验尺寸与有限数值。封闭领域适合 sealed;第三方需要增加分支时,开放接口加多态方法更合适。模式 switch 也不会默认接受 null,需要明确非空契约或单独处理。

仍适用 Item 24:不需要外部实例时使用静态嵌套类

非静态内部类具有关联的外部实例,可能让外部对象随内部对象一起存活。辅助类型不需要访问外部实例时,声明为 static;嵌套 record 与成员接口本身就具有静态性质。

1
2
3
4
5
6
7
8
9
10
11
12
13
static final class Index {
static final class Entry {
private final String key;

Entry(String key) {
this.key = key;
}

String key() {
return key;
}
}
}

Lambda 也可能捕获 this。注册到长寿命执行器或监听器后,检查捕获的对象以及取消注册的时机,不能只看有没有写内部类。

仍适用 Item 25:一个源文件保持一个主要顶层类型

Java 允许一个文件包含多个包可见顶层类型,但会让类型定位、增量编译和维护变得含混。工程上把主要顶层类型放进同名文件,紧密相关且不独立公开的辅助类型作为嵌套类型。

本文为了对照展示,部分片段把多个小类型放在一起;落地到项目时按可见性与职责拆分文件。不要把“示例省篇幅”当作项目结构规范。

泛型与类型安全:Item 26–33

仍适用 Item 26:不要使用原始类型

List 原始类型会绕开部分泛型检查,让问题推迟到读取元素时以 ClassCastException 出现。List<Object> 表示可存放任意对象的列表,List<?> 表示元素类型未知的列表,两者与原始类型不是一回事。

1
2
3
4
5
6
7
static int count(List<?> values) {
return values.size();
}

static void addMessage(List<String> messages, String value) {
messages.add(value);
}

不知道元素类型但只读通用信息时用 <?>;需要写入时表达具体类型或类型参数。兼容旧 API 不得不使用原始类型时,把边界局限在适配层。

仍适用 Item 27:消除 unchecked 警告

unchecked 警告意味着编译器无法完成类型安全证明,不是“代码一定没问题,只是编译器啰嗦”。先改成正确的泛型声明;只有能说明内部不变量时,才在最小作用域上用 @SuppressWarnings("unchecked")。

类型擦除使运行时一般无法区分 List<String> 与 List<Integer>,所以把 Object 强转为 List<String> 并不能验证元素。反序列化或框架返回未经验证的对象时,应逐个校验元素并创建新集合,不能靠压制警告建立信任。

仍适用 Item 28:泛型容器优先列表

数组协变且保留运行时组件类型;泛型不变,类型参数通常被擦除。数组允许 String[] 赋给 Object[],但写入整数时会失败;List<String> 则不能赋给 List<Object>,把这类错误挡在编译阶段。

1
2
3
4
5
6
7
8
static void arrayTrap() {
Object[] values = new String[1];
// values[0] = 42; // 执行会抛 ArrayStoreException
}

static List<String> names() {
return new ArrayList<>();
}

这不意味着一律禁止数组。二进制数据、固定大小数值存储和互操作接口仍适合 byte[]、int[];装箱列表未必适合性能敏感的数值计算。

仍适用 Item 29:让容器本身成为泛型类型

把“这个容器存放什么”写进类型,而不是要求每次取出都强转。使用泛型容器实现内部存储,可以减少自建 Object[] 时的 unchecked 边界。

1
2
3
4
5
6
7
8
9
10
11
static final class History<E> {
private final List<E> entries = new ArrayList<>();

void append(E entry) {
entries.add(Objects.requireNonNull(entry));
}

List<E> snapshot() {
return List.copyOf(entries);
}
}

类型参数只约束静态类型,不自动限制容量、保证非空或提供线程安全;这些仍由实现与契约负责。

仍适用 Item 30:独立算法优先泛型方法

工具方法在调用处建立输入与输出的类型关系,比返回 Object 再强转可靠。只有整类状态都围绕某个类型时,才把类型参数提升到类上。

1
2
3
4
5
6
static <T> T first(List<T> values) {
if (values.isEmpty()) {
throw new NoSuchElementException("empty list");
}
return values.getFirst();
}

getFirst() 在 Java 21 的有序集合 API 中提供统一入口;它不会替你处理空集合。对“正常情况下可能没有结果”的查询,也可按 Item 55 返回 Optional<T>。

仍适用 Item 31:用有界通配符描述输入输出

PECS 的含义是:提供 T 的来源用 ? extends T,消费 T 的目标用 ? super T。它描述的是当前操作方向,不是笼统的“这个对象是否可变”。同时读写同一具体类型的参数,通常直接使用 T。

1
2
3
4
5
6
7
static <T> void transfer(
Collection<? extends T> source,
Collection<? super T> target) {
for (T value : source) {
target.add(value);
}
}

这个方法允许把 List<Integer> 转移到 List<Number>。公开返回值尽量给调用方明确类型,不要为了显得灵活而处处返回带通配符的容器。

仍适用 Item 32:泛型与可变参数混用要证明安全

可变参数在调用处创建数组,而泛型参数不能完整地由数组运行时类型表达;两者混用可能产生堆污染。安全实现应只读取参数数组,不把不兼容值写进去,也不让数组逃逸。

1
2
3
4
5
6
7
8
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... groups) {
List<T> result = new ArrayList<>();
for (List<? extends T> group : groups) {
result.addAll(group);
}
return List.copyOf(result);
}

@SafeVarargs 是作者作出的保证,不会让危险实现变安全。若 API 不依赖可变参数,接收 List<List<T>> 一类结构往往更容易解释与验证。

仍适用 Item 33:异构容器把类型令牌放在键上

普通 Map<K, V> 只能统一约束值类型;需要按类型保存不同对象时,可以让 Class<T> 建立键与值的关系,并在读写边界使用 cast 校验。

1
2
3
4
5
6
7
8
9
10
11
12
static final class Registry {
private final Map<Class<?>, Object> values = new HashMap<>();

<T> void put(Class<T> type, T value) {
values.put(type, type.cast(
Objects.requireNonNull(value)));
}

<T> Optional<T> get(Class<T> type) {
return Optional.ofNullable(type.cast(values.get(type)));
}
}

Class<List> 不能表达 List<String> 与 List<Integer> 的区别。需要参数化类型令牌时,应采用专门的 Type 描述与相应校验机制;不要把 Class 键方案误认为能绕过类型擦除。

枚举与注解:Item 34–41

仍适用 Item 34:有限取值使用枚举

枚举让编译器阻止不同取值集合混用,也能把行为放到对应常量上。业务中的状态、方向与策略集合有限时,比散落的整数或字符串更可靠。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Operation {
ADD {
@Override double apply(double a, double b) {
return a + b;
}
},
MULTIPLY {
@Override double apply(double a, double b) {
return a * b;
}
};

abstract double apply(double a, double b);
}

外部协议仍需要显式解码:未知状态应按协议选择拒绝、保留原值或映射到 UNKNOWN,而不是假定所有输入都能交给 Enum.valueOf。

仍适用 Item 35:不要把 ordinal 当业务值

ordinal() 反映声明顺序;插入或重排常量就会改变数值。数据库值、协议编号和显示顺序应使用显式字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Status {
CREATED(10), PAID(20), CANCELLED(90);

private final int code;

Status(int code) {
this.code = code;
}

int code() {
return code;
}
}

显式编码也需要唯一性检查与未知值处理。枚举名称同样不是天然稳定的协议:如果可能重命名常量,也应独立定义线上的文本编码。

仍适用 Item 36:枚举组合使用 EnumSet

位标志把类型、取值范围和组合操作都压成整数。EnumSet 用类型安全 API 表示枚举集合,同时保留紧凑表示与高效集合运算。

1
2
3
4
5
6
enum Permission { READ, WRITE, DELETE }

static Set<Permission> readerPermissions() {
return Collections.unmodifiableSet(
EnumSet.of(Permission.READ));
}

EnumSet 本身可变,跨边界返回时需要明确所有权或创建只读副本。它不能替代外部协议既定的位编码;转换应该集中在协议适配层。

仍适用 Item 37:枚举索引用 EnumMap

以枚举为键的映射优先 EnumMap,避免数组下标与 ordinal() 绑定。缺少的映射也能直接通过键表达,而不必猜某个数组位置属于哪个状态。

1
2
3
4
5
6
7
8
9
enum Phase { START, RUN, STOP }

static Map<Phase, Duration> phaseTimeouts() {
Map<Phase, Duration> result = new EnumMap<>(Phase.class);
result.put(Phase.START, Duration.ofSeconds(5));
result.put(Phase.RUN, Duration.ofSeconds(30));
result.put(Phase.STOP, Duration.ofSeconds(2));
return Collections.unmodifiableMap(result);
}

是否每个枚举都必须有值,是业务不变量,需要在构建时验证。EnumMap 不保证线程安全,与不可变发布或同步策略一起使用。

仍适用 Item 38:用接口表达可扩展的枚举能力

枚举的常量集合固定,不能通过继承添加常量。希望多个枚举提供同一种能力时,让它们实现接口;调用方依赖能力,而不是某个枚举的具体常量集合。

1
2
3
4
5
6
7
8
9
10
11
12
interface TextTransform {
String apply(String text);
}

enum BasicTransform implements TextTransform {
STRIP;

@Override
public String apply(String text) {
return text.strip();
}
}

开放插件适合接口;需要穷尽匹配的封闭数据分支适合 sealed。不能同时期待“第三方任意扩展”和“编译器知道全部实现”。

仍适用 Item 39:使用注解表达元数据

靠方法名以 test 开头、字段名包含某个标记来识别行为,重命名就可能悄悄改变结果。注解把元数据与名字分离,适用位置和保留期也能显式声明。

1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Audited {
String action();
}

注解本身不会执行审计;编译处理器、框架或反射代码负责解释它。自行设计注解前,应先确认现有框架是否已有合适的契约,避免制造没有消费者的装饰。

仍适用 Item 40:覆盖方法时加 Override

@Override 让编译器检查“覆盖”是否真的发生,能抓出参数类型错误导致的意外重载。实现接口方法时也应使用,避免接口升级后悄悄留下无效方法。

1
2
3
4
5
6
record Label(String text) {
@Override
public String toString() {
return text;
}
}

仍适用 Item 41:需要类型参与检查时使用标记接口

标记接口定义一个类型,能出现在泛型约束和方法签名中;标记注解主要承载工具读取的元数据,还可以标记方法、字段等。需要调用方在编译阶段只能传入某类对象时,接口更合适。

1
2
3
4
5
6
7
interface Exportable {}
record Report(String title) implements Exportable {}

static <T extends Exportable> List<T> exportBatch(
Collection<T> values) {
return List.copyOf(values);
}

不要为了一个框架扫描功能就创建空接口,也不要用注解代替真正需要编译期约束的类型契约。Serializable 是标记接口的例子,但使用它的风险需单独阅读 Item 85–90。

Lambda、Stream 与执行方式:Item 42–48

仍适用 Item 42:函数对象优先 Lambda

单个抽象方法的行为用 Lambda 更简洁。需要多个方法、独立状态或自己的对象身份时,匿名类或具名类仍有价值。Lambda 中的 this 指向外围实例,匿名类的 this 指向匿名类实例,这个差异会改变捕获与调用行为。

1
2
3
4
5
static List<String> sortedNames(List<String> names) {
List<String> result = new ArrayList<>(names);
result.sort((a, b) -> a.compareToIgnoreCase(b));
return List.copyOf(result);
}

Lambda 过长时,把行为提取成具名方法,错误处理和业务含义会更清楚。不要依赖 Lambda 实例是否被缓存,也不要把其实现类当成稳定协议。

仍适用 Item 43:方法引用在更清楚时优先

方法引用能省去只转发参数的 Lambda,但目标方法的名字必须足以说明操作。如果引用让接收者、参数对应或重载选择更难理解,保留 Lambda。

1
2
3
4
5
static List<Integer> parseIds(List<String> ids) {
return ids.stream()
.map(Integer::parseInt)
.toList();
}

绑定接收者的 service::run 会保留该接收者;需要长时间保存函数对象时,要把这份引用纳入生命周期分析。

仍适用 Item 44:优先标准函数式接口

转换用 Function<T, R>,条件用 Predicate<T>,供应用 Supplier<T>,副作用用 Consumer<T>。数值热点考虑 IntFunction、ToLongFunction 等原始类型接口,减少不必要装箱。

1
2
3
4
static List<String> select(
List<String> values, Predicate<String> predicate) {
return values.stream().filter(predicate).toList();
}

当行为有重要领域含义、特殊异常契约或标准接口无法表达的参数结构时,可以定义自己的函数式接口并加 @FunctionalInterface。标准接口没有检查所有业务约束的能力。

仍适用 Item 45:审慎使用 Stream

Stream 适合筛选、转换、聚合等数据流水线。涉及复杂控制流、需要多个局部状态、频繁提前返回或受检异常时,普通循环通常更直接。Stream 是一次性、通常惰性执行的计算描述,不是可以重复遍历的容器。

1
2
3
4
5
6
static int totalLength(List<String> values) {
return values.stream()
.filter(s -> !s.isBlank())
.mapToInt(String::length)
.sum();
}

String.chars() 流出 UTF-16 代码单元,不是完整 Unicode 码点;按码点处理应使用 codePoints(),用户眼中的字符还可能由多个码点组成。语法整齐不能替代对数据语义的判断。

仍适用 Item 46:流水线函数尽量无副作用

把结果放进 Collector,而不是从 forEach 修改外部列表。无副作用操作更容易重排与并行,也不会依赖流实现具体执行了多少次某个中间操作。

1
2
3
4
static Map<String, Long> frequencies(List<String> words) {
return words.stream().collect(Collectors.groupingBy(
Function.identity(), Collectors.counting()));
}

peek 适合辅助诊断,不应承担审计、写数据库等必须执行的业务动作;优化可能使部分中间操作不发生。toMap 遇到重复键需要明确冲突策略,不能依赖运行时偶然没有重复。

需更新 Item 47:重复消费的序列优先返回集合

小规模、已经物化、需要反复遍历的数据,返回 List<T> 通常更方便。巨量数据、懒加载或资源驱动的序列则应按访问方式选择 Stream、迭代器或分页接口,并说明一次性消费与关闭责任。

1
2
3
4
5
6
static List<String> normalizedNames(List<String> names) {
return names.stream()
.map(String::strip)
.filter(s -> !s.isEmpty())
.toList();
}

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
2
3
4
5
static String slice(String text, int from, int to) {
Objects.requireNonNull(text, "text");
Objects.checkFromToIndex(from, to, text.length());
return text.substring(from, to);
}

这里索引按 UTF-16 代码单元定义,不保证切分点是完整码点。对懒执行 API,还要明确参数是在方法调用时还是消费结果时校验;调用方不能把“创建流成功”理解成“所有元素都有效”。

需更新 Item 50:在可变状态边界做防御性复制

输入与输出两侧都可能泄露可变状态。数组要复制,嵌套可变对象要逐层考虑;对时间优先使用不可变的 Instant、LocalDate 等类型,减少 Date 的复制负担。

1
2
3
4
5
6
7
8
9
10
record Packet(byte[] payload) {
Packet {
payload = payload.clone();
}

@Override
public byte[] payload() {
return payload.clone();
}
}

这个例子只保护数组所有权,record 默认的 equals 与 hashCode 仍按数组对象比较;需要内容相等时,应另行设计值类并使用 Arrays.equals、Arrays.hashCode。复制也不能从另一个线程正在修改的数据中自动得到一致快照,源数据仍需要同步或所有权转移。

仍适用 Item 51:方法签名应该帮助调用者避免混淆

参数数量要少,同类型参数尤其容易颠倒。用值对象表达单位与角色,集合参数依赖所需接口,避免用多个布尔值表达互斥模式。

1
2
3
4
record SourceAccount(String value) {}
record TargetAccount(String value) {}
record Transfer(SourceAccount from, TargetAccount to,
BigDecimal amount) {}

真实入口应校验账号、金额与币种;这里强调不同角色不能互换。不要把所有字段都包装成类型,优先保护单位、权限、身份与业务方向这些高代价误用点。

仍适用 Item 52:谨慎重载,理解编译期选择

重载根据编译期类型选择签名;覆盖才根据运行时接收者选择实现。因此集合里的对象实际是什么,不会使重载自动切到“更具体”的参数版本。

1
2
3
4
5
6
7
8
9
10
11
12
static String classify(Set<?> value) {
return "set";
}

static String classify(Collection<?> value) {
return "collection";
}

static String demo() {
Collection<String> values = new HashSet<>();
return classify(values); // 返回 collection
}

避免设计同参数数量、依赖装箱或不相关引用类型区分的重载,尤其是调用方可能传 null、Lambda 或方法引用时。方法名能说明不同动作时,直接命名比让编译器猜分支更清楚。

仍适用 Item 53:可变参数要表达最小参数数量

min(int... values) 允许空调用,若语义要求至少一个值,应把首值单独放进签名。可变参数每次调用通常需要一个数组;高频热点才有必要进一步考虑固定参数重载。

1
2
3
4
5
6
7
static int min(int first, int... rest) {
int result = first;
for (int value : rest) {
result = Math.min(result, value);
}
return result;
}

可变参数不意味着接管调用方传入的数组。需要保存它时必须明确复制或所有权协议,不能在调用方不知情的情况下保留并修改数组。

仍适用 Item 54:没有元素时返回空容器

查询结果“有零个元素”是正常集合状态,应返回空集合或空数组,使调用方能直接遍历。null 应留给另有明确契约的状态,不能把“没有元素”与“查询未执行”混在一起。

1
2
3
4
5
6
static List<String> activeNames(List<String> names) {
if (names.isEmpty()) {
return List.of();
}
return List.copyOf(names);
}

空结果也要保持可变性契约一致。若非空结果允许调用方添加元素,空结果也应返回可变空容器,不能只在有数据时支持修改。

仍适用 Item 55:审慎返回 Optional

Optional<T> 适合返回“查询可能没有一个结果”。无结果是预期分支时比抛异常更自然;集合通常直接返回空集合,避免 Optional<List<T>> 把状态增到没有业务意义的三种。

1
2
3
4
5
6
7
8
9
10
static Optional<String> firstNonBlank(List<String> values) {
return values.stream()
.filter(s -> !s.isBlank())
.findFirst();
}

static String displayName(List<String> values,
Supplier<String> fallback) {
return firstNonBlank(values).orElseGet(fallback);
}

orElse 的参数会提前求值,orElseGet 才按需执行供应函数。不要让返回 Optional 的方法再返回 null;参数与实体字段通常用其他方式表达可选性,确实使用 Optional 时要确认框架和序列化边界。数值返回可考虑 OptionalInt 等类型。

仍适用 Item 56:文档写出契约,而不只复述实现

公开方法至少说明参数单位、返回值的可变性与所有权、失败条件,以及阻塞、线程安全和关闭责任。泛型类型参数、继承扩展点与枚举含义也属于契约。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 读取第一行文本,不包含换行符。
*
* @param path UTF-8 文本文件路径
* @return 第一行;空文件返回 null
* @throws IOException 打开、读取或关闭文件失败
*/
static String readHeader(Path path) throws IOException {
try (var reader = Files.newBufferedReader(
path, StandardCharsets.UTF_8)) {
return reader.readLine();
}
}

这里保留了 JDK 读取方法的空文件语义并写清楚;若业务接口希望消除 null,可以在外层转成 Optional。文档不能靠“永不返回 null”等口号覆盖实际实现。

日常编程与性能:Item 57–68

仍适用 Item 57:缩小局部变量作用域

在即将使用时声明并初始化变量,减少错误复用与跨分支状态。循环变量放在循环头;复杂方法优先按行为拆分,而不是让几十个变量一起存活到方法末尾。

1
2
3
4
5
6
7
static long total(List<Integer> values) {
long result = 0;
for (int value : values) {
result += value;
}
return result;
}

var 不改变静态类型,也不要求到处使用。右侧无法清楚说明类型,或接口类型对阅读有帮助时,显式声明更好。

仍适用 Item 58:不需要索引时使用 for-each

只消费元素就用增强 for,避免手工维护索引与迭代器。需要删除当前元素、并行遍历多个序列或依赖位置时,再选专门操作或显式循环。

1
2
3
static void removeBlank(List<String> values) {
values.removeIf(String::isBlank);
}

不要在普通 for-each 中直接结构性修改正在遍历的集合。ConcurrentModificationException 是尽力检测误用的机制,不是并发安全保证,代码正确性不能依赖它一定抛出。

需更新 Item 59:熟悉标准库,再决定是否自己实现

日期时间优先 java.time,资源读写考虑 NIO,随机数、集合和并发结构先找已有实现。库通常已经处理边界与平台差异,但仍要读具体契约,例如加法是否检测溢出、比较器如何处理 null。

1
2
3
4
5
6
7
static int boundedRandom(int bound) {
return ThreadLocalRandom.current().nextInt(bound);
}

static long checkedTotal(long a, long b) {
return Math.addExact(a, b);
}

这里随机数适合普通随机选择,不能用于令牌或密钥;那类需求使用 SecureRandom。Java 21 有序集合、Java 24 Stream Gatherers 等工具提供了新选择,采用前确认项目基线与任务是否真的需要。

仍适用 Item 60:精确十进制计算不用 float 或 double

二进制浮点无法精确表示很多十进制小数。金额可以用带币种的最小单位整数,或 BigDecimal 配合明确的 scale、舍入模式与计算规则;BigDecimal 并不会替业务决定如何舍入。

1
2
3
4
5
6
7
8
9
static BigDecimal gross(BigDecimal net, BigDecimal rate) {
return net.multiply(BigDecimal.ONE.add(rate))
.setScale(2, RoundingMode.HALF_UP);
}

static BigDecimal example() {
return gross(new BigDecimal("19.90"),
new BigDecimal("0.06"));
}

字符串构造器表达精确十进制,new BigDecimal(0.1) 会带入该 double 的二进制近似值。除法可能是无限小数,需要明确精度或舍入;最小单位整数也要防溢出,并承认不同币种的小数位可能不同。

仍适用 Item 61:数值运算优先基本类型

包装类型引入可空性、对象身份和装箱成本。比较两个 Integer 的 == 是引用比较,缓存可能让某些小值“看起来正确”;拆箱 null 会抛 NullPointerException。

1
2
3
4
5
6
7
8
static long sum(List<Long> values) {
long result = 0;
for (Long value : values) {
result = Math.addExact(result,
Objects.requireNonNull(value));
}
return result;
}

泛型集合需要包装类型,但局部累加器应使用基本类型。确实需要缺失状态时,先决定用 null、Optional 数值类型还是独立业务状态,而不是靠隐式拆箱碰运气。

仍适用 Item 62:不要用字符串代替所有领域类型

字符串适合文本,不适合同时承担状态、数量、身份和结构化协议。把毫秒写成 String、用逗号拼接多个字段,会失去类型检查,并引入转义与格式演化问题。

1
2
record RequestId(UUID value) {}
record RetryPolicy(int attempts, Duration delay) {}

HTTP 与文件里的字符串应在边界解析成领域类型,再进入业务流程。类型包装只有在校验或防混淆上有价值时才引入;字段名本身已经足够说明语义的临时文本无须过度建模。

仍适用 Item 63:循环拼接字符串使用累积器

循环中反复 result = result + part 会不断复制已有内容,输入增长时总复制量可能接近平方级。单个短表达式中的 + 通常清楚且能被编译器优化,无需机械替换。

1
2
3
4
5
6
7
static String joinLines(List<String> lines) {
StringBuilder result = new StringBuilder();
for (String line : lines) {
result.append(line).append('\n');
}
return result.toString();
}

固定分隔符且无复杂格式时也可以用 String.join。输出很大时直接写入 Writer,避免先在内存里生成完整字符串再复制一遍。

仍适用 Item 64:引用类型围绕所需能力声明

变量与参数通常声明为 List、Map 等接口,使实现可以替换。构造时仍选择具体实现;依赖排序、导航或有序首尾操作时,应声明能表达这些能力的接口。

1
2
3
4
5
6
static NavigableMap<Integer, String> orderedLabels() {
NavigableMap<Integer, String> labels = new TreeMap<>();
labels.put(10, "low");
labels.put(20, "high");
return labels;
}

HashMap 换成 TreeMap 不是仅换实现:键的相等判定与迭代顺序可能改变。依赖具体能力而标准接口无法表达时,具体类型也是合理选择。

仍适用 Item 65:把反射限制在动态边界

插件发现与框架装配可能需要反射,但完成构造后应尽量回到普通接口调用。反射会把成员名、可见性与类型错误推迟到运行时,也更难与模块封装、静态分析及提前编译协作。

1
2
3
4
5
6
7
8
9
interface Codec {
String encode(String value);
}

static List<Codec> discoverCodecs() {
return ServiceLoader.load(Codec.class).stream()
.map(ServiceLoader.Provider::get)
.toList();
}

服务发现需要配套 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
2
3
4
5
6
7
static OptionalInt lookup(Map<String, Integer> values,
String key) {
Integer value = values.get(key);
return value == null
? OptionalInt.empty()
: OptionalInt.of(value);
}

库只提供异常式解析入口时,在边界捕获并转换成领域失败是合理适配。避免为了不出现异常而返回合法范围内的魔法值,使真正失败无法区分。

仍适用 Item 70:按调用方恢复义务选择异常类型

受检异常把处理义务放进方法签名,适合调用者必须明确面对的可恢复失败;运行时异常适合违反前置条件、非法状态等错误。是否可恢复取决于调用层,而不是简单地把“网络全部受检、业务全部运行时”作为分类规则。

1
2
3
4
5
6
7
static String requireName(String name) {
Objects.requireNonNull(name, "name");
if (name.isBlank()) {
throw new IllegalArgumentException("blank name");
}
return name;
}

Error 通常不是普通业务恢复入口,不应随手包装成业务失败。异常层次还要能承载诊断信息,避免只留下一句笼统的“操作失败”。

仍适用 Item 71:受检异常应有明确收益

如果调用方只能反复 catch 后原样包装,受检异常可能没有增加处理价值。偶发缺失可用 Optional,有限且需要正常分支处理的业务结果可用 sealed 结果类型;底层 I/O 失败仍应保留原因与清晰错误边界。

1
2
3
sealed interface BookingResult permits Booked, SoldOut {}
record Booked(String bookingId) implements BookingResult {}
record SoldOut() implements BookingResult {}

不要把所有异常都塞进 Result,再要求每层代码机械传递。选择返回值还是异常,核心在于调用方是否应把这个状态作为正常控制流处理。

仍适用 Item 72:优先使用语义匹配的标准异常

参数非法用 IllegalArgumentException,状态不允许用 IllegalStateException,索引非法用 IndexOutOfBoundsException,无元素用 NoSuchElementException,不支持操作用 UnsupportedOperationException。

不能为了省一个类,把所有业务失败都当作 IllegalStateException。只有调用方需要按领域失败分别处理,或需要专门诊断字段时,再引入自己的异常类型;名字与继承层次应帮助处理者区分失败。

仍适用 Item 73:异常跨抽象边界时转换,并保留原因

上层不应被迫理解底层库的每一种异常。转换异常时保留 cause,使调用者看到符合当前抽象的失败,诊断者仍能追到根因。

1
2
3
4
5
6
7
8
9
10
11
12
13
static final class ConfigException extends RuntimeException {
ConfigException(String message, Throwable cause) {
super(message, cause);
}
}

static String loadConfig(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException cause) {
throw new ConfigException("cannot load config", cause);
}
}

转换前确定调用方是否还需要受检处理义务。日志一般由真正处理失败或终止请求的边界记录,避免每一层“记录后再抛”导致同一次失败刷出多份堆栈。

仍适用 Item 74:写清方法会怎样失败

为受检异常写 @throws,也说明由参数、状态和调用顺序触发的运行时异常。接口尤其要描述调用方可依赖的失败语义,不应把某个实现偶然抛出的全部底层类型列为长期契约。

并发 API 还应说明中断、超时、取消的区别:超时返回是否会取消任务,取消是否会停止底层 I/O,关闭是否等待任务结束。调用方的重试与资源释放往往依赖这些差别。

仍适用 Item 75:异常信息帮助复现,同时避免泄密

信息应包含有用的边界、操作类型和相关标识,而不是只写 invalid。敏感原始输入、凭据、完整请求体和 SQL 参数不应无条件进入异常或日志。

1
2
3
4
5
6
static void checkLimit(int limit) {
if (limit < 1 || limit > 1_000) {
throw new IllegalArgumentException(
"limit out of range: " + limit + " (1..1000)");
}
}

机器处理优先独立错误码与结构化字段,不解析自然语言 message。异常消息与对外错误响应可以有不同详细程度,由服务边界进行映射。

仍适用 Item 76:失败后对象应保持有效状态

失败原子性通常要求操作失败后对象保持操作前状态,至少也要明确哪些状态已经提交。先校验与计算,再一次性修改状态,比“改一半再尝试补救”容易推理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static final class Balance {
private long cents;

Balance(long cents) {
if (cents < 0) {
throw new IllegalArgumentException("negative balance");
}
this.cents = cents;
}

void credit(long amount) {
if (amount < 0) {
throw new IllegalArgumentException("negative credit");
}
long next = Math.addExact(cents, amount);
cents = next;
}

long cents() {
return cents;
}
}

溢出发生时赋值尚未执行,余额保持原状。这个类没有并发保证;跨数据库与远程服务的多步操作还需要事务、幂等或补偿协议,局部对象不变不能证明外部副作用没有发生。

仍适用 Item 77:不要丢弃异常的处理义务

捕获异常后要恢复、转换、传播,或在有明确理由时记录并终止;空 catch 会把失败伪装成成功。允许忽略的异常必须有局部说明,并能证明对当前契约无影响,不要求每个 catch 都打印日志。

1
2
3
4
5
6
7
8
9
static boolean pause(Duration duration) {
try {
Thread.sleep(duration);
return true;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return false;
}
}

这里不能继续向上抛中断,就恢复标记并让调用方看到失败;调用方也必须根据返回值停止或调整操作。若签名允许,直接传播 InterruptedException 通常更清楚。

并发、发布与任务生命周期:Item 78–84

仍适用 Item 78:共享可变状态需要同步

同步既用于互斥,也用于可见性与顺序。volatile 写与后续读建立相应的 happens-before 关系,但不能把 count++ 这种读、改、写组合变成原子操作。

1
2
3
4
5
6
7
8
9
10
11
static final class Counter {
private final AtomicLong value = new AtomicLong();

long increment() {
return value.incrementAndGet();
}

long current() {
return value.get();
}
}

多个字段需要一起保持不变量时,单独把每个字段改成原子类型还不够,应使用同一把锁或不可变快照替换。LongAdder 适合高争用统计,但其 sum() 不是与并发更新严格一致的瞬时快照;不能直接拿它实现精确余额。

正确构造的 final 字段有初始化安全保证,但前提包括构造期间不让 this 逃逸。final 引用指向的可变内容仍需要同步;更换到虚拟线程也不会改变这些规则。

需更新 Item 79:缩小锁范围,不在锁内调用外部代码

锁保护必要的状态变化,不应顺带包住网络 I/O、未知回调或昂贵计算。外部调用可能阻塞、重入或按不同顺序获取其他锁,导致吞吐下降甚至死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final class EventBus {
private final List<Consumer<String>> listeners =
new ArrayList<>();

synchronized void add(Consumer<String> listener) {
listeners.add(Objects.requireNonNull(listener));
}

void publish(String event) {
List<Consumer<String>> snapshot;
synchronized (this) {
snapshot = List.copyOf(listeners);
}
for (var listener : snapshot) {
listener.accept(event);
}
}
}

这个快照允许发布时新注册的监听器等到下一次事件,也未保证并发发布的顺序;回调失败是否阻止后续监听器,需要另外定义策略。读多写少的注册表也可考虑 CopyOnWriteArrayList,但写入会复制数组。

Java 21 中,某些 synchronized 区域内的阻塞会把虚拟线程固定在载体线程上;Java 24 的 JEP 491 已消除这类监视器导致的固定。不要把旧版本的“统一改成 ReentrantLock”当作永久规则:Java 25 仍需留意本地调用等固定情形,同时控制锁持有时间。见 JDK 24 变更与虚拟线程说明。

需更新 Item 80:任务抽象仍优先,执行策略按负载选择

业务描述 Runnable 或 Callable,执行器负责调度与生命周期。CPU 密集型任务使用受核数与队列预算约束的执行策略;大量阻塞 I/O 可以使用 Java 21 正式提供的虚拟线程,每个任务一个虚拟线程,不再通过池化虚拟线程来限制资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
static <T> T runTask(Callable<T> task)
throws InterruptedException, ExecutionException,
TimeoutException {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
Future<T> future = executor.submit(task);
try {
return future.get(2, TimeUnit.SECONDS);
} finally {
future.cancel(true);
}
}
}

这个例子展示等待超时后发出取消请求;它不保证两秒内返回,因为关闭执行器会等待任务结束,中断也只是协作请求。任务必须支持取消,网络和数据库操作还应配置自己的超时。应用级执行器一般在服务启动时创建、关闭时统一收束,避免每个普通方法自行创建。

虚拟线程减少等待时占用的平台线程,并不增加数据库连接数、远端配额或可用内存。用连接池或信号量约束稀缺资源,再给入口设置总量、排队和拒绝预算,避免无限提交任务。

1
2
3
4
5
6
7
8
9
static <T> T withPermit(Semaphore permits, Callable<T> task)
throws Exception {
permits.acquire();
try {
return task.call();
} finally {
permits.release();
}
}

这是资源并发上限,不是完整的流量控制:等待许可的任务仍占内存。CompletableFuture 适合异步结果组合,但 orTimeout 与 cancel 不自动停止底层任务,cancel(true) 也不通过它中断计算;需要在任务协议中单独安排取消传播。相关契约见 CompletableFuture API。

结构化并发进一步把子任务纳入父任务作用域,但 Java 25 的 StructuredTaskScope 仍为预览 API,而且该版本的 API 已不同于早期示例。需要在项目明确接受预览版本后采用,不能直接把旧版 ShutdownOnFailure 代码当作 Java 25 正式接口。参见结构化并发文档。

需更新 Item 81:优先并发工具,不手写等待协议

生产者与消费者用 BlockingQueue,一次性等待用 CountDownLatch,资源预算用 Semaphore,共享映射用 ConcurrentHashMap。这些工具封装了唤醒、可见性和部分状态管理,但业务的多步原子性仍需自己表达。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static final class Metrics {
private final ConcurrentMap<String, LongAdder> counts =
new ConcurrentHashMap<>();

void record(String name) {
counts.computeIfAbsent(name, key -> new LongAdder())
.increment();
}

long count(String name) {
LongAdder counter = counts.get(name);
return counter == null ? 0 : counter.sum();
}
}

这里假定键集合有业务边界且不会并发移除计数器,否则还需要清理与移除协议。computeIfAbsent 的函数应短小,不进行远程调用,也不递归更新同一映射。多个线程安全方法拼在一起并不自动形成一个原子业务操作。

Java 25 正式提供 ScopedValue,适合沿调用链传递只在词法作用域内绑定的请求上下文。它不是任意可变 ThreadLocal 的通用替代品,也不会把可变引用对象变成不可变。

1
2
3
4
5
6
7
8
9
10
private static final ScopedValue<String> REQUEST_ID =
ScopedValue.newInstance();

static void serve(String requestId, Runnable handler) {
ScopedValue.where(REQUEST_ID, requestId).run(handler);
}

static String requestId() {
return REQUEST_ID.get();
}

此片段需要 Java 25。 普通执行器提交的任务不能假定自动继承当前绑定;跨任务传播要符合具体并发工具的契约。若使用 ThreadLocal,仍应理解其线程生命周期并在必要时 remove()。参见 ScopedValue API。

仍适用 Item 82:把线程安全级别写进契约

说明对象是不可变、内部同步、需要外部同步,还是仅允许单线程使用;同时说明哪些复合操作原子、迭代看到什么,以及是否允许并发关闭。仅写“线程安全”不足以推断全部组合行为。

Collections.synchronizedList 的单个操作有同步,但遍历通常仍需要按其契约锁住包装对象。服务方法内部使用并发集合,也不代表整个服务业务事务是原子的。虚拟线程数量更多,这些边界反而更重要。

仍适用 Item 83:惰性初始化只在确有收益时采用

普通实例依赖优先在构造时建立,逻辑最简单。静态、昂贵且不总使用的对象,可以通过 holder 惯用法利用类初始化的线程安全保证。

1
2
3
4
5
6
7
8
9
10
11
12
static final class Parser {
private Parser() {}

private static final class Holder {
static final Pattern VALUE =
Pattern.compile("[A-Z]{2}-[0-9]{4}");
}

static Pattern pattern() {
return Holder.VALUE;
}
}

实例级双重检查需要 volatile 以及正确的局部引用和锁顺序;没有测出收益时,直接同步或提前初始化更容易维护。类初始化失败也不是每次访问重新尝试,需要可重试加载的配置应另外建模。

仍适用 Item 84:正确性不能依赖调度运气

sleep、yield、线程优先级不能建立“另一线程已经更新状态”的保证。通过 Future、队列、锁、闩锁等显式协调;忙等会浪费 CPU,虚拟线程也不让忙等变廉价。

并发测试等待明确事件并设置超时,避免“睡 100 毫秒以后应该完成”。超时失败只是观察到系统没有及时满足条件,不能单独证明死锁;还需要线程状态和资源等待证据。

序列化与长期兼容:Item 85–90

仍适用 Item 85:优先选择 Java 原生序列化以外的协议

ObjectInputStream 重建的是对象图,还可能触发类中的恢复逻辑,风险不只是读取几个字段。跨服务通信与持久数据优先明确的 DTO 加 JSON、Protobuf 等协议,再做字段、长度、版本和业务校验;替代格式也需要安全配置,特别是多态类型解析。

必须兼容遗留流时,在读取前配置类白名单和对象图限制;不能把过滤器视为允许接收任意不可信对象流的通行证。官方机制见序列化过滤说明。

1
2
3
4
5
6
7
8
9
10
static Object readLegacy(InputStream input)
throws IOException, ClassNotFoundException {
try (var stream = new ObjectInputStream(input)) {
var filter = ObjectInputFilter.Config.createFilter(
"maxdepth=10;maxrefs=1000;maxbytes=1048576;"
+ "maxarray=10000;com.example.LegacyMessage;!*");
stream.setObjectInputFilter(filter);
return stream.readObject();
}
}

这里的类名只是策略占位,真实白名单必须覆盖经过审查的完整对象图,并验证根对象类型;该方法还接管并关闭输入流。过滤器不是每读一个字节都回调,字符串也有特殊处理,因此 maxbytes 不能代替输入层严格的字节上限。字段校验、传输限制与对象过滤分别处理不同边界。规则语法见 ObjectInputFilter.Config API。

仍适用 Item 86:实现 Serializable 等于增加一份长期契约

implements Serializable 不只是标记;默认形式会把字段布局与类结构绑定到持久字节流,也引入额外对象创建入口。给公共类或可继承类增加这个能力时,必须承担兼容、安全和测试成本。

普通可序列化类显式定义 serialVersionUID,使用 @Serial 检查相关声明;UID 一致只满足部分版本检查,不代表字段变化就业务兼容。不要因为某个容器或框架支持序列化,就给全部领域对象自动添加该接口。

仍适用 Item 87:序列化逻辑状态,不固化偶然实现

缓存、链表节点、锁与派生索引属于实现细节;稳定协议应表达业务数据,恢复时重新建立派生结构。默认字段序列化可能把这些内部结构永久写进兼容约束。

在原生序列化中,transient 可排除字段,但排除后就需要明确重建方案,构造函数初始化的值也不会自动按普通创建流程补齐。自定义 writeObject/readObject 必须成对设计并跨版本测试;对于新持久格式,显式 DTO 和独立版本号通常更容易演化。

需更新 Item 88:把反序列化视为另一个构造入口

普通 Serializable 类的恢复过程不会执行该类的正常构造函数,输入流可以绕过构造时的检查。因此 readObject 要验证不变量,复制可变状态,并避免调用可覆盖方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static final class LegacyRange implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

private int from;
private int to;

LegacyRange(int from, int to) {
if (from > to) {
throw new IllegalArgumentException("invalid range");
}
this.from = from;
this.to = to;
}

@Serial
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
if (from > to) {
throw new InvalidObjectException("invalid range");
}
}
}

record 是重要例外:其原生反序列化通过规范构造函数恢复组件,因此可以复用构造校验;record 的 readObject、writeObject 等定制钩子也不像普通类那样工作。这改善了创建不变量,却不自动消除组件对象图、资源耗尽或协议演化风险。参见 record 序列化规则。

仍适用 Item 89:需要序列化实例控制时优先枚举

普通单例的 readResolve 要处理恢复时的额外实例和对象图引用,设计容易遗漏。枚举由序列化机制特殊处理,可以恢复为对应枚举常量,适合真正需要序列化的固定单例或有限实例集合。

1
2
3
enum EmptyState {
INSTANCE
}

它解决的是同一枚举类型中的实例身份,不是分布式唯一性。枚举原生序列化也不会按普通类的方式保存、恢复自定义实例字段;不要把带可变业务状态的枚举当作持久化容器。

仍适用 Item 90:遗留值类考虑序列化代理

代理把字节流对应的状态放在小型专用对象里,再通过目标类正常构造函数恢复;目标类拒绝直接 readObject。这样恢复路径能复用正常构造校验,避免直接写入私有字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static final class Money implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

private final long cents;

Money(long cents) {
if (cents < 0) {
throw new IllegalArgumentException("negative amount");
}
this.cents = cents;
}

long cents() {
return cents;
}

@Serial
private Object writeReplace() {
return new Proxy(cents);
}

@Serial
private void readObject(ObjectInputStream input)
throws InvalidObjectException {
throw new InvalidObjectException("proxy required");
}

private record Proxy(long cents) implements Serializable {
@Serial
private Object readResolve() throws ObjectStreamException {
try {
return new Money(cents);
} catch (IllegalArgumentException cause) {
var failure = new InvalidObjectException(
"invalid amount");
failure.initCause(cause);
throw failure;
}
}
}
}

代理适合不支持任意继承的值类型,不适合直接套用到复杂循环对象图。示例只存最小单位金额,真实货币类型还要绑定币种。对于新跨进程协议,优先 Item 85 的显式数据格式;代理是在必须保留原生序列化时降低设计风险的工具。

现代检查清单

90 条建议可以压缩成下面的工程检查入口;遇到边界问题,再回到对应条款阅读机制与例外。

#现在优先检查什么对应条款
1创建入口有语义,复杂参数用 Builder,统一校验不变量1、2、49
2时间、随机源与外部服务通过依赖传入5
3资源用 try-with-resources,关闭责任明确8、9
4相等、哈希与排序契约一致,键状态稳定10、11、14
5record 的可变组件复制,敏感组件脱敏12、17、50
6封装可变表示,扩展行为优先组合15、16、18、19
7封闭分支用 sealed,开放能力用接口20、23、38
8不使用原始类型,unchecked 只留在可证明的窄边界26、27
9泛型 API 按输入输出使用 PECS29–32
10枚举编码独立于 ordinal,集合用 EnumSet/EnumMap34–37
11Stream 无必要副作用,结果可变性写清楚45–47
12并行化先匹配负载,再测真实吞吐与尾延迟48、67、80
13空集合表示零元素,Optional 表示可缺失单值54、55
14金额明确精度、舍入与币种,整数计算考虑溢出60、61
15异常跨层保留 cause,失败后状态有效,中断不丢弃73、76、77
16共享状态说明可见性、原子性和发布协议78、82
17锁内不执行未知回调或长时间 I/O79
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 模式、模式 switchJava 21解构与穷尽分支检查
有序集合、虚拟线程Java 21首尾访问与阻塞 I/O 执行策略
FFM API、Stream GatherersJava 22、24本地互操作与流中间操作扩展
synchronized 导致的虚拟线程固定问题修复Java 24不再沿用 Java 21 的固定规避规则
ScopedValueJava 25作用域内的上下文绑定
StructuredTaskScopeJava 25 仍为预览API 与采用策略需单独确认

条款编号按第三版,版本边界按上述 JDK 的官方规范核对;不根据更高版本推断同名 API 的行为。继续查阅可使用以下入口:

现代 Java 减少了需要手写的代码,但不会自动决定对象身份、资源所有权、线程安全和长期兼容。逐条重读的重点,是让语言工具承担它已经能保证的部分,把剩下的业务契约写清楚并验证。