经常使用的HashMap究竟是个什么结构

0x00 前言

HashMap 是最经常使用的容器之一,应该没什么疑问了。可你到底了解他吗?网上已经有不少文章来总结 HashMap 了,我来写这篇,主要是为了记录本身阅读以后的一点点小感悟,如如有错误的地方,请你们指正。下文分析基于 jdk1.8java

0x01 一句话介绍

HashMap 内部是一个 Node 类数组,每一个节点存放对应的数据。数组

0x02 概述

先来介绍下 HashMap ,主要依据来自 HashMap 的注释(熟悉的同窗能够直接跳过到0x03部分)。安全

一、HashMap 实现了 Map 接口,拥有 Map 的全部操做,具备如下特色:bash

  • 容许 null 的 key 和 value 。
  • 大体上,他和 HashTable 相同,可是 HashTable 是线程安全的,而 HashMap 非线程安全。出于此缘由,HashMap 在性能上明显会优于 HashTable 。
  • 不保证顺序,缘由在于其内在的原理,他是根据 key 的 hash 值来计算位置的,因此,顺序天然是没法保证的了。(到底怎么算的,往下看。)

二、HashMap 的 get , put 在hash值比较均匀的状况下,操做都是常数级别的时间复杂度。一个很是重要的点是,capacity 不能设置过高,load factor 不能设置的过低。(这两个变量又是干吗的呢,这里先卖个关子✧(≖ ◡ ≖✿)嘿嘿)。函数

三、由于他不是线程安全的,因此能够经过 Collections.synchronizedMap 来包装,从而变成一个线程安全的 Map。性能

四、拥有 fail-fast 特性。简单来讲,就是在遍历的时候,发现元素被改变,就抛出异常。ui

0x03 解释几个变量

构造函数里面的 initialCapacity

这个参数的意思比较明显,就是初始的 Map 长度。默认是 16。this

Node<K,V>[] table

Map 中真正存放元素的地方,能够看到他是一个 Node 数组。Node 结构比较简单,就是一个 key-value 组成的一个链表,其中还有 hash变量,和 next 变量。spa

float loadFactor

顾名思义,负载因子。默认值是0.75,是一个空间和时间上的权衡。具体怎么来的,多是一个复杂的逻辑推算。线程

int threshold

阈值,Map 所能容纳的键值对数量。是根据 Map 中的数组长度*loadFactor计算出来的。看到这个,应该就能够想到,若是 loadFactor设置的过小,会有什么问题了。没错,若是设置过小,容量就会很小,致使空间上的一个浪费,大部分的位置都是空的,没有被充分利用。反之,若是设置太大,就会致使元素放置很是拥挤,查询起来效率就会变低。

0x04 方法分析

构造函数

HashMap 有好几个构造函数,来看一个比较重要的吧。

