HashMap 原理之 HashMap 的存储结构(基于 JDK1.8)

上篇文章中解答了初步认识了 HashMap 的扩容,初始化以及容量的实现,今天接着上篇文章来讲,解决一下第二个问题。Q2. 底层的存储数据结构?

老规矩,在开始之前还是先把常用的几个属性掏出来

/**
 * 树化的阈值
 */
static final int TREEIFY_THRESHOLD = 8;
/**
 * 反树化的阈值 
 */
static final int UNTREEIFY_THRESHOLD = 6;
/**
 * 最小树节点容量
 */
static final int MIN_TREEIFY_CAPACITY = 64;
/**
 * 节点数组
 */
transient Node<K,V>[] table;

还有一部分属性在上一篇文章中已经讲过了,这里不再描述了。本篇文章的重点是描述 HashMap 的存储结构,其他没有介绍的属性暂时不用关心。

HashMap putVal 存值的过程

上篇文章说道:HashMap 是在 putVal 这个方法里面进行初始化的,这个方法里面的 resize 方法既担任了初始化的工作同时也担任了扩容的工作。

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; //这里的长度就是 32
    if ((p = tab[i = (n - 1) & hash]) == null) // (32 - 1)和 key 的 hash 值做 & 运算,检测当前索引位置是否存在值
        tab[i] = newNode(hash, key, value, null); //不存在就把该 Node 节点放在数组的最后位置。
    else { //该位置已经有值了
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;  //判断该位置旧 Node key 的 hash 值是否与传进来的相等,并且(key == key,或者 key.equals(key))
        else if (p instanceof TreeNode) //不相等就判断对象的实例是否是 TreeNode 类型
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //如果是 TreeNode 类型,说明已经树化,调用 TreeNode 的 putTreeVal 将节点插入到树中
        else {//该位置有值,并且 hash 不一致,则产生和 hash 冲突,下面的循环开始解决 hash 冲突
            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;
                }
                //链表的下一个节点不为空,那么计算判断链表的 next 节点的 hash 和传进来的 key 的 hash 是否相等,k 是否相等
                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;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

根据上面的代码进行分析,基本可以确定 HashMap 是由数组 + 链表(树)组成的。首先 tab数组作为主体部分,tab 数组里面的每一个值都挂了一个 Node 节点的单链表。(Node 节点中都存有下一个节点的引用)

static class Node<K,V> implements Map.Entry<K,V> {
   final int hash;
   final K key;
   V value;
   Node<K,V> next; //下一个节点的引用

   Node(int hash, K key, V value, Node<K,V> next) {
       this.hash = hash;
       this.key = key;
       this.value = value;
       this.next = next;
   }

   public final K getKey()        { return key; }
   public final V getValue()      { return value; }
   public final String toString() { return key + "=" + value; }

   public final int hashCode() {
       return Objects.hashCode(key) ^ Objects.hashCode(value);
   }

   public final V setValue(V newValue) {
       V oldValue = value;
       value = newValue;
       return oldValue;
   }

   public final boolean equals(Object o) {
       if (o == this)
           return true;
       if (o instanceof Map.Entry) {
           Map.Entry<?,?> e = (Map.Entry<?,?>)o;
           if (Objects.equals(key, e.getKey()) &&
               Objects.equals(value, e.getValue()))
               return true;
       }
       return false;
   }
}

1. 先分析数组 + 链表的情况

当我们想往 Map 容器中 put 一个值时,会先调用 hash(key) 这个方法,计算出 key 的 hash 值,然后将计算完毕的 hash 以及 key,value 传给 putVal 方法。

调用 putVal 方法之后,会先去判断该值是否在 tab 表中存在。判断方式如下:

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

a. 如果 (p = tab[i = (n - 1) & hash]) == null 满足条件
说明该位置没有数据,此时将 Node 节点放在 tab[i] 的位置,并且将该节点的下一个节点置为 null,也就是说没有 hash 冲突的 Node 节点只是一个单节点的链表。

看看 hash 方法是如何计算的,源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里有个点需要注意,HashMap 是允许空 key 的,如果 put 进来的 key 为 null 的话,就将 0 作为 key 为 null 的 hash 值。如果不为空的话就计算 key 的 hash 值为 h,与 h >>> 16 做位运算。

b. 如果 (p = tab[i = (n - 1) & hash]) == null 不满足条件
说明该位置已经数据了,这里就出现了 hash 冲突。既然出现了冲突,就需要去解决冲突。接着 putVal 的源码往下看。

既然这个位置已经有值了,那么就需要判断当前位置的 Node 节点和要存储的 Node 节点是否相同。
判断依据

  • 存储的 Node 节点的 hash 值和 当前位置的 hash 是否想等
  • 存储的 Node 节点的 key 和当前位置的 key 相等(==)。或者、要存储的节点的 key 不能为空且存储的 key 和源节点的 key 要相同(equal)
    如果上面两个条件都满足,那么将执行 e = p(原值返回)

如果当前位置的 Node 节点和要存储的 Node 节点不相同,那么就要判断当前节点的位置是否是 TreeNode 类型,如果是 TreeNode 类型,就调用 TreeNode 类型来 put TreeVal 存值。(树化存储,后面讲)

如果当前位置的 Node 节点和要存储的 Node 节点不相同,且当前节点不是 TreeNode 类型,那么就要遍历当前节点的链表。直到将这个链表的节点遍历完为止。(也就是将值插入到链表的最后位置,尾插法)

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; // 让指针位置后移
}

这里一直找当前节点的下一个;
如果下一个为空,那么就将要存储的节点插入到当前节点所处在的链表中。
如果当前节点的链表下一个不为空,那么还是执行与上面同样的比较。

这个过程可以使用流程图来描述一下,更加形象。
在这里插入图片描述
根据上面的流程图,我们大概知道了,HashMap 的存储结构应该是这个样子(在未树化之前
在这里插入图片描述

2.再分析数组 + 数的过程

上面我们说到了树化,也就是如果 Node 是 TreeNode 的实例时,会调用 TreeNode 这个对象的 putTreeVal 方法。
同时在遍历 Node 链表时,也会判断当前这个节点的长度到达树化的阈值时,会调用 treeifyBin 这个方法将链表树化。
在这里插入图片描述
存值的过程我们先不管,先看看 treeifyBin 方法里面的内容。源码如下:

final void treeifyBin(Node<K,V>[] tab, int hash) {
  int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //当数组的长度为空或者数组的长度 < 64 的时候,会调用 resize 方法进行扩容
        resize();
    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);
    }
}

通过 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) 和前面我们分析代码来看树化需要两个条件

  • 数组中元素下面的链表节点数必须要 >= 8 才能树化
  • 数组的长度需要大于 64,如果你的长度小于 64,hashMap 会优先选择先扩容。
    只有满足了上述的两个要求,tab 数组才会树化。

为什么当数组容量 >= 64 时才树化?因为当 table 数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。

关于树化的过程我看的还不是很明白,这里先留个坑吧。等我看明白树化的过程了再补上这个坑。

总结

虽然树化的过程没有梳理出来,但是对于第二个问题我们确是可以回答了。
jdk1.8 的存储结果是使用 数组 + 链表 + 红黑树来进行存储的。当数组中元素下面的链表节点数大于 8 时,并且数组的长度大于等于 64,如果数组的长度小于 64,hashMap 会优先选择先扩容。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值