网站公告

本次更新:没啥更新的

“钱塘江上潮信起,今日方知我是我”

Skip to content

ConcurrentHashMap ​

字数: 0 字 时长: 0 分钟

1. ConcurrentHashMap 概述 ​

ConcurrentHashMap 是 Java 中专门用于:

text
多线程并发环境

的线程安全 Map。

它解决的核心问题就是:

HashMap 在多线程环境下线程不安全,而 ConcurrentHashMap 可以在保证线程安全的同时,尽量提高并发性能。

简单理解:

text
HashMap
    ↓
性能高
    ↓
线程不安全


Hashtable
    ↓
线程安全
    ↓
大量方法直接 synchronized
    ↓
并发性能较差


ConcurrentHashMap
    ↓
线程安全
+
尽量减少锁竞争

基本使用:

java
Map<String, Integer> map =
        new ConcurrentHashMap<>();

map.put(
        "张无忌",
        20
);

map.put(
        "赵敏",
        18
);

System.out.println(
        map.get(
                "赵敏"
        )
);

2. 为什么需要 ConcurrentHashMap ​

前面在 HashMap 中已经提到:

text
HashMap
    ↓
线程不安全

多个线程同时进行:

text
put()

remove()

resize()

时,可能出现:

text
数据丢失

读取异常

内部结构被破坏

那最简单的思路是什么?

直接给整个 Map 加锁。

比如早期的:

java
Hashtable

它的很多方法直接使用:

java
synchronized

例如可以简单理解成:

java
public synchronized V put(
        K key,
        V value) {

    // ...
}

这种方式确实能够保证线程安全。

但问题也很明显:

text
线程 A 操作 Map
    ↓
锁住整个对象
    ↓
线程 B 等待
    ↓
线程 C 等待

即使多个线程操作的是:

text
不同的 Key

也可能互相阻塞。

ConcurrentHashMap 的目标就是:

保证线程安全,但尽可能不要让所有线程争抢同一把大锁。

3. JDK 7 和 JDK 8 的区别 ​

ConcurrentHashMap 是一个非常典型的:

text
JDK 7

JDK 8

实现差异比较大的类。

3.1 JDK 7 ​

JDK 7 中,ConcurrentHashMap 主要使用:

text
Segment

也就是:

text
分段锁

整体结构可以简单理解成:

text
ConcurrentHashMap

├── Segment 0
│   └── HashEntry[]
│
├── Segment 1
│   └── HashEntry[]
│
├── Segment 2
│   └── HashEntry[]
│
└── ...

不同 Segment:

text
拥有不同的锁

因此:

text
线程 A
    ↓
操作 Segment 1


线程 B
    ↓
操作 Segment 5

两者可以同时进行。

这就比:

text
Hashtable

锁整个 Map 的方式并发能力更强。

所以 JDK 7 的 ConcurrentHashMap 经常被总结成:

text
Segment
+
分段锁

3.2 JDK 8 ​

到了 JDK 8,ConcurrentHashMap 取消了:

text
Segment

这一套主要结构。

改成:

text
Node 数组
+
链表
+
红黑树
+
CAS
+
synchronized

整体结构和 HashMap 越来越像:

text
ConcurrentHashMap

table
 ↓

Node[]
├── Node
├── Node → Node → Node
├── TreeBin
├── Node
└── ...

所以 JDK 8 以后可以简单记:

ConcurrentHashMap 不再通过 Segment 分段锁实现并发,而是通过 CAS + synchronized 对更细粒度的位置进行控制。

4. JDK 8 底层结构 ​

核心数组:

java
transient volatile Node<K, V>[] table;

可以简单理解:

text
table
    ↓
Node[]
    ↓
一个个桶

每一个桶:

text
没有哈希冲突
    ↓
一个 Node


存在哈希冲突
    ↓
链表


链表达到条件
    ↓
红黑树

和 HashMap 比较类似:

text
HashMap
    ↓
数组 + 链表 + 红黑树


ConcurrentHashMap
    ↓
数组 + 链表 + 红黑树
    +
并发控制

Node 大致可以理解成:

java
static class Node<K, V>
        implements Map.Entry<K, V> {

    final int hash;

    final K key;

    volatile V val;

    volatile Node<K, V> next;
}

其中:

text
hash
    ↓
哈希值


key
    ↓
Key


val
    ↓
Value


next
    ↓
链表下一个节点

需要注意:

java
val

和:

java
next

使用:

java
volatile

保证线程之间的可见性。

5. put() 原理 ​

ConcurrentHashMap 最重要的方法之一:

java
put()

最终会进入:

java
putVal()

整个流程可以先简单理解成:

text
put(key, value)
    ↓
计算 hash
    ↓
找到数组桶位置
    ↓
判断桶当前状态

然后根据情况分别处理。

5.1 Key 或 Value 为 null ​

ConcurrentHashMap 不允许:

text
null Key

null Value

所以:

java
map.put(
        null,
        "赵敏"
);

或者:

java
map.put(
        "赵敏",
        null
);

都会抛出:

text
NullPointerException