public HashMap(int initialCapacity, float loadFactor) {
        // 若是传递进来的初始化数组的大小小于0,就是不合法,直接抛异常。
        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;
        // 根据 tableSizeFor 方法进行数组长度的对齐。
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    // 数组长度的对齐。
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        // 通过如下的变化,数组的长度必定是2^n了。 
        n |= n >>> 1;  // 1
        n |= n >>> 2;  // 2
        n |= n >>> 4;  // 3
        n |= n >>> 8;  // 4
        n |= n >>> 16; // 5
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
复制代码

在这里给方法tableSizeFor举个🌰:

若是我设置cap为13,则13-1的二进制是:
  0000 0000 0000 1100
此时进行第一步:
          0000 0000 0000 1100
    >>>1  0000 0000 0000 0110
    |=    0000 0000 0000 1110
第二步:
          0000 0000 0000 1110
    >>>2  0000 0000 0000 0011
    |=    0000 0000 0000 1111
第三步:
          0000 0000 0000 1111
    >>>4  0000 0000 0000 0000
    |=    0000 0000 0000 1111
……
能够看出,后面应该全是 1111(2)了。最后加个1,就是16,2^4.
有的同窗可能不信,因此再举个更大的🌰:
  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
……
能够看到,最终结果仍是同样。二进制有不少好玩的特性,若是能利用好,性能上的提高绝对不止一点半点。
复制代码
Node节点类

包含一个 hash,key, value。

put
public V put(K key, V value) {
    // 实际调用 putVal 方法。此时可能有个疑问,key他本身不是有hashcode方法吗?为何还要本身写一个?暂且按下,先看看 putVal 方法。 ①
    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;
    // 若是 tab 尚未被初始化,或者是空,则先进行 resize .
    if ((tab = table) == null || (n = tab.length) == 0)
        // n 就是tab的长度。 ③
        n = (tab = resize()).length;
    // 这里是重点,怎么定位? (n-1)&hash 来定位的。 ②
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 若是是null,则建立一个新的节点。
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 若是旧节点就是须要被put的节点,则将值直接进行替换。
        // 能够看到他是根据 == 判断是不是同一个对象,或者 equals 方法来判断是否相等。
        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 {
            // 若是是链表,则一个一个往下寻找,
            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) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                // 若是标志检查符合,或者原值为null,则进行赋值。
                e.value = value;
            // 节点可用完成通知
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 修改变动标志加一
    ++modCount;
    // 若是总的节点数量,大于了阈值,则进行扩容
    if (++size > threshold)
        resize();
    // 插入完毕通知
    afterNodeInsertion(evict);
    return null;
}
复制代码

讲讲②处,为何这样定位呢? (n - 1) & hash 。n 是 table 的长度,是一个 2^n 的数字。通常状况下,若是有一个大于数组长度的位置,咱们怎么来将其放入数组中呢?很简单,取模。对这个位置取模,获得的值确定都是小于数组长度的。划重点!因此这个 (n - 1) & hash 也是取模!咱们都知道,n-1 的二进制,都是高位0 + 低位多个1组成的。此时和 hash 值相与,与出来的值,确定是小于 n-1 的,这就达到了一个取模的效果。空说无凭,仍是举个🌰。

假设 n = 16, n-1的二进制即为 1111 。再随便写个32位的hash。
    0000 0000 0000 1111
&   1001 1101 1110 0110
=   0000 0000 0000 0110 = 6 < 16
复制代码

方法很是巧妙,避免了取模,大大提高了索引的速度。

此时引出了①的缘由。先看看hash的尊容。

// hash 方法。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码

来讲说这个 hash 方法。能够看到若是是 null,则返回的是0.非null,则调用了key#hashCode方法,而且异或了hash值右移16位。分析②的时候,能够看到定位是根据hash值和n-1相与来肯定位置的,这就是为何要从新一个hash的缘由。哈?为何?根据②的🌰,能够看出来,定位和hash值的前几位都没有关系,只和 n-1 的二进制长度的位数有关。这就带来一个问题,很是容易产生冲突,随机性被下降了,毕竟高xx位都没有参与运算,就那么几位,确定容易产生冲突。异或这个操做,将高位也拉了进来,大大提升了参与度,hash散列也会更好。仍是举一个🌰。

1110 0010 0011 1101      1110 0000 0011 1101
>>16   0000 0000 1110 0010      0000 0000 1110 0000
^=     1110 0010 1101 1111      1110 0000 1101 1101
能够看到,若是不进行这个操做,这两个元素确定是在一个定位上的,若是加上高位操做,则被分散了。
复制代码
resize

resize是用来对map进行扩容的方法。对应上面的注释③。

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; // 阈值翻倍。
    }
    else if (oldThr > 0)
        // 若是阈值大于0&旧的数组长度小于1,则将新数组长度设置为阈值的大小。
        newCap = oldThr;
    else {               // 上述条件都不符合,则使用初始值。。
        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;
    if (oldTab != null) { // 若是是扩容,不是初始化,则须要进行元素迁移。
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // e 已经引用了就数据,因此将数组对应位置清空,利于垃圾回收。
                oldTab[j] = null;
                if (e.next == null)  // ① 若是链表只有一个元素,则将其放入新数组对应的位置,计算方法已经说过。
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) //②  若是是一颗红黑树,则利用 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 = e.next;
                        if ((e.hash & oldCap) == 0) { // e 的 hash 值和旧的数组长度相与为0 ③-1
                            if (loTail == null)  // 若是低位尾部是null,则低位头是 e.
                                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) { // 将低位链表放置到 j 的位置上。
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) { // 将高位组成的链表放置到 j + oldCap 位置上。
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
复制代码

上面一部分都很好理解。重点看看①-③这几个地方。先画个图吧。好比一个 map 的的长度是4,里面如此放置元素(为了举例,假设能够放置这么多):

[0] -> (0)  //第①种,元素0的后面没有任何元素了,因此直接进行放置
[1] -> (             1               
                5          25
              9   17    21    29
               13                33   ) // 这就属于②了,链表已经被转化为一颗红黑树了。因此须要将树给拆掉。
[2] -> (2 -> 6 -> 10 -> 14) // 这就属于第③种状况了,须要将此链表拆开,放置到新的数组当中。
[3] -> (3)
复制代码

在此着重讲讲第三种状况。仍是上图。

[0]                                 [0]
[1]                                 [1]
[2]-> (2 -> 6 -> 10 -> 14) ----->   [2] -> (2 -> 10)
[3]                                 [3]
                                    [4]
                                    [5]
                                    [6] -> (6 -> 14)
                                    [7]
                                    [8]
此处看看怎么迁移。先看看他们和旧长度4相与是否为0。
            2       6       10      14 
二进制      0010    0110    1010    1110
&          0100    0100    0100    0100
=          0000    0100    0000    0100
列出4和8的二进制数:
            4       8
二进制      0100    1000
n-1        0011    0111
复制代码

能够看出一些规律,长度的二进制老是只有一个1,其他位都是0。而位置计算是 hash&n-1,能够发现,新的位置不过是hash&2n-1,用二进制来看,就是左移了一位补1.因此和原来位置惟一的差异在哪呢,就在这个左移出来的1身上。这就是为何③-1中为何判断e.hash & oldCap。若是长度二进制为1的那个位置是0的元素,就留在原地,反之,则放置到 j +oldCap 位置。由于扩容是两倍,因此就是原来的位置加上一个原数组长度。

get

get 方法和 put 方法很是相似。只不过 get 是 get 返回,put 是 set 值进去。内部调用了 getNode 方法。

remove

remove 和 put 方法也很是相似,就是找到对应的元素,进行删除而已。

containsKey
public boolean containsKey(Object key) {
    // 也是调用了get方法调用的内部方法,判断返回的值是否为null。因此和 get 方法只是一个用不用返回值的区别。
    return getNode(hash(key), key) != null;
}
复制代码
size

直接返回了记录元素个数的 size 变量。

clear

遍历数组,挨个进行 null 赋值。

containsValue

遍历数组和对应的链表,查看 value 是否相等。

红黑树相关的函数

这些函数是java8新增的,若是链表过长,一个个遍历很是影响效率,因此 map 内部将他变成了一颗红黑树,此文就不进行详解了。这部分放到 TreeMap 分析的时候再进行描述。

0x05 喝口水,来个总结

本文讲解了 HashMap 中的一部分核心问题,没有所有都讲下来。还有resize线程安全问题,红黑树相关的部分没有讲解。线程安全这个,后面也会单独来一篇进行讲解。红黑树则放到 TreeMap 的分析当中。若是文中有误,请你们指出,感激涕零。

相关文章
相关标签/搜索