Java集合之HashMap及扩容机制-XXOO

前言

HashMap基于Map接口实现,元素以键值对的方式存储,并且允许使用null 建和null值,因为key不允许重复,因此只能有一个键为null,另外HashMap不能保证放入元素的顺序,它是无序的,和放入的顺序并不能相同。HashMap是线程不安全的。


一、HashMap构造方法

通过源码的分析,我们可以看到ArrayList有三种构造方法

- 空的构造函数

- 自定义初始容量

- 自定义默认初始容量与哈希因子

- 通过传入Map元素列表进行生成

 /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * 自定义默认初始容量与哈希因子的构造函数
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        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;
        this.threshold = tableSizeFor(initialCapacity);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * 自定义初始容量的构造函数
     * 注意:在构造函数中一般会初始化加载因子,不会初始化默认容量与扩容阈值(在 resize() 方法中初始化)
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     *
     * 无参构造函数,初始化容量默认为 16,哈希因子为 0.75f
     *
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    /**
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

    /**
     * Implements Map.putAll and Map constructor
     *
     * 向当前 Map 添加指定 map 中的所有元素
     *
     * @param m the map
     * @param evict false when initially constructing this map, else
     * true (relayed to method afterNodeInsertion).
     */
    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            if (table == null) { // pre-size
                // TODO 为什么要怎么设计?
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                // t > threshold 时重置阈值
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            // 如果当前 HashMap 装不下指定 Map 中的元素时,进行扩容
            else if (s > threshold)
                resize();
            // 遍历指定的 Map,将所有元素添加到当前 Map 中
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

二、HashMap解析

1.put方法

 public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * Implements Map.put and related methods
     *
     * 向 map 中添加键值对
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value 为 true 时不改变已经存在的值
     * @param evict if false, the table is in creation mode.    为 false 时表示哈希表正在创建
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        /**
         * tab:哈希表数组
         * p:槽中的节点
         * n:哈希表数组大小
         * i:下标(槽位置)
         */
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 当哈希表数组为 null 或者长度为 0 时,初始化哈希表数组
        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;
            // 如果 key 已经存在,记录存在的节点
            // 先判断哈希冲突是否是头节点
            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) {
                    // 在链表尾部插入新节点,注意 jdk1.8 中在链表尾部插入新节点
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 如果当前链表中的元素大于树化的阈值,进行链表转树的操作
                        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 用于遍历
                    p = e;
                }
            }
            // 如果 key 重复则更新 key 值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                // 更新当前 key 值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 如果键值对个数大于阈值时(capacity * load factor),进行扩容操作
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

1. 哈希表是否初始化判断

2. 是否发生哈希碰撞,(无)头节点判断:如果头节点不存在直接插入当前键值对

3. key 是否存在条件判断:头节点冲突进行记录、树形态与链表形态分别走对应的流程插入键值对

4. 链表处理,如果插入的 key 在链表中不存在,则在链表尾部插入键值对(后判断是否需要转树),如果发生冲突,则记录当前冲突节点

5. key 存在,新的 value 覆盖旧的 value,并返回旧 value

6. 是否需要扩容

