当前位置: 首页 > news >正文

concurrenthashmap为什么get方法不需要加锁呢

一、get方法为什么不需要加锁

1. 为什么 get 不需要加锁

在 JDK8 的 ConcurrentHashMap 里:

  • get 方法内部基本是:

     1 public V get(Object key) {
     2     Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
     3     int h = spread(key.hashCode());
     4     if ((tab = table) != null && (n = tab.length) > 0 &&
     5         (e = tabAt(tab, (n - 1) & h)) != null) {
     6         if ((eh = e.hash) == h) {
     7             if ((ek = e.key) == key || (ek != null && key.equals(ek)))
     8                 return e.val;
     9         } else if (eh < 0)
    10             return (p = e.find(h, key)) != null ? p.val : null;
    11         while ((e = e.next) != null) {
    12             if (e.hash == h &&
    13                 ((ek = e.key) == key || (ek != null && key.equals(ek))))
    14                 return e.val;
    15         }
    16     }
    17     return null;
    18 }
  • 这里没有锁,依赖的是:

    • 哈希桶数组 table 使用了 volatile 修饰,保证可见性。

    • 节点链表的 next 也是 finalvolatile 方式保证可见性。

    • 读取过程是只读操作,不会修改节点本身,所以不需要锁。


🔹 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 只会存在于旧表或新表之一,不会丢失。

  • 迁移完成的桶会被替换成 ForwardingNodeget 会跳去新表找;

  • 未完成迁移的桶还保留旧链表,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. 总结

  1. get 不加锁,是因为只读 + volatile 保证可见性。

  2. 扩容时,桶级别迁移,未迁移的桶仍在旧表查,迁移完的桶被 ForwardingNode 引导到新表查。

  3. 不存在“只迁移了一半链表导致 key 丢失”的情况。

  4. 这样既保证了性能(无锁读),又保证了正确性(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) { /* 查新表 */ }
}

关键点:

  • hashkeyfinal(不可变);

  • valnextvolatile(写后其他线程可见);

  • 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],如果遇到 ForwardingNodehash < 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 可以无锁且总是安全地读到最新数据?

要点汇总:

  1. 迁移构建新链时不破坏旧链(通常通过 new Node(...) 构建新链或复用完整尾链):因此在 ForwardingNode 安装前,旧链完整可遍历。

  2. ForwardingNode 的安装是一次写入(volatile 写 / setTabAt),写入后其它线程看到的是 ForwardingNode,从而被引导到新表;这个写操作提供了内存可见性屏障,保证新表已构建的节点对读线程可见。

  3. Node 的字段设计(final hash/key, volatile val/next 保证了读线程看到的一致性(不会看到半初始化的 key/hash),并且 next 的写入对其他线程可见。

  4. 读线程的选择逻辑:若读到桶头是普通 Node(hash >=0),直接遍历旧链;若读到 ForwardingNode(hash <0),转到 nextTable 查找。两条路径把旧表和新表在时间上分隔开,互不丢失节点。


6) 额外说明(树化 / treebin 情形)

  • 对于高度冲突的桶,JDK8 会把链表转换为 TreeBin(红黑树),transfer 会把树分裂到两个新槽位。设计同样遵循“先准备好新位,再原子替换为 ForwardingNode”的思路,因此同样不会丢节点。


7) 代码证据(你可以看真实实现)

如果你看 OpenJDK 的 ConcurrentHashMap.transfer(...)(JDK8)实现,会看到上述行为的真实代码片段:

  • 遍历旧链,构造 lohi(通常新 Node),

  • 写入 nextTab 对应槽,

  • 最后 setTabAt(tab, i, new ForwardingNode<>(nextTab))

(我这里给出的伪码已准确反映实现思路;如需我可以贴出简化的真实代码片段并逐行注释。)


结论(一句话)

  • 扩容迁移不会破坏旧链从而导致 get 丢数据。迁移要么没开始(get 在旧表能找到所有节点),要么迁移完成并原子替换(get 会被导到新表并在新表找到所有节点)。因此 get 在扩容期间是安全的,不需要加锁。


 

 

 

http://www.hskmm.com/?act=detail&tid=18120

相关文章:

  • Prometheus_basic_auth
  • dify二次开发之数据库表设计
  • 美国股票市场数据API的完整对接指南,包含NYSE、NASDAQ等主要交易所的实时行情、历史数据、公司信息等核心功能
  • 用宜家说明书的方式了解“快速排序”
  • JAVA变量
  • 深入理解 CSS 浮动:从原理到实战应用​ - space
  • Winform程序中将datagridview导出到excel (推荐)
  • 第二章Pycharm和Jupiter
  • 微服务基础3-服务保护与分布式事务 - 详解
  • 使用parted命令扩容vm内磁盘分区大小
  • Rce漏洞
  • pyinstaller
  • 剖析布谷相亲婚恋交友app源码之关键论述
  • AT_agc052_d [AGC052D] Equal LIS
  • 将网站展示图片的格式由 JPG 切换到了 WebP
  • 【F#学习】元组 Tuple
  • 洛谷题单指南-进阶数论-P3861 拆分
  • AI工作流详解以及应用场景(AI)
  • 20250820_浙江省职业职工技能竞赛_crypto
  • 非结构网格中计算场梯度的手段比较
  • 第一章pytorch安装
  • 钡铼技术:2025工业智能体元年,盘点已推出的工业AI大模型总有一款适合您
  • 微算法科技(NASDAQ MLGO)使用基于深度学习的物理信息神经网络(PINN),增强区块链IoT网络交易中的入侵检测
  • 【MySQL】XML中基于已有查询代码,进一步做汇总统计
  • 别再一张证件照用到底了,我建了个“个人形象库”
  • Vue3.5 + Node.js + Express 实现完整登录注册鉴权流程
  • 【SPIE出版】第七届地球科学与遥感测绘国际学术会议(GRSM 2025)
  • ARL(灯塔)安装步骤--超简单!!
  • 实用指南:Java基础(十四):枚举类详解
  • 传统开水壶升级智能水壶低成本开发方案WT588F02KD-32N