HashMap概述
链表(LinkedList)短板是查询慢,动态数组(ArrayList)短板是增删慢,HashMap克服了这两个缺点,在录入数据时候花费额外的算力结构化每个数据,做到了可以快速查找,增删。存储数据时候输入<key, value>键值对, 在后面的查询时候直接给出key就可以快速得到value。为了方便后面的说明,先说下HashMap的大致工作原理,用一个Object<key, value>的key的hashcode处理下,得到一个近似独特的身份信息,用这个信息结合算法得到HashMap中的位置,如果两个元素恰巧都放置到哈希表中的同一位置,那么就用链表连接他们。如果一个位置上的元素太多,为了查找效率,链表会转化成树。
1.源码分析HashMap的创建, 扩容, 数据存储结构
2.
让我们从源码入手一步步分析
构造函数
先来看构造方法:无参构造方法注解标注是新建初始容量是16,加载因子是0.75。一个参数的构造可以指定初始容量,加载因子默认0.75。重点看下面这个双参数构造,我把注解加在了代码中,其中最大容量 MAXIMUM_CAPACITY = 1 << 30(2的30次方)。
public HashMap(int initialCapacity, float loadFactor) {
//初始容量小于零抛异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//初始容量不能取超过最大容量的值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//加载因子要为大于0且合法的浮点数
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//见加载因子说明
this.loadFactor = loadFactor;
//见tableSizeFor说明
this.threshold = tableSizeFor(initialCapacity);
}
这段代码引申出两个内容:加载因子和出发扩容的临界值threshold
加载因子说明
为什么是0.75?一部分博客都说是泊松分布,笔者经过认真查证,发现这说法是错的。JDK源码中那段泊松分布是指链表转红黑树的临界值的由来,里面的0.75是指在threshold为0.75情况下进行计算,大家别看个0.75就以为是说threshold的取值由来。 下面这部分才是真正进行描述的字段。大意就是取高了哈希碰撞比较严重,需要各种接连表链表,建立树,花费较大,查询性能下降;取低了扩容频繁且浪费空间,源码并没有给出0.75的由来。推荐看下StackOverFlow上面大家的猜测: the-significance-of-load-factor-in-hashmap
tableSizeFor说明
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
因为hashmap的尺寸要求返回2的幂(原因见下面的putVal函数),因此tableSizeFor的作用就是将用户看心情输入的数字转化为大于等于这个数的最小的2的幂。下面是异或操作,然后稍微推演下
假设有用户输入的数字是 0100000001,减一后转成二进制数是 0100000000,我们的任务是产生1000000000。
先右移一位,变成0010000000,做|=操作,变成0110000000,
再右移两位,变成0001100000,做|=操作,变成0111100000,
再右移四位,变成0000011110,做|=操作,变成0111111110,
再右移八位,变成0000000001,做|=操作,变成0111111111,
0111111111 + 1 = 1111111111
以此类推,31个1以内,都可以通过这个方法找到正确的解。另外需要注意的是减一的操作,是针对于用户输入恰巧就是一个2的幂的情况,这种情况下用户输入的就是最终解,不减一求出来的解会是用户输入答案的二倍。
MAXIMUM_CAPACITY = 1 << 30说明
有人觉得这个MAXIMUM_CAPACITY 应该取整数最大值1 << 31,但是看下面resize()函数,这个函数是在hashMap准备扩容时候返回的新数组长度,threshold扩容临界值被赋予了Integer.MAX_VALUE,数组长度是可以达到这个最大值的!
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
经过上面一番探索,hashmap的构造函数的几个考点以及思维导图就可以呈现出来了。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
包含了hash值(根据输入的key值生成的“身份证”, 后文会提到),输入的Key和value, next 是指向下一个node的指针。下面代码就是生成map后向里面塞元素的操作put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
看hash()方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
返回的是key的hashcode的再运算。因为key的hashcode是一个4字节整数,因此共有32位。亦或操作规则如下,数位不等返回1,相等返回0。这么做的意义是混合高位和低位的信息,在数组长度较小的情况下也能尽量不浪费高位信息。hash方法返回的数据是用来帮助后面确定某个key在数组中对应的位置。其实直接使用hashcode取模数组长度也行,但是这样计算代价有点大。
向HashMap写入数据的函数put追根溯源就是putVal函数,好大一坨,我们把它分成几部分
1 resize 数组扩容
2 新来的元素塞到哪个位置
3一个格子挤了太多元素咋办
1 resize 数组扩容
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
当数组长度为0时候向里面添加元素,数组会先进行扩容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//oldCap大于零代表哈希表已经建立,不属于初次扩容
if (oldCap >= MAXIMUM_CAPACITY) {
//如果已经大于最大容量,就不扩容
//MAXIMUM_CAPACITY是2^30次方答案在这里,数组最大长度还是会达到MAX_VALUE(2^31)的
threshold = Integer.MAX_VALUE;
return oldTab;
}
//没大于最大容量,在老容量的基础上变成二倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩容阈值也变成二倍
newThr = oldThr << 1;
}
//oldTab为空,但是threshold有值,就会落入这个else if , resize函数在table为空创建新 HashMap时候被调用
else if (oldThr > 0)
newCap = oldThr;
else {
// oldTab为空,但是threshold无值,一切按照默认来
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
中间断开一点说明下,下面开始是初始化table,原来一直有个疑问,如果数组resize,上面原来放进去的元素和后面放进去的摆放规律发生了变化,怎么做到快速查找?下面的代码给出了答案:每次扩容,之前的元素重新计算位置摆放(也只能这么解决,听起来有点麻烦)。
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//遍历老table
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//拿出Node,清空老位置数据
oldTab[j] = null;
//如果老Node后面没有挂其他Node,直接安家到新table
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//老Node底下挂着树结构,树结点挪到新地方(具体需要看Node下面的split方法,这里不做展开)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
这里断开一下,就是老node是链表的情况下如何移动,其他博客没有提到为什么链表不是整条迁移到新地方,缺少连贯性。因为位置是(n - 1) & hash这么来的,本质上是hash取模(表长 - 1)
假设表长4,key1 hashcode是00010, key2 hashcode 00110, key3 hascode 11010。
他们三个元素和 4 - 1也就是 011做&运算的结果都是010,一声令下,被安排到数组索引是2的位置组成了一个链表,如下图所示
这时候扩容了4 << 1变为8
第一和第三位老铁的位置原地不动,第二位前面多了个1。红色位置是否为1让他们拥有了截然不同的命运。红色位置就是老表长二进制的最高位H。因此需要检查hashcode的H位是否为1,若为1,新索引就是就是(老表长 + 原来位置)。若为0, 就是原地不动。源码中resize函数的如下部分说的就是这个。
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
截止到现在,我们的思维导图构建到这步
resize函数剩下的部分就是常规操作,复制链表之类的大家可以直接看代码没什么难点。
//老Node后面连着其他Node,开整
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
再来看下put方法,其本质就是putVal, 传入的是hash(key), key和value
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可以看到,(n - 1) & hash用来计算位置,我们来想下为什么不用hash%(n - 1)。取模运算需要转化十进制成为二进制再运算,位运算直接在内存中操作很快。但是&只有在n是2的幂才管用。
13 % 4 = 1
13 & 4 - 1 就是 1101 & 11 = 1
13 % 3 = 4;
13 & 3 - 1 就是 1101 & 10 不等于 4
tableSizeFor中返回2的幂就是要符合位运算的要求,提高速度
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//新建table,计算size
n = (tab = resize()).length;
//用(n - 1) & hash 来计算元素应该放置的位置
if ((p = tab[i = (n - 1) & hash]) == null)
//位置是空的,直接放进去
tab[i] = newNode(hash, key, value, null);
else {
//否则连到已有节点后面
Node<K,V> e; K k;
//第一个node的hash值即为要加入元素的hash,且key是同一个,覆盖老Node中的value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//发现要进入的地方已经成树了,转到树节点的putTreeVal方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//那就是链表没跑了
for (int binCount = 0; ; ++binCount) {
//找到尾端也没发现,新建一个node,挂到最后一个node上
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//达到TREEIFY_THRESHOLD - 1 = 7,链表转成树,为啥是8,我们后文来探究!
//binCount从0开始,也就是说挂到第八个时候转树,链表最长可以到7
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//找到和要放入元素的key的hash码相同且key相等的元素
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e不为空,覆盖老Node中的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
TREEIFY_THRESHOLD取值为8
源码中直截了当给出了解释
tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* with a parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)).
在一个良好分布的哈希表中很少用到树形结构,每个桶(数组元素)里面的元素数量服从泊松分布,下面式子是泊松分布的概率公式,源码中假设λ就是0.5,那么只要代入相应的k值,你就能得到相应概率。然后有给出了P在k不同的情况下的值。可以看到一盒桶里面有8个元素的概率是0.00000006,这也太小了!所以树形结构真是不太可能用到。你想想,前面都是怎么折腾的:hashcode本身就不太容易重复,完后hash()函数又把hashcode前16位和后16位做异或运算,还有扩容机制,这些都保证了hash的均匀分布。
到现在,知识图谱已经进行到这一步了
哇咔咔,基本的东西都写好了,后续我会把HashTree, HashTable都整理出来,期待拼一个更大的图!