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):每次修改操作(如 addremoveset)时,CopyOnWriteArrayList 会创建一个底层数组的副本,对副本进行修改,然后将新数组设置为当前数组,这一过程是线程安全的

  • 读写分离:读操作直接访问底层数组,无需加锁,允许多个线程同时读取;写操作通过锁(通常是 ReentrantLock)保证线程安全

内部实现

  • 使用 volatile 修饰的数组存储元素,保证读操作的可见性
  • 写操作
    • **获取锁 **确保同一时刻只有一个线程执行写操作
    • 复制当前数组
    • 在新数组上执行修改
    • 用新数组替换旧数组
    • 释放锁
  • 读操作: 直接访问 volatile 数组,无锁
  • 锁机制:使用 ReentrantLock(Java 8 及之前)或内部锁对象(Java 9 及之后)

**为什么写时复制还需要锁? **

保证写操作的原子性

  • 写操作(如 add、remove)涉及多个步骤:复制数组、修改新数组、更新数组引用
  • 线程 A 和线程 B 同时复制数组并修改,B 的修改可能覆盖 A 的修改

是不是说传统的线程安全数组只是读和读之间没有锁,但读和写之间还是有锁的,但是写时复制 就不一样, 读操作一直没有锁,就算是在写的时候,读也可以进行?

VectorCollections.synchronizedList 中,读操作是加锁的, 虽然读和读本身不修改数据,但读操作需要确保它们访问的数据不受写操作干扰, 如果两个读线程同时运行,且没有锁,写线程可能在它们之间插入修改,导致一个读线程看到旧数据,另一个看到新数据,破坏一致性, 通过给读操作加锁,所有读线程在写操作完成前等待,统一看到一致的状态,

如果你对读操作加锁有疑问,应该是因为受到数据库的影响,只有写会获得 x锁,而读不会,数据库之所以可以这么做是因为他们使用了 MVVM,快照读,如果使用当前读也是需要加锁的, 综上:

CopyOnWriteArrayList 的读操作一直无锁, 即使写操作正在进行, 这是因为写时复制机制隔离了读写操作:

  • 写操作复制数组并修改副本,当前数组保持不变
  • 读操作访问当前数组,无需担心写操作的干扰, 读操作看到的是某一时刻的数组快照(可能是旧的,但始终完整)
  • volatile 确保写操作完成后,读线程最终看到新数组,但读线程不会被阻塞
特性 Vector / Collections.synchronizedList CopyOnWriteArrayList
读和读之间 有锁(竞争同一把锁) 无锁
读和写之间 有锁(读等待写,写等待读) 无锁(读可并行)
写和写之间 有锁(互斥) 有锁(互斥)
读性能 较低(锁竞争) 极高(无锁)
写性能 一般(锁开销) 较低(复制开销)
适用场景 读写均衡 读多写少