Lombok 你用对了吗?@Data 之外的 6 个隐藏神器

栏目:注解速查 | 环境:JDK 21 | Lombok 1.18.34 | Spring Boot 3.2.7


从一个 StackOverflow 说起

假设同事A写了一个简单的用户-订单管理模块。User 有一个 List<Order>Order 引用了所属的 User------标准的 JPA 双向关联。他觉得 Lombok 的 @Data 很方便,两个类都加上了。

功能跑起来没问题,直到他为了 debug 在日志里输出了一个 User 对象------

服务直接崩了。StackOverflowError。

java 复制代码
@Data
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;  // User 里有 Order 列表
}

@Data
@Entity
public class Order {
    @ManyToOne
    private User user;  // Order 又引用回 User
}

// 日志里调用 user.toString() →
//   User.toString() 调 orders.toString() →
//     每个 Order.toString() 又调 user.toString() →
//       无限递归 → StackOverflowError 💥

@Data 虽然方便,但不分场景一把梭会出事。Lombok 远不止 @Data,它还有 6 个真正好用的注解,每个都能省掉十几行样板代码------而且不会让你的服务崩溃。


前置知识:Lombok 依赖配置

Spring Boot 项目通过 spring-boot-starter-parent 已默认管理 Lombok 版本,只需引入依赖:

xml 复制代码
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>annotationProcessor</scope>  <!-- 编译时生成代码,不打包进 jar -->
</dependency>

新版 IntelliJ IDEA 已内置 Lombok 插件,无需额外安装。如果代码中写 Lombok 注解后 IDE 仍然飘红,检查 Settings → Build → Compiler → Annotation Processors → Enable annotation processing 是否勾选。


第一招:@Builder / @SuperBuilder --- 建造者模式一行搞定

没它之前

一个带 4 个字段的类,手写 Builder 模式需要 60+ 行代码:

java 复制代码
public class User {
    private String name;
    private int age;
    private String email;
    private String phone;

    // 私有构造器
    private User(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
        this.email = builder.email;
        this.phone = builder.phone;
    }

    // Builder 内部类 ------ 40 行样板代码
    public static class Builder {
        private String name;
        private int age;
        private String email;
        private String phone;

        public Builder name(String name) { this.name = name; return this; }
        public Builder age(int age) { this.age = age; return this; }
        public Builder email(String email) { this.email = email; return this; }
        public Builder phone(String phone) { this.phone = phone; return this; }

        public User build() {
            if (name == null) throw new IllegalStateException("name 不能为 null");
            return new User(this);
        }
    }

    public static Builder builder() { return new Builder(); }
}

字段一多,Builder 内部类就成了"重复劳动制造机"------字段声明要写两遍(类里一遍,Builder 里一遍),赋值代码是机械重复。

用它之后

@Builder 一行注解,全部搞定:

java 复制代码
@Builder
@ToString
public class User {
    private String name;
    private int age;
    private String email;
    private String phone;
}

// 使用:链式调用,字段名即方法名
User user = User.builder()
        .name("张三")
        .age(25)
        .email("zhangsan@example.com")
        .phone("13800138000")
        .build();

从 60+ 行缩减到 ~15 行。 字段 ≥4 个的类,Builder 模式让代码可读性提升一个档次------new User("张三", 25, "z@t.com", "138...") 这种传参方式过两周你自己都看不懂每个参数是啥。

关键坑点------继承场景

@Builder 有一个很常见的暗坑:父类的字段不会出现在子类的 Builder 里。

java 复制代码
@Builder
class Animal {
    private String name;
    private int age;
}

@Builder
class Dog extends Animal {
    private String breed;
}

// Dog.builder().name("旺财").age(3).breed("金毛").build();  ← 编译报错!
// Dog.builder() 只有 breed() 方法,没有 name() 和 age()

解法:@SuperBuilder 父类和子类都标注 @SuperBuilder

java 复制代码
@SuperBuilder
@ToString
class Animal {
    private String name;
    private int age;
}

@SuperBuilder
@ToString(callSuper = true)
class Dog extends Animal {
    private String breed;
}

