1. 前言
HashMap作为java数据结构中重要的一员,同时拥有高效的查询和插入的优点。近期在做项目的时候我也是领略到了hashmap的强大之处,hashmap为前后台数据的交互提供了非常高的可扩展性和灵活性。键值对的数据结构也天然对json字符串的转换提供良好的支持。这篇文章主要分析HashMap的原理,以及如何自己实现一个HashMap。
2. HashMap的原理
2.1 什么是HashMap?
基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变 ——摘自百度百科。
2.2 什么是Hash
Hash是一种算法,能将一个任意长度的二进制值通过一个映射关系转换成一个固定长度的二进制值。在HashMap中,哈希算法主要是用于根据key的值算出存放数组的index值。HashMap中的存储都是根据key的哈希值有关的。
哈希算法又被称为散列的过程,那么什么是散列呢?散列即把数据均匀的存放到数组中的各个位置,从而尽量避免出现多个数据存放在一块区间内。
优秀的哈希算法应该具备以下两点:
- 保证散列值非常均匀
- 保证冲突极少出现
哈希函数有很多种,在这里我以除留取余法(取模)为例,首先定义一个数组的长度,假设为16。那么此时的索引为key摸于m,m的取值规则是比数组长度小的最大质数。在这个情况下m为13。由于这种算法会导致这样的情况出现,即不同的key经过哈希运算之后得到了一样的index,如key为2和15的index值都为2,那么此时就需要我们处理冲突了。
Ps. JDK中的hash算法远比这个复杂,在这里我仅仅只是举一个例子方便读者理解。
2.3 处理冲突
- 线性探测法
当冲突产生时,查找下一个索引是否被占用,如果没有,则把数据存到该索引上。 - 链表形式
由于在HashMap中,单个数据是以entry的形式存储的,而entry中包含了key,value和next指针。那么当冲突产生时,我们就把原先存放到这个位置的数据取出来,然后在这个位置存放新的数据,并且把新数据的next指针设为原数据,也就是说链表头位置的数据永远是最新的数据。
3. 实现自己的HashMap
3.1 Map接口
HashMap的顶层接口是Map,那么我们自己实现的Map也需要一个接口,在这里我定义接口的名称为MyMap<K,V>。这个接口中应该含有的方法包括:
- put(K k,V v)
- get(K k)
- size()
和一个内部接口
- Entry<K,V>
这个内部接口中包含两个方法
- getKey()
- getValue()
代码如下
1 | public interface MyMap<K,V> { |
3.2 内部类Entry的实现
接口设计完毕之后,我们需要创建一个类来实现这个接口的方法。我创建了一个名为MyHashMap的类实现MyMap接口。
这里我们需要一个内部类来实现MyMap的内部接口,内部类的实例对象即数组中存储的entry对象,所以我们需要定义三个成员变量,分别是K,V和Next。next的类型就是entry本身,因为它指向的是下一个entry对象。
内部类代码如下
1 | class Entry<K, V> implements MyMap.Entry<K, V> { |
3.3 定义成员变量
HashMap中含有以下几个成员变量:
- 默认数组长度
- 默认负载因子
- Entry数组
- HashMap的大小
1 | //默认数组大小,初始大小为16 |
3.4 定义构造方法
在HashMap中默认数组长度和默认负载因子都是可以自定义的,那么我们定义一个可以自定义数组长度和负载因子的构造方法。
1 | /** |
如果不指定数组长度和负载因子则使用默认值
1 | /** |
3.5定义哈希函数
上文已经提过了,哈希函数我们使用除留取余法。定义一个整型m,m的取值应该是一个比数组长度小的最大质数,为了简化算法我取数组的长度作为m的值。以key的哈希值模于m,得到index的值并且返回。
1 | /** |
最后返回的时候用了一个三元运算符,是为了要确保index的值必须是一个正数。
3.6 实现put方法
首先,我们需要通过哈希算法得到数组的下标,然后把一个包含键值对以及next指针的entry对象存到该位置中。
在存入数组之前我们需要判断当前索引中是否已经存在数据。根据不同情况,做出不同的存储处理,代码中的注释有详细的解释。
1 | public V put(K k, V v) { |
3.7 实现HashMap的扩容
HashMap的扩容机制是:当map的大小大于默认长度*默认负载因子,那么数组的长度会翻倍,数组中的数据会重新散列然后再存放。那么在原先的put方法中,需要先判断是否达到扩容的标准在进行执行下面的代码。如果达到扩容的标准则需要调用扩容的方法。代码如下:
1 | //数组的扩容 |
因为在put方法中加上了对扩容标准的判断,原先的put方法需要修改
1 | public V put(K k, V v) { |
3.8 实现get方法
上文提到了,由于hash算法可能会导致相同的索引中包含了不同的entry对象,我们需要通过对比key值的方式来找到我们真正要的那个entry对象。代码如下:
1 | public V get(K k) { |
由于size方法非常简单,直接返回size即可,我直接贴一下代码
1 | //返回HashMap的大小 |
到了这里,HashMap的基本功能已经都写完了,包括put方法、get方法和扩容机制。那么下面我们来测试一下看看自己写的map能否实现功能。
4. 测试MyHashMap
写一个测试类,对比一下我们的HashMap和jdk自带的HashMap,看看输出是否一样。
测试类代码
1 | /** |
部分结果的截取:
1 | key: key980---value: value980 |
可以发现MyHashMap的输出和JDK的HashMap是完全一样的,那么说明了这个自己实现的HashMap功能已经基本实现。
然后我又在这个基础上测试了一下耗时,结果如下:
1 | MyHashMap耗时:30 |
性能上自然是JDK的HashMap更强大,这个毋庸置疑,JDK中的HashMap可以说是最优化的map实现了,我们自己实现的肯定不如人家的。但是这个性能已经是不错的了。
至此,我们已经完整的实现了一个自己的HashMap,希望这篇文章对你有帮助。