所以 put 一开始会进行检查:

java
if (
        key == null
                || value == null
) {

    throw new NullPointerException();
}

5.2 计算 hash ​

接下来计算 Key 的 hash。

整体思想和 HashMap 类似:

text
key.hashCode()
    ↓
扰动
    ↓
得到 hash

然后:

text
hash
    ↓
计算数组桶下标

5.3 table 还没有初始化 ​

如果:

text
table == null

或者:

text
table.length == 0

说明数组还没有初始化。

此时会调用:

text
initTable()

完成数组初始化。

也就是说 ConcurrentHashMap 同样采用:

text
延迟初始化

的方式。

5.4 当前桶为空 ​

假设计算得到:

text
index = 5

然后发现:

text
table[5] == null

也就是:

text
这个桶现在没人

这时候就没有必要加锁整个桶。

可以通过:

text
CAS

直接尝试把新节点放进去。

流程:

text
桶为空
    ↓
CAS
    ↓
尝试放入新节点

如果成功:

text
插入完成

如果失败:

text
说明其他线程抢先修改了
    ↓
重新尝试

这也是 ConcurrentHashMap 性能比较高的一个重要原因。

5.5 当前桶正在扩容 ​

如果发现当前节点是:

text
ForwardingNode

说明:

text
ConcurrentHashMap 正在扩容

此时当前线程不会简单地一直等待。

它可能会:

text
帮助完成扩容

也就是:

text
多个线程
    ↓
一起迁移数据

这也是 JDK 8 ConcurrentHashMap 扩容设计中非常重要的一点。

5.6 当前桶已经有元素 ​

如果桶中已经存在节点:

text
table[index] != null

说明发生了:

text
哈希冲突

这时候会锁住:

text
当前桶的头节点

而不是锁整个 ConcurrentHashMap。

可以简单理解:

java
synchronized (f) {

    // 操作当前桶
}

所以:

text
线程 A
    ↓
操作桶 3


线程 B
    ↓
操作桶 10

只要操作的是不同的桶:

text
互不影响

这就是所谓:

text
更细粒度的锁

5.7 链表插入 ​

如果当前桶还是链表:

text
Node
 ↓
Node
 ↓
Node

就会遍历链表。

如果找到相同 Key:

text
更新 Value

如果没有:

text
添加新节点

整个过程:

text
找到桶
    ↓
synchronized 锁住当前桶
    ↓
遍历链表
    ↓
Key 相同
    ├── 是 → 更新 Value
    │
    └── 否 → 插入新节点

5.8 红黑树插入 ​

如果当前桶已经树化:

text
TreeBin

那么就按照红黑树的方式:

text
查找

插入

更新

数据。

所以 JDK 8 ConcurrentHashMap 的整体结构同样可以理解为:

text
数组
+
链表
+
红黑树

6. CAS 是什么 ​

ConcurrentHashMap 中会大量看到:

text
CAS

CAS:

text
Compare And Swap

也就是:

text
比较并交换

可以简单理解成:

text
我认为当前值还是 A
    ↓
如果真的还是 A
    ↓
修改成 B


如果已经不是 A
    ↓
说明被其他线程修改了
    ↓
本次操作失败

例如当前桶:

text
table[index] == null

线程 A 想插入:

text
Node A

线程 B 也想插入:

text
Node B

此时:

text
线程 A
    ↓
CAS 成功
    ↓
Node A 放入桶


线程 B
    ↓
CAS 发现桶已经不是 null
    ↓
失败
    ↓
重新处理

CAS 的好处就是:

text
某些简单操作
    ↓
不需要 synchronized

从而减少锁竞争。

7. get() 原理 ​

ConcurrentHashMap 的:

java
get()

相比 put() 要简单很多。

基本流程:

text
Key
    ↓
计算 hash
    ↓
计算数组下标
    ↓
找到桶

如果桶中第一个节点就是目标:

text
直接返回 Value

如果不是:

text
继续查找链表 / 红黑树

整个过程:

text
get(key)
    ↓
计算 hash
    ↓
定位桶
    ↓
比较第一个节点
    ↓
继续查找
    ↓
返回 Value

7.1 为什么 get 不需要像 put 一样加锁 ​

因为:

text
get()

主要是:

text
读取

而 ConcurrentHashMap 内部关键字段使用了:

java
volatile

例如:

java
volatile V val;

volatile Node<K, V> next;

从而保证线程间的可见性。

所以在大多数普通读取场景下:

text
get()
    ↓
不需要 synchronized

这也是 ConcurrentHashMap 读取效率很高的重要原因。

8. synchronized 锁的是什么 ​

这里非常容易和 Hashtable 搞混。

Hashtable 可以简单理解成:

text
put()
    ↓
锁整个 Hashtable

而 JDK 8 ConcurrentHashMap:

text
put()
    ↓
桶为空
        ↓
        CAS

桶不为空
        ↓
        synchronized 当前桶

所以锁的粒度明显更小。

例如:

text
table

[0]
[1]
[2] → Node A
[3]
[4]
[5] → Node B
[6]

线程 A:

text
操作桶 2

