之前看过一次HashMap源码,忒难看。直接怼源码容易迷失在黑暗的森林中,开debug跟流程又不能一一睹其完整的芳容。于是尝试着先按自己的思路手撕一版简单的HashMap,再拿来和jdk的HashMap做比较,以此来拨开HashMap面纱下的真容。

按照预想,我的HashMap也采用了数组+链表的形式,它包含了如下的成员变量:

private Node<K,V>[] tab; //用来存放Key-Value对的数组

private int capacity; //HashMap的容量,也是tab数组的大小

private float loadFactor; //负载因子

private int threshold; //扩容阈值。等于capacity × loadFactor,HashMap中的元素个数超过此值时,触发自动扩容

private int size; //HashMap中当前的元素个数

其中静态内部类Node定义如下:

//一个元素是一个Node
static class Node<K,V>{
private K key;
private V value;
private int hash;
private Node<K,V> next;
private Node<K,V> prev;
public Node(int hash,K key,V value){
this.hash = hash;
this.key = key;
this.value = value;
}
}

包含了如下的静态全局变量:

private static int DEFAULT_CAPACITY = 1 << 4; 

private static float DEFAULT_LOAD_FACTOR = 0.75f;

private static int MAX_CAPACITY = 1 << 30;

包含如下成员函数:


  • 构造函数
    ​//还有2个重载的构造函数,方法签名如下 //public HasMap() //public HashMap(int capacity) //他们都调用了这个函数,只是对应属性传了DEFAULT的静态变量值 public HashMap(int capacity,float loadFactor){ //获得不小于capacity的最近的2的幂 capacity = tableSize(capacity); this.capacity = capacity; this.loadFactor = loadFactor; threshold = (int)(capacity * loadFactor); } ​
  • get函数
    ​//大概思路是根据key的hash值,取出tab数组中对应位置的元素 //然后判断该元素的key是否和要取的key一致,若一致则取出,不一致则尝试遍历链表,循环判断 public V get(K key){ Node<K, V> node = getNode(key); if (node == null){ return null; }else if (node.hash == hash(key) && Objects.equals(key,node.key)){ return node.value; }else { node = node.next; while (node != null){ if (node.hash == hash(key) && Objects.equals(key,node.key)){ return node.value; } node = node.next; } return null; } } private Node<K,V> getNode(K key){ int hash = hash(key); Node<K,V>[] table; Node<K,V> node; int i; if ((table = tab) == null || (node = table[i = (hash & (capacity - 1))]) == null){ return null; }else { return table[i]; } } ​
  • put函数
    ​public V put(K key,V value){ return putVal(hash(key),key,value); } private V putVal(int hash,K key,V value){ Node<K,V>[] table; Node<K,V> node; V oldValue = null; int i; if ((table = tab) == null || table.length == 0){ resize(); }else { node = getNode(key); if (node == null){ /** 不存在 **/ ensureInternalCapacity(size + 1); table[hash & capacity - 1] = new Node<>(hash,key,value); size++; }else { if (node.hash == hash && Objects.equals(node.key,key)) { oldValue = node.value; node.value = value; }else { Node prev = node; node = node.next; while (node != null){ if (node.hash == hash && Objects.equals(node.key,key)){ oldValue = node.value; node.value = value; break; } prev = node; node = node.next; } if (node == null){ node = new Node<>(hash,key,value); prev.next = node; node.prev = prev; size++; } } } } return oldValue; } ​
  • remove函数
    ​//大概思路是先获取key的hash值找到tab对应位置的Node元素,再比较该元素的hash是否等于key的hash,该元素的key是否等于key,若是,若该元素后还有冲突元素,则调整链表头为下一个元素,否则把tab[i]直接置空。若该元素不是要找的元素,则遍历链表,尝试移除要找的元素 public V remove(K key){ Node<K, V> node = getNode(key); if (node == null) return null; V oldValue = null; Node<K,V> prev = null; if (node.hash == hash(key) && Objects.equals(node.key,key)){ Node<K,V> next = node.next; tab[hash(key) & (capacity - 1)] = next; oldValue = node.value; node = null; }else { prev = node; node = node.next; while (node != null){ if (node.hash == hash(key) &&Objects.equals(node.key,key)){ prev.next = node.next; oldValue = node.value; node.next = null; node = null; break; } } } if (oldValue != null) size--; return oldValue; } ​
  • resize函数
    ​//大概思路是 //扩容为原来的2倍,然后遍历旧的tab,将位置为i的元素的链表,根据每个链表节点的hash值大小,拆分为2个链表,小于原capacity的为一个链表,大于原capacity的为一个链表。小于原capacity的链表保持原先的位置i不变,大于原capacity的链表,放到i + oldCap 的位置 private void resize(){ Node<K,V>[] oldTab = tab; int oldCap = capacity; int oldThr = threshold; int newCap = 0; int newThr = 0; if (tab == null || tab.length == 0){ newCap = oldCap; newThr = threshold; }else { newCap = (oldCap << 1) > MAX_CAPACITY ? MAX_CAPACITY : (oldCap << 1); newThr = (int)(newCap * loadFactor); } tab = new Node[newCap]; for (int i = 0; i < oldCap; i++){ Node<K,V> node = oldTab[i]; if (node != null){ Node<K,V> lowHead = null,lowTail = null; Node<K,V> highHead = null,highTail = null; do{ if ((node.hash & oldCap) == 0){ if (lowHead == null){ lowHead = node; }else { lowTail.next = node; } lowTail = node; }else { if (highHead == null){ highHead = node; }else { highTail.next = node; } highTail = node; } }while ((node = node.next) != null); tab[i] = lowHead; tab[i + oldCap] = highHead; } } } ​
  • 其他的功能性私有函数
    ​ /** return pow of 2 **/ private int tableSize(int size){ int n = size - 1; n |= n >> 1; n |= n >> 2; n |= n >> 4; n |= n >> 8; n |= n >> 16; return n <= 0 ? 1 : (n + 1) >= MAX_CAPACITY ? MAX_CAPACITY : (n + 1); } /** 获得Key的hash值 **/ private int hash(K k){ int h; return k == null ? 0 : (h = k.hashCode()) ^ (h >>> 16); } //put插入新元素时,先调用该函数 private void ensureInternalCapacity(int newSize){ if (newSize > threshold){ resize(); } } ​

