面试必考之HashMap底层以及扩容机制

目录

一:知识前瞻 

二:HashMap的底层数据结构

三:HashMap扩容为什么总是2的次幂?

四、JDk1.7HashMap扩容死循环问题

五:由链表到红黑树的转变

六:HashMap扩容机制

七:get(i)的流程

八:HashMap是否线程安全,为什么

九:支持线程安全的实现类有哪些;ConcurrentHashMap是如何支持线程安全的

十:ConcurrentHashMap是如何支持线程安全?

十一:HahsMap和HashTable的区别​​​​​​​


一:知识前瞻 

    // 初始化 1左移四位
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

	
	//最大容量 1左移30位
	static final int MAXIMUM_CAPACITY = 1 << 30;

	
	//加载因子系数
	// TODO  一分成四等分,在容量四分三的时候自动扩容
	static final float DEFAULT_LOAD_FACTOR = 0.75f;

由源码可知,HashMap的容量,默认是16,HashMap的加载因子,默认是0.75

面试:hashMap什么时候扩容?

注意,是在put的时候才会扩容,在容量超过四分之三的时候就会扩容

面试:hashMap的key可以为空吗

可以,Null值会作为key来存储

面试:key重复了,会被覆盖吗?

会的,参考下面代码注释3

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;
   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))))
         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;
         }
      }
      //注释3
      //value是新传进来的value,把他覆盖给了原来的e.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;
}

二:HashMap的底层数据结构

HashMap 就是以 Key-Value 的方式进行数据存储的一种数据结构

JDK1.7采用的是数组+链表,使用 Entry 类存储 Key 和 Value

JDK1.8采用的是数组+链表/红黑树,使用 Node 类存储 Key 和 Value

三:HashMap扩容为什么总是2的次幂?

HashMap扩容主要是给数组扩容的,因为数组长度不可变,而链表是可变长度的。从HashMap的源码中可以看到HashMap在扩容时选择了位运算,向集合中添加元素时,会使用(n - 1) & hash的计算方法来得出该元素在集合中的位置。只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)的2进制也就是1111111***111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞

当HashMap的容量是16时,它的二进制是10000,(n-1)的二进制是01111,与hash值得计算结果如下:

在这里插入图片描述

上面四种情况我们可以看出,不同的hash值,和(n-1)进行位运算后,能够得出不同的值,使得添加的元素能够均匀分布在集合中不同的位置上,避免hash碰撞。

下面就来看一下HashMap的容量不是2的n次幂的情况,当容量为10时,二进制为01010,(n-1)的二进制是01001,向里面添加同样的元素,结果为:
在这里插入图片描述

可以看出,有三个不同的元素经过&运算得出了同样的结果,严重的hash碰撞了。导致某一个链表的长度特别长,影响查询的效率

四、JDk1.7HashMap扩容死循环问题

 HashMap是一个线程不安全的容器,在最坏的情况下,所有元素都定位到同一个位置,形成一个长长的链表,这样get一个值时,最坏情况需要遍历所有节点,性能变成了O(n)。
JDK1.7中HashMap采用头插法拉链表,所谓头插法,即在每次都在链表头部(即桶中)插入最后添加的数据。
死循环问题只会出现在多线程的情况下。


假设在原来的链表中,A节点指向了B节点。
在线程1进行扩容时,由于使用了头插法,链表中B节点指向了A节点。
在线程2进行扩容时,由于使用了头插法,链表中A节点又指向了B节点。
在线程n进行扩容时,…
这就容易出现问题了。。在并发扩容结束后,可能导致A节点指向了B节点,B节点指向了A节点,链表中便有了环
 

死循环原因https://gupaoedu-tom.blog.csdn.net/article/details/124449573?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-blog-2~default~CTRLIST~Rate-3-124449573-blog-122054014.pc_relevant_multi_platform_featuressortv2dupreplace&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2~default~CTRLIST~Rate-3-124449573-blog-122054014.pc_relevant_multi_platform_featuressortv2dupreplace&utm_relevant_index=6

