HashMap - 核心原理与知识点记录(上)

HashMap put 方法执行逻辑及部分问题的分析

数据结构可视化网站: https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
HashMap - 核心原理与知识点记录(中)
HashMap - 核心原理与知识点记录(下)

本文主要通过一下几个问题进行探讨:

  • 什么是 HashMap
  • HashMap为什么要使用这样的数据结构?解决了什么问题?
  • 1.8 版本中hashcode 为什么要和 hash高16位做异或运算 ?
  • HashMap 执行 put() 内部做了些什么事情 ?
  • 如何进行扩容的,扩容大小为什么总是2的幂次大小 ?
  • 采用红黑树的目的 ?

1. 什么是 HashMap ?

HashMap 主要是存放 key,value 的数据结构,通过 key 值获取到 value ,key 是不允许重复的并且 key 值允许为 null,在 Java 1.7 版本(包含1.7)之前使用的是使用的数组加链表的形式进行存储,在 1.8 版本升级了 HashMap,将其新增了红黑树的转换,并且还优化了 hash 冲突的可能性。

public static void main(String[] args) {
    Map<String,String> map = new HashMap<>();
    map.put(null, "1");
    map.put(null, "2");

    String val = map.get(null);
    // 输出结果为 2, 第一次put被第二次覆盖了
    System.out.println(val);
}

2. HashMap为什么要使用这样的数据结构?解决了什么问题?

在这里我们主要从 1.8 版本的数据结构进行分析,因为 1.8 版本也包含了 1.8 之前的数据结构
HashMap 的源码中,我们可以得知主要底层的支撑的是一个叫 Node<K,V>[] table 的定义,而这个 table 数组我们可以理解为桶,每只桶里可以装很多个 k v 结构的数据,分析到这里似乎出现了问题:怎么用数组的一个节点去存储多个 KV,显然这里肯定是不满足要求的,所以我们得思考怎么让一个桶里面可以存储多个 KV 的数据,此时毫无疑问链表出现在了我的面前,正好链表也可以使用一个 Node 对象来代替,完美的适配了数组 + 链表对桶的描述:
数组+链表
但是,在 1.8 版本中新增了红黑树数据结构存储,那么为什么要新增红黑树,这样又达到了什么样的目的?
链表
根据图片中得到的信息,这是一个链表,那么我们想通过链表得到一个数的时候,那么需要对链表进行遍历,如果链表的深度为10000且刚好我们想要匹配的值就在最后一个节点上,那么这个链表将会被遍历10000次才能正确的拿到我们想要的结果,时间复杂度为 O(n) 级别,非常的耗费性能,接下来我们在看看红黑树:
红黑树
通过上图中,如果我要查询数字 7,那么只需要遍历树节点四次即可得到,相对于链表节省了3次,如果有 10000 条数据,那么我们也不需要再像链表一样需要遍历 10000次,这种效率高了接近一半。但是红黑树也不是完全没有缺点,接下来来说一下链表与红黑树各自的优缺点。
链表与红黑树各自的优缺点:
链表:插入数度快,但查询的速度慢(需要遍历所有的节点进行判断)
红黑树:查询速度快,插入速度慢(每次插入数据的时候,都需要将节点进行平衡,满足左小右大的特性)

3. 1.8 版本中hashcode 为什么要和 hash高16位做异或运算 ?

进入 HashMap#put() 中,调用了 hash() 方法,代码如下:

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

接下来重点分析一下 (h = key.hashCode()) ^ (h >>> 16) 这句代码:

h :计算得到的 key 对应的 hash 值
>>> :左移运算
^ :异或运算

假设 key 为 “baidu” 通过计算得出
93498907 (十进制)
00000101 10010010 10101110 00011011(二进制)
00000000 00000000 00000101 10010010 (左移16位)
00000101 10010010 10101011 10001001 (左移后和左移之前进行异或运算)
93498249(异或的结果转换为十进制)
00000000 00000000 00000000 00001111(数组的长度减一为15)
00000101 10010010 10101011 10001001(长度和hash码做与运算)
00000101 10010010 10101011 10001001(最终算出来落到数组上的下标二进制)
9 (十进制的数组下标位置)

通过以上的计算,可以看出执行了左移运算和异或运算后,得到的结果也携带上了高 16 位的特性,而并非原 hashcode 的原始值,这样做的目的是有效的降低了 hash 碰撞的可能性。如果单纯的拿低16位做运算,那么有很多组数的低16位可能会相同,但是高16位就不一定相同。

4. HashMap 执行 put() 内部做了些什么事情 ?

接下来进入源码环节,主要探讨一下 put 方法的执行逻辑:

public V put(K key, V value) {
	// 执行 putVal 之前,先计算出 hash 值,正如小结3所示
    return putVal(hash(key), key, value, false, true);
}

4.1 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;
    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;
        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) {
                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) { // 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;
}

