系列目录
- 从单线程到线程池:云盘转 Java 后的第一堂并发课
- 线程池不是 new 出来就完事:参数、队列与快慢接口隔离
- 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上
- ConcurrentHashMap 与锁:文件元数据的并发读写(本篇)
项目开始前需要确认的并发边界
第 1 篇排查 Spring 单例 Bean 的成员变量时,已经将必须共享的 Map 列为待确认项。当时已有 Node.js、C# 等后端经验,我知道 Java 项目的请求会在多个线程上同时执行,但还不能根据访问模式判断 Map 和锁的选择。云盘项目会有文件元数据缓存和目录树这类共享状态,项目进入相关实现前,我先学习 ConcurrentHashMap、ReadWriteLock 和 CopyOnWriteArrayList 的使用条件,再将结论用于代码设计。
文件和目录的属性信息,包括名字、大小、类型、修改时间,存于 DB 中。列表页一次会查询几十条元数据,同一批热点目录也可能被重复读取。service 层因此需要一层进程内缓存,key 为 fileId,value 为属性对象。写路径在文件变更时更新或失效对应 entry。
最直接的实现是为整个 Map 的访问加同一把锁:
public class MetadataCache {
private final Map<String, FileMeta> map = new HashMap<String, FileMeta>();
public synchronized FileMeta get(String fileId) {
return map.get(fileId);
}
public synchronized void put(String fileId, FileMeta meta) {
map.put(fileId, meta);
}
public synchronized void remove(String fileId) {
map.remove(fileId);
}
}这段代码可以保护 HashMap,但还需要评估并发读的等待成本。第 2 篇处理的是线程池中任务等待的问题;这里要判断多个请求访问同一个数据结构时,哪些访问需要互斥,哪些访问可以并行。
线程安全与并发性能
上面的类通过同一个监视器使 get、put 和 remove 互斥执行,HashMap 不会因并发访问被破坏。它的并发性能仍受这把锁限制。
- 线程安全回答「对不对」:在该类提供的单个操作范围内,数据结构不会因并发访问损坏。
- 并发性能回答「快不快」:N 个线程同时到达时,操作能否并行执行,还是需要等待同一把锁。
synchronized 全方法锁覆盖整个对象。两个读取不同 fileId 的 get 操作也要依次执行。读多写少时,本可并行的读操作会在这把锁上排队。
锁内只保留一行 map.get 可以缩短单次持锁时间,却不能改变所有访问共用同一串行点的事实。多个线程竞争同一把锁时,后取得锁的线程仍要等待先取得锁的线程释放它。锁的粒度决定了哪些访问会被串行化,也影响可达到的吞吐。
JDK 7 ConcurrentHashMap 的分段锁
为判断元数据缓存是否适合 ConcurrentHashMap,我阅读了 JDK 7 的源码。它将数据划分到多个 Segment,每个 Segment 管理自己的哈希表,并继承一把锁。
- 写入时先按 hash 定位 Segment,并锁住该 Segment。两个写操作落在不同 Segment 时可以并行;落在同一 Segment 时仍会互斥。默认
concurrencyLevel为 16,默认构造的实例按这一并发级别分配 Segment 槽位,实际 Segment 按需初始化。 get的正常路径不获取 Segment 锁。实现将读取路径涉及的表引用、节点值和后继节点等字段声明为volatile。写入在持有 Segment 锁时完成更新。volatile 读写与锁释放、获取共同建立可见性关系,使无锁读取可以读取已发布的节点和字段。无锁读取不提供整个 Map 的一致快照。- 可以在构造时通过
concurrencyLevel调整段数。段数更高会增加结构开销,段数更低会增加写操作落在同一段的概率。项目中没有依据业务数据调整该参数,使用默认值。
这里讨论的是 JDK 7 的实现,项目线上运行的也是 JDK 7。本文的「分段锁」均指这一版本。JDK 8 重写了 ConcurrentHashMap 的实现,本文不展开。
ReadWriteLock 与 CopyOnWrite 的适用条件
并发包中也有面向读多写少的实现,但它们保护的对象不同:
- ConcurrentHashMap:适合散点读写。每个操作只访问一个 key,键之间没有需要同时维护的结构关系。
- ReentrantReadWriteLock:读锁可共享,写锁独占。读操作在持有读锁期间可以并发执行;写操作在持有写锁期间排斥读锁和其他写锁。读取和修改都通过同一把读写锁协调时,读操作不会读取到写入过程中的中间结构。它不自动为多个独立读取生成一致快照。公平性策略、锁降级等细节需要按实际操作设计。写操作频繁或持锁时间长时,读操作也会等待。
- CopyOnWriteArrayList:写操作复制底层数组,在新数组完成修改后替换引用。读取和遍历不需要持有它的写锁;迭代器基于创建时的数组快照,不会反映之后的修改,也不会因并发修改抛出
ConcurrentModificationException。它适合极少写、频繁遍历的列表,例如事件监听器列表。列表较大或写入变多时,需要重新评估复制成本。
项目实现前形成的选型判断是:散点 kv 使用 CHM;整体结构需要同一把锁保护的读多写少场景使用读写锁;极少修改且频繁遍历的列表使用 CopyOnWrite。
用于元数据缓存和目录树
这两个结论用于云盘后,对应两种不同的共享状态。
-
元数据缓存使用 ConcurrentHashMap。 该场景是散点 kv,get/put/remove 只访问一个 fileId,没有跨键操作。实现只需替换类型并去掉
synchronized。后续压测用于容量摸底,相关方法留到第 10 篇。压测中,RT 随并发上涨的曲线基本拉平,jstack 中等待缓存监视器锁的BLOCKED线程也消失了。数字按惯例模糊,量级感受是「线性变平」。 -
目录树使用读写锁。 目录树包含跨节点关联。移动一个目录时,需要同时修改父节点的孩子列表和子节点的父链;解析
/a/b/c路径时,需要逐层遍历。若遍历期间只完成部分关联更新,路径解析结果不可靠。这类操作的原子单位是一段树结构,不是单个 key。ConcurrentHashMap无法单独保证这类多键操作的一致性。目录树因此使用ReentrantReadWriteLock:路径解析、列表等读操作持有读锁并发执行;移动、重命名等结构变更持有写锁独占执行。同一把锁保护结构变更与读取。
线程池按快慢拆池隔离的是任务等待;数据结构和锁的选择决定互斥范围。两类设计都需要先识别共享资源和访问方式。
ConcurrentHashMap 不保证复合操作原子性
学习 ConcurrentHashMap 时,需要单独检查每个业务操作是否由多个 API 调用组成。用户配额初始化是一个例子:
if (!metaMap.containsKey(uid)) {
metaMap.put(uid, initQuota(uid)); // initQuota 里还有别的副作用
}containsKey 和 put 各自按方法语义是线程安全的,但「判断 + 放入」由两次调用组成。两个线程可能同时通过 containsKey 检查,各自执行一次初始化,再依次 put,后写入的值覆盖先写入的值。ConcurrentHashMap 提供部分原子复合方法,多个独立调用和业务副作用仍需要单独协调。
下面的最小 demo 可以观察这个竞态,JDK 7 可跑:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class CheckThenActDemo {
private static final ConcurrentHashMap<String, String> map =
new ConcurrentHashMap<String, String>();
private static final AtomicInteger initCount = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread[] ts = new Thread[8];
for (int i = 0; i < ts.length; i++) {
ts[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
// 错误写法:check-then-act
if (!map.containsKey("quota")) {
initCount.incrementAndGet();
map.put("quota", "init");
}
}
}
});
ts[i].start();
}
for (Thread t : ts) {
t.join();
}
System.out.println("期望初始化 1 次,实际 " + initCount.get() + " 次");
}
}程序能否复现取决于线程调度。多个线程在第一个 put 前完成检查时,实际初始化次数可能大于 1;也可能只执行一次。
值已经准备好,且业务只需原子发布「该 key 已初始化」的结果时,可以使用 CHM 的 putIfAbsent。它将「不存在才放入」作为一次原子操作,返回值可区分调用方是否成功放入:
if (map.putIfAbsent("quota", "init") == null) {
initCount.incrementAndGet(); // 返回 null 说明是我放进去的,初始化只算一次
}这段代码只保证 "init" 的发布和计数判断互斥于同一 key 的 putIfAbsent 竞争。initQuota(uid) 含有副作用,直接将其作为 putIfAbsent 的候选值仍可能使多个线程在调用前计算该值。副作用需要在成功取得初始化资格后执行,或者使用额外同步,将资格获取、初始化和结果发布作为整体协调。
同类需求还有 replace(key, oldValue, newValue):当前值等于预期值时才替换,可用于条件更新已有 entry。项目中检查缓存相关代码时,能表达为 CHM 原子方法的条件更新使用对应方法;需要基于旧值进行复杂计算,或需要让外部副作用与状态更新处于同一业务原子范围的逻辑,使用额外同步。
并发容器只提供其 API 定义的原子性和可见性保证。业务操作的原子性取决于这些 API 的组合方式。
当时的理解范围
阅读源码后,我能解释分段锁「16 把锁各管一段」的基本机制,也能根据项目的单 key 缓存访问选择 CHM,并识别复合操作需要额外设计原子性。
CHM 的读取为何不获取 Segment 锁,又如何读取已发布的节点和字段,我当时只追到「关键字段是 volatile」。volatile 的可见性、锁释放和获取建立的关系,以及 JMM 的具体保证,没有继续追完。size() 的跨段统计实现,包括先无锁试算、结果不稳定时再锁住所有段,也只阅读了基本过程。
这些边界没有妨碍当时的选型,但限制了结论范围。后续项目中,涉及单 key 缓存访问时可以使用 CHM;涉及多键关系、外部副作用或结构性更新时,需要先定义业务原子范围,再选择额外同步方案。
参考资料
- JDK 7
ConcurrentHashMap/ReentrantReadWriteLock源码 - 《Java并发编程实战》(Brian Goetz)第 5、11 章

