JAVA:concurrentHashMap

本文详细解析了ConcurrentHashMap的内部实现机制,包括分段加锁、CAS操作、扩容策略等,阐述了其如何在保证线程安全的同时,提高并发性能。

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

       Hashtable是一个线程安全的类,通过使用synchronized来锁住整张Hash表来实现线程安全的。由于对整个表加锁会导致操作效率低。为此,为了实现一个线程安全且效率高的数据结构,Concurrenthashmap通过使用分段加锁的机制来提高效率。

1.如何实现分段加锁的机制

Concurrenthashmap使用分段锁技术,将数据分为一段一段的存储,然后给每一段数据配一把锁。这样,每段数据在操作上是互斥的。即一个线程访问其中一段数据,那么其他线程可以访问其他数据段互不干扰。

                    

由图可以看出数据结构为:数组+(数组+链表)

从源码级分析特性:

1.继承关系

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable 

继承AbstractMap类,实现ConcurrentMap<K,V>, Serializable 接口,其中AbstractMap、Serializable是在Hashmap中已经使用过的,因此与Hashmap的区别在于实现接口不同,从而与ConcurrentHashMap方法也不同。

对于ConcurrentMap接口的研究来探索ConcurrentHashMap多了那些方法,其中方法的特点你是什么?

2.由于concurrenthashmap的数据结构为数组+数组+链表,其中链表的Node节点的属性如下:

class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
}

其中属性大体和hashmap相同,就是val,next变量使用volatile 修饰,由于volatile修饰的变量可以保证内存可见性以及禁止指令重排序。因此达到我们每次操作的值都是最新的。

下面就源码具体分析它的增删改查操作:

1.首先判断key,value值是否为空,如果为空,抛出空指针异常。

2.如果没有空指针异常问题,则需要通过spread函数计算哈希值

 static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }

3.根据hash值计算存放在桶的位置index,判断index位置是否存在元素,如果没有元素,则采用无锁的CAS操作尝试插入新节点,若成功,则执行;失败,则重新执行3;若当前位置有元素,则执行4;

4.判断当前是否正在扩容,若正在扩容,则加入协助扩容,扩容完毕后则回去重新执行3;若不是正在扩容,则执行5.

5.锁住当前index位置的节点,遍历该位置的所有节点,判断是否存在相同的元素,存在则替换旧值;不存在则插入一个新的节点保存元素。然后执行6.

6.给容器的当前元素个数count加1,若超出负载因子,则进行扩容操作。

 public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
//检查key和value是否为null,为null则抛出异常
        if (key == null || value == null) throw new NullPointerException();
//计算key的hash值,与hashmap计算方式相同
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
//根据hash值计算存放的位置,判断当前位置是否存在元素
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果为空,使用CAS操作尝试更新插入节点,成功,则跳出循环,count+1,否则代表已经有其他线程在该 //位置插入元素,则需要重新进入循环,以便插入新元素的后面。
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
//若当前位置元素的hash值为MOVED(这是一个常数),标志着正在进行扩容操作,则当前线程加入协助扩容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
//锁住当前位置的头元素,这里使用的分段锁,它并没有锁住整个table,而是锁住了table中某一个位置的
//头元素因此对于可以并发对不同位置进行插入操作
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
//执行count+1,这里可能会触发扩容操作
        addCount(1L, binCount);
        return null;
    }

CAS插入头结点:

当table的index位置无元素时,则会尝试使用CAS操作插入新节点作为头元素。

CAS操作是JAVA提供的一种无锁更新操作,它有三个操作数,分别是被修改的变量地址,旧值,新值。若果当前地址的值与旧值相同,则认为它没有被修改过,则使用新值替换旧值,修改成功;否则代表已经有其他线程已经更改了变量,则修改失败。与传统的加锁不同,传统的加锁是一种悲观锁的实现,而CAS操作则是一种乐观锁的实现,他避免了加锁,从而使得线程不会因为等待锁而进入阻塞,在一定程度上提高了并发性能。

分段锁:

   分段锁是concurrenthashmap的核心,在其内部结构中,hash表是用数组保存,数组中每个元素它相当于一个个桶,桶内装了发生冲突的(key,value)对。

 当进行插入动作时,concurrenthashmap并不会锁住hash表,则是先通过key的hash计算应在的桶的位置,再锁住该桶,再执行插入操作。该方式允许并发地对不同的桶进行插入操作,但当插入的(key,value)存放于同一个桶中,还是会由于等待锁的获取而进入阻塞。所以从整体上看,当元素存取较分散时,性能还是会比hashtable要高得多。

