☰
深度解析:读写锁为何强制互斥,而 ConcurrentHashMap 却能无锁读?
2026/10/6 15:59:07 网站建设 项目流程

一、从一道经典面试题说起

面试中常会问到:

  • 为什么ReentrantReadWriteLock要求读-写互斥?
  • 为什么ConcurrentHashMap的get()可以不加锁,还能和写并发执行?
  • 如果“读写可以不互斥”,那是不是意味着所有引用类型都可以这样优化?

要回答这些问题,我们必须先理解并发编程中“中间状态”的真正含义,再深入到两种截然不同的数据更新策略。

二、读写锁:究竟在防止什么样的“中间状态”?

2.1 单变量场景:并没有幻数

如果只修改一个变量,即使不加锁,只用volatile或CAS,你只会读到旧值或新值之一,绝不会出现一个凭空捏造的数字。例如:

volatile int balance = 1000;
// 写线程
balance = balance - 200;

读线程在任何时刻读取balance,结果要么是1000,要么是800,不会是一个奇怪的中间值。

但这不意味着安全。真正的危险来自多个关联变量。

2.2 多变量场景:业务脏快照

考虑一个账户有两个业务上必须同步更新的字段:

int total = 1000; // 总额度
int usable = 1000; // 可用额度

// 扣款业务(写线程)
total = total - 200; // ① 执行后 total=800
usable = usable - 200; // ② 执行后 usable=800

写线程执行完 ① 但还未执行 ② 的瞬间,读线程并发进入,读到:

total = 800,usable = 1000

这个组合在系统的业务逻辑中根本不应该存在。这就是并发编程中所说的中间状态、脏快照:不是单个变量值出错,而是一组变量的组合视图出现新旧混搭。

ReentrantReadWriteLock的读写互斥,就是通过阻止读操作在写操作“进行到一半”时进入,来彻底屏蔽这种风险。

2.3 双向互斥的必要性

  • 有读时阻塞写:多个读线程正在享受一致的旧数据,此时放写线程进入会立刻破坏这份一致性。
  • 有写时阻塞读:写线程正在更新多个变量,读线程此时进入必然读到部分更新、部分未更新的混乱视图。

任意时刻,要么全是读,要么只有一个写,这就是读写锁保证强一致性的根基。

三、ConcurrentHashMap:如何打破读写互斥?

JDK 7 的ConcurrentHashMap实现了读操作完全无锁,写操作仅锁分段(Segment),读写可以高度并发。它的底气来自两个核心设计:volatile可见性+引用替换。

3.1 大量 volatile 字段保证可见性

关键字段全部用volatile修饰:

static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}

static final class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
transient volatile int count;
}

volatile保证:写线程对这些字段的修改,读线程能够立即感知,不会长期滞留在本地缓存的老值。

3.2 get() 的无锁流程

  1. 定位 Segment,读取volatile table拿到最新桶数组。
  2. 沿链表通过volatile next遍历,找到目标节点。
  3. 读取volatile value返回。

全程只有读取,不涉及任何锁。

3.3 为什么不会读到脏数据?

答案在于ConcurrentHashMap的更新从不原地修改节点内部内容,而是采用新建节点 + 替换引用:

  • 插入/删除时,会构造新的链表结构(新节点或跳过旧节点),最后通过 CAS 或锁一次性将哈希桶的volatile引用指向新链表。
  • 旧链表结构保持完整,丝毫不变。

因此,读线程遍历链表时,要么看到完整的旧链表,要么看到完整的新链表。绝不会出现一个链表断裂、循环或半新半旧的中间态。视图只有“旧完整快照”和“新完整快照”两种。

代价:可能读到稍旧的数据(弱一致性),但容器绝不会崩溃。这是ConcurrentHashMap明确接受的设计取舍。

四、本质分界线:原地修改 vs 替换引用

很多人会误以为:“只要用引用类型、加volatile,并发读写就安全。”这是错误的。

4.1 引用类型 ≠ 安全

看一个反例:

class Account {
int total;
int usable;
}
volatile Account acc;

// 写线程:原地修改对象内部字段
Account obj = acc;
obj.total -= 200; // 步骤1
obj.usable -= 200; // 步骤2

尽管acc是volatile引用,但写线程没有改变引用本身,而是直接修改了引用指向对象的内部字段。此时读线程并发进入,依然会看到total已改、usable未改的错乱状态。

4.2 真正的安全分界线

更新策略行为并发读风险典型案例
原地修改(修改对象内部字段)引用不变,直接改动对象里的属性可能读到部分更新、部分未更新的混合中间态ReentrantReadWriteLock保护的多数业务代码
替换引用(新建对象,切换指针)不碰旧对象,构建全新对象后原子替换 volatile 引用只有旧完整状态或新完整状态两种视图ConcurrentHashMap链表更新
  • 原地修改=在原有文档上直接擦写,旁人随时可能看到修改了一半的混乱内容。
  • 替换引用=重新打印一份新文档,最后换掉共享区的链接,旁人要么取到旧文档,要么取到新文档,永远不会取到半成品。

4.3 为什么 ReentrantReadWriteLock 不用“替换引用”方案?

因为ReentrantReadWriteLock是通用锁工具。它无法限制程序员在锁内部编写什么样的逻辑。程序员完全可以在临界区内执行原地修改、多次 I/O、复合计算等任意操作。为了保证任何场景下都不出现并发脏数据,只能采用最保守、最通用的方案:读写互斥。

而ConcurrentHashMap作为一个高度定制的容器,其设计者可以从底层保证更新方式一定是引用替换,从而开辟出读写并发的高性能路径。

五、一张表看清所有差异

维度ReentrantReadWriteLockConcurrentHashMap (JDK 7/8)
读写能否并发互斥,防止读到中间状态可以,读操作完全无锁
更新模式通用业务代码,多为原地修改引用替换,原子发布新版本
中间状态风险多个变量更新不同步链表结构永不断裂
一致性级别强一致性弱一致性(允许读到旧数据)
实现保证volatile+ 队列策略volatile+ 不可变节点 + CAS/锁
适用场景通用业务逻辑保护高性能容器,高并发读写

六、总结

  1. 读写锁为什么互斥:写操作往往涉及多个关联变量,中途并发读取会拿到业务上非法的“新旧混搭”状态,必须互斥。
  2. CHM 为什么能无锁读:所有写操作都用新建对象 + 替换 volatile 引用实现,读操作只会看到完整旧或完整新链表,绝无中间态。
  3. 本质区别:不在于是否引用类型,而在于更新策略——原地修改 vs 替换引用。
  4. Hashtable:读写全锁,并发极差;ConcurrentHashMap以弱一致性换高吞吐。
  5. JDK 8同样延续 volatile + 引用替换思路,get()依然无锁。

七、延伸思考

数据库 InnoDB 的 MVCC 为什么能实现读写不阻塞?

MVCC 通过 undo 日志构建历史快照,读操作可以读取适当时机的老版本,这与ConcurrentHashMap“替换引用保留旧版本”的思路有异曲同工之妙。而ReentrantReadWriteLock作为悲观锁,没有任何版本机制,只能选择阻塞。理解这三种模型,你对并发一致性的认知就完整了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询