揭秘 HashMap 实现原理(Java 8)

作者: Single_YAM | 来源:发表于2017-12-07 09:13 被阅读611次

    HashMap 作为一种容器类型,无论你是否了解过其内部的实现原理,它的大名已经频频出现在各种互联网面试中了。从基本的使用角度来说,它很简单,但从其内部的实现来看(尤其是 Java 8 的改进以来),它又并非想象中那么容易。如果你一定要问了解其内部实现与否对于写程序究竟有多大影响,我不能给出一个确切的答案。但是作为一名合格程序员,对于这种遍地都在谈论的技术不应该不为所动。本篇文章主要从 jdk 1.8 的版本初步探寻 HashMap 的基本实现情况,主要涉及内容如下:

    • HashMap 的基本组成成员
    • put 方法的具体实现
    • remove 方法的具体实现
    • 其他一些基本方法的基本介绍

    一、HashMap 的基本组成成员

    首先,HashMap 是 Map 的一个实现类,它代表的是一种键值对的数据存储形式。Key 不允许重复出现,Value 随意。jdk 8 之前,其内部是由数组+链表来实现的,而 jdk 8 对于链表长度超过 8 的链表将转储为红黑树。大致的数据存储形式如下:

    图片来自网络

    下面分别对其中的基本成员属性进行说明:

    //默认的容量,即默认的数组长度 16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    //最大的容量,即数组可定义的最大长度 
    static final int MAXIMUM_CAPACITY = 1 << 30;
    

    这就是上述提到的数组,数组的元素都是 Node 类型,数组中的每个 Node 元素都是一个链表的头结点,通过它可以访问连接在其后面的所有结点。其实你也应该发现,上述的容量指的就是这个数组的长度。

    transient Node<K,V>[] table;
    
    //实际存储的键值对个数
    transient int size;
    //用于迭代防止结构性破坏的标量
    transient int modCount;
    

    下面这三个属性是相关的,threshold 代表的是一个阈值,通常小于数组的实际长度。伴随着元素不断的被添加进数组,一旦数组中的元素数量达到这个阈值,那么表明数组应该被扩容而不应该继续任由元素加入。而这个阈值的具体值则由负载因子(loadFactor)和数组容量来决定,公式:threshold = capacity * loadFactor。

    int threshold;
    final float loadFactor;
    //HashMap 中默认负载因子为 0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    

    好了,有关 HashMap 的基本属性大致介绍如上。下面我们看看它的几个重载的构造函数。

    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);
    }
    

    这是一个最基本的构造函数,需要调用方传入两个参数,initialCapacity 和 loadFactor。程序的大部分代码在判断传入参数的合法性,initialCapacity 小于零将抛出异常,大于 MAXIMUM_CAPACITY 将被限定为 MAXIMUM_CAPACITY。loadFactor 如果小于等于零或者非数字类型也会抛出异常。

    整个构造函数的核心在对 threshold 的初始化操作:

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        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;
    }
    

    这是一个小巧但精妙的方法,这里通过或的位运算将两个字节的 n 打造成比 cap 大但最接近 2 的 n 次幂的一个数值。例如:

    这里写图片描述

    这里我们表示 n 的时候使用了 7 个 x,所以无论 x 为 0 或者 1,n 的值都是大于 2 的 7 次幂的。我们从最终结果可以看到,最后的 n 被打造为 8 个 1,也就是 2 的 8 次幂减一。

    所以从宏观上看,传入的容量无论是处于任何范围,最终都会被打造成比该值大并且比最近的一个 2 的 n 次幂小一的值。为什么这么做?因为 2 的 n 次幂小一的值在二进制角度看全为 1,将有利于 HashMap 中的元素搜索,这一点我们后续将介绍。

    那么通过该方法,我们将获得一个 2 的整数次幂的容量的值,此处存放至 threshold,实际上我们获取的是一个有关数组容量的值,不应该存放至阈值 threshold 中,但在后续实际初始化数组的时候并不会受到影响,这里可能是写 jdk 的大神偷了一次懒吧。

    那么我们对于这个最基本的构造函数的介绍就已经结束了,当然,HashMap 中还有很多的重载构造函数,但几乎都是基于上述的构造函数的。例如:

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

    最后需要说明一点的是,以上的一些构造函数都没有直接的创建一个切实存在的数组,他们都是在为创建数组需要的一些参数做初始化,所以有些在构造函数中并没有被初始化的属性都会在实际初始化数组的时候用默认值替换。

    二、put 方法的具体实现

    put 方法的源码分析是本篇的一个重点,因为通过该方法我们可以窥探到 HashMap 在内部是如何进行数据存储的,所谓的数组+链表+红黑树的存储结构是如何形成的,又是在何种情况下将链表转换成红黑树来优化性能的。带着一系列的疑问,我们看这个 put 方法:

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

    添加一个元素只需要传入一个键和一个值即可,putVal 方法是关键,我已经在该方法中进行了基本的注释,具体的细节稍后详细说明,先从这些注释中大体上建立一个直观的感受。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果 table 还未被初始化,那么初始化它
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //根据键的 hash 值找到该键对应到数组中存储的索引
        //如果为 null,那么说明此索引位置并没有被占用
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //不为 null,说明此处已经被占用,只需要将构建一个节点插入到这个链表的尾部即可
        else {
            Node<K,V> e; K k;
            //当前结点和将要插入的结点的 hash 和 key 相同,说明这是一次修改操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果 p 这个头结点是红黑树结点的话,以红黑树的插入形式进行插入
            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);
                        //如果插入后链表长度大于等于 8 ,将链表裂变成红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1)
                            treeifyBin(tab, hash);
                        break;
                    }
                    //遍历的过程中,如果发现与某个结点的 hash和key,这依然是一次修改操作 
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //e 不是 null,说明当前的 put 操作是一次修改操作并且e指向的就是需要被修改的结点
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //如果添加后,数组容量达到阈值,进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    从整体上来看,该方法的大致处理逻辑已如上述注释说明,下面我们针对其中的细节进行详细的解释。

    首先,我们看 resize 这个方法是如何对 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) {
                //极限的限定,达到容量限定的极限将不再扩容
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                //未达到极限,将数组容量扩大两倍,阈值也扩大两倍
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1; 
            }
            //数组未初始化,但阈值不为 0,为什么不为 0 ?
            //上述提到 jdk 大神偷懒的事情就指的这,构造函数根据传入的容量打造了一个合适的数组容量暂存在阈值中
            //这里直接使用
            else if (oldThr > 0) 
                newCap = oldThr;
            //数组未初始化并且阈值也为0,说明一切都以默认值进行构造
            else {
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            //这里也是在他偷懒的后续弥补
            //newCap = oldThr 之后并没有计算阈值,所以 newThr = 0
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);
            }
            threshold = newThr;
    ****************后续代码......*******
    

    这一部分代码结束后,无论是初始化数组还是扩容,总之,必需的数组容量和阈值都已经计算完成了。下面看后续的代码:

    ************第一部分代码.....************
    //根据新的容量初始化一个数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //旧数组不为 null,这次的 resize 是一次扩容行为
    if (oldTab != null) {
        //将旧数组中的每个节点位置相对静止地拷贝值新数组中
        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;
                //如果 e 是红黑树结点,红黑树分裂,转移至新表
                else if (e instanceof TreeNode)
                    ((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;
                        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;
                    }
                }
            }
        }
    }
    //不论你是扩容还是初始化,都可以返回 newTab
    return newTab;
    

    对于第二部分的代码段来说,主要完成的是将旧链表中的各个节点按照原序地复制到新数组中。关于头结点是红黑树的情况我们暂时不去涉及,下面重点介绍下链表的拷贝和优化代码块,这部分代码不再重复贴出,此处直接进行分析,有需要的可以参照上述列出的代码块或者自己的 jdk 进行理解。

    这部分其实是一个优化操作,将当前链表上的一些结点移出来向刚扩容的另一半存储空间放。

    一般我们有如下公式:

    index = e.hash & (oldCap - 1)
    
    这里写图片描述

    随便举个例子,此时的 e 在容量扩大两倍以后的索引值没有变化,所以这部分结点是不需要移动的,那么程序如何判断扩容前后的 index 是否相等呢?

    //oldCap 一定是 100...000 的形式
    if ((e.hash & oldCap) == 0)
    

    如果原 oldCap 为 10000 的话,那么扩容后的 newCap 则为 100000,会比原来多出一位。所以我们只要知道原索引值的前一位是 0 还是 1 即可,如果是 0,那么它和新容量与后还是 0 并不改变索引的值,如果是 1 的话,那么索引值会增加 oldCap。

    这样就分两步拆分当前链表,一条链表是不需要移动的,依然保存在当前索引值的结点上,另一条则需要变动到 index + oldCap 的索引位置上。

    这里我们只介绍了普通链表的分裂情况,至于红黑树的裂变其实是类似的,依然分出一些结点到 index + oldCap 的索引位置上,只不过遍历的方式不同而已。

    这样,我们对于 resize 这个扩容的方法已经解析完成了,下面接着看 putVal 方法,篇幅比较长,该方法的源码已经在介绍 resize 之前贴出,建议读者根据自己的 jdk 对照着理解。

    上面我们说到,如果在 put 一个元素的时候判断内部的 table 数组还未初始化,那么调用 resize 根据相应的参数信息初始化数组。接下来的这个判断语句就很简单了:

    if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
    

    根据键的 hash 值找到对应的索引位置,如果该位置为 null,说明还没有头结点,于是 newNode 并存储在该位置上。

    否则的话说明该位置已经有头结点了,或者说已经存在一个链表或红黑树了,那么我们要做的只是新建一个节点添加到链表或者红黑树的最后位置即可。

    第一步,

    if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
          e = p;
    

    p 指向当前节点,如果我们要插入的节点的键以及键所对应的 hash 值和 p 节点完全一样的话,那么说明这次 put 是一次修改操作,新建一个引用指向这个需要修改的节点。

    第二步,

    else if (p instanceof TreeNode)
         e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    

    如果当前 p 节点是红黑树结点,那么需要调用不同于链表的的添加节点的方法来添加一个节点到红黑树中。(主要是维持平衡,建议读者去了解下红黑树,此处没有深谈是限于它的复杂度和文章篇幅)。

    第三步,

    else {
         for (int binCount = 0; ; ++binCount) {
         if ((e = p.next) == null) {
             p.next = newNode(hash, key, value, null);
             if (binCount >= TREEIFY_THRESHOLD - 1) 
                 treeifyBin(tab, hash);
             break;
         }
        if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
             break;
        p = e;
        }
    }
    

    这里主要处理的是向普通链表的末尾添加一个新的结点,e 不断地往后移动,如果发现 e 为 null,那么说明已经到链表的末尾了,那么新建一个节点添加到链表的末尾即可,因为 p 是 e 的父节点,所以直接让 p.next 指向新节点即可。添加之后,如果发现链表长度超过 8,那么将链表转储成红黑树。

    在遍历的过程中,如果发现 e 所指向的当前结点和我们即将插入的节点信息完全匹配,那么也说明这是一次修改操作,由于 e 已经指向了该需要被修改的结点,所以直接 break 即可。

    那么最终,无论是第一步中找到的头节点即需要被修改的节点,还是第三步在遍历中找到的需要被修改的节点,它们的引用都是 e,此时我们只需要用传入的 Value 值替换 e 指向的节点的 value 即可。正如这段代码一样:

    if (e != null) { // existing mapping for key
         V oldValue = e.value;
         if (!onlyIfAbsent || oldValue == null)
              e.value = value;
         afterNodeAccess(e);
         return oldValue;
     }
    

    如果 e 为 null,那更简单了,说明此次 put 是添加新元素并且新元素也已经在上述代码中被添加到 HashMap 中了,我们只需要关心下,新加入一个元素后是否达到数组的阈值,如果是则调用 resize 方法扩大数组容量。该方法已经详细阐述过,此处不再赘述。

    所以,这个 put 方法是集添加与修改一体的一个方法,如果执行的是添加操作则会返回 null,是修改操作则会返回旧结点的 value 值。

    那么至此,我们对添加操作的内部实现想必已经了解的不错了,接下来看看删除操作的内部实现。

    三、remove 方法的具体实现

    删除操作就是一个查找+删除的过程,相对于添加操作其实容易一些,但那是你基于上述添加方法理解的不错的前提下。

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    

    根据键值删除指定节点,这是一个最常见的操作了。显然,removeNode 方法是核心。

    final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
                if (node instanceof TreeNode)                                                                     ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }
    

    删除操作需要保证在表不为空的情况下进行,并且 p 节点根据键的 hash 值对应到数组的索引,在该索引处必定有节点,如果为 null ,那么间接说明此键所对应的结点并不存在于整个 HashMap 中,这是不合法的,所以首先要在这两个大前提下才能进行删除结点的操作。

    第一步,

    if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
         node = p;
    

    需要删除的结点就是这个头节点,让 node 引用指向它。否则说明待删除的结点在当前 p 所指向的头节点的链表或红黑树中,于是需要我们遍历查找。

    第二步,

    else if ((e = p.next) != null) {
         if (p instanceof TreeNode)
              node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
         else {
             do {
                  if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {
                         node = e;
                  break;
             }
             p = e;
             } while ((e = e.next) != null);
         }
    }
    

    如果头节点是红黑树结点,那么调用红黑树自己的遍历方法去得到这个待删结点。否则就是普通链表,我们使用 do while 循环去遍历找到待删结点。找到节点之后,接下来就是删除操作了。

    第三步,

    if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
           if (node instanceof TreeNode)
                        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
           else if (node == p)
                tab[index] = node.next;
           else
                p.next = node.next;
           ++modCount;
           --size;
           afterNodeRemoval(node);
           return node;
     }
    

    删除操作也很简单,如果是红黑树结点的删除,直接调用红黑树的删除方法进行删除即可,如果是待删结点就是一个头节点,那么用它的 next 结点顶替它作为头节点存放在 table[index] 中,如果删除的是普通链表中的一个节点,用该结点的前一个节点直接跳过该待删结点指向它的 next 结点即可。

    最后,如果 removeNode 方法删除成功将返回被删结点,否则返回 null。

    这样,相对复杂的 put 和 remove 方法的内部实现,我们已经完成解析了。下面看看其他常用的方法实现,它们或多或少都于这两个方法有所关联。

    四、其他常用的方法介绍

    除了常用的 put 和 remove 两个方法外,HashMap 中还有一些好用的方法,下面我们简单的学习下它们。

    1、clear

    public void clear() {
        Node<K,V>[] tab;
        modCount++;
        if ((tab = table) != null && size > 0) {
            size = 0;
            for (int i = 0; i < tab.length; ++i)
                tab[i] = null;
        }
    }
    

    该方法调用结束后将清除 HashMap 中存储的所有元素。

    2、keySet

    //实例属性 keySet
    transient volatile Set<K>        keySet;
    
    public Set<K> keySet() {
        Set<K> ks;
        return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
    }
    
    final class KeySet extends AbstractSet<K> {
        public final int size()                 { return size; }
        public final void clear()               { HashMap.this.clear(); }
        public final Iterator<K> iterator()     { return new KeyIterator(); }
        public final boolean contains(Object o) { return containsKey(o); }
        public final boolean remove(Object key) {
            return removeNode(hash(key), key, null, false, true) != null;
        }
        public final Spliterator<K> spliterator() {
            return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
        }
    }
    

    HashMap 中定义了一个 keySet 的实例属性,它保存的是整个 HashMap 中所有键的集合。上述所列出的 KeySet 类是 Set 的一个实现类,它负责为我们提供有关 HashMap 中所有对键的操作。

    可以看到,KeySet 中的所有的实例方法都依赖当前的 HashMap 实例,也就是说,我们对返回的 keySet 集中的任意一个操作都会直接映射到当前 HashMap 实例中,例如你执行删除一个键的操作,那么 HashMap 中将会少一个节点。

    3、values

    public Collection<V> values() {
        Collection<V> vs;
        return (vs = values) == null ? (values = new Values()) : vs;
    }
    

    values 方法其实和 keySet 方法类似,它返回了所有节点的 value 属性所构成的 Collection 集合,此处不再赘述。

    4、entrySet

    public Set<Map.Entry<K,V>> entrySet() {
        Set<Map.Entry<K,V>> es;
        return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
    }
    

    它返回的是所有节点的集合,或者说是所有的键值对集合。

    5、get

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    

    get 方法的内部实现其实是我们介绍过的 put 方法中的一部分,所以此处也不再赘述。

    至此,我们简单的解析了 HashMap 的内部实现,虽然说并没有面面俱到,但是最基本的、最核心的部分应该是叙述清晰的。总结不到之处,望不吝赐教!

    相关文章

      网友评论

      • pigCoder:这里通过异或的位运算将两个字节的 n 打造成比 cap 大但最接近 2 的 n 次幂的一个数值...这里应该是或而不是异或吧
        pigCoder:@Single_YAM 客气,写的非常好:+1:
        Single_YAM:谢谢指正,笔误已更改!

      本文标题:揭秘 HashMap 实现原理(Java 8)

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