数据结构与集合(八) --- Map类集合之(ConcurrentHashMap)

本文详细介绍了Java中的并发集合ConcurrentHashMap,包括CAS机制、发展历程、源码分析,以及在不同版本中的优化策略,特别是针对计算集合size()的改进。通过对属性和内部类的探讨,揭示了ConcurrentHashMap如何在高并发场景下保证线程安全和性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


前言

考虑到线程并发安全性,ConcurrentHashMap 是比HashMap 更加推荐的一种哈希式集合. JDK8 对ConcurrentHashMap 进行了脱胎换骨式的改造,使用了大量的lock-free 技术来减轻因锁的竞争而对性能造成的影响. 它是学习并发编程的一个绝佳示例,此类超过6300 行代码,涉及 volatile , CAS ,锁 , 链表 ,红黑树等众多知识点.

一、CAS扩展说明

CAS( Compara And Swap) , 它是解决轻微冲突的多线程并发场景下使用锁造成性能损耗的一种机制. 每一次执行都必须进行加锁和解锁的成本是比较高的,在并发度比较低的情况下, 这种时间成本消耗是比较奢侈的, CAS 就是先比较,如果不符合预期,则进行重试. CAS 操作包含三个操作要素: 内存位置 ,预期原值(假设为A) 和新值(假设为B) .如果内存位置的值与预期原值相等,则处理器将该位置更新为新值. 如果不相等,则获取当前值,然后进行不断的轮询操作,直到成功或达到某个阈值退出, 典型代码如下:

public final int getAndIncrement(){
	for(;;){
		int current = get();
		int next = current + 1;
		if (compareAndSet(current, next)){
			return current;
		}
	}
}

上述示例代码中的compareAndSet 的功能是与current 比较,如果相等则值变为next , 这个原子操作时在硬性层面来保证的, 唯一要避免的是ABA 问题(例如:这里会有一个问题,如果一个线程将0改成了1,因为某些操作内存中的V值又变为了0,那么第二个线程+1返回1也会成功,那么线程一的操作不就没有意义了?这就是ABA问题,解决这个问题的本质就是使线程1的操作对线程二可见,所以最简单的做法就是对V值加版本号控制即可,使变化可见).

二、高并发场景下哈希式集合历史

----> Hashtable 是在 JDK1.0 中引入的哈希式集合, 以全互斥的方式处理并发情况,性能极差;
----> HashMap 是在JDK1.2 中引入的,是非线程安全的,它最大的问题是在并发写的情形下,容易出现死链问题,导致服务不可用;
----> ConcurrentHashMap 是在JDK5 中引入的线程安全的哈希式集合;
----> 在JDK8 之前采用分段锁的设计理念,相当于Hashtable 与 HashMap的折中版, 这是效率与一致性权衡后的结果. 分段锁是右内部类 Segment 实现的,它继承于ReentrantLock , 用来管理它辖区的各个 HashEntry , ConcurrentHashMap 被 Segment 分成了很多小区, Segment 就相当于小区保安, HashEntry 列表相当于小区业主,小区保安通过加锁的方式, 保证每个Segment 内都不发生冲突 .
----> 以下对JDK11 版本的ConcurrentHashMap 进行深度分析, 它对JDK7 的版本进行了三点改造:

  1. 取消分段锁机制,进一步降低冲突概率.
  2. 引入红黑树机构. 同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构.
  3. 使用了更加优化方式统计集合内的元素数量. 首先,Map原有的size() 方法最大只能表示到 231 -1 ,ConcurrentHashMap 额外提供了mappingCount() 方法,用来返回集合内元素的数量, 最大可以表示到263 -1 . 此外,在元素总数更新时,使用了CAS 和多种优化以提高并发能力.

三、ConcurrentHashMap 属性源码分析

了解ConcurrentHashMap 的相关属性定义, 对这些信息的深刻理解有助于对整个类的体现认知. 如下所示:

// 默认为null, ConcurrentHashMap 存放数据的地方,扩容时大小总是2 的幂次方
// 初始化发生在第一次插入操作,数组默认初始化大小为16
transient volatile Node<K, V>[] table;

