万字深入理解 HashMap 源码,分析解读树化和非树化过程

HashMap

作者:CloudMissing

HashMap

写在前面

HashMap 是 java.util 包下的基于 <K,V> 键值对存储的数据结构。HashMap 是集合框架中的重要角色之一,实现了 Map 接口。

HashMap 对大数据量的元素插入有最优的解决方案,由 bins 数组 -> 链表 <=> 平衡树 但由于 LinkedHashMap 子类的存在,HashMap在插入、移除或者连接时,都会提供对应的钩子函数给 LinkedHashMap。

HashMap 通常被当作 分桶哈希表,使用数组来存放这些桶。在桶过大时,桶的内部对象会由 Node 演变为 TreeNode 排序树,类似于 TreeMap 的结构。TreeNodes 节点也会跟 Nodes 节点一样遍历,同时在数量过多时,提供了更快的便利。当桶变小时,还会由 TreeNode 变为 Node。

因为 HashMap 是线程不安全的,所以迭代器返回的都是快速-失败的:如果在创建迭代器后的任何时候对映射进行了结构修改,除了通过迭代器自己的 remove 方法之外的任何方式,迭代器都会抛出 ConcurrentModificationException。因此,面对并发修改,迭代器快速而干净地失败,而不是在未来不确定的时间冒出任意的、不确定的行为。
在这里插入图片描述

与 HashTable 的比较

HashMap 大致相当于 HashTable,但是二者也是有一些区别的。

  • 是否允许出现 NULL 值。HashMap 是允许 key 和 value 出现 NULL 值,HashTable 是不允许 key 和 value 出现 NULL 值

    tip :因为 HashTable 实现了 Dictionary 接口,字典的 key 和 value 必须不为 null

  • 是否线程安全。HashMap 是线程不安全的,非同步方法,当然可以使用 Collections.synchronizedMap(new HashMap(…)) 去实现同步。HashTable 是线程安全的,在 PUT 方法时,添加了 Synchonized 关键字

  • 扩容大小不同。HashMap 每次 resize 方法执行之后,旧的容量 左移一位 (newThr = oldThr << 1)就是新的容量。HashTable 每次 rehash 之后,旧的容量 左移一位加一(newCapacity = (oldCapacity << 1) + 1)为新的容量

  • 初始化容量不同。HashMap 的初始化大小为 16,HashTable 的初始化大小为 11。二者的初始化负载因子均为 0.75

静态属性

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * 用于调用含容量的构造参数时,判断入参是否大于最大容量
 * 入参必须是小于等于 2^30 的 2 的次幂
 * 为什么不是 1 << 31 呢?因为最开始的一位是代表符号位,不参与计算
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 用于对树节点的阈值,而非数组。当一个桶内链表的长度达到 8 时,再次插入一个相同散列的对象就会将桶结构演变为树结构。
 * 如果树的值小于等于 6 ,那么就会由树结构变换为桶结构(在每次的 remove 方法时),如果太小就执行非树化( TreeNode.untreeify() )方法
 * 为什么是 8 呢?
 * 首先一个 treeNode 的大小就普通 Node 节点的2倍,在 resize 的过程中,会重新散列结果。
 * 通过 p.hash & oldCap == 0 来确定扩容两倍之后,是在高位还是地位。因为扩容 2 倍,相当于左移一位
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 只会在 resize 的时候去使用,在 split 方法中,会去判断,如果当前的树节点小于 6,就会执行去树化方法,而非拆分为上树结构和下树结构
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 哈希表的最小树化容量。
 * 如果一个桶的最大长度达到了 TREEIFY_THRESHOLD,但是数组的长度小于 64,那么就会去扩容,然后重新散列,而非树化。
 */
static final int MIN_TREEIFY_CAPACITY = 64;

为什么最大化树阈值为8?

​ 首先一个 treeNode 的大小就普通 Node 节点的2倍,在 resize 的过程中,会重新散列结果。

​ 这个 key 值不能过大,链表的长度过大,也会影响查询速度。这个 key 也不能过小,过小的话,树化所带来的内存消耗也是不小的。假设 m 最大化树阈值为 k,也就是同一个 key 哈希冲突的最大值。问题就可以转换为简单的泊松分布,经过计算,是符合参数位 0.5 的泊松分布

​ 经过推算,当 k > 8 时,概率就无限接近于 0 ,可以说是忽略不计。

​ 当选择 8 时,既满足了最大化的链表长度,又找到了最小化的扩容或树化长度。(冲突导致地链表长度为 9 的概率几乎为0,如果有了就进行扩容,这样的代价是最小的)。

为什么由树转为链表的阈值为6?

/**
 * If the current tree appears to have too few nodes, the bin is converted back to a plain bin. (The test triggers somewhere between   	*	2 and 6 nodes, depending on tree structure).
**/

​ 测试阶段的最好效果是在 2 和 6 之间的某处,所以小于6时,就可以直接转为链表

为什么最小化树桶容量为64?

为了更好地解决扩容和树化的冲突,该值必须大于 4 * TREEIFY_THRESHOLD = 32, HashMap 的整个容量计算必须是二的倍数,所以最近的值为 64

构造方法

// 如果
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);
}

// 只有容量的构造参数
public HashMap(int initialCapacity) {
   
  	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

HashMap 在初始化的时候一定要给初始化容量,这样可以减少扩容带来的影响,因为每次扩容就要重新散列,如果数据太多的话,那么重新散列所带来的时间耗费也是巨大的。之所以在强调一下只是想介绍下构造方法中的 tableSizeFor 方法。

// 返回给定容量的最近的 2 的次幂数
static final int tableSizeFor(int cap) {
   
    int n = cap - 1;
  	/**
  	* 将 n 与它的右移 1 位、2 位、 4 位、8 位、16 位后的结果取余并加 1 返回
  	* 举个例子:加入初始化容量为 5(16 进制数据为 100 )
  	* 1、n | n >>> 1 = 100 | 010 = 110 = 6
  	* 2、n | n >>> 2 = 110 | 001 = 111 = 7
  	* 3、n | n >>> 4 = 111 | 000 = 111 = 7
  	* 4、n | n >>> 8 = 111 | 000 = 111 = 7
  	* 5、结果同上
  	*/
    n |= n >>> 1; // n = n | n >>> 1; 将 n 与 n >>> 1 (n 右移 1 位)
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
 		// 那么 n 的结果最后就为 7 + 1 = 8。也就是离 4 最近的 2 次幂
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

所以在初始化容量的时候,相信也有一定的策略,如果数据不会超过 8 ,那么初始化给 4 < n <= 8 即可。其他结果依此类推。

Node 节点

// 实现于 Map 的 Entry 接口
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
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值