解决 方案:

1)、使用线程安全的ConcurrentHashMap替代HashMap,个人推荐使用此方案。

2)、使用线程安全的容器Hashtable替代,但它性能较低,不建议使用。

3)、使用synchronized或Lock加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。
 

五:由链表到红黑树的转变

为了解决JDK1.7中的死循环问题, 在jDK1.8中新增加了红黑树,即在数组长度大于64,同时链表长度大于8的情况下,链表将转化为红黑树。同时使用尾插法。当数据的长度退化成6时,红黑树转化为链表。

从JDK1.8开始,在HashMap里面定义了一个常量TREEIFY_THRESHOLD,默认为8。当链表中的节点数量大于TREEIFY_THRESHOLD时,链表将会考虑改为红黑树,代码是在上面putVal()方法的这一部分:

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;
}

其中的treeifyBin()方法就是链表转红黑树的方法,这个方法的代码是这样的: 

    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

可以看到,如果table长度小于常量MIN_TREEIFY_CAPACITY时,不会变为红黑树,而是调用resize()方法进行扩容。MIN_TREEIFY_CAPACITY的默认值是64。显然HashMap认为,虽然链表长度超过了8,但是table长度太短,只需要扩容然后重新散列一下就可以。

后面的代码中可以看到,如果table长度已经达到了64,就会开始变为红黑树,else if中的代码把原来的Node节点变成了TreeNode节点,并且进行了红黑树的转换。

六:HashMap扩容机制

当HashMap决定扩容时,会调用HashMap类中的resize(int newCapacity)方法,参数是新的table长度。在JDK1.7和JDK1.8的扩容机制有很大不同。

JDK 1.7

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
 
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

代码中可以看到,如果原有table长度已经达到了上限,就不再扩容了。

如果还未达到上限,则创建一个新的table,并调用transfer方法:

    /**
     * Transfers all entries from current table to newTable.
     */
    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;              //注释1
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); //注释2
                e.next = newTable[i];                  //注释3
                newTable[i] = e;                       //注释4
                e = next;                              //注释5
            }
        }
    }

transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。

JDK 1.8

    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) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)                      //注释1
                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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {                                 //注释2
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)                                        //注释3
                        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;
                            //4444444444444444444444444
                            if ((e.hash & oldCap) == 0) {                      //注释4
                                if (loTail == null)                            //注释5
                                    loHead = e;
                                else
                                    loTail.next = e;                           //注释6
                                loTail = e;                                    //注释7
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {                                  /注释8
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

注释1:在resize()方法中,定义了oldCap参数,记录了原table的长度,定义了newCap参数,记录新table长度,newCap是oldCap长度的2倍(注释1),同时扩展点也乘2。

注释4:

hash值的每个二进制位用abcde来表示,那么,hash和新旧table按位与的结果,最后4位显然是相同的,唯一可能出现的区别就在第5位,也就是hash值的b所在的那一位,如果b所在的那一位是0,那么新table按位与的结果和旧table的结果就相同,反之如果b所在的那一位是1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制10000就是旧table的长度16。

换言之,hash值的新散列下标是不是需要加上旧table长度,只需要看看hash值第5位是不是1就行了,位运算的方法就是hash值和10000(也就是旧table长度)来按位与,其结果只可能是10000或者00000。

所以,注释4处的e.hash & oldCap,就是用于计算位置b到底是0还是1用的,只要其结果是0,则新散列下标就等于原散列下标,否则新散列坐标要在原散列坐标的基础上加上原table长度。
 

七:get(i)的流程

  1. 如果表不为空,则去判断索引位首节点是否为null
  2. 判断索引位首节点是否为需要寻找的节点,是则返回该节点
  3. 如果不是则去索引位首节点中的链(或树)中继续去寻找
  4. 判断索引位首节点是否为树结构,如果是则去红黑树中查找
  5. 如果不是则去遍历链表的每个节点,找到后返回,没找到则为空

// 根据指定的key和val获取节点
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
   // 如果哈希表不为空并且key对应的桶上不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
    // 判断数组元素是否相等
    // 根据索引的位置检查第一个节点
    // 注意:总是检查第一个节点
        if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
      // 如果是这个节点,返回这个节点
            return first;
    // 如果不是第一个节点,判断是否有后续节点
        if ((e = first.next) != null) {
      // 判断是否是红黑树,是的话调用红⿊树中的getTreeNode方法获取节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
        // 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
          // 返回这个节点
                    return e;
            } while ((e = e.next) != null);
        }
    }
  // 未获取到节点返回null
    return null;
}
 