// 默认为null, 扩容时新生成的数组,其大小为原数组的两倍
private transient volatile Node<K, V>[] nextTable;

/**
* 存储单个KV 数据节点, 内部有key, value, hash, next 指向下一个节点
* 它有4个在ConcurrentHashMap 类内部定义的子类:
* TreeBin, TreeNode, ForwardingNode, ReservationNode
* 前3个子类都重写了查找元素的重要方法find()
* 这些节点的概念必须清晰的区分,否则极大阻碍对源码的理解
*/
static class Node<K, V> implements Map.Entry<K, V> {...}

// 它并不存储实际数据,维护对桶内红黑树的读写锁,存储对红黑树节点的引用
static final class TreeBin<K, V> extends Node<K, V> {...}

// 在红黑树结构中,实际存储数据的节点
static final class TreeNode<K, V> extends Node<K, V> {...}

// 扩容转发节点,放置此节点后,外部对原有哈希槽的操作会转发到 nextTable 上
static finale class ForwardingNode<K, V> extends Node<K, V> {...}

// 占位加锁节点,执行某些方法时,对其加锁,如computeIfAbsent 等
static final class ReservationNode<K, V> extends Node<K, V> {...}

/**
* 默认为0, 重要属性,用来控制table 的初始化和扩容操作
* sizeCtl = -1 ,表示正在初始化中
* sizeCtr = -n ,表示(n-1) 个线程正在进行扩容中
* sizeCtr > 0 ,初始化或扩容中需要使用的容量
* sizeCtr = 0 ,默认值,使用默认容量进行初始化
*/
private transient volatile int sizeCtr;

// 集合size小于64 ,无论如何,都不会使用红黑树结构
// 转化为红黑树还有一个条件时 TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

//  同一个哈希桶内存储的元素个数超过此阈值时,则存储结构由链表转为红黑树
static final int TREEIFY_THRESHOLD = 8;

// 同一个哈希桶内存储的元素个数小于等于此阈值时,从红黑树回退到链表结构,因为元素个数较少时,链表更快
static final int UNTREEIFY_THRESHOLD = 6;

根据属性的初步认识, 可以勾勒出ConcurrentHashMap 的大致存储结构,如下图所示:
在这里插入图片描述
如图所示,table 的长度为64, 数据存储结构分为两种: 链表和红黑树 .
某个槽内的元素个数增加到超过8个且table 的容量大于等于64 时,由链表转为红黑树; 当某个槽内的元素个数减少到6个时,由红黑树重新转回链表. 链表转红黑树的过程,就是把给定顺序的元素构造成一颗红黑树的过程. 需要注意的是, 当table 的容量小于64 时,只会扩容,并不会把链表转为红黑树. 在转化过程中,使用同步块锁住当前槽的首元素,防止其他进程对当前槽进行增删改操作,转化完成后利用CAS 替换原有链表. 因为TreeNode 节点也存储了next 引用,所以红黑树转链表的操作就变得非常简单,只需从TreeBin 的first 元素开始遍历所有的节点,并把节点从TreeNode 类型转化为 Node 类型即可,当构造好新的链表之后,会同样利用CAS 替换原有的红黑树.
相对来说,链表转红黑树更为复杂, 流程图如下图所示:
在这里插入图片描述

触发上述存储结构转化最主要的操作是增加元素,即 put() 方法. 基本思想与HashMap 一致, 区别就是增加了锁的处理, ConcurrentHashMap 元素插入扩容流程图如下图所示:
在这里插入图片描述

四、 ForwardingNode 和 ReservationNode 类

ForwardingNode 在table 扩容时使用, 内部记录了扩容后的table, 即nextTable . 当table 需要进行扩容时,依次遍历当前table 中的每一个槽,如果不为null, 则需要把其中所有的元素根据hash 值放入扩容后的nextTable 中, 而原table 的槽内会放置一个ForwardingNode 节点. 正如其名,此节点会把find() 请求转发到扩容后的nextTable 上, 而执行put() 方法的线程如果碰到此节点,也会协助进行迁移 .