// 现在可以了------
Dog dog = Dog.builder()
        .name("旺财")    // 父类字段 ✅
        .age(3)          // 父类字段 ✅
        .breed("金毛")   // 子类字段 ✅
        .build();

你需要知道的

  • @Builder 不支持继承 ------需要继承时用 @SuperBuilder,且父类和子类都要标注
  • @Builder 生成的构造器默认是包私有(package-private) ------这意味着在别的包里不能直接 new User()。如果你希望跨包也能 new,额外加 @AllArgsConstructor(默认 public)。如果你希望强制所有人必须通过 Builder 创建(包括本包),加 @AllArgsConstructor(access = AccessLevel.PRIVATE) 把构造器变成真正的 private
  • @Builder 会忽略字段的默认值 ------比如 private boolean enabled = true;,Builder 构建后 enabled 会是 false(Java 的零值)。解决方案:在字段上加 @Builder.Default
  • Builder 模式不等于"所有类都用"------字段只有 2-3 个的简单 DTO,直接用构造器更清晰

第二招:@SneakyThrows --- Stream/Lambda 里再也不写丑陋的 try-catch

场景

在 Stream 的 map() 里调了一个声明 throws IOException 的方法。编译直接报错------因为 Stream.flatMap() 接收的 Function 接口没有声明 throws,lambda 体里不能抛出受检异常。

java 复制代码
// Files.lines() 声明了 throws IOException
// 这段代码直接编译报错 ❌
List<String> lines = fileNames.stream()
        .flatMap(name -> Files.lines(Path.of(name)))  // 编译错误!
        .toList();

没它之前

在 lambda 里包一层 try-catch,catch 里转成 RuntimeException

java 复制代码
List<String> lines = fileNames.stream()
        .flatMap(name -> {
            try {
                return Files.lines(Path.of(name));
            } catch (IOException e) {
                throw new RuntimeException(e);  // 丑陋!
            }
        })
        .toList();

lambda 本应简洁,结果被 try-catch 撑得比方法体还长。

用它之后

把会抛受检异常的逻辑抽成一个方法,加上 @SneakyThrows

java 复制代码
@SneakyThrows
private static Stream<String> readLines(String path) {
    return Files.lines(Path.of(path));  // 编译器不再报错
}

// lambda 里干净了
List<String> lines = fileNames.stream()
        .flatMap(Demo::readLines)
        .toList();

你需要知道的

  • @SneakyThrows 不是在运行时吞掉异常 ------异常仍然会正常抛出,只是在编译期 bytecode 层面隐藏了 throws 声明。上面示例里如果文件不存在,会正常抛出 NoSuchFileException
  • 不要滥用 ------受检异常的存在是有原因的(比如 IOException 提醒调用方处理 I/O 失败的情况)。只在确认调用方一定能处理这个异常、且不想污染上层方法签名时使用
  • try-catch + throw new RuntimeException() 的区别@SneakyThrows 抛出的是原始异常(类型不变),try-catch 转成的是 RuntimeException

第三招:@Cleanup --- 比 try-with-resources 更简洁的资源管理

没它之前

Java 7 引入了 try-with-resources,已经比传统的 try-finally 简洁很多了:

java 复制代码
try (InputStream is = new FileInputStream("data.txt");
     InputStreamReader isr = new InputStreamReader(is);
     BufferedReader reader = new BufferedReader(isr)) {
    // 读数据...
} // 自动按声明逆序关闭:reader → isr → is

但这仍然需要嵌套的括号声明,资源一多括号比内容还长。

用它之后

java 复制代码
@Cleanup InputStream is = new FileInputStream("data.txt");
@Cleanup InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8);
@Cleanup BufferedReader reader = new BufferedReader(isr);

// 正常读数据...
// 当前作用域结束时,reader → isr → is 按声明逆序自动 close()

没有嵌套括号,资源声明和业务代码在同一层缩进上,读起来更扁平。

关键坑点------作用域陷阱

@Cleanup 的资源在变量所在作用域结束时才关闭。 如果在一个方法开头声明了 @Cleanup,中间调了耗时操作(比如 RPC 调用),资源会一直占用到方法结束。

