HashMap核心知识-深度学习

HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。

HashMap底层数据结构

在早期的JDK版本中,HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则主要是为了解决哈希冲突而存在的。向 HashMap 中存放元素时,哈希值相同的对象会形成一个链表放在对应的数组元素中。但是链表查询元素的时间复杂度是O(n),所以链表元素越多查询效率越低。

基于上面的背景,所以在JDK8中,HashMap 引入了第三种数据结构——红黑树,红黑树是一棵接近于平衡的二叉树,其查询时间复杂度为O(logn),比链表的查询效率高。那为什么不直接使用红黑树代替链表呢?这是因为红黑树自身维护的代价也是比较高的,每插入一个元素都可能打破红黑树的平衡性,这就需要红黑树在插入数据的时候,通过左旋、右旋、变色这些操作来保持自身平衡。所以在 HashMap 中链表转红黑树需要满足一定的条件。

1、HashMap中数组、链表、红黑树的源码定义与结构图示
如下源码定义基于JDK8版本,首先我们使用结构图示的方式了解一下HashMap的数据结构定义。
在这里插入图片描述
从图示中我们可以看出 HashMap 由数组、链表、红黑树共同构成,JDK8采用红黑树的数据结构来做优化,使得HashMap存取速度更快。

  • 数组定义:

    // 数组
    transient Node<K,V>[] table;
    
  • 单链表节点定义:

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; // 对key的hashCode值进行hash运算后得到的值,存储在Node中,避免重复计算
        final K key;
        V value;
        Node<K,V> next; // 存储指向下一个Node的引用,单链表结构
        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;
        }
    }
    
  • 红黑树节点定义:
    TreeNode<K,V> 继承自 LinkedHashMap.Entry<K,V>,同时 LinkedHashMap.Entry<K,V> 又是 HashMap.Node<K,V>的子类

    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    	TreeNode<K,V> parent;
    	TreeNode<K,V> left;
    	TreeNode<K,V> right;
    	TreeNode<K,V> prev;
    	boolean red;
    	TreeNode(int hash, K key, V val, Node<K,V> next) {
    	    super(hash, key, val, next);
    	}
    	// …… 省略其他函数方法
    }
    

2、HashMap中链表、红黑树的使用

前面了解了数据结构的定义后,我们再来看看在 HashMap 中是怎么使用链表和红黑树这两种数据结构的?

  • 使用链表处理哈希冲突问题 : 哈希值相同的对象通过寻址算法计算出的索引位置也会相同,那么要想在同一个元素位置插入多个对象就需要使用链表结构

  • 链表树化: 当链表节点数量大于等于 8 (TREEIFY_THRESHOLD)时,会触发链表树化函数 final void treeifyBin(Node<K,V>[] tab, int hash),但是在该函数中会判断数组长度是否小于 64 (MIN_TREEIFY_CAPACITY),若小于64则只会进行数组扩容操作,也就是说只有数组容量大于等于64时才会进行链表树化操作。基于这样的机制,就避免了HashMap在数据量相对较少的情况下出现因链表树化而导致的性能消耗。也就是说HashMap在容量较小的时候,会通过扩容机制来让链表暂时维护更多的元素

  • 红黑树转链表: 当红黑树节点元素小于等于 6 (UNTREEIFY_THRESHOLD)个时会将红黑树转成链表

    下面我们通过 HashMap 的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; // 如果数组 table 中没有元素或者长度为0,则调用扩容函数进行数组初始化操作,并将数组赋值给 tab,然后将数组长度赋值给 n
    	if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash 就是hash寻址算法,将寻址后得到的元素赋值给 p
    		tab[i] = newNode(hash, key, value, null); // 如果数组的当前索引位置(i)是 null,则直接将key-value插入到该位置
    	else { // 数组的当前索引位置不是 null
    		Node<K,V> e; K k; // 重新定义一个Node,和一个K
    		if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
    			e = p; // 如果该对应数据已存在(hash相等并且key值也相等),将该位置的 Node 赋值给 e
    		else if (p instanceof TreeNode) // 判断当前位置的 Node 的类型是否为 TreeNode
    			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 向红黑树中插入一个元素
    		else {
    			// 下面开始遍历数组中当前位置的链表
    			for (int binCount = 0; ; ++binCount) { // 无条件循环,binCount 用于记录链表中节点数量(它从0开始的,也可以看作是索引数)
    				if ((e = p.next) == null) { // 当 node.next 为 null 时,表示找到链表尾节点
    					p.next = newNode(hash, key, value, null); // 直接将key-value插入到链表尾部
    					if (binCount >= TREEIFY_THRESHOLD - 1) // 完成插入动作后,判断链表索引数是否大于等于(8-1),即链表节点树是否大于等于8
    						treeifyBin(tab, hash); // true, 进行链表树化操作。
    						// 注意,在 treeifyBin 函数中会判断数组长度是否小于64,若小于64则只会进行数组扩容操作,也就是说只有数组容量大于等于64时才会进行链表树化操作
    					break;
    				}
    				if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
    					break; // 如果该对应数据已存在(hash相等并且key值也相等),直接跳出。注意:该位置的 Node 已经赋值给 e 了
    				p = e; // 如果以上条件都不满足,则将 e 赋值给 p,进行后续节点的查找
    			}
    		}
    		// 如果e不为空,即找到了一个去存储 key-value 的 Node。就是key相同时,覆盖value值操作
    		if (e != null) {
    			V oldValue = e.value;
    			if (!onlyIfAbsent || oldValue == null)
    				e.value = value;
    			afterNodeAccess(e);
    			return oldValue;
    		}
    	}
    	++modCount;
    	if (++size > threshold)
    		resize(); // 当 HashMap 的 size 大于了临界值,需要调整数组的容量。注意:在数据扩容时,发现红黑树节点元素小于等于6个时会将红黑树转成链表
    	afterNodeInsertion(evict);
    	return null;
    }
    

