搞懂new 关键字(构造函数)和 .builder() 模式(建造者模式)创建对象

使用 new 关键字(构造函数)和 .builder() 模式(建造者模式)创建对象是两种主要的方式。

简单直接的结论是:对于参数较多(超过3-4个)或包含可选参数的复杂对象,.builder() 更好;对于简单对象,new 更好。

1. 直观对比

假设有一个 User 类,包含 5 个属性:firstName, lastName, age, phone, address

使用 new (构造函数)

sql 复制代码
<JAVA>
// 如果有些参数可选,你可能不得不传 null
User user = new User("Zhang", "San", 25, null, "Beijing");

问题:

  • 可读性差 :如果不看 IDE 提示或源码,你很难知道 25 是年龄还是 ID?"Beijing" 是地址还是备注?
  • 参数顺序锁死:必须严格按照构造函数定义的顺序传参,一旦记错顺序(比如两个 String 类型的参数),编译不报错但运行逻辑错误。
  • 要么是创建一个空对象,再去set属性值,看起来臃肿

使用 .builder() (建造者模式)

scss 复制代码
<JAVA>
User user = User.builder()
            .firstName("Zhang")
            .lastName("San")
            .age(25)
            .address("Beijing")
            .build();

优势:

  • 代码如文档.age(25) 一目了然。
  • 灵活 :没有传入的参数(如 phone)会自动赋默认值或 null,不需要显式传递 null
  • 顺序无关 :可以先调 .address() 再调 .firstName()

2. 深度优缺点分析

🅰️ new (Constructor)

优点:

  1. 原生支持:Java 语言内置机制,无需额外代码或注解。
  2. 强制性:如果某个对象必须依赖某些参数才能初始化,构造函数可以强制要求调用者传入这些参数(编译期检查)。
  3. 性能极高 :没有中间对象的创建开销(虽然 Builder 的开销微乎其微,但在极端高性能场景下 new 更快)。
  4. 代码量少:对于只有 1-2 个字段的简单类(如 DTO、VO),写起来最快。

缺点:

  1. 伸缩构造函数反模式 (Telescoping Constructor Problem) :当参数变多,或者有多种参数组合时,你不得不写一大堆构造函数(只有A的,有A和B的,有A、B和C的...)。
  2. 维护困难:如果在中间插入一个新的字段,所有调用该构造函数的地方都会报错,或者需要重构。

🅱️ .builder() (Builder Pattern)

通常配合 Lombok@Builder 注解使用。

优点:

  1. 可读性极强:链式调用(Fluent API),代码清晰。
  2. 处理可选参数:不需要为不同的参数组合编写多个构造函数。
  3. 支持不可变对象 (Immutability) :通常 Builder 最终构建出的对象没有 Setter 方法,这对于多线程安全和数据一致性非常有好处。对象一旦 build() 出来就是最终状态。
  4. 易于维护:新增字段时,只需要在链式调用中增加一行,不影响其他不需要该字段的代码。

缺点:

  1. 样板代码多:如果不使用 Lombok,手动手写 Builder 模式需要写大量的静态内部类代码。
  2. 对象开销:创建目标对象前,会先创建一个临时的 Builder 对象(虽然现代 JVM 对此优化很好,GC 回收也很快,但理论上内存占用稍多)。
  3. 复杂性:对于只有 1 个字段的类,用 Builder 显得杀鸡用牛刀。

3. 哪个更好?

场景一:使用 .builder() (推荐)

  • 参数数量 > 4 个:为了可读性,强烈建议使用。
  • 参数具有多种组合:例如搜索条件对象(可能有名字、可能有日期、可能有状态,组合无穷无尽)。
  • 需要不可变对象:你希望对象创建后不被修改(没有 Setter),Builder 是最佳伴侣。
  • 字段类型重复多 :例如有 5 个 String 类型的字段,用构造函数极其容易传错位置。

场景二:使用 new

  • 参数数量 < 3 个 :例如 new Point(x, y),比 Point.builder().x(1).y(2).build() 简洁得多。
  • 必须强制参数 :如果你要求对象创建时必须包含 idname,使用构造函数 new Obj(id, name) 可以利用编译器强制约束,而 Builder 默认是允许不调用的(除非在 build() 方法里加校验逻辑)。
  • 极度在意性能:在超高频交易或底层库开发中,为了省去 Builder 对象的创建开销。

4. 最佳实践 (Modern Java)

在现代 Java 开发(尤其是 Spring Boot 项目)中,最流行的做法是 结合 Lombok

typescript 复制代码
<JAVA>
import lombok.Builder;
import lombok.Data;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private String name;
    private int age;
    private String address;
}

用法:

  • 简单场景或框架反射用:new User()
  • 复制属性场景:new User(name, age, address)
  • 业务逻辑构建场景:User.builder().name("X").build()