扩容:

  扩容机制与hashmap的实现类似,将table的容量扩展为原来的2倍,再对旧表的元素重哈希到新的位置,不过在这基础上,增加了并发扩容机制,允许多个线程同时参与扩容,使得扩容更快完成。

在看源代码前,先提几个重要变量 
1. sizeCtl 
- -1:表示 hash 表正在进行初始化或扩容操作 
- -n:表示当前有 n-1 个线程执行扩容操作 
- 0 or n:代表当前 hash 表未进行初始化或负载因子,当超过则需要扩容 
  
2. transferIndex:表示要被重哈希的位置 
3. stride:由于允许并发扩容,该数值记录一次扩容需重哈希多少长度的 hash 表,该值会根据你的 CPU 个数和表长而决定,最小为 16

工作流程: 
1. 根据 CPU 个数和当前表的长度,计算一轮重哈希需要对多少长度的表进行重哈希; 
2. 如果是第一个触发扩容的线程,创建新表,长度为原来的两倍; 
3. 循环对旧表的元素进行重哈希,直到全部位置都重哈希完毕 
3. 获取新一段需重哈希的初始 index,每一段的长度为 stride,获取后该线程就负责对 [index - stride , index] 的元素进行重哈希,由于存在并发扩容,因此获取并更新 transferIndex 需要循环使用 CAS 操作来获取 
4. 对段中的每一个位置都进行重哈希,哈希过程中需要对头元素进行加锁,避免由于扩容和 put、delete 操作冲突,具体重哈希原理与 HashMap 类似。

来看两个问题,下面我们就这两个问题分析 ConcurrentHashMap 的具体实现: 
1. 为什么扩容时要将 table 分成一段一段来处理? 
2. 多个线程如何协调地一起进行扩容操作? 
3. 其他线程如何可以知道当前正在执行扩容操作?

扩容时将 table 分成一段一段来处理 
  首先前面提及到 stride 用于保存重哈希时的段容量,它的数值由当前 CPU 个数和表长决定,计算公式如下:

  

int stride = NCPU > 1 ? (n >>> 3) / NCPU : n; 



  从公式可以看出,当系统的 CPU 个数为 1 个时,stride 的值为 n,即由单个线程完成整个 hash 表的重哈希过程。 
  有的人可能会想,为什么要这样做呢?多个线程一起扩容不是更快吗?注意,效率更高是要建立系统有多个 CPU 的情况下,在单个 CPU 的系统,一个时间点只能执行一个线程,如果让多个线程协助扩容,不仅不会提高效率,还增加了线程切换的开销,导致扩容更慢。因此只有当系统 CPU 个数多于一个时,才会将扩容过程分为多段来进行。注:段的长度最小规定为 16。

多个线程如何协调地一起进行扩容操作? 
  前面提过每个线程会各自负责一段的重哈希,当该段完成后再去负责下一段。

下面通过一个具体场景来分析运行过程: 
当前有一个 concurrentHashMap 对象需要进行扩容,n 为 512,stride = 64,即一段为 64 长度,transferIndex 为 512。

线程 1 申请一段,申请成功,返回段的位置 transferIndex - 1 = 511,则此时线程 1 负责 [512-64,511] 段的重哈希,此时 transferIndex 为 448
线程 2 加入扩容,申请,此时线程 2 负责 [448-64, 448] 段的重哈希,此时 transferInex 为 384
线程 1 完成了 [448,511] 段的重哈希,想要继续再申请一段,当前 transferIndex 为 384;但此时线程 3 加入进来,它同时也申请一段,并且为它先分配了,transferIndex 变为 320。线程 1 通过 CAS 操作更新 transferIndex 失败,则重新获取 transferIndex 的最新值,再次申请。
3 个线程通过上述这样的协助操作,完成整个 hash 表的重哈希过程
其他线程如何可以知道当前正在执行扩容操作? 
  在扩容过程中,对每一个位置重哈希完毕后,会将旧表的当前位置元素使用一个 ForwardingNode 来代替该位置,其他线程当对 ConcurrentHashMap 执行操作时,探测到一个 ForwardingNode 时,则就可以知道当前集合正在进行扩容操作。是否加入扩容,则需要根据配置和当前的扩容线程数来决定,具体可参看 helpTransfer 方法。
 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值