ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度解析:读写锁为何强制互斥,而 ConcurrentHashMap 却能无锁读?

深度解析:读写锁为何强制互斥,而 ConcurrentHashMap 却能无锁读? 一、从一道经典面试题说起面试中常会问到为什么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; // ① 执行后 total800usable usable - 200; // ② 执行后 usable800写线程执行完 ① 但还未执行 ② 的瞬间读线程并发进入读到total 800,usable 1000这个组合在系统的业务逻辑中根本不应该存在。这就是并发编程中所说的中间状态、脏快照不是单个变量值出错而是一组变量的组合视图出现新旧混搭。ReentrantReadWriteLock的读写互斥就是通过阻止读操作在写操作“进行到一半”时进入来彻底屏蔽这种风险。2.3 双向互斥的必要性有读时阻塞写多个读线程正在享受一致的旧数据此时放写线程进入会立刻破坏这份一致性。有写时阻塞读写线程正在更新多个变量读线程此时进入必然读到部分更新、部分未更新的混乱视图。任意时刻要么全是读要么只有一个写这就是读写锁保证强一致性的根基。三、ConcurrentHashMap如何打破读写互斥JDK 7 的ConcurrentHashMap实现了读操作完全无锁写操作仅锁分段Segment读写可以高度并发。它的底气来自两个核心设计volatile可见性引用替换。3.1 大量 volatile 字段保证可见性关键字段全部用volatile修饰static final class HashEntryK,V {final int hash;final K key;volatile V value;volatile HashEntryK,V next;}static final class SegmentK,V extends ReentrantLock {transient volatile HashEntryK,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; // 步骤1obj.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/锁适用场景通用业务逻辑保护高性能容器高并发读写六、总结读写锁为什么互斥写操作往往涉及多个关联变量中途并发读取会拿到业务上非法的“新旧混搭”状态必须互斥。CHM 为什么能无锁读所有写操作都用新建对象 替换 volatile 引用实现读操作只会看到完整旧或完整新链表绝无中间态。本质区别不在于是否引用类型而在于更新策略——原地修改 vs 替换引用。Hashtable读写全锁并发极差ConcurrentHashMap以弱一致性换高吞吐。JDK 8同样延续 volatile 引用替换思路get()依然无锁。七、延伸思考数据库 InnoDB 的 MVCC 为什么能实现读写不阻塞MVCC 通过 undo 日志构建历史快照读操作可以读取适当时机的老版本这与ConcurrentHashMap“替换引用保留旧版本”的思路有异曲同工之妙。而ReentrantReadWriteLock作为悲观锁没有任何版本机制只能选择阻塞。理解这三种模型你对并发一致性的认知就完整了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进