ConcurrentHashMap

	在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。

1、HashMap

众所周知 HashMap 底层是基于数组 + 链表组成的,不过在 jdk1.7 与1.8 中具体实现稍有不同。

​ HashMap是线程不安全的,在并发(多线程)环境下,可能会形成环状链表(扩容时可能造成),导致get操作时,cpu空转,所以,在并发环境中使用HashMap是非常危险的。

1)1.7 结构

1.7中底层基于数组 + 链表结构, 结构图如下:
在这里插入图片描述

2)1.8 结构

而JDK1.8中,HashMap采用位桶(数组)+链表+红黑树实现,当链表长度超过阈值(临界值)为8的时候,就会将链表转换为红黑树,这样大大减少了查找时间。

结构图如下:

在这里插入图片描述

3)红黑树 R-B Tree

​ R-B Tree,全称是Red-Black Tree,又称为“红黑树”,它是一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。
在这里插入图片描述

红黑树的五条特性:

  1. 每个节点要么是黑的,要么是红的;
  2. 根节点是黑的;
  3. 叶子节点是黑的; ( 注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!)NIL–>零,无值
  4. 如果一个节点是红的,他的两个儿子节点都是黑的;
  5. 对于任一节点而言,其到叶节点树尾端NIL指针的每一条路径都包含相同数目的黑节点;

应用:

​ 红黑树的应用比较广泛,主要是用它来存储有序的数据,它的时间复杂度是O(lgn),效率非常之高。例如,Java集合中的 TreeSet 和 TreeMap,以及Linux虚拟内存的管理,都是通过红黑树去实现的。

2、HashTable

​ HashTable和HashMap的实现原理几乎一样,差别无非是 HashTable 不允许 key 和 value 为 null。

​ Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,且key不能重复。其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。

​ 它的底层多线程操作是安全的。

​ 但是HashTable线程安全的策略实现代价却太大了,简单粗暴,所有涉及到多线程操作(get / put所有相关操作)都是synchronized的,这相当于给整个哈希表加了一把大锁(也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下),多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。虽然它是安全的,但是无疑效率低下的。

3、ConcurrentHashMap(1.7)(并发的HashMap)

​ HashTable性能差主要是由于所有操作需要竞争同一把锁,那么考虑一下,如果容器中有多把锁,每一把锁去锁一段数据,这样的话在多线程访问不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。

​ ConcurrentHashMap主要就是为了应对HashMap在并发环境下不安全、HashTable效率低而诞生的,ConcurrentHashMap的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响。这就是ConcurrentHashMap所采用的"分段锁"思想。

在JDK1.7中ConcurrentHashMap采用了HashEntry数组+Segment数组+分段锁的方式实现。

Segment: [ˈseɡmənt ] 段

如下图所示:

在这里插入图片描述

​ 简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable,且在每个类似Hashtable的分段中又维护了一个个Entry这样的数组。这样一来,在执行put操作时首先根据 hash 算法定位到元素属于哪个 Segment 数组,然后对该Segment数组进行加锁即可。

1)Segment(分段锁)

​ ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个或者多个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock–重入锁。

​ ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数组中数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。

在这里插入图片描述640?wx_fmt=png

​ 每个 Segment 对象用来守护其(成员对象 table 中)包含的若干个桶。

​ table 是一个由 HashEntry 对象组成的数组,table 数组的每一个数组成员就是散列映射表的一个桶。

2)ReentrantLock(重入锁)

​ ReentrantLock是一个可重入独占式的锁,它具有与使用synchronized监视器锁相同的基本行为和语义,但与synchronized关键字相比,它更灵活、更强大,增加了轮询、超时、中断等高级功能。

​ ReentrantLock,顾名思义,它是支持可重入锁的锁,是一种递归无阻塞的同步机制。除此之外,该锁还支持获取锁时的公平和非公平选择

相关概念解释:

1> 独占锁(Exclusive Lock)

​ 独占锁锁定的资源只允许进行锁定操作的程序使用,其它任何对它的操作均不会被接受。执行数据更新命令,即INSERT(插入)、 UPDATE(更新) 或DELETE(删除) 命令时,SQL Server 会自动使用独占锁。但当对象上有其它锁存在时,无法对其加独占锁。独占锁一直到事务结束才能被释放。

2> 共享锁(Shared Lock)

