← 返回题目列表

Builder 对象本身是线程安全的吗?能不能复用?

高频 困难 第 15 / 26 题 更新于 2026/07/28
建造者模式线程安全Builder复用不可变对象

简化版

Builder 对象通常不是线程安全的,也不建议在多线程之间共享。它是构建阶段的临时可变对象,推荐每次构建新对象时创建新的 Builder;如果要复用模板,应使用复制构建器或 toBuilder() 这类明确语义的方法。

详细版

Builder 内部会保存临时字段:

builder.name("Tom").age(18)

这些字段在调用链中不断变化,所以 Builder 本身一般是可变对象。可变对象如果被多个线程共享,就可能出现竞态条件。

例如:

User.Builder builder = User.builder();

// 线程 A
User a = builder.name("A").role("ADMIN").build();

// 线程 B
User b = builder.name("B").role("USER").build();

两个线程可能互相覆盖字段,最终结果不可预测。

即使在单线程里,也不建议随意复用 Builder:

User.Builder builder = User.builder().role("ADMIN");
User a = builder.name("A").build();
User b = builder.name("B").build();

b 会继承 ADMIN 角色,这可能是预期,也可能是隐藏 bug。

更推荐:

User a = User.builder().name("A").role("ADMIN").build();
User b = User.builder().name("B").role("USER").build();

如果确实要复用一组默认配置,可以提供模板方法:

User.Builder adminUserBuilder() {
    return User.builder().role("ADMIN").enabled(true);
}

每次返回新的 Builder,而不是共享同一个 Builder 实例。

完整版教学

一、为什么 Builder 通常不是线程安全的

线程安全问题来自“共享可变状态”。Builder 恰好就是可变状态的容器。

它会保存这些临时字段:

private String name;
private String role;
private boolean enabled;

每次调用链式方法,都会修改 Builder 自身:

public Builder name(String name) {
    this.name = name;
    return this;
}

如果多个线程同时改同一个 Builder,就会互相覆盖字段。即使单个赋值操作本身是原子的,也无法保证多个字段组合的一致性。

所以 Builder 的默认使用模型是:单线程、局部变量、短生命周期。

二、最终对象线程安全不代表 Builder 线程安全

很多人会混淆这两个概念。

最终对象如果是不可变的,可能是线程安全的:

public class User {
    private final String name;
    private final String role;
}

但 Builder 是创建这个不可变对象之前的可变工具:

public static class Builder {
    private String name;
    private String role;
}

Product 稳定,不代表 Builder 稳定。施工现场可以乱,交付成品可以稳,但不能让多个施工队同时抢同一张施工图。

三、复用 Builder 的风险

复用 Builder 最大的问题是字段残留。

例如:

Message.Builder builder = Message.builder()
        .source("system")
        .priority("HIGH");

Message m1 = builder.to("userA").content("hello").build();
Message m2 = builder.to("userB").content("hi").build();

如果 sourcepriority 是模板字段,这样可能没问题。但如果某一次又设置了 traceId,下一次忘了清理,就可能带出脏数据。

Message m3 = builder.traceId("T-001").to("userC").build();
Message m4 = builder.to("userD").build(); // 可能意外带上 T-001

这种 bug 很隐蔽,因为代码看起来像是在构建新对象,实际上沿用了旧状态。

四、如何安全表达模板复用

如果确实有模板需求,更好的方式是每次返回一个新 Builder:

public static Message.Builder systemHighPriorityMessage() {
    return Message.builder()
            .source("system")
            .priority("HIGH");
}

调用方拿到的是新 Builder:

Message message = systemHighPriorityMessage()
        .to("userA")
        .content("hello")
        .build();

另一个常见方式是 toBuilder()

Message updated = oldMessage.toBuilder()
        .content("new content")
        .build();

它表达的是“基于已有对象复制一份并修改部分字段”。这个语义比“偷偷复用旧 Builder”清楚得多。

五、要不要把 Builder 做成线程安全

通常没必要。给 Builder 方法加锁会增加复杂度,也违背它短生命周期的设计。

如果一个 Builder 需要跨线程共享,往往说明设计可以调整:

  • 每个线程创建自己的 Builder;
  • 把公共配置提取成不可变模板对象;
  • 使用工厂方法生成新的 Builder;
  • 避免共享可变构建状态。

模式设计里一个很实用的判断是:能通过限制使用方式解决的问题,不要优先用复杂同步机制解决。

六、两线程交错展示字段组合如何被撕裂

共享 User.Builder 时,即使每次 String 引用赋值是原子的,namerole 两个字段也不是一个原子事务。线程 A 可能写 name=A,线程 B 随即写 name=B 和 role=USER,A 再 build 就得到本不属于它的组合。