java 复制代码
// ❌ 不好:资源过早声明,占用时间太长
public void badExample() {
    @Cleanup InputStream is = new FileInputStream("bigfile.bin");
    // ... 中间有 3 秒的 RPC 调用 ...
    processData(is);  // 资源一直占用到方法最后
}

// ✅ 好:用 {} 缩小作用域
public void goodExample() {
    {
        @Cleanup InputStream is = new FileInputStream("bigfile.bin");
        processData(is);
    } // ← 这里就关闭了,不用等到方法结束
    // 后续耗时操作不会占用文件资源
}

你需要知道的

  • @Cleanup 的关闭顺序和 try-with-resources 一样 :按声明的逆序关闭(后声明的先关)
  • 必须实现 AutoCloseableCloseable 接口 ------否则 @Cleanup 编译报错
  • 和 try-with-resources 比哪个更好? try-with-resources 是 Java 标准语法,团队大多数人更熟悉;@Cleanup 更简洁但依赖 Lombok。两者在功能上等价,选团队更接受的即可

第四招:@UtilityClass --- 工具类的正确"官方"写法

没它之前

写一个工具类需要遵守一堆"规矩":

java 复制代码
public final class StringUtils {  // ① 类必须 final,防止被继承

    private StringUtils() {  // ② 私有构造器,防止外部 new
        throw new UnsupportedOperationException("工具类不能实例化");  // ③ 防反射
    }

    public static String toUpperCase(String str) {  // ④ 所有方法必须是 static
        return str == null ? null : str.toUpperCase();
    }

    public static boolean isEmpty(String str) {
        return str == null || str.isEmpty();
    }
}

四条规矩,少写一条就有隐患------忘了 final,别人继承你的工具类;忘了 private 构造器,别人 new StringUtils()

用它之后

@UtilityClass 一行注解,自动完成全部四条:

java 复制代码
@UtilityClass
public class StringUtils {

    public String toUpperCase(String str) {  // 不用写 static------自动加上
        return str == null ? null : str.toUpperCase();
    }

    public boolean isEmpty(String str) {
        return str == null || str.isEmpty();
    }
}

// 使用方式完全一样
String result = StringUtils.toUpperCase("hello");  // → "HELLO"

@UtilityClass 自动生成的代码:

  • 类变 final
  • 所有方法变 static
  • 生成私有构造器(内部抛异常)
  • 禁止外部实例化------私有构造器 + 内部抛异常双重保护

你需要知道的

  • 方法里不需要写 static 关键字 ------@UtilityClass 自动把所有方法变成 static。你在源码里写 public String toUpperCase(...),编译后变成 public static String toUpperCase(...)
  • 适用于一切"只有静态方法的工具类"StringUtilsDateUtilsCollectionUtilsFileUtils
  • 不适合需要实例化或继承的类 ------工具类本身就是"无状态 + 纯函数",如果某个类需要 @Autowired 注入依赖,它不该是工具类

第五招:@With --- 不可变对象"修改"一个字段的正确姿势

场景

你用了 @Value 创建不可变对象(所有字段 private final,只有 getter)。这是很棒的实践------不可变对象线程安全、不用考虑并发修改。但需要更新一个字段时怎么办?

没它之前

手写 withXxx() 方法------新建一个对象,复制所有字段,替换目标字段:

java 复制代码
@Value
public class User {
    String name;
    int age;
    String email;

    // 手写 with 方法
    public User withName(String newName) {
        return new User(newName, this.age, this.email);
    }
    public User withAge(int newAge) {
        return new User(this.name, newAge, this.email);
    }
    public User withEmail(String newEmail) {
        return new User(this.name, this.age, newEmail);
    }
}

字段一多,每个字段都要手写一个 with 方法------又是重复劳动。

用它之后

@With 给所有字段自动生成 withXxx()

java 复制代码
@Value
@With
public class User {
    String name;
    int age;
    String email;
}

// 使用:链式"修改",每次返回新对象
User user = new User("张三", 25, "zhangsan@example.com");
User updated = user.withName("张三丰").withAge(30).withEmail("zsf@example.com");

