HashMap整理,面试看这篇就够了!
整理自:https://blog.youkuaiyun.com/zjxxyz123/article/details/81111627
和:https://blog.youkuaiyun.com/moakun/article/details/80231067
用自己的思路缕了一遍,复习自用~
基本概念
-
一般将数组中的每一个元素称作桶(segment),桶中连的链表或者红黑树中的每一个元素成为bin
-
capacity(方法内变量): 源码中没有将它作为属性,但是为了方便,引进了这个概念,是指HashMap中桶的数量。默认值为16。扩容是按照原容量的2倍进行扩。如果在构造函数中指定了Map的大小,那么进行put操作时,初始化后的容量为离传入值最近的2的整数幂,是通过tableSizeFor() 函数达到该目的。总之,容量都是2的幂。主要是可以使用按位与替代取模来提升hash的效率。
-
loadFactor: 译为装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。
-
threshold: threshold表示当HashMap的size大于threshold时会执行resize操作
-
DEFAULT_INITIAL_CAPACITY : 默认初始化容量 16。容量必须为2的次方。默认的hashmap大小为16.
-
MAXIMUM_CAPACITY :最大的容量大小2^30
-
DEFAULT_LOAD_FACTOR: 默认resize的因子。0.75,即实际数量超过总数DEFAULT_LOAD_FACTOR的数量即会发生resize动作。为什么是0.75,网上有些答案说是,因为capcity是2的次方,那么与之相乘会得到整数。还有一种说法更为可靠,负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
-
TREEIFY_THRESHOLD: 树化阈值 8。当单个segment的容量超过阈值时,将链表转化为红黑树。
-
UNTREEIFY_THRESHOLD :链表化阈值 6。当resize后或者删除操作后单个segment的容量低于阈值时,将红黑树转化为链表。
-
MIN_TREEIFY_CAPACITY :最小树化容量 64。当桶中的bin被树化时最小的hash表容量,低于该容量时不会树化。
HashMap扩容及其树化的具体过程
- 如果在创建 HashMap 实例时没有给定capacity、loadFactor则默认值分别是16和0.75。
- 当好多bin被映射到同一个桶时,如果这个桶中bin的数量小于等于TREEIFY_THRESHOLD当然不会转化成树形结构存储;如果这个桶中bin的数量大于了 TREEIFY_THRESHOLD ,但是capacity小于MIN_TREEIFY_CAPACITY 则依然使用链表结构进行存储,此时会对HashMap进行扩容;如果capacity大于了MIN_TREEIFY_CAPACITY ,才有资格进行树化(当bin的个数大于8时)。
hash值的计算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
n = table.length;
index = (n-1) & hash;
HashMap是以hash操作作为散列依据。但是又与传统的hash存在着少许的优化。其hash值是key的hashcode与其hashcode右移16位的异或结果。在put方法中,将取出的hash值与当前的hashmap容量-1进行与运算。得到的就是位桶的下标。那么为何需要使用key.hashCode() ^ h>>>16的方式来计算hash值呢。其实从微观的角度来看,这种方法与直接去key的哈希值返回在功能实现上没有差别。但是由于最终获取下标是对二进制数组最后几位的与操作。所以直接取hash值会丢失高位的数据,从而增大冲突引起的可能。由于hash值是32位的二进制数。将高位的16位于低位的16位进行异或操作,即可将高位的信息存储到低位。因此该函数也叫做扰乱函数。目的就是减少冲突出现的可能性。而官方给出的测试报告也验证了这一点。直接使用key的hash算法与扰乱函数的hash算法冲突概率相差10%左右。
其他的hash实现?
-
直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。
-
数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。
-
除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
-
分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
-
平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
-
伪随机数法:采用一个伪随机数当作哈希函数。
解决hash冲突的方法?
- 开放定址法
- 开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
- 链地址法
- 将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。
- 再哈希法
- 当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。
- 建立公共溢出区
- 将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
为什么capcity是2的幂?
-
index:因为 算index时用的是(n-1) & hash,这样就能保证n -1是全为1的二进制数,如果不全为1的话,存在某一位为0,那么0,1与0与的结果都是0,这样便有可能将两个hash不同的值最终装入同一个桶中,造成冲突。所以必须是2的幂。
-
resize:HashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),个人理解。
扩容操作resize
resize扩容操作主要用在两处:
-
向一个空的HashMap中执行put操作时,会调用resize()进行初始化,要么默认初始化,capacity为16,要么根据传入的值进行初始化
//putval if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; -
put操作后,检查到size已经超过threshold,那么便会执行resize,进行扩容:
//putval if (++size > threshold) resize(); -
如果此时capcity已经大于了最大值,那么便把threshold置为int最大值,否则对capcity,threshold进行扩容操作。
//resize Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { //运行时 put扩容情况(桶数>0) if (oldCap >= MAXIMUM_CAPACITY) { //如果此时capcity已经大于了最大值,那么便把threshold置为int最大值并返回,即无法扩容 threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //这里先把桶数capcity*2,然后对比最大容量(2 30)再看看原来的桶数是不是>=默认容量(如果删减的多就会小)如果都符合则 阈值(threshold)直接*2,否则newThr还是0 newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold //原桶数capcity=0,threshold>0的情况,应该是有参构造函数刚构造完还没put的情况 newCap = oldThr; else { // zero initial threshold signifies using defaults //原capcity=0,threshold=0的情况,应该是无参构造函数构造完还没put的情况,使用默认值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { //如果newThr在之前没有经过无参构造器或没有*2(原capcity*2>最大容量 或 原capcity<默认容量)则newThr赋值为新capcity*负载因子 float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; -
发生了扩容操作,那么必须Map中的所有的数进行再散列,重新装入。

在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变(因为任何数与0与都依旧是0),是1的话index变成“原索引+oldCap”。
例如:n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

//resize 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; }
put操作

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//初始化时,map中还没有key-value
if ((tab = table) == null || (n = tab.length) == 0)
//利用resize生成对应的tab[]数组
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//当前桶无元素
tab[i] = newNode(hash, key, value, null);
else {//桶内有元素
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//桶内第一个元素的key等于待放入的key,用
e = p;
else if (p instanceof TreeNode)
//如果此时桶内已经树化
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//桶内还是一个链表,则插入链尾(尾插)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//变成红黑树
treeifyBin(tab, hash);
break;
}
//遇到了一模一样的元素
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//检查是否应该扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
本文详细介绍了HashMap的内部机制,包括容量、装载因子、树化阈值等核心概念,并探讨了扩容和树化的过程。计算hash值时使用了扰动函数减少冲突,解决冲突的方法有开放定址法、链地址法等。HashMap在容量超过阈值时会进行扩容,当单个桶的元素过多时会转换为红黑树。在扩容时,已有的元素会根据新的hash值重新分布,减少了数据移动。此外,文章还提到了不同类型的哈希实现方法。
428

被折叠的 条评论
为什么被折叠?