3、 实操验证
通过上面的源码分析,现在是不是已经对 HashMap 的底层数据结构有了一个比较清晰的认识了呢?下面我们趁热打铁,通过一段测试代码来进一步观察 HashMap 的内部数据结构的变化。

@Test
public void hashMap() {
    Map<String, String> map = new HashMap<>(32); // 初始化容量为 64,感兴趣的朋友可以试试 16 和 32
    List<String> list = Arrays.asList("A646", "9C9B", "FAF4", "674C", "5DCD", "C504", "555F", "F164"); // 数组索引位置均为 9
    for (String key : list) { map.put(key, key); }
    map.put("440A", "440A"); // 添加该元素到HashMap时,会触发链表树化操作,treeifyBin(tab, hash);
    map.put("531D", "531D"); // 该key的索引位置也为 9,此时put的数据会在红黑树中维护, e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    // map.put("FDF1", "FDF1"); // 当初始化容量为16时,观察插入和不插入该元素的区别
    // 移除4个元素,此时树中还有 6 个节点
    map.remove("A646"); map.remove("9C9B"); map.remove("FAF4"); map.remove("674C");

    // 继续追加更多的元素,迫使HashMap进行扩容,注意后面添加的这些元素的索引位置都不是 9
    List<String> list2 = Arrays.asList(
            "A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X",
            "Y", "Z", "AA",  "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP", "AQ");
    for (String key : list2) { map.put(key, key); }
    map.put("AR", "AR"); // 再添加元素会触发HashMap扩容,扩容时发现数组索引 9 位置的树节点长度等于6,所以会将红黑树退化为链表
    System.out.println(map);
}

大家可以在编辑器中通过DeBug的方式一步一步的观察数据的变化情况。

HashMap扰动函数的实现原理

对象的 hashCode 值是int类型的,那么从理论上来说,我们可以直接使用 hashCode 值作为数组下标了,且不会出现碰撞。但是这个 hashCode 的取值范围是 [-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。

既然字符串的 hashCode 值不能直接作为下标使用,那么我们是不是可以让 hashCode 值与数据长度进行取模运算得到一个下标值呢?理论上是可行的,但是这会导致元素的位置不够散列,元素碰撞严重的问题。

所以在HashMap源码中不是直接使用 hashCode 值,而是对 hashCode 值进行了一次扰动计算。这样就可以在数组长度比较小的时候,也能保证 hashCode 值的高位和低位都参与到 hash 的计算中,增大了随机性。

在JDK 8 中,扰动函数就是通过 key 的 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16)。使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 把hashCode值右移16位,之后与原hashCode值做异或运算
}

