Lombok 是一种 Java™ 实用工具,可用于帮助开发人员消除 Java 的冗长,尤其是对于简单的 Java 对象(POJO)。它通过注解实现这一目的。
最近一个新项目中开始使用了 lombok,由于其真的是太简单易懂了,以至于我连文档都没看,直接就上手使用了,引发了一桩惨案。
实体类定义
1@Data
2public class Project {
3 private Long id;
4 private String projectName;
5 private List<Project> projects;
6}
我在项目中设计了一个 Project 类,其包含了一个 List projects 属性,表达了项目间的依赖关系。@Data 便是 Lombok 提供的常用注解,我的本意是使用它来自动生成 getter/setter 方法。这样的实体类定义再简单不过了。
意外出现
使用 Project 类表达项目间的依赖关系是我的初衷,具体的分析步骤不在此赘述,对 Project 类的操作主要包括创建,打印,保存几个简单操作。运行初期,一切看似风平浪静,但经过长时间运行后,我意外的获得了如下的异常:
1Exception in thread "Tmoe.cnkirito.dependency0" java.lang.StackOverflowError
2 at moe.cnkirito.dependency.model.Project.hashCode(Project.java:20)
3 at java.util.AbstracList.hashCode(AbstractList.java:541)
这让我感到很意外,我并没有对 Project 类进行什么复杂的操作,也没有进行什么递归操作,怎么会得到 StackOverflowError 这个错误呢?更令我百思不得其解的地方在于,怎么报错的日志中还出现了 hashCode 和 AbstractList 这两个家伙?等等…hashCode…emmmmm…我压根没有重写过它啊,怎么可能会报错呢….再想了想 Lombok 的 @Data 注解,我似乎发现了什么…emmmmm…抱着怀疑的态度翻阅了下 Lombok 的文档,看到了如下的介绍
@Data
is a convenient shortcut annotation that bundles the features of@ToString
,@EqualsAndHashCode
,@Getter
/@Setter
and@RequiredArgsConstructor
together: In other words,@Data
generates all the boilerplate that is normally associated with simple POJOs (Plain Old Java Objects) and beans: getters for all fields, setters for all non-final fields, and appropriatetoString
,equals
andhashCode
implementations that involve the fields of the class, and a constructor that initializes all final fields, as well as all non-final fields with no initializer that have been marked with@NonNull
, in order to ensure the field is never null.
原来 @Data 注解不仅帮我们实现了生成了@Getter
/ @Setter
注解,还包含了@ToString
, @EqualsAndHashCode
, 和 @RequiredArgsConstructor
注解,这其中的 @EqualsAndHashCode 注解似乎和我这次的惨案密切相关了。顺藤摸瓜,看看 @EqualsAndHashCode 的文档:
Any class definition may be annotated with
@EqualsAndHashCode
to let lombok generate implementations of theequals(Object other)
andhashCode()
methods. By default, it'll use all non-static, non-transient fields
@EqualsAndHashCode 会自动生成 equals(Object other)
和 hashCode()
两个方法,默认会使用所有非静态,非瞬时状态的字段。
回到我的案例中,也就是说,Lombok 会将 Project 类中的 List projects 当做是 hashCode 计算的一部分(同理,equals,toString 也会存在同样的问题),而如果我的项目中出现循环引用,这就会导致死循环,最终就会抛出 StackOverFlowError。
为了验证我的想法,简化的项目中的代码后,来测试下
1public String testHashCode(){
2 Project project = new Project();
3 Project other = new Project();
4 other.setProjects(Arrays.asList(project));
5 project.setProjects(Arrays.asList(other));
6 System.out.println(project.hashCode());
7 return "success";
8}
调用该代码后,复现了上述的异常。
1Exception in thread "Tmoe.cnkirito.dependency0" java.lang.StackOverflowError
2 at moe.cnkirito.dependency.model.Project.hashCode(Project.java:20)
3 at java.util.AbstracList.hashCode(AbstractList.java:541)
紧接着,继续测试下 toString 和 eqauls 方法
1## 测试循环引用实体类中下的 toString 方法
2java.lang.StackOverflowError: null
3 at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:125) ~[na:1.8.0_161]
4 at java.lang.AbstractStringBuilder.appendNull(AbstractStringBuilder.java:493) ~[na:1.8.0_161]
5 at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:446) ~[na:1.8.0_161]
6 at java.lang.StringBuilder.append(StringBuilder.java:136) ~[na:1.8.0_161]
7 at com.qianmi.dependency.model.Project.toString(Project.java:18) ~[classes/:na]
8## 测试循环引用实体类中下的 equals 方法
9java.lang.StackOverflowError: null
10 at java.util.AbstractList.rangeCheckForAdd(AbstractList.java:604) ~[na:1.8.0_161]
11 at java.util.AbstractList.listIterator(AbstractList.java:325) ~[na:1.8.0_161]
12 at java.util.AbstractList.listIterator(AbstractList.java:299) ~[na:1.8.0_161]
13 at java.util.AbstractList.equals(AbstractList.java:518) ~[na:1.8.0_161]
14 at com.qianmi.dependency.model.Project.equals(Project.java:18) ~[classes/:na]
不出所料,都存在同样的问题。
这一案例可以稍微总结下,一是在使用新的技术框架(Lombok)之前没有看文档,对其特性不太了解,望文生义,认为 @Data 不会重写 hashCode 等方法,二是没有考虑到 hashCode,eqauls 等方法应该如何正确地覆盖。
这两个方法说是 JAVA 最基础的方法一点不为过,但往往越基础的东西越容易被人忽视,让我想起了 JAVA 闲聊群中一位长者经常吐槽的一点:『现在的面试、群聊动不动就是高并发,JVM,中间件,却把基础给遗忘了』。 我感觉很幸运,在当初刚学 JAVA 时,便接触了一本神书《effective java》,一本号称怎么夸都不为过的书,它的序是这么写的
我很希望10年前就拥有这本书。可能有人认为我不需要任何Java方面的书籍,但是我需要这本书。 ——Java 之父 James Gosling
其书中的第三章第 8 条,第 9 条阐述了 equals 和 hashCode 的一些重写原则,我将一些理论言简意赅的阐述在本节中,喜欢的话推荐去看原书哦。
equals
时请遵守通用约定什么时候应该覆盖
Object.equals
呢?如果类具有自己特有的“逻辑相等”概念(不同于对象等同的概念),而且超类还没有覆盖equals
以实现期望的行为,这时我们就需要覆盖equals
方法。这通常属于“值类(value class)”的情形。值类仅仅是一个表示值的类,例如Integer
或者Date
。程序员在利用equals
方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。为了满足程序员的要求,不仅必需覆盖equals
方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key),或者集合(set)的元素,使映射或者集合表现出预期的行为。 在覆盖equals
方法的时候,你必须要遵守它的通用约定。下面是约定的内容,来自Object
的规范[JavaSE6]:equals
方法实现了等价关系(equivalence relation):
null
的引用值x
,x.equals(x)
必须返回true
。null
的引用值x
和y
,当且仅当y.equals(x)
返回true
时,x.equals(y)
必须返回true
。null
的引用值x
、y
和z
。如果x.equals(y)
返回true
,并且y.equals(z)
也返回true
,那么x.equals(z)
也必须返回true
。null
的引用值x
和y
,只要equals
的比较操作在对象中所用的信息没有被修改,多次调用x.equals(x)
就会一致地返回true
,或者一致的返回false
。null
的引用值x
,x.equals(null)
必须返回false
。学过高数,离散的同学不会对上述的理论陌生,它们源自于数学理论,没了解过这些概念的同学也不必有所顾忌,因为你只需要养成习惯,在设计一个实体类时时刻惦记着上述几个关系,能符合的话大概就没有问题。结合所有这些要求,得出了以下实现高质量equals
方法的诀窍:
true
。这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。false
。一般说来,所谓“正确的类型”是指equals
方法所在的那个类。有些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals
约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口(collection interface)如Set
、List
、Map
和Map.Entry
具有这样的特性。instanceof
测试,所以确保会成功。true
;否则返回false
。如果第2步中的类型是个借口,就必须通过接口方法访问参数中的域;如果该类型是个类,也许就能够直接访问参数中的域,这要取决于它们的可访问性。
对于既不是float
也不是double
类型的基本类型域,可以使用==
操作符进行比较;对于对象引用域,可以递归地调用equals
方法;对于float
域,可以使用Float.compare
方法;对于double
域,则使用Double.compare
。对于float
和double
域进行特殊的处理是有必要的,因为存在着Float.NaN
、-0.0f
以及类似的double
常量;详细信息请参考Float.equals
的文档。对于数组域,则要把以上这些指导原则应用到每个元素上。如果数组域中的每个元素都很重要,就可以使用发行版本1.5中新增的其中一个Arrays.equals
方法。
有些对象引用域包含null
可能是合法的,所以,为了避免可能导致NullPointerException
异常,则使用下面的习惯用法来比较这样的域:
1(field == null ? o.field == null : field.equals(o.field))
如果field
域和o.field
通常是相同的对象引用,那么下面的做法就会更快一些:
1(field == o.field || (field != null && field.equals(o.field)))equals
方法的代码。当然,equals
方法也必须满足其他两个特性(自反性和非空性),但是这两种特性通常会自动满足。其他原则还包括:
equals
约定。如果想过度地去寻求各种等价关系,则很容易陷入麻烦之中。把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。例如,File
类不应该视图把指向同一个文件的符号链接(symbolic link)当作相等的对象来看待。所幸File
类没有这样做。equals
时总要覆盖hashCode
一个很常见的错误根源在于没有覆盖
hashCode
方法。在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。如果不这样做的话,就会违反Object.hashcode
的通用约定,从而导致该类无法结合所有基于散列的集合一起正常工作,这样的集合包括HashMap
、HashSet
和Hashtable
。 下面是约定的内容,摘自Object
规范[JavaSE6]:
equals
方法的比较操作所用到的信息没有被修改,那么对同一个对象调用多次,hashCode
方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。equals(Object)
方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode
方法都必须产生同样的整数结果。equals(Object)
方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode
方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。因没有覆盖hashCode而违反的关键约定是第二条:相等的对象必须具有相等的散列码(hash code)。根据类的equals
方法,两个截然不同的实例在逻辑上有可能是相等的,但是,根据Object
类的hashCode
方法,它们仅仅是两个没有任何共同之处的对象。因此,对象的hashCode
方法返回两个看起来是随机的整数,而不是根据第二个约定所要求的那样,返回两个相等的整数。
默默看完书中的文字,是不是觉得有点哲学的韵味呢,写好一手代码真的不容易。
hashCode 和 equals 很重要,在使用中,与之密切相关的一般是几个容器类:HashMap 和 HashSet,意味着当我们将一个类作为其中的元素时,尤其需要考量下 hashCode 和 equals 的写法。
话不多数,即刻介绍。对了,你指望我手敲 hashCode 和 equals 吗?不存在的,程序员应该优雅的偷懒,无论你是 eclipse 玩家还是 idea 玩家,都能找到对应的快捷键,帮你自动重写这两个方式,我们要做的就是对参数的选择做一些微调。例如使用 idea 生成下面这个类的 hashCode 和 equals 方法,设置前提:将所有字段当做关键(significant)域。
1public class Example {
2 private int a;
3 private float b;
4 private double c;
5 private BigDecimal d;
6 private char e;
7 private byte f;
8 private String g;
9}
1@Override
2public boolean equals(Object o) {
3 if (this == o) return true;
4 if (o == null || getClass() != o.getClass()) return false;
5 if (!super.equals(o)) return false;
6
7 Example example = (Example) o;
8
9 if (a != example.a) return false;
10 if (Float.compare(example.b, b) != 0) return false;
11 if (Double.compare(example.c, c) != 0) return false;
12 if (e != example.e) return false;
13 if (f != example.f) return false;
14 if (!d.equals(example.d)) return false;
15 return g.equals(example.g);
16}
17
18@Override
19public int hashCode() {
20 int result = super.hashCode();
21 long temp;
22 result = 31 * result + a;
23 result = 31 * result + (b != +0.0f ? Float.floatToIntBits(b) : 0);
24 temp = Double.doubleToLongBits(c);
25 result = 31 * result + (int) (temp ^ (temp >>> 32));
26 result = 31 * result + d.hashCode();
27 result = 31 * result + (int) e;
28 result = 31 * result + (int) f;
29 result = 31 * result + g.hashCode();
30 return result;
31}
这可能是大家最熟悉的方法,先来分析下 equals 的写法。看样子的确是遵循了《effective java》中提及的 java1.6 规范的,值得注意的点再强调下:Float 和 Double 类型的比较应该使用各自的静态方法 Float.compare 和 Double.compare。
hashCode 方法则更加有趣一点,你可能会有如下的疑问:
带着疑问来看看下面的解释。
一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”。这正是上一节中hashCode
约定中第三条的含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的散列值上。要想完全达到这种理想的情形是非常困难的。但相对接近这种理想情形则并不太苦难。《effective java》给出了一种简单的解决办法:
result
的int
类型的变量中。f
(指equals
方法中涉及的每个域),完成以下步骤:
a. 为该域计算int
类型的散列码c
:
i. 如果该域是boolean
类型,则计算(f ? 1 : 0)
.
ii. 如果该域是byte
、char
、short
或者int
类型,则计算(int)f
。
iii. 如果该域是long
类型,则计算(int)(f ^ (f >>> 32))
。
iv. 如果该域是float
类型,则计算Float.floatToIntBits(f)
。
v. 如果该域是double
类型,则计算Double.doubleToLongBits(f)
,然后按照步骤2.a.iii,为得到的long
类型值计算散列值。
vi. 如果该域是一个对象引用,并且该域的equals
方法通过递归地调用equals
的方式来比较这个域,则同样为这个域递归地调用hashCode
。如果需要更复杂的比较,则为这个域计算一个“范式(canonical representation)”,然后针对这个范式调用hashCode
。如果这个域的值为null
,则返回0(或者其他某个常数,但通常是0)。
vii. 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组域中的每个元素都很重要,可以利用发行版本1.5中增加的其中一个Arrays.hashCode
方法。
b. 按照下面的公式,把步骤2.a中计算得到的散列码c
合并到result
中:
1result = 31 * result + c;hashCode
方法之后,问问自己“相等的实例是否都具有相等的散列码”。要编写单元测试来验证你的推断。如果相等实例有着不相等的散列码,则要找出原因,并修正错误。在散列码的计算过程中,可以把冗余域(redundant field)排除在外。换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。必须排除equals
比较计算中没有用到的任何域,否则很有可能违反hashCode
约定的第二条。
上述步骤1中用到了一个非零的初始值,因此步骤2.a中计算的散列值为0的那些初始域,会影响到散列值。如果步骤1中的初始值为0,则整个散列值将不受这些初始域的影响,因为这些初始域会增加冲突的可能性。值17
则是任选的。
步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String
散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串都会有相同的散列码。之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于位移运算。使用素数的好处并不很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即用位移和减法来代替乘法,可以得到更好的性能,31 * i == (i << 5) - i
。现代的VM可以自动完成这种优化。
是不是几个疑惑都解开了呢?
1@Override
2public boolean equals(Object o) {
3 if (this == o) return true;
4 if (o == null || getClass() != o.getClass()) return false;
5 if (!super.equals(o)) return false;
6 Example example = (Example) o;
7 return a == example.a &&
8 Float.compare(example.b, b) == 0 &&
9 Double.compare(example.c, c) == 0 &&
10 e == example.e &&
11 f == example.f &&
12 Objects.equals(d, example.d) &&
13 Objects.equals(g, example.g);
14}
15
16@Override
17public int hashCode() {
18 return Objects.hash(super.hashCode(), a, b, c, d, e, f, g);
19}
JAVA 是一个与时俱进的语言,有问题从自身解决,便利了开发者,如《effective java》所言,在 jdk1.6 中上述那些原则只是一纸空文。错误同真理的关系,就象睡梦同清醒的关系一样。一个人从错误中醒来,就会以新的力量走向真理。在 jdk1.7 中便造就了诸多的方法 Objects.hash 和 Objects.equals 帮助你智能的实现 hashCode 和 equals 方法。很明显,代码量上比方法一少了很多,并且有了 jdk 的原生支持,心里也更加有底了。
前面已经提到了 Lombok 的这个注解,在此详细介绍下这个注解的用法,方便大家写出规范的 hashCode 和 equals 方法。
equals(Object other)
和 hashCode()
方法。exclude
排除一些属性of
指定仅使用哪些属性callSuper=true
解决上一点问题。让其生成的方法中调用父类的方法。使用 Lombok 很便捷,整个代码也很清爽
1@Data
2@EqualsAndHashCode(of = {"a","b","c","d","e","f","g"})//默认就是所有参数
3public class Example {
4
5 private int a;
6 private float b;
7 private double c;
8 private BigDecimal d;
9 private char e;
10 private byte f;
11 private String g;
12
13}
如果想知道编译过后的庐山真面目,也可以在 target 包中找到 Example.java 生成的 Example.class,:
1public boolean equals(Object o) {
2 if (o == this) {
3 return true;
4 } else if (!(o instanceof Example)) {
5 return false;
6 } else {
7 Example other = (Example)o;
8 if (!other.canEqual(this)) {
9 return false;
10 } else if (this.getA() != other.getA()) {
11 return false;
12 } else if (Float.compare(this.getB(), other.getB()) != 0) {
13 return false;
14 } else if (Double.compare(this.getC(), other.getC()) != 0) {
15 return false;
16 } else {
17 Object this$d = this.getD();
18 Object other$d = other.getD();
19 if (this$d == null) {
20 if (other$d != null) {
21 return false;
22 }
23 } else if (!this$d.equals(other$d)) {
24 return false;
25 }
26
27 if (this.getE() != other.getE()) {
28 return false;
29 } else if (this.getF() != other.getF()) {
30 return false;
31 } else {
32 Object this$g = this.getG();
33 Object other$g = other.getG();
34 if (this$g == null) {
35 if (other$g != null) {
36 return false;
37 }
38 } else if (!this$g.equals(other$g)) {
39 return false;
40 }
41
42 return true;
43 }
44 }
45 }
46}
47
48protected boolean canEqual(Object other) {
49 return other instanceof Example;
50}
51
52public int hashCode() {
53 int PRIME = true;
54 int result = 1;
55 int result = result * 59 + this.getA();
56 result = result * 59 + Float.floatToIntBits(this.getB());
57 long $c = Double.doubleToLongBits(this.getC());
58 result = result * 59 + (int)($c >>> 32 ^ $c);
59 Object $d = this.getD();
60 result = result * 59 + ($d == null ? 43 : $d.hashCode());
61 result = result * 59 + this.getE();
62 result = result * 59 + this.getF();
63 Object $g = this.getG();
64 result = result * 59 + ($g == null ? 43 : $g.hashCode());
65 return result;
66}
大致和前两种行为一致,这里选择素数从 31 替换成了 59,没有太大差异。
我在开发时也曾考虑一个问题:一个数据库持久化对象到底怎么正确覆盖 hashCode 和 equals?以订单为例,是用主键 id 来判断,还是 流水编号 orderNo 来判断,可能没有准确的答案,各有各的道理,但如果将它丢进 HashSet,HashMap 中就要额外注意,hashCode 和 equals 会影响它们的行为!
这次 Lombok 发生的惨案主要还是由于不合理的 hashCode 和 equals(也包括了 toString)方法导致的,循环引用这种问题虽然没有直接在《effective java》中介绍,但一个引用,一个集合类是不是应该作为 hashCode 和 equals 的关键域参与计算,还是值得开发者仔细推敲的。本文还介绍了一些 hashCode 和 equals 的通用原则,弱弱地推荐 Lombok 便捷开发,强烈安利《effective java》一书。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有