深入理解 HashMap
1. 简介
HashMap 是Java开发中使用频率最高的键值对数据类型容器。它根据键的哈希值(hashCode)来存储数据,访问速度高,但无法按照顺序遍历。HashMap 允许键值为空和记录为空,非线程安全。
另外,如果想要保持有序,可以使用LinkedHashMap。LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 Iterator 遍历 LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
2. 存储结构
HashMap 是数组 + 链表 + 红黑树(JDK1.8 新增)实现的哈希表结构,如下图如所示:
HashMap内部维护的数组类型为Node(jdk 1.8以前为Entry,仅仅换了名字)
transient Node[] table;
Node就是存储数据的键值对,它包含了四个字段,具体的实现如下:
static class Node<K, V> implements Map.Entry<K, V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K, V> next; //链表的下一个node
Node(int hash, K key, V value, Node<K, V> next) { ...}
public final K getKey() { ...}
public final V getValue() { ...}
public final String toString() { ...}
public final int hashCode() { ...}
public final V setValue(V newValue) { ...}
public final boolean equals(Object o) { ...}
}
由next字段可以看出,Node其实还是一个链表,即数组中的每个位置都被当成一个桶,一个桶存放一个链表,链表中存放哈希值相同的元素。
执行代码时,Java 会调用 key 的 hashCode 方法,计算哈希值,而后通过 Hash 算法的后两步运算(高位运算和取模运算)来定位该键值对在数组中的存储位置。而后遍历该位置上的链表,即可得到所要的值。
key 的哈希值有可能相同,造成 Hash 碰撞。避免 Hash 碰撞的方法主要有两种:采用更好的哈希函数(根据数据计算哈希值的函数)和更大的哈希数组。当然,数组过大时会造成空间的浪费,因此在效率和空间上需要做一个权衡。Java 采用了扩容机制来权衡数组大小。具体而言,每个哈希数组有一个上限大小和一个负载因子,当数据达到上限* 负载因子后,数组大小翻倍。
默认数组大小为 16,负载因子为 0.75。
需要注意的是,哈希表的大小一定为 2 的整数次方。所以当调用 new HashMap<>(19)
时,哈希表的大小为 32。(原因后面解释)
2.1 解决哈希冲突
哈希表为解决 Hash 冲突,可以采用开放地址法和链地址法来解决问题。HashMap 采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被 Hash 后,得到数组下标,把数据放在对应下标元素的链表上。
2.2 具体实现
2.2.1. 确定数组中的位置
不管增加、删除、查找键值对,定位到哈希数组的位置都是很关键的第一步。我们希望哈希值尽可能少的冲突,最好是数组中的每个位置只有一个元素,这样就可以直接定位,而不用去遍历链表。为了达到这个目的,一个好的定位方法是必须的。
Java 中 Hash 算法本质上就是三步:取 key 的 hashCode 值、高位运算、取模运算(结果作为数组下标)。
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取 hashCode 值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先,hash()
方法返回 key 的散列值(int 类型)。如果直接拿散列值作为下标访问话,范围从 -2147483648 到 2147483648,这么大的数组当然不能直接拿来使用。Java 是将它进行取模运算后当做数组下标使用的。模运算是在 indexFor(hash, length)
函数中完成的:
// 第三步,取模运算,利用与运算来实现,效率比 % 高
static int indexFor(int h, int length) {
return h & (length - 1);
}
不是说模运算么?为什么用的与运算?这是出于性能考虑的。模运算虽然简单便捷,但效率低下。为了更高效的实现模运算,Java 开发团队采用了一种「取巧」的方式来计算模运算的结果。
首先,HashMap 数组的长度必须为 2 的整数次幂,这样数组长度 -1正好相当于一个“低位掩码”。与操作的结果就是散列值的高位全部归零,只保留低位值用来做数组下标访问。(解释了为什么扩容总是翻2倍)
例如长度为 16 时,16-1 = 15,位表示为:00000000 00000000 00001111,与散列值做与运算后,只保留了低位的值。但是这样又会造成问题,要是只取最后几位的话,碰撞会很严重。在这里,高位运算就起了作用:
h = key.hashCode() ^ (h >>> 16);
右位移 16 位,正好是 32bit 的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
整个过程如下图所示:
3. 并发环境下 HashMap 会碰到的问题
多线程下 HashMap 会有线程安全的问题,主要是因为 HashMap 需要进行resize的操作。HashMap的容量是有限的,当元素个数超过负载上限*负载因子后,需要将大小变更为原来的两倍。这个操作的具体流程如下:
-
扩容:创建一个新的Entry数组,大小为原来的2倍
void resize(int newCapacity) { Entry[] oldTable = table; //引用扩容前的Entry数组 int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了 return; } Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组 transfer(newTable); //!!将数据转移到新的Entry数组里 table = newTable; //HashMap的table属性引用新的Entry数组 threshold = (int)(newCapacity * loadFactor);//修改阈值 }
-
ReHash,因为数组大小不同,Hash的规则也不同了,所以需要进行重新Hash
void transfer(Entry[] newTable) { Entry[] src = table; //src引用了旧的Entry数组 int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素 if (e != null) { src[j] = null; //释放旧Entry数组的对象引用 do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置 e.next = newTable[i]; //标记[1] newTable[i] = e; //将元素放在数组上 e = next; //访问下一个Entry链上的元素 } while (e != null); } } }
当多个线程同时对HashMap进行put操作时,如果同时出发了rehash操作,会导致HashMap中可能出现循环节点。