HashMap源码解析、jdk7和8之后的区别、相关问题分析(多线程扩容带来的死循环)


一、概览


HashMap<String, Integer> map = new HashMap<>();

这个语句执行起来,在 jdk1.8 之前,会创建一个长度是 16 的 Entry[] 数组,叫 table,用来存储键值对。

在 jdk 1.8 后,不在这里创建数组了,而是在第一次 put 的时候才会创建数组叫 Node[] table ,用来存储键值对。


二、源码的成员变量分析


声明部分

HashMap 实现了 Map 接口,又继承了 AbstractMap,但是 AbstractMap 也是实现了 Map 接口的,而且很多集合类都是这种实现,这是一个官方失误造成的冗余,不过一直流传了下来。

  1. 继承 AbstractMap ,这个父类作为抽象类,实现了 Map 的很多方法,为了减少直接实现类的工作;
  2. 实现 Cloneable 接口和 Serializable 接口,这个问题在 原型模式 里面说过,就是深拷贝的问题,但是值得注意的是,HashMap 实现这两个接口,重写的方法仍然不是深拷贝,而是浅拷贝

属性部分

2.1 序列号serialVersionUID

序列化默认版本号,不重要。

2.2 默认初始化容量DEFAULT_INITIAL_CAPACITY

集合默认初始化容量,注释里写了必须是 2 的幂次方数,默认是 16。

问题 1 : 为什么非要是 2 的次方数呢?

答:第一方面为了均匀分布,第二方面为了扩容的时候重新计算下标值的方便。

这个涉及到了插入元素的时候对每一个 node 的应该在的桶位置的计算:

核心在这个方法里,会根据 (n - 1) & hash 这个公式计算出 ihash 是提前算出的 key 的哈希值,n 则是整个 map 的数组的长度。

那么这个节点应该放在哪个桶,这就是散列的过程,我们当然希望散列的过程是尽量均匀的,而不会出现都算出来进入了 table[] 的同一个位置。那么,可以选择的方法有取余啊、之类的,这里采用的方法是位运算来实现取余。

就是(n - 1) & hash 这个位运算,2 的幂 -1 都是11111结尾的:


2 进制,所以 2 的几次方都是 1 00000(很多个 0 的情况),然后 -1, 就会变成 000 11111(很多个1)

那么和 本来计算的具有唯一性的 hash 值相与,

  1. 用高位的 0 把hash 值的高位都置为了 0 ,所以限制在了 table 的下标范围内。
  2. 保证了 hash 值的尽量散开。


对于第 2 点,如果不是 2 的幂次方,那么 -1 就不会得到 1111 结尾,甚至如果是个基数,-1 后就会变成形如 0000 1110
这样的偶数,那么相与的结果岂不是永远都是偶数了?这样 table 数组就会有一半的位置永远利用不上的。所以 2 的幂次方以及 -1 的操作,才能保证得到和取模一样的效果。

因此得出结论,如果 n 是 2 的幂次方,计算出的位置会很均匀,相反则会干扰这个运算,导致计算出的位置不均匀。

第二个方面的原因就是扩容的时候,重新要计算下标值 hash2 的幂次方带给了好处,下面的扩容部分有详细说明。

注意到我们初始化 HashMap 的时候可以指定容量。

问题 2 那么如果传入的容量并不是 2 的次方,怎么办呢?

从构造方法可以看到,调用指定加载因子和 容量的方法,如果大于最大容量,就会改为最大容量,接着对于容量,调用 tableSizeFor 方法,此时传入的参数已经肯定是 <= 最大容量的数字了。

tableSizeFor 这个方法会产生一个大于传入数字的、最小的 2 的幂次方数。

2.3 最大容量MAXIMUM_CAPACITY

最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方

2.4 默认加载因子DEFAULT_LOAD_FACTOR

默认加载因子为 0.75 ,也就是说,如果键值对超过了当前的容量 * 0.75 ,就会触发扩容。

问题 为什么是 0.75 而不是别的数呢?

答:如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。