下图以字符串“面试题”为例,展示了一个字符串进行扰动运算的过程。
在这里插入图片描述

HashMap 中 数组 table 的容量如何确定?

HashMap 中 table 的默认初始容量是16 (static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;),但是在初始化 HashMap 时,推荐指定初始值大小。最大限制是1<<30(static final int MAXIMUM_CAPACITY = 1 << 30)。

1、 指定初始化容量

当我们 new HashMap 并且指定初始化容量 initialCapacity 时,JDK会帮我们计算第一个大于 initialCapacity 的幂次方数。源码即推演过程如下:

/**
 * 取第一个大于 cap 的 2的幂次方数,如: 6 -> 8; 10 -> 16; 17 -> 32
 * @param cap
 * @return
 */
static final int tableSizeFor(int cap) {
    // 首先减一操作的目的是将入参降为一个小于2的幂次方数(如:8,16,32等)
    // 例如我们传入的10,对应的2进制为00000000 00000000 00000000 00001010
    int n = cap - 1;
    // 减后
    // 00000000 00000000 00000000 00001001  -> 9

    n |= n >>> 1;
    // 右移1位
    // 00000000 00000000 00000000 00000100
    // 之后再与自身相‘或’,也就是上面两个二进制相‘或’。‘或’运算:参加运算的两个值只要有一个为1,其值为1
    // 00000000 00000000 00000000 00001101
    n |= n >>> 2;
    // 再次右移2位
    // 00000000 00000000 00000000 00000011
    // 再次对上面两个二进制相‘或’
    // 00000000 00000000 00000000 00001111      // 将低位全部置为1,此时我们可以看到我们想要的效果了
    n |= n >>> 4;
    // 再次右移4位
    // 00000000 00000000 00000000 00000000
    // 再次对上面两个二进制相‘或’
    // 00000000 00000000 00000000 00001111
    n |= n >>> 8;
    // 运算同上
    n |= n >>> 16;
    // 运算同上

    int result = (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    // 之后对运算完的数字进行+1操作
    // 00000000 00000000 00000000 00010000      // +1之后变为了一个幂次方数
    return result;
}

那么为什么数组长度要保证为2的幂次方呢?

  • 只有当数组长度为2的幂次方时,h & (length-1)才等价于 h % length,并且位运算的效率远高于 hash 取模运算;
  • 如果 length 不是 2 的幂次方,比如 length 为 15,则 length - 1 为 14,对应的二进制为 1110,在与 h 的二进制做‘与’运算,最后一位都为 0 ,而 0001001101011001101101111101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率;

2、数组动态扩容
随着 HashMap 中元素数量的增加,当数组容量达到某个临界值时就需要进行动态扩容。

装载因子(static final float DEFAULT_LOAD_FACTOR = 0.75f;)的主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如 table 数组大小为 16,装载因子为 0.75 时,临界值就是12,即当 table 的实际大小超过 12 时,table 就需要动态扩容。

扩容时,会调用 resize() 方法,将 table 长度变为原来的两倍(注意是 table 长度,而不是临界值)。

HashMap数组扩容元素拆分问题

HasMap 数组扩容时最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程,在JDK7中需要重新计算哈希值,但在JDK8中已经进行了优化,不在重新计算哈希值,而是通过 (e.hash & oldCap) 是否等于 0 这个算法来确定元素在新数组中的位置。

  • 等于 0 时,则索引位置的值不变,即在新数组中的索引位置等于其在旧数组是的索引位置。我们将其记为低位链表区(lo开头 - low)
  • 不等于 0 时,则索引位置的值等于其在旧数组时的索引位置加上旧数组的长度。我们将其记为高位链表区(hi开头 - high)

1、(e.hash & oldCap) 算法的推导解析

  • 前置知识:
    a. e.hash 代表的是节点元素的 hash 值,该值是根据 key 的 hashCode值扰动计算后得到的。

    b. oldCap 为旧数组的数组长度,是2的n次幂的整数。那么其二进制表示就是1个1后面跟着n个0,如:1000 …… 00;并且2的n次幂的整数 - 1后,其二进制表示就是n个1,如:…… 1111

    c. 如果想要 e.hash & oldCap 的结果为 0,则e.hash 的二进制表示必须与对应的oldCap 的二进制中的 1 的位置为 0,其他位置可以为 0 或 1。

    d. 如果想要 e.hash & oldCap 的结果不为 0,则e.hash 的二进制表示必须与对应的oldCap 的二进制中的 1 的位置为 1,其他位置可以为 0 或 1。

  • 推导 (e.hash & oldCap) == 0
    a. 我们进行如下推算举例
    在这里插入图片描述

    b. 我们知道 数组长度 - 1(也就是 2的n次幂的整数 - 1)的二进制表示就是n个1
    在这里插入图片描述
        所以,通过(数组长度 - 1) & e.hash 计算元素在数组中的索引位置结果如下:
            在这里插入图片描述
        由上面的计算可以看出,真正能够影响计算结果的只有低3位(这是因为 e.hash 只有低3位是有效位)。可是两者的低3位完全一样都是1。故,(2 * oldCap - 1) 和 (oldCap - 1) 两者与 e.hash 进行 & 运算之后的结果一样。

    c. 综上,可以得出 (e.hash & oldCap) == 0时, 元素 e 在新旧数组中的索引位置不变

  • 推导(e.hash & oldCap) != 0
    a. 我们进行如下推算举例
    在这里插入图片描述

    b. 我们知道 数组长度 - 1(也就是 2的n次幂的整数 - 1)的二进制表示就是n个1
    在这里插入图片描述
        所以,通过(数组长度 - 1) & e.hash 计算元素在数组中的索引位置的结果如下:
            在这里插入图片描述
        同样由上面的计算可以看出,真正能够影响计算结果的只有从低到高数的第4位(这是因为(2 * oldCap - 1) 和 (oldCap - 1) 两者的二进制的低3位完全一样都是1)故,(2 * oldCap - 1) 和 (oldCap - 1) 两者与 e.hash 进行 & 运算之后的结果相差了oldCap。
    c. 综上,可以得出 (e.hash & oldCap) != 0时, 元素 e 在新数组中的索引位置等于其在旧数组中索引位置 + 旧数组的长度这个偏移量

HashMap中put方法的执行流程

put<k, v>方法是 HashMap 中比较核心的一个方法,在put<k, v>方法中,HashMap 需要经历初始化、存值、扩容、解决冲突等操作。流程复杂但设计精巧,因此它也常常出现在Java面试中。下面我们从宏观层面梳理一下put<k, v>方法的核心流程,更为细节的地方先不考虑。

1、核心流程步骤:

  1. 先对key进行hash操作,计算hash值;
  2. 检查数组 table 是否为空,如果为空则进行一次resize()操作,初始化一个数组;
  3. 根据hash值计算key在数组中的索引位置;
  4. 如果索引指定的位置值为空,则新建一个k-v的新节点;
  5. 如果索引指定的位置值不为空,则说明该位置的已经存在元素,也就是说“哈希冲突”出现了;
    5.1 判断key是否与索引位置中的首元素相等,若相等,说明key已存在,进行值覆盖操作,若不相等,则需要进行后续判断;
    5.2 判断索引位置的元素是否为treeNode,若是,则需要将该key插入的红黑树中;
    5.3 如果判断该索引位置的元素不是treeNode,说明这里存储的是链表结构,遍历链表并且链表中不存在与key相等的元素时,则新建一个k-v的新节点插入到链表尾部;
    5.4 插入完元素后,如果链表长度是否大于等于 8,则此时会调用treeifyBin(),将链表转成红黑树,注意,如果此时数组长度小于 64,只会扩容不会转红黑树;
  6. 完成插入动作后,HashMap 的 size 会加一,然后判断当前容量是否超过阈值,如果超过则会进行扩容操作;
  7. 在扩容操作中,会创建一个2倍的新数组,并将原数组中的元素拆分到新数组中,注意,如果在拆分时发现红黑树中的节点元素小于等于6,则该红黑树会转成链表。

2、put<k, v>方法流程图:
根据上面分析出的流程步骤,整理了一份流程图,方便大家理解。
在这里插入图片描述

(完)

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小瓦匠学编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值