再来看JDK的HashMap,先只关心基本的get/put/remove函数,不关心树化等操作

对比了一下,发现JDK的HashMap,和我自己手撕的HashMap,主要有以下几点不同:



JDK的HashMap,其成员变量只有如下的几个,并不包含capacity。它使用threshold作为此处是容量的placeholder,其实capacity这个属性,在之后貌似也没有什么地方需要用到了,需要获得容量时,直接取table.length就可以了

拨开HashMap的隐秘森林_java



其构造函数,只设置了loadFactor和threshold(无参构造函数的threshold默认为初始值0)

拨开HashMap的隐秘森林_数据结构_02
拨开HashMap的隐秘森林_构造函数_03
拨开HashMap的隐秘森林_java_04

可以看到若指定了初始容量,会调用tableSizeFor函数,来调整容量为2的幂

拨开HashMap的隐秘森林_构造函数_05



get函数

拨开HashMap的隐秘森林_java_06

get函数调用了一个getNode函数,入参是key的hash值,和key

拨开HashMap的隐秘森林_链表_07

可以看到getNode函数会先取出tab对应位置上的Node,然后判断是否是要找的Node,若不是,则循环遍历这个Node对应的链表。这样找到的Node就是最终要找的元素。我自己的getNode实现是直接取出tab对应位置的Node,而把判断Node是否是要找的元素这一步,放到了get里面去做



put函数

拨开HashMap的隐秘森林_数据结构_08
拨开HashMap的隐秘森林_数据结构_09

当第一次调用put函数时,由于table为null,会进入到resize方法进行扩容。然后根据要插入的元素的key,定位到table中对应的位置,若该位置的元素为null,说明没有发生冲突,则直接插入。

