哈希表也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如:Redis)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。
在讨论哈希表之前,我们先回顾一下数组和链表来实现对数据的存储的优缺点:
**数组:**占用空间连续。 寻址容易,查询速度快。但是,增加和删除效率非常低。
**链表:**占用空间不连续。 寻址困难,查询速度慢。但是,增加和删除效率非常高。
从上分析我们知道,数组优势是查询效率高,链表的优势是增删效率高。那么有没有一种数据结构能结合“数组+链表”的双方优点呢?答案就是“哈希表”。
哈希表的本质就是“数组+链表”,这是一种非常重要的数据结构。在哈希表中进行添加、删除和查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成。
我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构。而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
我们打开HashMap源码,发现有如下两个核心内容:
其中的,Node[] table 就是HashMap的核心数组结构,我们也称之为“位桶数组”。我们再继续看Node是什么,源码如下:
一个Node对象存储了:
key:键对象
value:值对象
next:下一个节点
hash:键对象的hash值
显然就是一个单向链表结构,我们使用图形表示一个Entry的典型示意:
然后,我们画出Node[]数组的结构(这也是HashMap的结构):
由图可知,哈希表就是数组链表,底层还是数组但是这个数组每一项就是一个链表。
接下来我们来基于JDK1.7来模拟HashMap的实现,本章节重点模拟HashMap的put()方法和get()方法,在进行模拟put()方法和get()方法的实现之前,我们先做好相关的准备工作。
首先创建一个Node节点类,Node节点类是HashMap的内部类,它有几个重要的属性:键对象(key) 、值对象(value)、键对象的hash值(hash)和下一个节点(next)。
代码实现如下:
class MyHashMap<K, V> {
// Node节点,是一个单链表
static class Node<K, V> {
int hash; // 键对象的hash值
K key; // 键对象
V value; // 值对象
Node<K, V> next; // 下一个节点
// 构造方法
public Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
}
Node节点类实现完毕,我们继续添加HashMap中的两个重要属性:哈希表的Node[]数组(table)和存放元素的个数(size)属性。
代码实现如下:
class MyHashMap<K, V> {
// HashMap的核心数组结构,数组中的每一个元素都是一个链表
private Node<K, V>[] table = new Node[16]; // 默认长度为16
// 实际存储元素的个数
private int size;
// 此处省略Node节点的实现
}
注意:本次模拟HashMap属于简化实现,此处并没有去考虑table数组的“扩容问题”,所以我们在声明table数组的同时并完成了数组的初始化操作,默认初始化长度16个空间大小。
- 存储数据过程put(key,value)
准备工作完成之后,我们继续深入学习HashMap如何存储数据。此处的核心是如何产生hash值,该值用来对应数组的存储位置。
我们的目的是将“key-value”两个对象成对存放到HashMap的Node[]数组中。
实现步骤:
【第一步】:判断key****是否为null
先判断一下要存储内容的key值是否为null,如果为null,则执行putForNullKey()方法,这个方法的作用就是将内容存储到table数组的第一个位置。
【第二步】:获得key****对象的hashcode
如果key不为null,则再去调用key对象的hashcode()方法,获得key对象的哈希值。
【第三步】:获得存储位置的下标
hashcode是一个整数,我们需要将它转化成[0,数组长度-1]范围的整数。我们要求转化后的hash值尽量均匀地分布在[0,数组长度-1]这个区间,减少“hash冲突”。
公式:下标 = key的哈希值%数组长度
【第四步】: 将Node对象添加到table****数组中
当table[index]返回的结果为null时,则直接创建一个新的Entry对象添加到table[index]处。
当table[index]返回的结果不为null时,则判断链表中是否在相同key。如果存在同的key,就用新的value代替老的value,也就是执行覆盖操作。如果不存在相同的key,那么新创建的Node对象将会储存在链表的表头,通过next指向原有的Node对象,形成链表结构(hash碰撞解决方案)。
代码实现如下:
class MyHashMap<K, V> {
// 此处省略HashMap的属性
/**
* 添加键值对
* 如果存在相同的key,则返回被覆盖的value值
* 如果不存在相同的key,则返回null
*/
public V put(K key, V value) {
// 第一步:如果 key 为 null,调用 putForNullKey 方法写入null键的值
if(null == key) {
return putForNullKey(value); // null总是放在数组的第一个链表中
}
// 第二步:获得key对象的hashcode,确保散列均匀
int hashCode = key.hashCode();
// 第三步:获取在table中的实际位置,也就是在数组中的下标
int index = hashCode % table.length;
// 第四步:将Node对象添加到table数组中
// 如果table[index]不为 null,通过循环不断遍历链表查找是否在链表中有相同key
for(Node<K, V> e = table[index]; null != e; e = e.next) {
// 找到与插入的值的key相同的Node
if(e.hash == hashCode && (e.key == key || key.equals(e.key))) {
// 保存覆盖之前的value值
V oldValue = e.value;
// key值相同时直接替换value值
e.value = value;
// 结束方法,完成hashMap添加的操作
return oldValue;
}
}
// 如果table[index]为null或者key的hash值相同而key不同,则需要新增Node
addNode(hashCode, key, value, index);
return null;
}
/**
* 添加元素节点
*/
private void addNode(int hashCode, K key, V value, int index) {
// 获取索引值为hash的Node对象
Node<K, V> e = table[index];
// 在table数组中新增Node对象
table[index] = new Node<K, V>(hashCode, key, value, e);
// 实际存放元素个数累加
size++;
}
/**
* 当key为null时,存放key所对应的value值。
*/
public V putForNullKey(V value) {
// 当数组的第一个元素,存在key为null时,直接覆盖以前的旧值即可
for(Node<K, V> e = table[0]; null != e; e = e.next) {
// 找到与插入的值的key相同的Node
if(null == e.key) {
// 保存覆盖之前的value值
V oldValue = e.value;
// key值相同时直接替换value值
e.value = value;
// 结束方法,完成hashMap添加的操作
return oldValue;
}
}
// 当数组的第一个元素,不存在key为null时,直追加元素即可
addNode(0, null, value, 0);
return null;
}
// 此处省略Node节点的实现
}
总结:简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
- 取数据过程get(key)
实现步骤:
我们需要通过key对象获得“键值对”对象,进而返回value对象。明白了存储数据过程,取数据就比较简单了。
【第一步】:判断key是否为null
先判断一下要获取内容的key值是否为null,如果为null,则执行getForNullKey()方法,这个方法的作用就是将内容存储到table数组的第一个位置。
【第二步】:获得key对象的hashcode
如果key不为null,则再去调用key对象的hashcode()方法,获得key对象的哈希值。
【第三步】:获得存储位置的下标
获得key的hashcode,通过hash()散列算法得到hash值,进而定位到数组的位置找到对应的链表。
【第四步】:在链表上挨个比较key对象
调用equals()方法,将key对象和链表上所有节点的key对象进行比较,直到碰到返回true的节点对象为止。如果key对象和链表上的某个节点的key对象相同,则直接返回该节点对象的value对象值。如果链表遍历比较完毕,都没有遇到key对象和链表节点的key对象相同的情况,那么证明key对象对应的value对象不存在,直接但会null即可!
代码实现如下:
class MyHashMap<K, V> {
// 此处省略HashMap的属性
/**
* 根据key,获取key所对应的value值
*/
public V get(Object key) {
// 如果key是null,调用getForNullKey取出null的value
if(null == key) {
return getForNullKey();
}
// 1.根据该 key的hashCode值计算它的 hash码
int index = key.hashCode() % table.length;
// 2.直接取出table数组中指定索引处的值,
for(Node<K, V> e = table[index]; null != e; e = e.next) {
// 如果该 Entry 的 key和hash 与被搜索 key 相同
if ((e.hash == index && e.key == key) || key.equals(e.key)) {
return e.value;
}
}
return null;
}
/**
* 当key为null时,获取key所对应的value值
*/
public V getForNullKey() {
for (Node<K,V> e = table[0]; e != null; e = e.next) {
if (null == e.key)
return e.value;
}
return null;
}
// 此处省略Node节点的实现
}
到此处,基于JDK1.7关于HashMap底层put()方法和get()方法的实现就讲解完毕,那么接下来我们来补充两个关于HashMap的知识点。
- 扩容问题
HashMap的位桶数组,初始大小为16。实际使用时,显然大小是可变的。如果位桶数组中的元素达到(0.75*数组 length), 就重新调整数组大小变为原来2倍大小。
- JDK1.8使用红黑树的改进
在JDK8中对HashMap的源码进行了优化,在JDK7中,HashMap处理“碰撞”的时候,都是采用链表来存储,当碰撞的结点很多时,查询效率较低。
在JDK8中,HashMap处理“碰撞”增加了红黑树这种数据结构。当碰撞结点较少时,采用链表存储;当较大时(大于8个),采用红黑树来存储,这样大大的提高了查找的效率。
ps:如需最新的免费文档资料和教学视频,请添加QQ群(627407545)领取。