4.2 初始化 table 数组

如以上代码所示,我们来一句句的分析:

// 声明变量,毫无疑问
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table 是成员变量,在小结2中已经描述了,代表 HashMap 的底层支撑数组
// 此时如果 table == null 说明这里并没有对这个数组进行初始化,那么直接执行了数组的初始化
// 并且返回初始化的数组大小,根据 resize 方法代码的查看,我们知道默认初始化是16个桶的数量
// 后续的 resize 方法会详细分析
if ((tab = table)== null || (n = tab.length) == 0)
	// 直接执行初始化
    n = (tab = resize()).length;

执行以上代码,会初始化16个长度的 table 数组
初始化数组

4.3 在数组中存储未被占用的节点数据

// 这里 tab 在上一个判断中已经进行了赋值为成员变量 table,那么此时的 tab 变量一定是不为 null的
// n - 1 & hash 主要是为了给当前存入的 KV 寻找对应的桶,前面提到了桶是数组,那么这里计算的就是数组的下标定位桶
// 此时的这个桶被赋值给了 p 变量,而 i 变量则是当前桶所在数组的下标位置
if ((p = tab[i = (n - 1) & hash]) == null)
	// 在 if 判断中计算出了桶所在的下标,那么当前下标的位置一定是没有被占用并且,直接把将要放入的 KV 放入该桶的位置
	tab[i] = newNode(hash, key, value, null);

将node节点放入数组下标为9且无占用的情况

4.4 数组已经被初始化,并且计算出来的数组下标位置所在的节点上已被占用的情况

以下是 else 部分,继续切分

// 既然你进入了 else,那么说明了上一步计算出来的 i 所在的下标位置的第一个桶已经被别的 KV 占用了
// 既然进入了,那么分为几种情况,要么 key 是同一个,要么就不是同一个
// 这里就先判断了 key 到底是不是同一个,如果是同一个,这里直接将当前的这个 key 对应的 node 节点赋值给了 e 变量
// 主意此时的 k 和 e 两个变量已经被赋值了
Node<K,V> e; K k;
if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;

相同的 key 不是 value的插入

4.5 如果生成的当前节点是一个红黑树节点

// 如果说当前节点类型是红黑树,那么执行红黑树的操作
else if (p instanceof TreeNode)
	// 红黑树put值,没啥好讲的,已经脱离了 HashMap 的本之了
  	e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

插入节点到红黑树中

4.6 针对链表节点的插入

如果既不是相同的 key,也不是红黑树,那么一定是链表咯,想插入到链表中,那么必须要遍历链表,判断 next 指针指向为 null 的节点即可作为当前数据存储的节点

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;
    }
}
4.6.1 如果当前链表的深度达到了设定的阈值,则转换为红黑树
// 第一次循环中,p就是当前桶中第一个节点,如果第一个节点就没有下一个节点,那么就把当前的 KV 生成一个 Node 放入到 p 节点的下一个节点
if ((e = p.next) == null) {
 	p.next = newNode(hash, key, value, null);
 	// 如果当前链表的深度大于了设定的阈值-1,那么就直接转换为红黑树
 	// 这里为什么要减一:阈值是从1开始的,1-8共8位,而for循环是从0开始,则0-7才是8个数,所以这里需要 -1
    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
        treeifyBin(tab, hash);
    // 执行完链表转红黑树的操作后,直接跳出当前循环
    break;
}

正常链表的插入
红黑树的插入

4.6.2 每次循环都需要与当前的 node 节点进行比对
// e 变量是在 4.6.1 小结中赋值的,是 p.next
// 如果 p 是第一个节点,那么 e 就是 p 的下一个节点
// 这里主要是判断了当前的 key 和 hash 是不是与 e 节点完全一致
// 如果完全一致,则说明当前传入的 kv 与 e 节点的 key 相同,相同则跳出
// 这里跳出但是 e 依然被附上了值,后续再来一同研究
if (e.hash == hash &&
	((k = e.key) == key || (key != null && key.equals(k))))
	break;

4.7 当 e 变量被赋值时,节点使用新值替换老值

if (e != null) 
   V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
}

相同 key 插入之前如图: 插入前
相同key插入之前
相同 key 插入之前如图:插入后

4.8 put 方法收尾

// modCount 主要是用来迭代器快速失败的
++modCount;
// size 插入的 node 节点个数
// threshold 扩容阈值,在 resize 方法中被计算出来的,第一次计算结果是 12 
// 如果当前的个数大于了12,那么就会调用 resize 方法,但是不一定会触发扩容
if (++size > threshold)
    resize();
// 本方法在 HashMap 中并无实质作用
afterNodeInsertion(evict);

5. 总结

由于缩短文章篇幅,将 resize 方法的解剖放到了下文中:HashMap - 核心原理与知识点记录(中)

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值