其实 0.75 是一个统计的结果,比较理想的值,根据旧版源码里面的注释,和概率的泊松分布有关系,当负载因子是 0.75 的情况下,哈希碰撞的概率遵循参数约为 0.5 的泊松分布,因此选择它是一个折衷的办法来满足时间和空间。

2.5 转树的阈值TREEIFY_THRESHOLD

默认为 8 ,也就是说一个桶内的链表节点数多于 8 的时候,结合数组当前长度会把链表转换为红黑树。

问题 为什么是超过 8 就转为红黑树?

答:首先,红黑树的节点在内存中是普通链表节点方式存储的 2 倍,成本是比较高的,那么对于太少的节点数目就没必要转化,继续扩容就行了。

结合负载因子 0.75泊松分布结果,每个链表有 8 个节点的概率已经到达可以忽略的程度,所以将这个值设置为 8 。为了避免出现恶意的频繁插入,除此之外还会判断数组长度是否达到了 64。

所以到这里我个人的理解是:
-> 最开始hashmap的思想就是数组加链表;
-> 因为数组里的各个链表长度要均匀,所以就有了哈希值的算法,以及适当的扩容,扩容的加载因子定成了 0.75 ;
-> 而扩容只能根据总共的节点数来计算,可能没来得及扩容的时候还是出现了在同一个链表里元素变得很多,所以要转红黑树,而这个数量就根据加载因子结合泊松分布的结果,决定了是8.

2.6 重新退化为链表的阈值UNTREEIFY_THRESHOLD

默认为 6, 也就死说如果操作过程发现链表的长度小于 6 ,又会把树退回链表。

2.7 转树的最小容量

不仅仅是说有链表的节点多于 8 就转换,还要看 table 数组的长度是不是大于 64 ,只有大于 64 了才转换。为了避免开始的时候,正好一些键值对都装进了一个链表里,那只有一个链表,还转了树,其实没必要。

还有属性的第二部分:

第一个是容器 table 存放键值对的数组,就是保存链表或者树的数组,可以看到 Node 类型也是实现了 Entry 接口的,在 1.8 之前这个节点是不叫 Node 的,就叫的 Entry,因为就是一个键值对,现在换成了 Node,是因为除了普通的键值对类型,还可能换成红黑树的树节点TreeNode 类型,所以不是 Entry了。

第二个是保存所有键值对的一个 set 集合,是一个存放缓存的;
第三个 size 是整个hashmap 里的键值对的数目;
第四个是 modCount 是记录集合被修改的次数,有助于在多个线程操作的时候报根据一致性保证安全;
第五个 threshold 是扩容的阈值,也就是说大于阈值的时候就开始扩容,也就是 threshold = 当前的 capacity * loadfactor
第六个 loadFactor 也是对应前面的加载因子。


三、源码的核心方法分析


3.1 构造方法

可以看到,这几个重载的构造方法做的事就是设置一些参数。

事实上,在 jdk1.8 之后,并不会直接初始化 hashmap,只是进行加载因子、容量参数的相关设定,真正开始将 table 数组空间开辟出来,是在 put 的时候才开始的。

第一个:

public HashMap()

是我们平时最常用的,只是设置了默认加载因子,容量没有设定,那显然就是 16

第二个:

public HashMap(int initialCapacity)

为了尽量少扩容,这个构造方法是推荐的,也就是指定 initialCapacity,在这个方法里面直接调用的是

第三个构造方法:

public HashMap(int initialCapacity, float loadFactor)

用指定的初始容量和加载因子,确保在最大范围内,也调整了 threshold 容量是 2 的幂次方数

这里就是一个问题,把 capcity 调整成 2 的幂次方数,计算 threshold 的时候不应该要乘以 loadfactor 吗,怎么能直接赋给 threshold 呢?

原因是这里没有用到 threshold ,还是在 put 的时候才进行 table 数组的初始化的,所以这里就没有操作。

最后一个构造方法是,将本来的一个 hashmap 放到一个新的 map 里。