总结: 尽量默认使用 @Builder,因为它提供了最好的可读性和维护性,除非你的类非常简单(如 Pair, Point 这种值对象)

5. 实体类可以通过@Data 和@Builder 两个注解,创建对象和set值,为什么说build() 出来就是最终状态?

Builder 模式本身是为了解决复杂对象的创建问题,而"不可变性"是 Builder 模式最常搭配的"最佳拍档",但并非"强制绑定"。 Lombok 的 @Builder 只是一个工具,它允许你"离经叛道"地同时使用 @Setter

为什么说"Builder 天生适合不可变对象"?

想象一下,如果你想设计一个绝对不可变 (Immutable)的类 User(即:多线程安全,创建后不可改):

  1. 你必须把所有字段设为 final
  2. 你必须删掉所有 Setter 方法

这时候问题来了:你怎么给它赋值?

  • 方法 A:用 Setter? 不行,你刚把它删了。
  • 方法 B:用构造函数? 如果字段有 10 个,你就陷入了刚才说的"伸缩构造函数反模式",代码极其难看。

方法 C:用 Builder! 这是唯一的完美解法。Builder 充当了一个缓冲容器,先在 Builder 里面把参数一点点凑齐,最后调用 .build() 的瞬间,一次性把所有参数传给那个私有的全参构造函数。

结论 :之所以说 Builder 支持不可变对象,是因为如果没有 Builder,创建复杂的不可变对象极其痛苦。Builder 是实现不可变对象的"最佳助攻"。

那为什么 Lombok 允许 @Builder@Data (Setter) 共存?

这是 Lombok 为了实用性(Pragmatism) 做出的妥协。

在 Java 的真实开发世界中,有两种截然不同的场景:

场景 A:领域模型 (Domain Object) / 值对象 (VO)

  • 要求:数据一致性、多线程安全。
  • 写法@Builder + @Value (或者 @Getter Setter)。
  • 状态 :此时 Builder 构建出来的对象是真的不可变
  • 这是教科书式的标准用法。

场景 B:实体类 (JPA Entity) / 数据传输对象 (DTO)

  • 要求:需要被框架反射修改、需要后续业务逻辑更新字段。
  • 写法@Builder + @Data (包含 Setter)。
  • 状态 :此时 Builder 仅仅是作为一个 "优雅的构造器" 存在,对象本身是可变的。

为什么这么做? 有时候我们仅仅是讨厌写 new User(param1, param2...) 这种长代码,觉得 .builder().name("A").build() 写起来更可读、更优雅。但同时,后续业务逻辑里我又确实需要 user.setStatus(1)。 Lombok 作为一个工具库,它把选择权交给了你:你想用来做防弹衣(不可变),还是做方便面(仅为了好用),都可以。

例:

🔴 真正的不可变模式

这是 Builder 被设计出来的初衷。

java 复制代码
<JAVA>
@Builder
@Getter // 只有 Get,没有 Set
// 或者直接用 @Value,它会自动把字段设为 private final
public class ImmutableUser {
    private final String name;
    private final int age;
}
// ImmutableUser user = ImmutableUser.builder().name("A").build();
// user.setName("B"); // ❌ 编译报错!根本没有这个方法!
// 安全!

🟡 混合模式 (Lombok 的灵活变通)

这是为了写代码爽,但牺牲了不可变性。

java 复制代码
<JAVA>
@Builder
@Data // 包含了 @Setter
public class MutableUser {
    private String name;
    private int age;
}
// MutableUser user = MutableUser.builder().name("A").build();
// user.setName("B"); // ✅ 可以修改
// 不安全,但在 CRUD 业务中很常见。

6. @Builder原理

@Builder 注解本身在运行时(Runtime)不会创建任何对象,也不会执行 new 操作。

当你编译代码时,Lombok 插件会拦截编译过程 ,读取你的类定义,然后自动生成大量的 Java 源代码(包括内部静态 Builder 类、构造函数调用等),并将这些生成的代码插入到编译流程中。

你看到的 .class 文件里之所以有 new 操作,是因为 Lombok 在编译阶段已经把包含 new 的代码写好并编译进去了,而不是在运行时动态创建的。

@Builder 是如何工作的?(编译时魔法)

Lombok 是一个 注解处理器(Annotation Processor) 。它的工作流程如下:

  • 源码阶段 :你写了一个简单的类,加上了 @Builder
arduino 复制代码
@Builder
public class User {
    private String name;
    private int age;
}

