本文共 10559 字,大约阅读时间需要 35 分钟。
被面试官N连接之后痛心疾首!探究一整天HashMap源码,眼睛已瞎,总结如下
底层数据结构
JDK1.7:数组+链表
JDK1.8:hash表=数组+链表+红黑树
什么是哈希表?
哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
链表长度>=8时链表转为红黑树
Hash冲突
我们通过记算存入元素的hashcode值,然后对其进行一定规则的算法得出其下标index
例如:当一个元素的通过hash算法(比如取模法)去确定该元素应该存放在数组上哪个位置时: index=hash%length (对hashcode取余),如果得到的结果与另外一个元素记算索引得到的结果相等的时候就会产生hash冲突
解决Hash冲突有四个方法:
1.开放定址法(以冲突的下标为基础再次记算hash,直到不冲突)
这种方法也称再散列法,其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。
2.重Hash法(多个hash函数,一个hash函数计算出来的索引冲突了就换一个函数记算,直到不冲突)
这种方法是同时构造多个不同的哈希函数,当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。
3.链地址法
这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
4.建立公共溢出区
将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
链表与数组查询的时间复杂度对比
链表查询的时间复杂度是O(n),数组的查询时间复杂度是O(1),链表查询效率非常低,所以就引出了红黑树
红黑树
那为什么要在链表长度大于等于8的时候变成红黑树呢?
如果链表的长度没有达到这个长度的话,因为红黑树它自身的这种维护,插入的这种维护的开销也是非常大的,因为每次一去插入一个元素的时候很有可能会破坏掉他的平衡。也就是说hashmap的put的操作非常多的时候极有可能会影响插入的性能,因为插入一个元素的话,极有可能会打破它原有的平衡,那么每时每刻它都需要再恢复平衡(也就是红黑树的再平衡,需要左旋右旋,以及重新着色),就非常影响性能
hashmap中数组的初始长度(如果不传参,默认为16),以及初始长度的转化
一般的话数组的初始长度为16
最大容量
如果具有参数的任一构造函数隐式指定,则使用最大容量。必须是2的幂<= 1 << 30。
如果我们传入的初始容量不是2的指数次幂,他就会将这个数改成大于该数且最接近这个数的2的指数次幂
比如说传入的初始容量是13,他就会将13转换为大于13且最接近13的2的指数次幂的一个数:
13----------->16
再比如:7--------->8
5--------->8
3--------->4
源码分析:我们使用构造传进来一个初始容量的值initialCapacity
,默认会传入一个加载因子0.75
这里会先判断传来的初始容量是不是小于零的数字。如果是直接抛出异常,再判断是不是超过了hash定义的最大容量2的30次幂,如果超过了就将最大的容量赋值给它。再接着判断传来的加载如果小于零或者不是一个数字直接抛出异常。然后调用了一个tableSizeFor()
方法去处理传进来的初始容量
进入到tableSizeFor()
方法这个处理初始容量的方法中,发现他的目的是返回给定目标容量的两个大小的幂,这个就是为什么如果我们传入的初始容量不是2的指数次幂,他就会将这个数改成大于该数且最接近这个数的2的指数次幂
那么为什么要把初始容量转成2的指数次幂呢?不转成2的指数次幂也是可以存储的啊,为什么要转?
先看看源码:
/** * 计算key的hash值 */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } /** * 插入key-value */ public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * 实现put操作 * * @param key的hash值 * @param key值 * @param value值 * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return 之前的value */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node p; int n, i; //1. 如果当前table为空,新建默认大小的table if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //2. 获取当前key对应的节点 if ((p = tab[i = (n - 1) & hash]) == null) //3. 如果不存在,新建节点 tab[i] = newNode(hash, key, value, null); else { //4. 存在节点 Node e; K k; //5. key的hash相同,key的引用相同或者key equals,则覆盖 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //6. 如果当前节点是一个红黑树树节点,则添加树节点 else if (p instanceof TreeNode) e = ((TreeNode )p).putTreeVal(this, tab, hash, key, value); //7. 不是红黑树节点,也不是相同节点,则表示为链表结构 else { for (int binCount = 0; ; ++binCount) { //8. 找到最后那个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //9. 如果链表长度超过8转成红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //10.如果链表中有相同的节点,则覆盖 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; //是否替换掉value值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } //记录修改次数 ++modCount; //是否超过容量,超过需要扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
分析:涉及到两个因素:
1.效率因素
我们来看hashmap的put操作,他会计算一个key键的hash值
记算Key的hash值
如果还去使用取模运算index=hash%length
去寻找存放的索引的位置的话就牵扯一个问题,因为这个取模运算最终还要换算成2进制去计算。
我们知道计算机的运行效率:加法(减法)>乘法>除法>取模,取模的效率是最低的。所以我们要在hashmap中避免频繁的取模运算,又因为在我们hashmap中他要通过取模去定位我们的索引,并且hashmp是在不停的扩容,这数组一旦达到容量的阈值的时候就需要对数组进行扩容。那么扩容就意味着要进行数组的移动,数组一旦移动,每移动一次就要重回记算索引,这个过程中牵扯大量元素的迁移,就会大大影响效率。那么如果说我们直接使用与运算,这个效率是远远高于取模运算的。
2.与运算的因素
只是从效率这个点还不足以让我们去使用位运算而不去取模运算,那么为什么还是要使用2的指数次幂进行位运算?难道我不是2的指数次幂就不能进行位运算了吗?
我们分析下面代码, 获取当前key对应的节点 (p = tab[i = (n - 1) & hash]) == null
当中的这一句tab[i = (n - 1) & hash
,我们可以知道这个tab其实就是Map中实体数组,然后我们通过计算下标 i = (n-1) & hash
来获取对应索引,(n表示数组的长度,hash表示hashcode值)。那么当我们数组的长度不为2的指数次幂的时候就没有办法好好去定位了。
我们继续往下看,为什么数组的长度不为2的指数次幂的时候就没有办法好好去定位了?
现在我们可以使用与运算(n-1) & hash
取代取模运算hash%length
,因为这两种方式记算出来的结果是一致的(n就是length),也就是(length-1)&hash = hash%length
如果当length不等于2的指数次幂的时候,这两种方式记算出来的结果是不一样的,也就是说(length-1)&hash ≠ hash&length
当length=2的指数次幂时:(length-1)&hash = hash%length当length≠2的指数次幂时:(length-1)&hash ≠ hash%length
那么到底是为什么length非要等于2的指数次幂时两个等式才相等呢?
例如:(16 - 1)& hash二进制的15: 0000 0000 0000 1111 hash(随意值): 1010 1111 0110 1011我们知道与运算的法则为:0·0=0,1·0=0,0·1=0,1·1=1当15和hash进行与运算(&),因为15的二进制的低四位都是1,高位都是0,那么进行与运算的结果就只能和15的低四位有关。也就是说当hash值的低四位都为1时,结果最大为15;当hash值低四位都为0的时候,结果最小为0。那么结果的范围就只能在0-15之间,这个索引就不会跑出数组的长度范围,就不会出现越界。所以我们才规定这个必须使用2的指数次幂。我们在put的时候,以及数组扩容元素迁移的时候都需要进行都需要进行索引的记算
加载因子0.75f
在构造函数中未指定时使用的加载因子
我们知道,最理想的情况就是,当我们put进来的元素刚好平铺在数组上,而不产生链表,尽量不产生Hash碰撞。但是我们明白这种情况只是理想化的,是难以实现的。
那么当我们将负载因子不定为0.75的时候(两种情况):
假如负载因子定为1(最大值),那么只有当元素填慢数组长度的时候才会选择去扩容,虽然负载因子定为1可以最大程度的提高空间的利用率,但是我们的查询效率会变得低下(因为链表查询比较慢)
所以当加载因子比较大的时候:节省空间资源,耗费时间资源
加入负载因子定为0.5(一个比较小的值),也就是说,知道到达数组空间的一半的时候就会去扩容。虽然说负载因子比较小可以最大可能的降低hash冲突,链表的长度也会越少,但是空间浪费会比较大
所以当加载因子比较小的时候:节省时间资源,耗费空间资源
但是我们设计程序的时候肯定是会在空间以及时间上做平衡,那么我们能就需要在时间复杂度和空间复杂度上做折中,选择最合适的负载因子以保证最优化。所以就选择了0.75这个值
泊松分布
当我们在put一个元素的产生hash冲突的时候,会遵循泊松分布(通过概率学统计出来)的规则
泊松分布公式:(exp(-0.5) * pow(0.5, k)
泊松分布图:
意思就是说,当负载因子为0.75的时候,还有当链表的长度的增加。如果再添加新的节点进链表的时候,这个添加进当前链表概率是随着节点的增加而越来越少。
也就是说,当put进来一个元素,通过hash算法,然后最后定位到同一个桶(链表)的概率会随着链表的长度的增加而减少,当这个链表长度为8的时候,这个概率几乎接近于0,所以我们才会将链表转红黑树的临界值定为8
当然,虽然在hashmap底层有这种红黑树的结构,但是我们要知道能产生这种结构的概率也不大
所以我们知道在 JDK1.7 到 JDK1.8 这其中HashMap的性能只提高了 7%-8% 左右,提高的并不多。
JDK1.7中,当数组容量达到16*0.75=12
的时候,数组就需要扩容,在扩容的时候我们知道就需要一个元素的迁移
那么下面代码就是元素迁移的主要代码:
我们将重新记算hash值的逻辑先去掉(回顾:为什么使用位运算而不使用取模运算:因为位运算效率高,取模运算效率低,在数组迁移的时候会进行重hash,如果效率不高会极大的影响put的效率)。只留下主要的迁移数组的代码:
举例:
假设有两个线程,两个线程都对这个hashmap进行扩容,扩容就要执行数组迁移代码。
假设线程T2先来对数组进行迁移,当线程而二执行到Entry<K,V> next = e.next
的时候,然后线程T2突然阻塞了
外层for循环来遍历数组的,当数组的值不是空的时候就进入内层的while循环去遍历链表,假设线程T2两个指针(一个叫做e2,一个叫做next2),当遍历到这个结点的时候:e2表示的就是杨过这个节点,next2表示e2的下一个节点,也就是小龙女这个节点
然后刚好线程T1也跑进啦这个逻辑里面了,那么线程T1也有两个指针(e,next),e也指向的是杨过,next也指向的是小龙女
T1线程继续跑这个逻辑(下面是T1线程自己的扩容与迁移图),会继续转移这个链表,转移完成之后发现一个问题:转移过去后两个节点的上下位置反了(小龙女和杨过已经反了)
然后T2这个时候又唤醒了,因为线程T2一直都是指向这两个元素的(e2–>杨过,next–>小龙女),也就是成下图这样
然后T2也要去循环执行这个逻辑
最后会产生这样的情况,就是最终会形成一个环
JDK1.8中HashMap扩容的代码
final Node[] resize() { Node [] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults 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; @SuppressWarnings({ "rawtypes","unchecked"}) Node [] newTab = (Node [])new Node[newCap]; table = newTab; if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode )e).split(this, newTab, j, oldCap); else { // preserve order Node loHead = null, loTail = null; Node hiHead = null, hiTail = null; Node 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; }
当我们对数组进行迁移的时候,这里面定义了两组指针,分别是高位和低位的头和尾
然后用当前节点的hash值和旧数组的长度(16)做’与’运算
hash值: 0010 1111 1010 1110数组的长度(16) 0000 0000 0000 1000‘与’运算的结果,只可能有两种值:0000 0000 0000 0000---------------0 0000 0000 0000 1000---------------16也就是说当前节点用当前节点的hash值和旧数组的长度(16)做'与'运算的结果只可能是0或16
用当前节点的hash值和旧数组的长度(16)做’与’运算,结果出来如果时0就是低位指针,如果是16就是高位指针
那么我们再看
如果说 ‘与’ 出来的值是0,那么就会用低位的指针去迁移该数组。
如果判断出来的是16,就会用高位的指针去迁移。
那么我们的最终的指针就会成下面的样子:
然后就开始迁移
低位迁移:
首先:先将高位和低位断开(将低位的尾部的next置为空)
就成下面这样了:
紧接着将低位的头部loHead
付给新数组的某个值,也就是将低位的所有节点移动过去,并且放在与就索引相同的位置
高位迁移:
先将高位的尾部断开
再将高位的头部放到新数组的j + oldCap
索引处(当前索引+旧数组的长度),比如说现在的索引是3,再加上数组长度16,最后就是将高位放到新数组的索引为19的地方去
然后我们发现这个设计非常的巧妙,避免了顺序的颠倒,再也不会出现杨过和小龙女颠倒的问题了
总结:
在JDK1.8之后,HashMap底层的数组扩容后迁移的方法进行了优化。把一个链表分成了两组,分成高为和低位分别去迁移,避免了死环问题。而且在迁移的过程中并没有进行任何的rehash(重新记算hash),提高了性能。它是直接将链表给断掉,进行几乎是一个均等的拆分,然后通过头指针的指向将整体给迁移过去,这样就减小了链表的长度
转载地址:http://ptiwi.baihongyu.com/