浅谈ConcurrentHashMap

摘要

在正式介绍ConcurrentHashMap之前,我想先说说并发和并行的两个概念。

我们先来看看并发在操作系统和数据库操作时的相关概念

1.在操作系统中,并发是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
2.在关系数据库中,允许多个用户同时访问和更改共享数据的进程。SQL Server 使用锁定以允许多个用户同时访问和更改共享数据而彼此之间不发生冲突

并发:当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时运行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。这种方式我们称之为并发(Concurrent)。

并行当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

并发与并发的区别:并发和并行是即相似又有区别的两个概念,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机系统中,每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。倘若在计算机系统中有多个处理机,则这些可以并发执行的程序便可被分配到多个处理机上,实现并发执行,即利用每个处理机来处理一个可并发执行的程序,这样,多个程序便可以同时执行。

我们可以看一张图片就能理解两者的含义以及区别:


ConcurrentHashMap实现原理

知晓了并发的概念,接下来我们就正式来说说ConcurrentHashMapConcurrentHashMap是Java并发包中提供的一个线程安全且高效的HashMap实现,ConcurrentHashMap在并发编程的场景中使用频率非常之高。在Java开发中,我们最常见到最频繁使用的就是HashMap和Hashtable,但是在线程竞争激烈的并发场景中两者都不太适用。为什么呢?看下面的解析。

HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(扩容时可能造成,导致get操作时,cpu空转,所以,在并发环境中使用HashMap是非常危险的。(具体原因大家可以自行百度或者谷歌)

Hashtable : Hashtable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是Hashtable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

我在网上找了一张hashtable和ConcurrentHashMap锁的图


在图中,图的左边表示Hashtable锁的示意,从图中我们可以看出,Hashtable的所有操作需要竞争同一把锁,在多线程环境下,这样的机制简直就是灾难。而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是图中右半部分ConcurrentHashMap所采用的"分段锁"思想。

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

ConcurrentHashMap的源码分析

1.构造方法

我们先来看看ConcurrentHashMap的大图


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

final Segment<K,V>[] segments;

Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentranLock)。在ConcurrentHashMap,一个segment就是一个哈希表,segment里维护了一个HashEntry数组,并发环境下,对于不同segment的数据进行操作是不用考虑锁竞争的。所以,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑。

每个segment相当于一个HashMap,一个segment维护着一个HashEntry数组,所以在ConcurrentHashMap中,我么可以理解为一个ConcurrentHashMap维护一个Segment数组,一个Segment维护一个HashEntry数组。

我们来看看HashEntry的源码

static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;
        ....
}

可以看到,除了value不是final的,其他的值都是final修饰的,这意味着不能从hash链的中间或者尾部添加或删除节点,因为一旦在在中间或者尾部进行了这两个操作,需要修改next的引用值,所有的节点只能从头部开始,为了确保读操作能获取到最新的值,将value设置成volatile,这就避免了加锁。

HashEntry数组有点像我们之前说的HashMap中的哈希桶数组(Node[] table),而segment类似于一个HashMap,来看看segnment的构造方法

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

我们惊奇地发现,Segment的构造方法中,也有loadFactor(负载因子)和threshold阈值,基本上和HashMap的构造方法差不多。

再来看看ConcurrentHashMap的构造方法

public ConcurrentHashMap(int initialCapacity,
                               float loadFactor, int concurrencyLevel) {
          if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
              throw new IllegalArgumentException();
          //MAX_SEGMENTS 为1<<16=65536,也就是最大并发数为65536
          if (concurrencyLevel > MAX_SEGMENTS)
              concurrencyLevel = MAX_SEGMENTS;
          //2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
         int sshift = 0;
         //ssize 为segments数组长度,根据concurrentLevel计算得出
         int ssize = 1;
         while (ssize < concurrencyLevel) {
             ++sshift;
             ssize <<= 1;
         }
         //segmentShift和segmentMask这两个变量在定位segment时会用到,后面会详细讲
         this.segmentShift = 32 - sshift;
         this.segmentMask = ssize - 1;
         if (initialCapacity > MAXIMUM_CAPACITY)
             initialCapacity = MAXIMUM_CAPACITY;
         //计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方.
         int c = initialCapacity / ssize;
         if (c * ssize < initialCapacity)
             ++c;
         int cap = MIN_SEGMENT_TABLE_CAPACITY;
         while (cap < c)
             cap <<= 1;
         //创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
         Segment<K,V> s0 =
             new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                              (HashEntry<K,V>[])new HashEntry[cap]);
         Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
         UNSAFE.putOrderedObject(ss, SBASE, s0); 
         this.segments = ss;
     }
