「Java 进阶之路」系列 Day08
写在前面
前七篇讲的都是锁、CAS 这些底层机制,这篇开始进入更贴近日常业务代码的话题——并发容器。写业务代码时很少会自己手写 synchronized/Lock 去保护一个 HashMap,绝大多数场景直接用 java.util.concurrent 包里现成的线程安全容器就够了。这篇重点拆两个最常被问到的:ConcurrentHashMap 和 CopyOnWriteArrayList,顺带把它们各自的底层原理讲清楚,而不是停留在"能用就行"的层面。
一、是什么:为什么不能直接把 HashMap 扔进多线程环境
先看三者的对比:
| HashMap | Hashtable | ConcurrentHashMap | |
|---|---|---|---|
| 线程安全 | 不安全 | 安全,全表加锁 | 安全,桶级别加锁 |
| 并发度 | 不适用 | 1,完全串行 | 高,默认16且JDK8之后更细 |
| null key value | 允许 | 不允许 | 不允许 |
| 适用场景 | 单线程 | 基本被淘汰 | 多线程首选 |
HashMap 本身没有任何同步措施,多线程并发 put 可能导致链表成环、数据丢失,是实打实的 bug 来源;Hashtable 虽然线程安全,但靠的是给整张表加一把锁,同一时刻只能有一个线程操作,并发度约等于单线程;ConcurrentHashMap 则是"既要线程安全,又要尽量高并发"这个目标下的产物。
二、ConcurrentHashMap:从 JDK7 到 JDK8 的进化
实现方式的重大变化
| JDK7 | JDK8及之后 | |
|---|---|---|
| 数据结构 | Segment数组加HashEntry链表 | Node数组加链表或红黑树 |
| 锁粒度 | Segment,默认16个 | 单个桶,即Node头节点 |
| 加锁方式 | ReentrantLock | synchronized加CAS |
| 并发度 | 固定,等于Segment数量 | 动态,最高等于桶的数量 |
JDK7 的思路是"分段加锁"——把整个表切成 16 个 Segment,每个 Segment 各自加锁,理论并发度就是 16。JDK8 把锁粒度进一步细化到每一个桶(数组里的每个位置),桶的数量通常远大于 16,并发度也就跟着上去了。
JDK8 核心机制:桶的四种状态
flowchart TB
A[数组Node数组] --> B[桶为空]
A --> C[桶有链表 长度小于8]
A --> D[桶有红黑树 长度大于等于8]
A --> E[桶是ForwardingNode]
B --> F[CAS无锁插入]
C --> G[synchronized锁头节点做链表操作]
D --> H[synchronized锁头节点做树操作]
E --> I[正在扩容 其他线程会协助迁移]
put 的完整流程
1 计算hash 定位到具体的桶
2 桶为空 直接CAS插入 成功就返回 不用加锁
3 桶是ForwardingNode 说明正在扩容 调用helpTransfer协助扩容后重试
4 桶里已经有节点 synchronized锁住桶的头节点 链表就遍历更新或者尾插 红黑树就走树插入
5 如果链表长度到达8并且数组长度到达64 触发树化
6 元素数量加1 检查是否需要整体扩容
这里最值得记住的一点:JDK8 的 ConcurrentHashMap 在桶为空时完全靠 CAS 无锁插入,只有桶里已经有数据、发生哈希冲突时才需要 synchronized 锁住这一个桶的头节点——绝大多数插入操作走的都是无锁路径,这也是它并发度能做到接近桶数量的原因。
扩容:多个线程一起干活
扩容时,ConcurrentHashMap 会把旧数组按区段分给多个线程,各自认领一段数据去搬迁,谁先来谁先干(不是单线程慢慢等)。迁移完的桶会放一个 ForwardingNode 作为标记,其他线程 put 时如果碰到这个标记,就知道"这里正在扩容",会主动加入协助迁移,而不是傻等。
size() 是怎么统计的
高并发下如果 size() 用一把全局锁去统计,会成为新的性能瓶颈。JDK8 的做法和 Day07 讲的 LongAdder 思路一致:维护一个 baseCount 加一个 CounterCell 数组分散计数,减少多线程写同一个计数器时的竞争。
最容易踩的坑:复合操作不是原子的
// 错误 两步操作之间可能被别的线程插队
if (!map.containsKey(key)) {
map.put(key, value);
}
// 正确 用原子的复合方法
map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -> new ArrayList<>());
map.merge(key, 1, Integer::sum);
ConcurrentHashMap 只保证单个方法调用内部是线程安全的,但"先检查再操作"这种由两个方法拼起来的复合逻辑,中间是有窗口期的——containsKey 返回 false 之后、put 真正执行之前,别的线程完全可能已经把这个 key 塞进去了。要保证复合逻辑的原子性,必须用 putIfAbsent/computeIfAbsent/merge 这类内置的原子复合方法。
三、CopyOnWriteArrayList:用写时复制换读无锁
核心思想
读操作完全不加锁,直接读当前的数组引用;写操作时,先复制一份完整的底层数组,在副本上修改,改完再用新数组替换旧的引用,原数组在替换之前始终保持不变。
读 任何时刻 直接读array引用 无锁
写 add remove set等操作
1 加ReentrantLock锁
2 复制当前array为一个新数组 长度加1
3 在新数组上做修改
4 把引用指向新数组
5 解锁
适用场景:读极多写极少才划算
| 场景 | 是否合适 |
|---|---|
| 读多写极少 比如事件监听器列表 | 非常合适 |
| 读写频率相近 | 不合适 每次写都要复制整个数组 |
| 数据量很大 | 不合适 复制的内存开销太高 |
| 需要强一致性读 | 不合适 读到的是快照 |
弱一致性:迭代器拿到的是快照
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
Iterator<Integer> it = list.iterator(); // 拿到的是当前数组的快照
list.add(2); // 新元素只加到了新的副本数组里
it.next(); // 这个迭代器依然只能看到1 看不到2
这是 CopyOnWriteArrayList 和普通加锁方案最大的行为差异——它的迭代器创建那一刻起就固定绑定了当时的数组快照,之后的写操作只会替换底层引用,不会影响已经创建好的迭代器,所以遍历过程中不会抛 ConcurrentModificationException,但也看不到遍历开始之后的新增元素。
和 synchronizedList 的对比
| CopyOnWriteArrayList | synchronizedList | |
|---|---|---|
| 读 | 无锁 | 需要加锁 |
| 写 | 复制数组加锁 | 加锁 |
| 迭代 | 不需要额外加锁 拿到的是快照 | 需要手动加锁遍历 |
| 一致性 | 弱 读到的是快照 | 强 |
四、全家桶里的其他成员
除了这两个重点,java.util.concurrent 里还有基于 CAS 无锁实现的 ConcurrentLinkedQueue(适合不需要阻塞等待的高并发队列场景),这篇先不展开。下一篇会专门讲 ThreadLocal(内存泄漏原理是面试超高频考点)和 BlockingQueue 家族(ArrayBlockingQueue/LinkedBlockingQueue 怎么选),把并发容器这块彻底讲完。
五、面试追问
Q1:ConcurrentHashMap 在 JDK7 和 JDK8 里的实现有什么本质区别?
JDK7 用 Segment 分段加锁,把整个表切成默认16个段,每段用 ReentrantLock 独立加锁,并发度固定等于段数;JDK8 把锁粒度细化到每一个桶(数组的每个位置),底层结构也从"数组加链表"升级为"数组加链表或红黑树",加锁方式换成 synchronized 加 CAS,并发度不再固定,理论上能到桶的数量,通常远高于JDK7的16。
Q2:ConcurrentHashMap 的 put 操作在什么情况下不需要加锁?
当计算出的目标桶当前是空的时候,直接用 CAS 无锁插入即可成功,不需要 synchronized。只有当目标桶已经存在节点、发生哈希冲突时,才需要 synchronized 锁住这个桶的头节点去做链表或红黑树的插入操作。这也是JDK8并发度大幅提升的关键原因。
Q3:为什么 containsKey 加 put 这种写法在 ConcurrentHashMap 上不安全?
因为 ConcurrentHashMap 只保证单个方法调用内部的原子性,不保证多个方法调用组合在一起的原子性。containsKey 返回 false 到 put 真正执行之间存在时间窗口,这段时间里别的线程完全可能已经把同一个 key 插入进去,导致两个线程都以为自己是第一个插入者。要保证这种"先检查后操作"逻辑的原子性,必须用 putIfAbsent、computeIfAbsent 或 merge 这些内置的原子复合方法。
Q4:CopyOnWriteArrayList 为什么适合读多写少的场景,不适合写多的场景?
它的读操作完全无锁、直接读当前数组,性能很好;但每次写操作都要复制一份完整的底层数组、在副本上修改、再替换引用,写的开销和数组长度成正比。如果写操作频繁或者数据量很大,每次都要复制整个数组,内存分配和拷贝的开销会变得很高,这时候用它反而比普通加锁方案更慢,所以只适合读远多于写、且数据量不大的场景,比如事件监听器列表。
Q5:CopyOnWriteArrayList 的迭代器为什么不会抛 ConcurrentModificationException?
因为它的迭代器在创建的那一刻就绑定了当时底层数组的一个快照引用,遍历过程中即使有其他线程执行了写操作,也只是创建了一个新的数组副本并替换了 CopyOnWriteArrayList 内部的引用,完全不会影响已经创建好、绑定着旧数组的迭代器,所以遍历时不存在"结构被修改"的冲突,自然不会抛这个异常。代价是迭代器看到的是遍历开始那一刻的旧数据,看不到遍历过程中新增的内容,这就是它的弱一致性。
下一篇预告
Day09 讲 ThreadLocal 的内存泄漏原理,以及 BlockingQueue 家族里 ArrayBlockingQueue 和 LinkedBlockingQueue 的实现差异,怎么根据场景选阻塞队列。