3.2 put 和 putVal 方法

put 方法是直接调用了计算 hash 值的方法计算哈希值,然后交给 putVal 方法去做的。

hash 方法就是调用本地的 hashCode 方法再做一个位移操作计算出哈希值。

为什么采用这种右移 16 位再异或的方式计算 hash 值呢?

因为 hashCode 值一般是一个很大的值,如果直接用它的话,实际上在运算的时候碰撞的概率会很高,所以要充分利用这个二进制串的性质:int 类型的数值是 4 个字节的,右移 16 位,再异或可以同时保留高 16 位低 16 位的特征,进行了混合得到的新的数值中,高位与低位的信息都被保留了 。

另外,因为,异或运算能更好的保留各部分的特征,如果采用 & 运算计算出来的值会向 1 靠拢,采用 | 运算计算出来的值会向 0 靠拢, ^ 正好。

最后的目的还是一样,为了减少哈希冲突。

算出 hash 值后,调用的是 putVal 方法:

传入哈希值;要插入的 key 和 value;然后两个布尔变量,onlyIfAbsent 代表当前要插入的 value 是否存在了如果是 true,就不修改;evict 代表这个 hashmap 是否处于创建模式,如果是 false,就是创建模式。

下面是源码及具体注释:

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;//调用resize方法初始化tab,验证了我们说的,构造方法不会创建数组,而是插入的时候创建。

    //这个算法前面也已经讲过,就是计算索引,如果p的位置是 null,就在这里放入一个newNode;
    //如果p的位置不是 null,说明这个桶里已经有链表或者树了,就不能直接 new ,而是要遍历链表插入,并同时判断是不是需要转树
    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)
            //已经不是链表是红黑树了,调用putTreeVal
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //是链表,用 for 循环遍历
            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;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;//如果已经有值,覆盖,这里用到了onlyIfAbsent
            afterNodeAccess(e);
            return oldValue;
        }
    }

    //增加修改hashMap的次数
    ++modCount;
    //如果已经达到了阈值,就要扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

这里面涉及到的步骤主要如下:

  1. 调用 resize 方法初始化 table 数组,jdk1.8 后确实是到 put 的时候才会初始化数组;

  2. hash 值计算出在数组里应该在的索引;

  3. 如果索引位置是 null,就直接放入一个新节点,也就是 Node 对象;

  4. 如果不是 null,则要在这个桶里插入:

    1. 如果遇见了一个节点的 hash 值、key值和传入的这个新的一样,赋值给 e 这个节点;
    2. instanceof 判断是否为 TreeNode 类型,也就是说如果这个桶里已经不是链表而是红黑树了,就调用 putTreeVal 方法;
    3. 如果不是,那就要遍历这个链表,同理,遍历的过程如果也找到了一个阶段的 hash 值、key 值和传入的一样,赋值给 e 这个节点,否则遍历到最后,把一个 Node 对象插到链表末尾,插完后链表长度已经大于阈值,就要转树。
  5. 结束插入的动作后,前面的 e 一旦被赋值过了,说明是有一样的 key 出现,那么就说明不用插入新节点,而是替代旧的 val

这里面涉及到的 resize 、putTreeVal 和 treeifyBin 也是比较复杂的方法,下来进行介绍。

3.3 treeifyBin 方法

转换为树的方法

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;

    //如果数组的长度还没有达到 64 ,就不转树,只是扩容。
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
        
    //如果 e 不为空,那么遍历整个链表,把每个节点都换成具有prev和next两个指针的树节点
    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);
    }
}

treeify 里面调用了各种左旋啊、右旋啊,平衡啊,各种很复杂的红黑树操作方法,这里不再深入。

3.4 resize 扩容方法

问题:什么时候会扩容?

从前面成员变量的解释和插入元素,已经能总结出两种扩容的情况:

  1. 当键值对的元素个数(也就是键值对的个数,size)超过了 数组长度*负载因子(0.75)的时候,扩容;
  2. 当其中某一个链表的元素个数达到 8 个,并且数组长度没有达到 64 ,则扩容而不转红黑树。

