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篇

Redis快问快答

快问快答—腾讯云服务器常见问题解答

快问快答,MySQL面试夺命20问

C语言入门快问快答!

快问快答,计算机网络面试夺命20问