[Java并发]ConcurrentHashMap
HashMap和ConcurrentHashMap的区别
主要区别就是hashmap线程不安全,ConcurrentHashMap线程安全
HashMap线程不安全,有以下两个问题
put覆盖问题
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
死循环问题
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 遍历数组,仅遍历数组下标元素
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
// 只有产生了新的hash表才需要重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 这里读者可以验证:i = oldIndex 或 i = oldIndex + oldCapacity
int i = indexFor(e.hash, newCapacity);
// 链表前插法
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
死循环问题是指如果两个线程同时对hashmap进行扩容,因为hashmap进行元素复制的方法是头插法,会产生next指针成环的问题,导致后面的数据无法访问到。
这个视频讲得很清楚
HashTable的效率又太低
因为hashtable的方法用synchronized修饰,相当于synchronized(this),导致只能有一个线程访问hashtable,效率太低
ConcurrentHashMap原理#
- 计算 key 哈希值:
JDK 1.7:key.hashCode()。
JDK 1.8+:((h=key.hashCode()) ^ (h>>>16)) -> 算法更均匀,哈希冲突越少。 - 计算槽位:hash & (table.length-1)。
在JDK7中原理,采用数组+Segment的段锁的数据结构,其中Segment继承ReentrantLock
在JDK8中,采用数组+链表+红黑树的数据结构,采用CAS+synchronized保证线程安全
JDK7对Segment进行加锁,JDK8对数组中每个元素(Node)加锁
查询时间复杂度 遍历链表O(N), 红黑树(O(logN))
size()#
jdk 1.7#
在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。
第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。其源码实现:
public int size() {
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; true if size overflows 32 bits
long sum; sum of modCounts
long last = 0L; previous sum
int retries = -1; first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
jdk 1.8#
1.8版本,先利用sumCount()计算,然后如果值超过int的最大值,就返回int的最大值。但是有时size就会超过最大值,这时最好用mappingCount方法
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
public long mappingCount() {
long n = sumCount();
return (n < 0L) ? 0L : n; // ignore transient negative values
}
sumCount有两个重要的属性baseCount和counterCells,如果counterCells不为空,那么总共的大小就是baseCount与遍历counterCells的value值累加获得的。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
baseCount是从哪里来的?
//当没有线程争用时,使用这个变量计数
private transient volatile long baseCount;
一个volatile变量,在addCount方法会使用它,而addCount方法在put结束后会调用
addCount(1L, binCount);
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x))
从上可知,在put操作结束后,会调用addCount,更新计数。
在并发情况下,如果CAS修改baseCount失败后,就会使用到CounterCell类,会创建一个对象,通常对象的volatile的value属性是1。
// 一种用于分配计数的填充单元。改编自LongAdder和Striped64。请查看他们的内部文档进行解释。
@sun.misc.Contended
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
并发时,利用CAS修改baseCount失败后,会利用CAS操作修改CountCell的值,
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
如果上面CAS操作也失败了,在fullAddCount方法中,会继续死循环操作,知道成功。
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
if ((as = counterCells) != null && (n = as.length) > 0) {
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
CounterCell r = new CounterCell(x); // Optimistic create
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try { // Recheck under lock
CounterCell[] rs; int m, j;
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
扩容#
什么时候触发扩容
CHM添加数据时,如果成功,最终会调用addCount。如果添加数据的过程中发现有hash冲突,则会触发扩容检查。如果此时CHM中容纳的元素的数量超过了table长度的0.75,则会触发扩容。
而在CHM的操作过程中,如果发现正在扩容(目标bin的hash被标志为MOVED),则当前线程也会参与扩容。
【注意】CHM的size()计算也比较有特色。并没有设置一个专门的用以计数的属性。可以参考我的另外一篇博文ConcurrentHashMap源码分析之计数:addCount、fullAddCount、size
扩容时如何保证线程安全
CHM保证线程安全的方式包括:
volatile数据:transerIndex声明为volatile
CAS操作:transferIndex -= stride
synchronized锁:迁移时对当前bin加锁
如何利用多线程扩容
CHM根据核数切分扩容任务
每线程负责的bin的数目为(stride),最小为16
参与扩容的线程,按批次处理扩容,直至所有的bin都已从table迁移到nextTable
访问CHM的线程如果发现正在扩容,则转而参与扩容
线程间如何协作
CHM的transferIndex属性记录当前尚未分配的待转移的bin的下标
参与扩容的线程通过CAS操作修改transferIndex(即tranfserIndex-stride),从而竞争该批次的处理权
竞争获胜者,以(transferIndex - stride, transferIndex) 这个双开区间作为自己的处理范围
竞争失败者,继续竞争下一批数据的处理权,直至竞争成功,或所有数据都已经迁移完毕
竞争获胜者,通过synchronized对当前bin加锁,以避免被其他写操作影响
原文链接:https://blog.csdn.net/pc_fly/article/details/125108562
ConcurrentHashMap迭代器是强一致性还是弱一致性?#
与HashMap不同的是,ConcurrentHashMap迭代器是弱一致性。 这里解释一下弱一致性是什么意思,当ConcurrentHashMap的迭代器创建后,会遍历哈希表中的元素,在遍历的过程中,哈希表中的元素可能发生变化,如果这部分变化发生在已经遍历过的地方,迭代器则不会反映出来,如果这部分变化发生在未遍历过的地方,迭代器则会反映出来。换种说法就是put()方法将一个元素加入到底层数据结构后,get()可能在某段时间内还看不到这个元素。 这样的设计主要是为ConcurrenthashMap的性能考虑,如果想做到强一致性,就要到处加锁,性能会下降很多。所以ConcurrentHashMap是支持在迭代过程中,向map中添加元素的,而HashMap这样操作则会抛出异常。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix