4.HashMap源码解析

156 阅读12分钟

一、HashMap类图总览

Image [3].jpg HashMap是一种散列表,用以存储key-value键值对的数据结构,提供平均时间复杂度为O(1)的基于key级别的get/put操作

实现java.util.Map、Serializable、Cloneable接口并集成AbstractMap抽象类

二、构造方法

1.HashMap()

/**
 * Constructs an empty {@code HashMap} with the default initial capacity
 * (16) and the default load factor (0.75).
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

初始化loadFactor为DEFAULT_LOAD_FACTOR=0.75

构造方法中并没有table数组的初始化。HashMap中使用的是延迟初始化,在向HashMap中添加key-value时,在resize()方法中才开始真正的初始化

2.HashMap(int initialCapacity)

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

内部调用HashMap(int initialCapacity)方法,初始化容量为initialCapacity的HashMap对象。

3.HashMap(int initialCapacity, float loadFactor)

/**
 * Constructs an empty {@code HashMap} 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) {
    // 校验 initialCapacity 参数
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 避免 initialCapacity 超过 MAXIMUM_CAPACITY
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 校验 loadFactor 参数
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    // 设置 loadFactor 属性
    this.loadFactor = loadFactor;
    // 计算 threshold 阀值
    this.threshold = tableSizeFor(initialCapacity);
}

该构造方法,初始化容量为initialCapacity、扩容因子为loadFactor的HashMap对象

4.HashMap(Map<? extends k, ? extends V> m)

/**
 * Constructs a new {@code HashMap} with the same mappings as the
 * specified {@code Map}.  The {@code HashMap} is created with
 * default load factor (0.75) and an initial capacity sufficient to
 * hold the mappings in the specified {@code Map}.
 *
 * @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;
    // 批量添加到 table 中
    putMapEntries(m, false);
}

三、属性

20210108121408478.png

  1. DEFAULT_INITIAL_CAPACITY:默认的初始化容量16。一般设置为2的n次幂,保证数据的离散性
/**
 * 默认的初始化容量
 *
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  1. MAXIMUM_CAPACITY:最大容量,最大为2的30次方。HashMap底层数组的最大长度
/**
 * 最大的容量为 2^30 。
 *
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;
  1. DEFAULT_LOAD_FACTOR:默认加载因子(数组中被占用数/数组长度),默认为0.75
/**
 * 默认加载因子为 0.75
 *
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  1. TREEIFY_THRESHOLD:每个位置变化为红黑树,需要的链表最小长度
/**
 * 每个位置链表树化成红黑树,需要的链表最小长度
 *
 * The bin count threshold for using a tree rather than list for a
 * bin.  Bins are converted to trees when adding an element to a
 * bin with at least this many nodes. The value must be greater
 * than 2 and should be at least 8 to mesh with assumptions in
 * tree removal about conversion back to plain bins upon
 * shrinkage.
 */
static final int TREEIFY_THRESHOLD = 8;

原理介绍: 在平时使用HashMap的时候我们都知道其平均查询时间复杂度基本为O(1),那它是怎么做到的呢?

HashMap其实底层也是个数组,只不过这个数组经过改造加强成为了超级数组。

HashMap存储的是k-v键值对,但是k可以是任意类型。所以我们可以使用hash,通过key的hash转成整数存储在数组中,但是得到的hash值可能非常大超过数组容量,于是我们可以通过hash(key)%size作为数组下标放到对应位置。

但是这样又会有其他问题?

  • hash(key)计算出来的值一定能保证唯一性吗?如果不唯一怎么办?
  • hash(key)%size操作后,即使不同哈希值也可能变成相同结果

这种问题就是我们通常所说的哈希冲突,那么如何解决这种问题呢?

  • 开放寻址法:后续有时间研究补充,我也不是很懂
  • 链表法:即数组中每个元素对应一个链表,将hash冲突的值放到对应下标的链表中解决hash冲突的问题。但是细想一下,如果n个key经过hash(key)%size计算得到的都是相同的值,那么链表长度就为n。这种情况下时间复杂度又退化到了O(n),那么如何解决极端情况下出现的问题呢?我们可以将数组中的链表换成其它数据结构,比如红黑树或者链表。这样查询平均时间复杂度可以变为O(logN)

需要注意的是:

  • 在JDK7中,HashMap数据结构是使用数组+链表形式实现
  • 在JDK8开始的版本中,HashMap采用数组+链表+红黑树的形式实现

四、哈希函数

HashMap是通过对key取hash值保证平均查询复杂度为O(1)的操作,那么一个高效的hash()函数至关重要

对于哈希函数来说,有两个方面特别重要

1.性能足够高,因为基本HashMap所有操作都需要用到哈希函数

2.哈希函数计算出的哈希值足够离散,这样就能够保证哈希冲突的概率足够小。

static final int hash(Object key) {
    int h;
    // h = key.hashCode() 计算哈希值
    // ^ (h >>> 16) 高 16 位与自身进行异或计算,保证计算出来的 hash 更加离散
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 高效性:从整个计算过程来看,^(h >>> 16)只有这块的逻辑,两个位操作,性能肯定是有保证的。如果想要保证哈希函数的高效性,传入key自身的 hashCode()函数获取hashCode即可
  • 离散型:这段代码保证hash更加离散,如果有兴趣可以深入研究《JDK 源码中 HashMap 的 hash 方法原理是什么?》

五、添加单个元素

public V put(K key, V value) {
    // hash(key) 计算哈希值
    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; // tables 数组
    Node<K,V> p; // 对应位置的 Node 节点
    int n; // 数组大小
    int i; // 对应的 table 的位置
    // 如果 table 未初始化,或者容量为 0 ,则进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize() /*扩容*/ ).length;
    // 如果对应位置的 Node 节点为空,则直接创建 Node 节点即可。
    if ((p = tab[i = (n - 1) & hash] /*获得对应位置的 Node 节点*/) == null)
        tab[i] = newNode(hash, key, value, null);
    // 如果对应位置的 Node 节点非空,则可能存在哈希冲突
    else {
        Node<K,V> e; // key 在 HashMap 对应的老节点
        K k;
        // 如果找到的 p 节点,就是要找的,则则直接使用即可
        if (p.hash == hash && // 判断 hash 值相等
            ((k = p.key) == key || (key != null && key.equals(k)))) // 判断 key 真正相等
            e = p;
        // 如果找到的 p 节点,是红黑树 Node 节点,则直接添加到树中
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 如果找到的 p 是 Node 节点,则说明是链表,需要遍历查找
        else {
            // 顺序遍历链表
            for (int binCount = 0; ; ++binCount) {
                // `(e = p.next)`:e 指向下一个节点,因为上面我们已经判断了最开始的 p 节点。
                // 如果已经遍历到链表的尾巴,则说明 key 在 HashMap 中不存在,则需要创建
                if ((e = p.next) == null) {
                    // 创建新的 Node 节点
                    p.next = newNode(hash, key, value, null);
                    // 链表的长度如果数量达到 TREEIFY_THRESHOLD(8)时,则进行树化。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break; // 结束
                }
                // 如果遍历的 e 节点,就是要找的,则则直接使用即可
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break; // 结束
                // p 指向下一个节点
                p = e;
            }
        }
        // 如果找到了对应的节点
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 修改节点的 value ,如果允许修改
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 节点被访问的回调
            afterNodeAccess(e);
            // 返回老的值
            return oldValue;
        }
    }
    // 增加修改次数
    ++modCount;
    // 如果超过阀值,则进行扩容
    if (++size > threshold)
        resize();
    // 添加节点后的回调
    afterNodeInsertion(evict);
    // 返回 null
    return null;
}
  1. <1>处判断如果table未初始化或者容量为0,调用resize()方法进行扩容
  2. <2>处固若对应位置的Node节点为空,则直接创建Node节点即可
  3. <3>处如果对应位置的Node节点非空,则可能存在哈希冲突,需要分成Node节点是链表(<3.3>)或是红黑树(<3.2>)的情况
  4. <3.1>处如果找到的p节点就是要找的,则直接使用即可这是个优化操作,无论是Node节点是链表还是红黑树
  5. <3.2>处如果找到的p节点,是红黑树Node节点,则调用putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v)方法,直接添加到树种
  6. <3.3>处如果找到的p节点是Node节点,则说明是链表,需要遍历查找。其中,binCount >= TREEIFY_THRESHOLD - 1代码段,在链表长度超过TREEIFY_THRESHOLD = 8的时候会调用treeifyBin(Node<K,V>[] tab, int hash)方法,将链表进行树化。
  7. <4>处根据是否在HashMap中已经存在key对应的节点有不同的处理
  8. <4.1>处如果存在的情况会有以下处理:
  • 如果满足需要修改节点,则进行修改
  • 如果节点被访问时,调用afterNodeAccess(Node<K,V> p)方法,节点被访问的回调,目前这是个空方法,用于HashMap的子类LinkedHashMap需要做的拓展逻辑
  • 返回老的值
  1. <4.2>处如果不存在的情况,会有如下处理
  • 增加修改次数
  • 增加key-value键值对size数,并且size如果超过阈值调用resize()方法进行扩容
  • 调用afterNodeInsertion(boolean evict)方法,添加节点后的回调,目前这是个空方法,用于HashMap的自雷LinkedHashMap需要做的拓展逻辑
  • 返回null,因为老值不存在

六、扩容

在第五步中我们提到了resize()扩容方法。实际上在构造方法中我们能看到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;
    // oldCap 大于 0 ,说明 table 非空
    if (oldCap > 0) {
        // 超过最大容量,则直接设置 threshold 阀值为 Integer.MAX_VALUE ,不再允许扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // newCap = oldCap << 1 ,目的是两倍扩容
        // 如果 oldCap >= DEFAULT_INITIAL_CAPACITY 满足,说明当前容量大于默认值(16),则 2 倍阀值。
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 【非默认构造方法】oldThr 大于 0 ,则使用 oldThr 作为新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 【默认构造方法】oldThr 等于 0 ,则使用 DEFAULT_INITIAL_CAPACITY 作为新的容量,使用 DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY 作为新的容量
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果上述的逻辑,未计算新的阀值,则使用 newCap * loadFactor 作为新的阀值
    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;
    // 创建新的 Node 数组,赋值给 table 属性
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 如果老的 table 数组非空,则需要进行一波搬运
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            // 获得老的 table 数组第 j 位置的 Node 节点 e
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 置空老的 table 数组第 j 位置
                oldTab[j] = null;
                // 如果 e 节点只有一个元素,直接赋值给新的 table 即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果 e 节点是红黑树节点,则通过红黑树分裂处理
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 如果 e 节点是链表
                else { // preserve order
                    // HashMap 是成倍扩容,这样原来位置的链表的节点们,会被分散到新的 table 的两个位置中去
                    // 通过 e.hash & oldCap 计算,根据结果分到高位、和低位的位置中。
                    // 1. 如果结果为 0 时,则放置到低位
                    // 2. 如果结果非 1 时,则放置到高位
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 这里 do while 的原因是,e 已经非空,所以减少一次判断。细节~
                    do {
                        // next 指向下一个节点
                        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);
                    // 设置低位到新的 newTab 的 j 位置上
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 设置高位到新的 newTab 的 j + oldCap 位置上
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

总结起来两步

  • 计算新的容量和扩容阈值并创建新的table数组
  • 将老的table内容复制到新的table数组中

1)第一步

  • <1.1> 处,oldCap 大于 0 ,说明 table 非空,说明是两倍扩容的骚操作。

    • <1.1.1> 处,超过最大容量,则直接设置 threshold阀值 为 Integer.MAX_VALUE,不再允许扩容。
    • 【重要】<1.1.2> 处,两倍扩容,这个暗搓搓的 newCap = oldCap << 1)代码段,差点就看漏了。因为容量是两倍扩容,那么在 newCap * loadFactor 逻辑,相比直接 oldThr << 1 慢,所以直接使用 oldThr << 1 位运算的方案。
  • <1.2.1> 和 <1.2.2> 处,oldCap 等于 0 ,说明 table 为空,说明是初始化的骚操作。

    • <1.2.1> 处,oldThr 大于 0 ,说明使用的是【非默认构造方法】,则使用 oldThr 作为新的容量。这里,我们结合 #tableSizeFor(int cap) 方法,发现 HashMap 容量一定会是 2 的 N 次方。
    • <1.2.2> 处,oldThr 等于 0 ,说明使用的是【默认构造方法】,则使用 DEFAULT_INITIAL_CAPACITY 作为新的容量,然后计算新的 newThr 阀值。
  • <1.3> 处,如果上述的逻辑,未计算新的阀值,则使用 newCap * loadFactor 作为新的阀值。满足该情况的,有 <1.2.1> 和 <1.1.1> 的部分情况(胖友自己看下那个判断条件)