ReservationNode 在computeIfAbsent() 及其相关方法中作为一个预留节点使用. computeIfAbsent() 方法会先判断响应的Key 值是否已存在,如果不存在,则调用由用户实现的自定义方法来生成Value 值,组成KV 键值对,随后插入此哈希集合中. 在并发场景下,在从得知Key 不存在到插入哈希集合的时间间隔内,为了防止哈希槽被其他线程抢占,当前线程会使用一个ReservationNode 节点放到槽中并加锁,从而保证了线程的安全性 .

五、ConcurrentHashMap 对于计算集合size() 的优化

需要注意的是,无论是JDK7 还是 JDK8 ,ConcurrentHashMap 的size() 方法都只能返回一个大概数量, 无法做到100% 的精确,因为已经统计过的槽在size() 返回最终结果前有可能又出现了变化,从而导致返回大小与实际大小存在些许差异. 在多个槽的设计下, 如果仅仅是为了统计元素数量而停下所有的增删操作,又会显得因噎废食 . 因此, ConcurrentHashMap 在涉及元素总数的相关更新和计算时,会最大限度的减少锁的使用,以减少线程间的竞争与互相等待. 在这个设计思路下, JDK8 的ConcurrentHashMap 对元素总数的计算右做出了进一步的优化,具体表现在: 在 put() , remove() 和 size() 方法中,涉及元素总数的更新和计算, 都彻底避免了锁的使用,取而代之的是众多的CAS操作.
在JDK7 版本中的put() 方法和remove() 方法,对于segment 内部元素和计数器的更新,全部处于锁的保护下, 如Segment.put() 方法的第一行:

// 经过这一行代码,能够保证当前线程取得该Segment 上的锁,随后可以大胆地更新元素和内部的计数器
HashEntry<K> node = tryLock() ? null : scanAndLockForPut(key, hash, value);

而JDK7 版本的ConcurrentHashMap 获取集合大小流程图如下图所示:
在这里插入图片描述
可以看到,在JDK7 版本中, ConcurrentHashMap 在统计元素总数时已经开始避免使用锁了,毕竟加锁操作会极大影响到其他线程对于哈希元素的修改, 当经过了3次计算(2次对比) 后, 发现每次统计时哈希都有结构性的变化, 这时它就会"气急败坏" 地把所有Segment都加上锁; 而当自己统计完成后,才会把锁释放掉,再允许其他线程修改哈希中的元素 .

  • 获取集合元素个数是否还有进一步的优化空间呢?
    答案在JDK8 的ConcurrentHashMap 里,在put() 中,对于哈希元素总数的更新,是置于某个槽的锁之外的,主要会用到的属性如下:
// 记录了元素总数值, 主要用在无竞争状态下
// 在总数更新后,通过CAS 方式直接更新这个值
private transient volatile long baseCount;

// 一个计数器单元, 维护了一个value 值
static final class CounterCell{...}

// 在竞争激烈的状态下启用,线程会把总数更新情况存放到该结构内
// 当竞争进一步加剧时,会通过扩容减少竞争
private transient volatile CounterCell[] counterCells;

正是借助baseContent 和 countCells 两个属性,并配合多次使用CAS 方法,JDK8 中的ConcurrentHashMap 避免了锁的使用. 虽然源码过程看起来非常复杂,但是思路却很清晰.

  • 当并发量较小时,优先使用CAS 的方式直接更新baseCount ;
  • 当更新baseCount 冲突,则会认为进入到比较激烈的竞争状态,通过启用counterCells 减少竞争,通过CAS 的方式把总数更新情况记录在counterCells 对应的位置上 ;
  • 如果更新counterCells 上的某个位置时出现了多次失败,则会通过扩容counterCells 的方式减少冲突 ;
  • 当counterCells 处在扩容期间时,会尝试更新baseCount 值.

对于元素总数的统计,逻辑就非常简单了,只需要让baseCount 加上各 counterCells 内的数据, 就可以得出哈希内的元素总数,整个过程完全不需要借助锁 ;


总结

正因为ConcurrentHashMap 提供了高效的锁机制实现, 在各种多线程引用场景中, 推荐使用此集合进行KV 键值对的存储与使用 .
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值