线程 B:

text
操作桶 5

它们可以并发执行。

所以可以简单理解:

JDK 8 ConcurrentHashMap 使用 CAS 处理无冲突插入,发生冲突时再对当前桶进行 synchronized,而不是锁整个 Map。

9. 扩容机制 ​

ConcurrentHashMap 底层仍然使用:

text
数组

所以随着元素不断增加:

text
数组空间不足
    ↓
需要扩容

扩容的核心思想和 HashMap 类似:

text
旧数组
    ↓
新数组
    ↓
容量扩大
    ↓
元素迁移

但 ConcurrentHashMap 最大的问题是:

扩容时可能还有其他线程正在 put、get。

所以它的扩容过程需要解决:

text
并发扩容

的问题。

9.1 ForwardingNode ​

在扩容过程中,会看到一种特殊节点:

text
ForwardingNode

它可以简单理解成:

text
当前桶的数据
    ↓
已经迁移 / 正在迁移

同时它还保存:

text
新数组的信息

当其他线程看到:

text
ForwardingNode

就知道:

text
这里正在扩容

于是可以:

text
继续去新数组查找

或者

帮助进行扩容

9.2 多线程协助扩容 ​

HashMap 扩容通常可以理解成:

text
一个线程
    ↓
完成整个迁移

ConcurrentHashMap 为了提高效率,可以让:

text
多个线程

共同参与数组迁移。

简单理解:

text
旧 table

├── 一部分桶 → 线程 A 负责
├── 一部分桶 → 线程 B 负责
└── 一部分桶 → 线程 C 负责

多个线程一起把数据迁移到:

text
nextTable

最终:

text
nextTable
    ↓
成为新的 table

所以 ConcurrentHashMap 的扩容不是简单地:

text
锁整个 Map
    ↓
一个线程慢慢搬

而是支持:

text
并发迁移

10. 为什么不允许 null ​

HashMap 可以:

text
一个 null Key

多个 null Value

但是 ConcurrentHashMap:

text
Key 不能为 null

Value 不能为 null

一个重要原因是:

text
并发场景下会产生歧义

例如:

java
V value =
        map.get(
                "赵敏"
        );

如果返回:

text
null

到底代表:

text
这个 Key 不存在

还是:

text
这个 Key 存在
但是 Value 本身就是 null

?

在普通 HashMap 中,可以继续:

java
containsKey()

判断。

但在并发环境中:

text
get()
    ↓
containsKey()

两次操作之间可能已经被其他线程修改。

所以 ConcurrentHashMap 直接规定:

text
Key != null

Value != null

这样:

java
get(key) == null

就可以明确表示:

text
当前没有这个映射

11. ConcurrentHashMap 和 HashMap ​

简单对比:

特性HashMapConcurrentHashMap
线程安全否是
null Key允许一个不允许
null Value允许不允许
底层结构数组 + 链表 + 红黑树数组 + 链表 + 红黑树
并发控制无CAS + synchronized
适用场景单线程或外部保证线程安全多线程共享 Map

核心区别:

text
HashMap
    ↓
没有并发控制


ConcurrentHashMap
    ↓
CAS
+
synchronized
+
volatile
+
并发扩容

12. ConcurrentHashMap 和 Hashtable ​

再来看:

text
ConcurrentHashMap

Hashtable

的区别。

Hashtable:

text
线程安全
    ↓
大量方法 synchronized
    ↓
锁粒度较大

ConcurrentHashMap:

text
线程安全
    ↓
CAS
+
桶级 synchronized
    ↓
锁粒度更细

简单理解:

text
Hashtable
    ↓
一把大锁


ConcurrentHashMap
    ↓
尽量只锁当前操作区域

所以在高并发场景下:

text
ConcurrentHashMap

通常具有更好的并发性能。

13. 小结 ​

ConcurrentHashMap 是 Java 中非常重要的:

text
线程安全 Map

主要用于:

text
多线程共享 Map

JDK 7:

text
ConcurrentHashMap
    ↓
Segment
    ↓
分段锁

JDK 8 以后:

text
ConcurrentHashMap
    ↓
Node[]
    ↓
链表 / 红黑树
    ↓
CAS + synchronized

put():

text
计算 hash
    ↓
找到桶
    ↓
桶为空
    ├── CAS 插入
    │
    └── 桶不为空
            ↓
       synchronized 当前桶
            ↓
       链表 / 红黑树处理

get():

text
计算 hash
    ↓
定位桶
    ↓
查找 Node
    ↓
返回 Value

读取过程主要依靠:

text
volatile

保证可见性,通常不需要加锁。

扩容时:

text
旧数组
    ↓
nextTable
    ↓
ForwardingNode
    ↓
多个线程协助迁移

ConcurrentHashMap 不允许:

text
null Key

null Value

整个 ConcurrentHashMap 最核心的一句话可以记成:

JDK 8 以后,ConcurrentHashMap 通过 CAS + synchronized + volatile,在保证线程安全的同时,将锁粒度尽可能缩小,从而提高并发性能。

Noctiflorous Garden · MIT License