Java 多线程 并发编程
1. ConcurrentHashMap
vs Collections.synchronizedMap
#
1.1. 锁粒度#
-
Collections.synchronizedMap
粗粒度 全局锁-
对整个 Map 使用单一的锁(synchronized 块)
-
任何操作(get、put、remove 等)都需要获取这把锁,导致所有线程串行执行
-
高并发时,线程竞争锁的开销大,性能瓶颈明显
-
-
ConcurrentHashMap
细粒度分段锁/CAS- 在 Java 7 中使用分段锁(Segment),将哈希表分成多个段,每个段有独立的锁,写操作只锁定相关段,其他段仍可并发访问
- 在 Java 8 及以上,放弃分段锁,改用 CAS(Compare-And-Swap) 和
synchronized
(仅锁住桶的头节点),进一步提高并发性 - 读操作通常无锁(基于 volatile 保证可见性),允许多线程同时读取
- 结果:锁竞争大幅减少,读写并发性能更
1.2. Collections.synchronizedMap
#
Collections.synchronizedMap
内部将 HashMap 的每个操作都使用了synchronized
块- 这意味着所有操作(包括读和读、读和写、写和写)都必须竞争同一把锁,导致高并发下性能较差
1.3. ConcurrentHashMap
#
- Java 7
ConcurrentHashMap
使用**分段锁(Segment)**机制,将哈希表分成多个段(默认 16 个),每个段是一个独立的锁- 写操作只锁定对应的段,其他段仍可被其他线程访问
- 读操作通常无锁,依赖内存可见性
- 优点:比
Collections.synchronizedMap
的全局锁更细粒度,允许多线程操作不同段 - 缺点:分段锁仍有限制(段数固定),内存开销较大,复杂场景下性能未完全优化
- Java 8 及以上
- 放弃分段锁,改用更细粒度的机制:CAS + 桶级别的 synchronized
什么是 CAS(Compare-And-Swap)?
- CAS 是一种原子操作,基于硬件支持(CPU 指令),用于在无锁情况下更新共享变量
- CAS 操作包含三个参数:内存值(V)、预期值(A)、新值(B)
- 逻辑:如果当前内存值 V 等于预期值 A,则将 V 更新为 B;否则不更新
- CAS 是原子性的,保证操作不会被其他线程中断
public class AtomicCounter {
private volatile int value = 0;
public boolean compareAndSet(int expected, int newValue) {
// 伪代码,实际由 JVM 和硬件实现
// 只有当变量的当前值等于预期值时,才将其更新为新值
if (value == expected) {
// 将共享变量 value 的值替换为 newValue
value = newValue;
return true;
}
return false;
}
public void increment() {
int oldValue;
do {
oldValue = value;
} while (!compareAndSet(oldValue, oldValue + 1));
}
}
在真实 CAS 实现中,
if (value == expected)
和value = newValue
是原子操作,防止其他线程在比较和更新之间干扰,所以不会出现:线程 A 判断等于期望值 10,还没修改 value,此时线程 B 判断也等于期望值 10,然后出现更新丢失的情况
假设有一个共享变量 value = 10,两个线程尝试用 CAS 更新它:
- 线程 A:希望执行 CAS,将 value 从 10 改为 20(expected = 10, newValue = 20)
- 线程 B:希望执行 CAS,将 value 从 10 改为 30(expected = 10, newValue = 30)
场景 1:线程 A 先执行
- 线程 A 检查:value == expected(10 == 10),条件满足
- 执行 value = newValue,将 value 设为 20
- 返回 true,线程 A 更新成功
场景 2:线程 B 后执行
- 线程 B 检查:value == expected(20 == 10),条件不满足(因为线程 A 已将 value 改为 20)
- 不执行 value = newValue,value 仍为 20
- 返回 false,线程 B 更新失败,可能重试
CAS 失败后会发生什么?
在 CAS 操作中,当线程 B 调用 compareAndSet(expected, newValue) 失败(返回 false),意味着共享变量 value 的当前值不再等于 expected,通常是因为其他线程(如线程 A)已经修改了 value。失败后,线程 B 需要决定如何处理,常见的选择包括:
- 重试:重新读取 value 的当前值,基于新的值再次尝试 CAS
- 放弃:根据业务逻辑,直接返回失败或执行其他操作
- 进入替代逻辑:例如加锁(synchronized)或其他同步机制来完成操作
问题: 线程 B 更新失败,可能重试, 失败了之后怎么办,就算是重试 value 的值也不会是期待的 10 了, 这样就会死循环
为什么不会轻易导致死循环?
-
初始 value = 10
-
线程 A 将 value 从 10 改为 20(CAS 成功)
-
线程 B 尝试 CAS(expected = 10, newValue = 30),失败(因为 value 现在是 20)
-
线程 B 重试,读取新的 value = 20,然后尝试 CAS(expected = 20, newValue = 30)
2. CopyOnWriteArrayList
#
基本原理
-
写时复制(Copy-On-Write):每次修改操作(如
add
、remove
、set
)时,CopyOnWriteArrayList
会创建一个底层数组的副本,对副本进行修改,然后将新数组设置为当前数组,这一过程是线程安全的 -
读写分离:读操作直接访问底层数组,无需加锁,允许多个线程同时读取;写操作通过锁(通常是 ReentrantLock)保证线程安全
内部实现
- 使用
volatile
修饰的数组存储元素,保证读操作的可见性 - 写操作
- **获取锁 **确保同一时刻只有一个线程执行写操作
- 复制当前数组
- 在新数组上执行修改
- 用新数组替换旧数组
- 释放锁
- 读操作: 直接访问 volatile 数组,无锁
- 锁机制:使用
ReentrantLock
(Java 8 及之前)或内部锁对象(Java 9 及之后)
**为什么写时复制还需要锁? **
保证写操作的原子性
- 写操作(如 add、remove)涉及多个步骤:复制数组、修改新数组、更新数组引用
- 线程 A 和线程 B 同时复制数组并修改,B 的修改可能覆盖 A 的修改
是不是说传统的线程安全数组只是读和读之间没有锁,但读和写之间还是有锁的,但是写时复制 就不一样, 读操作一直没有锁,就算是在写的时候,读也可以进行?
在
Vector
或Collections.synchronizedList
中,读操作是加锁的, 虽然读和读本身不修改数据,但读操作需要确保它们访问的数据不受写操作干扰, 如果两个读线程同时运行,且没有锁,写线程可能在它们之间插入修改,导致一个读线程看到旧数据,另一个看到新数据,破坏一致性, 通过给读操作加锁,所有读线程在写操作完成前等待,统一看到一致的状态,如果你对读操作加锁有疑问,应该是因为受到数据库的影响,只有写会获得 x锁,而读不会,数据库之所以可以这么做是因为他们使用了 MVVM,快照读,如果使用当前读也是需要加锁的, 综上:
CopyOnWriteArrayList
的读操作一直无锁, 即使写操作正在进行, 这是因为写时复制机制隔离了读写操作:
- 写操作复制数组并修改副本,当前数组保持不变
- 读操作访问当前数组,无需担心写操作的干扰, 读操作看到的是某一时刻的数组快照(可能是旧的,但始终完整)
volatile
确保写操作完成后,读线程最终看到新数组,但读线程不会被阻塞
特性 | Vector / Collections.synchronizedList | CopyOnWriteArrayList |
---|---|---|
读和读之间 | 有锁(竞争同一把锁) | 无锁 |
读和写之间 | 有锁(读等待写,写等待读) | 无锁(读可并行) |
写和写之间 | 有锁(互斥) | 有锁(互斥) |
读性能 | 较低(锁竞争) | 极高(无锁) |
写性能 | 一般(锁开销) | 较低(复制开销) |
适用场景 | 读写均衡 | 读多写少 |