集合框架之 HashMap 源码分析

存储结构

以 Entry 类型的数组 table 作为存储结构,从 Entry 的 next 可以看出 Entry 是链表,即存储结构为数组加链表的形式。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
    
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    ......
}

当 Entry 的 hash 属性发生冲突时,采用拉链法解决冲突,且拉链法采用头插法(下面 put 方法时展开说)。

put 方法(重点)

先上 put 代码:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
    	// 初始化表
        inflateTable(threshold);
    }
    // 键为 null 单独处理
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    // 确定桶下标
    int i = indexFor(hash, table.length);
    // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 插入新键值对
    addEntry(hash, key, value, i);
    return null;
}

头插法就在这里

void addEntry(int hash, K key, V value, int bucketIndex) {
	// 扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
	// 创建新 Entry
    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 头插法,链表头部指向新的键值对 (Entry 的构造函数)
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

hashMap 扩容

JDK 1.7 (先扩容再插入)

1、插入键值对时判断是否需要扩容(和阀值 threshold 做比较);
2、保存旧数组,同时新建容量二倍的 table 数组;
3、转移旧数组的键值对到新数组;(下标重新计算、头插法)

JDK 1.8 (先插入再扩容,减少一次无效扩容)

与 JDK 1.7 的不同之处:
1、尾插法 ——> 头插法;
2、下标计算方法改变:新下标 = 原下标 / 原下标 + 原容量(通过新增加的二进制位是 0 还是 1 判断)

如何通过 hash 确定桶的下标

可以看到操作 Entry 之前总是要进行如下操作:

// 哈希算法
int hash = hash(key);
// 确定桶下标
int i = indexFor(hash, table.length);

那么如何确定下标呢?

static int indexFor(int h, int length) {
    return h & (length-1);
}

正常按照我的想法,确定元素在桶中的下标,我会选择 h % length,但是如果 length 是 2 的幂次则 h % length == h & (length - 1)
实例如下

令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x   : 00010000
x-1 : 00001111
令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010
这个性质和 y 对 x 取模效果是一样的:

y   : 10110010
x   : 00010000
y%x : 00000010

但是hashMap的容量就一定是 2 的幂次吗?答案是「是的」,hashMap 初始化时容量为 2 的幂次,此后每次容量扩大都以二倍的形式扩大容量,以此来保证 hashMap 的容量为 2 的幂次。

/**
     * Returns a power of two size for the given target capacity.
     */
    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;
    }

JDK 1.8 变化

1、从存储结构上采用数组 + 链表 + 红黑树的组合,当链表长度为 8 时会变成红黑树存储;
2、节点上 1.7 采用 Entry 而 1.8 中节点采用 Node + TreeNode;
3、尾插法转变为头插法;

问题补充

数组下标三联问 ——> 出处

1、为什么不直接采用经过 hashCode 处理的哈希码作为存储数组 table 的下标位置?
哈希码范围远大于存储数组 table 的长度,无法匹配这个空间,为解决这个问题引申出问题二;
2、为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
见上文「如何通过 hash 确定桶的下标」即取哈希码的低位,使用与运算代替取模运算来提高效率;
3、为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突。

文章参考(🙏)

https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%AE%B9%E5%99%A8.md#hashmap
https://blog.csdn.net/carson_ho/article/details/79373134

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值