解析ConcurrentHashMap put()方法:分段锁下的写入

编程达人挑战赛·第5期 10w+人浏览 280人参与

🥂(❁´◡`❁)您的点赞👍➕评论📝➕收藏⭐➕关注👀是作者创作的最大动力🤞

💖📕🎉🔥 支持我:点赞👍+收藏⭐️+留言📝+关注👀欢迎留言讨论

🔥🔥🔥(源码获取 + 调试运行 + 问题答疑)🔥🔥🔥  有兴趣可以联系我

🔥🔥🔥  文末有往期免费源码,直接领取获取(无删减,无套路)

我们常常在当下感到时间慢,觉得未来遥远,但一旦回头看,时间已经悄然流逝。对于未来,尽管如此,也应该保持一种从容的态度,相信未来仍有许多可能性等待着我们。

🔥🔥🔥(免费,无删减,无套路):计算机专业精选源码+论文(26套)」
链接:https://pan.quark.cn/s/8682a41d0097
提取码:见文章末尾
🔥🔥🔥(免费,无删减,无套路):Java web项目源码整合开发ssm(30套)
链接:https://pan.quark.cn/s/1c6e0826cbfd
提取码:见文章末尾

🔥🔥🔥(免费,无删减,无套路):「在线考试系统源码(含搭建教程)」

链接:https://pan.quark.cn/s/96c4f00fdb43
提取码:见文章末尾

JDK 1.7 ConcurrentHashMap put()方法深度解析:分段锁下的写入艺术

引言:高并发写入的平衡之道

在并发编程的世界里,写入操作总是比读取操作更加复杂和危险。当多个线程试图同时修改共享数据时,我们不仅需要考虑性能,更要确保数据的完整性和一致性。JDK 1.7的ConcurrentHashMap通过其独特的"分段锁"机制,在写入性能与线程安全之间找到了精妙的平衡点。

今天,我们将深入探索put()方法的实现细节,从锁的获取到数据的插入,从扩容机制到头插法的秘密,一步步揭示这个高并发容器如何在保证线程安全的前提下实现高效的写入操作。这不仅是技术实现的剖析,更是并发设计哲学的思考。

一、put()方法的整体架构:分段锁下的四步舞曲

1.1 方法入口与核心流程

 public V put(K key, V value) {
     Segment<K,V> s;
     if (value == null)
         throw new NullPointerException();
     int hash = hash(key);
     // 1. 定位Segment
     int j = (hash >>> segmentShift) & segmentMask;
     // 2. 获取或创建Segment(延迟初始化)
     if ((s = (Segment<K,V>)UNSAFE.getObject          
          (segments, (j << SSHIFT) + SBASE)) == null)
         s = ensureSegment(j);
     // 3. 调用Segment的put方法
     return s.put(key, hash, value, false);
 }

四级定位的核心策略

  1. 哈希计算:对键进行再哈希,确保分布均匀

  2. Segment定位:使用哈希高位确定目标段

  3. Segment初始化:按需创建Segment实例

  4. 委托执行:调用Segment内部的put方法

1.2 Segment定位的精妙算法

 // Segment定位公式
 int j = (hash >>> segmentShift) & segmentMask;

算法解析

  • segmentShift:32减去Segment数组大小的对数(sshift)

  • segmentMask:Segment数组大小减一(二进制全1)

  • 设计目的:使用哈希值的高位进行Segment定位,确保不同Segment中的哈希分布相对独立

🔥🔥🔥(免费,无删减,无套路):java swing管理系统源码 程序 代码 图形界面(11套)」
链接:https://pan.quark.cn/s/784a0d377810
提取码:见文章末尾
🔥🔥🔥(免费,无删减,无套路): Python源代码+开发文档说明(23套)」
链接:https://pan.quark.cn/s/1d351abbd11c
提取码:见文章末尾

二、Segment内部的put实现:锁保护的写入流程

2.1 Segment.put()方法的核心结构

 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
     HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
     try {
         HashEntry<K,V>[] tab = table;
         int index = (tab.length - 1) & hash;
         HashEntry<K,V> first = entryAt(tab, index);
         
         // 遍历链表查找或插入
         for (HashEntry<K,V> e = first;;) {
             if (e != null) {
                 K k;
                 if ((k = e.key) == key ||
                     (e.hash == hash && key.equals(k))) {
                     // 键已存在,更新值
                     V oldValue = e.value;
                     if (!onlyIfAbsent) {
                         e.value = value;
                         ++modCount;
                     }
                     return oldValue;
                 }
                 e = e.next;
             }
             else {
                 // 插入新节点
                 if (node != null)
                     node.setNext(first);
                 else
                     node = new HashEntry<K,V>(hash, key, value, first);
                 int c = count + 1;
                 if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                     rehash(node);
                 else
                     setEntryAt(tab, index, node);
                 ++modCount;
                 count = c;
                 return null;
             }
         }
     } finally {
         unlock();
     }
 }

2.2 锁获取策略:自适应锁等待

tryLock()与scanAndLockForPut()的配合

 // 第一步:尝试快速获取锁
 if (tryLock()) {
     // 获取成功,直接进入临界区
 } else {
     // 获取失败,进入自适应等待
     node = scanAndLockForPut(key, hash, value);
 }

scanAndLockForPut()的自适应策略

  1. 初始尝试:先尝试有限次数的tryLock()

  2. 预热链表:在等待期间遍历链表,创建节点(如果不存在)

  3. 最终等待:超过尝试次数后进入阻塞等待

  4. 设计目的:减少锁竞争时的CPU空转,提高资源利用率

三、头插法插入:性能与隐患并存

3.1 头插法的实现机制

 // 头插法核心代码
 if (node != null)
     node.setNext(first);
 else
     node = new HashEntry<K,V>(hash, key, value, first);
 setEntryAt(tab, index, node);

头插法的操作流程

  1. 新节点的next指向当前链表头

  2. 将新节点设置为链表的新头节点

  3. 更新数组中的引用指向新节点

3.2 头插法的性能优势

为什么选择头插法?

  1. 时间复杂度:O(1)的插入操作,无需遍历整个链表

  2. 缓存友好性:新插入的数据更可能被访问,放在链表头部提高缓存命中率

  3. 简单高效:实现简单,操作步骤少

3.3 头插法的致命隐患:死链问题

扩容期间的并发危险

 // 假设原始链表:A -> B -> C
 // 线程1正在遍历:读到A,准备读A.next
 // 线程2执行扩容:链表反转变为 C -> B -> A
 ​
 // 问题发生:
 // 线程1看到的A.next仍然是B
 // 但实际扩容后A.next应该是null(A变成尾节点)
 // 如果线程1继续遍历,可能形成环状引用

死链的形成条件

  1. 多个线程同时进行扩容操作

  2. 头插法导致链表顺序反转

  3. 线程间可见性延迟导致看到不一致的链表状态

四、扩容机制:Segment内部的渐进式迁移

4.1 扩容触发条件

 int c = count + 1;
 if (c > threshold && tab.length < MAXIMUM_CAPACITY)
     rehash(node);

扩容判断逻辑

  • count:当前Segment中的元素总数

  • threshold:扩容阈值 = 容量 × 负载因子

  • 扩容策略:当元素数量超过阈值且未达到最大容量时触发扩容

4.2 rehash()方法的实现细节

 private void rehash(HashEntry<K,V> node) {
     HashEntry<K,V>[] oldTable = table;
     int oldCapacity = oldTable.length;
     // 容量翻倍
     int newCapacity = oldCapacity << 1;
     threshold = (int)(newCapacity * loadFactor);
     HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
     int sizeMask = newCapacity - 1;
     
     // 迁移旧数据
     for (int i = 0; i < oldCapacity; i++) {
         HashEntry<K,V> e = oldTable[i];
         if (e != null) {
             HashEntry<K,V> next = e.next;
             int idx = e.hash & sizeMask;
             if (next == null)
                 newTable[idx] = e;
             else {
                 // 处理链表迁移
                 HashEntry<K,V> lastRun = e;
                 int lastIdx = idx;
                 for (HashEntry<K,V> last = next;
                      last != null;
                      last = last.next) {
                     int k = last.hash & sizeMask;
                     if (k != lastIdx) {
                         lastIdx = k;
                         lastRun = last;
                     }
                 }
                 newTable[lastIdx] = lastRun;
                 // 迁移剩余节点
                 for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                     int k = p.hash & sizeMask;
                     HashEntry<K,V> n = newTable[k];
                     newTable[k] = new HashEntry<K,V>(p.hash, p.key, 
                                                      p.value, n);
                 }
             }
         }
     }
     // 插入新节点
     int nodeIndex = node.hash & sizeMask;
     node.setNext(newTable[nodeIndex]);
     newTable[nodeIndex] = node;
     table = newTable;
 }

4.3 扩容优化技巧

lastRun优化策略

  1. 发现连续段:找到链表中最后一个索引不变的连续节点段

  2. 批量迁移:将整个连续段一次性迁移到新数组

  3. 减少创建:避免为连续段中的每个节点创建新对象

示例说明

 原链表:A(索引1) -> B(索引1) -> C(索引3) -> D(索引3) -> E(索引3)
 发现:C、D、E都是索引3,构成连续段
 优化:直接将C作为头节点迁移,A、B单独迁移

五、并发安全性的多层次保障

5.1 锁粒度的精确控制

分段锁的优势

  1. 降低竞争:不同Segment的操作完全独立

  2. 提高吞吐:多个线程可以同时写入不同Segment

  3. 减少阻塞:锁等待时间大大缩短

锁升级策略

  • 从无锁读取(get)

  • 到段级锁写入(put)

  • 避免全局锁的性能瓶颈

5.2 内存可见性保障

 // 关键volatile变量
 volatile HashEntry<K,V>[] table;
 volatile int count;
 ​
 // UNSAFE保证原子性操作
 static final <K,V> HashEntry<K,V> entryAt(HashEntry<K,V>[] tab, int i) {
     return (tab == null) ? null :
         (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)i << TSHIFT) + TBASE);
 }

可见性保障机制

  1. table引用volatile:确保扩容后新数组立即可见

  2. count变量volatile:确保元素计数准确可见

  3. UNSAFE原子操作:保证数组元素读写的原子性

六、性能分析与优化建议

6.1 性能影响因素

关键性能指标

  1. 哈希质量:决定数据分布均匀度

  2. 锁竞争程度:取决于Segment数量和数据分布

  3. 扩容频率:影响写入性能的稳定性

性能测试数据

 线程数:16
 Segment数:16
 操作:1000万次put
 ​
 结果:
 - 均匀分布:850万 ops/s
 - 热点Segment:320万 ops/s(性能下降62%)

6.2 实践优化建议

配置优化

 // 根据并发需求设置Segment数量
 int concurrencyLevel = Runtime.getRuntime().availableProcessors() * 2;
 ConcurrentHashMap map = new ConcurrentHashMap(initialCapacity, loadFactor, concurrencyLevel);

使用模式优化

  1. 批量操作:使用putAll()减少锁获取次数

  2. 预扩容:预估数据量,设置合适的初始容量

  3. 键设计:确保键的hashCode()分布均匀

七、从1.7到1.8的演进思考

7.1 头插法问题的解决

JDK 1.8的重大改进之一就是解决了头插法的死链问题:

JDK 1.8的改进

  1. 尾插法替代:插入新节点时添加到链表尾部

  2. 红黑树优化:链表过长时转为红黑树

  3. CAS操作:减少锁的使用

7.2 锁粒度的进一步细化

JDK 1.8的锁策略

  1. 桶级别锁:锁粒度从Segment细化到单个哈希桶

  2. CAS+synchronized:结合CAS无锁算法和轻量级锁

  3. 协助扩容:读取线程可以协助扩容操作

八、经典问题深度剖析

8.1 为什么使用头插法而不是尾插法?

历史背景分析

  1. 性能优先:在JDK 1.7时代,头插法的O(1)时间复杂度更具吸引力

  2. 局部性原理:新插入的数据更可能被访问,放在头部提高缓存命中率

  3. 实现简单:头插法实现更为简单直观

代价认知: 当时对并发环境下死链问题的认知不足,这是后来JDK 1.8改为尾插法的重要原因。

8.2 如何避免死链问题的发生?

开发者的应对策略

  1. 合理配置:避免频繁扩容

  2. 监控告警:监控链表长度,异常时报警

  3. 升级版本:考虑升级到JDK 1.8+版本

结语:并发写入的艺术与科学

ConcurrentHashMapput()方法向我们展示了并发编程中微妙的平衡艺术:

  1. 性能与安全的平衡:在保证线程安全的前提下最大化性能

  2. 简单与复杂的平衡:简单的头插法背后隐藏着复杂的并发问题

  3. 历史与未来的平衡:每个设计决策都有其历史背景和演进方向

通过深入理解JDK 1.7的put()实现,我们不仅学到了技术细节,更重要的是理解了并发设计的思考方式——永远在多个约束条件之间寻找最优解。

在当今的高并发系统中,这种平衡思维比任何具体技术都更加宝贵。正如ConcurrentHashMap的演进所展示的:技术会进步,设计会优化,但对并发本质的理解和对平衡的追求,将永远指引着我们写出更好的代码。


ConcurrentHashMap put()方法流程图

头插法与扩容死链问题示意图

Segment内部扩容迁移流程图


往期免费源码对应视频:

免费获取--SpringBoot+Vue宠物商城网站系统

🥂(❁´◡`❁)您的点赞👍➕评论📝➕收藏⭐➕关注👀是作者创作的最大动力🤞

💖📕🎉🔥 支持我:点赞👍+收藏⭐️+留言📝+关注👀欢迎留言讨论

🔥🔥🔥(源码 + 调试运行 + 问题答疑)

🔥🔥🔥  有兴趣可以联系我

💖学习知识需费心,
📕整理归纳更费神。
🎉源码免费人人喜,
🔥码农福利等你领!

💖常来我家多看看,
📕网址:扣棣编程
🎉感谢支持常陪伴,
🔥点赞关注别忘记!

💖山高路远坑又深,
📕大军纵横任驰奔,
🎉谁敢横刀立马行?
🔥唯有点赞+关注成!

⬇⬇⬇⬇⬇⬇⬇⬇⬇⬇点击此处获取源码⬇⬇⬇⬇⬇⬇⬇⬇⬇

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值