深入浅出解析HashMap源码

转载自:https://huanglei.rocks/coding/inside-jdk-hashmap.html (该个人博客十分geek)

基于 OpenJDK1.8

    • 1 综述
      • 1.1 内部类和字段
        • 1.1.1 Node
        • 1.1.2 TreeNode
        • 1.1.3 table:Node []
        • 1.1.4 modCount:int
      • 1.2 底层数据结构
    • 2 插入
      • 2.1 流程综述
      • 2.2 hash()
      • 2.3 putVal()
    • 3 性能:容量和负载因子
      • 3.1 容量 Capacity
      • 3.2 负载因子 LoadFactor
    • 4 resize()的实现
    • 5 其他:entrySet

1 综述

1.1 内部类和字段

1.1.1 Node

实现了Map.Entry接口,代表链表状态下 HashMap 里面存放的一个元素。

static class Node implements Map.Entry {
    final int hash;
    final K key;
    V value;
    Node next;
    //...
}

Node当中有一个next字段,指向另一个Node实例。也就是说Node是链表的一个元素。

1.1.2 TreeNode

TreeNodeNode的一个子类,用于当链表升级为红黑树时存储 entry。

1.1.3 table:Node[]

节点元素的数组,即所谓的 bucket

1.1.4 modCount:int

HashMap 被修改的次数。多线程条件下可能某个线程迭代 map 的时候另一个线程修改了 map 的元素,可能导致数据的不一致。而迭代的线程可以通过在迭代开始和结束的时候的modCount来判断是否有另一个线程在这个过程中修改了数据,如果修改了则抛出ConcurrentModifictionException(详见java.util.HashMap#forEach)。

1.2 底层数据结构

HashMap 会根据当前 table 的大小和冲突情况,逐渐升级存储的数据结构,数组->链表->RBT。

一开始 HashMap 由一个 Entry 数组支撑,也就是table:Map.Entry[]table当中的每一个元素都是某个链表的头结点,结构如下:
深入浅出解析HashMap源码_第1张图片

  • 如果两个 key 的 hashCode 不同,那么这两个元素被放在table:Node[]数组的不同位置上
  • 如果两个 key 的 hashCode 相同,也就是发生了 hash 冲突,则两个元素被 append 到table:Node[]的相同位置的链表尾部

显然,hashCode 的设计直接关系到 HashMap 的查询效率。如果 hashcode 没有冲突,那么 HashMap 的查询效率是O(1),如果出现了 hash 冲突,那么 HashMap 的查询效率下降到O(N)

2 插入

2.1 流程综述

通过 key 的hashCode方法获取 hashCode,对 hashCode 调用HashMap.hash()方法使高位的 bits 分散到低位来,然后通过hash()的返回值决定当前的 entry 到底放在哪一个 bucket 当中(即table:Node[]的哪一个位置上)。

  • 如果算出来的槽位上本来没有元素,那么直接把这个 entry 放在这个位置上即可
  • 如果槽位上面已经是一个红黑树节点,那么把用 entry 构造新的 rbt 节点插入到这棵树上
  • 如果槽位上面是一个普通链表节点,那么把当前 entry append 到链表尾部。判断链表是否达到需要升级的阈值,如果达到阈值则将链表转换为红黑树
  • 如果插入之后的 map 的容量已经达到扩容阈值(capacity*loadfactor),那么对 map 进行扩容(HashMap#resize())

2.2 hash()

HashMap#put()方法实际上是对HashMap#putVal()方法的调用。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

首先通过HashMap.hash()方法来计算 hash 值(整数)。

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

hash()方法读取 key 的 hashCode,并且把高 16 位与第 16 位 XOR。

这里 XOR 主要是为了解决这种场景:如果某些 hashCode 只有高 16 位不同而低 16 位全部一致,那么这些 hashCode 永远都会冲突从而降低效率。典型的例子就是以一组连续近似相同的float为 key 的数据。比如下面这个例子:
2.123111112f: 0x4007E10D
3.123111113f: 0x4047E10D
4.123111114f: 0x4083F087
5.123111115f: 0x40A3F087
6.123111116f: 0x40C3F087
7.123111117f: 0x40E3F087
可见低 16 位冲突比较严重。移位+XOR 是速度、实现难度等的一种折中实现。

2.3 putVal()

putVal()是一个插入数据的多功能实现,执行具体的插入操作,具有很多可选参数,这里不进行解释。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    Node[] tab; //bucket
    Node p;    //待插入的 entry
    int n, i;       //
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;//当前为空,则创建 table
    if ((p = tab[i = (n - 1) & hash]) == null)//bucket 没有元素占用(无冲突)
        tab[i] = newNode(hash, key, value, null);//直接占用当前 bucket
    else {//出现冲突,p 为 bucket 上面已有的元素
        Node e; K k;
        //待插入的数据和原来的数据的 key 完全一致
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)//如果原来 bucket 里面就是 rbt 的根节点
            //那么构造 rbt 节点插入到 rbt 当中
            e = ((TreeNode)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);
                    //判断是否到升级为 rbt 的阈值
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);//将链表升级为 rbt
                    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;//增加修改计数(modification count)用于检测并发修改
    if (++size > threshold)//如果达到扩容阈值
        resize();//进行扩容
    afterNodeInsertion(evict);
    return null;
}

