[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指针成环的问题,导致后面的数据无法访问到。

这个视频讲得很清楚

https://www.bilibili.com/video/BV1z54y1i73r/?spm_id_from=333.999.0.0&vd_source=2254c66bb775d7bf8d83535888768545

HashTable的效率又太低

因为hashtable的方法用synchronized修饰,相当于synchronized(this),导致只能有一个线程访问hashtable,效率太低

ConcurrentHashMap原理#

  1. 计算 key 哈希值:
    JDK 1.7:key.hashCode()。
    JDK 1.8+:((h=key.hashCode()) ^ (h>>>16)) -> 算法更均匀,哈希冲突越少。
  2. 计算槽位:hash & (table.length-1)。

在JDK7中原理,采用数组+Segment的段锁的数据结构,其中Segment继承ReentrantLock
在JDK8中,采用数组+链表+红黑树的数据结构,采用CAS+synchronized保证线程安全
JDK7对Segment进行加锁,JDK8对数组中每个元素(Node)加锁

查询时间复杂度 遍历链表O(N), 红黑树(O(logN))

具体原理请参考下文
参考1
参考2

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这样操作则会抛出异常。

作者:Esofar

出处:https://www.cnblogs.com/DCFV/p/18263603

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   Duancf  阅读(9)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示