Java基础(四)—— HashCode和Equals
正如标题所言,今天我们来讲讲hashCode和equals。或许有些人会奇怪了,这两个东西为什么要放在一起来讲呢?这是因为按照JDK规范:
如果两个对象根据equals方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生相同的整数结果。
所以为了遵守这个约定,就必须在重写equals时同样重写hashCode方法。如果不这样的话,就会违反该约定。
违反约定的后果
如果违反了这个约定,会出现什么后果呢?我们来一起探讨一下。最简单的一个分析,如果这种违反了约定的对象插入到HashSet中会怎么样呢?
首先,我们应该知道,HashSet的底层实现是使用的HashMap。代码如下:
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
static final long serialVersionUID = -5024744406713321676L;
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
// 略
}
从源码中可以看出,在构造HashSet时会初始化内部的map对象,然后add和remove等对set的操作其实就是对内部map的操作。add的对象会作为map的key,PRESENT这个Object的对象会作为map的value,这两者作为一个键值对put到map中。
那么问题来了,HashMap的put流程是怎么样的呢?这里我先简要说下:主要是根据key的hash值算到对应的槽,如果对应槽位有值,则比较槽位值的key与插入key是否相等(hashCode,==,equals都为true),如果为true的则槽位的值会被覆盖,否则遍历判断该槽位下的链表,如果都不相等则链表链接新值。主要流程图如下:
到这里其实我们就能了解到几点:
-
HashSet去重用的是HashMap的key如果相等会覆盖value的特性,而相等首先是hash之后会进入同一个槽,然后再通过hashCode和equals等判断是否为true,这才保证是相等的。
-
如果HashSet的add的对象equals为true,但是hashCode不是相等的值,那么就可能会出现add第二个值时,导致第二个对象也被HashMap存储,以至于HashSet的去重特性被打破。
可以使用以下代码验证:
@AllArgsConstructor
public class HashEqualsTest {
private String name;
private Integer age;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof HashEqualsTest)) return false;
HashEqualsTest that = (HashEqualsTest) o;
return name.equals(that.name) && age.equals(that.age);
}
public static void main(String[] args) {
HashEqualsTest a = new HashEqualsTest("aischen", 3);
HashEqualsTest b = new HashEqualsTest("aischen", 3);
System.out.println(a.equals(b));
Set<HashEqualsTest> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(set);
}
}
输出结果:
true
[org.aischen.HashEqualsTest@5b2133b1, org.aischen.HashEqualsTest@77459877]
可以看到,结果确实如我们所料,因为违反了hashCode和equals的约定,所以HashSet可以插入多个相互之间equals为true的对象,那这种对象的到底算不算重复对象,就见仁见智了。就实际业务上来说,这种对象是算重复对象的,毕竟相同名字,相同身份证号的两条数据,不能就算是两个人吧。
hashCode一些特性
我们已经知道hashCode一般是用于散列寻址和前置判断使用,那么hashCode可以随意生成吗?怎么生成比较好呢?
还是以HashMap举例,如果我们的hashCode生成算法不够优雅,生成的hashCode值碰撞概率高,以极端情况来看,hash之后所有的元素全部在一个hash槽中,那就完全成了一个链表或者红黑树了。所有的查询都得基于链表和红黑树来查。而纯以计算来说,逻辑越多,计算越多,那效率必然就越低,所以经过了那么多前置的计算和判断之后还是用链表的数据结构,那相对于单纯使用链表效率必然是比不上的。
我们来看这样一段代码:
public static int hashCode(Object a[]) {
if (a == null)
return 0;
int result = 1;
for (Object element : a)
result = 31 * result + (element == null ? 0 : element.hashCode());
return result;
}
这其实是JDK中的Objects.hashCode方法,具体逻辑就不讲了,很直观。
首先说明下为什么需要使用乘法。有乘法的话,就使得散列值依赖于传入参数的顺序,如果一个类包含了多个相似的域,这样的乘法运算就会产生一个更好区分的散列值。
其次为什么要选择31这样的数。引用自Effective Java:
之所以选择31,是因为它是一个奇素数。如果乘法是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不是很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即使用移位和减法来代替乘法,可以得到更好的性能:31×i==(i<<5) - 1。现代的虚拟机可以自动完成这种优化。
如此,我们知道,选择31的原因主要还是性能。不过实际上我们使用时,也只需要调用Objects.hashCode的方法即可,无需再重复造轮子。
平常对象或者String的hashCode都是一些比较长的数字。但是Integer或者Short等这种整型的类,他们的hashCode就等于他们的value,这点在实际使用时可以注意下。
equals一些特性
我们已经知道,equals是用来比较对象是否相等的一个函数,如果没有重写的话,那默认是使用"=="。而我们常用的一些类其实JDK的开发人员已经帮我们重写过了,比如String,比如Integer。
在重写equals方法时,必须要遵守它的通用约定。下面是约定的内容,来自Object的规范:
-
自反性:对于任何非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(y)就会一致的返回true或者一致的返回false。
-
对于任何非null的引用值x,x.equals(null)时必须返回false。
约定看起来很多,也好像很复杂的样子,但是却是我们必须遵守的一些约定,否则就可能会导致系统出现非常严重的后果,甚至崩溃,而且你很难找到根源。John Donne说过:没有哪个类是孤立的,一个类的实例通常会被频繁的传递给另一个类的实例。
不过这些约定虽然看起来比较复杂,但实际上并非如此,一旦理解了,遵守它们也并不困难。
自反性
很难想象会怎么无意识违反这一约定。假如违背这一条的话,那么把类加到集合实例中,再调用集合的contains方法时将返回false,告诉你该集合不包含刚刚添加的实例。
对称性
这个要求是说,任何两个对象对于"它们是否相等"的问题都必须保持一致。这种违反的情况其实不难想象。一般是用于equals不同的类导致的。比如有段代码如下:
public class IgnoreCaseString {
private final String s;
public IgnoreCaseString(String s) {
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof IgnoreCaseString)
return s.equalsIgnoreCase(((IgnoreCaseString) o).s);
if (o instanceof String)
return s.equalsIgnoreCase((String) o);
return false;
}
}
这个意图很明显,是想能和普通的字符串进行互操作。 但是这样会明显的违反对称性:
public static void main(String[] args) {
String s = "aTad";
IgnoreCaseString ics = new IgnoreCaseString("atad") ;
System.out.println(ics.equals(s));
System.out.println(s.equals(ics));
}
结果不出所料:第一个返回true,第二个返回false
true
false
传递性
这点是要求我们在第一个对象等于第二个对象,第二个对象等于第三个对象时,第一个对象一定等于第三个对象。这个要求无意识违反的话也是不难想象的,比如类继承的的情况下,扩展了属性,那么需不需要将扩展属性也加入到比较中呢?
如果不加的话那么显然是不会违反equals约定的,但是新加的信息被比较时忽略掉也是无法接受的。那么就需要将扩展信息加入到比较中。此时就会出现对称性问题。
例如这两个类:
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
}
class Point3D extends Point {
private int z;
public Point3D(int x, int y, int z) {
super(x, y);
this.z = z;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point3D)) return o.equals(this);
if (!super.equals(o)) return false;
Point3D point3D = (Point3D) o;
return z == point3D.z;
}
}
这两个类的equals都是很常见的通过ide生成的。但是很明显它们违反了对称性。如果要解决该问题也很简单,将代码if (!(o instanceof Point3D)) return false;
调整为if (!(o instanceof Point3D)) return o.equals(this);
即可。但是这样虽然可以保证对称性,但是确违反了传递性。
事实上,这种解法还有可能导致无限递归问题,假设Point有两个子类,且它们各自都带有一个equals方法,那么两个子类对象之间的equals将导致它们互相调用对方的equals方法,然后将抛出StackOverflowError
。
事实上,这种问题是无解的。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。
一致性
这个要求是,如果两个对象相等,它们就必须始终保持相等,除非它们中有一个对象发生了变更。
无论类是否可变,都不要使equals依赖于不可靠的资源。如果违反了这一原则,要想满足一致性就十分困难了,例如JDK中的URL类,因为它的equals是依赖于IP地址的比较,而IP又是需要访问网络的,随着时间的推移,就不能确保会产生相同的结果。遗憾的是,因为兼容性要求,这一行为无法被改变。为了避免这种问题,equals方法应该对驻留在内存中的对象执行确定性的计算。
最后再留下几个告诫:
-
重写equals时需要重写hashCode
-
不要企图让equals方法过于智能。
-
不要将equals声明中的Object替换成别的类,否则就不是重写equals方法而是重载了。
写在最后
到此我们的分享就告一段落了,虽然篇幅不算短,但是总感觉还有很多东西其实是没有说清楚了,只是说的很粗略,很大概。可能也是因为我最近实在是太忙了,每天都要到凌晨到家,只有周天的这一点点时间可以挪出来写点东西,但是又因为太累需要补血,以至于都不能保证一整天的时间有输出。另外就是也忙得连书都没法看了,希望过了这段忙碌期能够好起来,上周又欠了一片文章,到现在算下来的话已经欠了两篇的。只能在后面找时间给补上了。
最后,希望大家能好好学习,天天向上~
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南