2.扩容机制,resize 方法

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * rehash(扩容)或者初始化哈希表
     * 注意;这个 resize() 不仅用于 rehash,也用于初始化哈希表,内部的具体实现细节可以经常翻出来看一下
     *
     * @return the table
     */
    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) {
            // 如果哈希表容量已达最大值,不进行扩容,并把阈值置为 0x7fffffff,防止再次调用扩容函数
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 新容量为原来数组大小的两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 把新的扩容阈值也扩大为两倍
                newThr = oldThr << 1; // double threshold
        }
        // TODO 为什么把新哈希表容量置为老的扩容阈值?
        // 如果执行下面的代码,表示哈希表还没有初始化且使用的是非空构造函数,在没有初始化的时候 threshold 为哈希表初始容量大小,这样就可以理解了,biu~
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        // 初始化哈希表,初始化容量为 16,阈值为 0.75 * 16,到这里表示使用的是默认无参构造函数
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 阈值为 0 额外处理
        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;
        /*------------------------------ 以上为新哈希表分配容量,以下为元素 rehash ------------------------------------*/
        if (oldTab != null) {
            // 遍历当前哈希表,将当前桶位置的键值对(链表或树或只有一个节点)赋值到新的哈希表中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // rehash 之前记录链表后直接置 null,让 GC 回收
                    oldTab[j] = null;
                    // 如果当前桶位置上只有一个元素,直接进行 rehash
                    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 { // preserve order
                        /**
                         * 条件:原哈希表大小为 16,扩容后的哈希表大小为 32
                         *
                         * 1.假设某个 key 的哈希值为 17,那么它在原来哈希表中的桶位置为 1,在新的哈希表中的桶位置为 17
                         * 通过 ((e.hash & oldCap) == 0) 判断条件不成立,rehash 时通过 newTab[j + oldCap] = hiHead 赋值,保证其位置正确性
                         * 2.假设某个 key 的哈希值为 63,那么它在原来哈希表中的桶位置为 15,在新的哈希表中的桶位置也为 31
                         * 通过 ((e.hash & oldCap) == 0) 判断条件不成立,通过 newTab[j + oldCap] = hiHead 赋值,保证其位置正确性
                         * 3.假设某个 key 的哈希值为 15,那么它在原来哈希表中的桶位置为 15,在新的哈希表中的桶位置也为 15
                         *  通过 ((e.hash & oldCap) == 0) 判断条件成立,通过 newTab[j] = hiHead 赋值,保证其位置正确性
                         */
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        // 遍历当前桶位置上的所有节点
                        do {
                            next = e.next;
                            /**
                             * 既可以使元素均匀的分布在新的哈希表中,又可以保证哈希值的正确性(比如 get(key) 操作)
                             * (e.hash & oldCap) 计算的不是在老哈希表中的桶位置,这样计算可以使数据均匀的分布在新的哈希表中
                             */
                            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;
    }

1. 判断哈希表是否初始化(已经初始化、`threshold` 已经初始化、`threshold` 没有初始化)

2. 当 `threshold` 没有初始化时初始化 `threshold`

3. 遍历旧的哈希表,进行 rehash,如果桶位置上没有键值对则直接略过,如果只有一个节点,直接 rehash 到新的哈希表中

4. 树与链表形态判断,分别走对应的 rehash 流程

5. 链表通过高低位方式 rehash

3.并发安全

Jdk1.7 中的 `HashMap` 在扩容时新哈希表数组和旧哈希表数组之间存在相互引用关系(我并没有仔细看过,有兴趣的可以阅读一下),因此在并发情况下会出现死循环的问题。在 jdk1.8 中是否还存在同样的问题?下面我们通过一个例子进行验证一下。

package com.yl.map;

import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;

/**
 * 描述: Jdk1.7 中的 `HashMap` 在扩容时新哈希表数组和旧哈希表数组之间存在相互引用关系
 * ,因此在并发情况下会出现死循环的问题。
 * 在 jdk1.8 中是否还存在同样的问题?
 * 下面我们通过一个例子进行验证一下。
 *
 * @author: yanglin
 * @Date: 2020-08-16-14:12
 * @Version: 1.0
 */
@Slf4j
public class HashMapTest {
    /**
     * NUMBER = 50,表示 50 个线程分别执行 put 方法 50 次
     * 线程安全的情况下因该 map size 应该为 2500
     */
    public static final int NUMBER = 50;

    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        for (int i = 0; i < NUMBER; i++) {
            new Thread(new HashMapTask(map)).start();
        }
        log.info("扩容时新哈希表数组和旧哈希表数组之间存在相互引用关系 map size = " + map.size());
    }

    static class HashMapTask implements Runnable {

        Map<String, String> map;

        public HashMapTask(Map<String, String> map) {
            this.map = map;
        }

        @Override
        public void run() {
            for (int i = 0; i < HashMapTest.NUMBER; i++) {
                map.put(i + "-" + Thread.currentThread().getName(), "put");
            }
        }
    }
}

上面开了 50 个线程往 `HashMap` 中添加元素,每个线程执行 50 次 `put` 方法,在线程安全的情况下,`map` 中应该有 2500 个键值对,但是执行的结果大都是小与 2500 的(并不会产生死循环)。

jdk1.8 中的 `HashMap` 新老数组之间不存在了引用关系,因此不会出现死循环的情况,但是却会存在键值对丢失的现象。为什么会出现键值对丢失的现象呢?

下面以链表为例来简单分析一下。

多线程情况下,可能会有多个线程进入 `resize` 方法,假设第一个线程进入了 `resize` 方法,在处理链表时会先记录一下,然后直接将对应的旧哈希表数组中的链表置 `null`,此时第二个线程进来了,因为上一个线程已经把链表置 `null` 了,线程 2 判定当前桶位置上没有键值对,如果线程 2 返回的哈希表数组覆盖了线程 1 的哈希表数组,就会丢失一部分因线程 1 置 `null` 的键值对。

  if (oldTab != null) {
            // 遍历当前哈希表,将当前桶位置的键值对(链表或树或只有一个节点)赋值到新的哈希表中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // rehash 之前记录链表后直接置 null,让 GC 回收
                    oldTab[j] = null;
..................................................................................

 [HashMap在JDK1.8中并发操作,代码测试以及源码分析](https://www.cnblogs.com/wenbochang/p/9425541.html)


以上

猜你喜欢

转载自blog.csdn.net/qq_35731570/article/details/109719154