1. 从一段"只有字段"的实体说起:@Data 到底替谁干了活
1.1 那个编译通过却满屏红线的下午
前阵子帮同事看一段代码,他把一个实体类推上来,长这样:
public class OrderVO { private Long id; private String orderNo; private BigDecimal amount; }本地mvn clean package顺利通过,CI 也是绿的。但他本地的 IDE 里,所有orderVO.getOrderNo()的调用点全是红色波浪线,提示"找不到符号"。他第一反应是"jar 包没下全",重新 import 了三遍,重启了两次 IDE,还是红的。
问题其实跟网络、跟依赖都没关系:他的 IDEA 里没装 Lombok 插件,或者装了但注解处理被关掉了。而 CI 之所以能过,是因为 Maven 编译阶段的注解处理器(Annotation Processor)是独立于 IDE 插件工作的——编译器在生成字节码之前,把方法补进去了,所以class文件里确实存在getOrderNo(),而 IDE 的静态分析器只认源码,源码里没有这个方法,它就报红。
这件事很典型,它把@Data这个注解最容易被误解的一点暴露出来了:@Data不是运行时反射,它是编译期代码生成。方法从来没消失过,只是不在你眼前。
1.2 把 @Data 折算成五个注解
很多人用了一年@Data都没搞清它到底生成什么。其实它就是把下面这五个注解打包贴在类上:
| 组成部分 | 生成内容 | 影响范围 |
|---|---|---|
@Getter | 每个字段的读方法 | 所有非静态字段 |
@Setter | 每个字段的写方法 | 所有非静态、非 final 字段 |
@ToString | toString() | 所有非静态字段(含 transient) |
@EqualsAndHashCode | equals()与hashCode() | 所有非静态、非 transient 字段 |
@RequiredArgsConstructor | 只含"必需参数"的构造方法 | final 字段与@NonNull字段 |
这里有几个细节必须记住,因为它们决定了你会不会踩坑:
第一,@Data不包含无参构造,也不包含全参构造。它只给一个"必需参数构造器"。所谓"必需",指的是被final修饰的字段,以及被@NonNull标注且在声明处没有赋初值的字段。一个普通类如果没有 final 字段、没有@NonNull,那生成的其实是零参数的构造方法——所以你会觉得"它好像给我加了个无参构造"。这种"看起来有、实际是靠巧合"的假象,是后面很多序列化问题的源头。
第二,equals/hashCode不参与静态字段,也不参与 transient 字段;而toString参与 transient,不参与静态。getter/setter则完全跳过静态字段。三个注解的字段筛选规则各不相同,这一点几乎没有文档会专门强调,但在做缓存序列化、做审计日志时非常关键。
第三,如果类里已经显式写了任何一个构造方法,@RequiredArgsConstructor就不会再生成。这条规则同样适用于@AllArgsConstructor、@NoArgsConstructor,它们的判断是"有没有显式构造",而不是"有没有同签名的构造"。所以在一个已经手写了全参构造的类上再挂@Data,你拿不到那个必需参数构造器,也不会报错,只会在调用处找不到构造方法。
第四,这个注解是lombok.Data,不要和 Spring 的@Data搞混——Spring 里没有这个注解;org.springframework.data.annotation下的注解是给持久化映射用的,跟 Lombok 完全两回事。项目里偶尔能看到有人 import 错了包,编译器不会提醒你,因为两个包都真实存在。
1.3 源码里看不见的方法,凭什么能被调用
Java 的注解处理器在javac的"解析(parse)"之后、"生成字节码(generate)"之前运行。Lombok 在这里做了一件比较激进的事:它直接操作 javac 的内部抽象语法树(AST),往类节点上挂新的方法节点,同时把注解本身从最终产物里抹掉。所以你反编译OrderVO.class,看到的是货真价实的getOrderNo(),而@Data的符号引用在 class 文件里根本不存在——这也解释了为什么 Lombok 是provided作用域就够了,运行时不需要这个 jar。
想亲眼确认,有两个办法。
一个是反编译。用javap -p -c看字节码里的方法表:
javap -p target/classes/com/example/OrderVO.class另一个是delombok,把注解展开成实实在在的源码,写代码评审时特别有用:
java -jar lombok.jar delombok src/main/java -d target/delombok我在团队里推过一个规矩:凡是第一次引入 Lombok 的模块,评审前先跑一遍 delombok,把展开后的代码给 reviewer 看一眼。因为很多时候,作者自己都没想到@Data会生成那么长一串equals,更没想到那个equals会把所有业务字段都算进去。
2. 五个注解各自的脾气:逐个拆开看生成规则
2.1 @Getter 与 @Setter:修饰符、is 前缀与链式调用
@Getter/@Setter可以贴在类上,也可以贴在单个字段上做覆盖。字段级的配置优先级高于类级,这是做精细控制的主要手段。
最常见的几个用法:
@Data public class UserProfile { private Long id; private String nickName; // 只读:不生成 setter @Setter(AccessLevel.NONE) private String idCardNo; // 对外只给读,包内可写 @Setter(AccessLevel.PACKAGE) private Integer status; }这里要提醒一个容易忽视的点:AccessLevel.NONE是"不生成",不是"生成 private"。有些人为了"禁止外部修改"选了PRIVATE,结果同一个类里还是能改,而且反射照样能改,起不到预期效果。
另一个坑是关于boolean字段的前缀。对于boolean active,Lombok 默认生成isActive();但对于Boolean active(包装类型),生成的是getActive()。这个差异会直接影响两类东西:一是 JSON 序列化时的字段名(Jackson 对isXxx和getXxx的识别逻辑不完全一样),二是 JSP/模板引擎里的属性访问。lombok.getter.noIsPrefix = true可以全局关掉is前缀,但如果你在同一个工程的不同模块里混用,就会出现同一个 DTO 在不同接口里字段名不同的荒唐现象。我的建议是:要么全项目统一,要么在字段上显式写@Getter加自定义方法。
链式调用也是常见需求。在lombok.config里打开lombok.accessors.chain = true,所有 setter 会返回this:
UserProfile p = new UserProfile().setNickName("张三").setStatus(1);但请注意,链式 setter 会让setXxx的返回类型从void变成类本身,这会破坏某些依赖"标准 JavaBean 签名"的框架。尤其是一些老版本的 ORM、模板引擎、以及做法反射调用的通用代码,它们会按void setXxx(T)去匹配方法。所以我一般只在纯 DTO、Internal API 的模型上开链式,实体类上不开。
2.2 @ToString:字段裁剪、循环引用与 includeFieldNames
toString()是排查问题时用得最多的方法,也是@Data里最容易闯祸的一个。
默认情况下它把所有非静态字段都拼进去,包括密码、身份证号、大文本、二进制内容。线上日志里出现完整的用户敏感信息,很多时候就是这里漏了裁剪:
@Data public class LoginForm { private String username; @ToString.Exclude private String password; @ToString.Exclude private byte[] avatar; @ToString.Include(name = "pwdLen") private int passwordLength() { return password == null ? 0 : password.length(); } }@ToString.Exclude是最省事的做法。如果你更希望"默认什么都不输出,只输出我指定的",可以改用@ToString(onlyExplicitlyIncluded = true),然后给每个想输出的字段加@ToString.Include。这个反向配置在安全敏感的对象上非常值用,因为它是"默认拒绝"的策略,新增字段不会自动泄露。
includeFieldNames默认是true,输出形如UserProfile(id=1, nickName=张三)。有人嫌啰嗦把它关掉,输出UserProfile(1, 张三),结果两个字段类型都是 String 的时候完全看不出谁是谁。这个开关我从来不动,格式化多几个字符的成本,远低于排查时认错字段的代价。
至于循环引用,它是@ToString的头号事故。两个实体互相持有引用(订单持有用户,用户持有订单列表),或者实体持有自己的父节点,toString()会一直递归到栈溢出。这类问题不会在开发环境暴露,往往是在打日志的那一刻突然StackOverflowError,而且异常栈特别长,很吓人。处理方式有两种:用@ToString.Exclude断掉其中一条边,或者干脆在关联字段上只输出 ID。
2.3 @EqualsAndHashCode:callSuper 与那个"不调用父类"的警告
这是@Data里最需要动脑子的部分,因为equals的语义一旦写错,问题会以最隐蔽的方式爆发。
Lombok 在生成equals/hashCode时,默认不会调用父类的实现,并且会给你一条警告:
Generating equals/hashCode implementation but without a call to superclass, even though this class does not extend java.lang.Object.这条警告经常被淹没在几千行构建日志里。它的含义是:如果父类也有自己的状态,子类比较时会完全忽略父类字段。最经典的后果就是——父类和子类实例互相比较时,永远不会相等,因为 Lombok 生成的equals里有一句o instanceof SubClass的判断(更准确说是canEqual机制),父类实例过不了这道检查。
处理这个问题的开关是lombok.equalsAndHashCode.callSuper,取值call、skip、warn。我个人的判断标准很简单:
- 父类是纯抽象、无状态、只提供行为的(比如一个
BaseService之类),skip,警告关掉。 - 父类本身带业务字段(比如
BaseEntity里有createTime),那要么call,要么更彻底一点,在子类上用@EqualsAndHashCode(onlyExplicitlyIncluded = true)加若干个@EqualsAndHashCode.Include,明确只按业务主键比较。
顺带说一个canEqual机制。Lombok 生成equals时会在类里偷偷塞一个protected boolean canEqual(Object other),子类生成时把它覆写成只认自己的类型。这是为了在"子类继承父类、父类也生成 equals"的场景下避免对称性被破坏。这个设计本身没问题,但它意味着:只要你在继承链的两端都生成了 equals,它们之间的相等关系就会变得比你想象的严格。很多人第一次遇到"两个值一模一样的对象 equals 返回 false",根因就在这里。
2.4 @RequiredArgsConstructor:final、@NonNull 与构造器冲突
@Data附带的这个构造器,实际价值比很多人以为的大。它天然支持构造器注入风格:
@Data public class OrderService { private final OrderRepository orderRepository; private final NotifyClient notifyClient; }上面这个类只有一个构造方法,接收两个 final 依赖,字段又都是 final、不可变。这比@Autowired字段注入好得多:依赖不可变、便于单元测试里直接 new、也不会出现循环依赖时那种启动期才报的怪问题。所以我在 Service 层其实是把@Data拆开用的——不挂@Data,只挂@RequiredArgsConstructor,再加@Getter给需要暴露的字段。
冲突场景主要有两个。一是和@Builder一起用,这个放在第 4 节细讲。二是@NonNull的语义容易被误解:
@Data public class Config { @NonNull private String appKey; }它会生成带判空的构造方法,并在 setter 里也插入判空逻辑——传null进去会抛NullPointerException,且异常消息是 Lombok 拼的,不是你的业务消息。有些人以为@NonNull只是个文档标记,结果在容器里传了个 null 参数进来,抛出的 NPE 完全看不到上下文,排查时一脸茫然。我一般会在lombok.config里加lombok.nonNull.exceptionType = IllegalArgumentException,让语义更直白一些。
还有一个细节:@NonNull字段如果在声明处赋了初值(比如private String appKey = "default";),那它不会被算进"必需参数"里,也就不会出现在构造器参数列表里。这个规则跟 final 字段一样,很容易被忽略。
3. 哪些类不该图省事挂 @Data:四类危险场景
3.1 数据库实体:主键未赋值时的 equals 悖论
这是我在真实项目里见过最多的一类事故。
@Data @Entity @Table(name = "t_order") public class Order { @Id @GeneratedValue private Long id; private String orderNo; private BigDecimal amount; }问题不在语法,在语义。@Data生成的equals会把id、orderNo、amount全算进去。于是:
- 两个刚
new出来、还没入库的对象,id都是null,字段也一样,它们会被判为相等。你不能在HashSet里同时放两个新订单。 - 你
new了一个Order(orderNo = "A")放进HashSet,然后把orderNo改成"B",这个对象就再也找不回来了,因为它的hashCode变了。这就是 3.3 要讲的哈希漂移。
正确做法是给实体类明确指定相等性依据。要么只用主键,且对未持久化对象做特殊处理:
@Getter @Setter @ToString @EqualsAndHashCode(onlyExplicitlyIncluded = true) @Entity public class Order { @Id @GeneratedValue @EqualsAndHashCode.Include private Long id; private String orderNo; private BigDecimal amount; @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Order)) return false; Order other = (Order) o; if (this.id == null || other.id == null) return false; // 未持久化对象不与其他实例相等 return this.id.equals(other.id); } @Override public int hashCode() { return id == null ? System.identityHashCode(this) : id.hashCode(); } }要么更省事一点,干脆不挂@Data,只挂@Getter/@Setter,equals从Object继承(按引用比较)。对 JPA 实体来说,按引用比较在很多场景下反而是最安全的默认值——因为 Hibernate 保证同一个持久化上下文里,同一条记录只会有一个 Java 实例。
判断标准:这个实体会不会被放进Set、会不会作为Map的 key、会不会用List.contains()做去重?如果会,就必须认真设计 equals;如果不会,那就别用@Data去生成一个你根本不需要、还可能出错的实现。
3.2 懒加载代理:instanceof 与 getClass 的差别
接着上面的话题。假设你在Order上只按主键做相等性判断,还写了o instanceof Order这种检查——这在 Hibernate 场景下是正确的做法,因为 Hibernate 的懒加载代理类是Order的子类,instanceof能通过,getClass() == o.getClass()则通不过。
反过来,如果你手写 equals 时用了getClass()比较,那就会出现"从session.get()拿到的对象和从关联属性里拿到的代理对象不相等"这种灵异现象。表现是:明明查出来是同一条订单,list.contains(order)永远是false。
这个坑之所以难查,是因为它在单测里通常复现不了——单测用的是 new 出来的真实实例,没有代理。只有到了集成环境、开启了懒加载,才会冒出来。我在评审实体类代码时,只要看到手写的equals里出现getClass(),基本都会要求改成instanceof加canEqual的写法,或者干脆去掉自定义 equals。
还有一种相关的坑:代理对象在toString()里触发懒加载,如果此时 Session 已经关闭,就会抛LazyInitializationException。表现是"打个日志就报错",日志本身把现场搞炸了。所以实体类上的toString一定要把关联字段排除掉,或者只输出关联对象的 ID。
3.3 可变字段做 Map 键:hashCode 漂移现场
这个场景不限于实体,任何挂@Data的可变对象都可能遇到:
@Data public class Point { private int x; private int y; } Map<Point, String> map = new HashMap<>(); Point p = new Point(); p.setX(1); map.put(p, "起点"); p.setX(2); System.out.println(map.get(p)); // nullput的时候hashCode按x=1算,存进了 1 号桶;改完x=2,get时按x=2算,跑到 2 号桶去找,自然找不到。对象其实还在 Map 里,成了"幽灵键",只能靠遍历entrySet才能捞出来。
@Data让这个坑变得特别容易踩,因为它默认就把所有字段放进hashCode,你一行代码都没写就中招了。防护手段有三个层次:
- 用作 Map 键的对象设计成不可变(字段全 final,没有 setter)。
- 必须可变时,把参与
hashCode的字段收窄到不可变的那些,用@EqualsAndHashCode(onlyExplicitlyIncluded = true)。 - 最保守的一种:这类对象不要挂
@Data,用@Getter加手写的equals/hashCode,只算 ID。
我曾经在一个权限模块里见过更隐蔽的版本:Map<Role, Set<Permission>>缓存,角色对象被人中途改了roleName,导致缓存再也命中不了,每次都要重新查库。性能问题排查了两天,最后是靠把 Map 里所有 key 打印出来跟预期对比才发现的。
3.4 双向关联与集合字段:递归与"改集合等于改哈希"
双向关联(Order持有List<OrderItem>,OrderItem持有Order)挂上@Data之后,toString会无限递归,equals也会互相调用,最终都是StackOverflowError。而且这个异常栈有几千行,打印出来能刷满整个终端。
处理规则我总结成一句话:关联字段一律排除出 equals、hashCode 和 toString,只保留标量字段。
@Data public class Order { private Long id; private String orderNo; @ToString.Exclude @EqualsAndHashCode.Exclude private List<OrderItem> items; }另外一个不能忽视的点:@Data生成的hashCode如果包含集合字段,那集合内容一变,hashCode就变。这不仅影响 Map 键,还会影响"对象存入 HashSet 后又被修改"的场景。集合字段几乎永远是"可变状态",把它算进hashCode里,等于给自己埋雷。
顺带说一句,@Data生成的getter直接返回集合引用,调用方可以随意往里面加元素,破坏封装。如果确实需要对外暴露集合,我会显式写:
public List<OrderItem> getItems() { return items == null ? Collections.emptyList() : Collections.unmodifiableList(items); }手写之后,Lombok 就不会再生成同名方法,不会冲突。
4. 框架协同:Jackson、MapStruct、MyBatis 眼里的 @Data
4.1 Jackson 反序列化时挑哪个构造函数
@Data和 Jackson 的冲突,几乎每次都在同一个地方爆发:没有无参构造。
前面说过,如果类里有 final 字段或者@NonNull字段,@Data会生成一个带参数的构造方法。此时类就没有无参构造了。Jackson 在把 JSON 转成对象时,如果找不到无参构造、也没有标注@JsonCreator的构造方法,就会抛:
InvalidDefinitionException: Cannot construct instance of `com.example.OrderVO` (no Creators, like default constructor, exist)解决方案有三种,各有适用场景:
| 方案 | 写法 | 适用场景 |
|---|---|---|
| 补无参构造 | 加@NoArgsConstructor | 大部分 Web 请求体,最省事 |
| 标注构造方法 | 在必需参数构造上加@JsonCreator,参数加@JsonProperty | 明确要做不可变对象 |
| 加构造属性名 | lombok.anyConstructor.addConstructorProperties = true | 多框架共用,一次配置全局生效 |
第三种值得单独说说。打开这个开关后,Lombok 会在生成的构造方法上加@java.beans.ConstructorProperties({"fieldA", "fieldB"})。Jackson、MyBatis、部分映射框架都能识别这个注解,从而知道参数和字段的对应关系。注意这要求构造器参数名和字段名保持一致,而 Lombok 本来就是按字段名生成的,所以天然匹配。这个配置我在多个项目里用过,确实省掉了大量手写@JsonProperty的工作。
还有一个默认行为要留意:@Data的getter/setter让 Jackson 默认按 JavaBean 属性名做映射,nickName对应 JSON 里的nickName。如果你在字段上加@JsonProperty("nick_name"),这个配置会被 Lombok 生成的 getter/setter 继承(因为 Jackson 会从字段、getter、setter 三处合并注解信息),不会丢,这点可以放心。
4.2 @Builder 与 @Data 同时上:构造函数被谁占了
@Data配@Builder是最常见的组合,但很多人写完发现反序列化坏了,原因在构造函数的归属上。
规则是这样的:@Builder需要一个全参构造方法。如果类里没有显式构造,@Builder会自己生成一个包级可见的全参构造。而这个构造方法一旦存在,@Data附带的@RequiredArgsConstructor就认为"已经有构造了",于是什么也不生成。
结果就是:你的类只有一个包级全参构造,没有无参构造,Jackson 反序列化失败。
所以这个组合的标准写法其实是:
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor public class OrderVO { private Long id; private String orderNo; }也就是把@Data拆成@Getter+@Setter,然后显式声明两个构造方法。这样谁都满意:@Builder用全参构造,Jackson 用无参构造。
这里顺便带走一个经验:只要同时用@Builder和任何序列化框架,就显式写全@NoArgsConstructor+@AllArgsConstructor,别指望注解之间的隐式协商。我在评审时看到@Data @Builder裸配,基本会直接提 comment 要求补构造方法,因为这种问题在本地自测时往往碰不到——本地通常只测对象转 JSON,没测 JSON 转对象。
4.3 MyBatis 结果映射与无参构造的硬性要求
MyBatis 默认的resultType映射走的是"无参构造 + setter"这条路。它有两条路可选:一是无参构造后逐个set,二是带参构造按列名匹配。默认走第一条。
所以如果你给实体挂了@Data,并且这个实体没有无参构造(比如所有字段都是 final),MyBatis 就会抛异常,报找不到合适的构造方法。这一点在"实体类字段全 final、追求不可变"的团队里经常翻车。
如果想走构造器映射,也可以在 resultMap 里用<constructor>显式声明,或者在 Maven 里配置lombok.anyConstructor.addConstructorProperties = true让 MyBatis 自动识别。我一般的选择是:持久层实体一律保留无参构造,不管是不是追求不可变。持久层对象的生命周期本来就由框架控制,谈不可变收益很低,反而增加集成成本。
还有一点,@Data生成的 setter 都返回void(除非开了链式)。MyBatis 通过反射找setXxx,链式 setter 也能被识别(它只看方法名和参数),但有些老版本的 MyBatis-Plus 或者自定义 TypeHandler 在按标准签名严格匹配时可能会出问题。所以实体类上我从不开启链式访问器。
5. 工程化配置:让 Lombok 在 IDE 和构建流水线里稳定下来
5.1 Maven 与 Gradle 的依赖声明和注解处理器路径
Maven 里用provided就够:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <scope>provided</scope> </dependency>但有个细节:如果你在pom.xml里配置了annotationProcessorPaths,那么 Lombok 就必须出现在这个列表里,因为它会覆盖 classpath 上的处理器发现机制:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${mapstruct.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin>我见过不止一次"本地好的、打包就报错"的案例,根因就是引入 MapStruct 之后配了annotationProcessorPaths但漏了 Lombok,于是 Lombok 完全不生效,所有 getter 找不到。报错信息是"找不到符号 getXxx",跟依赖缺失一模一样,容易带偏排查方向。
Gradle 的写法:
dependencies { compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' testCompileOnly 'org.projectlombok:lombok:1.18.30' testAnnotationProcessor 'org.projectlombok:lombok:1.18.30' }测试相关的两行不能省,否则单测里用到的 Builder、getter 全是红的。
另外一个组合问题:Lombok 和 MapStruct 一起用时,处理顺序有影响。MapStruct 在生成映射实现类时需要读取源类的方法签名,如果 Lombok 还没处理,它看到的源码里就没有 getter。解决办法是把 Lombok 放在annotationProcessorPaths的前面(新版 Lombok 和 MapStruct 都做了兼容处理,但显式排序更稳),或者把 MapStruct 相关代码放到单独的模块里编译。这类问题在构建日志里不会明确报错,只会表现为"映射类里所有字段都是 null",特别费时间。
5.2 lombok.config 里值得打开的几项
lombok.config放在项目根目录或者源码目录下,逐级生效。为避免向上找到父目录意外继承配置,第一行通常写:
config.stopBubbling = true我常用的几项:
| 配置项 | 值 | 作用 |
|---|---|---|
lombok.addLombokGeneratedAnnotation | true | 生成的代码加@lombok.Generated,让 JaCoCo 等覆盖率工具忽略 |
lombok.anyConstructor.addConstructorProperties | true | 生成构造方法参数名注解,方便 Jackson/MyBatis |
lombok.data.flagUsage | ERROR | 在指定模块里禁止使用@Data |
lombok.equalsAndHashCode.callSuper | skip或call | 明确继承链上的相等性策略 |
lombok.nonNull.exceptionType | IllegalArgumentException | 让@NonNull抛出语义更清晰的异常 |
第一项特别值得打开。它常被忽略,但效果很直接:不打开的时候,JaCoCo 会把equals、hashCode、toString这些自动生成的方法都算进覆盖率分母,一个 POJO 多的项目覆盖率会凭空掉十几个点。打开之后这些方法被标记为"生成的",覆盖率统计就干净了。我见过团队为了凑覆盖率数字去给 POJO 写测试,就是在跟这个配置较劲。
第三项是团队治理的利器。我们有个模块专门存放对外接口模型,评审后决定全面禁用@Data,只允许显式@Getter/@Setter加自定义equals。做法就是在那个模块的lombok.config里写lombok.data.flagUsage = ERROR,谁不小心挂了@Data,编译直接失败。这种强制手段比写在文档里管用得多,因为它是编译期拦截。
5.3 JDK 升级后那个 IllegalAccessError 的来龙去脉
JDK 版本往上升的时候,老版本 Lombok 会突然炸掉,报这种错:
java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor (in unnamed module @0x...) cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.processing to unnamed module @0x...原因在于 Lombok 直接操作 javac 的内部 AST,而新 JDK 的模块系统收紧了这些内部包的访问权限。这不是你的代码问题,是工具和 JDK 的适配问题。
两个处理方向。首选是把 Lombok 升到跟 JDK 匹配的版本——这个系列一直在跟进,新版本基本能覆盖较新的 JDK,具体版本号对着官方 changelog 挑一个比当前 JDK 更晚发布的即可。次选是在编译参数里打开导出:
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED但我个人更倾向升级 Lombok,因为--add-exports这种方案要写进构建脚本、还得保证所有开发者的 IDE 编译参数一致,维护成本更高,而且升级 JDK 之后这类参数还会一直叠加上去,越积越乱。
这里有个团队协作上的经验:Lombok 的版本应该跟 JDK 版本一起纳入统一升级计划。很多团队的 pom 里写的是${lombok.version},但继承自某个父 pom,实际锁在一个很旧的值上,几年不动。等到某次要升级 JDK,才发现构建直接挂了,然后一边排查业务代码一边猜是不是 Lombok 的问题。把这两个版本放在同一张升级清单上,能省掉不少事。
6. 排查实录:三个报错现场的完整链路
6.1 子类比较永远返回 false
现象:一个测试用例里,两个字段完全一样的SubOrder对象,equals返回false。
排查链路是这样的。
第一步,确认是不是同一个类加载器加载的。用a.getClass() == b.getClass()和a.getClass().getClassLoader() == b.getClass().getClassLoader()打印一下,确认是同一个类,排除了热部署导致的类加载器差异——这是最容易被误判的方向,尤其在 IDE 里跑测试的时候。
第二步,反编译看生成的equals。javap -p -c target/classes/.../SubOrder.class,看到了canEqual调用,也看到了instanceof SubOrder的判断,逻辑本身没问题。
第三步,找到父类。父类BaseOrder也挂了@Data,并且它自己的equals里也有一句instanceof BaseOrder。而SubOrder.equals在比较完后调用了other.canEqual(this),canEqual在SubOrder里被覆写成只接受SubOrder实例。
问题是这样的:如果代码里有一步是把SubOrder赋给了BaseOrder类型的变量再去做比较,或者列表里存的是父类类型、实际元素是子类,那么对称性要求会导致两边路由不同,结果就飘忽不定。真正让人困惑的是"看起来完全一样却不相等"。
最终的根因其实是更简单的一件事:BaseOrder里有个status字段,两边确实不同,只是打印日志时被@ToString.Exclude排除掉了,看日志看不出来。
这个案例给我两个教训。第一,排查 equals 问题时先把toString的排除项临时去掉,或者直接打印所有字段的反射值,别信任被裁剪过的日志。第二,继承链上不要两代都挂@Data,父类要么不生成 equals,要么明确callSuper = call,让语义可预测。
6.2 StackOverflowError 的调用栈怎么读
现象:一个接口偶尔返回 500,日志里是超长的StackOverflowError。
这种栈其实很好读,不用怕它长。看最上面那几十行,找到重复出现的两三个方法名,那就是递归环:
at Order.toString(Order.java:12) at OrderItem.toString(OrderItem.java:15) at Order.toString(Order.java:12) at OrderItem.toString(OrderItem.java:15) ...一眼就能看出是Order和OrderItem互相引用。修复就是在一侧的关联字段上加@ToString.Exclude,或者更彻底,两侧都加,各自只输出自己的标量字段。
这里要提醒一个容易走偏的方向:有些人第一反应是"日志打太多把栈打爆了",去调 JVM 的栈大小。栈大小确实能延后爆栈的时机,但只是在增加递归深度,问题还在,而且下一次可能换成equals递归或者序列化时的递归。治标的做法是把-Xss调大,治本的做法是把关联字段排除掉。
另外,equals/hashCode也可能形成递归环,而且比toString更隐蔽——因为它不一定在日志里出现,可能表现为某个接口偶尔卡死或者 CPU 飙高。判断依据同样是看栈里的重复方法名。如果确认是 equals 递归,最干脆的修复是把关联字段用@EqualsAndHashCode.Exclude排除。
6.3 JaCoCo 覆盖率突然掉下来
现象:某次迭代没加多少代码,覆盖率从 78% 掉到 61%。
第一反应是新增业务代码没写测试。但看增量覆盖率报告,新增代码的覆盖率其实是满的。问题出在某个 POJO 模块上,那个模块的Instruction覆盖数几乎为零。
到这里基本就能定位了:引入了 Lombok 之后,一批 POJO 自动生成了equals、hashCode、toString、getter、setter,这些方法被计入了统计分母,但没人会给它们写测试。
验证方式很简单:用 delombok 把源码展开,数一下生成的方法行数,和覆盖率分母的增量对一下,数量级能对上就基本确定了。
修复:在lombok.config里加lombok.addLombokGeneratedAnnotation = true,重新跑覆盖率。JaCoCo 从 0.8.0 之后会识别@lombok.Generated并跳过这些方法,覆盖率立刻回到正常水平。
这个坑还有个变体:如果项目里同时用了别的代码生成工具(比如某些 Mapper 生成器),它们的产物同样会被计入分母,需要各自找对应的排除配置。我的建议是引入任何代码生成工具时就同步处理覆盖率排除,别等到季度考核前才发现数字难看。
7. 手写还是交给注解:record、显式代码与团队规范
7.1 record 能替代 @Data 的部分
Java 14 之后有了record,它天生就是不可变数据载体,自动生成访问器、equals、hashCode、toString:
public record OrderVO(Long id, String orderNo, BigDecimal amount) {}跟@Data相比,差异挺明确:
| 对比项 | @Data | record |
|---|---|---|
| 可变性 | 默认可变,有 setter | 天生不可变,无 setter |
| 继承 | 支持继承 | 不能继承其他类 |
| 无参构造 | 视情况可能没有 | 永远没有 |
| 字段扩展 | 随时加字段 | 声明即定,改构造就破坏兼容 |
| 序列化框架支持 | 成熟 | 需要框架版本支持 |
record在纯内部传输、方法返回值、值对象这类场景挺好用,特别是它天然不可变,直接从设计上避免了 3.3 那类哈希漂移问题。但它在 Web 请求体上不太合适——没有无参构造,Jackson 需要额外配置或框架版本支持;字段一多,构造函数参数列表又长,改一次就得动所有调用点。
我现在的取舍是:内部服务之间传值用record,需要跟外部框架来回序列化的用@Data或显式的@Getter/@Setter。混着用完全没问题,关键是每个类都想清楚它的可变性需求,而不是见到"数据类"就无脑挂@Data。
7.2 什么情况下我会手写 getter/setter
有三种情况我宁愿多敲几行。
第一种,setter 里需要校验或归一化。比如金额必须setScale(2),手机号要去空格。手写之后 Lombok 就不会生成同名方法,语义清楚:
public void setAmount(BigDecimal amount) { this.amount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP); }第二种,getter 需要返回防御性副本。集合、日期、byte[]这些字段直接返回引用会带来外部篡改风险。手写返回不可变副本比事后排查"谁把这个 List 改了"要省事得多。
第三种,这个类是对外契约的核心模型。API 返回体、SDK 里的模型类,我倾向显式声明所有访问器。原因是这些类的字段名和签名一旦发布就很难改,显式写出来能让代码评审看到全貌,也避免某天有人在类上加个注解、或者换个配置,导致序列化结果悄悄变化。
反过来说,内部用的、生命周期短的、只在一两个方法里传来传去的临时对象,挂@Data完全没问题。分清这两类,比纠结"Lombok 好不好"要有意义得多。
7.3 团队里怎么把这条线划清楚
工具本身没有对错,失控的用法才有。我在几个项目里落过一套比较简单的约定,执行成本不高:
- DTO / VO / 请求响应模型:允许
@Data,但必须显式加@NoArgsConstructor和@AllArgsConstructor(配合 Builder 时),敏感字段必须@ToString.Exclude。 - 持久层实体:禁止裸挂
@Data。只允许@Getter/@Setter,equals/hashCode要么不生成,要么按主键显式声明。 - Service / 组件类:不挂
@Data,用@RequiredArgsConstructor做构造注入。 - 值对象 / 需要在 Map 里当 key 的对象:优先用
record或不挂@Data的不可变类。
约定靠什么落地?靠配置,不靠自觉。lombok.data.flagUsage = ERROR可以按模块生效,把实体模块和契约模块设成禁止使用,编译期就拦住了。另外在 CI 里可以加一步delombok加 grep,检查生成的实体类里有没有出现包含集合字段的hashCode。这些检查看着土,但比事后开会强调有效得多。
最后分享一个我自己一直在用的排查习惯:遇到任何跟对象行为相关的诡异问题——不相等、找不到、打印不出来——第一步先跑 delombok 把注解展开。很多问题只要看见展开后的真实代码,答案就自己浮出来了。@Data的便利性和风险都来自同一件事:它把你没写出来的代码替你写好了,而读代码的人只能看见你没写的那部分。养成"展开看一眼"的习惯,这个注解就从隐患变成了真正好用的工具。