一、从一道经典面试题说起
面试中常会问到:
- 为什么
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() 的无锁流程
- 定位 Segment,读取
volatile table拿到最新桶数组。 - 沿链表通过
volatile next遍历,找到目标节点。 - 读取
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作为一个高度定制的容器,其设计者可以从底层保证更新方式一定是引用替换,从而开辟出读写并发的高性能路径。
五、一张表看清所有差异
| 维度 | ReentrantReadWriteLock | ConcurrentHashMap (JDK 7/8) |
|---|---|---|
| 读写能否并发 | 互斥,防止读到中间状态 | 可以,读操作完全无锁 |
| 更新模式 | 通用业务代码,多为原地修改 | 引用替换,原子发布新版本 |
| 中间状态风险 | 多个变量更新不同步 | 链表结构永不断裂 |
| 一致性级别 | 强一致性 | 弱一致性(允许读到旧数据) |
| 实现保证 | volatile+ 队列策略 | volatile+ 不可变节点 + CAS/锁 |
| 适用场景 | 通用业务逻辑保护 | 高性能容器,高并发读写 |
六、总结
- 读写锁为什么互斥:写操作往往涉及多个关联变量,中途并发读取会拿到业务上非法的“新旧混搭”状态,必须互斥。
- CHM 为什么能无锁读:所有写操作都用新建对象 + 替换 volatile 引用实现,读操作只会看到完整旧或完整新链表,绝无中间态。
- 本质区别:不在于是否引用类型,而在于更新策略——原地修改 vs 替换引用。
- Hashtable:读写全锁,并发极差;
ConcurrentHashMap以弱一致性换高吞吐。 - JDK 8同样延续 volatile + 引用替换思路,
get()依然无锁。
七、延伸思考
数据库 InnoDB 的 MVCC 为什么能实现读写不阻塞?
MVCC 通过 undo 日志构建历史快照,读操作可以读取适当时机的老版本,这与ConcurrentHashMap“替换引用保留旧版本”的思路有异曲同工之妙。而ReentrantReadWriteLock作为悲观锁,没有任何版本机制,只能选择阻塞。理解这三种模型,你对并发一致性的认知就完整了。