此时,内存里还没有 Builder 类,也没有 new User(...) 的代码。

  • 编译阶段(关键步骤)

    • javac 编译器启动。
    • Lombok 的注解处理器被触发。
    • Lombok 扫描到 @Builder
    • Lombok 动态生成代码 :它在内存中构造出一个完整的内部静态类 UserBuilder,并生成了 builder() 方法、name() 方法、age() 方法、build() 方法等。
    • 注入 new 操作 :在生成的 build() 方法中,Lombok 硬编码new User(this.name, this.age) 这样的语句。
  • 生成的代码长什么样?

Lombok 实际上把你的类变成了下面这个样子(这是编译器真正处理的内容):

arduino 复制代码
public class User {
    private String name;
    private int age;

    // 1. 私有构造函数(防止外部直接 new User,强制走 Builder)
    private User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 2. 静态方法,返回 Builder 实例
    public static UserBuilder builder() {
        return new UserBuilder(); // 这里 new 了 Builder 对象
    }

    // 3. 生成的内部静态 Builder 类
    public static class UserBuilder {
        private String name;
        private int age;

        // 链式调用方法
        public UserBuilder name(String name) {
            this.name = name;
            return this;
        }

        public UserBuilder age(int age) {
            this.age = age;
            return this;
        }

        // 4. 关键的 build 方法:这里发生了对象的最终创建
        public User build() {
            // 【重点】:这里的 new User(...) 是 Lombok 在编译时生成的代码!
            return new User(this.name, this.age); 
        }
    }
}
  • 字节码阶段 :编译器将上述包含 new 操作 的完整代码编译成 .class 文件。
  • 运行阶段 :JVM 加载这个已经包含完整逻辑的 .class 文件。当你调用 User.builder().name("A").build() 时,只是正常执行已经存在的字节码指令(INVOKESPECIALnew 指令)。

什么时候用到 new

@Builder 的模式下,new 关键字出现在两个地方,但都是编译后存在的:

A. 创建 Builder 对象时

当你调用 User.builder() 时:

  • 底层执行的是:return new UserBuilder();
  • 时机:编译时由 Lombok 生成该语句,运行时执行该语句创建 Builder 实例。

B. 创建目标对象时(最关键)

当你调用 .build() 时:

  • 底层执行的是:return new User(this.name, this.age);
  • 时机 :编译时由 Lombok 生成该语句,运行时执行该语句创建最终的 User 对象。
  • 注意 :Lombok 通常会将原类的构造函数权限改为 private,所以除了 build() 方法内部,其他地方无法直接使用 new User() 。这就是 Builder 模式强制规范对象创建的方式。

为什么你看不到源码里的 new

因为你看到的是原始源码(Source Code)

  • 原始源码 :只有注解 @Builder
  • 编译后的源码(反编译 view) :包含了 Lombok 生成的所有类和 new 语句。

你在 IntelliJ IDEA 中如果安装了 Lombok 插件,IDE 会"欺骗"你,让你在源码视图也能看到生成的方法(虚线显示),但这只是为了方便你开发(自动补全、跳转)。真正的物理代码是在编译那一刻才"变"出来的。

可以尝试以下操作来验证:

  1. 编写一个带 @Builder 的类。
  2. 编译项目 (Build -> Rebuild Project)。
  3. 在 IDEA 中打开对应的 .class 文件(通常在 out/production/...target/classes/... 目录下,或者按 Ctrl+Shift+N 查找类名后选择 .class 后缀的文件)。
  4. 使用反编译插件(如 FernFlower,IDEA 自带)查看。
  5. 你会清晰地看到 private User(...) 构造函数和 build() 方法里的 new User(...)

机制@Builder编译时代码生成技术 ,不是运行时反射或动态代理。new 的位置new 操作符是由 Lombok 在编译阶段 自动写入到生成的 build() 方法和 builder() 方法中的。当你在运行时调用 .build() 时,JVM 执行的是那段早已生成好new 指令。

  • 优势 :因为是编译期生成,所以没有运行时性能损耗(不像反射那样慢),且类型安全。
相关推荐
用户908324602731 小时前
Spring Boot + MyBatis-Plus 多租户实战:从数据隔离到权限控制的完整方案
java·后端
桦说编程2 小时前
实战分析 ConcurrentHashMap.computeIfAbsent 的锁冲突问题
java·后端·性能优化
程序员清风6 小时前
用了三年AI,我总结出高效使用AI的3个习惯!
java·后端·面试
beata7 小时前
Java基础-13: Java反射机制详解:原理、使用与实战示例
java·后端
用户0332126663677 小时前
Java 使用 Spire.Presentation 在 PowerPoint 中添加或删除表格行与列
java
Seven979 小时前
Condition底层机制剖析:多线程等待与通知机制
java
怒放吧德德18 小时前
Spring Boot 实战:RSA+AES 接口全链路加解密(防篡改 / 防重放)
java·spring boot·后端
郑州光合科技余经理1 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
大大水瓶1 天前
Tomcat
java·tomcat