并发编程系列之并发容器——ConcurrentHashMap

本人花费半年的时间总结的《Java面试指南》已拿腾讯等大厂offer,已开源在github ,欢迎star!

本文GitHub https://github.com/OUYANGSIHAI/JavaInterview 已收录,这是我花了6个月总结的一线大厂Java面试总结,本人已拿大厂offer,欢迎star

原文链接:blog.ouyangsihai.cn >> 并发编程系列之并发容器——ConcurrentHashMap

并发编程系列之并发容器:ConcurrentHashMap

前言

之前我们讲了线程,锁以及队列同步器等等一些并发相关底层的东西,当然Java开发者在开发中很少直接去使用之前所讲的,因为Java为了简化开发,为我们提供了一整套并发容器和框架,但是这些容器和框架都是建立在之前所讲的基础之上的,今天就让我们来看第一个并发容器:ConcurrentHashMap,我们主要从原理和使用两个方面介绍,让我们扬帆起航,开始今天的并发之旅吧。

并发编程系列之并发容器:ConcurrentHashMap

景点一:为什么要使用ConcurrentHashMap

ConcurrentHashMap是一个线程安全并且高效的HashMap,基于下面两点我们还是在并发场景中优先考虑ConcurrentHashMap。

  • 线程不安全的HashMap:在多线程环境下,使用HashMap进行操作会引起死循环,导致CPU100%甚至服务器之间崩溃,读者可以参考下面代码自己试一下,(亲测使用Hash服务器直接卡死,不信U CAN TRY):多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环,那么Entry的next节点永远不为空,Hash就会陷入死循环获取Entry的场景

  • 
    public class ConcurrentMapDemo {
        public static void main(String[] args) throws InterruptedException {
           // final HashMapString, String map = new HashMapString, String();
           final ConcurrentHashMap map = new ConcurrentHashMap();
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i  1000; i++) {
                        new Thread(new Runnable() {
                            @Override
                            public void run() {
                                for (int i = 0; i  1000; i++) {
                                    map.put(UUID.randomUUID().toString(), "");
                                }
                            }
                        }, "t2").start();
                    }
                }
            }, "t1");
            t1.start();
        }
    }
    
  • 效率低下的HashTable:HashTable是使用synchronized来保证线程安全,但是在多线程并发环境下线程竞争激烈,HashTable的效率非常低,也正是因为synchronized导致每次只能有一个线程访问同步块,其他线程处于阻塞或者轮询状态,根本无法对同步块进行任何操作,所以线程竞争越激烈(线程数量越多),效率越低,这明显不满足我们使用多线程的初衷;
  • ConcurrentHashMap锁分段技术:ConcurrentHashMap内部使用段(segment)来表示这些不同的部分,每个段其实就是一个小的HashTable,他们有自己各自的锁,只要多个修改操作发生在不同的段上,他们之间就可以并发的进行;
  • ConcurrentHashMap锁分段技术:ConcurrentHashMap内部使用段(segment)来表示这些不同的部分,每个段其实就是一个小的HashTable,他们有自己各自的锁,只要多个修改操作发生在不同的段上,他们之间就可以并发的进行;

    并发编程系列之并发容器:ConcurrentHashMap

    景点二:ConcurrentHashMap的结构

    并发编程系列之并发容器:ConcurrentHashMap

    通过上面的结构图,我们来分析下:ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成,Segment是一种可重入锁,在ConcurrentHashMap中充当锁的角色,HashEntry是用来存储键值对数据。这三者的关系如下图:

    并发编程系列之并发容器:ConcurrentHashMap 并发编程系列之并发容器:ConcurrentHashMap

    景点三:ConcurrentHashMap的底层实现

    ConcurrentHashMap把一个整体分成16个段(segment),也就是说最高支持16个线程的并发修改操作。这也是在多线程场景下减小锁粒度从而降低竞争的一种方案。并且代码中大多共享变量使用volatile关键字,目的是第一时间获取修改的内容,性能非常好。

    ConcurrentHashMap的底层主要包括初始化Segment数组、SegmentShift、SegmentMask和每个Segment里面的HashEntry数组以及Segment的定位,下面我们将来一一分析:

    初始化Segment数组

    
    if (concurrencyLevel  MAX_SEGMENTS)
                concurrencyLevel = MAX_SEGMENTS;
            // segments数组的长度ssize通过concurrencyLevel计算得出。
            //为了能通过按位与的哈希算法来定位segments数组的索引,
            //必须保证segments数组的长度是2的N次方
            // 所以必须计算出一个是大于或等于concurrencyLevel的最小的2的N次方值来作为segments数组的长度
            int sshift = 0;
            int ssize = 1;
            while (ssize  concurrencyLevel) {
                ++sshift;
                ssize = 1;
            }
            this.segmentShift = 32 - sshift;
            this.segmentMask = ssize - 1;
    

    初始化SegmentShift和SegmentMask:这两个全局变量在定位segment时的哈希散列中使用,sshift等于ssize从1向左移位的次数,在默认情况下concurrencyLevel等于16,1需要向左移位移动4次,所以sshift等于4。segmentShift用于定位参与hash运算的位数,segmentShift等于32减sshift,所以等于28,这里之所以用32是因为ConcurrentHashMap里的hash()方法输出的最大数是32位的。

    segmentMask是哈希运算的掩码,等于ssize减1,即15,掩码的二进制各个位的值都是1。因为ssize的最大长度是65536,所以segmentShift最大值是16,segmentMask最大值是65535,对应的二进制是16位,每个位都是1;

    初始化每个Segment里面的HashEntry:

    
    // initialCapacity是ConcurrentHashMap的初始化容量
    // loadFactor是每个Segment的负载因子
    // cap为Segment里面HashEntry数组的长度,它等于initialCapacity/ssize的倍数c,如果c1则就会取大于等于c的2的N次方值
    // 所以cap如果不是1就是2的N次方。
    // Segment的容量=(int)cap*loadFactor,默认情况下loadFactor=0.75,initialCapacity=16
    if (initialCapacity  MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
            int c = initialCapacity / ssize;
            if (c * ssize  initialCapacity)
                ++c;
            int cap = MIN_SEGMENT_TABLE_CAPACITY;
            while (cap  c)
                cap = 1;
            // create segments and segments[0]
            SegmentK,V s0 = new SegmentK,V(loadFactor, (int)(cap * loadFactor),(HashEntryK,V[])new HashEntry[cap]);
            SegmentK,V[] ss = (SegmentK,V[])new Segment[ssize];
    

    定位Segment:ConcurrentHashMap使用分段锁Segment来保护每段数据,那么在插入和获取元素的时候,必须先通过哈希算法定位到Segment,ConcurrentHashMap会对元素的HashCode在进行一次hash散列,进行2次哈希的目的是减少散列冲突,使元素能够均匀地分布在不同的Segment上,提高容器的存取效率。

    如果哈希散列最坏的情况是所有元素元素全部散落在一个Segment中,那么分段锁的意义就没有了,而且存取效率也极差,所以为了尽量减少散列冲突ConcurrentHashMap是通过2次哈希来做的,我们可以看具体源码如下:

    
    // segmentShift默认情况下=28
    // segmentMask默认情况下=15
    private SegmentK,V segmentForHash(int h) {
            // hash值右移28位(让高4位参与到散列中) 再和segmentShift做与运算
            long u = (((h  segmentShift) & segmentMask)  SSHIFT) + SBASE;
            return (SegmentK,V) UNSAFE.getObjectVolatile(segments, u);
        }
    
    并发编程系列之并发容器:ConcurrentHashMap

    景点四:ConcurrentHashMap的操作

    ConcurrentHashMap的的方法有很多如下,我们主要讲三种:get、put和size

    并发编程系列之并发容器:ConcurrentHashMap 并发编程系列之并发容器:ConcurrentHashMap

    put操作

    调用特别简单跟使用HashMap没有任何区别,如下:

    
    ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap();
            concurrentHashMap.put("Justin","Justin的后端书架");
    

    但是,由于ConcurrentHashMap是线程安全的所以put方法需要对共享变量进行写入操作,所以为了线程安全,必须加锁,我们看下put的底层实现:

    
    /**
    * put方法首先定位到Segment,然后在Segment里面进行插入操作
    * 插入操作需要进行2步:
    * 第一步:先判断是否需要对Segment里的HashEntry进行扩容,判断HashEntry是否
    *        是否超过容量threshold,如果超过阈值则进行扩容,Segment的扩容比
    *        Hashmap的扩容更合理,Hash是在插入元素之后再判断是否需要进行扩容,
    *        如果插入元素之后,满足扩容条件,但是后续没有元素新增,那就会做了一次无效的扩容
    *  拓展:如何扩容?扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。
    *      为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容
    * 第二步是定位到添加元素的位置,然后将它放入HashEntry数组里面
    */
    public V put(K key, V value) {
            SegmentK,V s;
            if (value == null)
                throw new NullPointerException();
            int hash = hash(key);
            int j = (hash  segmentShift) & segmentMask;
            if ((s = (SegmentK,V)UNSAFE.getObject          // nonvolatile; recheck
                 (segments, (j  SSHIFT) + SBASE)) == null) //  in ensureSegment
                s = ensureSegment(j);
            return s.put(key, hash, value, false);
        }
    

    get操作

    使用很简单:

    
    ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap();
            concurrentHashMap.put("Justin","Justin的后端书架");
            System.out.println(concurrentHashMap.get("Justin"));
    

    我们再看下源码实现:

    
    /**
     * 三步走:第一步,先对key经过一次再散列
     *        第二步:使用这个散列值通过散列运算定位到Segment
     *        第三步:再通过散列算法定位到元素
    */ 
    public V get(Object key) {
            SegmentK,V s;
            HashEntryK,V[] tab;
            int h = hash(key);
            long u = (((h  segmentShift) & segmentMask)  SSHIFT) + SBASE;
            if ((s = (SegmentK,V)UNSAFE.getObjectVolatile(segments, u)) != null &&
                (tab = s.table) != null) {
                for (HashEntryK,V e = (HashEntryK,V) UNSAFE.getObjectVolatile
                         (tab, ((long)(((tab.length - 1) & h))  TSHIFT) + TBASE);
                     e != null; e = e.next) {
                    K k;
                    if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                        return e.value;
                }
            }
            return null;
        }
    

    get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空的才会加锁重读,那么我们看下ConcurrentHashMap是如何做到get不加锁的吧:原因是它的get方法里将要使用的共享变量都定义成volatile,在get操作里只需要读不需要写共享变量,所以可以不用加锁。之所以不会读到过期的值,是因为根据JMM的先行发生原则,对volatile字段的写入操作优先于读操作,即使两个线程同时修改和获取volatile变量,get操作最终也只会拿到最新的值。

    size操作

    我们要统计ConcurrentHashMap里元素的大小,就必须统计所有的分区Segment里面元素的总和,但是如果我们在统计每个Segment里面元素总数的过程中,之前已经统计过得Segment又发生了更新,那么之前统计的总数就失效了,所以最安全的做法是在统计每个Segment的时候统计好的Segment就将他的更新操作全部锁住,等待全部Segment统计完毕再释放,但是显然这样是不科学的,效率非常低下。

    源码如下:

    
    /**
     * 在累加count操作过程中,之前统计过的Segment发生变化的几率比较小,所以<p> * ConcurrentHashMap的做法是先尝试2次不加锁的方式来统计各个Segment大小,如果统计的过程中,
     * 容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小</p> * 
     * ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?
     * 使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1, * 那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化 */
    public int size() {
            final SegmentK,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) {
                        SegmentK,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;
        }
    
    并发编程系列之并发容器:ConcurrentHashMap

    景点五:ConcurrentHashMap的缺点

    ConcurrentHashMap的缺点就是他最多只能支持16个线程的并发,如果实际场景中,你需要启动的线程的数量比较多,还是同样会发生锁竞争和等待的问题:

    并发编程系列之并发容器:ConcurrentHashMap

    以上就是今天所讲的ConcurrentHashMap的五大景点的所有内容,希望能对你有所帮助,通过短短十几分钟的阅读,希望你能有所收获!!!

    并发编程系列之并发容器:ConcurrentHashMap

    原文始发于微信公众号(Justin的后端书架):

    本人花费半年的时间总结的《Java面试指南》已拿腾讯等大厂offer,已开源在github ,欢迎star!

    本文GitHub https://github.com/OUYANGSIHAI/JavaInterview 已收录,这是我花了6个月总结的一线大厂Java面试总结,本人已拿大厂offer,欢迎star

    原文链接:blog.ouyangsihai.cn >> 并发编程系列之并发容器——ConcurrentHashMap


     上一篇
    并发编程系列之并发容器——ConcurrentLinkedQueue 并发编程系列之并发容器——ConcurrentLinkedQueue
    前言 上节我们介绍了线程安全的HashMap,今天我们再来介绍一个线程安全的并发容器:ConcurrentLinkedQueue,它是一个线程安全的队列,在Java中如果要实现一个线程安全的队列由2种方式:一个是使用阻塞算法的队列,用
    2021-04-05
    下一篇 
    并发编程系列之Condition接口 并发编程系列之Condition接口
    前言 前面我们学习线程的时候讲过等待通知模式,之前讲的是通过wait,notify/notifyAll配合synchronized关键字,实现等待通知,今天呢,我们介绍另外一种同样实现等待通知模式的对象叫做condition
    2021-04-05