美文网首页Java基础JAVA
源码修炼笔记之HashMap源码解析

源码修炼笔记之HashMap源码解析

作者: 花醉霜寒 | 来源:发表于2020-05-24 18:38 被阅读0次

    HashMap高频面试题

    HashMap备受面试官的青睐,笔者几乎每次面试都会遇到关于HashMap的问题,整理出来一些,供大家自查对HashMap的掌握程度。

    • HashMap 在 JDK1.8 中做了哪些优化?
    • 简述一下HashMap中get和put的基本过程;
    • HashMap在什么情况下会扩容?
    • HashMap为什么采用红黑树而不是其他的平衡二叉树?为什么不用跳表?
    • HashMap put操作当存在hash冲突时,新插入的值放在链表头部还是尾部?
    • HashMap扩容过程简述一下?
    • HashMap是如何解决hash冲突的,还了解其他解决hash冲突的方法吗?
    • HashMap是线程安全的吗?在高并发场景下使用HashMap存在什么问题?
    • 为什么Integer或者String比较适合作为HashMap的key?若用自定义的类作为key需要注意什么问题?
    • HashMap、HashTable和CurrentHashMap的区别?
    • 扩容时链表导致死循环?
    • containsKey和containsValue用哪个?性能如何?
    • HashMap中Iterator的便利顺序是什么

    HashMap源码解析

    \color{green}{重要成员变量}

        //  默认HashMap的初始容量
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
        //HashMap的最大容量
        static final int MAXIMUM_CAPACITY = 1 << 30;
        //默认负载因子0.75
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
        //链表转化为红黑树的阈值
        static final int TREEIFY_THRESHOLD = 8;
        //红黑树退化为链表的阈值
        static final int UNTREEIFY_THRESHOLD = 6;
        //链表转化为红黑树的最小HashMap容量条件
        static final int MIN_TREEIFY_CAPACITY = 64;
        //存储数据的数组
        transient Node<K,V>[] table;
        //元素个数
        transient int size;
        //操作次数
        transient int modCount;
        int threshold;
        //负载因子
        final float loadFactor;
    

    \color{green}{构造函数}

        public HashMap(int initialCapacity, float loadFactor) 
        public HashMap(int initialCapacity) 
        public HashMap() 
        public HashMap(Map<? extends K, ? extends V> m) 
    

    HashMap共有四个构造函数:
    1)指定初始大小和负载因子;
    2)指定初始大小采用默认负载因子;
    3)为指定初始大小和负载因子,采用默认负载因子和初始大小;
    4)通过指定的Map构造新的HashMap。

    关于负载因子和初始大小

    • 负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacityloadFactor=HashMap的容量,当HashMap中的元素个数到达initailCapacityloadFactor后,继续插入元素,HashMap会进行自动扩容为原来大小的两倍。负载因子的设置对于HashMap的性能有很大的影响,负载因子的设置是时间和空间的博弈,如果设置很小的负载因子,空间利用率低,存储相同个数的元素需要更多的空间,但是出现hash冲突的概率低,由于hash冲突的概率低,所以查询效率会高一些,反之如果设置比较大的负载因子,空间利用率高,但是出现hash冲突的概率较大,影响HashMap的操作性能。
    • 在使用HashMap时尽量指定合理的初始大小,HashMap的初始大小默认为16,在我们日常使用过程中,有时HashMap中的元素会远小于16,甚至是一个或者两个元素,例如某些sql的传入参数,如果不指定默认初始大小,将会造成内容浪费,如果一个HashMap中会存放大量的元素,未指定初始大小的情况下会产生大量的扩容操作,每次扩容都涉及到大量元素的拷贝,严重影响性能。初始大小尽量设置为2的幂,这与HashMap中通过元素的hash值计算元素在table中的位置相关,后面介绍HashMap的put方法会详解,如果传入的初始大小不是2的幂,HashMap根据传入的参数,计算大于该参数的最小2的幂来作为HashMap的初始大小,所以为了减少程序的计算时间,传入参数最好设置为2的幂。
        public HashMap(int initialCapacity, float loadFactor) {
            //判断initialCapacity是否合法
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
            this.loadFactor = loadFactor;
            //根据设置的初始大小返回2的幂
            this.threshold = tableSizeFor(initialCapacity);
        }
    

    \color{green}{get方法流程}

    get方法的流程大致可以包括如下步骤:

    • 判断table不为空,table的长度大于0,对应位置上的第一个元素不为空,如果不满足,则直接返回null;
    • 判断对应位置上的第一个元素的hash值和key的值是否相等,若相等直接返回first;
    • 判断first是否为红黑树节点;
    • 是红黑树节点通过getTreeNode方法获取key对应的值;
    • 若为链表节点,则通过遍历链表节点获取与key对应的节点;
    • 若为链表节点,则通过遍历链表节点获取与key对应的节点;
    • 上述步骤依然未获取key对应的节点,则返回null。

    get方法源码如下所示,上述步骤均在源码基础上进行了注释:

        final Node<K,V> getNode(int hash, Object key) {
            Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
            //判断table不为空,table的长度大于0,对应位置上的第一个元素不为空,如果不满足,则直接返回null
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                //判断对应位置上的第一个元素的hash值和key的值是否相等,若相等直接返回first
                if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                    return first;
                //判断first节点的下一个节点是否为null,若为null直接返回null
                if ((e = first.next) != null) {
                    //判断first是否为红黑树节点
                    if (first instanceof TreeNode)
                        //是红黑树节点通过getTreeNode方法获取key对应的值
                        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    //若为链表节点,则通过遍历链表节点获取与key对应的节点
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            return e;
                    } while ((e = e.next) != null);
                }
            }
            //上述步骤依然未获取key对应的节点,则返回null
            return null;
        }
    

    \color{green}{put方法流程}
    put方法的过程相对于get方法要复杂一些,通过分析源码,put方法调用了putVal方法,添加元素的主要逻辑在putVal方法中,添加元素的过程如下图所示:

    hashmap的put过程
    put方法的源码如下所示,添加元素的步骤均在源码的基础上进行了注释:
    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为空,通过reSize方法进行初始化
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            //计算插入元素在table中的位置,该位置元素为null,直接添加元素
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            //若该位置元素不为null,则有hash冲突
            else {
                //e为对应位置的oldValue
                Node<K,V> e; K k;
                //判断该位置第一个元素的hash值与插入元素key的hash值是否相等,相等则e=p
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                //上述条件不满足,判断p是否为红黑树节点,若为红黑树节点,通过putTreeVal获取e
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                //为链表节点
                else {
                    //遍历链表
                    for (int binCount = 0; ; ++binCount) {
                        //每次判断为e赋值,如果e为null,则在链表中添加元素
                        if ((e = p.next) == null) {
                            p.next = newNode(hash, key, value, null);
                            //链表长度大于8,将链表转化为红黑树
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                        //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表示只有该处如果该位置存在值,不替换,为false表示替换旧值
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    //空方法,子类可以实现自己的逻辑,主要给LinkedHashMap用的
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            //操作数+1,用于fail fast
            ++modCount;
            //判断是否需要进行扩容操作
            if (++size > threshold)
                resize();
            //空方法,子类可以实现自己的逻辑
            afterNodeInsertion(evict);
            return null;
        }
    

    源码中的极致设计思路:

    • HashMap的初始化采用的是延迟加载的方式,最大限度为应用程序节省内存;
    • i = (n - 1) & hash,i表示元素在table中的位置,n表示table的长度,是2的幂,通过这种方式计算元素在table中的位置,可以最大限度的使元素尽量均匀的分布在table中,如table的长度为16,n-1为15,15的二进制表示为“1111”,若n-1为14,n-1的二进制为 “1110”,则有个位置将永远不可能有元素插入。

    \color{green}{扩容resize方法详解}
    resize方法在HashMap中是非常重要的方法,table的初始化和扩容都是通过resize方法完成的,resize的过程大致如下:

    • 判断table是否初始化,若没有初始化,则初始化table;
    • 设置新的容量大小和threshold;
    • 判断是否需要元素拷贝到新table中;
    • 进行元素拷贝,元素拷贝分为普通节点、红黑树节点和链表节点的拷贝;
    • 最后返回新的table。

    resize方法源码如下所示,详细过程均在源码的基础进行了注释:

    final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
            //判断原来table的长度
            if (oldCap > 0) {
                //到达最大长度,无法进行扩容
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                //容量和threshold变为原来的两倍,因为都是2的幂,采用移位运算提高效率
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1; // double threshold
            }
            //判断oldThr是否大于0,大于0表示初始化时指定了大小
            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);
            }
            //设置newThr的值
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);
            }
            //将newThr设置为新的threshold
            threshold = newThr;
            @SuppressWarnings({"rawtypes","unchecked"})
                Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;
            //判断table原来的table是否为null,不为null则需要进行元素拷贝到新的table中
            if (oldTab != null) {
                //元素拷贝到新table中
                for (int j = 0; j < oldCap; ++j) {
                    Node<K,V> e;
                    if ((e = oldTab[j]) != null) {
                        //旧元素快速GC
                        oldTab[j] = null;
                        //e节点上不存在hash冲突,直接在newTable中赋值
                        if (e.next == null)
                            newTab[e.hash & (newCap - 1)] = e;
                        //存在hash冲突且节点为红黑树,通过split方法进行扩容
                        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;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    

    接下来对元素的拷贝进行分析,普通节点只需简单的赋值操作即可,红黑树是非常复杂的数据结构,关于红黑树本文不作详细介绍,此处主要分析一下链表在扩容时是如何处理的,首先扩容过程中一个链表会被拆分为两个链表,放入新的table中,即如果在oldTable中的位置是j,那这两个链表在newTable中的位置是j和j+oldCap,源码中采用了十分巧妙的方式,过设置四个Node对象进行操作:

    Node<K,V> loHead = null, loTail = null;
    Node<K,V> hiHead = null, hiTail = null;
    

    其中loHead和loTail分别指向拷贝到newTable中j位置上链表的头节点和尾节点,hiHead和hiTail则分别指向拷贝到j+oldCap位置上链表的头节点和尾节点,处理过程如图所示:


    链表扩容过程

    高频面试题解析

    • HashMap 在 JDK1.8 中做了哪些优化?
      Entry变为Node;
      链表变为链表+红黑树的形式等

    • 简述一下HashMap中get和put的基本过程;
      源码解析中已详细分析

    • HashMap在什么情况下会扩容?
      源码解析中已详细分析

    • HashMap为什么采用红黑树而不是其他的平衡二叉树?为什么不用跳表?

    • HashMap put操作当存在hash冲突时,新插入的值放在链表头部还是尾部?
      源码解析中已详细分析,应该是尾部

    • HashMap扩容过程简述一下?
      源码解析中已详细分析,参考resize过程分析

    • HashMap是如何解决hash冲突的,还了解其他解决hash冲突的方法吗?
      解决hash冲突的方法主要包括开放寻址法和链表法

    • HashMap是线程安全的吗?在高并发场景下使用HashMap存在什么问题?
      纵观HashMap的源码,并未出现sychronized、Lock、volatile、CAS等相关技术,HashMap在并发共享的情况下并非是线程安全的,在高并发情况下,HashMap实例作为共享变量读写过程中会出现线程安全问题。

    • 为什么Integer或者String比较适合作为HashMap的key?若用自定义的类作为key需要注意什么问题?
      这些都是final修饰的不可变类型,自定义类作为key需要重写equals和hashCode方法。

    • HashMap、HashTable和CurrentHashMap的区别?
      HashMap非线程安全,HashTable线程安全,使用synchronized性能差,CurrentHashMap采用unsafe的CAS操作,即乐观锁,线程安全,性能比HashTable好。

    • 扩容时链表导致死循环?

    • containsKey和containsValue用哪个?性能如何?
      containsKey在不存在hash冲突的情况下,时间复杂度为o(1),而containsValue时间复杂度为o(n)。

    • HashMap中Iterator的便利顺序是什么
      这个是我一个朋友遇到的面试题,以前还真没有仔细考虑过,其实源码中非常简单,iterator分为KeyIterator和ValueIterator都是按照table的顺序进行遍历的。

    \color{red}{注:这些高频面试题无疑都是考察对HashMap源码的熟悉程度,此处我并未作详细解答,只是抛砖引玉。}

    源码修炼系列
    源码修炼笔记之线程池ThreadPoolExecutor详解
    源码修炼笔记之ThreadLocal详解

    相关文章

      网友评论

        本文标题:源码修炼笔记之HashMap源码解析

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