否则,说明待插入的元素已经存在,或者存在Hash冲突。它首先判断了是否有相同元素,若有,则e不为空,若没有相同元素,则直接以尾插法插入到链表尾部。若e不为空,则说明是存在相同元素,则进行value的覆盖,并直接return。若e不为空,才会继续往下走,对modCount和size进行+1,然后若size超过threshold,在进行一次扩容。

和我自己实现的put方法不一样的地方在于:我是在插入新元素之前,调用ensureCapacity根据需要进行扩容,而jdk的HashMap,是在每次插入完后,根据需要进行扩容。并且他是通过一个Node变量e,来表示是否存在重复元素,统一再最后来对重复元素做判断,以及对size进行+1。而我则是用比较笨的分支控制,没有他写的优雅



remove函数

拨开HashMap的隐秘森林_java_10

拨开HashMap的隐秘森林_数据结构_11

看上去没什么不一样,也是先定位到table中的某个位置,然后判断hash值和key是否一样,若要移除的是链表头元素,则需要将table对应的位置设置为要移除的Node的next,否则,就是从一个普通链表中移除一个中间元素



resize函数

这个函数太长了,分两次截图

拨开HashMap的隐秘森林_链表_12
拨开HashMap的隐秘森林_链表_13

这写法也是没shei了,我愣生生看了好几遍才琢磨清楚。

resize函数分为2部分来说,第一部分是new出一个新的table,第二部分是把oldTable里的元素重新放到newTable中。

先说说调用resize的2种场景:


  1. 第一次调用put函数
  2. 元素个数超过阈值进行扩容

对于场景1,第一次调用put函数。则resize函数只是做了个初始化,故可以只看resize函数中new出一个table的逻辑。这又得看是调用的什么构造函数



调用无参构造函数

对于无参构造函数,我们知道,仅仅是设置了loadFactor为0.75。这时oldCap=0,oldThr=0。则会进入到如下的代码片段

拨开HashMap的隐秘森林_java_14

可以看到将newCap设为了默认容量16,newThr则是16 * 0.75=12



调用含参构造函数

若调用了含参的构造函数,则threshold为大于指定的容量的最小的2的幂。那么此时oldCap=0,oldThr不为0,进入到如下代码片段

拨开HashMap的隐秘森林_构造函数_15

将newCap设为了oldThr,这个oldThr暂存了初始容量(这也就体现了threshold作为初始容量的placeholder),而后更新了newThr为 newCap * loadFactor



设置完newCap,newThr后,就是对table进行初始化了

拨开HashMap的隐秘森林_构造函数_16

场景1,第一次调用put函数,说完了。现在来说场景2,元素个数达到阈值后的自动扩容

此时很明显,oldCap和oldThr都不为0。则new一个新的table时,走如下的代码片段

拨开HashMap的隐秘森林_链表_17

新的table创建完毕后,就要将旧的table里的值,塞到新的table里去了。

开始遍历oldTable,获取当前的元素Node,若当前Node不为空,且当前Node没有next节点,则直接根据hash值塞到新的Table中

拨开HashMap的隐秘森林_构造函数_18

否则,说明当前Node存在冲突,是一个链表,则要对该链表进行rehash
拨开HashMap的隐秘森林_hashmap_19

大概原理是,遍历该链表,对于每个节点,看该节点的hash值,是否小于原来的capacity,这是通过计算 ​​hash & oldCap​​是否等于0来判断的,因为我们的capacity是2的幂,表示为二进制则是1后面很多个0,如​​10000000​​,那只要hash值小于这个oldCap,与操作得到的结果一定是0,若hash值大于oldCap,与操作得到的结果不为0。于是把hash值小于oldCap的节点,放在了链表loHead上,把hash值大于oldCap的节点,放在了链表hiHead上。划分完成后,再把loHead节点,放在新的table里,原先的位置j,把hiHead节点,放在位置 j + oldCap 上。因为原先放在同一个位置j上的节点,其hash值一定是 n*oldCap + j,而现在扩容为原先的2倍,所以放在j + oldCap位置上的节点,其hash值本身是满足 hash & (newCap - 1) = j + oldCap的。

HashMap的基本操作就分析到这里,另外说说HashMap里暗藏的一些小玄机。