3 性能:容量和负载因子

3.1 容量 Capacity

Capacity 是table:Node[]数组的长度。

HashMap 会根据存放的数据数量进行扩容,但是不管怎么扩容其容量都是 2^n。这个设计是为了计算通过 hashCode 计算 bucket 槽位的时候方便。
HashMap 是通过tab[i = (n - 1) & hash来计算当前 Entry 到底是放在table:Node[]的哪一个 bucket 当中的,而老版本的 JDK 使用tab[i = hash % n,只有当 n 为 2 的整数次幂的时候,这两个计算((n - 1) & hashhash%n)才能等价。使用按位与&取代取模%主要是因为&一般是单周期指令而%需要用到除法器,速度相差好几倍。

3.2 负载因子 LoadFactor

负载因子是 HashMap 元素总数与 capacity 的比值,用于衡量当前的 bucket table 装了多满。
高负载因子可以增大空间利用率,但是会降低查询和插入的效率;低负载因子浪费空间而查询插入效率高。
初始情况下负载因子为 0.75。
当 HashMap 的容量大于 Capacity*LoadFactor 的时候,就会对table:Node[]进行扩容:

//OpenJDK1.8 HashMap.java:662
if (++size > threshold)//threshold 即为 capacity*loadfactor,每次 resize 的时候重新计算
    resize();

负载因子本身就是在控件和时间之间的折衷。当我使用较小的负载因子时,虽然降低了冲突的可能性,使得单个链表的长度减小了,加快了访问和更新的速度,但是它占用了更多的控件,使得数组中的大部分控件没有得到利用,元素分布比较稀疏,同时由于Map频繁的调整大小,可能会降低性能。但是如果负载因子过大,会使得元素分布比较紧凑,导致产生冲突的可能性加大,从而访问、更新速度较慢。所以我们一般推荐不更改负载因子的值,采用默认值0.75.

4 resize()的实现

由于table:Node[]的长度永远都是 2 的整数次幂,因此 resize 之后的元素所在的槽位要么是在原地,要么是在移动 2^k 的位置上。
比如一开始容量为 16(2^4),也就是只有hash()h^(h>>>16))运算之后的值的最低 4 位决定到底将 Entry 放在table:Node[]数组的哪一个位置,即掩码为0x0000000F
扩容之后,容量变为16>>1=32,hash()后的值的低 5 位参与计算槽位,掩码为0x0000001F

  • 如果某个 key 的 hash 之后的值为0xAABBCC0A,则扩容之前的槽位为0xAABBCC0A & 0x0000000F = 0x0000000A=10(dec),扩容之后的槽位为0xAABBCC0A & 0x0000001F = 0x0000000A=10(dec),可见对于这个 key,扩容前后没有变化;
  • 相反的,如果某个 key 的 hash 之后的值为0xAABBCCDA则扩容之前的槽位为0xAABBCCDA & 0x0000000F = 0x0000000A=10(dec),扩容之后的槽位为0xAABBCCDA & 0x0000001F = 0x0000001A = 26(dec),即原来的槽位偏移了 16。

这个设计的特点在于,扩容的时候,不需要重新计算槽位,只需要知道对于原来的table:Node[]里面的每一个 Entry(Node),它的 hash 值(即hash()运算之后的值)按位与上扩容之后的 mask 上面新增的那一个 bit(这个 bit 用 16 进制表示出来正好就是旧的table的 capacity,比如 16 扩容到 32,mask 新增的那一个 bit 即是0x00000010(dec 16)),其值为 0 还是 1。如果按位与的值为 0,那么这个 entry 在新的table里的下标与原来相同;如果按位与的结果为 1,那么其在新table的下标等于原来的下标+扩容前的 capacity。

for each Node in oldTable:
    if Node.hash & oldCapacity == 0:
        newTable[Node 在 oldTable 当中的下标]= Node //保留在 newTable 的原位
    else:
        newTable[Node 在 oldTable 当中的下标 + oldCapacity] = Node

5 其他:entrySet

一个 HashMap 可以有两种视图,一种是 map 视图,也就是键值对;另一种是 set 视图,即把 hashmap 看做若干个Entry元素组成的 set。HashMapentrySet成员变量就是用来缓存当前 hashmap 的 set 视图的。entrySet并不存储任何数据,它只是返回一个迭代器(java.util.HashMap.EntrySet#iterator) 或者对 HashMap 的每一个元素执行某个Consumer>操作而已(java.util.HashMap.EntrySet#forEach

你可能感兴趣的:(Java,个人总结)