【Java】HashMap 源码分析
HashMap 结构
HashMap 继承自 Map 接口,特点如下:
- 存储数据是无序的(因为 hash 值是无序的)
- 插入和查询效率很高,时间复杂度接近 O(1)
- 线程不安全(若想要保证线程安全,可以使用
Collections
的synchronizedMap
,或者ConcurrentHashMap
)
HashMap 的结构为数组 + 链表 + 红黑树(JDK 1.8):
红黑树是在 JDK1.8 版本才引入的,目的是加快链表的查询效率
从图中可以看出,HashMap 的底层是一个哈希桶数组 Node<K, V>[] table
,数组内存储的是 Node
结构的数据。
数组中每一个桶(bucket)内可以存储多个 Node
:
- JDK 1.7:以链表形式存储
- JDK 1.8:如果链表很长,那查询效率便会降低,所以自 JDK 1.8 开始便引入了红黑树结构,即当某一个桶内的链表长度超过 8 的时候,链表便会转为红黑树。另外,当链表长度小于 6 的时候,会从红黑树转为链表。
HashMap 采用链地址法,即在数组的每个索引处都是一个链表结构,这样就可以有效解决 Hash 冲突(Hash 碰撞)问题。
HashMap 重要字段
静态常量值:
1 | // 默认的初始容量为 16 (PS:aka 应该是 as know as) |
以上几个字段的意义:
DEFAULT_INITIAL_CAPACITY
:默认的初始容量为 16,代表默认的数组初始化长度为 16(桶的个数)。可以通过构造方法传参自定义该值,而无论自定义传入的值是多少,HashMap 在初始化时都会先将容量值向上取最接近该值的 2 的幂次方值(例如用户输入 20,将被修改为 32)DEFAULT_LOAD_FACTOR
:默认的负载因子为 0.75
HashMap 中没有单独维护一个
capacity
的字段,而是在每次resize()
时根据之前的数组长度更新扩容后的长度(长度乘以 2)。如果没有在构造方法里自定义指定initialCapacity
,则初始化数组时长度会使用默认的 16,否则长度就为最接近自定义initialCapacity
值的 2 的幂次方值
TREEIFY_THRESHOLD
:当链表长度为 8 的时候会转为红黑树。但是前提是桶内数据量大于MIN_TREEIFY_CAPACITY
(默认为 64)。意味着如果数据量比较小的时候,优先扩容更加高效UNTREEIFY_THRESHOLD
:当链表长度为 6 的时候会从红黑树转为链表
成员变量:
1 | // Map 中存储的数据量,即 key-value 键值对的数量 |
size
:HashMap 中存储的数据量,即 key-value 键值对的数量。该变量将在每次插入一个数据后执行size++
modCount
:HashMap 内部结构发生变化的次数,即新增、删除数据的时候都会记录。用于threshold
:重要。代表当前长度的数组下最多能容纳的数据量,即 key-value 键值对的数量。当数组内总数据量大于该值时,将执行扩容resize()
。计算公式:threshold = length * loadFactor
,其中length
为当前数组长度(桶的个数)loadFactor
:重要。实际负载因子。默认值为 0.75,是基于时间和空间考虑而得的一个比较平衡的点
注意区分数组长度
length
和阈值threshold
的区别
Node<K,V>
JDK 1.7 的基本结构为
Entry<K,V>
。JDK 1.8 后改为Node<K,V>
(因为引入了红黑树)
Node<K,V>
是 HashMap 的一个静态内部类。HashMap 底层是一个 Node[] table
。Node 实现了 Entry
接口,所以,Node 本质上就是一个 Key-Value 数据结构。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
构造方法
该类共有四个构造方法:
HashMap()
构造一个空的 HashMap,初始容量为 16,负载因子为 0.75。
1 | // 构造一个空的 HashMap,初始容量为 16,负载因子为默认值 0.75 |
HashMap(int initialCapacity)
构造一个空的 HashMap,并指定初始化容量,负载因子为默认的 0.75。构造函数内部会调用下文紧接着讲到的第三种构造函数。
1 | // 构造一个空的 HashMap,并指定初始化容量,负载因子采用默认的 0.75 |
HashMap(int initialCapacity, float loadFactor)
构造一个空的 HashMap,并指定初始化容量,指定负载因子。
1 | // 构造一个空的 HashMap,并指定初始化容量,指定负载因子 |
构造函数中会进行一系列的参数判断,并且会进行初始化操作:
- 如果初始容量小于 0,或者负载因子小于 0 或不为数字时,会抛出
IllegalArgumentException
异常 - 如果初始容量大于最大值(2^30),则会使用最大容量
- 设置
threshold
,直接调用tableSizeFor()
方法,该方法会返回一个大于等于指定容量的 2 的幂次方的整数,例如传入 6,则会返回 8;传入 20,则会返回 32
tableSizeFor()
方法的详细解释见下文。另外,直接将得到的值赋给threshold
,难道不应该是这样的操作吗:this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
,其实不然,初始化的动作放在了 put 操作中。
HashMap(Map<? extends K, ? extends V> m)
构造一个非空的 HashMap,保证初始化容量能够完全容下传进来的 Map,另外,负载因子使用的是默认值 0.75。
1 | // 构造一个非空的 HashMap,指定了默认的负载因子 0.75 |
putMapEntries()
方法是将传递进来的 Map 中的数据全都存入到当前的 HashMap 中去,方法的详解见下文。
HashMap 关键方法
tableSizeFor(int cap)
作用:计算数组的大小。该方法的作用是返回一个大于等于传入的参数的数值,并且返回值会满足以下两点:
- 返回值是 2 的幂次方
- 返回值是最接近传入的参数的值
该方法的设计非常巧妙:
1 | static final int tableSizeFor(int cap) { |
方法内使用了大量的 “或运算”和 “右移”操作,目的是保证从最高位起的每个 bit 都是 1。
- 首行
int n = cap - 1;
的作用,是为了防止传入的参数本身就是一个 2 的幂次方,否则会返回两倍于参数的值; n |= n >>> 1;
的作用,是保证倒数第二个高位也是 1,下面的代码类似。- 最后一行之前,得到的数类似 0000 1111 这种从第一个高位起全是 1,这样只要加了 1,则返回的数值必然是 2 的幂次方。
一种比较朴素的实现方法是:从左到右遍历 cap
,直到遇到第一个 1。那么想要的结果就是:该位的更高一位为 1,其余为全为 0 的数字。例如:
1 | // 原数字 |
putMapEntries(Map<? extends K, ? extends V> m, boolean evict)
该方法是将参数 m 中的所有数据存入到当前的 HashMap 中去,在上文提到的第四种构造函数便调用了此方法。
1 | // 将参数 m 中的所有元素存入到当前的 HashMap 中去 |
hash(Object key)
HashMap 中的 hash()
方法将调用 key 的 hashCode()
方法得到该对象的哈希值 h,然后将 h 的高 16 位与低 16位进行异或运算。
这样做的目的是:开发人员自定义的 hashCode()
方法得到的哈希值可能不够散列,就会导致数组中某些桶上的节点过多影响性能。因此 HashMap 额外将高位和低位进行混合运算,低位中掺杂了高位的信息,最后生成的 hash 值的随机性会增大(更加散列),从而有效降低哈希冲突的概率。
1 | static final int hash(Object key) { |
在计算得到处理后的哈希值后,后续还需要进行一步“取余”操作,将哈希值均匀分布在数组长度范围内。即将原始范围很大的哈希值映射到数组长度 [0, length - 1]
范围内,从而决定当前 key 放在哪个桶里。
最常用的实现方式是取模运算:hash % length
,但是 HashMap 却用了更加巧妙的方式:
1 | hash & (length - 1); |
其中,length
为当前数组的长度。之所以这样写可以实现该效果是因为: HashMap 会强制将数组长度设置为 2 的幂次方,从而这里的 length 的二进制形式只会有一位为 1,其余位都是 0,那么将其减一后,即可得到低位全是 1,高位全是 0 的形式,那么这样再和 hash
取与运算,就相当于把 hash
的高位给截断了(设置为 0),只留下低位的值,从而将数字的范围限制在了 [0, length - 1]
。这也是 capacity
/length
要设置为 2 的幂次方的一个原因,只有 length
为 2 的幂次方才可以用这种方式加速运算。
这种方式计算效率显然高于取余操作。
put(K key, V value)
此处介绍的是 putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
,因为 put()
其实就是直接调用的 putVal()
。
JDK 1.8 源码:
1 | public V put(K key, V value) { |
get(Object key)
此处介绍的是 getNode(int hash, Object key
,因为 get()
其实就是直接调用的 getNode()
。get()
方法也是比较简单的,就是根据 key 获取 table 的索引,然后再分情况查找拥有相同 key 的 Node。
JDK 1.8 源码
1 | public V get(Object key) { |
resize()
resize()
是一个很重要的方法,作用是扩容,从而使得 HashMap 可以存储更多的数据。因为当我们不断向 HashMap 中添加数据时,它总会超过允许存储的数据量上限,所以必然会经历 扩容 这一步操作,但是 HashMap 底层是一个数组,我们都知道数组是无法增大容量的,所以 resize 的过程其实就是新建一个更大容量的数组来存储当前 HashMap 中的数据。
JDK 1.8 源码:
1 | final Node<K,V>[] resize() { |
其中,如果桶内节点是链表结构,则遍历链表内每一个节点,使用尾插法移动扩容后的节点位置,并判断该节点的哈希值在原容量的最高位的值是 1 还是 0。这里是利用了容量为 2 的幂次方的特点快速进行计算(也是容量强制修改为 2 的幂次方的原因),节省扩容时重新计算桶位置编号的时间,从而加速扩容速度,并且使得扩容后节点的分布更加散列均匀。
具体原理为:原容量 oldCap
肯定为 2 的幂次方,意味着其只在某一位为 1,其余都是 0。那么在计算 (e.hash & oldCap)
时,就可以利用该特性快速得到节点 e
的 hash 值(保存在 Node
中) 在该位的值是 1 还是 0。例如:
1 | // e.hash |
此时发现 e.hash
在该位置上的值为 1。这一位为 1 代表着在扩容后 e
对应的桶编号将变化(若为 0 则不变)。
在扩容后,新的容量 newCap
变为了原来的两倍。此时若再采用前面介绍过的方法 e.hash & (newCap - 1)
计算新的桶编号时,将因为该位值为 1 而使得其桶编号增大 oldCap
,变为 j + oldCap
(j
为旧编号)。这样就不再需要使用 e.hash & (newCap - 1)
公式重新计算每一个节点的新桶编号,而是可以直接根据 (e.hash & oldCap)
的值是 1 还是 0,决定该节点在扩容后处于原位置还是新位置 j + oldCap
。
可以看出,这种方式一方面节省了 rehash 的计算时间,另一方面也保证了扩容后的节点分布是散列均匀的。这也是为什么容量要设置为 2 的幂次方的原因。
HashMap 工作流程
1. 新建 HashMap<K,V>
- 执行
new HashMap(capacity, loadFactor)
时没有立刻创建Node
数组(懒加载),而是在第一次调用put()
方法时再初始化一个长度默认为 16 的Node
数组 - 如果构造方法指定了容量
capacity
和负载因子loadFactor
,则构造方法里会先调用 [tableSizeFor(int cap)](#tableSizeFor(int cap)) 方法,将传入的capacity
向上取最接近的 2 的幂次方值,作为初始化的容量值。 - 否则默认的初始化容量为 16,负载因子
loadFactor
为 0.75
2. put(key, value)
- 首先判断 table 是否为空或
table.length == 0
:如果是,说明该数组此时是第一次使用,需要进行初始化,创建指定长度的Node
数组。如果不是,则不需要再做初始化 - 根据传入的
key
计算 hash 值 hash(Object key):- 首先调用该对象的
hashCode()
方法,得到其自定义的哈希值 h - 然后将 h 的高 16 位与低 16位进行异或运算(为了让该 hash 更加散列,防止用户自定义的
hashCode()
不够散列) - 将异或后的 h 值进行
h & (length - 1)
操作(效果等效于取余数,效率更高),截取出 h 的低位数字,作为key
应该进入的桶的编号i
- 首先调用该对象的
- 在
Node
数组中定位到该编号的桶,判断table[i]
是否为 null:- 如果为 null,说明该桶是空的,没有任何节点。此时可以直接插入:令
table[i] = new Node(key, value)
- 如果不为 null,说明该桶不为空,判断:
- 如果
table[i]
位置存储的节点内保存的k
等于key
(引用地址相等或equals()
成立),则覆盖该位置的节点的v
为传入的value
。然后方法直接返回旧值return oldValue
,不再进行后面的处理。如果不相等: - 如果
table[i]
的类型为TreeNode
,则说明当前桶内存储结构为红黑树。则按照红黑树的插入方法,直接插入当前key
、value
- 如果
table[i]
的类型为普通Node
,则说明当前桶内存储结构为链表。遍历该链表的每个节点(遍历过程中,统计该桶内的节点个数binCount
)- 如果发现某个节点的
k
等于key
,则跳出遍历,将该节点的v
修改为当前value
。然后方法直接返回旧值return oldValue
,不再进行后面的处理 - 如果遍历到链表尾部还没发现一个等于
key
的节点,则创建一个节点new Node(key, value)
,并将该节点链接到链表的尾部,作为最后一个节点(尾插法) - 完成遍历后,如果
binCount
个数大于 8,且所有节点的个数大于 64,则将该链表转换为红黑树
- 如果发现某个节点的
- 如果
- 如果为 null,说明该桶是空的,没有任何节点。此时可以直接插入:令
- 只有插入新节点后才会来到这里(如果是覆盖原节点值,则之前已经
return oldValue
)。 - 节点插入完成后,当前数组的节点总数加一
++size
,并判断增加后的节点总数是否大于阈值++size > threshold
(threshold = length * loadFactor
)。如果大于,则需要进行扩容 resize():- 计算扩容后的数组大小:将原数组长度乘以 2(仍然是 2 的幂次方)
- 遍历数组中的每一个桶:
- 如果桶内第一个节点是当前桶内唯一一个节点,则可以直接 rehash 赋值到新的数组:
newTab[e.hash & (newCap - 1)] = e
(借助容量newCap
为 2 的幂次方的特点) - 如果桶内节点是红黑树结构
TreeNode
,则调用((TreeNode<K,V>)e).split(this, newTab, j, oldCap)
- 如果桶内节点是链表结构
Node
,则遍历链表内每一个节点,使用尾插法移动扩容后的节点位置,并判断该节点的哈希值在原容量的最高位的值是 1 还是 0(利用容量为 2 的幂次方的特点快速进行计算):(e.hash & oldCap) == 0
:表明扩容后不需要移动桶编号,还在原编号位置,即newTab[j] = loHead
(e.hash & oldCap) == 1
:表明扩容后需要移动桶编号,并且移动后的值为 【原值 + 原容量】:j + oldCap
,即newTab[j + oldCap] = hiHead
- 最后分别设置链表的尾部为 null
- 如果桶内第一个节点是当前桶内唯一一个节点,则可以直接 rehash 赋值到新的数组:
- 记录 HashMap 结构发生变化的次数
++modCount
,fail-fast 机制将借由该值判断迭代过程中该哈希表是否被其他线程修改过,若修改过就抛出ConcurrentModificationException
异常 - 返回 null(表明是新增而非覆盖)
如果覆盖则返回旧值,如果新增则返回 null
流程图
3. get(key)
get(key)
方法比较简单,就是重复上面的部分操作:先计算 hash 值确定桶索引 i
,然后根据该桶内节点的类型是红黑树还是链表进行遍历,直到获取到对应的 value
或返回 null(没有找到)
HashMap 1.7 VS 1.8
JDK 1.7 版本的 HashMap:
- 元素类型为
Entry
- 【数组 + 链表】结构
- 当插入新元素时,采用头插法:新元素插入到
Entry<K,V>
数组中某个桶上table[i]
,原本的元素以链表形式连接在新元素的next
上(高并发下扩容时可能出现循环移动链表节点问题,导致 CPU 运行 100%) - 扩容后,链表中的先后顺序颠倒,在头部的节点被保存到了尾部(因为是头插法),在高并发下可能出现**环形链表死循环(循环引用)**问题,导致 CPU 运行 100%
JDK 1.8 版本的 HashMap:
- 元素类型为
Node
- 【数组 + 链表 + 红黑树】结构
- 当插入新元素时,采用尾插法:新元素插入到
Node<K,V>
数组中某个桶上table[i]
时,将先遍历该桶内链表的节点个数,然后将新元素插入到最后一个节点的next
上 - 当插入新元素时发现某个桶内的节点个数大于 8 个,且数组内总节点个数大于 64 时,会将该桶内的节点从链表形式转为红黑树形式。当之后发现红黑树节点个数小于 6 或不平衡时,就会退化回链表结构
- 扩容后,链表中的先后顺序保持不变,在头部的节点仍然在头部(因为是尾插法)。高并发下不会有环形链表死循环问题。
在并发修改时,两个版本都会出现数据被覆盖的情况。即第一个线程刚在某个桶上创建了节点,就被第二个线程修改了。
HashMap 线程不安全
Jdk7的问题:第一个线程先完成了扩容。但是第二个线程在切换前还拿着扩容后的两个entry的引用。e和next,他还会继续移动的,最终可能会出现循环链表,一直把cpu资源耗尽