​ 共享锁锁定的资源可以被其它用户读取,但其它用户不能修改它。在SELECT (查询)命令执行时,SQL Server 通常会对对象进行共享锁锁定。通常加共享锁的数据页被读取完毕后,共享锁就会立即被释放。

3> 更新锁(Update Lock)

​ 更新锁是为了防止死锁而设立的。当SQL Server 准备更新数据时,它首先对数据对象作更新锁锁定,这样数据将不能被修改,但可以读取。等到SQL Server 确定要进行更新数据操作时,它会自动将更新锁换为独占锁。但当对象上有其它锁存在时,无法对其作更新锁锁定。

4> 轮询(Polling)

​ 是一种 CPU 决策如何提供周边设备服务的方式,又称“程控输入输出”(Programmed I/O)。轮询法的概念是:由CPU定时发出询问,依序询问每一个周边设备是否需要其服务,有需要则给予服务,服务结束后再问下一个周边,接着不断周而复始。

5> 可重入锁

​ 是指任意线程在获取到锁之后,能够再次获取到该锁,而且还不会被锁阻塞。

6> 获取锁时是否公平

​ 这里是针对获取锁的顺序而言的。如果一个锁是公平的,那么锁的获取顺序就应该符合FIFO原则。

​ ( FIFO–> First Input First Output:先进先出)

7> synchronized和ReentrantLock的区别?

​ synchronized是和if、else、for、while一样的关键字,而ReentrantLock是类,这是二者的本质区别。

​ 既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:

(1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁;

(2)ReentrantLock可以获取各种锁的信息;

(3)ReentrantLock可以灵活地实现多路通知。

3)1.7结构的优劣势

​ 坏处:这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长;

​ 好处:写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(且这些写操作都非常平均地分布在所有的Segment上)。

​ 所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。

4)源码分析

ConcurrentHashMap采用了非常精妙的"分段锁"策略,ConcurrentHashMap的主干是个Segment数组。

 final Segment<K,V>[] segments;

Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在ConcurrentHashMap,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,并发环境下,对于不同Segment的数据进行操作是不用考虑锁竞争的。(就按默认的ConcurrentLeve为16来说,理论上就允许16个线程并发执行,是不是感觉很酷)

​ 所以,若是对于同一个Segment的操作才会需考虑线程的同步,不同的Segment则无需考虑。

Segment类似于HashMap,一个Segment维护着一个HashEntry数组

 transient volatile HashEntry<K,V>[] table;

​ HashEntry是目前我们提到的最小的逻辑处理单元了。一个ConcurrentHashMap维护一个Segment数组,一个Segment维护一个HashEntry数组。

 static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;
        //其他省略
}    

我们说Segment类似哈希表,那么一些属性就跟我们之前提到的HashMap差不离,比如负载因子loadFactor,比如阈值threshold等等,看下Segment的构造方法

Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
            this.loadFactor = lf;//负载因子
            this.threshold = threshold;//阈值
            this.table = tab;//主干数组即HashEntry数组
        }

我们来看下ConcurrentHashMap的构造方法:

 1  public ConcurrentHashMap(int initialCapacity,
 2                                float loadFactor, int concurrencyLevel) {
 3           if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
 4               throw new IllegalArgumentException();
 5           //MAX_SEGMENTS 为1<<16=65536,也就是最大并发数为65536
 6           if (concurrencyLevel > MAX_SEGMENTS)
 7               concurrencyLevel = MAX_SEGMENTS;
 8           //2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
 9          int sshift = 0;
10          //ssize 为segments数组长度,根据concurrentLevel计算得出
11          int ssize = 1;
12          while (ssize < concurrencyLevel) {
13              ++sshift;
14              ssize <<= 1;
15          }
16          //segmentShift和segmentMask这两个变量在定位segment时会用到,后面会详细讲
17          this.segmentShift = 32 - sshift;
18          this.segmentMask = ssize - 1;
19          if (initialCapacity > MAXIMUM_CAPACITY)
20              initialCapacity = MAXIMUM_CAPACITY;
21          //计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方.
22          int c = initialCapacity / ssize;
23          if (c * ssize < initialCapacity)
24              ++c;
25          int cap = MIN_SEGMENT_TABLE_CAPACITY;
26          while (cap < c)
27              cap <<= 1;
28          //创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
29          Segment<K,V> s0 =
30              new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
31                               (HashEntry<K,V>[])new HashEntry[cap]);
32          Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
33          UNSAFE.putOrderedObject(ss, SBASE, s0); 
34          this.segments = ss;
35      }

