Java并发编程之ConcurrentHashMap

Java并发编程之ConcurrentHashMap

引言: 我们知道在多线程环境下使用HashMap是线程不安全的,其主要原因是在使用HashMap的时候由于扩容操作会造成死循环、数据丢失、数据覆盖这些问题,这是因为在进行扩容操作的时候会重新定位每个元素的下标,并采用头插法将元素迁移到新数组中。而头插法会将链表的顺序翻转,这也是形成死循环和数据丢失的关键点。虽然在JDK1.8中对HashMap进行了很多优化,但是在进行put操作的时候依然会存在数据覆盖问题。由此我们便引入了ConcurrentHashMap这个线程安全且比HashTable更高效的并发同步容器类。

原理: ConcurrentHashMap由Segment和HashEntry组成。Segment是可重入锁,它在ConcurrentHashMap中扮演分离锁的角色,HashEntry主要存储键值对,CurrentHashMap包含一个Segment数组,每个Segment包含一个HashEntry数组并且守护它,当修改HashEntry数组数据时,需要先获取它对应的Segment锁;而HashEntry数组采用开链法处理冲突,所以它的每个HashEntry元素又是链表结构的元素。采用ConcurrentHashMap定位一个元素的时候需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上),所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。

ConcurrentHashMap初始化具体实现: 整个初始化是通过参数initialCapacity,loadFactor和concurrencyLevel来初始化segmentShift(段偏移量)、segmentMask(段掩码)和segment数组。segment数组长度ssize是由concurrencyLevel计算得出,当ssize<concurrencyLevel时,ssize*=2,至于为什么一定要保证ssize是2的N次方是为了可以通过按位与来定位segment。(注:concurrencyLevel的最大值是65535,那么,ssize的最大值就为65536,对应到二进制就是16位。)SegmentShift 和SegmentMask在定位segment使用,segmentShift=32-ssize向左移位的次数,segmentMask=ssize-1。ssize的最大长度是65536,对应的 segmentShift最大值为16,segmentMask最大值是65535,对应的二进制16位全1。初始化segment,初始化每个segment的HashEntry长度,创建segment数组和segment[0]。(注:HashEntry长度cap同样也是2的N次方,默认情况,ssize=16,initialCapacity=16,loadFactor=0.75f,那么cap=1,threshold=(int)cap*loadFactor=0。)

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)   // Use at least as many bins
            initialCapacity = concurrencyLevel;   // as estimated threads
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }

get操作:
1、根据key,计算出hashCode;
2、根据计算出的hashCode定位segment,如果segment不为null&&segment.table也不为null,跳转到步骤3,否则,返回null,该key所对应的value不存在;
3、根据hashCode定位table中对应的hashEntry,遍历hashEntry,如果key存在,返回key对应的value;
4、步骤3结束仍未找到key所对应的value,返回null,该key锁对应的value不存在。
比起Hashtable,ConcurrentHashMap的get操作高效之处在于整个get操作不需要加锁。如果不加锁,ConcurrentHashMap的get操作是如何做到线程安全的呢?原因是volatile,所有的value都定义成了volatile类型,volatile可以保证线程之间的可见性,这也是用volatile替换锁的经典应用场景。

 public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

put操作:
1.数组下标没有对应hash值,直接newNode()添加;
2.数组下标有对应hash值,添加到链表最后;
3.链表超过最大长度(8),将链表改为红黑树再添加元素。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) thrownew NullPointerException();
    int hash = spread(key.hashCode());// 得到 hash 值
    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 值对应的数组下标,得到第一个节点 f
        elseif ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 如果数组该位置为空,利用 CAS 操作将这个新值放入其中即可
            // 如果 CAS 失败,那就是有并发操作,进到下一个循环再put
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        
        // hash 等于 MOVED(-1),数组正在扩容,帮助数据迁移
        elseif ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);// 帮助数据迁移

        // 到这里就是说,f 是该位置的头结点,而且不为空
        else {
            V oldVal = null;
            // 获取数组该位置的头结点的监视器锁
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    // 头结点的 hash 值大于 0,说明是链表
                    if (fh >= 0) {
                        binCount = 1;// 用于累加,记录链表的长度
                        // 遍历链表添加node
                        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;
                            }
                        }
                    }
                    // 红黑树
                    elseif (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;
            }
        }
    }
    addCount(1L, binCount);
    returnnull;
}

线程进行put操作的时候通过以下两种措施保证线程安全:
1.table用volatile修饰,保证数组数据修改的可见性;
2.获取结点使用tabAt(),设置结点使用casTabAt(),利用CAS进行数据操作,使用乐观锁,如果因为其他线程修改数据导致当前线程操作失败,自旋重试直到成功。

扩容操作:
ConcurrentHashMap单线程扩容过程与HashMap类似,大致过程如下:
1.newTab=new Node[2*1ength],创建一个两倍于原来数组oldTab的新数组newTab,遍历oldTable,将oldTab中的结点转移到newTab中。
2.如果桶中oldTab[i]只有一个元素node,直接将node放入newTab[node.hash&(newCap-1)]中。
3.如果桶中oldTab[i]是链表,分成两个链表分别放入newTab[i]和newTab[i+oldTab.length]。
4.如果桶中oldTab[i]是树,树打散成两颗树插入到新桶中去。

ConcurrentHashMap支持多线程扩容:
1.当一个线程发现数组结点到达阀值时,调用transfer(tab,nul1)进行扩容并迁移数据,会创建一个2倍长度的新数组nextTable。
2.当另一个线程要操作数据时发现table数组正在扩容,就会调用transfer(tab,nextTable)帮忙迁移数据。
3.多个线程同时迁移数据怎么实现呢?设置一个步长stride,每个线程负责一个步长的数据迁移。例:table.length==64,步长stride=16,每个线程每次负责迁移16个桶,如果当前线程16个桶迁移结束再去申请16个桶迁移。

多线程进行扩容操作的时候通过以下两种措施保证线程安全:
1.table和nextTable的修改都是通过CAS操作,失败后自旋重试,不会造成数据丢失和错误。
2.链表和红黑树的操作都是将第一个结点作为监视器锁加synchronized锁,同步处理,保证线程安全。

总结:
1.CAS操作数据:table数组的取值/设置值、链表的数值操作、sizeCtl修改都利用CAS来完成,当因为其他线程修改了数据导致操作失败后,会自旋重试直到成功,保证了在多线程环境下的数据安全。
2.synchronized互斥锁:操作链表/树种的元素时,使用synchronized锁,将第一个结点作为监视器锁,保证线程安全。
3.volatile修饰变量:table、baseCount、CounterCell[]、sizeCtl等变量用volatile修饰,保证了多线程环境下数据读写的可见性。

本文参考
本文主要参考以下文章,谨以技术分享为目的,将此文搬到CSDN上,如有侵权问题请联系本人,乐于分享提高。
作者: 喵喵
链接:https://mp.weixin.qq.com/s/kswNRS2f6BO2wt99ClHFtQ

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值