【Java】HashMap 解析

205 阅读9分钟

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

前言

HashMap:根据 key 关键字,设置对应的值或获取对应的值。key 的算法是 hash

public class Test {
    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("1", "yyds");
        map.put("2", "tql");
        map.put("3", "xswl");
        map.get("1");
        map.get("2");
        map.get("3");
    }
}

其数据结构思考过程:

  1. 数组:只需要根据 keyhash 后成 hashKey,直接映射到数组中某个位置。
  2. 链表:哈希冲突之后呢?那就加个链表,把相同的 hashKey 的值链起来。查询性能 O(n)
  3. 红黑树:数据多后,链表性能差?那就换成红黑树,查询性能 O(logn)

哈希类集合的三个基本存储概念,如图:

数据结构:数组 + 链表 + 红黑树

2021-07-2714-12-05.png

JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。

下面分析 HashMap 的关键点,JDK1.8

1)成员变量解析

// 初始容量,默认容量:16,1 左移 4位,2 ^ 4
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量:2 ^ 30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化阈值:从链表转为树
static final int TREEIFY_THRESHOLD = 8;
// 不树化阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树化容量
static final int MIN_TREEIFY_CAPACITY = 64;


// 内部类:链表
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

参数:

  • 默认负载因子:0.75
  • 默认容量大小:16

即 12个人的会议,需要:12 / 0.75 = 16把椅子

因为 mod 16 比 mod 12的冲突概率小,且并不浪费资源

人数 > (椅子数量 × 负载因子) 时会进行扩容。调用resize,将容量扩充为原来的 2 倍。


2)Hash 算法

哈希碰撞的概率取决与 hashCode 计算方式 和 空间容量。

碰撞后,采用拉链法。

hash 算法,源码解析:

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

// 比如说:有一个 key 的 hash 值
// 原值      :1111 1111 1111 1111 1111 1010 0111 1100
// 右移 16 位: 0000 0000 0000 0000 1111 1111 1111 1111
// 异或后    : 1111 1111 1111 1111 0000 0101 1000 0011 

先右移16位再异或操作,为什么要这么做呢?

目的就是:尽可能减少 hash 冲突,进入数组的同一个位置。 让低16位包含:低16位 和 高16位的特征。

这里又引申出,那为什么不全位数比较呢?

  1. 大部分情况下,高16位差异不大。
  2. 比较操作,可能从低位开始,这样就能越快比较出不同。

3)寻址算法

还是直接进入 put(K key, V value) 方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
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;
    // 对应的 key 是否存在
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 不存在,则创建一个
        tab[i] = newNode(hash, key, value, null);
    else {
        // ... ...
    }
    // ... ...
}

如何计算? 答:(n - 1) & hash

  • n:默认 16
  • hashhash(key),是对 key 进行 hash 计算之后
// 举个栗子:
// 比如,hash:
1111 1111 1111 1111 0000 0101 1000 0011

// n - 1 = 15
0000 0000 0000 0000 0000 0000 0000 1111

// 那么 (n-1)&hash, 进行与操作,15 & hash:
// 转为 10进制之后,就是 3
0000 0000 0000 0000 0000 0000 0000 0011


// 那么 i = 3 ,就是最后寻址算法获取到的那个 hash值对应的数组的 index

总结寻址算法:

  • (n - 1) & hash : 确定数组里的一个位置。

取模运算,它的性能是比较差的,为了优化这个数组寻址的过程。

  • (n - 1) & hash:效果是跟 hashn 取模一样的,但是与运算的性能要比 hashn 取模要高很多

为什么呢?

这是一个数学问题:数组的长度会一直是 2 的 n 次方,只要它保持数组长度是 2 的 n 次方。 hashn 取模的效果 -> (n - 1) & hash 效果是一样的,后者性能更高。


4)hash冲突时的链表处理

如果hash冲突了呢?那如何处理呢?

先来看下源码:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
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;
    // 对应的 key 是否存在
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 不存在,则创建一个
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // hash 相同,key 也相同
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // ... ...
        
        // 进行旧值覆盖
        // 相同的key在进行value的覆盖
        if (e != null) { // existing mapping for key
            // oldValue 旧值
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                // value 新值,赋值新值
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // ... ...
}

举个栗子:

// 有两个操作:
map.put("1", "yyds");
map.put("2", "xswl");


// 那么 "yyds" 就是 oldValue
// 那么 "xswl" 就是 value
V oldValue = e.value;

// 说白了,就是新值覆盖旧值

如果出现大量 hash 冲突之后,某个位置挂着一个很长的链表,那遍历起来就特别痛苦。

所以,JDK 1.8 以后优化了这块,在链表长度达到 8 的时候,链表会转换成红黑树:时间复杂度变为 O(logn)

对应源码如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // ... ...
    
    else {
        // ... ...
        // 此时已经是一颗红黑树
        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);
                    // 如果 binCount >= 15
                    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;
            }
        }
        // ... ...
    }
    // ... ...
}

treeifyBin(tab, hash); 源码如下:

  1. 单链表 转换成 双向链表
  2. 双向链表 转换成 红黑树
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)
        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);
        // do-while 执行完之后,先是将单向链表转换为 TreeNode 类型的双向链表
        // 之后再变为红黑树
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

小结:先是挂链表,如果链表长度超过了 8,就将链表转换成红黑树


5)基于数组的扩容原理

HashMap 底层数据结构是:数据 + 链表 + 红黑树。

HashMap 扩容:2倍扩容 + rehash

扩容之后,之前的 hash 过的key,要重新hash。 每个 key-value 对,都会基于 keyhash值重新寻址找到新数组的位置

扩容是按照 2的倍数来的,源码如下:

static final int tableSizeFor(int cap) {
    // 目的是另找到的目标值大于或等于原值
    // 例如二进制1000,十进制数值为8。
    // 如果不对它减1而直接操作,将得到答案10000,即16。
    // 显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
    int n = cap - 1;
    // 先来假设n的二进制为01xxx...xxx
    // 对n右移1位:001xx...xxx,再位或:011xx...xxx
    // 对n右移2为:00011...xxx,再位或:01111...xxx
    // 此时前面已经有四个1了,再右移4位且位或可得8个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;
}

原先冲突的 key,在扩容之后,可能会在新的数组上分布在不同的位置:

collector-hashmap扩容.png


6)JDK 1.8的高性能 rehash 算法

JDK 1.8 以后,为了提升 rehash 这个过程的性能,不是简单的用 keyhash 值对新数组的长度进行取模。

而是采用位操作,举个栗子:

案例图解下: collector-hashmap-rehash.png

  1. 数组长度为 16时:
# 案例1
# 数组长度 n = 16,hash1 = hash(key1)
n - 1 0000 0000 0000 0000 0000 0000 0000 1111
hash1 1111 1111 1111 1111 0000 1111 0000 0101
# n - 1 与 hash1 进行与(&) 操作之后:
&结果  0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)

# 数组长度 n = 16,hash2 = hash(key2)
n - 1 0000 0000 0000 0000 0000 0000 0000 1111
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果  0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)

可以发现案例1中的两个 hash 出现了 hash 碰撞,这时候可以使用链表或者红黑树来解决。

  1. 数组扩容之后,数组长度为 32时:
# 案例2
# 数组长度 n = 32,hash1 = hash(key1)
n-1   0000 0000 0000 0000 0000 0000 0001 1111
hash1 1111 1111 1111 1111 0000 1111 0000 0101
&结果  0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)


# 数组长度 n = 32,hash2 = hash(key2)
n-1   0000 0000 0000 0000 0000 0000 0001 1111
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果  0000 0000 0000 0000 0000 0000 0001 0101 = 21(index = 21的位置)

通过案例2,发现 hash2 从原来 index = 5 变成 index = 21了。 通过规律发现,每次扩容之后,hash值变化如下:

  1. 要么保持原来位置不变(index不变)
  2. 要么 新index = 旧index + 旧数组长度oldCap,举个小栗子:index(21) = index(5) + oldCap(16)

总结扩容机制:

  1. 数组2倍扩容

  2. 重新寻址(rehash):hash & n - 1,判断二进制结果中是否多出一个bit的1

    • 如果没多,那么就是原来的 index

    • 如果多了出来,那么就是 index + oldCap

    通过这个方式,就避免了 rehash 的时候,用每个 hash 对新数组.length 取模,取模性能不高,位运算的性能比较高。