==
和 equals
这个问题,简直是程序员面试和日常开发中的“老朋友”了,几乎每隔一段时间就会在代码里和它不期而遇地打个招呼。别看它基础,但真要说清楚,特别是后面那个 hashCode
,刚入门的时候我确实也踩了不少坑。
今天,我就以一个过来人的身份,跟大家聊聊我当初是怎么一步步搞懂这哥仨的。
面试官最爱问的==
和equals
,我到底踩了多少坑?
缘起:一个让我百思不得其解的 Bug
还记得那是刚工作不久,我在做一个用户管理的功能。有个需求是,从数据库里查出来一个用户对象,然后跟一个本地创建的、信息完全相同的用户对象做比较,判断它们是不是“同一个人”。
当时我的代码差不多是这个样子的:
public class User {
private Long id;
private String username;
// 构造函数、getter/setter省略...
}
// ... 在我的业务代码里
User userFromDB = new User(1L, "xiaowu");
User userFromFrontend = new User(1L, "xiaowu");
if (userFromDB == userFromFrontend) {
System.out.println("是同一个用户!");
} else {
System.out.println("奇怪,明明信息都一样,怎么就不是同一个用户呢?");
// 输出结果:奇怪,明明信息都一样,怎么就不是同一个用户呢?
}
看到这个输出,我当时就懵了。ID 和用户名明明都一样啊,这俩 User
对象怎么看都应该是“相等”的,为什么 ==
会告诉我它们不相等?这就是我踩的第一个坑。
探索之旅:==
到底在比什么?
没办法,只能硬着头皮去查资料。折腾了一番后,我总算搞明白了。
==
这个操作符,它的行为非常“耿直”,得分情况看:
-
对于基本数据类型 (Primitive Types):比如
int
,long
,boolean
这些,==
比较的就是它们的值。int a = 10; int b = 10;
,那a == b
就是true
,这没啥好说的。 -
对于引用数据类型 (Reference Types):比如我们自己写的
User
类,还有String
、Integer
这些。==
比较的是 内存地址。
啥是内存地址?你可以把它想象成每个对象在内存中的“门牌号”。
new User(...)
这个操作,就相当于在内存里盖了一栋新房子,并把门牌号交给你。我的代码里 new
了两次,就等于盖了两栋独立的房子。虽然这两栋房子里面的装修(id、username)一模一样,但它们的门牌号是绝对不一样的。
所以,userFromDB == userFromFrontend
其实是在问:“这两把钥匙能打开同一扇门吗?” 答案显然是不能。
这就是 ==
的本质:检查两个引用是否指向内存中的同一个对象。
柳暗花明:equals
方法才是正解?
知道了 ==
不行,我立马想到了 equals
方法。毕竟,写 String
的时候,我们都是用 equals
来比较字符串内容的。于是我赶紧把代码改了:
if (userFromDB.equals(userFromFrontend)) {
System.out.println("这次总该是同一个用户了吧!");
} else {
System.out.println("怎么还是不行?!");
// 输出结果:怎么还是不行?!
}
结果,又打脸了。这 equals
怎么也“叛变”了?
万念俱灰之下,我只好按住 Ctrl
点击 equals
方法,想看看它底层到底是个啥。不看不知道,一看吓一跳,Object
类的 equals
方法源码长这样:
// 这是所有类的祖宗 Object 类里的 equals 方法
public boolean equals(Object obj) {
return (this == obj);
}
破案了!如果我们自己写的类没有“重写” (Override) equals
方法,那它默认继承的就是 Object
类的这个版本,而这个版本骨子里还是在用 ==
做比较!
所以,要想比较两个 User
对象的内容是否相等,我们必须亲自动手,告诉程序什么是“内容相等”。
// 在 User 类里重写 equals 方法
@Override
public boolean equals(Object o) {
// 1. 先用 == 判断是不是同一个对象,如果是,那肯定相等
if (this == o) return true;
// 2. 判断 o 是不是 null,或者类型和我不一样,那肯定不等
if (o == null || getClass() != o.getClass()) return false;
// 3. 把 o 强转成 User 类型,然后比较我们关心的字段
User user = (User) o;
return java.util.Objects.equals(id, user.id) &&
java.util.Objects.equals(username, user.username);
}
加上这段代码后,userFromDB.equals(userFromFrontend)
终于返回了 true
。长舒一口气!
终极陷阱:hashCode
的致命一击
本以为故事到这里就结束了,结果没过多久,我在用 HashMap
或者 HashSet
时,又遇到了一个更诡异的问题。
我想用 HashSet
来存一堆用户,实现去重的效果。
Set<User> userSet = new HashSet<>();
User user1 = new User(1L, "xiaowu");
User user2 = new User(1L, "xiaowu");
System.out.println("user1.equals(user2): " + user1.equals(user2)); // 输出 true
userSet.add(user1);
userSet.add(user2);
System.out.println("Set size: " + userSet.size()); // 期望是1,结果是 2
我明明重写了 equals
方法,user1
和 user2
已经是“相等”的了,为什么 HashSet
还是把两个都加进去了?它不是应该去重吗?
这就是因为我只重写了 equals
,却忘了重写 hashCode
!
要理解这个问题,得先知道 HashSet
、HashMap
这些集合是怎么工作的。你可以把 HashMap
想象成一个大仓库,里面有很多货架(bucket)。
- 当你存一个东西(比如
user1
)进去时,HashMap
会先问这个东西一个数:user1.hashCode()
。这个返回的数字(哈希码)就决定了user1
该放到哪个货架上。 - 当你再存
user2
时,HashMap
同样计算user2.hashCode()
,找到对应的货架。如果那个货架上已经有东西了,它才会用equals()
方法去一个个比较,看看是不是已经存在一个一模一样的了。
关键点来了:如果你不重写 hashCode
,它默认也是根据对象的内存地址来生成一个几乎唯一的数字。
这就导致 user1
和 user2
虽然 equals
相等,但它们的 hashCode()
返回了两个完全不同的值。HashSet
就认为它们应该被放在两个不同的“货架”上,压根就没机会用 equals
去比较它们,理所当然地认为这是两个不同的对象。
黄金法则:equals
和 hashCode
的约定
Java 中有一条神圣的、不可破坏的规定:
如果两个对象通过
equals()
方法比较是相等的,那么它们的hashCode()
方法必须返回相同的值。
反过来不一定成立:hashCode
相同,equals
不一定为 true
(这叫哈希冲突),但 equals
为 true
,hashCode
必须相同。
所以,最完美的解决方案是,在重写 equals
的同时,也根据相同的字段来重写 hashCode
。
// 在 User 类里,补上 hashCode 方法
@Override
public int hashCode() {
// 使用 Objects.hash 工具类可以很方便地根据字段生成一个哈希码
return java.util.Objects.hash(id, username);
}
加上这个 hashCode
方法后,上面那个 HashSet
的例子就能正常工作了,userSet.size()
会正确地输出 1
。
我的感悟
从 ==
的地址比较,到 equals
的内容比较,再到 hashCode
为集合服务的本质,这一路踩坑下来,让我对 Java 的对象模型有了更深的理解。
==
:对比的是“身份”,是不是内存里的同一个东西。equals()
:对比的是“内涵”,内容是不是我们认为的“相等”。(前提是你得重写它)hashCode()
:是equals()
的“开路先锋”,在海量数据里快速定位到“可能相等”的小团体,是保证HashMap
、HashSet
这类集合能够高效运行的基石。
记住这个铁律:但凡你要重写 equals
,请务必、一定、必须把 hashCode
也一起重写了! 否则,一旦你的对象被放进哈希集合里,各种诡异的 Bug 就会找上门来。
不知道大家在面试或者工作中是不是也经常被问到这个问题?你们有没有遇到过因为忘了重写 hashCode
导致的诡异 bug?欢迎在评论区留言讨论啊!