栏目:注解速查 | 环境: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 一样 :按声明的逆序关闭(后声明的先关)- 必须实现
AutoCloseable或Closeable接口 ------否则@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(...) - 适用于一切"只有静态方法的工具类" :
StringUtils、DateUtils、CollectionUtils、FileUtils等 - 不适合需要实例化或继承的类 ------工具类本身就是"无状态 + 纯函数",如果某个类需要
@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 自动推断的 ------
tags→tag、items→item、users→user。如果推断不对(比如children→ Lombok 可能推不出child),用@Singular("customName")显式指定 - 支持的集合类型 :
List、Set、SortedSet、NavigableSet、Map、SortedMap、NavigableMap(对应的 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 |
单数方法名可能推断不准 |
总结
- JPA Entity 辨别场景用
@Data------用@Getter+@Setter分开控制,@ToString.Exclude打断双向递归 @Builder+@SuperBuilder是日常编码最大提效工具 ------字段 ≥4 个的类直接上 Builder,继承场景用@SuperBuilder@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 独立运行,输出内容见下方。注意:为便于教学,文章中的类名做了简化(如 WithBuilderDemo → User、After → StringUtils),功能完全一致。
实际运行验证
以下是本地 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 打断递归 | --- | ✅ |