2)第二步

  • 一共分成 <2.1>、<2.2>、<2.3> 的三种情况。相信看懂了 #put(K key, V value) 也是分成三种情况,就很容易明白是为什么了。

  • <2.1> 处,如果 e 节点只有一个元素,直接赋值给新的 table 即可。这是一个优化操作,无论 Node 节点是链表还是红黑树。

  • <2.2> 处,如果 e 节点是红黑树节点,则通过红黑树分裂处理。

  • <2.3> 处,如果 e 节点是链表,以为 HashMap 是成倍扩容,这样原来位置的链表的节点们,会被分散到新的 table 的两个位置中去。可能这里对于不熟悉位操作的胖友有点难理解,我们来一步一步看看:

    为了方便举例,{} 中的数字,胖友记得是二进制表示哈。

    • 1)我们在选择 hash & (cap - 1) 方式,来获得到在 table 的位置。那么经过计算,hash 在 cap 最高位(最左边)的 1 自然就被抹去了。例如说,11 & (4 - 1) = {1011 & 011} = {11} = 3 ,而 15 & (4 - 1) = {1111 & 011} = {11}= 3 。相当于 15 的 1[1]11 的 [1] 被抹去了。
    • 2)HashMap 成倍扩容之后,我们在来看看示例。11 & (7 - 1) = {1011 & 0111} = {11} = 3 ,而 15 & (8 - 1) = {1111 & 0111} = {111}= 7 。相当于 15 的 1[1]11 的 [1] 被保留了。
    • 3)那么怎么判断这 [1] 是否能够在扩容的时候被保留呢,那就使用 hash & oldCap 是否等于 1 即可得到。既然 [1] 被保留下来,那么其位置就会 j + oldCap ,因为 [1] 的价值就是 + oldCap 。

七、树化

/**
 * 每个位置链表树化成红黑树,需要的链表最小长度
 *
 * The bin count threshold for using a tree rather than list for a
 * bin.  Bins are converted to trees when adding an element to a
 * bin with at least this many nodes. The value must be greater
 * than 2 and should be at least 8 to mesh with assumptions in
 * tree removal about conversion back to plain bins upon
 * shrinkage.
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * HashMap 允许树化最小 key-value 键值对数
 *
 * The smallest table capacity for which bins may be treeified.
 * (Otherwise the table is resized if too many nodes in a bin.)
 * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
 * between resizing and treeification thresholds.
 */
static final int MIN_TREEIFY_CAPACITY = 64;


/**
 * Replaces all linked nodes in bin at index for given hash unless
 * table is too small, in which case resizes instead.
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 如果 table 容量小于 MIN_TREEIFY_CAPACITY(64) ,则选择扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 将 hash 对应位置进行树化
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 顺序遍历链表,逐个转换成 TreeNode 节点
        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_THRESHOLD=8,那么我们思考个问题,为什么要求是8呢?

 * Because TreeNodes are about twice the size of regular nodes, we
 * use them only when bins contain enough nodes to warrant use
 * (see TREEIFY_THRESHOLD). And when they become too small (due to
 * removal or resizing) they are converted back to plain bins.  In
 * usages with well-distributed user hashCodes, tree bins are
 * rarely used.  Ideally, under random hashCodes, the frequency of
 * nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average for the default resizing
 * threshold of 0.75, although with a large variance because of
 * resizing granularity. Ignoring variance, the expected
 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
 * factorial(k)). The first values are:
 *
 * 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
  • 首先参考泊松概率函数,当链表长度到达8的概率是0.00000006,不到千分之一。所以在hash算法正常时,不太会出现链表转红黑树的情况。
  • 其次TreeNode相比普通Node,会有两倍的空间占用,并且在长度较小的情况下红黑树的性能和链表差别不大。例如,红黑树O(logN) = log8 = 3和链表的O(N)=8只相差5
  • 比较HashMap是JDK提供的基础数据结构,必须在空间和时间做抉择。所以选择链表是空间复杂度优先,选择红黑树是时间复杂度优化。在绝大多数情况不会出现需要红黑树的情况
  • <1>处如果table容量小于MIN_TREEIFY_CAPACITY=64时,调用resize()方法进行扩容。一般情况该链表可以分裂到两个位置上。当然极端情况下解决不了,这时候一般是hash算法有问题。
  • <2>处,如果table容量大于等于MIN_TREEIFY_CAPACITY = 64时,则将hash对应位置进行树化。

八、总结

  1. HashMap默认容量为16(1<<4),每次超过阈值时,按照两倍大小自动扩容,所以容量总是2的N次方,且底层的table数组是延迟初始化,在首次添加key-value时才进行初始化。
  2. HashMap默认加载因子是0.75,如果我们一直HashMap大小,需要正确设置容量和加载因子
  3. HashMap的每个槽位在满足如下两个条件时可以进行树化成红黑树,避免槽位是链表数据结构时,链表过长导致查找性能慢。
  • 条件一,HashMap的table数组大于等于64
  • 条件二,槽位链表长度大于等于8时,选择8作为阈值的原因是,参考泊松概率函数
  • 在槽位的红黑树节点数量小于等于6时会退化为链表
  1. HashMap的查找和添加key-value键值对的平均时间复杂度为O(1)
  • 对于槽位是链表的节点,平均时间复杂度为O(k),其中k为链表长度
  • 对于槽位是红黑树的节点,平均时间复杂度为O(logk),其中k为红黑树节点数量