ConcurrentHashMap构造方法有三个参数,initialCapacity为用户自定义初始容量,loadFactor为负载因子,concurrentLevel为并发级别。如果用户不指定三个参数的值,则会使用默认值,initialCapacity为16,loadFactor为0.75(负载因子,扩容时需要参考),concurrentLevel为16。当然并发级别(concurrentHashMap)和每个段的初始容量都是可以通过此构造函数设定的。

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

ConcurrentHashMap使用segment来分段和管理锁,segment继承自ReentrantLock,因此ConcurrentHashMap使用ReentrantLock来保证线程安全。

创建好默认的ConcurrentHashMap之后,它的结构大致如下图:


2.put(K key,V value)的实现——用Lock加锁

我们先来看看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);
    }

在该源码中,我要重点说一下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。


ConcurrentHashMap和HashMap的put操作的原理类似:

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

具体实现:首先根据key值算出对应的hash值(int hash = hash(key);),然后进行高位运算定位segement的位置。高位运算和HashMap的高位运算类似。在HashMap中(hash >>>16),ConcurrentHashMap中(hash >>> segmentShift),无符号右移segmentShift,就表示只保留高几位,最后与segmentMask=(length-1)(这个与HashMap中的基本一致),得到segment的位置。

2.定位到具体的segment位置后,在该segment中进行插入操作

那么在segment中如何执行具体的插入操作呢?先看看源码

V put(K key,int hash,boolean onlyIfAbsent){
  lock();//加锁,这里是锁定某个segment对象而不是整个ConcurrentHashMap
  try{
     int c =count;
     if(c++ > threshold)//如果超过阈值
      rehash();//执行再哈希,进行扩容,数组的长度扩大一倍
     HashEntry<K,V>[] tab = table;
     //把哈希值与table数组的长度-1进行与操作,得到该哈希值对应的table数组的下标值
     int index = hash & (tab.length-1);
     //找到该下标对应的HashEntry
     HashEntry<K,V> first = tab[index];
     HashEntry<K,V> e =first;
     while(e !=null && (e.hash!=hash || !key.equals(e.key))){
         e = e.next;
         V oldValue;
         if(e!=null){//如果键值已经存在
            oldValue = e.value;
            if(!onlyIdAbsent){
               e.value = value;//设置value值
            }
          }else{//键值不存在
             oldVlue = null;
             ++modCount;//要添加新节点链表中,所有modCount要加1
             //创建新的节点,并添加到链表的头部
             tab[index] = new HashEntry<K,V>(key,hash,first,value);
              count =c;
             
          }
          return oldValue;
     }
    
   }finally{
      unlock();//解锁
   }
  


}

在segment中进行插入操作需要经过两个步骤:第一步是判断是否需要对segment里的HashEntry数组进行扩容,第二部定位添加元素的位置后放在HashEntry数组中。

是否需要扩容,在插入元素之前会先判断segment里的HashEntry数组是否超过容量(threhold),如果超过阀值,数组进行扩容。值得一提的是,segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断是否已经达到容量的,如果达到了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容,而且扩容操作对性性能的消耗是巨大的。

如何扩容呢?扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组中。

还需要注意的一点是,这里的加锁操作是针对某个具体的segment的,锁定的是segment而不是整个ConcurrentHashMap.因为插入键值对操作知识在segment包含的哈希桶数组中完成,不需要锁定整个ConcurrentHashMap.此时,其他线程对另外15个segment的加锁并不会因为当前线程对这个segment加锁而阻塞。同时,所有度线程几乎不会因为本线程的加锁而阻塞(除非度线程刚好督导这个segment中某个HashEntry的value域的值为null,此时需要加锁后重新读取该值)。

空值的唯一源头就是HashEntry中的默认值,因为HashEntry中的value不是final的,非同步读取有可能读到空值。why?仔细看下面的put操作的语句:

tab[index] = new HashEntry<K,V>(key,hash,first,value);

new Entry对象是通过new HashEntry(K k,V v,    HashEntry next)来创建的。如果另一个线程刚好new这个对象,当前想成来get它,因为没有同步,就可能会出现当前线程刚好得到的newEntry对象是一个没有完全构造好的对象引用。这里同样有可能一个线程new这个对象的时候还没有执行完构造函数就被另一个线程得到这个对象的引用。

在上面的语句中,HashEntry构造函数对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致节点的值为空。不过这种情况很少见,一旦发生这种情况,ConcurrentHashMap采取的方式就是在持有锁的情况下再读一遍,这样就能得到最新的值,并且一定为空值。

3.get(Object key)方法

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操作相比put操作来说就简单很多了,而且效率比put的操作效率要高。get操作效率高指出在于整个get过程中,不需要加锁,除非读到的值是空的才会重新加锁重读。

之所以不会读到过期的值,是根据Java内存模型的happen before原则,对volatile字段的写入操作优先于读操作,即使两个线程同时修改和获取volatile修饰的变量,get操作也能拿到最新的值,这是volatile替换锁的经典应用场景。










  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值