// 原始对象不受影响
System.out.println(user);     // User(name=张三, age=25, email=zhangsan@example.com)
System.out.println(updated);  // User(name=张三丰, age=30, email=zsf@example.com)

你需要知道的

  • withXxx() 返回的是新对象(浅拷贝) ------只复制引用,不深拷贝。如果字段是 List<String>,新旧对象会共享同一个 List 引用(需要深拷贝的话在 with 方法里手动处理)
  • 配合 @Value 使用最佳 ------@Value 保证对象不可变,@With 提供"修改"的唯一合法通道
  • 不想给某些字段生成 with 方法? 在字段上加 @With(AccessLevel.NONE) 排除

第六招:@Singular --- 集合字段一行一个元素地添加

场景

@Builder 的类里有个 List<String> tags 字段。用 Builder 时得先构造一个完整的 List 传进去:

java 复制代码
// 没它之前:必须先 new 一个 ArrayList(或用 List.of)
Article article = Article.builder()
        .title("Lombok 教程")
        .tags(List.of("Java", "Lombok", "注解"))  // 必须一次性构造完整 List
        .build();

用它之后

在集合字段上加 @Singular,Builder 里会多出逐元素添加的方法:

java 复制代码
@Builder
@ToString
public class Article {
    private String title;

    @Singular
    private List<String> tags;  // 标签

    @Singular("contributor")
    private Set<String> contributors;  // 贡献者(单数方法名指定为 "contributor")

    @Singular
    private Map<String, Integer> scores;  // 评分
}

// 使用:逐元素添加,代码更自然
Article article = Article.builder()
        .title("Lombok 注解速查")
        .tag("Java")          // 单数形式,逐个添加
        .tag("Lombok")
        .tag("注解速查")
        .contributor("张三")   // 指定了单数方法名 "contributor"
        .contributor("李四")
        .score("张三", 95)     // Map 的单数形式:score(key, value)
        .score("李四", 88)
        .build();

@Singular 生成的额外方法(以 tags 为例):

  • tag(T element) ------ 逐个添加单个元素(单数形式,方法名由 Lombok 自动推断)
  • tags(Collection<? extends T>) ------ 批量添加全部元素(复数形式,覆盖之前添加的)
  • clearTags() ------ 清空已添加的所有元素

你需要知道的

  • 单数方法名是 Lombok 自动推断的 ------tagstagitemsitemusersuser。如果推断不对(比如 children → Lombok 可能推不出 child),用 @Singular("customName") 显式指定
  • 支持的集合类型ListSetSortedSetNavigableSetMapSortedMapNavigableMap(对应的 Guava 不可变集合也支持)
  • clear 方法很有用 ------比如先用 Builder 的默认值,在特定条件下 clearTags() 再重新添加

番外:@Data 的正确使用姿势

回到文章开头那个 StackOverflow。

@Data 实际上是个"组合注解",它等价于:

less 复制代码
@Data = @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor

五个注解打包一起用,方便是真方便,但 JPA Entity 有它的绝对禁区

问题 原因 后果
@ToString 无限递归 双向关联的 toString() 互相调用 StackOverflowError
@EqualsAndHashCode 不稳定 使用所有字段(含 @Id)计算 hash。@GeneratedValue 的 ID 在 persist() 前为 null,hash 基于对象标识;persist() 后 ID 有值,hash 改变 Set/HashMap 中同一个对象出现在两个位置
@Setter 破坏封装 双向关联的 setter 被误调用,只设了一端忘设另一端 数据不一致

正确做法

java 复制代码
@Getter
@Setter
@ToString(exclude = "orders")  // 打断递归链
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}

@Getter
@Setter
@ToString(exclude = "user")  // 打断递归链
public class Order {
    @ManyToOne
    private User user;
}

简单规则

  • JPA Entity → 注意@Getter + @Setter(或只 @Getter),还是 @Data
  • DTO / POJO / VO → 放心用 @Data,没有 JPA 关联关系的纯数据对象完全没问题

速查表