扩容每次都会把数组的长度扩到 2 倍,并且之后还要把每个元素的下标重新计算,这样的开销是很大的。

值得注意的是,重新计算下标值的方法 和第一次的计算方法一样,这样很简便且巧妙:

  • 首先,仍然使用 (n - 1) & hash 这个式子计算索引,但是显然有重新计算的时候,变化的是 n-1,有些就不会在原位置了;
  • n 的变化入手,因为是 2 倍扩容,而数组长度本身也设置是 2 的幂次,在二进制位上来说,新算出来的 n-1 只是相比旧的 n-1 左移了一位;
比如 16-1 = 15,就是  1 0000 - 1 =  0 1111;
新的 32-1 = 31,就是 10 0000 - 1 = 01 1111;
  • 那么这个值再和 hash 相与运算,节点要么在原来位置,要么在原位置+旧的容量的位置,也就是在最高位加上了一个原来的容量;
  • 这样计算的时候就不用频繁的再计算,而是用一个加法就直接定位到要挪动的地方。

上面讲过的为什么长度设置 2 的幂次,这里也能作为一个优势的解释。

源码如下:

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)
            newThr = oldThr << 1; //这里把新的阈值和新的边界值都*2
    }
    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循环就开始把所有旧的节点都放到新数组里
        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 { 
                    //是链表,保持顺序,用do-while循环进行新的位置安排
                    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) {//用hash和oldCap的与结果,拆分链表
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }else {//用hash和oldCap的与结果,拆分链表
                            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;//放在新索引位置,就是加上 oldCap 
                    }
                }
            }
        }
    }
    return newTab;
}

3.5 remove 和 removeNode 删除方法

remove 直接调用的 removeNode 方法,类似于前面的 put 调用 putVal 。

注意 remove 根据 key 的时候肯定默认那个对应的 value 也是要删除的,所以 matchValue 置为 false,意思就是不用看 value

removeNode 的整体思路比较常规,就是我们能想到的:

  1. 如果本身 hashmap 不为空,且 hash 值对应的索引位置不为空,才去某一个桶里找并删除;

    1. 在遍历查找的过程里,分成对于链表节点和树节点的查找,就是根据 key 来比较的;
    2. 找到之后,根据 matchValue 判断要不要删除,删除的过程就是用之前找到的那个位置,然后指针操作就可。
  2. 否则,直接返回 null

3.6 get 和 getNode 方法

get 也只直接调用了 getNode 方法:

这里面的代码就和 remove 方法的前半部分几乎一样,也就是找到指定的 key 的位置,并返回对应的 value

3.7 HashMap的遍历

HashMap 本身维护了一个 keySet 的 Set,拿到所有的 key 。(显然维护 value 是没办法的,因为 key 都是唯一的),但这种方法不推荐,因为拿到 key 后再去找 value又是对 map 的遍历。

Set<String> keys = map.keySet();
for (String key: keys){
    System.out.println(key + map.get(key));//根据key得到value
}

也可以拿到所有的 value 需要用 Collection 来接收:

Collection<Integer> values = map.values();
for (Integer v: values){
    System.out.println(v);
}

也可以获取到所有的键值对Entry 的 Set 集合,然后拿到对应的迭代器进行遍历:

Set<Map.Entry<String,Integer>> entries = map.entrySet();
Iterator<Map.Entry<String,Integer>> iterator = entries.iterator();

while (iterator.hasNext()){
    Map.Entry<String,Integer> entry = iterator.next();
    System.out.println(entry.getKey()+entry.getValue());//得到key和value
}

jdk 1.8 之后,还增加了一个 forEach 方法,可以接口里的这个方法本身也是通过第二种方法实现的,在HashMap 里重写了这个方法,变成了对 table 数组的遍历,使用的时候,用 lambda 表达式传入泛型就可以。

map.forEach((key,value)->{
    System.out.println(key + value);
});

这种方法其实用到的也属于设计模式的代理模式


