一、get方法为什么不需要加锁
1. 为什么 get
不需要加锁
在 JDK8 的 ConcurrentHashMap
里:
-
get
方法内部基本是: -
这里没有锁,依赖的是:
-
哈希桶数组
table
使用了volatile
修饰,保证可见性。 -
节点链表的
next
也是final
或volatile
方式保证可见性。 -
读取过程是只读操作,不会修改节点本身,所以不需要锁。
-
🔹 2. 那么扩容 (resize) 阶段会不会丢数据?
我们分析下 JDK8 ConcurrentHashMap 的迁移逻辑。
2.1 扩容触发
当元素过多,ConcurrentHashMap
会创建一个新的 nextTable
,并迁移节点。迁移由多个线程共同完成,每个线程负责一部分桶。
2.2 ForwardingNode 协调
在迁移过程中,一个桶被迁移完后,会被替换成一个特殊的节点 ForwardingNode
,代码大概是:
static final class ForwardingNode<K,V> extends Node<K,V> {final Node<K,V>[] nextTable;ForwardingNode(Node<K,V>[] tab) {super(MOVED, null, null, null);this.nextTable = tab;}Node<K,V> find(int h, Object k) {// 在新表 nextTable 里找return findInNewTable(h, k);} }
-
当
get
过程中遇到ForwardingNode
,说明该桶已经迁移完毕,它会自动去 新表里找对应的 key。
2.3 桶未迁移完的情况
-
如果桶还没迁移完(部分链表节点迁移了,部分还在旧表),那么该桶位置还指向旧链表。
-
此时
get
会直接在旧链表里遍历,仍然能读到尚未迁移的节点。 -
所以不会出现“key 丢失”。
换句话说:
-
迁移过程中,某个 key 只会存在于旧表或新表之一,不会丢失。
-
迁移完成的桶会被替换成
ForwardingNode
,get
会跳去新表找; -
未完成迁移的桶还保留旧链表,
get
就在旧表找。
🔹 3. 会不会少读?
不会。
-
get
要么在旧表里读(未迁移完成的桶), -
要么被
ForwardingNode
引导到新表里读(迁移完成的桶)。 -
不存在读到“一半”的情况,因为链表迁移是逐桶完成的,而不是逐节点即时替换。
🔹 4. 具体代码支撑
关键逻辑在 ConcurrentHashMap.transfer()
和 ForwardingNode.find()
:
// 当桶迁移完成后,会用 ForwardingNode 替换 setTabAt(tab, i, fwd);// get 中遇到 hash < 0 (MOVED)时,会走 ForwardingNode.find() else if (eh < 0)return (p = e.find(h, key)) != null ? p.val : null;
保证了 get
在迁移期间的正确性。
5. 总结
-
get
不加锁,是因为只读 + volatile 保证可见性。 -
扩容时,桶级别迁移,未迁移的桶仍在旧表查,迁移完的桶被 ForwardingNode 引导到新表查。
-
不存在“只迁移了一半链表导致 key 丢失”的情况。
-
这样既保证了性能(无锁读),又保证了正确性(ForwardingNode + 桶级迁移)。
二、扩容的时候是新建节点还是直接修改原节点
先给出结论:
-
扩容的时候是新建节点,而不是像Hashmap那样在原节点基础上做修改
-
不会出现
get
在扩容过程中丢失节点的情况。原因是:在 JDK8 的实现里,迁移是按照「桶(bin)级别」完成的;迁移线程要么为该桶构建并安装好新表的节点(通常是新建节点的链表或复用一个完整的尾子链),然后原子地把旧桶位置替换成ForwardingNode
;在替换之前,旧链表保持可遍历(没有被破坏)。因此,get
要么在旧链上找到节点,要么遇到ForwardingNode
被引导到新表查找,新表里也有完整节点 —— 不会“少读”节点。
下面分步骤、带关键代码片段(简化版)说明实现细节与不丢失的理由。
1) 相关数据结构简要(关键字段)
static class Node<K,V> {final int hash;final K key;volatile V val;volatile Node<K,V> next;Node(int hash, K key, V val, Node<K,V> next) {this.hash = hash; this.key = key; this.val = val; this.next = next;} } static final class ForwardingNode<K,V> extends Node<K,V> {final Node<K,V>[] nextTable;ForwardingNode(Node<K,V>[] nt) { super(MOVED, null, null, null); nextTable = nt; }Node<K,V> find(int h, Object k) { /* 查新表 */ } }
关键点:
-
hash
、key
是final
(不可变); -
val
和next
是volatile
(写后其他线程可见); -
ForwardingNode
用来标识该旧桶已被迁移完,并包含nextTable
指向新表。
2) get
的行为(简化)
V get(Object key) {Node<K,V>[] tab = table; int n = tab.length; Node<K,V> e = tabAt(tab, index);if (e == null) return null;int eh = e.hash;if (eh == h && equals) return e.val;else if (eh < 0) // 注意:负 hash 表示特殊节点,如 ForwardingNodereturn e.find(h, key); // ForwardingNode.find 会在 newTable 中继续查while ((e = e.next) != null) { ... } // 遍历链表或 treereturn null; }
要点:get
不加锁、直接读 tab[index]
,如果遇到 ForwardingNode
(hash
< 0,表示已经迁移),它会查 nextTable
(新表)。
3) transfer
(迁移)核心逻辑(简化伪码)
下面是关键流程的简化表示(真实代码更复杂,有优化):
void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {int n = tab.length;for (int i = 0; i < n; ++i) {Node<K,V> f = tabAt(tab,i);if (f == null) {setTabAt(tab, i, new ForwardingNode<>(nextTab)); // 桶为空,直接放转发节点continue;}if (f instanceof ForwardingNode) continue; // 已被迁移// 关键:对 f 同步(或其他协作)以避免并发重复迁移synchronized (f) {if (tabAt(tab,i) != f) continue; // recheck// 将 old 链表拆分为 lo 和 hi 两条链(按 hash & n)Node<K,V> loHead = null, hiHead = null;Node<K,V> e = f;for (; e != null; e = e.next) {if ((e.hash & n) == 0) {// 创建新节点(注意:通常会 new Node(...),不是直接改旧节点)loHead = new Node<>(e.hash, e.key, e.val, loHead);} else {hiHead = new Node<>(e.hash, e.key, e.val, hiHead);}}// 写入 nextTab 的对应槽位(新表) setTabAt(nextTab, i, reverse(loHead));setTabAt(nextTab, i + n, reverse(hiHead));// 最后,用 ForwardingNode 原子替换旧表该槽,表示迁移完成setTabAt(tab, i, new ForwardingNode<>(nextTab));}} }
重要细节:
-
对多数节点,迁移过程是 新建节点(
new Node(...)
)并链成新表的链表;也就是说旧链表的next
指针并没有被破坏或改写——旧链仍然完整可遍历,直到迁移线程把tab[i]
替换成ForwardingNode
为止。 -
有个优化(
lastRun
)会尝试在满足条件时直接重用旧链表的一段尾部以减少分配,但那段被重用的是一个连续的子链,迁移代码确保不会把旧链拆成不连续段从而破坏旧链的可遍历性。
因此:在安装 ForwardingNode
之前,旧桶链保持可遍历(未被破坏)。一旦 ForwardingNode
安装完成,get
若遇到它会自动到新表查找(新表已被完整填充该桶对应的节点)。
4) 并发场景逐步说明(关键保证)
考虑你举的“总共 7 个节点,迁移了 3 个到 new table,新表已有 4 个,旧表剩 3 个” 的假设 —— 这在实现上不会发生,因为:
-
transfer 不会先把前 3 个节点“挪走”并同时把
tab[i]
指向新的小链;实现是先构建好 newTable 对应槽位(通过 new Node(...) 或复用尾链),再原子地把tab[i]
换成 ForwardingNode。 -
在这整个过程中,旧链要么仍完好(迁移还没替换 ForwardingNode),
get
在旧链能找到所有节点;要么迁移完成并安装了 ForwardingNode,此时get
会被引导去新表,而新表中该桶的链是完整的(含全部应有节点)。 -
不会出现“旧表只剩下 3 个可遍历节点,而新表只有 4 个、合起来是 7 个但分布在两处且旧串已被破坏从而部分节点不可达”的情况。
换言之,迁移对外的可见性是原子切换:切换前旧数据完整可见;切换后新表完整可见——没有中间状态丢失数据。
5) 为什么 get
可以无锁且总是安全地读到最新数据?
要点汇总:
-
迁移构建新链时不破坏旧链(通常通过 new Node(...) 构建新链或复用完整尾链):因此在 ForwardingNode 安装前,旧链完整可遍历。
-
ForwardingNode 的安装是一次写入(volatile 写 / setTabAt),写入后其它线程看到的是 ForwardingNode,从而被引导到新表;这个写操作提供了内存可见性屏障,保证新表已构建的节点对读线程可见。
-
Node 的字段设计(
final hash/key
,volatile val/next
) 保证了读线程看到的一致性(不会看到半初始化的 key/hash),并且next
的写入对其他线程可见。 -
读线程的选择逻辑:若读到桶头是普通 Node(hash >=0),直接遍历旧链;若读到 ForwardingNode(hash <0),转到 nextTable 查找。两条路径把旧表和新表在时间上分隔开,互不丢失节点。
6) 额外说明(树化 / treebin 情形)
-
对于高度冲突的桶,JDK8 会把链表转换为
TreeBin
(红黑树),transfer
会把树分裂到两个新槽位。设计同样遵循“先准备好新位,再原子替换为 ForwardingNode”的思路,因此同样不会丢节点。
7) 代码证据(你可以看真实实现)
如果你看 OpenJDK 的 ConcurrentHashMap.transfer(...)
(JDK8)实现,会看到上述行为的真实代码片段:
-
遍历旧链,构造
lo
和hi
(通常新Node
), -
写入
nextTab
对应槽, -
最后
setTabAt(tab, i, new ForwardingNode<>(nextTab))
。
(我这里给出的伪码已准确反映实现思路;如需我可以贴出简化的真实代码片段并逐行注释。)
结论(一句话)
-
扩容迁移不会破坏旧链从而导致
get
丢数据。迁移要么没开始(get
在旧表能找到所有节点),要么迁移完成并原子替换(get
会被导到新表并在新表找到所有节点)。因此get
在扩容期间是安全的,不需要加锁。