美文网首页
面试Java——集合之HashMap和ConcurrentHas

面试Java——集合之HashMap和ConcurrentHas

作者: 别拿爱情当饭吃 | 来源:发表于2021-03-12 19:31 被阅读0次

    前言

    友善提醒,本文篇幅涉及知识点较多,消耗脑力比较大。如果你怕以后找不到此文,建议先收藏
    如果你不用复习,可直接跳到——面试开始
    以下代码都出自JDK8

    面试前,我们复习一下

    HashMap的put方法

    public V put(K key, V value) {
            //这里已经对key进行一次哈希了
            return putVal(hash(key), key, value, false, true);
        }
        //扰动函数,主要功能:降低哈希冲突(详细内容不展开)
        static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab;//桶数组
            Node<K,V> p; //节点
            int n, i;
                  //如果table为空,也就是没初始化,或者已经被初始化了,但是数组长度为0,即不是2的幂次方
            if ((tab = table) == null || (n = tab.length) == 0)
                    //给tab扩容,分配空间,初始值为n=16
                n = (tab = resize()).length;
                //如果桶i位置上没有节点
            if ((p = tab[i = (n - 1) & hash]) == null)
                      //那么就直接,创建节点,然后把节点放在桶的i位置上
                tab[i] = newNode(hash, key, value, null);
                //如果桶i位置上有节点,p是指向节点的引用
            else {
                Node<K,V> e;
                K k;
                      //如果hash相等,且key的内存地址或key的值相等。那就
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                else if (p instanceof TreeNode)
                            //如果p是红黑树上的节点,那就把节点加到红黑树上
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                else {
                            //遍历链表
                    for (int binCount = 0; ; ++binCount) {
                                  //如果后一个节点为null
                        if ((e = p.next) == null) {
                                        //就把新节点放在p的后一个节点
                            p.next = newNode(hash, key, value, null);
                                        //如果bincount>=8-1,就是bincount==8时,链表转变红黑色
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                                  //如果该节点的hash,key和准备加的节点相等。在后面会进行替换操作
                        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;
                            //如果onlyIfAbsent为true,就不改变value的值
                    if (!onlyIfAbsent || oldValue == null)
                                  //改变value值
                        e.value = value;
                    //留给LinkedHashMap的空方法
                    afterNodeAccess(e);
                            //返回oldValue
                    return oldValue;
                }
            }
            ++modCount;
            // map中的元素数量大于threshold时,就扩容
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
        }
    

    总结一下,put方法的大致流程如下:

    1. 对key进行哈希,获得哈希值
    2. 将哈希值和数组长度取余,其值就是key的索引值
    3. 如果数组的索引位置为null,则直接插入即可
    4. 如果数组的索引位置有值,需分三种情况:
    • 情况1:如果结点的Key和即将插入的key相等,那就直接替换value值即可
    • 情况2:如果节点属于红黑树的节点,就按照红黑树的更新或插入方式进行操作即可
    • 情况3:如果节点属于链表的节点,就遍历链表,能找到对应的节点,就替换值即可;如果不能找到对应的节点,就在链表的尾巴插入新的节点。

    这只是一个大概的描述,具体的实现细节,直接看源代码就好。

    HashMap的get方法

    public V get(Object key) {
            Node<K,V> e;
            return (e = getNode(hash(key), key)) == null ? null : e.value;
        }
        //扰动函数,和put方法中使用的是同一个hash方法
        static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
        final Node<K,V> getNode(int hash, Object key) {
            Node<K,V>[] tab;
            Node<K,V> first, e;
            int n;
            K k;
            //如果数组不为空,且数组的长度大于0,且头结点不为空;否则直接返回null
            if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
                //  如果头结点的哈希值和key的哈希值相等,且key的地址或key的内容相等;就直接返回头结点
                if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                    return first;
                    //头结点不相等,如果有下一个节点,就遍历;如果没下一个节点,就返回null
                if ((e = first.next) != null) {
                    //如果节点是红黑树,那么就按照红黑树的方式来获取节点,并返回
                    if (first instanceof TreeNode)
                        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    do {
                        //如果是链表,就遍历哈希值,key的地址或Key的内容,有符合条件的就立即返回,如果没,那么继续遍历下一个节点。如果遍历全部节点后,都没,那就返回null
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            return e;
                    } while ((e = e.next) != null);
                }
            }
            return null;
        }
        
    

    总结一下,HashMap的get方法大致流程如下:

    1. 根据key获取哈希值
    2. 根据哈希值和数组长度取余,获得索引值
    3. 根据索引值,获取对应的节点,然后比较节点的key和hash
    4. 如果相等,就返回对应的节点;不相等,就继续遍历;如果遍历到最后都没,就返回null

    这只是一个大概的描述,具体的实现细节,直接看源代码就好。

    ConcurrentHashMap的put方法

    public V put(K key, V value) {
            return putVal(key, value, false);
        }
    
        final V putVal(K key, V value, boolean onlyIfAbsent) {
            //判空:key、value均不能为null
            if (key == null || value == null) throw new NullPointerException();
            //计算出hash值
            int hash = spread(key.hashCode());
            int binCount = 0;
            //遍历table
            for (Node<K,V>[] tab = table;;) {
                Node<K,V> f; int n, i, fh;
                // table为null,进行初始化工作
                if (tab == null || (n = tab.length) == 0)
                    tab = initTable();
                //如果i位置没有节点,则直接插入,不需要加锁
                else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                    //CAS
                    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;
                    //对该节点进行加锁处理(hash值相同的链表的头节点),对性能有点儿影响
                    //特别注意一下这个f,这个f是头结点,锁的粒度是节点
                    synchronized (f) {
                        if (tabAt(tab, i) == f) {
                            //fh > 0 表示为链表,将该节点插入到链表尾部
                            if (fh >= 0) {
                                binCount = 1;
                                for (Node<K,V> e = f;; ++binCount) {
                                    K ek;
                                    //hash 和 key 都一样,替换value
                                    if (e.hash == hash &&
                                            ((ek = e.key) == key ||
                                                    (ek != null && key.equals(ek)))) {
                                        oldVal = e.val;
                                        //putIfAbsent()
                                        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;
                                    }
                                }
                            }
                            //树节点,按照树的插入操作进行插入
                            else if (f instanceof TreeBin) {
                                Node<K,V> p;
                                binCount = 2;
                                if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                        value)) != null) {
                                    oldVal = p.val;
                                    if (!onlyIfAbsent)
                                        p.val = value;
                                }
                            }
                        }
                    }
                    if (binCount != 0) {
                        // 如果链表长度已经达到临界值8 就需要把链表转换为树结构
                        if (binCount >= TREEIFY_THRESHOLD)
                            treeifyBin(tab, i);
                        if (oldVal != null)
                            return oldVal;
                        break;
                    }
                }
            }
    
            //size + 1
            addCount(1L, binCount);
            return null;
        }
    

    总结一下,ConcurrentHashMap的put方法的大致流程如下:

    1. 首先,就是判空,key和value都不允许为null。(见补充知识1)
    2. 然后计算哈希值。(见补充知识4)絮叨一下:
    3. 接着遍历table,进行节点的插入操作,具体过程如下:
    • 如果table为空,则表示ConcurrentHashMap还没有初始化,则进行初始化操作:initTable()
    • 根据hash值获取节点的位置i,若该位置为空,则直接插入,这个过程是不需要加锁的。计算f位置:i=(n - 1) & hash。(见补充知识2)
    • 如果检测到fh = f.hash == -1,则f是ForwardingNode节点,表示有其他线程正在进行扩容操作,则帮助线程一起进行扩容操作。(见补充知识3)
    • 如果f.hash >= 0 表示是链表结构,则遍历链表,如果存在当前key节点则替换value,否则插入到链表尾部。如果f是TreeBin类型节点,则按照红黑树的方法更新或者增加节点
    • 若链表长度 > TREEIFY_THRESHOLD(默认是8),则将链表转换为红黑树结构

    ConcurrentHashMap的get方法

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        // 先计算hash
        int h = spread(key.hashCode());
        //如果数组不为空,且长度大于0,且节点不为空
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (e = tabAt(tab, (n - 1) & h)) != null) {
            // 搜索到的节点key与传入的key相同且不为null,直接返回这个节点
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            // 树
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            // 链表,遍历
            while ((e = e.next) != null) {
                if (e.hash == h &&
                        ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }
    

    总结一下,ConcurrentHashMap的get方法的大致流程如下:

    1. 先计算哈希值h
    2. 通过(n - 1) & h)获取索引值
    3. 如果匹配是头节点,就直接返回对应的value
    4. 如果是树,就根据红黑树的读操作返回value
    5. 如果是链表,进行匹配,遍历,获取对应的value

    补充知识

    • 1、HashMap是允许key和value都为null的。
    • 2、这个过程用了CAS,可以理解为有锁也行,自旋锁。也可以理解为无锁,因为CAS属于硬件指令,不像Synchronized这样的操作系统级别的锁。因此,是否有锁,仁者见仁智者见智。你自己理解就好。
    • 3、ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用。只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或则已经被移动。
    • 4、这个和HashMap的hash函数比较,都有移位操作,只有略微不同,但目的都是为了降低哈希冲突。

    好了,相信你看完HashMap和ConcurrentHashMap的get和put方法后,已经超级无敌累了。因为我也写的好累,害。现在你可以先点个赞,以慰藉我的劳苦;最好先收藏一下,方便你以后复习再看。如果你累了,点个在看,方便你去喝口水,吃个饭回来再看。如果你觉得写得还不错,直接点分享,转给你的朋友。

    广告打完了!!!我继续了!!!面试正式开始!!!

    面试开始

    面试官:你说一下HashMap的put方法的过程吧?

    • 巴拉巴拉一堆,自己参考上面写的大致流程即可,我就不重复了。

    面试官:HashMap中的链表什么条件下才会变树?

    • 必须满足2个条件,一个是链表的长度>=8 且 HashMap的容量capacity必须>=64,才会让链表变树。如果链表的长度>=8,但HashMap的容量capacity<64,那么会进行扩容操作。进行扩容操作后,链表的长度也会相应变短。(相关源码如下:)
    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();
                
                ...省略一堆代码
    

    面试官:HashMap中链表变树为什么是8个节点?

    • 你就直接告诉面试官,这是一个统计学问题。官方经过测试,大于8个节点时,发生冲突的概率是小于千万分之一的。(为了做到心中有数,我从源码中截取一段注释下来)
        * 0:    0.60653066
         * 1:    0.30326533
         * 2:    0.07581633
         * 3:    0.01263606
         * 4:    0.00157952
         * 5:    0.00015795
         * 6:    0.00001316
         * 7:    0.00000094
         * 8:    0.00000006
         * more: less than 1 in ten million //小于千万分之一
    

    面试官:HashMap的JDK7 和JDK8 有什么区别?

    • JDK7 发生哈希冲突时,链表会越来越长,时间复杂度会变为O(N);相反,JDK 8 发生哈希冲突,链表在一定条件下,会变为红黑树,时间复杂度会变为O(LogN);
    • JDK7 在高并发环境下,因线程不安全,操作put方法时,会因为扩容resize方法和头插法,使得链表成环,从而在get方法时,引发cpu100%安全问题。JDK 8 虽然线程不安全,但把头插法改为尾插法,已经不会在resize时,使得链表成环。
    • 总结一下,比较重要的区别,就是引入红黑树,面试官可能会问你,为什么是引入红黑树,而不是其他树,比如搜索二叉树。其实主要考察你对数据结构的了解,面试前,最好把平衡树,红黑树,搜索二叉树这些树的读写的时间复杂度,以及优缺点都了解一下。

    面试官:SynchronizedMap 和 ConcurrentHashMap 有什么区别?

    • SynchronizedMap
      一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map 。
    • ConcurrentHashMap
      使用CAS+Synchronized来保证在线程安全。相对SynchronizedMap来说。ConcurrentHashMap锁的是节点,SynchronizedMap锁的是整张表。可以类比到MySQL的行锁和表锁。ConcurrentHashMap锁的粒度更小。
      另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当 iterator 被创建后集合再发生改变就不再是抛出 ConcurrentModificationException 异常,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。

    面试官:ConcurrentHashMap 为何读不用加锁?

    • 关于这个点,其实需要对比JDK7 和JDK 8的ConcurrentHashMap。

    • 在 JDK7 以及以前

      • 在HashEntry 中的 key、hash、next 均为 final 型,只能表头插入节点或删除结点。
      • 在HashEntry 中的 value 为 volatile 型。
      • 不允许用 null 作为键和值,当读线程读到某个 HashEntry 的 value 域的值为 null 时,便知道产生了冲突——发生了重排序现象(put 方法设置新 value 对象的字节码指令重排序),需要加锁后重新读入这个 value 值。
      • volatile 变量 count 协调读写线程之间的内存可见性,写操作后修改 count ,读操作先读 count,根据 happen-before 传递性原则写操作的修改读操作能够看到。
    • 在 JDK8

      • Node 的 val 和 next 均为 volatile 型。
      • tabAt()方法 和 casTabAt()方法 对应的 Unsafe 操作实现了 volatile 语义,这样子就可以禁止指令重排序,不用担心读取到Null值。
      static class Node<K,V> implements Map.Entry<K,V> {
            final int hash;
            final K key;
            volatile V val; 
            volatile Node<K,V> next;
      
            Node(int hash, K key, V val, Node<K,V> next) {
                this.hash = hash;
                this.key = key;
                this.val = val;
                this.next = next;
            }
      

      面试官:面试结束,恭喜进入下一轮面试

    总结

    其实,关于Java集合——HashMap和ConcurrentHashMap还有很多面试题,这里不一一展开

    • ConcurrentHashMap的迭代器是强一致性,还是弱一致性?HashMap呢?
    • HashMap什么时候开始扩容?如何扩容?
    • ConcurrentHashMap 7和8的区别?

    絮叨

    非常感谢你能看到这里,如果觉得文章写得不错 求关注 求点赞 求分享 (对我非常非常有用)。
    如果你觉得文章有待提高,我十分期待你对我的建议,求留言。
    如果你希望看到什么内容,我十分期待你的留言。
    各位的捧场和支持,是我创作的最大动力!

    参考资料

    • 芋道源码
    • 小明哥——J.U.C之Java并发容器

    相关文章

      网友评论

          本文标题:面试Java——集合之HashMap和ConcurrentHas

          本文链接:https://www.haomeiwen.com/subject/tgnzqltx.html