八:HashMap是否线程安全,为什么

JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap#transfer(),具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。


JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap#putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

get和put并发使用时,可能会导致get为null  

一开始new了一个新的hash表,然后将新创建的空hash表赋值给实例变量table,如果在new了一个新的表后,另一个线程执行了get,那么就为null
 

九:支持线程安全的实现类有哪些;ConcurrentHashMap是如何支持线程安全的

1.java.util.concurrent.atomic包下的原子类AtomicXXX

例如:AtomicInteger AtomicBoolean AtomicLong

AtomicIntegerArray、AtomicLongArray:该类是Java对Integer数组和Long数组支持的原子性操作;

2.可变字符串:StringBuffer也是线程安全

3.常见的集合类

List:Vector

Map:ConcurrentHashMap HashTable ConcurrentSkipListMap

Set:ConcurrentSkipListSet

十:ConcurrentHashMap是如何支持线程安全?

ConcurrentHashMap是怎么做到线程安全的?

  • get方法如何线程安全地获取key、value?
  • put方法如何线程安全地设置key、value?
  • size方法如果线程安全地获取容器容量?
  • 底层数据结构扩容时如果保证线程安全?
  • 初始化数据结构时如果保证线程安全?

ConcurrentHashMap并发效率是如何提高的?

  • 和加锁相比较,为什么它比HashTable效率高?

static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;
  final K key;
  volatile V val;
  volatile Node<K,V> next;
  ...
}
 

值得注意的是,value和next指针使用了volatile来保证其可见性

  • 使用volatile来保证当Node的值变化时,对于其他线程是可见的
  • 使用table数组的头节点作为synchronized的锁来保证写操作的安全
  • 当头节点为null时,使用CAS操作来保证数据能正确写入

使用CAS
当有一个新的值需要put到ConcurrentHashMap中时,首先会遍历ConcurrentHashMap的table数组,然后根据key的hashCode来定位到需要将这个value放到数组的哪个位置。

tabAt(tab, i = (n - 1) & hash))就是定位到这个数组的位置,如果当前这个位置的Node为null,则通过CAS方式的方法写入。所谓的CAS,即即compareAndSwap,执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。

这里就是调用casTabAt方法来实现的。

     static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }

使用synchronized
当头结点不为null时,则使用该头结点加锁,这样就能多线程去put hashCode相同的时候不会出现数据丢失的问题。synchronized是互斥锁,有且只有一个线程能够拿到这个锁,从而保证了put操作是线程安全的。

下面是ConcurrentHashMap的put操作的示意图,图片来自于ConcurrentHashMap源码分析(JDK8)get/put/remove方法分析。

在这里插入图片描述

十一:HahsMap和HashTable的区别

1. HashTable线程安全,key和value不能是null,否则会报空指针异常

2. HashMap线程不安全,key和value可以是null

3. HashTable的实现方法中添加了Synchronized关键词保证线程安全,因此相对而言HashMap性能更高一些

4. HashMap是Map接口的实现,HashTable实现了Map接口和Dictionary抽象类

5. HashMap初始容量16,扩容直接翻倍

    HashTable初始容量11,扩容翻倍+1

6. HashTable计算hash是使用key的hashcode对table数组长度直接取模

    HashMap计算hash对key的hashcode进行了两次hash,以获得更好的散列值

 
 

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值