共享 Builder 初始 name=null, role=null
T1: name("A")
T2: name("B")
T2: role("USER")
T1: role("ADMIN")
T2: build() 读取 name=B, role=ADMIN(撕裂组合)
T1/T2 结果取决于交错
即使 Product 是 final 字段也无法修复错误快照
每线程新建 Builder 后状态不共享
模板复用改为工厂每次返回新 Builder

这条时间线把“可变构建阶段”和“稳定成品阶段”分开。Builder 可以反复接收参数,但 Product 只有在所有规则通过后才出现;若 Product 在第一步就被 new 出来再逐项修改,就仍然存在半初始化对象泄漏的窗口。

七、构建契约与对象不变量

评审维度本题结论
必填信息每次构建自己的必填参数,不从其他线程或上次构建继承。
默认值模板默认值通过方法创建新 Builder,而不是共享可变实例。
跨字段规则build 只能校验读到的组合是否合法,不能知道字段属于哪个线程意图。
可变引用处理Product 构造时复制集合,避免 Builder 构建后继续修改影响旧 Product。
Builder 生命周期线程局部、短生命周期、通常单次或明确复制语义。
Product 交付保证不可变 Product 可安全共享,但前提是快照本身构建正确。

build() 不是形式上的结束标记,而是对象从“参数集合”变成“合法业务值”的原子边界。单字段输入可以提前拒绝明显错误,跨字段规则必须等信息齐全后判断,Product 私有构造器还应保留必要兜底,避免未来新增创建入口绕过不变量。

八、与构造器、JavaBean 和工厂的选择边界

  • 字段少且全部必填时,短构造器或 record 通常比 Builder 更直接。
  • 字段多、可选项多、同类型参数易错时,命名步骤能显著提升调用可读性。
  • JavaBean setter 适合某些框架绑定,但对象可能在设置完成前就被观察到。
  • Builder 关注“同一种复杂对象怎样组装”;工厂模式关注“创建哪一种产品实现”。
  • 两者可以组合:工厂选择具体产品族或 Builder,Builder 再完成复杂组装。
  • 经典 Director 只有在固定步骤序列需要复用或存在多种表示时才有价值。
  • 链式 return this 只是语法,校验、默认值、拷贝和收口才是设计语义。
  • 若 Builder 比 Product 规则还复杂,应重新拆分对象职责,而不是继续堆方法。

不要优先给 Builder 全面加锁;让每个线程拥有自己的 Builder,公共模板提取为不可变配置或新建函数。

九、交付前的代码与测试检查

  1. 缺少每个必填字段分别调用 build(),应得到明确且稳定的异常。
  2. 完全不设置可选字段,核对默认值来自唯一权威位置。
  3. 传入最小值、最大值和越界值,确认范围判断没有反向或 off-by-one。
  4. 构造两个互相冲突的字段组合,确认只在信息完整时执行跨字段校验。
  5. 传入集合或数组后修改原引用,已构建 Product 不应跟着变化。
  6. 若 getter 返回可变数据,再尝试修改返回值,内部状态仍应保持不变。
  7. 连续调用同一个 Builder 两次,确认语义是明确允许复制还是文档明确禁止复用。
  8. 两线程共享一个 Builder 做压力测试应被禁止或证明安全,不能靠偶然结果。
  9. 若使用 Lombok,检查生成代码、默认值、@Singular、构造器可见性和框架兼容。
  10. 若迁移旧 API,比较默认值、异常类型、序列化字段和所有旧调用点行为。

记忆钩子:成品 final 只能冻结结果,不能纠正施工时被其他线程写乱的参数。

十、常见误区与追问

  • 误区:Product 不可变所以 Builder 也线程安全。 二者是不同对象,Builder 保存共享可变构建状态。
  • 误区:字段赋值原子就不会出现竞态。 多个字段组合和调用序列并不原子。
  • 误区:复用 Builder 只会继承明确默认值。 任何上次设置后未重置字段都可能形成脏数据。
  • 追问:toBuilder 为什么更清楚? 它显式表达从一个已知 Product 快照复制并修改,而非隐藏残留状态。
  • 追问:能把 Builder 做成线程安全的吗? 技术上可加锁或用不可变 Builder,但通常成本高于每线程新建。
  • 追问:多次 build 是否允许? 由 API 明确定义;若允许,必须说明后续修改是否影响已建对象并做好拷贝。

十一、加强记忆

Builder 是可变施工图,Product 可以是不可变成品。Builder 默认不线程安全,也不建议复用;要复用默认配置,就每次生成新 Builder,或用 toBuilder() 明确表达复制修改。