四、总结 jdk 1.7 和 1.8 之后关于 HashMap 的区别


4.1 数据结构的使用

  • 1.7 :单链表
  • 1.8 :单链表,如果链表长度>8且数组长度已经>64,转为红黑树

关于数组本身,1.7 是一个 Entry 类型的数组,1.8是一个 Node 类型。

4.2 什么时候扩容?

1.7 扩容时机

  • 扩容只有一种情况。利用了两个信息:
  1. 数组长度 * 加载因子。加载因子默认情况是 0.75 ,等键值对个数 size 达到了数组长度 * 加载因子
  2. 产生哈希冲突,当前插入的时候数组的这个位置已经不为空了。

扩容后,添加元素。

1.8 的扩容时机

先添加元素,再看是否需要扩容。

  • 扩容的第一种情况。

数组长度 * 加载因子。加载因子默认情况是 0.75 ,等键值对个数 size 达到了数组长度 * 加载因子(这点判断是一样的)

  • 扩容的第二种情况。

当其中某一个链表的元素个数达到 8 个,走到转树节点的方法里,但是又发现数组长度没有达到 64 ,则扩容而不转红黑树。

4.3 扩容的实现

1.7 扩容的实现

  • 数组长度 * 2 操作;
  • 然后用一个 transfer 方法进行数据迁移,transfer 里,对单向链表进行一个一个 hash 重新计算并且安排,采用头插法来安排单向链表,把节点都安排好。

但是如果多线程的情况下,有别的线程先完成了扩容操作,这个时候链表的重新挪动已经导致节点位置的变化,切换回这个线程的时候,继续改变链表指针就可能会产生环,然后这个线程死循环。

具体就是 7 的扩容方法在迁移的时候采用的是头插法,那么比如两个元素 ab一个链表,线程1和2都发现要扩容,就会去调用transfer方法:

  1. 1 先读取了 e 是 a,next 是 b,但是没来得及继续操作就挂起了;
  2. 2 开始读取,并采用头插法就是遍历ab,先把a移到新数组的位置,此时a.next = null;继续遍历到 b,b移到新位置,b.next = a;(形成了 b->a)
  3. 这时候切换到了线程 1 执行,本来已经再循环里面记录了 e 和 e.next 了,然而这时本来数组都变新的了,所以修改的时候计算位置啥的还是这个新数组里,不会变,因为计算的肯定是一样的, a.next = b,而前面就修改过了b.next = a,这样已经是环了,那么线程 1 继续while,一直next,死循环。

1.8 扩容的实现

因为是先插入,再扩容,所以插入的时候对于链表就是一个尾插法。

然后如果达到了扩容的条件,也就先进行数组长度 * 2 操作,直接在 resize 方法里完成数据迁移,这里因为数据结构已经有链表+红黑树两种情况:

  1. 如果是链表,把单链表进行数据迁移,充分利用与运算,将单链表针对不同情况拆断,放到新数组的不同位置;
  2. 如果是红黑树,树节点里维护了相当于双向链表的指针,重新处理,如果处理之后发现树的节点(双向链表)小于等于 6 ,还会再操作把树又转换为单链表。

但是如果在多线程的情况下,不会形成环链表,但是可能会丢失数据,因为会覆盖到一样的新位置。

4.4 为什么HashMap线程不安全

  1. put、get 等等核心方法在多线程情况下,都会出现修改的覆盖,数据不一致等等问题。比如多个线程 put 先后的问题,会导致结果覆盖,如果一个 put 一个get,也可能会因为调度问题获取到错误的结果。
  2. 正如上面具体分析过的死循环问题,在多线程扩容的时候,1.7的 hashmap 因为采用头插法进行扩容之后的重新节点分配,可能会出现死循环;
  3. 因为 Hashmap 的迭代器是 fast-fail iterator,所以多线程一边写操作一边遍历,会出现 ConcurrentModificationException 并发读写异常。
posted @ 2020-09-07 19:41  Life_Goes_On  阅读(780)  评论(0编辑  收藏  举报