← 返回题目列表

为什么建造者模式常用来创建不可变对象?

高频 中等 第 7 / 26 题 更新于 2026/07/28
建造者模式不可变对象线程安全Java

简化版

建造者模式适合创建不可变对象,因为 Builder 可以在构建阶段临时保存可变参数,build() 后返回字段固定的最终对象。这样既保留了灵活配置能力,又避免对象创建后被随意修改。

详细版

不可变对象通常要求:

  • 字段使用 private final
  • 不提供修改字段的 setter;
  • 构造时完成所有必要校验;
  • 对可变引用做防御性拷贝;
  • 对外返回集合时避免暴露内部可变集合。

如果字段很多,直接用构造方法创建不可变对象会很痛苦:

new UserProfile("Tom", 18, "BJ", true, List.of("Java"), 3, null);

Builder 可以把可变过程放在 Builder 里,把不可变结果放在 Product 里:

UserProfile profile = UserProfile.builder()
        .name("Tom")
        .age(18)
        .city("北京")
        .tags(List.of("Java", "AI"))
        .build();

最终对象:

public class UserProfile {
    private final String name;
    private final int age;
    private final List<String> tags;

    private UserProfile(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
        this.tags = List.copyOf(builder.tags);
    }
}

这里 List.copyOf() 很关键,它避免外部传入的集合后续修改影响对象内部状态。

所以建造者模式常和不可变对象一起出现:Builder 负责灵活,Product 负责稳定。

完整版教学

一、不可变对象为什么重要

不可变对象创建完成后状态不再变化。它的好处非常明显:

  • 逻辑更容易推理;
  • 不会被其他代码悄悄改掉;
  • 多线程读取时更安全;
  • 可以放心作为缓存 key 或值对象;
  • 错误更容易定位,因为状态变化入口更少。

比如订单查询条件、HTTP 请求配置、权限规则、金额对象、时间范围对象,都很适合做成不可变对象。

二、构造方法创建不可变对象的痛点

不可变对象通常没有 setter,所以所有字段都要通过构造方法传进去。

字段少时没问题:

new Money("CNY", 100);

字段多时就很难维护:

new ReportConfig("日报", true, false, 1000, "xlsx", null, true, 30);

这个调用有明显风险:

  • 参数含义不清楚;
  • 布尔参数尤其难读;
  • 可选参数也必须占位;
  • 添加新参数会影响多个构造方法;
  • 校验逻辑可能散落在构造器里。

Builder 正好补上这个缺口。

三、Builder 如何兼顾灵活和不可变

Builder 本身是可变的。调用方可以一步步设置字段:

Builder builder = ReportConfig.builder();
builder.title("日报");
builder.format("xlsx");
builder.includeChart(true);

但最终对象是不可变的:

public class ReportConfig {
    private final String title;
    private final String format;
    private final boolean includeChart;

    private ReportConfig(Builder builder) {
        this.title = builder.title;
        this.format = builder.format;
        this.includeChart = builder.includeChart;
    }
}

可以理解为:Builder 是施工现场,Product 是交付成品。施工现场可以变化,交付成品应该稳定。

四、防御性拷贝不能忽略

很多人创建不可变对象时只写 final,但如果字段是集合、数组、日期这类可变引用,final 只能保证引用不变,不能保证引用指向的对象内容不变。

错误示例:

private UserProfile(Builder builder) {
    this.tags = builder.tags;
}

如果外部继续修改原集合,UserProfile 内部也会受影响。

更稳的写法:

private UserProfile(Builder builder) {
    this.tags = List.copyOf(builder.tags);
}

public List<String> getTags() {
    return tags;
}

如果是数组,可以使用 Arrays.copyOf()。如果是复杂可变对象,需要根据业务决定深拷贝策略。

五、从外部可变 List 到内部不可变快照

调用方把 ArrayList 传给 Builder 后仍持有该引用。若 Product 直接保存引用,之后 tags.add() 会穿透 final 字段改变对象;在构造边界执行 List.copyOf,Product 才获得与外部后续修改隔离的快照。

List tags = new ArrayList(["Java"])
builder.tags(tags)
build(): 校验 name/age
Product 构造: this.tags = List.copyOf(builder.tags)
返回 profile
外部执行 tags.add("AI")
原 tags 变为 [Java, AI]
profile.tags 仍为 [Java]
getter 返回不可变 List
多线程只读共享更容易推理

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

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

评审维度本题结论
必填信息姓名等身份字段在 build() 前必须存在。
默认值可选集合默认空集合,避免 Product 内部 null 分支。
跨字段规则年龄范围、字段组合等在完整参数可见时检查。
可变引用处理输入集合/数组做防御性拷贝,输出也不泄漏可变引用。
Builder 生命周期Builder 可变且局部;Product 创建后不再依赖 Builder 状态。
Product 交付保证所有字段 private final,外界无法观察到后续输入引用变化。

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

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

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

字段少的不可变值对象可直接用 record/短构造器;Builder 适合参数多且可选规则复杂的不可变对象。

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

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

记忆钩子:final 固定的是引用,防御性拷贝固定的才是对象看到的内容。

九、常见误区与追问

  • 误区:所有字段 final 就完成深度不可变。 字段指向的集合、数组或日期仍可能可变。
  • 误区:只在 getter 返回 unmodifiableView 就够了。 外部若还持有原输入集合,仍可从另一入口修改底层内容。
  • 误区:Builder 不可变才能创建不可变对象。 Builder 可以是局部可变施工区,关键是 Product 与它彻底隔离。
  • 追问:List.copyOf 是深拷贝吗? 它复制容器结构,但不会递归复制可变元素。
  • 追问:元素本身可变怎么办? 需要不可变元素、逐项复制或明确所有权协议。
  • 追问:不可变对象一定线程安全吗? 自身状态安全发布后便于共享,但其引用的外部资源和方法副作用仍需判断。

十、加强记忆

Builder 和不可变对象是一对好搭档:Builder 负责接收复杂配置,build() 负责校验和拷贝,Product 负责保持稳定状态。只写 final 不够,遇到集合和数组还要注意防御性拷贝。