注解 一句话 最佳场景 关键坑点
@Builder 生成建造者模式 字段 ≥4 个的类 不支持继承(改用 @SuperBuilder
@SuperBuilder 支持继承的建造者 父类有公共字段,子类扩展 父类和子类都要标注
@SneakyThrows bytecode 层面隐藏 throws Stream/Lambda 中调受检异常方法 别滥用------受检异常存在有原因
@Cleanup 作用域结束自动 close() I/O 流、JDBC 连接 作用域结束才关闭,注意用 {} 缩小
@UtilityClass 标准化工具类 纯静态方法的工具类 方法不写 static,自动加
@With 不可变对象的字段克隆 @Value 不可变对象 浅拷贝,嵌套对象需手动深拷贝
@Singular 集合字段逐元素构建 @Builder 中有 List/Set/Map 单数方法名可能推断不准

总结

  1. JPA Entity 辨别场景用 @Data ------用 @Getter + @Setter 分开控制,@ToString.Exclude 打断双向递归
  2. @Builder + @SuperBuilder 是日常编码最大提效工具 ------字段 ≥4 个的类直接上 Builder,继承场景用 @SuperBuilder
  3. @SneakyThrows@Cleanup@UtilityClass@With@Singular 虽小但实用------一个注解替代十几行样板代码,知道它们存在就是生产力。下次遇到 Stream 里写 try-catch、手写 Builder 内部类,想想这六个

Lombok 不只是 @Data。用对了,你的 Java 代码可以少写 30% 的样板代码------而且不会再因为打日志把服务搞崩。


完整源码

本文 Demo 的完整代码已上传到 Gitee:

arduino 复制代码
https://gitee.com/gcchech/articles-demo

进入 lombok-annotations/ 目录:

bash 复制代码
mvn spring-boot:run

所有注解的演示代码按模块组织在 com.coderplus.lombok 包下,每个 Demo 独立运行,输出内容见下方。注意:为便于教学,文章中的类名做了简化(如 WithBuilderDemoUserAfterStringUtils),功能完全一致。


实际运行验证

以下是本地 JDK 21 + Spring Boot 3.2.7 + Lombok 1.18.34 环境下的真实控制台输出(已去除 Spring 启动日志)。

@Builder / @SuperBuilder

less 复制代码
[手写Builder] User{name='张三', age=25, email='zhangsan@example.com', phone='13800138000'}
[手写Builder] 代码行数:60+ 行(构造器 + Builder 内部类 + 字段重复声明)
[@Builder] WithBuilderDemo(name=张三, age=25, email=zhangsan@example.com, phone=13800138000)
[@Builder] 代码行数:~15 行(只有字段声明 + @Builder + @ToString)
[@SuperBuilder] InheritanceDemo.Dog(super=InheritanceDemo.Animal(name=旺财, age=3), breed=金毛)
[@SuperBuilder] 父类 Animal 和子类 Dog 都加 @SuperBuilder,父类字段自动出现在子类 Builder 中

@SneakyThrows

java 复制代码
[@SneakyThrows] 成功读取 2 行内容
[@SneakyThrows] lambda 中不再需要 try-catch 包装,被 @SneakyThrows 标记的方法在编译时 bytecode 层面隐藏了 throws 声明
[@SneakyThrows] 异常仍然会正常抛出(不会被静默吞掉): NoSuchFileException

@Cleanup

typescript 复制代码
[@Cleanup] 读取文件内容:
  Hello @Cleanup!
  第二行内容
[@Cleanup] 当前作用域结束后,reader → isr → fis 按声明逆序自动关闭
[@Cleanup] 对比 try-with-resources:省去了嵌套的 () 声明,代码更扁平
[@Cleanup ⚠️] 注意作用域陷阱:资源在变量所在作用域结束时才关闭,
            如果方法开头声明 @Cleanup、中间有耗时操作,资源会一直占用。
            解决方案:用 {} 包裹,缩小作用域------就像上面演示的那样。

@UtilityClass

sql 复制代码
[手写版] Before.toUpperCase('hello'): HELLO
[手写版] Before.isEmpty(''): true
[手写版] Before.defaultIfEmpty(null, '默认'): 默认
[@UtilityClass] After.toUpperCase('hello'): HELLO
[@UtilityClass] After.isEmpty(''): true
[@UtilityClass] After.defaultIfEmpty(null, '默认'): 默认
[@UtilityClass] 手写版 20+ 行样板代码 → @UtilityClass 版 14 行,且更不容易出错
[@UtilityClass] 反射也无法实例化: java.lang.IllegalAccessException: ... cannot access ... with modifiers "private"

@With

less 复制代码
[@With] 原始对象: WithDemo.User(name=张三, age=25, email=zhangsan@example.com)
[@With] 修改年龄后: WithDemo.User(name=张三, age=30, email=zhangsan@example.com)
[@With] 原始对象未被修改: WithDemo.User(name=张三, age=25, email=zhangsan@example.com)
[@With] 两个对象是否相同引用: false
[@With] 链式修改后: WithDemo.User(name=张三丰, age=25, email=zhangsanfeng@example.com)
[@With] 关键:每次 withXxx() 返回一个新对象(浅拷贝),原对象不变。适合配合 @Value 实现不可变数据模型

@Singular

scss 复制代码
[@Singular] SingularDemo.Article(title=Lombok 注解速查, tags=[Java, Lombok, 注解速查], contributors=[张三, 李四], scores={张三=95, 李四=88})
[无@Singular] SingularDemo.Article(title=Lombok 注解速查, tags=[Java, Lombok, 注解速查], contributors=[李四, 张三], scores={李四=88, 张三=95})
[@Singular] 优势:可以按需逐个添加元素,代码更自然易读。
[@Singular] 生成的额外方法:tag(T) / clearTags() / tags(Collection) 等
[@Singular clearTags] SingularDemo.Article(title=测试, tags=[最终标签], contributors=[], scores={})

@Data 的坑 vs 正确做法

scss 复制代码
[@Data 错误] 尝试打印 user 对象...
[@Data 错误] ❌ StackOverflowError!
[@Data 错误] 原因:User.toString() → orders.toString() →
          每个 Order.toString() → user.toString() → 无限递归

[@Getter + @ToString.Exclude 正确] DataPitfallDemo.GoodUser(name=张三)
[@Getter + @ToString.Exclude 正确] DataPitfallDemo.GoodOrder(product=电脑)
[正确] @ToString(exclude="orders") 和 @ToString(exclude="user") 打断了循环引用
[最佳实践] JPA Entity 永远不要用 @Data!只用 @Getter + @Setter,需要 toString 时显式写或用 @ToString.Exclude

验证清单

验证项 注解 结果
Builder 替代 60+ 行样板代码 @Builder
继承场景父类字段可用 @SuperBuilder
Stream 中调 throws 方法不报错 @SneakyThrows
异常仍正常抛出,不被吞掉 @SneakyThrows
资源自动按逆序关闭 @Cleanup
工具类防实例化(含反射) @UtilityClass
with 方法返回新对象,原对象不变 @With
逐元素构建集合 @Singular
clearTags 清空已添加元素 @Singular
@Data 双向关联 StackOverflow --- ✅ 复现
@ToString.Exclude 打断递归 ---
相关推荐
梅头脑1 小时前
交易跨10个库、日均千万订单——选错一次分布式事务方案,加班三个月重写
java·分布式
董员外1 小时前
RAG 系统进化论(二):Naive RAG,检索增强生成的最小闭环
前端·人工智能·后端
久久学姐1 小时前
基础转码学 AI:Java+Python 双语言入门,3 个月可落地实战项目
java·python·ai·转码·实战项目
花生了什么事o1 小时前
synchronized 与 ReentrantLock:Java 锁机制原理与实现对比
java·开发语言
掘金一周1 小时前
看看大家每月的成本有多少 | 沸点周刊 7.30
前端·人工智能·后端
aramae2 小时前
C++ IO流完全指南:从C标准库到C++流式编程
服务器·c语言·开发语言·c++·后端
SelectDB2 小时前
Apache Doris 事务保障实战教程:从三阶段提交到 Flink 精确一次写入
后端
SelectDB2 小时前
Apache Doris 向量化执行实战教程:从 CPU 指令到性能验证的完整实践
后端
神奇小汤圆2 小时前
别再乱排查了!Kafka 消息积压、重复、丢失,根源基本都是 Rebalance!
后端