为什么要重写 hashcode 和 equals 方法?
引言
以前面试的时候被面试官问到过这样一个问题:
你有没有重写过 hashCode 方法?
心里想着我没事重写哪玩意干啥,能不写就不写。嘴上当然没敢这么说,只能略表遗憾的说抱歉,我没写过。
撇了面试官一眼,明显看到他对这个回答不满意,但是这已经触及到我的知识盲点了,我也很惭愧,可是确实没有重写过,咱也不能胡扯不是。
然后他又问到另外一个问题:
你在用 HashMap 的时候,键(Key)部分,有没有放过自定义对象?
我说我放过,很自信的说我放过(其实我忘了我有没有放过),但是不能怂啊,第一个都不会了,第二个再说不会哪不是直接拜拜要走人了吗?
面试官狡猾的笑了,说是你既然没有重写过 hashCode 方法,你怎么把自定义对象放进去的?
我勒个去,原来你在这等着我呢,没想到这还是个连环炮,惹不起惹不起,认怂三连
不过不会就学,不懂就问,这一直都是咱程序猿优秀的素养,今天就干脆从 Hash 表学起,讲述 HashMap 的存取数据规则,由此来搞定上述问题的答案。
通过 Hash 算法来了解 HashMap 对象的高效性
我们先复习数据结构里的一个知识点:
在一个长度为 n(假设是100)的线性表(假设是 ArrayList)里,存放着无序的数字;如果我们要找一个指定的数字,就不得不通过从头到尾依次遍历来查找,这样的平均查找次数是 n / 2(这里是50)。
我们再来观察 Hash 表(这里所说的 Hash 表纯粹是数据结构上的概念,和 Java 无关)。
哈希表就是一种以 键-值(key-indexed) 存储数据的结构,我们只要输入待查找的值即 key,即可查找到其对应的值。
它的平均查找次数接近于 1,代价相当小。
使用哈希查找有两个步骤:
使用哈希函数将被查找的键转换为数组的索引:在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以哈希查找的第二个步骤就是处理冲突
处理哈希碰撞冲突:有很多处理哈希碰撞冲突的方法,本文后面会介绍拉链法和线性探测法。
既然哈希查找第一步就是使用哈希函数将键映射成索引,那我们就先假设一个 Hash 函数是x * x % 5
,(当然实际编程中不可能用这么简单的 Hash 函数,一般选择的哈希函数都是要易于计算并且能够均匀分布所有键的,这里纯粹为了说明方便),然后假设 Hash 表是一个长度是 11 的线性表。
接下来如果我们如果要把 6 放入其中,那么我们首先会对 6 用 Hash 函数计算一下,结果是 1,所以我们就把 6 放入到索引号是 1 这个位置。同样如果我们要放数字 7,经过 Hash 函数计算,7 的结果是 4,那么它将被放入索引是 4 的这个位置。
如下如所示:
这样做的好处非常明显:比如我们要从中找 6 这个元素,我们可以先通过 Hash 函数计算 6 的索引位置,然后直接从 1 号索引里找到它了。不过我们有可能会遇到Hash值冲突这个问题,比如经过 Hash 函数计算后,7 和 8 会有相同的 Hash 值,此时我们就需要了解一下解决哈希碰撞的几种常见方式:
开放地址法
使用某种探查(亦称探测)技术在散列表中形成一个探查序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存入该地址单元)。
按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、线性补偿探测法以及随机探测等。限于篇幅,我们此处只讨论线性探查法。
线性探查法
该方法基本思想是:
将散列表 T[0..m-1] 看成是一个循环向量,若初始探查的地址为d(即h(key)=d),则最长的探查序列为:
d,d+l,d+2,…,m-1,0,1,…,d-1
即 : 探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到 T[d-1] 为止。 探查过程终止于三种情况:
若当前探查的单元为空,则表示查找失败(若是插入则将 key 写入其中);
若当前探查的单元中含有 key,则查找成功,但对于插入意味着失败;
若探查到 T[d-1] 时仍未发现空单元也未找到 key,则无论是查找还是插入均意味着失败(此时表满)。
利用开放地址法的一般形式,线性探查法的探查序列为:
hi = (h(key)+i)%m 0≤i≤m-1 // 即di=i
用线性探测法处理冲突,思路清晰,算法简单,但存在下列缺点:
处理溢出需另编程序。一般可另外设立一个溢出表,专门用来存放上述哈希表中放不下的记录。此溢出表最简单的结构是顺序表,查找方法可用顺序查找。
按上述算法建立起来的哈希表,删除工作非常困难。假如要从哈希表 HT 中删除一个记录,按理应将这个记录所在位置置为空,但我们不能这样做,而只能标上已被删除的标记,否则,将会影响以后的查找。
线性探测法很容易产生堆聚现象。所谓堆聚现象,就是存入哈希表的记录在表中连成一片。按照线性探测法处理冲突,如果生成哈希地址的连续序列愈长 ( 即不同关键字值的哈希地址相邻在一起愈长 ) ,则当新的记录加入该表时,与这个序列发生冲突的可能性愈大。因此,哈希地址的较长连续序列比较短连续序列生长得快,这就意味着,一旦出现堆聚 ( 伴随着冲突 ) ,就将引起进一步的堆聚。
在使用了上述线性探查法的情况下,则 7 和 8 在存储的时候,因为两者哈希后得到的索引一致,并且 7 已经存到了哈希表中,哪么 8 在找到索引 4 的时候会发现已经有值了,则它继续开始往后查找,此时找到索引为 5 的位置发现为空,它就会把 8 放到索引为 5 的位置上,如下:
链地址法
拉链法解决冲突的做法是:将所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为 m,则可将散列表定义为一个由 m 个头指针组成的指针数 组 T[0..m-1]。凡是散列地址为 i 的结点,均插入到以 T[i] 为头指针的单链表中。T 中各分量的初值均应为空指针。在拉链法中,装填因子 α 可以大于 1,但一般均取 α≤1。
与开放定址法相比,拉链法有如下几个优点:
拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
开放定址法为减少冲突,要求装填因子 α 较小,故当结点规模较大时会浪费很多空间。而拉链法中可取 α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在 用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。
使用拉链法的时候 7 和 8 的时候具体的做法是:为所有 Hash 值是 i 的对象建立一个同义词链表。假设我们在放入 8 的时候,发现 4 号位置已经被占,那么就会新建一个链表结点放入 8。同样,如果我们要找 8,那么发现 4 号索引里不是 8,那会沿着链表依次查找。
存储位置如下:
Java 中的 HashMap 对象采用的是链地址法的解决方案。
虽然我们还是无法彻底避免 Hash 值冲突的问题,但是 Hash 函数设计合理,仍能保证同义词链表的长度被控制在一个合理的范围里。这里讲的理论知识并非无的放矢,大家能在后文里清晰地了解到重写 hashCode 方法的重要性。
2 为什么要重写 equals 和 hashCode 方法
当我们用 HashMap 存入自定义的类时,如果不重写这个自定义类的 equals 和 hashCode 方法,得到的结果会和我们预期的不一样。
我们来看一个例子,定义一个 HashMapKey.java 的类,这个类只有一个属性 id :
public class HashMapKey {
private Integer id;
public HashMapKey(Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
}
测试类如下:
public class TestHashMap {
public static void main(String[] args) {
HashMapKey k1 = new HashMapKey(1);
HashMapKey k2 = new HashMapKey(1);
HashMap<HashMapKey, String> map = new HashMap<>();
map.put(k1, "程序猿杂货铺");
System.out.println("map.get(k2) : " + map.get(k2));
}
}
在 main 函数里,我们定义了两个 HashMapKey 对象,它们的 id 都是 1,然后创建了一个 HashMap 对象,紧接着我们通过 put 方法把 k1 和一串字符放入到 map里,最后用 k2 去从 HashMap 里得到值,因为 k1 和 k2 值是一样的,理论上我们是可以用这个键获取到对应的值的,看似符合逻辑,实则不然,它的执行结果是:
map.get(k2) : null
其实出现这个情况的原因有两个:
没有重写 hashCode 方法
没有重写 equals 方法。
当我们往 HashMap 里放 k1 时,首先会调用 HashMapKey 这个类的 hashCode 方法计算它的 hash 值,随后把 k1 放入 hash 值所指引的内存位置。
但是我们没有在 HashMapKey 里重写 hashCode 方法,所以这里调用的是 Object 类的 hashCode 方法,而 Object 类的 hashCode 方法返回的 hash 值其实是 k1 对象的内存地址(假设是 0x100)。
如果我们随后是调用 map.get(k1),那么我们会再次调用 hashCode 方法(还是返回 k1 的地址 0x100),随后根据得到的 hash 值,能很快地找到 k1。
但我们这里的代码是 map.get(k2),当我们调用Object类的 hashCode方法(因为 HashMapKey 里没定义)计算 k2 的 hash值时,其实得到的是 k2 的内存地址(假设是 0x200)。由于 k1 和 k2 是两个不同的对象,所以它们的内存地址一定不会相同,也就是说它们的 hash 值一定不同,这就是我们无法用 k2 的 hash 值去拿 k1 的原因。
接下来我们在类 HashMapKey 中重写 hashCode 方法
@Override
public int hashCode() {
return id.hashCode();
}
此时因为 hashCode 方法返回的是 id 的 hash值,所以此处 k1 和 k2 这两个对象的 hash 值就变得相等了。
但是问题还没有结束,我们再来更正一下存 k1 和 取 k2 的动作。存 k1 时,是根据它 id 的 hash 值,假设这里是 103,把 k1 对象放入到对应的位置。而取 k2 时,是先计算它的 hash 值(由于 k2 的 id 也是 1,这个值也是 103),随后到这个位置去找。但运行结果还是会出乎我们意料:
map.get(k2) : null
明明 103号位置已经有 k1,但打印输出结果依然是 null。
其实原因就是没有重写 HashMapKey 对象的 equals 方法。
HashMap 是用链地址法来处理冲突,也就是说,在 103号位置上,有可能存在着多个用链表形式存储的对象。它们通过 hashCode 方法返回的 hash 值都是 103。
当我们通过 k2 的 hashCode 到 103号位置查找时,确实会得到 k1。但 k1 有可能仅仅是和 k2 具有相同的 hash值,但未必和 k2 相等,这个时候,就需要调用 HashMapKey 对象的 equals 方法来判断两者是否相等了。
由于我们在 HashMapKey 对象里没有定义 equals 方法,系统就不得不调用 Object 类的 equals 方法,同理由于 Object 的固有方法是根据两个对象的内存地址来判断,所以 k1 和 k2 一定不会相等,这就是为什么通过 map.get(k2) 依然得到 null 的原因。
为了解决这个问题,我们继续重写 equals 方法,在这个方法里,只要两个对象都是 Key 类型,而且它们的 id 相等,它们就相等。
@Override
public boolean equals(Object o) {
if (o == null || !(o instanceof HashMapKey)) {
return false;
} else {
return this.getId().equals(((HashMapKey) o).getId());
}
}
至此,问题已经解决。
总结
我们平时在项目开发中经常会用到 HashMap,虽然很多时候我们都会尽可能避免去在键值存放自定义对象,但是正因为如此,一旦碰到需要存放自定义对象了就容易出问题,重申一遍:如果你需要要在 HashMap 的“键”部分存放自定义的对象,一定要重写 equals 和 hashCode 方法。
其实 这个问题本身不难,只要我们平时稍微注意以下就可以避免,本文也是大概总结了以下,避免大家以后碰到了踩坑,希望对你有所帮助,保不齐下次面试也有人问你同样的问题。
为什么要重写 hashcode 和 equals 方法?的更多相关文章
- 为什么要重写hashcode和equals方法?初级程序员在面试中很少能说清楚。
我在面试 Java初级开发的时候,经常会问:你有没有重写过hashcode方法?不少候选人直接说没写过.我就想,或许真的没写过,于是就再通过一个问题确认:你在用HashMap的时候,键(Key)部分, ...
- (转)为什么要重写 hashcode 和 equals 方法?
作者丨hsm_computer cnblogs.com/JavaArchitect/p/10474448.html 我在面试Java初级开发的时候,经常会问:你有没有重写过hashcode方法?不少候 ...
- HashMap中使用自定义类作为Key时,为何要重写HashCode和Equals方法
之前一直不是很理解为什么要重写HashCode和Equals方法,才只能作为键值存储在HashMap中.通过下文,可以一探究竟. 首先,如果我们直接用以下的Person类作为键,存入HashMap中, ...
- 为什么要重写hashcode和equals方法
我在面试 Java初级开发的时候,经常会问:你有没有重写过hashcode方法?不少候选人直接说没写过.我就想,或许真的没写过,于是就再通过一个问题确认:你在用HashMap的时候,键(Key)部分, ...
- 重写hashcode和equals方法
重写hashcode和equals方法 简乐君 2019-05-07 21:55:43 35481 收藏 191分类专栏: Java 文章标签: equals() hashcode()版权 一.前言我 ...
- 【转】 如何重写hashCode()和equals()方法
转自:http://blog.csdn.net/neosmith/article/details/17068365 hashCode()和equals()方法可以说是Java完全面向对象的一大特色.它 ...
- 如何重写hashCode()和equals()方法
hashCode()和equals()方法可以说是Java完全面向对象的一大特色.它为我们的编程提供便利的同时也带来了很多危险.这篇文章我们就讨论一下如何正解理解和使用这2个方法. 如何重写equal ...
- Java 重写 hashCode() 和 equals() 方法
1. hashCode 1.1 基本概念 hashCode 是 JDK 根据对象的地址算出来的一个 int 数字(对象的哈希码值),代表了该对象再内存中的存储位置. hashCode() 方法是超级类 ...
- 【java编程】重写HashCode和equals方法
[一]重写equals方案的规则 equals方法本来的原则 1.类的每个实例本质上都是唯一的. 2.不关心类是否提供了“逻辑相等”的测试功能 3.超类已经覆盖了equals,从超类继承过来的行为对于 ...
- Hibernate中用到联合主键的使用方法,为何要序列化,为何要重写hashcode 和 equals 方法
联合主键用Hibernate注解映射方式主要有三种: 第一.将联合主键的字段单独放在一个类中,该类需要实现java.io.Serializable接口并重写equals和hascode,再将该类注解为 ...
随机推荐
- tkinter属性(总结)
一.主要控件 1.Button 按钮.类似标签,但提供额外的功能,例如鼠标掠过.按下.释放以及键盘操作事件 2.Canvas 画布.提供绘图功能(直线.椭圆.多边形.矩形) 可以包含图形或位图 3.C ...
- ajax异步调用遇到的问题
今天在使用ajax的时候遇到一个问题,也加深了我对ajax的理解,到segmentfault上提问了,现把讨论过程呈现,也可访问链接http://segmentfault.com/q/10100000 ...
- 关于ftp用户连接时出现500 OOPS: cannot change directory的解决办法
RHEL5 中配置好后,今天想在XP下用ftp连接虚拟机中的linux,但ftp连接的时候会出现 "500 OOPS:cannot change directory:/root" ...
- Nginx 入门学习教程
昨天听一个前同事说他们公司老大让他去研究下关于Nginx 方面的知识,我想了下Nginx 在如今的开发技术栈中应该会很大可能会用到,所以写篇博文记录总结下官网学习教程吧. 1. 什么是Nginx? 我 ...
- Python 文件读写的三种模式和区别
#coding=utf-8 #__author:Administrator #__time:2018/5/9 13:14 #__file_name:text1 import io #能调用方法的一定是 ...
- python黑科技:还在为没有wifi而烦心吗?这篇文章解决你的困扰
python作为一门高级编程语言,它的定位是优雅.明确和简单.阅读Python编写的代码感觉像在阅读英语一样,这让使用者可以专注于解决问题而不是去搞明白语言本身.Python虽然是基于C语言编写,但是 ...
- 支付宝使用流程和踩坑小记(附Demo)
# 支付宝使用整理 html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym, ...
- 大数据小视角2:ORCFile与Parquet,开源圈背后的生意
上一篇文章聊了聊基于PAX的混合存储结构的RCFile,其实这里笔者还了解一些八卦,RCfile的主力团队都是来自中科院的童鞋在Facebook完成的,算是一个由华人主导的编码项目.但是RCfile仍 ...
- springboot2.x里面访问静态资源的坑
在spring boot的自定义配置类继承 WebMvcConfigurationSupport 后,发现自动配置的静态资源路径( classpath:/META/resources/,classpa ...
- zookeeper基本命令
集群角色: Leader:客户端提供读和写服务 Follower:提供读服务,所有写服务都需要转交给Leader角色,参与选举 Observer:只提供读服务,不参与选举过程,一般是为了增强zk集群的 ...