HashMap快问快答
Posted 光光-Leo
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap快问快答相关的知识,希望对你有一定的参考价值。
他强由他强,清风拂山岗;他横由他横,明月照大江;他自狠来他自恶,我自一口真气足。 — 金庸 《倚天屠龙记》
目录
欢迎关注微信公众号“江湖喵的修炼秘籍”
1.HashMap的底层使用了什么数据结构进行存储?
HashMap使用哈希表进行数据存储,JDK1.7使用数组+链表实现,JDK1.8使用数组+链表+红黑树实现。
2.HashMap的put过程?
JDK1.8中HashMap进行put操作的过程如下:
1.计算关于key的hashcode值(与Key.hashCode的高16位做异或运算) (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
2.如果散列表为空时,调用resize()初始化散列表
3.通过(n-1)&hash 计算槽位 如果没有发生哈希冲突,直接添加元素到散列表中去
4.如果发生了哈希冲突,进行三种判断
4.1:若key地址相同或者equals后内容相同,则替换旧值
4.2:如果是红黑树结构,就调用树的插入方法
4.3:链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阙值8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。
5.如果桶满了大于阈值,则resize进行扩容
3.put时为什么要重新计算hash值?
哈希值的计算逻辑为key的hashcode异或其高16位 (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
目的是保留高16位的特征,在低16位一致,高16位有差异的情形下 一起参与计算,尽可能获取到不同的hashcode, 尽量的避免哈希冲突。
如:
key1.hashCode() : 1111 1111 1101 1101 0101 1101 1011 1111
key2.hashCode(): 1111 1111 1101 1110 0101 1101 1011 1111
如果直接使用key的hashCode计算在散列表中的位置 (n-1)&hash
key1.hashCode() :1111 1111 1101 1101 0101 1101 1011 1111
&
16-1 :0000 0000 0000 0000 0000 0000 0000 1111
= 0000 0000 0000 0000 0000 0000 0000 1111 =15
key2.hashCode() :1111 1111 1101 1110 0101 1101 1011 1111
&
16-1 :0000 0000 0000 0000 0000 0000 0000 1111
= 0000 0000 0000 0000 0000 0000 0000 1111 =15
key1和key2低16位一致 高16位有一些差异,在哈希列表的容量小于2的16次方时,高16位按位与的结果都是0,差异无法体现,计算的在散列表中一致。
如果把key的hashcode的高低16位进行异或运算后再计算
key1.hashCode() :1111 1111 1101 1101 0101 1101 1011 1111
^ >>> 16 : 0000 0000 0000 0000 1111 1111 1101 1101
=: 1111 1111 1101 1101 1010 0010 0110 0010
&
16-1 :0000 0000 0000 0000 0000 0000 0000 1111
= 0000 0000 0000 0000 0000 0000 0000 0010 =2
key2.hashCode() :1111 1111 1101 1110 0101 1101 1011 1111
^ >>> 16 : 0000 0000 0000 0000 1111 1011 1101 1110
=: 1111 1111 1101 1110 1010 0110 0110 0011
&
16-1 :0000 0000 0000 0000 0000 0000 0000 1111
= 0000 0000 0000 0000 0000 0000 0000 0011 =3
4.重新计算hashcode时为什么用异或运算符?
首先异或运算是针对hashcode的高16位和低16位进行的,使用位运算符,保证32位的值有一个发生变化,最终的结构就可能发生变化,同时也有一个较高的性能。
其次,对于三种位运算:
与运算 均为1时为1,否则为0,所以结果为1和为0概率是1:3
或运算 有一个为1则为1 否则为0 ,所以结果1和0概率是3:1
异或运算相同为0 不同为1 ,结果1和0的概率是1:1 不会有偏向性。
5.为什么哈希表长度必须是2的n次方?
1.计算元素在散列表中的位置,实际上是使用hash值对散列表长度取余,而对2的n次方取余可以使用&(n-1)代替 位运算的效率明显高于算数运算
2.如果长度不是2的n 次方,首先%n 与 &(n-1)就是不等价的,不可以使用与运算计算散列的位置,所以很多文章说的&(17-1)导致低位特征被屏蔽 导致哈希冲突概率增大说法其实不是很准确,这种方法计算的结果本身就不对。所以当指定hashmap的初始大小时,会使用tableSizeFor方法重新计算获取比入参大的最小的2的n次幂
6.谈一下hashMap的初始容量/扩容阈值/负载因子?
hashMap的初始容量默认是16 ,可以在定义hashMap时指定,如果指定了初始容量,hashMap会使用tableSizeFor方法重新计算获取比入参大的最小的2的n次幂作为最终的哈希表数组结构的长度。负载因为是一个介于0-1之间的数字,默认是0.75,数组长度*负载因为就是扩容阈值,但数组上的槽位使用量达到阈值时就会触发扩容。
7.负载因子的默认值为什么是0.75?
当初始容量为16,负载因子是0.75时,当数组中的元素大于12时,会触发扩容。
如果负载因为设置为1,则必须在数组元素被全部填充后才会进行扩容,期间由于哈希冲突很难避免,所以会导致链表或者红黑树承载的数据过多,降低查询效率,对get和put操作的性能都有影响。提高了空间利用率但降低了时间效率。
如果负载因子设置成0.5,则数组被填充一半就会开始扩容,可以降低红黑树的复杂度和链表长度,提高查询效率,也可以降低哈希冲突的几率。但是一方面由于频繁的扩容和rehash也是对性能的损耗。
0.75算是一个比较折中合理的值。
8.扩容的过程是怎样的?
1.7中扩容时,会对数据进行遍历,如果有对应的链表,会从数组元素作为头节点依次遍历,重新计算散列位置,存储到新的数组中,发生哈希冲突时,以头插法的方式添加到数组中,
这个过程会出现扩容前后,链表倒置。
void transfer(Entry[] newTable, boolean rehash)
int newCapacity = newTable.length;
for (Entry<K,V> e : table)
while(null != e)
Entry<K,V> next = e.next;
if (rehash)
e.hash = null == e.key ? 0 : hash(e.key);
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
1.8扩容时,也是对数组和链表依次进行遍历,但是不是直接重新计算散列值,而是通过e.hash & oldCap将原hash值与旧数组长度进行按位与,将结果为0的数据使用loHead和loTail作为头节点和尾节点的链表进行存储,并最终指向原位置;将结果不为0的使用hiHead和hiTail作为头节点和尾节点的链表进行存储,并指向newTab[j + oldCap],而且这个过程中先遍历的数据作为头节点,所以扩容后,链表不会倒置。
for (int j = 0; j < oldCap; ++j)
Node<K,V> 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<K,V>)e).split(this, newTab, j, oldCap);
else // preserve order
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;
关于e.hash & oldCap原理如下:
如下:
hash= 9: 0000 1001
&: 0000 0100
= : 0000 0000 = 0 仍保留在原位置
hash=5: 0000 0101
&: 0000 0100
= : 0000 0100 = 4 存储在新数组1+4=5的位置
1: 0000 0000
& 0000 0100 = 0 仍保留在原位置
1.7和1.8扩容后差异如下:
1.7计算散列值的indexFor 实际的运算是h & (length-1),1.8是e.hash & oldCap,都是一个位运算,个人理解性能差异应该不会太大,只不过1.8不会出现扩容后链表倒置的问题。
还有1.7是随着遍历将元素一个一个的转移到新的数组中,1.8是将原来的链表拆分成两条(原位置和需要挪位置的)然后将拆分后的链表整个转移
9.头插法和尾插法的区别?哪个好?
1.8是插入尾部,1.8之前是插入头部
1.8中put方法涉及链表的部分代码如下,是直接插在链表的尾部的
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;
为什么1.8之前采用头插法?作者认为后来新增的值被搜索的可能性更大。
缺点:1.7之前,采用头插法,当多线程环境下使用了hashmap,两个线程同时进行resize时,可能出现环形链表,当使用get方法查找该位置的元素时,会出现死循环。
链表头插法的会颠倒原来一个散列桶里面链表的顺序。在并发的时候原来的顺序被另外一个线程a颠倒了,而被挂起线程b恢复后拿扩容前的节点和顺序继续完成第一次循环后,又遵循a线程扩容后的链表顺序重新排列链表中的顺序,最终形成了环。1.8解决了这个问题 但并不代表hashmap就不存在线程安全问题,hashmap仍然没有解决多线程环境下两次put值会被覆盖的问题
10.对红黑树了解多少?
红黑树是一种平衡二叉查找树变体,左右子树的树高可能大于1,所以严格说不是平衡树;
每个节点非红即黑,根节点是黑色的;
所有叶子节点都是黑色的;
每个红色节点的左右子节点都是黑色的;
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点;
11.为什么引入红黑树?为什么不一开始就用红黑树?
引入红黑树是为了解决链表深度过大时,遍历查询效率慢的问题。当链表长度大于8时才会转换为红黑树进行存储,由于红黑树的转换和旋转也是有性能损耗的,一开始就使用红黑树,反而会更慢。如果hashcode分布够均匀,基本不会用到红黑树
12.ConcurrentHashMap怎么实现线程安全的
jdk1.7中ConcurrentHashMap通过分段锁保证安全性。ConcurrentHashMap本质是一个segment数组,segment是通过继承ReentrantLock来实现锁机制的,加锁的对象是segment,对于不同segment的操作不需要考虑锁竞争的问题。
1.ConcurrentHashMap是一个Segment数组,初始化ConcurrentHashMap时,会创建一个Segment数组,默认大小是16,所以默认创建的ConcurrentHashMap理论上最大支持16的并发线程,也就是并发度。
2.segment数组长度必须是2的N次幂,方便使用位运算进行散列。
3.Segment不允许扩容,ConcurrentHashMap一旦初始化完成,segment的数量就不允许增加了。
4.Segment数组的最大长度是1<<16,也就是ConcurrentHashMap的最大并发数。
5.创建segment数组时只会初始化第一个segment,其余的延迟初始化,默认都是null。
6.每个segment会包含一个Hash数组,类似HashMap的哈希数组,默认大小是2,负载因子是0.75,默认阈值是1.5,所以在插入第二个元素之后才会进行扩容。
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//MAX_SEGMENTS 为1<<16=65536,也就是最大并发数为65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
//2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
int sshift = 0;
//ssize 为segments数组长度,根据concurrentLevel计算得出
int ssize = 1;
while (ssize < concurrencyLevel)
++sshift;
ssize <<= 1;
//segmentShift和segmentMask这两个变量在定位segment时会用到
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方.
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;//2
while (cap < c)
cap <<= 1;
//创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
put操作:
1.先根据key的hash 值找到对应的segment
2.进入segment的put方法,先加锁,锁成功则继续,否则自旋 自旋最大次数单核为1 否则为64(Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1),因为1.7采用的是头插法,自旋时会获取到要插入的链的头节点然后持续判断next是否为null,不为null则预创建一个节点。
3.加锁成功后,类似hashmap的操作,hash & (length-1) 计算散列的位置,判断key存在则修改 不存在则新增
final V put(K key, int hash, V value, boolean onlyIfAbsent)
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;)
if (e != null)
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k)))
oldValue = e.value;
if (!onlyIfAbsent)
e.value = value;
++modCount;
break;
e = e.next;
else
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
finally
unlock();
return oldValue;
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value)
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
while (!tryLock())
HashEntry<K,V> f; // to recheck first below
if (retries < 0)
if (e == null)
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
else if (++retries > MAX_SCAN_RETRIES)
lock();
break;
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first)
e = first = f; // re-traverse if entry changed
retries = -1;
return node;
1.8 synchronized+CAS+HashEntry+红黑树
1.8ConcurrentHashMap的结构基本已经和hashmap接近了,放弃了重量级的synchroized
put时:
1.如果没有hash冲突就直接CAS插入
2.如果还在进行扩容操作就先进行扩容
3.如果存在hash冲突,就加锁来保证线程安全,链表长度大于8按是红黑树就按照红黑树结构插入 否则直接插入到链表尾部。
final V putVal(K key, V value, boolean onlyIfAbsent)
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;)
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null)
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else
V oldVal = null;
synchronized (f)
if (tabAt(tab, i) == f)
if (fh >= 0)
binCount = 1;
for (Node<K,V> e = f;; ++binCount)
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek))))
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
Node<K,V> pred = e;
if ((e = e.next) == null)
pred.next = new Node<K,V>(hash, key,
value, null);
break;
C++面试八股文快问快答のSTL篇