深入浅出 JAVA 之集合 - HashMap
文章目录
!版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。
系统环境
- JDK 版本: OpenJDK 8
参考地址:
- Java 8系列之重新认识HashMap
- HashMap 源码详细分析(JDK1.8)
- 拿下面试!HashMap源码解析!!
- 红黑树详细分析
- HashMap 相关面试题及其解答
- HashMap与ConcurrentHashMap
- 深度解析HashMap高频面试及底层实现结构
- HashMap 源码解析
一、HashMap 概述
HashMap 中文翻译过来就是 哈希映射
,最早出现在 JDK1.2 版本中,它是一个基于哈希表的 Map 接口的实现,也是一个用于存储基于 Key-Value
键值对的集合,提供了多种用于操作键值对的方法。不过在 HashMap 集合中,存储的数据并不能保证存储顺序,特别是它不保证该顺序恒久不变。
除此之外,在 HashMap 集合中的每个键值对数据,都分散存储在一个数组 table
中,这个数组就是 HashMap 的主干,其也称为哈希表,大部分的对集合操作都是围绕这个数组展开的。
二、HashMap 特性
HashMap 集合存在很多特性,这里对部分特性进行总结,如下:
- ① HashMap 实现了 Map 接口中的全部的方法。
- ② HashMap 由数组、链表、红黑树组成 (JDK 8 版本)。
- ③ HashMap 中的键 Key 使用 Set 存储,所以 Key 是不允许重复。
- ④ HashMap 是非线程安全的,在多线程环境下作为共享变量使用,可能会存在线程安全问题。
- ⑤ HashMap 允许插入 null 键和 null 值,但是 null 键只允许存在一个,而且只会存在数组下标 0 的位置。
- ⑥ HashMap 中存储的键值对数据是无序的,而且顺序会不定时改变,每次扩容后都会重新哈希,这就导致哈希表里的元素是没有顺序,会随时变化。
- ⑦ HashMap 中判断俩个 Key 是否相同,是根据 key 所属的类中的 hashCode 与 equals 方法进行判断的,所以插入集合中的 Key 需要重写这两个方法。
- ⑧ HashMap 中的默认负载因子是 0.75,当集合大小 (size) 达到当前集合容量的 75% 后,集合就会进行扩容操作,将容量扩容为原先容量的 2 倍,但是容量最大不能超过 2^30。
- ⑨ HashMap 的初始容量大小为 0,当第一次执行插入数据,使用 put() 方法插入数据时会进行扩容操作:
- 如果创建 Map 集合时没有指定初始容量,则会将容量扩容为默认的初始容量值 16;
- 如果创建 Map 集合时有指定初始容量,则会将容量扩容为 ≥ 指定容量相近的 2 的次幂方值;
- ⑩ HashMap 中使用链表和红黑树解决哈希冲突:
- 当数组下标桶中的链表长度 ≥ 8,且集合容量 ≥ 64 时,会将桶关联的链表转换为红黑树;
- 当数组下标桶中的链表长度 ≤ 6 时,会将桶关联的红黑树还原为链表;
三、哈希冲突概念
3.1 哈希冲突是什么
在 HashMap 集合中存储 Key-Value
数据时,需要经过一些计算来确定数据要存储在哈希表 table
中的具体位置。一般情况下每个 Key
所在的下标位置是不同的,但是有些 Key
在经过计算后,都得出了相同的哈希值,这样就会导致计算后的结果为同一个数组下标位置,导致不能确定这个数组下标位置到底存储哪个 Key
的数据,相当于产生了冲突,而这个冲突就是我们常说的 哈希冲突
。
3.2 如何解决哈希冲突
解决哈希冲突常用的方法有两种,分别为 链表法
和 开放寻址法
:
- 链表法: 链表法是指,将经过哈希计算产生哈希冲突的数据,构建成一个链表进行存储,这样每次在经过哈希计算产生哈希冲突时,只要将该待插入的数据,加入到冲突位置关联的链表中,就可以解决哈希冲突。
- 开放寻址法: 开放寻址法是指,当经过哈希计算得到某个位置时,发现该位置已经存在了数据,产生了哈希冲突,这时就从该位置开始,继续向后查找下一个可用的位置,找到后就直接将数据插入到该位置中,就可以解决哈希冲突;
在 HashMap 中,主要是使用 链表法
来解决哈希冲突的,不过又和常用的链表法不同,因为在 HashMap 中不仅仅使用了链表,而且还使用了红黑树。使用红黑树的主要目的是为了解决哈希表中的数据过多,哈希冲突严重时,尽可能的不会影响 HashMap 集合查找数据的效率。
注:
- HashMap 存储数据时,需要经过哈希运算、高位运算、取模运算后,才能确定 Key 在哈希表中的具体存储位置。
- 链表的查询时间复杂度为 O(n),红黑树查找时间复杂度为 O(logn),在数据量大时使用红黑树结构存储数据,能够保存数据的查询效率。
四、HashMap 常用方法
4.1 HashMap 常用方法简介
HashMap 常用的方法如下:
- put(K key, V value): 将指定的 key-value 键值对存储到 HashMap 中,如果 key 已经存在,则会用新的 value 覆盖旧的 value。
- get(Object key): 根据指定的 key 获取对应的 value,如果 key 不存在,则返回 null。
- remove(Object key): 根据指定的 key 删除对应的键值对,如果 key 不存在,则不执行任何操作。
- size(): 返回 HashMap 中存储的键值对个数。
- clear(): 清空 HashMap 中所有的键值对。
- containsKey(Object key): 判断 HashMap 是否包含指定的 key。
- containsValue(Object value): 判断 HashMap 是否包含指定的 value。
- keySet(): 返回 HashMap 中所有 key 的集合。
- values(): 返回 HashMap 中所有 value 的集合。
- entrySet(): 返回 HashMap 中所有键值对的集合。
4.2 HashMap 四种遍历方法示例
方法1: 迭代器
使用 Iterator 进行循环遍历,迭代器可以保证在遍历过程中不会出现 ConcurrentModificationException 异常。
1Map<String, String> map = new HashMap<>();
2Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
3while (iterator.hasNext()) {
4 Map.Entry<String, String> entry = iterator.next();
5 System.out.println("key=" + entry.getKey());
6 System.out.println("value=" + entry.getValue());
7}
方法2: for-each 循环
使用 for-each 循环遍历,也可以保证在遍历过程中不会出现 ConcurrentModificationException 异常。
1Map<String, String> map = new HashMap<>();
2for (Map.Entry<String, String> entry : map.entrySet()) {
3 System.out.println("key=" + entry.getKey());
4 System.out.println("value=" + entry.getValue());
5}
方法3: 键集合 keySet 循环
使用 keySet() 方法获取 HashMap 的键集合,再使用 for-each 循环遍历键集合,通过键获取对应的值。这种方式不建议使用,因为每次循环都需要通过键获取对应的值,如果键集合很大,将导致性能问题。
1Map<String, String> map = new HashMap<>();
2for (String key : map.keySet()) {
3 System.out.println("key=" + key);
4 System.out.println("value=" + map.get(key));
5}
方法4: 值集合 values 循环
使用 values() 方法获取 HashMap 的值集合,再使用 For-each 循环遍历值集合,这种方式同样不建议使用,因为无法获取键,无法对键和值同时进行操作。
1Map<String, String> map = new HashMap<>();
2for (String value : map.values()) {
3 System.out.println("value=" + value);
4}
五、HashMap 源码分析-底层结构
5.1 HashMap 继承关系和接口
要了解一个类,先要了解这个类的结构,先来看一下 HashMap 的结构:
1public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
2 // 略
3}
Map 接口
Map 接口是 Map 集合的接口定义,其内部定义了 Key-Value 键值对的标准方法。在 Map 中每个键都是唯一的,不会重复,每个键都对应一个值。Java 中许多类都实现了 Map 接口,其中包括常见的 HashMap、TreeMap 和 LinkedHashMap 等。
在 Map 接口中定义了 Map 集合需要的一些常见方法,如 put()
、remove()
、values()
、clear()
、containsKey()
、containsValue()
、entrySet()
、isEmpty()
等等.
Map 的实现类根据其内部实现方式的不同,实现了不同的性质和特点。其中 HashMap 是最常用的实现类之一,它实现了快速查找,并支持 null 键和 null 值。
AbstractMap 类
AbstractMap 是 Map 接口的一个抽象类,它提供了一些实现 Map 接口的通用方法,其余方法大部分是 abstract 方法,需要由继承 AbstractMap 的具体实现类来实现,继承 AbstractMap 类可以简化开发过程。
AbstractMap 中最重要的方法是 entrySet()
,它返回一个 Set 集合,包含了 Map 中所有的键值对 (Map.Entry 类型)。AbstractMap 中也提供了实现 Map 接口中其他方法的默认实现,包括 put()
、get()
、remove()
、containsKey()
、isEmpty()
、size()
等。
5.2 HashMap 组成结构
在 JDK 8 中,HashMap 底层结构主要是由 数组 (table)
、链表
和 红黑树
组成,如下图所示:
数组 table 是由一个个 桶
组成,所以也常称数组为 桶数组
。并且每个 桶
都关联了一个 链表
或者 红黑树
结构,这主要是用于解决哈希表结构中的哈希冲突。其中链表使用 Node
类型节点组成,红黑树则使用 TreeNode
节点组成,两种类型节点中都存储了键值对的 key
、value
以及 hash
信息,并且每个节点还使用 next
属性进行关联。
六、HashMap 源码分析-属性
6.1 HashMap 中的默认常量值
1/**
2 * 默认初始容量。该值必须是 2 的 n 次幂,默认值为 16
3 */
4static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
5
6/**
7 * 集合的最大容量。最大不能超过 2^30
8 */
9static final int MAXIMUM_CAPACITY = 1 << 30;
10
11/**
12 * 默认的负载因子
13 */
14static final float DEFAULT_LOAD_FACTOR = 0.75f;
15
16/**
17 * 桶中-链表转换为红黑树的阈值
18 */
19static final int TREEIFY_THRESHOLD = 8;
20
21/**
22 * 桶中-红黑树还原为链表的阈值
23 */
24static final int UNTREEIFY_THRESHOLD = 6;
25
26/**
27 * 链表转化为红黑树时对集合最小容量的限制值,如果集合容量小于该值则链表不会转换为红黑树
28 */
29static final int MIN_TREEIFY_CAPACITY = 64;
6.2 HashMap 中的成员变量
1/**
2 * 存储元素的数组
3 */
4transient Node<K,V>[] table;
5
6/**.
7 * 存放具体元素的集合 (俗称的 “桶”)
8 */
9transient Set<Map.Entry<K,V>> entrySet;
10
11/**
12 * 记录集合当前大小,即记录集合中键值对个数
13 */
14transient int size;
15
16/**
17 * 记录集合数据修改次数,用于记录迭代过程中集合结构是否发生变化,如果有则迭代时会快速失败
18 */
19transient int modCount;
20
21/**
22 * 集合扩容的阈值,当实际大小超过临界值时,会进行扩容
23 * - ① 如果初始化时给定数组大小的话,通过 tableSizeFor 方法计算,数组大小永远接近于 2 的幂次方
24 * 比如你给定初始化大小 19,实际上初始化大小为 32,即 2^5
25 * - ② 如果是通过 resize 方法进行扩容,则阈值大小为: 数组容量 * 负载因子 (capacity * loadFactor)
26 */
27int threshold;
28
29/**
30 * 集合的负载因子,用于参与阈值计算,控制进行扩容的百分比。
31 */
32final float loadFactor;
transient 关键字修饰的成员变量可以防止变量被序列化。
七、HashMap 源码分析-关键方法
7.1 构造方法 HashMap
在 HashMap 中的构造方法总共有四种,分别为 无参构造方法
、支持配置初始化容量的构造方法
、支持配置初始化容量与负载因子的构造方法
和 支持根据已有 Map 集合创建新集合的构造方法
,它们的源码如下:
(1) 无参构造方法
无参构造方法不支持配置 初始化容量
和 负载因子
,这俩个参数均为默认值,初始化容量的默认值为 16
,负载因子的默认值为 0.75
。
1/**
2 * 无参构造方法
3 */
4public HashMap() {
5 this.loadFactor = DEFAULT_LOAD_FACTOR;
6}
(2) 支持配置初始化容量的构造方法
支持配置初始化容量的构造方法,可以配置 初始化容量
,但是不支持配置 负载因子
,负载因子的默认值为 0.75
。
1/**
2 * 支持配置初始化容量的构造方法
3 *
4 * @param initialCapacity 初始化容量
5 * @throws IllegalArgumentException 非法参数异常 (如果初始容量为负数或者负载因子为负数,则抛出该异常)
6 */
7public HashMap(int initialCapacity) {
8 // 调用 HashMap(int initialCapacity, float loadFactor) 构造方法
9 this(initialCapacity, DEFAULT_LOAD_FACTOR);
10}
(3) 支持配置初始化容量与负载因子的构造方法
支持配置初始化容量与负载因子的构造方法,可以配置 初始化容量
和 负载因子
,不过在对参数进行配置前需要对参数进行一系列校验。
HashMap(int initialCapacity, float loadFactor)
1/**
2 * 支持配置初始化容量与负载因子的构造方法
3 *
4 * @param initialCapacity 初始化容量
5 * @param loadFactor 负载因子
6 * @throws IllegalArgumentException 非法参数异常 (如果初始容量为负数或者负载因子为负数,则抛出该异常)
7 */
8public HashMap(int initialCapacity, float loadFactor) {
9 // 如果初始化容量为负数,则抛出 IllegalArgumentException 异常
10 if (initialCapacity < 0) {
11 throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
12 }
13 // 如果初始化容量大于 MAXIMUM_CAPACITY 值,则就设置初始化容量为 MAXIMUM_CAPACITY
14 if (initialCapacity > MAXIMUM_CAPACITY) {
15 initialCapacity = MAXIMUM_CAPACITY;
16 }
17 // 如果负载因子为负数,或者非数字 (NaN),则抛出 IllegalArgumentException 异常
18 if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
19 throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
20 }
21 // 设置【负载因子】和【扩容阈值】变量
22 this.loadFactor = loadFactor;
23 this.threshold = tableSizeFor(initialCapacity);
24}
tableSizeFor(int cap)
1/**
2 * 返回一个大于等于且最接近 cap 的 2 的幂次方:
3 * - 比如传入初始化容量 cap = 9,则返回 2^4,即 16
4 * - 比如传入初始化容量 cap = 25,则返回 2^5,即 32
5 *
6 * @param cap 传入构造方法中初始化容量
7 * @return 传入初始化容量 cap 的 2 的幂次方
8 */
9static final int tableSizeFor(int cap) {
10 int n = cap - 1;
11 n |= n >>> 1;
12 n |= n >>> 2;
13 n |= n >>> 4;
14 n |= n >>> 8;
15 n |= n >>> 16;
16 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
17}
(4) 支持根据已有 Map 集合创建新集合的构造方法
支持根据已有 Map 集合创建新集合的构造方法,该方法可以将 Map
集合作为方法参数,方法执行时会将传入集合中的全部元素写入到新的 HashMap
集合中。
HashMap(Map<? extends K, ? extends V> m)
1/**
2 * 支持根据已有 Map 集合创建新集合的构造方法
3 *
4 * @param m 已有的 Map 集合
5 * @throws NullPointerException 空指针异常
6 */
7public HashMap(Map<? extends K, ? extends V> m) {
8 this.loadFactor = DEFAULT_LOAD_FACTOR;
9 putMapEntries(m, false);
10}
putMapEntries(Map<? extends K, ? extends V> m, boolean evict)
1/**
2 * 将传入已有 Map 集合中的全部元素逐个添加到当前集合中
3 *
4 * @param m 指定的 Map 集合
5 * @param evict 当构造方法传入指定 Map 集合时该值为 false,其他情况为 true
6 */
7final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
8 // 获得传入 Map 集合的大小,并赋值给变量 s
9 int s = m.size();
10
11 // 判断传入 Map 集合大小是否存在数据,是则执行下面步骤
12 if (s > 0) {
13 // 判断 table 是否已经初始化:
14 // - 如果 table 已经初始化,则计算存储传入 Map 集合中全部元素所需要的最小容量,然后根据计算结果
15 // 确认是否对当前集合的阈值进行调整。
16 // - 如果 table 没有初始化,则判断传入的 Map 集合大小是否大于当前集合的阈值,然后根据判断结果
17 // 确认是否对当前集合的容量进行扩容。
18 if (table == null) {
19 // --- 计算存储传入 Map 集合所需要的最小容量 ---
20 // s 为传入集合的大小,loadFactor 为传入集合的负载因子,因此下面代码中 s/loadFactor 是
21 // 为了计算存储传入集合所需最小容量,算完成后将值赋给变量 ft,而这里计算结果 +1.0F,则是
22 // 为了防止集合扩容。
23 float ft = ((float) s / loadFactor) + 1.0F;
24 // --- 判断上面计算出的容量是否超出限制 ---
25 // 为了防止计算出的容量超出最大限制,需要判断容量 ft 是否超出容量限制值 MAXIMUM_CAPACITY:
26 // - 如果没有超出最大容量限制,就设置集合容量为 ft,并赋给变量 t
27 // - 如果超出最大容量限制,就设置集合容量为 MAXIMUM_CAPACITY,并赋给变量 t
28 int t = ((ft < (float) MAXIMUM_CAPACITY) ? (int) ft : MAXIMUM_CAPACITY);
29 // --- 根据计算出的所需容量进行判断,再依据判断结果对当前集合阈值进行调整 ---
30 // 如果计算出的容量 t > 当前集合扩容阈值 threshold,则对当前集合扩容阈值 threshold 进行调整
31 if (t > threshold) {
32 // 调整当前集合的扩容阈值 threshold,将其设置为与最小容量 t 最接近且大于的 【2的n次幂】 的值
33 threshold = tableSizeFor(t);
34 }
35 } else if (s > threshold) {
36 // 执行 resize 方法进行扩容
37 resize();
38 }
39
40 // 执行循环,将传入的 Map 集合中的全部元素逐个添加到当前集合中
41 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
42 K key = e.getKey();
43 V value = e.getValue();
44 putVal(hash(key), key, value, false, evict);
45 }
46 }
47}
在 putMapEntries()
方法中,有一段代码为 float ft = ((float)s / loadFactor) + 1.0F
,这里可能大家会有疑问,为什么计算出的容量最后要加 1.0F
,这里说明一下:
- 变量 s 表示的是传入 Map 集合的大小 size;
- 变量 loadFactor 表示的是当前集合的负载因子;
- 变量 threshold 表示的是当前集合的扩容阈值;
- 代码 (float) s / loadFactor 用于计算存储传入 Map 所需最小容量;
使用 size / loadFactor
可以计算出存储传入 Map 集合全部元素所需的最小容量,正常来说计算结果可以直接使用,不进行加 1.0F
也可以,但是存在一种特殊情况。比如,传入的 Map 集合的大小正好达到了当前集合的扩容阈值 threshold
,这就会导致传入 Map 集合中的数据存储到当前集合后,刚好达到了当前集合的扩容阈值,导致当前集合马上进行一次扩容操作造成性能损耗。不过如果计算的容量值加 1.0F
的话,就可以解决这个情况,具体的原因将给一个示例进行解释说明:
例如,传入了一个 Map 集合,其集合大小为 24
,使用 s / loadFactor
进行计算,即 24 / 0.75 = 32
,那么计算出的容量就是 32
,该值为 2的n次幂
:
- 如果使用一个容量为
32
的集合存储传入 Map 集合元素,在数据转移后会发现要存储的24
个元素正好达到了当前集合的阈值 (容量 * 负载因子
,即32 * 0.75 = 24
),就会立即进行一次扩容操作。 - 如果使
s / loadFactor
的计算结果加1
,即32 + 1 = 33
,容量的值并不是2的n次幂
,所以会进行调整,将其更改与33
接近且大于的2的n次幂
值64
,那么当前集合的扩容阈值为64 * 0.75 = 48
,因此当前集合足以存储大小为24
的传入 Map 集合的数据,存储过程也不会进行扩容。
7.2 哈希方法 hash(Object key)
哈希方法主要用于计算 Key 的哈希值,并将较高的哈希位传播到较低的哈希位。
1/**
2 * 计算 Key 哈希值方法
3 */
4static final int hash(Object key) {
5 // 定义记录 key 哈希值的变量
6 int h;
7 // 判断 key 是否为 null:
8 // - 如果 key 为 null 则直接返回 0。
9 // - 如果 key不为 null 则获取 key 的哈希值,然后使哈希值进行高位运算,将 h 右移 16 位,最后使
10 // (低16位hash) 异或 (高16位hash),这样得出的哈希值更加散列均匀,可以一定程度减少哈希碰撞。
11 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
12}
7.3 插入方法 put(K key, V value)
在 put()
方法中主要是调用 putVal()
方法实现将键值对数据加入到 HashMap
集合,由于调用逻辑代码比较多,这里给出一个流程图方便分析:
put(K key, V value)
1/**
2 * 将指定的键值对插入到 HashMap 集合中
3 *
4 * @param key 待插入的键
5 * @param value 待插入的值
6 * @return 返回与 Key 关联的旧值,分两种情况:
7 * - 如果集合中不存在对应的 key,即插入一个新的 key,则返回 null;
8 * - 如果集合中已经存在一个相同的 key,则新 value 覆盖旧 value,然后返回旧 value;
9 */
10public V put(K key, V value) {
11 return putVal(hash(key), key, value, false, true);
12}
putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
1/**
2 * 实现 Map.put() 和相关方法。
3 *
4 * @param hash Key 的 Hash 值
5 * @param key 插入的键
6 * @param value 插入的值
7 * @param onlyIfAbsent 如果为 true 则不更改现有的值
8 * @param evict 如果为 false 则表示 table 处于创建模式
9 * @return 如果插入的 Key 已经存在,则返回已存在 Key 的节点值,否则返回 null
10 */
11final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
12 Node<K,V>[] tab; // 记录 table 数组的变量
13 Node<K,V> p; // 记录临时 Node 节点的变量,如记录 i 所在数组下标位置的 Node 节点
14 int n; // 记录 table 数组长度的变量
15 int i; // 记录 key 经过计算后得到在 table 数组下标位置索引的变量
16
17 // 判断 table 数组是否为空,如果为空则调用 resize() 方法初始化 table 数组
18 if ((tab = table) == null || (n = tab.length) == 0) {
19 n = (tab = resize()).length;
20 }
21
22 // 通过哈希值计算当前 key 在 table 数组中的索引位置,然后判断数组当前位置(桶)是否为空,如果当前数组位置为空,
23 // 则直接在当前位置存储键值对数据,插入链表节点 Node。
24 // 注: 这里 (n-1)&hash 位运算,而不是使用 (n-1)%hash 取模运算,这主要是因为:
25 // - ① 位运算比取模运算效率更高
26 // - ② 保证索引不会发生数组越界
27 // - ③ 保证元素尽可能的均匀分布
28 if ((p = tab[i = (n - 1) & hash]) == null){
29 tab[i] = newNode(hash, key, value, null);
30 }
31 // 键值对数据加入到当前位置,关联到的链表/红黑树中来解决哈希冲突
32 else {
33 Node<K,V> e; // 记录当前节点的临时变量
34 K k; // 记录插入数据 key 的变量
35
36 // 判断当前数组下标位置的节点,是否是为要查找的 key 的节点,符合下面两条条件之一则说明就是要查找的节点:
37 // - 如果节点 key 和 hash 与插入的 key 和 hash 相同,则该节点就是要查找的节点;
38 // - 如果插入的 key 不为空,并且插入的 key 和节点 key 相同,则该节点就是要查找的节点;
39 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
40 e = p;
41 }
42 // 判断当前节点是否为红黑树 TreeNode 阶段,则将键值对数据加入到红黑树中
43 else if (p instanceof TreeNode) {
44 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
45 }
46 // 如果以上判断条件都不满足,则说明当前阶段是一个链表阶段,则将键值对数据加入到链表中,并赋给变量 e
47 else {
48 // 从链表头节点开始遍历链表
49 for (int binCount = 0; ; ++binCount) {
50 // 判断是否遍历到了链表的尾结点
51 if ((e = p.next) == null) {
52 // 创建一个新节点,将其加入到链表的尾部
53 p.next = newNode(hash, key, value, null);
54 // 判断链表长度是否 ≥ 链表转换为红黑树的最小节点限制,是则将链表转换为红黑树
55 if (binCount >= TREEIFY_THRESHOLD - 1) {
56 treeifyBin(tab, hash);
57 }
58 break;
59 }
60 // 判断遍历到的当前阶段,是否为要查找的 key 的节点,如果是则结束当前循环
61 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
62 break;
63 }
64 // 更改循环的当前元素,使 p 在遍历过程中一直往后移动
65 p = e;
66 }
67 }
68 // 判断变量 e 是否为空,如果不为空,则说明找到了 key 所在节点,需要将插入的新值替换掉老值,然后返回老值
69 if (e != null) {
70 // 获取老值,然后判断变量 onlyIfAbsent 是否为 false,并且老值不为空,如果是则新值会覆盖老值
71 V oldValue = e.value;
72 if (!onlyIfAbsent || oldValue == null) {
73 e.value = value;
74 }
75 afterNodeAccess(e);
76 // 返回老值
77 return oldValue;
78 }
79 }
80 // 使 modCount 值加 1,用于记录 HashMap 的数据结构发生了变化
81 ++modCount;
82 // 如果 HashMap 的大小 size 超过了扩容阈值 threshold,则调用 resize() 方法进行扩容操作
83 if (++size > threshold) {
84 resize();
85 }
86 // 调用 afterNodeAccess 方法,该方法 HashMap 没有任何实现逻辑,目的是为了让子类根据需要自行覆写
87 afterNodeInsertion(evict);
88 return null;
89}
putTreeVal(HashMap<K, V> map, Node<K, V>[] tab, int h, K k, V v)
1/**
2 * 红黑树中插入键值对的方法
3 *
4 * @param map Map集合
5 * @param tab 数组 table
6 * @param h 要插入键值对 key 的 hash值
7 * @param k 要插入键值对的 key
8 * @param v 要插入键值对的 value
9 * @return 指定 key 所匹配到的节点对象,针对这个对象去修改 V (返回空说明创建了一个新节点)
10 */
11final TreeNode<K, V> putTreeVal(HashMap<K, V> map, Node<K, V>[] tab, int h, K k, V v) {
12 Class<?> kc = null;
13 boolean searched = false;
14
15 // 如果当前节点的父节点不为空,则寻找根节点,否则就以当前节点作为根节点
16 TreeNode<K, V> root = (parent != null) ? root() : this;
17 // 遍历当前节点所在的树形结构
18 for (TreeNode<K, V> p = root;;) {
19 int dir, ph; K pk;
20 // 如果当前节点的哈希值 ph 大于待插入节点的哈希值 h,说明 p 在 h 的右边
21 if ((ph = p.hash) > h) {
22 dir = -1;
23 }
24 // 否则,如果当前节点的哈希值 ph 小于待插入节点的哈希值 h,说明 p 在 h 的左边
25 else if (ph < h) {
26 dir = 1;
27 }
28 // 否则,如果当前节点的哈希值等于带插入节点的哈希值,并且当前节点的 key 和带插入节点的 key 相等,
29 // 则说明要放进去 key 在当前树中已经存在了
30 else if ((pk = p.key) == k || (k != null && k.equals(pk))) {
31 return p;
32 }
33 // 否则,哈希值相等但是 key 值不相等同时 key 的类型都没有实现 Comparable 接口
34 else if ((kc == null && (kc = comparableClassFor(k)) == null)
35 || (dir = compareComparables(kc, k, pk)) == 0) {
36 // 还没找到节点
37 if (!searched) {
38 TreeNode<K, V> q, ch;
39 searched = true;
40 if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null)
41 || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) {
42 return q;
43 }
44 }
45 // 如果左右节点均无法找到,而 key 的类名相等,则通过 key 的哈希进行比较
46 dir = tieBreakOrder(k, pk);
47 }
48
49 TreeNode<K, V> xp = p;
50 // 根据上一步骤比较的结果进行判断,p 等于其左子节点还是右子节点,并且其子节点为空,则进行插入
51 if ((p = (dir <= 0) ? p.left : p.right) == null) {
52 // 获取 p 节点的下一个节点
53 Node<K, V> xpn = xp.next;
54 // 新建节点
55 TreeNode<K, V> x = map.newTreeNode(h, k, v, xpn);
56 // 将新建的节点和 p 节点相连
57 if (dir <= 0) {
58 xp.left = x;
59 } else {
60 xp.right = x;
61 }
62 xp.next = x;
63 x.parent = x.prev = xp;
64 if (xpn != null) {
65 ((TreeNode<K, V>) xpn).prev = x;
66 }
67 // 将新节点插入到红黑树中
68 // 在将节点插入到链表节点以后,调整节点数组的结构,将数组放到节点数组的最前面
69 moveRootToFront(tab, balanceInsertion(root, x));
70 return null;
71 }
72 }
73}
7.4 扩容方法 resize()
1/**
2 * 对哈希表进行初始化或者扩容
3 *
4 * @return table
5 */
6final Node<K,V>[] resize() {
7 Node<K,V>[] oldTab = table; // 现有 table 变量
8 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 现有 table 的容量 (现有容量)
9 int oldThr = threshold; // 现有 table 阈值 (现有阈值)
10 int newCap; // 扩容后 table 容量 (新容量)
11 int newThr = 0; // 扩容后 table 阈值 (新阈值)
12
13 // 如果现有容量 (oldCap) 大于 0,则说明现有 table 不为空,需要重新计算现有阈值 (threshold) 和新阈值 (newThr) 大小
14 if (oldCap > 0) {
15 // 若现有容量 (oldCap) 大于集合所允许的最大容量 (2^30),则置扩容临界值 threshold=Integer.MAX_VALUE
16 if (oldCap >= MAXIMUM_CAPACITY) {
17 threshold = Integer.MAX_VALUE;
18 return oldTab;
19 }
20 // 将现有容量 (oldTab) 扩大 2 倍,若现有容量 (oldTab) 扩容后,小于集合所允许的最大容量 (2^30),
21 // 且大于默认的初始容量 (16),则将现有阈值 (oldThr) 扩大一倍得到一个新的阈值 (newThr)
22 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) {
23 newThr = oldThr << 1;
24 }
25 }
26 // 如果现有容量 oldCap ≤ 0,但是现有阈值 oldThr > 0,则将新容量等于现有阈值 newCap = oldThr;
27 else if (oldThr > 0) {
28 newCap = oldThr;
29 }
30 // 如果现有容量 oldCap ≤ 0 和现有阈值 oldThr ≤ 0,则:
31 // - ① 将新容量设置为默认容量 16
32 // - ② 将新阈值为默认容量 * 默认负载因子 0.75
33 else {
34 newCap = DEFAULT_INITIAL_CAPACITY;
35 newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
36 }
37
38 // 如果新阈值为 0,说明新阈值未赋值,则:
39 // - ① 通过新容量 * 负载因子,即 newCap * loadFactor 计算新的阈值
40 // - ② 判断新容量 newCap 和第①步计算的阈值 ft 是否都小于最大集合容量,是则设置新阈值为 ft,否则为 Integer.MAX_VALUE
41 if (newThr == 0) {
42 float ft = (float) newCap * loadFactor;
43 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ? (int) ft : Integer.MAX_VALUE);
44 }
45
46 // 设置现有阈值等于新阈值
47 threshold = newThr;
48 // 创建新的 table
49 @SuppressWarnings({"rawtypes", "unchecked"}) // 忽略检查,如 sonar 扫描检查等
50 Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
51 table = newTab;
52 // 如果现有 table 不为 null,则需要进行循环遍历,将现有 table 中的元素转移到新 table 中
53 if (oldTab != null) {
54 for (int j = 0; j < oldCap; ++j) {
55 Node<K,V> e;
56 if ((e = oldTab[j]) != null) {
57 oldTab[j] = null;
58 if (e.next == null) {
59 newTab[e.hash & (newCap - 1)] = e;
60 } else if (e instanceof TreeNode) {
61 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
62 } else { // preserve order
63 Node<K,V> loHead = null, loTail = null;
64 Node<K,V> hiHead = null, hiTail = null;
65 Node<K,V> next;
66 do {
67 next = e.next;
68 if ((e.hash & oldCap) == 0) {
69 if (loTail == null) {
70 loHead = e;
71 } else {
72 loTail.next = e;
73 }
74 loTail = e;
75 }
76 else {
77 if (hiTail == null) {
78 hiHead = e;
79 } else {
80 hiTail.next = e;
81 }
82 hiTail = e;
83 }
84 } while ((e = next) != null);
85 if (loTail != null) {
86 loTail.next = null;
87 newTab[j] = loHead;
88 }
89 if (hiTail != null) {
90 hiTail.next = null;
91 newTab[j + oldCap] = hiHead;
92 }
93 }
94 }
95 }
96 }
97 return newTab;
98}
7.5 删除方法 remove(Object key)
remove(Object key)
1/**
2 * 从集合中删除指定 key 对应的键值对数据
3 *
4 * @param key key 要删除数据的 key
5 * @return 判断集合中存在指定的 key 对应的键值对数据,不存在则返回 null,存在则返回键值对的 value
6 */
7public V remove(Object key) {
8 Node<K,V> e;
9 return (e = removeNode(hash(key), key, null, false, true)) == null ?
10 null : e.value;
11}
removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable)
1/**
2 * 删除集合中指定的键值对数据
3 *
4 * @param hash key 的 hash 值
5 * @param key 要删除数据的 key
6 * @param value 要删除数据的 value (该值是否作为删除的条件取决于参数 matchValue 是否为 true)
7 * @param matchValue 删除数据时是否需要验证传入的 value 是否和查找到的 value 相等,true 则验证,false 则不验证
8 * @param movable 删除后是否移动节点,true 则移动,false 则不移动
9 * @return 返回被删除的节点对象,如果没有删除任何节点则返回 null
10 */
11final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
12 Node<K,V>[] tab; // 节点数组
13 Node<K,V> p; // 当前节点
14 int n; // 数组长度
15 int index; // 数组索引
16
17 // 获取数组 table 不为空,则使用【传入哈希值】与【数组长度】进行位运算,得出 key 在数组的下标位置,如果该数组
18 // 下标位置不为空,则执行下面的删除逻辑。
19 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {
20 Node<K,V> node = null; // 记录返回节点对象
21 Node<K,V> e; // 记录临时节点变量
22 K k; // 记录节点 key 的变量
23 V v; // 记录节点 value 的变量
24
25 // 判断数组当前位置节点的 hash 和 key 是否和要删除键值对的 hash 和 key 相同,如果是则将当前阶段赋给变量 node
26 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
27 node = p;
28 }
29 // 如果当前数组当前位置节点不是要删除的节点,则继续判断是否存在下一个节点,如果存在则继续遍历红黑树/链表
30 // 寻找要删除的节点。
31 else if ((e = p.next) != null) {
32 // 判断下一个节点是否为树节点,是则遍历红黑树寻找要删除的节点,并将其赋给变量 node
33 if (p instanceof TreeNode) {
34 node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
35 }
36 // 如果下一个节点不是树节点,则说明是链表,继续遍历链表寻找要删除的节点,并将其赋给变量 node
37 else {
38 do {
39 // 判断链表中的当前节点是否为要删除的节点,如果是则赋给变量 node
40 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
41 node = e;
42 break;
43 }
44 p = e;
45 } while ((e = e.next) != null);
46 }
47 }
48
49 // 如果寻找到的要删除的节点 node 不为空,需要继续验证是否设置 matchValue 为 true,如果为是则还需要继续验证节点
50 // 的 value,如果条件都符合,则执行下面逻辑。
51 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {
52 // 如果节点属于红黑树,则从红黑树中删除
53 if (node instanceof TreeNode) {
54 ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
55 }
56 // 如果是链表,并且要删除的节点为链表的头节点,则直接将数组下标位置直接指向要删除节点的下一个节点,这样就
57 // 相当于进行了删除操作。
58 else if (node == p) {
59 tab[index] = node.next;
60 }
61 // 如果是链表,且要删除的节点不是链表的头节点,则让要删除节点的前一个阶段的 next 属性,指向要删除节点的
62 // 下一个节点,这样就相当于进行了删除操作
63 else {
64 p.next = node.next;
65 }
66 // 使 modCount 值加 1,用于记录 HashMap 的数据结构发生了变化
67 ++modCount;
68 // 使集合大小 size 减 1
69 --size;
70 // 调用 afterNodeRemoval 方法,该方法 HashMap 没有任何实现逻辑,目的是为了让子类根据需要自行覆写
71 afterNodeRemoval(node);
72 return node;
73 }
74 }
75 return null;
76}
7.6 查询方法 get(Object key) 和 containsKey(Object key)
get(Object key)
1/**
2 * 查找指定 Key 的值,如果未找到则返回 null
3 *
4 * @see #put(Object, Object)
5 */
6public V get(Object key) {
7 Node<K,V> e;
8 return (e = getNode(hash(key), key)) == null ? null : e.value;
9}
containsKey(Object key)
1/**
2 * 判断当前 HashMap 集合中是否包含指定的 Key
3 *
4 * @param key 指定的键
5 * @return 当前 HashMap 集合是否包含指定 Key 的结果,存在则返回 true,否则返回 false
6 */
7public boolean containsKey(Object key) {
8 return getNode(hash(key), key) != null;
9}
getNode(int hash, Object key)
1/**
2 * 在集合中查找指定 key 节点 (实现 Map.get() 和相关方法)
3 *
4 * @param hash 键 key 的哈希值
5 * @param key 要查找的 key
6 * @return 寻找到的键值对节点,如果为寻找到则返回 null
7 */
8final Node<K, V> getNode(int hash, Object key) {
9 Node<K,V>[] tab; // 记录数组 table 的变量
10 Node<K,V> first; // 记录数组指定位置的 Node 节点的变量
11 Node<K,V> e; // 记录临时 Node 节点的变量
12 int n; // 记录 table 数组长度的变量
13 K k; // 记录数组指定位置的 Node 节点的键值对 key
14
15 // 判断数组 table 是否不为空,如果是则使用 key 的哈希值进行位运算,得出 key 在数组 table 中的下标位置,并且赋给变量 first
16 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
17 // 始终每次都先判断第一个 Node 节点是否就是要查找的 key 的节点
18 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) {
19 return first;
20 }
21 // 如果第一个 Node 节点不是要查找的 key 的节点,则继续判断下一个节点是否为空
22 if ((e = first.next) != null) {
23 // 判断是否为红黑树 TreeNode 类型节点,如果是则说明关联的节点为红黑树,则遍历红黑树获取查找 key 节点
24 if (first instanceof TreeNode) {
25 return ((TreeNode<K, V>) first).getTreeNode(hash, key);
26 }
27 // 如果不为红黑树 TreeNode 类型节点,则说明关联的节点为链表,则遍历链表查找 key 节点
28 do {
29 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
30 return e;
31 }
32 } while ((e = e.next) != null);
33 }
34 }
35 // 如果数组 table 为空,或者遍历红黑树和链表后都没有找到 key 节点,则返回 null
36 return null;
37}
7.7 链表转红黑树方法 treeifyBin(Node<K,V>[] tab, int hash)
1/**
2 * 链表转换为红黑树 (将当前桶下的链表中的 Node 节点类型转化为 TreeNode 节点类型)
3 *
4 * @param tab 集合数组 table
5 * @param hash 键值对 key 的哈希值
6 */
7final void treeifyBin(Node<K,V>[] tab, int hash) {
8 int n; // 记录数组长度的变量
9 int index; // 记录索引的变量
10 Node<K,V> e; // 记录临时 Node 节点的变量
11
12 // 如果前桶下的链表为 null,或者数组 table 长度小于链表转化为红黑树时对集合最小容量的限制值 64,则链表不转换为红黑树,
13 // 只对现有集合 table 进行一次扩容操作
14 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
15 resize();
16 }
17 // 如果前桶下的链表和链表中的最后元素不为 null,并且当前桶下的链表长度 ≥ 链表转化为红黑树时对集合最小容量的限制值,
18 // 则将链表转换为红黑树
19 else if ((e = tab[index = (n - 1) & hash]) != null) {
20 // 将链表 Node 节点 e 转化 TreeNode 树节点 p,然后构建由 TreeNode 组成的双向链表
21 TreeNode<K,V> hd = null; // 头结点
22 TreeNode<K,V> tl = null; // 尾节点
23 do {
24 TreeNode<K,V> p = replacementTreeNode(e, null);
25 if (tl == null) {
26 hd = p;
27 }
28 else {
29 p.prev = tl;
30 tl.next = p;
31 }
32 tl = p;
33 } while ((e = e.next) != null);
34
35 // 将由 TreeNode 组成的双向链表进行数化,转换为红黑树
36 if ((tab[index] = hd) != null) {
37 // 转换为红黑树的方法实现
38 // 这个方法里就开始做各种比较,左旋右旋,然后把双向链表搞成一个红黑树
39 hd.treeify(tab);
40 }
41 }
42}
7.8 红黑树转链表方法 untreeify(HashMap<K,V> map)
1/**
2 * 取消树化,将红黑树转换为链表
3 *
4 * @param map Map集合,这里将 Map 集合传入进来是为了调用类中的 replacementNode() 方法,来构建 Node 节点进而生成链表
5 * @return 链表的头结点
6 */
7final Node<K,V> untreeify(HashMap<K,V> map) {
8 Node<K,V> hd = null; // 链表头节点
9 Node<K,V> tl = null; // 链表尾节点
10
11 // 遍历 TreeNode 节点
12 for (Node<K,V> q = this; q != null; q = q.next) {
13 // 将 TreeNode 节点转换为普通的 Node 节点
14 Node<K,V> p = map.replacementNode(q, null);
15 // 如果为遍历的第一次,则保存头结点其引用
16 if (tl == null) {
17 hd = p;
18 }
19 // 将上一个节点的下一个节点指向当前这个节点
20 else {
21 tl.next = p;
22 }
23 // 将 tl 节点指向 p
24 tl = p;
25 }
26
27 // 返回链表的头结点
28 return hd;
29}
八、HashMap 线程安全
8.1 HashMap 是否线程安全
HashMap 在多线程环境下是非线程安全的,因为它的内部实现采用了数组+链表/红黑树的数据结构,而数组中每个元素是一个链表或者红黑树,多个线程同时对同一个链表/红黑树进行操作可能会导致链表/红黑树结构的破坏,从而影响 HashMap 的正确性。
例如,当多个线程同时尝试向同一个桶 (bucket) 中添加元素时,可能会导致链表/红黑树结构被破坏,导致出现元素覆盖、元素遗漏等问题。另外,在 resize() 过程中,如果多个线程同时进行,可能会出现死循环、链表/红黑树节点遗漏等问题。
8.2 HashMap 如何保证线程安全
HashMap 在多线程环境下是线程非安全的,如果要保证 HashMap 线程安全,常用以下方法:
- (1) 使用 collections.synchronizedMap() 包装 HashMap,保证线程安全。
- (2) 使用线程安全集合 Hashtable 替换 HashMap 集合,保证线程安全。
- (3) 使用线程安全集合 ConcurrentHashMap 替换 HashMap 集合,保证线程安全。
(1) 使用 collections.synchronizedMap() 包装 HashMap
使用 collections.synchronizedMap() 示例
1public class HashMapThreadSafe {
2
3 public static void main(String[] args) {
4 // 创建 HashMap,然后使用 Collections.synchronizedMap 包装 HashMap
5 Map<String, String> safeMap = Collections.synchronizedMap(new HashMap());
6 }
7
8}
使用 collections.synchronizedMap() 保证线程安全的原因简介
Collections.synchronizedMap() 方法可以创建一个线程安全的 Map,其基本思路是将对 Map 的操作进行同步,从而实现多线程环境下的安全操作。
1public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
2 return new SynchronizedMap<>(m);
3}
具体实现上,Collections.synchronizedMap() 方法会返回一个包装后的 SynchronizedMap 对象,该对象内部维护了一个锁,用于同步所有对 Map 的读写操作。在每个方法中,先获取锁,然后执行对应的操作,最后释放锁。
1public int size() {
2 synchronized (mutex) {return m.size();}
3}
4
5public boolean isEmpty() {
6 synchronized (mutex) {return m.isEmpty();}
7}
8
9public boolean containsKey(Object key) {
10 synchronized (mutex) {return m.containsKey(key);}
11}
12
13public boolean containsValue(Object value) {
14 synchronized (mutex) {return m.containsValue(value);}
15}
16
17public V get(Object key) {
18 synchronized (mutex) {return m.get(key);}
19}
20
21public V put(K key, V value) {
22 synchronized (mutex) {return m.put(key, value);}
23}
24
25public V remove(Object key) {
26 synchronized (mutex) {return m.remove(key);}
27}
28
29public void putAll(Map<? extends K, ? extends V> map) {
30 synchronized (mutex) {m.putAll(map);}
31}
32
33public void clear() {
34 synchronized (mutex) {m.clear();}
35}
需要注意的是,虽然 SynchronizedMap 能够保证线程安全,但是在高并发场景下性能可能较差,因为所有对 Map 的操作都需要获取锁,这可能导致多个线程因等待锁而被阻塞,从而降低程序的并发能力和响应速度。因此,在高并发环境中,我们通常会采用其他更高效的线程安全的 Map 实现,例如 ConcurrentHashMap。
使用线程安全集合 Hashtable
使用 Hashtable 示例
1public class HashMapThreadSafe2 {
2
3 public static void main(String[] args) {
4 // 创建 Hashtable 线程安全集合
5 Map<String, String> safeMap = new Hashtable<>();
6 }
7
8}
使用 Hashtable 保证线程安全的原因简介
Hashtable 是 Java 中的一种线程安全的 Map 实现,主要原因是它的方法都使用了 synchronized 锁。
在 Hashtable 中,每个方法都会被 synchronized 关键字修饰,这使得在多线程访问时,同一时刻只有一个线程可以执行 Hashtable 的方法。这种同步方式虽然保证了线程安全,但也带来了性能上的问题,因为在多线程访问时,如果一个线程持有锁,其他线程需要等待锁释放才能访问,会降低程序的并发性能。
另外,Hashtable 不允许空键和空值的存在,这在某些场景下会受到限制。而且由于所有方法都是同步的,所以在多线程高并发场景下,Hashtable 的性能也不如一些并发安全性更好的 Map 实现,如 ConcurrentHashMap。
使用线程安全集合 ConcurrentHashMap
使用 ConcurrentHashMap 示例
1public class HashMapThreadSafe3 {
2
3 public static void main(String[] args) {
4 // 创建 ConcurrentHashMap 线程安全集合
5 Map<String, String> safeMap = new ConcurrentHashMap<>();
6 }
7
8}
使用 ConcurrentHashMap 保证线程安全的原因简介
ConcurrentHashMap 在 JDK 7 和 JDK 8 中有着不同的实现方式:
在 JDK 1.7 中,ConcurrentHashMap 集合通过使用分段锁技术,来保证集合在多线程环境下的线程安全。
而在 JDK 1.8 以后,放弃了分段锁技术,而是采用了 volatile 关键字修饰变量,使用 unsafe 类的 CAS 方法更新变量,来保证变量的 可见性、有序性 和 原子性,在多线程环境下更新变量的线程安全。并且在插入数据的 put() 方法中,还是使用了 synchronized 锁,保证了写入数据到集合时的线程安全。
九、HashMap 中的疑问
9.1 为什么 HashMap 中要进行扩容?
HashMap 中进行扩容是为了保证 HashMap 中的数据不会过度拥挤,影响 HashMap 的性能。
在 HashMap 中,键值对被存储在一个数组中,每个数组元素称为一个桶 (bucket),每个桶中可能会存储多个键值对,如果桶中的元素数量过多,会影响查找、插入和删除操作的性能,因此需要对 HashMap 进行扩容,以增加桶的数量并重新分配键值对,从而降低每个桶中元素的数量,提高 HashMap 的性能。
9.2 为什么 HashMap 的容量是 2 的次幂?
在 Java 语言中,2 的次幂是一个有规律的值,HashMap 的容量之所以是 2 的次幂,主要是为了提高计算哈希值时的效率和减少哈希碰撞的概率。
在 Java 中,计算一个数值的模运算(如取余)时,通常采用位运算的方式,比使用除法运算更高效。由于 2 的次幂的二进制表示只有最高位是 1,其余位都是 0,所以对 2 的次幂进行模运算时,只需要保留该数值的低位,可以通过位运算的方式来实现。
此外,HashMap 中采用链表或红黑树的方式解决哈希冲突,如果容量为 2 的次幂,那么每个元素的哈希值对容量进行取模的结果只会落在 0 到容量-1 之间的某个数,这可以保证元素在各个桶中的分布比较均匀,减少链表或红黑树的长度,从而提高查找效率。
9.3 为什么 HashMap 中链表大小超过 8 时才转换成红黑树?
在 HashMap 中,当链表中的元素个数超过 8 时,会将链表转换为红黑树。这是为了提高查询效率,因为在链表中查询元素的时间复杂度是 O(n),而在红黑树中是 O(log n)。
为什么是 8 呢?这是因为链表的长度越长,查询效率就越低。而转换成红黑树需要付出的代价也是不小的。红黑树是一种平衡树,它的插入和删除操作比较耗费时间和空间,因此将链表转换成红黑树需要权衡链表长度和转换成红黑树所需要的代价。通过实验和分析,8 这个数值在大多数情况下都是一个比较合适的阈值,既能保证查询效率,又能避免过多地进行红黑树的转换。
需要注意的是,8 只是一个经验值,实际使用时也可以根据具体的场景进行调整。例如,如果链表中的元素都是相邻的内存地址,查询时的缓存命中率会比较高,那么可以适当增加阈值;如果哈希表中的元素总数很少,那么转换成红黑树可能没有必要,此时可以将阈值调整为一个较大的数值。
9.4 为什么 HashMap 不是线程安全的?
HashMap 不是线程安全的主要原因是它不会对多线程的并发访问进行同步处理。在多线程并发情况下,如果有多个线程同时进行写操作,可能会导致数据丢失、覆盖或者链表结构异常,进而造成 HashMap 内部状态的不一致性。具体原因如下:
Hash冲突:
在使用 HashMap 时,由于 Hash 算法的不可避免的限制,不同的 key 可能会被 Hash 到相同的位置,这种情况被称为 Hash 冲突。在单线程环境下,当发生 Hash 冲突时,HashMap 会将新的 Entry 添加到链表的尾部。但是在多线程环境下,如果两个线程同时添加 Entry,就有可能造成数据覆盖或者链表结构异常。
多线程写操作
在多线程环境下,如果多个线程同时对 HashMap 进行写操作,例如添加、删除或者扩容,就会出现并发问题。例如,在一个线程进行扩容的过程中,另一个线程可能会发现 HashMap 的大小已经超过了 threshold,于是也开始进行扩容。这就会导致两个线程对同一个 HashMap 进行扩容,可能会导致其中一个线程的数据被覆盖或者链表结构异常。
因此,在多线程环境下,为了保证 HashMap 的正确性和一致性,需要采用线程安全的 HashMap 实现,例如 ConcurrentHashMap。
9.5 JDK 7 和 8 中 HashMap 有什么不同?
在 JDK 7 和 8 中,HashMap 实现有以下几点不同:
底层数据结构不同
在 JDK 1.7 版本中,HashMap 底层的数据结构采用 数组 + 链表
。而在 JDK 1.8 版本中,HashMap 底层的数据结构采用 数组 + 链表 + 红黑树
。
初始容量不同
在 JDK 1.7 版本中,如果创建集合时如果没有指定初始容量,则 HashMap 会设置集合的容量大小为默认值 16。而在 JDK 1.8 版本中,如果创建集合时如果没有指定初始容量,则 HashMap 会设置集合的容量大小为 0,并且只有在第一次执行 put() 方法插入数据时,才会将集合的容量扩容为 16。
数据插入方式和扩容方式不同
在 JDK 1.7 版本中,插入数据到链表时,使用的是头插法,多线程环境下可能会引发出现死循环的问题,导致出现 OOM 内存溢出错误。并且在插入数据后,发现集合大小已经达到现有容量的 75% 时,则会先进行扩容,然后再插入数据。而在 JDK 1.8 版本中,插入数据到链表时,使用的是尾插法,解决了多线程环境下可能会出现死循环的问题。并且在插入数据前,HashMap 会检查集合大小是否已经达到现有容量的 75%,如果已经达到,则会先插入数据,然后再进行扩容。
9.6 JDK 7 头插法为什么会引发线程安全问题?
JDK 7 中的 HashMap 在进行元素插入时使用了链表的头插法,即将新元素插入到链表的头部。这种方法会导致在多线程环境下,同时对同一个链表进行插入操作时可能会导致链表出现环形数据结构,从而造成死循环或者无限扩容等问题,导致线程安全问题。
具体来说,在多线程环境下,当两个或多个线程同时对同一个链表的头部进行插入操作时,可能会导致链表的结构发生变化,出现环形结构,从而无限循环,最终导致 CPU 占用率飙升,程序无法正常执行等问题。
为了解决这个问题,在 JDK 8 中,HashMap 采用了尾插法来插入元素,这样就避免了出现环形结构,从而使得 HashMap 在多线程环境下更加安全。
--- END ---
!版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。