初始化方法有三个参数,如果用户不指定则会使用默认值,initialCapacity为16,loadFactor为0.75(负载因子,扩容时需要参考),concurrentLevel为16。

​ 从上面的代码可以看出来,Segment数组的大小ssize是由concurrentLevel来决定的,但是却不一定等于concurrentLevel,ssize一定是大于或等于concurrentLevel的最小的2的次幂。比如:默认情况下concurrentLevel是16,则ssize为16;若concurrentLevel为14,ssize为16;若concurrentLevel为17,则ssize为32。为什么Segment的数组大小一定是2的次幂?其实主要是便于通过按位与的散列算法来定位Segment的index。

接下来,来看put方法:

 public V put(K key, V value) {
        Segment<K,V> s;
        //concurrentHashMap不允许key/value为空
        if (value == null)
            throw new NullPointerException();
        //hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
        int hash = hash(key);
        //返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

​ 从源码看出,put的主要逻辑也就两步:

​ 1.定位segment并确保定位的Segment已初始化

​ 2.调用Segment的put方法。

关于segmentShift和segmentMask:

​ segmentShift和segmentMask这两个全局变量的主要作用是用来定位Segment,int j =(hash >>> segmentShift) & segmentMask。

segmentMask:段掩码,假如segments数组长度为16,则段掩码为16-1=15;segments长度为32,段掩码为32-1=31。这样得到的所有bit位都为1,可以更好地保证散列的均匀性。

segmentShift:2的sshift次方等于ssize,segmentShift=32-sshift。若segments长度为16,segmentShift=32-4=28;若segments长度为32,segmentShift=32-5=27。而计算得出的hash值最大为32位,无符号右移segmentShift,则意味着只保留高几位(其余位是没用的),然后与段掩码segmentMask位运算来定位Segment。

get方法:

 public V get(Object key) {
        Segment<K,V> s; 
        HashEntry<K,V>[] tab;
        int h = hash(key);
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;//先定位Segment,再定位HashEntry
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
            for (HashEntry<K,V> e = (HashEntry<K,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方法无需加锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据。

来看下concurrentHashMap代理到Segment上的put方法,Segment中的put方法是要加锁的。只不过是锁粒度细了而已。

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);//tryLock不成功时会遍历定位到的HashEnry位置的链表(遍历主要是为了使CPU缓存链表),若找不到,则创建HashEntry。tryLock一定次数后(MAX_SCAN_RETRIES变量决定),则lock。若遍历过程中,由于其他线程的操作导致链表头结点变化,则需要重新遍历。
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;//定位HashEntry,可以看到,这个hash值在定位Segment时和在Segment中定位HashEntry都会用到,只不过定位Segment时只用到高几位。
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;              //若c超出阈值threshold,需要扩容并rehash。扩容后的容量是当前容量的2倍。这样可以最大程度避免之前散列好的entry重新散列,具体在另一篇文章中有详细分析,不赘述。扩容并rehash的这个过程是比较消耗资源的。
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

5、ConcurrentHashMap(1.8)

​ JDK8中的ConcurrentHashMap参考了 JDK8 HashMap的实现,采用了 数组+链表+红黑树 的实现方式来设计,内部大量采用CAS操作。

640?wx_fmt=png

1)何为CAS操作?

​ CAS 即compare and swap 或者 compare and set,即我们所说的比较和交换。cas是一种基于锁的操作,而且是乐观锁。

​ CAS 操作涉及到三个操作数,数据所在的**内存值,预期值,新值。**当需要更新时,判断当前内存值与之前取到的值是否相等,若相等,则用新值更新,若失败则重试,一般情况下是一个自旋操作,即不断的重试。

2)何为悲观锁与乐观锁?
乐观锁对应于生活中乐观的人,总是想着事情往好的方向发展;悲观锁对应于生活中悲观的人,总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。
悲观锁:

​ 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会进行阻塞,直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。

​ 应用场景:

​ 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁:

​ 总是假设最好的情况,每次获取数据的时候,都不会担心数据被修改,所以每次获取数据的时候都不会进行加锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。

​ 应用场景:

​ 乐观锁比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。

6、总结

​ 其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发。1.7与1.8的一些区别如下:

1、数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。

2、保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。

3、锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。

4、链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。

5、查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值