深入理解ConcurrentHashMap原理分析以及线程安全性问题

在之前的文章提到ConcurrentHashMap是一个线程安全的,那么我么看一下ConcurrentHashMap如何进行操作的。

ConcurrentHashMap与HashTable区别?

HashTable
put()源代码
这里写图片描述
从代码可以看出来在所有put 的操作的时候 都需要用 synchronized 关键字进行同步。并且key 不能为空。
这里写图片描述
这样相当于每次进行put 的时候都会进行同步 当10个线程同步进行操作的时候,就会发现当第一个线程进去 其他线程必须等待第一个线程执行完成,才可以进行下去。性能特别差。
CurrentHashMap
分段锁技术:ConcurrentHashMap相比 HashTable而言解决的问题就是 的 它不是锁全部数据,而是锁一部分数据,这样多个线程访问的时候就不会出现竞争关系。不需要排队等待了。
这里写图片描述
这里写图片描述

从图中可以看出来ConcurrentHashMap的主干是个Segment数组。
这就是为什么ConcurrentHashMap支持允许多个修改同时并发进行,原因就是采用的Segment分段锁功能,每一个Segment 都想的于小的hash table并且都有自己锁,只要修改不再同一个段上就不会引起并发问题。

 final Segment<K,V>[] segments;

   
   

    使用ConConcurrentHashMap时候 有时候会遇到跨段的问题,跨段的时候【size()、 containsValue()】,可能需要锁定部分段或者全段,当操作结束之后,又回按照 顺序 进行 释放 每一段的锁。注意是按照顺序解锁的。,每个Segment又包含了多个HashEntry.

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

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

      需要注意的是 Segment 是一种可重入锁(继承ReentrantLock)

      那么我简单说一下ReentrantLock 与synchronized有什么区别?
      这里写图片描述

      • synchronized 是一个同步锁 synchronized (this)
        • 同步锁 当一个线程A 访问 【资源】的代码同步块的时候,A线程就会持续持有当前锁的状态,如果其他线程B-E 也要访问【资源】的代码同步块的时候将会收到阻塞,因此需要排队等待A线程释放锁的状态。(如图情况1)但是注意的是,当一个线程B-E 只是不能方法 A线程 【资源】的代码同步块,仍然可以访问其他的非资源同步块。
      • ReentrantLock 可重入锁 通常两类:公平性、非公平性
        • 公平性:根据线程请求锁的顺序依次获取锁,当一个线程A 访问 【资源】的期间,线程A 获取锁资源,此时内部存在一个计数器num+1,在访问期间,线程B、C请求 资源时,发现A 线程在持有当前资源,因此在后面生成节点排队(B 处于待唤醒状态),假如此时a线程再次请求资源时,不需要再次排队,可以直接再次获取当前资源 (内部计数器+1 num=2) ,当A线程释放所有锁的时候(num=0),此时会唤醒B线程进行获取锁的操作,其他C-E线程就同理。(情况2)
        • 非公平性:当A线程已经释放所之后,准备唤醒线程B获取资源的时候,此时线程M 获取请求,此时会出现竞争,线程B 没有竞争过M线程,测试M获取的线程因此,M会有限获得资源,B继续睡眠。(情况2)
      • synchronized 是一个非公平性锁。 非公平性 会比公平性锁的效率要搞很多原因,不需要通知等待。
      • ReentrantLock 提供了 new Condition可以获得多个Condition对象,可以简单的实现比较复杂的线程同步的功能.通过await(),signal()以实现。
      • ReentrantLock 提供可以中断锁的一个方法lock.lockInterruptibly()方法。
      • Jdk 1.8 synchronized和 ReentrantLock 比较的话,官方比较建议用synchronized。

      在了解Segment 机制之后我们继续看一下ConcurrentHashMap核心构造方法代码。

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

        构造方法

        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;
                 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有比较重要的三个参数:

          1. loadFactor 负载因子 0.75
          2. threshold 初始 容量 16
          3. concurrencyLevel 实际上是Segment的实际数量。

          ConcurrentHashMap如何发生ReHash?
          ConcurrentLevel 一旦设定的话,就不会改变。ConcurrentHashMap当元素个数大于临界值的时候,就会发生扩容。但是ConcurrentHashMap与其他的HashMap不同的是,它不会对Segmengt 数量增大,只会增加Segmengt 后面的链表容量的大小。即对每个Segmengt 的元素进行的ReHash操作。


          我们再看一下核心的ConcurrentHashMapput ()方法:

           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 方法 当前key 为空的时候 ,代码报错。
            这个代码主要是把Key 通过Hash函数计算出hash值 现计算出当前key属于那个Segment 调用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()是ReentrantLock获取锁一个方法。如果当前线程获取锁成功 返回true,如果别线程获取了锁返回false不成功时会遍历定位到的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;
                    }
            
             
             
              1. Put 时候 ,通过Hash函数将即将要put 的元素均匀的放到所需要的Segment 段中,调用Segment的put 方法进行数据。
              2. Segment的put 是加锁中完成的。如果当前元素数大于最大临界值的的话将会产生rehash. 先通过 getFirst 找到链表的表头部分,然后遍历链表,调用equals 比配是否存在相同的key ,如果找到的话,则将最新的Key 对应value值。如果没有找到,新增一个HashEntry 它加到整个Segment的头部。

              我们先看一下Get 方法的源码:

              //计算Segment中元素的数量
              transient volatile int count;
              ***********************************************************
                  public V get(Object key) {  
                      int hash = hash(key.hashCode());  
                      return segmentFor(hash).get(key, hash);  
                  }  
              ***********************************************************
              
              final Segment&lt;K,V&gt; segmentFor(int hash) {  
                  return segments[(hash &gt;&gt;&gt; segmentShift) &amp; segmentMask];  
              }  
              

              V get(Object key, int hash) {  
                  if (count != 0) { // read-volatile  
                      HashEntry&lt;K,V&gt; e = getFirst(hash);  
                      while (e != null) {  
                          if (e.hash == hash &amp;&amp; key.equals(e.key)) {  
                              V v = e.value;  
                              if (v != null)  
                                  return v;  
                              return readValueUnderLock(e); // recheck  
                          }  
                          e = e.next;  
                      }  
                  }  
                  return null;  
              }  
              

                1.读取的时候 传递Key值,通过Hash函数计算出 对应Segment 的位置。
                2.调用segmentFor(int hash) 方法,用于确定操作应该在哪一个segment中进行 ,通过 右无符号位运算 右移segmentShift位在与运算 segmentMask【偏移码】 获得需要操作的Segment

                • 确定了需要操作的Segment 再调用 get 方法获取对应的值。通过count 值先判断当前值是否为空。在调用getFirst()获取头节点,然后遍历列表通过equals对比的方式进行比对返回值。

                ConcurrentHashMap为什么读的时候不加锁?

                • ConcurrentHashMap是分段并发分段进行读取数据的。
                • Segment 里面有一个Count 字段,用来表示当前Segment中元素的个数 它的类型是volatile变量。所有的操作到最后都会 在最后一部更新count 这个变量,由于volatile变量 happer-before的特性。导致get 方法能够几乎准确的获取最新的结构更新。

                再看一下ConcurrentHashMapRemove()方法:

                
                    V remove(Object key, int hash, Object value) {  
                        lock();  
                        try {  
                            int c = count - 1;  
                            HashEntry<K,V>[] tab = table;  
                            int index = hash & (tab.length - 1);  
                            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 = null;  
                        if (e != null) {  
                            V v = e.value;  
                            if (value == null || value.equals(v)) {  
                                oldValue = v;  
                                // All entries following removed node can stay  
                                // in list, but all preceding ones need to be  
                                // cloned.  
                                ++modCount;  
                                HashEntry&lt;K,V&gt; newFirst = e.next;  
                                for (HashEntry&lt;K,V&gt; p = first; p != e; p = p.next)  
                                    newFirst = new HashEntry&lt;K,V&gt;(p.key, p.hash,  
                                                                  newFirst, p.value);  
                                tab[index] = newFirst;  
                                count = c; // write-volatile  
                            }  
                        }  
                        return oldValue;  
                    } finally {  
                        unlock();  
                    }  
                }  
                

                  这里写图片描述

                  1. 调用Segment 的remove 方法,先定位当前要删除的元素C,此时需要把A、B元素全部复制一遍,一个一个接入到D上。
                  2. remove 也是在加锁的情况下进行的。

                  volatile 变量

                  我们发现 对于CurrentHashMap而言的话,源码里面又很多地方都用到了这个变量。比如HashEntry 、value 、Segment元素个数Count。

                  volatile 属于JMM 模型中的一个词语。首先先简单说一下 Java内存模型中的 几个概念:

                  • 原子性:保证 Java内存模型中原子变量内存操作的。通常有 read、write、load、use、assign、store、lock、unlock等这些。
                  • 可见性:就是当一个线程对一个变量进行了修改,其他线程即可立即得到这个变量最新的修改数据。
                  • 有序性:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。
                  • 先行发生:happen-before 先行发生原则是指Java内存模型中定义的两项操作之间的依序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前.
                  • 传递性

                  volatile 变量 与普通变量的不同之处?

                  • volatile 是有可见性,一定程度的有序性。
                  • volatile 赋值的时候新值能够立即刷新到主内存中去,每次使用的时候能够立刻从内存中刷新。

                  做一个简单例子看一下 这个功能

                  public class VolatileTest{
                  
                  int a=1;
                  int b=2;
                  
                  //赋值操作
                  public  void change(){
                  	a=3;
                  	b=a;
                  }
                   
                  //打印操作
                  public  void print(){
                  	System.out.println("b:"+b+",a:"+a);
                  }
                  
                  @Test
                  public void testNorMal(){
                  	VolatileTest vt=new VolatileTest();
                  	
                   for (int i = 0; i &lt; 100000; i++) {
                  	 new Thread(new Runnable() {
                  			@Override
                  			public void run() {
                  				 try {
                  					Thread.sleep(100);
                  				} catch (InterruptedException e) {
                  					// TODO Auto-generated catch block
                  					e.printStackTrace();
                  				}
                  				 vt.change();
                  			}
                  		}).start();
                  		
                  		
                  		new Thread(new Runnable() {
                  			@Override
                  			public void run() {
                  				 try {
                  					Thread.sleep(10);
                  				} catch (InterruptedException e) {
                  					// TODO Auto-generated catch block
                  					e.printStackTrace();
                  				}
                  				 vt.print();
                  			}
                  		}).start();
                  }	
                  	
                  	
                  }
                  

                  }

                    跑了 n 次会出现一条 b=3,a=1 的错误打印记录。这就是因为普通变量相比volatile 不存在 可见性。

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

                    请填写红包祝福语或标题

                    红包个数最小为10个

                    红包金额最低5元

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

                    抵扣说明:

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

                    余额充值