美文网首页
常用的HashMap源码分析感悟

常用的HashMap源码分析感悟

作者: developer_chang | 来源:发表于2018-05-12 22:52 被阅读0次

    0、前言

    HashMap是Java中最常用的集合之一,相信大家都使用过,可是你对他了解吗?HashMap存储的数据结构是怎样的?他是如何进行工作的?HashMap的get和put内部的工作原理?如何避免hash冲突以及扩容的机制是怎样的?网上也有很多文章对HashMap的源码总结和分析,我写这篇文章,主要还是为了记录自己阅读后的一点感悟,如不对的地方望大家指正!本文基于JDK1.8。

    1、HashMap概述

    HashMap是一个散列表,他存储的内容是键值对(key-value)映射,每个节点是一个Node类。HashMap实现了Map接口,因此拥有Map的一系列操作,如:get、put、remove...等方法,而HashMap对此也进行了重写。HashMap允许null的key和value,但只允许一条记录的键为null,可以允许多条记录的值为null。HashMap是非线程安全的(后面会阐述为啥),可以使用Collections.synchronizedMap来包装HashMap,使其具备线程安全,或者使用ConcurrentHashMap。

    2、HashMap的存储结构

    HashMap底层是基于数组+链表+红黑树(JDK1.8后增加了红黑树部分)来实现的。


    HashMap存储结构

    图中的HashTable也就是HashMap数组。那么HashMap是怎么存储数据的呢?HashMap通过计算key的hash值与(n-1)进行二进制&操作(n为HashMap数组的长度)即hash&(n-1),得到HashMap数组的下标,从而确定存储位置。当存储的元素过多时,就会存在相同hash值的元素,也就说不同元素key的hash&(n-1)得到相同的存储位置,这时就出现了hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有多种,HashMap是通过链表的方式来解决冲突的。当链表的长度大于8时,链表就会转成红黑树结构(感兴趣的朋友可以自行了解),当HashMap实际存储的元素个数超过了最大容量threshold时,HashMap就会进行扩容,对已存储的元素进行再hash重新存储(后面会详细阐述扩容及再hash过程)。

    正是由于HashMap是采用链表来解决冲突的,当线程A和线程B同时进行put时,计算出了相同的hash值对应的相同数组位置,两个线程会同时得到头结点,A将数据写入头结点后,B也写入,那B的写入操作就会覆盖A的写入从而导致A写入的数据丢失。当A去get(K k)获取时得到的不是put进去的值。所以HashMap是非线程安全的。
    HashMap是非线程安全的 - 在多线程put情况下,有可能在容量超过扩容阈值时进行rehash,因为HashMap为避免尾部遍历,在链表插入时使用的是头插法,多线程场景下,可能会产生死循环。

    3、HashMap核心方法分析

    (1) 解释几个关键变量
    /**
     * HashMap默认容量大小,必须为2的整数次幂
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
    
    /**
     * HashMap的最大容量为2的30次幂
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;        
    
    /**
     * HashMap的默认加载因子
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    /**
     * 链表转成红黑树的阈值。即在当链表的长度大于等于这个值的时候,将链表转成红黑树
     */
    static final int TREEIFY_THRESHOLD = 8;
    
    /**
     * 红黑树转为链表的阈值。即在哈希表扩容时,如果发现链表长度小于6,则会由红黑树重新退化为链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;
    
    /**
     * HashMap的最小树形化容量
     * 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    /**
     * HashMap数组,初始化分配的时候,数组的长度总是2的幂
     */
    transient Node<K,V>[] table;
    
    /**
     * HashMap实际存储的key-value键值对元素个数
     */
    transient int size;
    
    /**
     * 用来记录HashMap内部结构发生变化的次数,例如put新的key-value,如果某个key对应的value被覆盖不属于结构变化
     */
    transient int modCount;
    
    /**
     * HashMap扩容的阈值,表示HashMap所能存储的最大元素个数,当size >= threshold时,HashMap就会扩容,threshold = HashMap数组的容量大小 * 加载因子(loadFactor)
     */
    int threshold;
    
    /**
     * 加载因子,默认值是0.75
     */
    final float loadFactor;
    

    Node<K,V>[] table 是存放存放元素的地方,是Node类的数组,当第一次向HashMap put元素时,会初始化默认长度(即 DEFAULT_INITIAL_CAPACITY默认值为16)。Node类包含四个属性,hash值、key、value、next下一节点的引用。

    size记录的是HashMap实际存储的元素个数,也就是键值对的数量,put新的元素时会加1,remove元素时减1。

    modCount记录的是HashMap内部结构发生变化的次数,如put新的元素时,modCount数量会加1,put的时候如果key已经存则value会进行覆盖,不属于结构变化,modCount的值不改变。同样的,如果进行remove操作引起结构变化,则modCount数量也会加1。

    threshold HashMap的扩容阈值也可以说是HashMap数组元素的装填程度,计算方式为capacity(HashMap数组的长度) * loadFactor。由于首次向HashMap添加元素时,HashMap数组的长度初始化为16,故threshold与loadFactor有关,如果loadFactor设置越大,threshold就越大填满的元素越多,扩容时机来的慢,毫无疑问空间利用率也越高,但hash冲突的概率也变高,如前面所说用链表来解决冲突,那么链表也会变得越长,导致查询效率会变得更低;同样的,如果loadFactor设置越小,threshold就越小填满的元素越少,扩容时机来的快,空间利用率变低,但存储的数据越稀疏,冲突就会减少,链表就不会太长,查询效率更高。

    举个🌰理解一下:
    假设HashMap数组的容量(即长度)为100,加载因子loadFactor为0.8,得到threshold为80,也就是HashMap存储80个元素后才会进行扩容,在存储过程中可能有很多key对应相同的hash值,那么链表长度也就越长。如果加载因子loadFactor设置为0.1,threshold为10,也就是说HashMap存储10个元素后就要开始扩容,而在这10个元素中出现hash冲突的概率要比上面的情况小的多,一旦扩容后,会重新计算hash确定存储位置,这样保证了链表长度不会太长,甚至就一个表头元素,空间利用率很低,因为很多空间没利用就扩容了。

    那么问题来了,加载因子loadFactor既不能太大也不能太小,太大影响查询效率,太小浪费存储空间。加载因子loadFactor到底设置什么值比较合理呢?其实这是在"减少冲突"和"空间利用率"之间寻找一种平衡,系统默认加载因子为0.75,是JDK权衡时间和空间效率之后得到的一个相对优良的数值,一般情况下我们无需修改。

    (2) 构造方法

    HashMap有好几个构造方法,下面详细分析一个比较重要的构造方法:

    public HashMap(int initialCapacity, float loadFactor) {
        // 构造时,如果传递的initialCapacity小于0,则会抛出参数不合法异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
                                               
        // 如果大于HashMap最大容量,则直接赋值最大容量值
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
            
        // 如果加载因子小于等于0,或者不是float类型的数,则会抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        
        // tableSizeFor方法会计算扩容阈值
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        // 通过下面二进制的逻辑右移和或操作,最终得到的数一定是2的k次幂
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    

    很多朋友可能不理解,tableSizeFor方法里面的二进制操作是怎么实现的?别急,现在我举个🌰,看懂这个例子你就明白为啥了。

    首先得明白n |= n >>> 1 这个运算是啥意思,表示的是n先逻辑右移一位(对二进制的位运算和移位运算不了解的朋友,建议先复习一下,不然会有点吃力)得到的值,再跟n进行|(或)运算,最后将得到的值赋给n。
    假设给定的cap为19(随机给的一个数),那么n = cap - 1也就是18的二进制为:
        0000 0000 0001 0010
    第一步操作:
                0000 0000 0001 0010
        >>>1    0000 0000 0000 1001   逻辑右移一位
        |=      0000 0000 0001 1011   或运算并赋值
        
    第二步操作:
                0000 0000 0001 1011
        >>>2    0000 0000 0000 0110   逻辑右移两位
        |=      0000 0000 0001 1111   或运算并赋值
    
    第三步操作:
                0000 0000 0001 1111
        >>>4    0000 0000 0000 0001  逻辑右移四位
        |=      0000 0000 0001 1111   或运算并赋值
        
    ......
    
    可以看出,后面的右移8位和右移16位得到的值都是0000 0000 0001 1111。最后n+1后,值为32,即2的5次幂,其实得到这样一个结论,经过tableSizeFor方法得到值一定是2的k次幂。
    我们再举一个二进制大点的🌰,来验证下:
        0100 0110 0101 0110
    第一步操作:
                0100 0110 0101 0110
        >>>1    0010 0011 0010 1011   逻辑右移一位
        |=      0110 0111 0111 1111   或运算并赋值
        
    第二步操作:
                0110 0111 0111 1111
        >>>2    0001 1001 1101 1111   逻辑右移两位
        |=      0111 1111 1111 1111   或运算并赋值
    
    第三步操作:
                0111 1111 1111 1111
        >>>4    0000 0111 1111 1111  逻辑右移四位
        |=      0111 1111 1111 1111   或运算并赋值
        
    ......
    
    结果一目了然,n+1后最终还是2的k次幂。
    
    (3) put方法
    public V put(K key, V value) {
        // 内部调用putVal方法,调用这个方法之前会hash(key),对key进行hash值的计算,后面会详细阐述计算过程,暂且跳过
        return putVal(hash(key), key, value, false, true); ①
    }
    
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 如果HashMap数组是null,或是空,则进行resize(),也就是先扩容(暂且放下,后面会详细阐述扩容原理)
        if ((tab = table) == null || (n = tab.length) == 0)
            // 将扩容后的HashMap数组长度值赋给n
            n = (tab = resize()).length;
        // 前面讲HashMap存储结构的时,提到过通过(n - 1) & hash来确定存储位置;如果HashMap数组这个位置的元素是null,则会在链表的表头插入新节点
        if ((p = tab[i = (n - 1) & hash]) == null)  ②
            tab[i] = newNode(hash, key, value, null);
        else {
            // 表头元素不为null,则进行遍历
            Node<K,V> e; K k;
            // 当前节点的key已存在,则会将value值进行替换
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 如果表头节点是红黑树节点类型,则调用红黑树putTreeVal方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 如果是链表,则遍历链表,一个一个往下寻找是否已存在key的节点
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                         // 找到链表结尾,没找到,则创建一个新的节点
                        p.next = newNode(hash, key, value, null);
                        // 链表中新增一个节点后,如果链表长度大于等于TREEIFY_THRESHOLD时,则链表转成红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1)
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 找到已存在key的节点,则退出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                        
                    p = e;
                }
            }
            // 如果找到了存在key的节点,则将value进行覆盖
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                // key在链表中已存在,返回覆盖之前的value
                return oldValue;
            }
        }
        // HashMap内部结构发生变化
        ++modCount;
        // 如果HashMap存储的元素个数大于阈值,则进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        // 插入新的元素节点成功后,返回null
        return null;
    }
    

    ②处的(n - 1) & hash是用来确定存储位置,同时也是用于解决hash冲突的,为什么要这么设计呢?试想一下我们怎样把一个大于数组长度的位置,将其放入数组中?取模,对这个位置取模hash%n,在数据结构中处理hash冲突也确实用到的是取模,得到的值肯定小于数组长度的值。但是取模是除法运算,效率很低,HashMap中通过(n - 1) & hash来代替取模,同样可以达到均匀散列的效果,而且效率会高很多。当HashMap数组的容量为2的整数次幂,(n - 1) & hash这是一个非常巧妙的设计。

    例子🌰走起:
    假设hash = 3,n = 16,则(n - 1) & hash = 3;
    假设hash = 4,n = 16,则(n - 1) & hash = 4;
    假设hash = 15,n = 16,则(n - 1) & hash = 15;
    假设hash = 16,n = 16,则(n - 1) & hash = 0;
    假设hash = 17,n = 16,则(n - 1) & hash = 1;
    
    与取模运算达到同样的效果,保证计算得到的下标值在HashMap数组索引范围内。
    

    接下来,我们分析下HashMap数组容量为什么一定要满足2的整数次幂。数组容量n为2的整数次幂,那么(n - 1) & hash相当于hash%n,既可以保证散列均匀,也提高了效率;同时,n为2的整数次幂,(n - 1)必然是奇数,奇数的二进制最后一位是1,(n - 1) & hash计算出来的结果最后一位要么是0要么是1,也就是结果可能是偶数也有可能是奇数,这样保证了散列的均匀性。如果n为奇数的话,(n - 1)最后一位必然是0,(n - 1) & hash计算出来的结果为偶数,也就是说只能得到HashMap数组偶数下标的位置,这样的话HashMap中近一半的空间浪费掉了。

    还是例子说明:
    假设HashMap数组长度分别为16和17,现有两个元素进行插入,对应的hash值分别为5和6,(n-1)&hash的结果如下:
     (n - 1)          hash
    0000 1111   &   0000 0101    =  0000 0101    (16 - 1)&5
    0000 1111   &   0000 0110    =  0000 0110    (16 - 1)&6
    --------------------------------------------------------
    0001 0000   &   0000 0101    =  0000 0000    (17 - 1)&5
    0001 0000   &   0000 0110    =  0000 0000    (17 - 1)&6
    

    从上面例子可以看出当HashMap数组长度为奇数时,计算出来为数组的同一位置,产生冲突,存储的时候形成链表,查询的时候就要遍历链表,降低了查询效率。而数组长度为偶数时,那么(n - 1)的每一位都是1,同hash值&操作时,得到的和原hash的低位相同,加之①处hash(K key)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表,而且数据在数组上分布均匀,这样查询效率也高。

    好了,现在我们回过头来看看①处hash(K key),是如何计算key的hash值?

    // 计算hash
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    看上面的方法逻辑是进行位运算重新计算hash值,有朋友不禁要问为什么不直接用key的hashCode作为hash值呢?这样做有两个好处:其一,我们知道在②处(n - 1)&hash过程中,hash值只有低位参与了运算,也就是说计算存储位置跟hash的高位没有关系。如果不经过上述方法重新计算hash,而仅仅取key的hashCode作为hash值,让低位参与运算,高位不参与运算的话,非常容易产生冲突,随机性也会降低。异或操作的目的就是让高位也参与运算,让高位数据与低位数据进行异或,以此加大低位信息的随机性,提高hash的散列性。还是举个🌰看看:

          1100 0010 0001 1101    1110 0100 0011 1101 
    >>>4  0000 1100 0010 0001    0000 1110 0100 0011
    ^=    1100 1110 0001 1100    1110 1010 0111 1110
    
    可以看出,如果不进行异或操作,重新计算hash,而让key的hashCode作为hash参与运算的话,两者存储位置一致,从而产生冲突。让高位参与运算后,随机性就加大了。
    
    在Java中,hashCode方法产生的hash是int类型,32位。前16位为高位,后16位为低位,所以要右移16位。
    

    除此之外,重新计算hash的另一个好处是可以增加hash的复杂度。当我们重写hashCode方法时,可能会写出分布性不佳的hashCode方法,进而导致hash的冲突率比较高。通过移位和异或运算,可以让hash变得更复杂,进而影响hash的分布性。这也就是为什么HashMap不直接使用key的hashCode作为hash的原因。

    (4) get方法

    get方法的实现思路:先计算hash值,(n-1)&hash计算出key在HashMap数组中的下标位置,取出头结点,如果头结点就是key存储的元素,则取出并返回;如果不是头结点,则判断是否头结点是否是红黑树节点,否则遍历链表,直至找到。若不存key存储的节点直接返回null。

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && 
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
    
    (5) resize扩容方法

    HashMap的扩容就是重新计算容量,当HashMap数组无法存储更多元素时,这是就需要进行扩容。扩容并不是扩大原HashMap数组的长度,况且java中的数组也无法自动扩容,而是新建一个容量是原来2倍的数组,把原数组中的元素重新计算hash存入新的数组中。

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        // 获取原HashMap数组的容量(长度)
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
             // 如果原HashMap数组容量已达到最大值,无法再进行扩容,将阈值设为最大,返回并停止扩容 
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 否则,新的HashMap数组容量扩大2倍;如果原HashMap数组容量大于等于16,新数组的阈值也扩大2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; 
        }
        else if (oldThr > 0) 
             // 原HashMap阈值大于0&HashMap数组长度小于1,则将原HashMap阈值作为新数组的容量
            newCap = oldThr;
        else {             
             // 上述条件不满足,初始化为默认值 
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
             // 新HashMap的阈值为0,则对新阈值重新赋值
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        // 创建容量为newCap的新HashMap数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        // 将新数组的引用赋值
        table = newTab;
        if (oldTab != null) {
             // 如果是在已存在的HashMap数组基础上扩容,则进行元素迁移
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                // 取出原HashMap数组中的每个元素,并赋值给临时变量e
                if ((e = oldTab[j]) != null) {
                     // 将原HashMap数组元素置为null
                    oldTab[j] = null;
                    if (e.next == null)
                         // 原HashMap数组该位置的元素只有头结点,迁移至新数组同样的位置
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                         // 原HashMap数组该位置的元素不只一个元素,且是红黑树节点,则调用split方法
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { 
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        // 如果是链表,则进行遍历
                        do {
                            next = e.next;
                            // 将原HashMap数组中每个链表e.hash & oldCap == 0的元素放入低位链表
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 将原HashMap数组中每个链表e.hash & oldCap == 1的元素放入高位链表
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                             // 将低位链表元素存入“原处”,这个“原处”是根据新的hash&(newCap - 1)算出来的
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                             // 将高位链表元素存入j + oldCap位置
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    直接看上面的代码不好理解,画个图便于理解:

    [0] -> (0)  
    [1] -> (                A               
                      B            C
                   D     E      F     G
                  H                     I )
    [2] -> (2) -> (6) -> (10) -> (14)
    [3] -> (7)
    

    图中展示了HashMap三种存储场景,只有一个元素、红黑树、链表。着重分析一下扩容时,链表重新hash存储的过程,直接上图

    [0]                                     [0]
    [1]                                     [1]
    [2] -> (2) -> (6) -> (10) -> (14) ----> [2] -> (2) -> (10)
    [3]                                     [3]
                                            [4]
                                            [5]
                                            [6] -> (6) -> (14)
                                            [7]
    
    链表上的元素是怎样迁移的呢?主要看(e.hash & oldCap)也就是元素的hash值与原HashMap数组的容量进行与运算的结果。如果结果为0,则存放在原位置;如果不为0,则存放在j + oldCap(原HashMap数组的长度)的位置。
    
    我们可以这么理解,扩容后其实就是在新HashMap数组中重新存储原HashMap数组中的元素,hash&(2n - 1)来确定新数组的存储位置。
    扩容前
    
                2           6           10          14
    二进制    0000 0010   0000 0110   0000 1010   0000 1110
    &(4-1)   0000 0011   0000 0011   0000 0011   0000 0011
    =        0000 0010   0000 0010   0000 0010   0000 0010
    
    扩容后
                2           6           10          14
    二进制    0000 0010   0000 0110   0000 1010   0000 1110
    &(8-1)   0000 0111   0000 0111   0000 0111   0000 0111
    =        0000 0010   0000 0110   0000 0010   0000 0110
    
    从扩容前后计算存储位置,我们似乎发现了什么,扩容前6&(4-1)= 0000 0010,扩容后6&(8-1) = 0000 0110,对比可见扩容后运算的结果中新增了一位1。同理2和10新增的一位是0,6和14新增的一位是1。因此我们只需要看看新增一位是1还是0,如果是0,存储到新数组中索引不变,如果是1,存储到新数组中的索引为原索引+oldCap。
    
    (5) Fail-Fast机制

    我们知道HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了HashMap,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过modCount变量来体现的,对HashMap内容的修改引起结构变化都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

    abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
        int expectedModCount;  // for fast-fail
        int index;             // current slot
    
        HashIterator() {
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }
    
        public final boolean hasNext() {
            return next != null;
        }
        // 迭代器迭代过程中会判断modCount跟expectedModCount是否相等,如果不相等表明有其他线程对HashMap进行了修改导致结构发生了变化,抛出异常
        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }
    
        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            removeNode(hash(key), key, null, false, false);
            expectedModCount = modCount;
        }
    }
    

    4、小结

    本文只分析了HashMap的部分核心源码,阐述了自己的一些感悟和理解,当然比如红黑树部分没有展开讲解,后续会放到TreeMap的分析当中。最后给大家分享一个链接:HashMap添加和删除动画演示。可以很直观的看到HashMap添加和删除数据的过程,帮助我们更好的理解HashMap。如有错误,欢迎指正,感激不尽!

    相关文章

      网友评论

          本文标题:常用的HashMap源码分析感悟

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