首先是各个静态变量都使用了位操作来提高效率:

拨开HashMap的隐秘森林_链表_20

还有HashMap自带的hash函数,用来计算key的hash值

拨开HashMap的隐秘森林_数据结构_21

返回的hash值是个int变量,由于int是4个字节,32位,算hash值的时候,将key本来的hashCode的高16位和低16位做了异或,将高16位的特征也apply在了低16位上了。这样能够很好的避免某些key的hashCode值只在高位不同,而低位相同的问题。由于在日常使用中,HashMap的大小通常不会超过216,而一个元素存放的位置下标,是由取模运算决定的,而HashMap的容量固定是2的幂,取模运算简化为了与运算,则可认为,一个元素存放的位置下标,就是其hash值的低x位的值(HashMap的容量为2x)。如果不将高位的特征apply到低位,则会导致多个key,由于低位特征相似, 而被放在HashMap的相同位置,产生频繁的碰撞。

还有这个计算不小于数n的最小的2的整数次幂,比如1,算出来就是1,3,算出来是4;9,算出来是16…

拨开HashMap的隐秘森林_java_22

先看看为什么能达到这样的效果,一个正数cap的二进制表示,从高位往低位看,最高位一定在第一次出现1的位置。

比如 ​​000010101111011​​,则最高位是最左侧的1。

把cap右移1位,再和cap做或运算,则能保证最高两位都是1,再将这个结果继续右移2位,做或运算,则能保证最高的前4位都是1,再继续右移4位,做或,保证了最高的前8位都是1…依次类推…由于int是32位,所以最后右移16位就可以保证,最高位之后的所有位,都是1,再将这个数加1,就得到一个2的幂,并且这个2的幂,是刚刚不小于原来的数cap的最小的2的幂。那么这个函数一开始为什么要对cap进行减1呢?想想如果传入的cap恰好是2的幂,比如4,如果不减掉1再做运算,则算出来的结果是8,而我们想要取不小于一个数的最小的2次幂,当这个数本来就是2的幂的时候,直接返回该数就可以了。

另外,关于树化阈值8和非树化阈值6。

拨开HashMap的隐秘森林_数据结构_23

当table的一个位置,链表长度达到树化阈值,并且HashMap的容量超过了最小树化容量(64)的时候,会触发树化,将该位置的链表,转换为红黑树。从而提高查询效率。

为什么树化阈值定为了8?根据源码的注释,是与一个统计学概念有关。当选择了一个散列性较好的hash函数时,table某个位置上的元素个数(链表的长度)的概率分布,服从泊松分布(泊松分布是一种离散概率分布,它描述的大概是在一段时间内,n次独立事件发生的次数的概率分布,比如我丢10次硬币,出现5次正面的概率,当然这个比喻不够严谨,详细的可以参考​​文末的链接​​)。当loadFactor为0.75时,泊松分布的均值为0.5,从而计算出出现相应元素个数时对应的概率值

拨开HashMap的隐秘森林_hashmap_24

出现元素个数为8的概率非常的小。所以其实在普通使用的场景下,是见不到树化操作的。个人认为,树化是处于安全性的考虑。如果用户有意或无意地使用了散列性能十分糟糕的hash函数,为了避免链表过长而导致的查询性能下降,才采取了树化的策略,牺牲一定的空间(树化后的节点比普通节点所占的空间要多一倍),来换取时间。另外,如果有黑客发起哈希碰撞的攻击,树化的策略也能为查询性能提供一个兜底的保障,再不济也有O(logn)的性能,而不至于下降到O(n)。

非树化阈值为6,和树化阈值8之间保留了7的位置,可以一定程度避免反复插入,删除会产生碰撞的元素,导致频繁地在树化,非树化之间进行转换,要知道树化和非树化的操作是比较耗时的。

还有一个最小树化容量64

拨开HashMap的隐秘森林_链表_25

这个参数,是考虑到这样一种场景,若table的某个位置上链表的长度达到了树化阈值8,可能是由于HashMap本身的容量太小,比如HashMap容量为1或2。此时应该采取的措施是扩容而不是树化。

(完)