【集合类】 3 ConcurrentHashMap原理探究


相关文章:
Java1.7 HashMap 实现原理和源码分析(多线程下扩容死循环问题)
Java1.8 HashMap 实现原理和源码分析
ConcurrentHashMap原理探究
【集合类】 java.util.ConcurrentModificationException异常详解&HashMap&ConcurrentHashMap原理探究

1. 简介

在多线程环境下,使用HashMap进行put操作时存在丢失数据的情况,为了避免这种bug的隐患,强烈建议使用ConcurrentHashMap代替HashMap

HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地,允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的Hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。

2. JDK1.7的ConcurrentHashMap的实现原理

ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment 的结构,一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组,我们用下面这一幅图来看下 ConcurrentHashMap 的内部结构,从下面的结构我们可以了解到,ConcurrentHashMap 定位一个元素的过程需要进行两次Hash操作第一次 Hash 定位到 Segment第二次 Hash 定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上),所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。我们用下面这一幅图来看下ConcurrentHashMap的内部结构详情图,如下:

在这里插入图片描述

不难看出,ConcurrentHashMap采用了二次hash的方式,第一次hash将key映射到对应的segment,而第二次hash则是映射到segment的不同桶(bucket)中。

为什么要用二次hash,主要原因是为了构造分离锁,使得对于map的修改不会锁住整个容器,提高并发能力。当然,没有一种东西是绝对完美的,二次hash带来的问题是整个hash的过程比hashmap单次hash要长,所以,如果不是并发情形,不要使用concurrentHashmap。

JAVA7之前ConcurrentHashMap主要采用锁机制,在对某个Segment进行操作时,将该Segment锁定,不允许对其进行非查询操作,而在JAVA8之后采用CAS无锁算法,这种乐观操作在完成前进行判断,如果符合预期结果才给予执行,对并发操作提供良好的优化.

3. JDK1.7的ConcurrentHashMap的源码分析

如上一节中的图示,ConcurrentHashMap 是由Segment 数组组成:

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {
    // 内部维护一个 Segment数组    
    final Segment<K,V>[] segments;
}

3.1 Segment

Segment是ConcurrentHashMap的内部类,Segment由HashEntry 数组组成,和 HashMap 一样,底层结构仍然是数组加链表。

Segment结构本质就是一个HashMap,ConcurrentHashMap被拆分成多个小的HashMap片段,这样通过在片段上加锁,避免锁住整个ConcurrentHashMap。对于某个片段,结构和HashMap几乎一致,也是数组加链表。

3.1.1 Segment的成员变量

让我们看看Segment里面的成员变量,源码如下:

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {
   
    //内部类
	static final class Segment<K,V> extends ReentrantLock implements Serializable {
	    transient volatile int count;    //Segment中元素的数量
	    transient int modCount;          //对table的大小造成影响的操作的数量(比如put或者remove操作)
	    transient int threshold;        //阈值,Segment里面元素的数量超过这个值那么就会对Segment进行扩容
	    final float loadFactor;         //负载因子,用于确定threshold
	    transient volatile HashEntry<K,V>[] table;    //链表数组,数组中的每一个元素代表了一个链表的头部
}

链表数组(HashEntry[]),数组中的每一个元素代表了一个链表的头部,因为采用头插法,具体细节和原理可以参考 Java1.7 HashMap 实现原理和源码分析 《2.2.4.1 createEntry()》章节里面的头插法介绍。

3.1.2 HashEntry类

我们知道Segment结构本质就是一个HashMap片段,Segment内部持有HashEntry数组,HashMap内部持有Entry数组,自然,HashEntry实现原理和Entry类似,源码如下:

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

        HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        /**
         * 设置具有volatile写语义的next字段。
         */
        final void setNext(HashEntry<K,V> n) {
            UNSAFE.putOrderedObject(this, nextOffset, n);
        }

        // Unsafe mechanics
        static final sun.misc.Unsafe UNSAFE;
     //下一个HashEntry的偏移量
        static final long nextOffset;
        static {
            try {
                UNSAFE = sun.misc.Unsafe.getUnsafe();
                Class k = HashEntry.class;
          //获取HashEntry next在内存中的偏移量
                nextOffset = UNSAFE.objectFieldOffset
                    (k.getDeclaredField("next"));
            } catch (Exception e) {
                throw new Error(e);
            }
        }
    }

是不是与 HashMap的 Entry非常类似?唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。

原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock(只对写和删加锁,读不加锁)。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

3.2 ConcurrentHashMap的方法

3.2.1成员变量和构造函数

接着让我们继续看看JDK1.7中ConcurrentHashMap的成员变量和构造函数,源码如下:

// 默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 默认segment层级
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// segment最小容量
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
// 一个segment最大容量
static final int MAX_SEGMENTS = 1 << 16;
// 锁之前重试次数
static final int RETRIES_BEFORE_LOCK = 2;

public ConcurrentHashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}

public ConcurrentHashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}

public ConcurrentHashMap(int initialCapacity, float loadFactor) {
        this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
}

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;
        // 找到两种大小的最匹配参数
        int sshift = 0;
        // segment数组的长度是由concurrentLevel计算来的,segment数组的长度是2的N次方,
        // 默认concurrencyLevel = 16, 所以ssize在默认情况下也是16,此时 sshift = 4
        // sshift相当于ssize从1向左移的次数
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift; 
            ssize <<= 1;
        }
        // 段偏移量,默认值情况下此时segmentShift = 28
        this.segmentShift = 32 - sshift;
        // 散列算法的掩码,默认值情况下segmentMask = 15
        this.segmentMask = ssize - 1;

        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]
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        // 创建ssize长度的Segment数组
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
 }

    其中,concurrencyLevel 一经指定,不可改变,默认concurrencyLevel = 16,后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。

    整个ConcurrentHashMap的初始化方法还是非常简单的,先是根据concurrencyLevelnewSegment,这里Segment的数量是不大于concurrencyLevel的最大的2的指数,就是说Segment的数量永远是2的指数个,这样的好处是方便采用移位操作来进行hash,加快hash的过程。接下来就是根据intialCapacity确定Segment的容量的大小,每一个Segment的容量大小也是2的指数,同样使为了加快hash的过程。

注意一下两个变量segmentShiftsegmentMask,这两个变量在后面将会起到很大的作用,假设构造函数确定了Segment的数量是2的n次方,那么segmentShift就等于32减去n,而segmentMask就等于2的n次方减一。

下文中有单独章节来解释为什么Segment的容量大小是2的指数

接下来让我们看看JDK1.7中的ConcurrentHashMap的核心方法 put 方法和get 方法。

3.2.2 put()方法实现
public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
     //计算key的hash值
        int hash = hash(key);
     //根据hash值,segmentShift,segmentMask定位到哪个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);
     //将键值对保存到对应的segment中。
        return s.put(key, hash, value, false);
}

可以看到首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。

总的来说,put 的流程如下:

  • 在Segment上持有锁
  • 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
  • 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
  • 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
  • 最后会解除Segment上 的锁。
3.2.2.1 调用Segment的put()方法

Segment 中进行具体的 put()的源码如下:

  final V put(K key, int hash, V value, boolean onlyIfAbsent) {
       // 对segment加锁,如果直接加锁成功,那么node=null;如果加锁失败,则会调用scanAndLockForPut方法去获取锁
       // 详情见注1
       HashEntry<K, V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
       V oldValue;
       try {
           // 这里是一个优化点,将table数组引用赋值给局部变量以实现编译、运行时的优化,详情见注2
           HashEntry<K, V>[] tab = table;
           // 计算对应HashEntry[](table数组)的下标
           int index = (tab.length - 1) & hash;
           // 获取当前这个segment中对应的链表的表头结点,由于采用头插法,找到表头结点,意味着就能找到整个链表
           // 疑问为什么不直接使用数组下标tab[index]形式获取HashEntry,而要用entryAt来获取链表?详情见注3
           HashEntry<K, V> first = entryAt(tab, index);

           for (HashEntry<K, V> e = first;;) { // 开始遍历first为头结点的链表
               if (e != null) {// <标记1> e != null,表明已经存在链表,则判断是否存在Key值相同的元素,存在则替换之
                   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; // 注意:如果循环中e是链表的最后一个元素,仍没找到,e也会为空,因为最后一个元素的next指向null
               } else {
                   // 进入到这个else分支,说明e为空,对应有两种情况下e可能会为空,即:
                   // 1. <标记11>中进行循环遍历,遍历到了链表的表尾仍然没有满足条件的节点。
                   // 2. e=first一开始就是null,链表为空
                   if (node != null)
                       node.setNext(first);
                   else
                       node = new HashEntry<K, V>(hash, key, value, first);
                   int c = count + 1;
                   if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                       rehash(node); // 超过了阈值或者segment中数组的长度超过了MAXIMUM_CAPACITY,如果满足条件则rehash扩容!
                   else
                       setEntryAt(tab, index, node); // 不需要扩容时,将node放到数组(HashEntry[])中对应的位置
                   ++modCount;
                   count = c;
                   oldValue = null;
                   break;
               }
           }
       } finally {
           unlock(); //最后释放锁
       }
       return oldValue;
   }
3.2.2.1.1 注1 scanAndLockForPut()作用

scanAndLockForPut()作用就是在第一次获取锁失败时,自旋获取锁:

HashEntry<K, V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);

可以不必阅读scanAndLockForPut()源码,只要知道2点就行,首先是一定会获取锁,其次,可能有返回值,注意是可能,不是一定有返回值。

我们看下scanAndLockForPut()源码,有点复杂:

  private HashEntry<K, V> scanAndLockForPut(K key, int hash, V value) {
       HashEntry<K, V> first = entryForHash(this, hash);
       HashEntry<K, V> e = first;
       HashEntry<K, V> node = null;
       int retries = -1; // negative while locating node
       while (!tryLock()) { // 尝试获取锁,失败则开始自旋
           HashEntry<K, V> f; // 用于后续重新检查头结点
           if (retries < 0) { // 获取锁失败,初始时retries=-1必然开始先进入,遍历链表,直至找到已存在的key或至尾部;如果自旋期间提前获取锁,也会提前终止遍历
               if (e == null) {
                   if (node == null) // node有2种可能为null,一是开始时first就是Null,二是到队尾,仍未找到已存在的key
                       node = new HashEntry<K, V>(hash, key, value, null);
                   retries = 0;
               } else if (key.equals(e.key)) // 遍历过程发现链表中找到了已存在的key
                   retries = 0;
               else
                   e = e.next; // 当前位置对应的key不是我们需要的,遍历下一个
           } else if (++retries > MAX_SCAN_RETRIES) {
               lock(); // 尝试获取锁次数超过设置的最大值,直接进入阻塞等待,这就是所谓的有限制的自旋获取锁
               break;
           } else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) {
               e = first = f; // 遍历过程中,有可能其它线程改变了遍历的链表,这时就需要重新进行遍历了
               retries = -1;
           }
       }
       return node;
   }

前面已经提过scanAndLockForPut()使用自旋次数受限制的自旋锁进行优化加锁的方式,此外在自旋期间,遍历一次链表也是一种优化方法,主要是尽可能使当前链表中的节点进入CPU高速缓存,提高缓存命中率,以便获取锁定后的遍历速度更快。

原文方法的英文注释:在尝试获取锁的同时扫描包含给定“key”的节点,如果找不到则创建并返回一个节点。 返回时,保证锁被持有。 与大多数方法不同, calls to method equals are not screened:由于遍历速度并不重要,因此我们也可能会帮助预热关联的代码和访问。

注意:自旋是有次数限制的,如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,使用lock加锁,获取锁失败则会进入等待队列,避免持续消耗资源。

3.2.2.1.2 注2 tab = table作用
HashEntry<K, V>[] tab = table;

从Segment源码可知,table被声明为volatile,为了保证内存可见性,table上的修改都必须立即更新到主存,volatile写实际是具有一定开销的。

由于put中的代码都在加锁区执行,锁既能保证可见性,也能保证原子性,因此,不需要针对table进行volatile写,将table引用赋值给局部变量以实现编译、运行时的优化。

3.2.2.1.2 注3 entryAt()作用

通过 entryAt()查找节点:

HashEntry<K, V> first = entryAt(tab, index);

加锁保证了table引用的同步语义,但是对table数组中元素的写入使用UNSAFE.putOrderedObject进行顺序写,该操作只是禁止写写指令重排序,不能保证写入后的内存可见性。因此,必须使用entryAt(tab, index)提供的volatile读来获取最新的数据。

本质上与写入节点对应setEntryAt()对应,内部实现是调用UNSAFE.putOrderedObject

setEntryAt(tab, index, node);
3.2.2.2 调用Segment的rehash()方法

rehash主要的作用的是扩容,将扩容前table中的节点重新分配到新的table。由于table的capacity都是2的幂,按照2的幂次方扩容为原来的一倍,扩容前在OldTable[i]中的元素,扩容后要么还是在NewTable[i]里,或者NewTable[i+capacity],这样使得只需要移动原来桶中的部分元素即可将所有节点分配到新的table。

接着让我们看看其扩容,rehash源码如下:

  /**
    * 两倍于之前的容量
    */
   @SuppressWarnings("unchecked")
   private void rehash(HashEntry<K,V> node) {

       HashEntry<K,V>[] oldTable = table;
       int oldCapacity = oldTable.length;
       // 扩大1倍(左移一位)
       int newCapacity = oldCapacity << 1;
       // 计算新的阈值
       threshold = (int)(newCapacity * loadFactor);
       // 创建新的数组
       HashEntry<K,V>[] newTable =
           (HashEntry<K,V>[]) new HashEntry[newCapacity];
       // mask
       int sizeMask = newCapacity - 1;
       // 遍历旧数组数据
       for (int i = 0; i < oldCapacity ; i++) {
           HashEntry<K,V> e = oldTable[i]; // 对应一个链表的表头结点
           if (e != null) {
               HashEntry<K,V> next = e.next;
               // 计算e对应的这条链表在新数组中对应的下标
               int idx = e.hash & sizeMask; 
               if (next == null)   //  只有一个结点时直接放入(新的)数组中
                   newTable[idx] = e;
               else { // 链表有多个结点时:
                   HashEntry<K,V> lastRun = e; // 就链表的表头结点做为新链表的尾结点
                   int lastIdx = idx;
                   for (HashEntry<K,V> last = next;
                        last != null;
                        last = last.next) {
                       // 旧数组中一个链表中的数据并不一定在新数组中属于同一个链表,所以这里需要每次都重新计算
                       int k = last.hash & sizeMask;
                       if (k != lastIdx) {
                           lastIdx = k;
                           lastRun = last;
                       }
                   }
                   // lastRun(和之后的元素)插入数组中。
                   newTable[lastIdx] = lastRun;
                   // 从(旧链表)头结点向后遍历,遍历到最后一组不同于前面hash值的组头。
                   for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                       V v = p.value;
                       int h = p.hash;
                       int k = h & sizeMask;
                       HashEntry<K,V> n = newTable[k];
                       newTable[k] = new HashEntry<K,V>(h, p.key, v, n); // 拼接链表
                   }
               }
           }
       }
       // 将之前的旧数据都添加到新的结构中之后,才会插入新的结点(依旧是插入表头)
       int nodeIndex = node.hash & sizeMask; // add the new node
       node.setNext(newTable[nodeIndex]);
       newTable[nodeIndex] = node;
       table = newTable;
  }
3.2.3 get()方法实现&弱一致性

接下来,再让我们看看JDK1.7中的get方法,源码如下:

   public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key);
        // 首先计算出segment数组的下标  ((h >>> segmentShift) & segmentMask))
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) { // 根据下标找到segment
            // 然后(tab.length - 1) & h) 得到对应HashEntry数组的下标
            // 遍历链表
            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;
    }

首先计算key的hash码,计算Segment的index(变量h),使用getObjectVolatile()方法提供的原子读语义获取Segment,再计算Segment中HashEntry的索引,使用getObjectVolatile()方法提供的原子读语义获取HashEntry头节点,遍历链表,判断是否存在key相同的节点以及获得该节点的value。

由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这就是ConcurrentHashMap的弱一致性。如果要求强一致性,那么必须使用Collections.synchronizedMap()。

3.2.4 remove()方法实现

接着再看看remove方法,源码如下:

public V remove(Object key) {
        // 计算hash值
        int hash = hash(key);
        // 根据hash值找到对应的segment
        Segment<K,V> s = segmentForHash(hash);
        // 调用Segment.remove 函数
        return s == null ? null : s.remove(key, hash, null);
}
public boolean remove(Object key, Object value) {
        int hash = hash(key);
        Segment<K,V> s;
        return value != null && (s = segmentForHash(hash)) != null &&
            s.remove(key, hash, value) != null;
}

找到Segment,然后调用Segment.remove()

3.2.4.1 Segment.remove函数

源码如下:

   /**
         * Remove; match on key only if value null, else match both.
         */
        final V remove(Object key, int hash, Object value) {
            if (!tryLock())
                scanAndLock(key, hash);
            V oldValue = null;
            try {
                HashEntry<K,V>[] tab = table;
                // 计算HashEntry数组下标
                int index = (tab.length - 1) & hash;
                // 找到头结点
                HashEntry<K,V> e = entryAt(tab, index);
                HashEntry<K,V> pred = null;
                while (e != null) {
                    K k;
                    HashEntry<K,V> next = e.next;
                    if ((k = e.key) == key ||
                        (e.hash == hash && key.equals(k))) { // 找到对应节点
                        V v = e.value;
                        if (value == null || value == v || value.equals(v)) {
                            if (pred == null)
                                // 当pred为空时,表示要移除的是链表的表头节点,重新设置链表
                                setEntryAt(tab, index, next);
                            else
                                pred.setNext(next);
                            ++modCount;
                            --count;
                            // 记录旧value值
                            oldValue = v;
                        }
                        break;
                    }
                    pred = e;
                    e = next;
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

4. JDK1.8中的ConcurrentHashMap原理分析

1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题。那么是什么问题呢?
在这里插入图片描述

  • 问题1:在Segment上加锁,粒度太大,线程1和线程2同时打算修改数据,会产生冲突
  • 问题2:链表长度过长,查询遍历链表效率太低

因此 1.8 做了一些数据结构上的调整。,在 JAVA8 中它摒弃了 Segment(锁段)的概念,而是启用了一种全新的方式实现,利用 CAS 算法。底层依然由“数组”+链表+红黑树的方式思想,但是为了做到并发,又增加了很多辅助的类,例如 TreeBin、Traverser等对象内部类。
如何让多线程之间,对象的状态对于各线程的“可视性”是顺序一致的:ConcurrentHashMap 使用了 happens-before 规则来实现。 happens-before规则(摘取自 JAVA 并发编程):

  • 程序次序法则:线程中的每个动作A都 happens-before 于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。

  • 监视器锁法则:对一个监视器锁的解锁 happens-before 于每一个后续对同一监视器锁的加锁。

  • volatile 变量法则:对 volatile 域的写入操作 happens-before 于每一个后续对同一个域的读写操作。

  • 线程启动法则:在一个线程里,对 Thread.start 的调用会 happens-before 于每个启动线程的动作。
    线程终结法则:线程中的任何动作都 happens-before 于其他线程检测到这个线程已经终结、或者从 Thread.join 调用中成功返回,或 Thread.isAlive 返回 false。

  • 中断法则:一个线程调用另一个线程的 interrupt happens-before 于被中断的线程发现中断。

  • 终结法则:一个对象的构造函数的结束 happens-before 于这个对象 finalizer 的开始。

  • 传递性:如果 A happens-before 于 B,且 B happens-before 于 C,则 A happens-before于C:

    假设代码有两条语句,代码顺序是语句1先于语句2执行;那么只要语句之间不存在依赖关系,那么打乱它们的顺序对最终的结果没有影响的话,那么真正交给CPU去执行时,他们的执行顺序可以是先执行语句2然后语句1。

首先来看下底层的组成结构(下图是百度来的,懒得画了):
在这里插入图片描述

4.1 ConcurrentHashMap基础结构

可以看到JDK1.8ConcurrentHashMap 和JDK1.8的HashMap是很相似的。其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

我们看下1.8 ConcurrentHashMap的基础结构,内部维护一个node数组,和普通的1.8的HashMap相似,和1.8相比,不再有Segment概念。

//键值输入。 此类永远不会作为用户可变的Map.Entry导出(即,一个支持setValue;请参阅下面的MapEntry),
//但可以用于批量任务中使用的只读遍历。 具有负哈希字段的节点的子类是特殊的,并且包含空键和值(但永远不会导出)。 否则,键和val永远不会为空。
 public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {
    //内部维护一个Node树组,不再是Segment数组
 transient volatile Node<K,V>[] table;
 
 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        //每个Node节点下又是一个Node集合,1.7中 Segment内部是一个HashEntry链表数组
        volatile Node<K,V> next;

        Node(int hash, K key, V val, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.val = val;
            this.next = next;
        }

        public final K getKey()       { return key; }
        public final V getValue()     { return val; }
        public final int hashCode()   { return key.hashCode() ^ val.hashCode(); }
        public final String toString(){ return key + "=" + val; }
        public final V setValue(V value) {
            throw new UnsupportedOperationException();
        }

        public final boolean equals(Object o) {
            Object k, v, u; Map.Entry<?,?> e;
            return ((o instanceof Map.Entry) &&
                    (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
                    (v = e.getValue()) != null &&
                    (k == key || k.equals(key)) &&
                    (v == (u = val) || v.equals(u)));
        }

        /**
         * 对map.get()的虚拟化支持; 在子类中重写。
         */
        Node<K,V> find(int h, Object k) {
            Node<K,V> e = this;
            if (k != null) {
                do {
                    K ek;
                    if (e.hash == h &&
                        ((ek = e.key) == k || (ek != null && k.equals(ek))))
                        return e;
                } while ((e = e.next) != null);
            }
            return null;
        }
    }

也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。

其中的 val next 都用了 volatile 修饰,保证了可见性。

4.2 put方法分析

核心思想是每次插入节点时,通过CAS+循环,直至成功的方式插入节点!

具体来说,插入链表的首个节点时,采用CAS,后续的节点采用synchronized加锁。

疑问1:有了CAS,为何还加锁?
答:除了头节点,很简单的CAS插入就行了之外,其他节点加入的过程,有好多步骤要做,因此需要加锁

疑问2:为何采用synchronized?不是说效率低吗?为啥不用ReentranLock?
答:ReentranLock侧重有序(公平锁或非公平锁都可以)场景时,必须使用,其余均可以用synchronized替代,二者用法差不多。

细分的话,ReentranLock底层利用AQS,线程会被挂起和唤醒,这样一来,我们就很容易会多出线程上下文开销的代。synchronized内部实现会由CAS轻量级锁往重量级锁上升级,一般情况下,加入节点操作很快,几乎都不需要升级到重量级锁。

接着再看看put方法的源码,源码如下:

 public V put(K key, V value) {
        return putVal(key, value, false);
   }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
     //(1)
        if (key == null || value == null) throw new NullPointerException();
     //(2)计算hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
    
        for (Node<K,V>[] tab = table;;) { //CAS经典写法,不成功无限重试,让再次进行循环进行相应操作
            Node<K,V> f; int n, i, fh;
       //除非构造时指定初始化集合,否则默认构造不初始化table,所以需要在添加时元素检查是否需要初始化。
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
       //(5) 链表首结点为null时,cas插入作为第一个节点
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // cas成功后,终止循环
            }      
            else if ((fh = f.hash) == MOVED)  //(6)hashcode == MOVED == -1表示当前结点正在扩容
                //让当前线程调用helpTransfer也参与到扩容过程中来,扩容完毕后tab指向新table。
                //这种设计很神奇
                tab = helpTransfer(tab, f); 
            else {
                V oldVal = null;
          //(7)
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
              //(8)
                        if (fh >= 0) {
                            binCount = 1;
                 //(9)
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                   //(10)
                                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;
                   //(11)如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
              //(12)
                        else if (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) {
            //(13)
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
     //代码(14)
        addCount(1L, binCount);
        return null;
    }

代码(1)若为空 抛异常

代码(5)f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功,就是首次建立链表,插入第一个节点

代码(7)如果都不满足,则利用 synchronized 锁写入数据。结点上锁 这里的结点可以理解为hash值相同组成的链表的头结点。 插入非链表的首节点, 这里可能和大家的预想不一样,这里竟然使用了 synchronized 锁。而且,加锁的对象是f变量,f一定是链表的头结点,即该元素在Node数组中。所以这里锁住的是hash冲突那条链表。

如果不是链表第一个object, 则直接用链表第一个object加锁,这里加的锁是synchronized,虽然效率不如 ReentrantLock, 但节约了空间,这里会一直用第一个object为锁, 直到重新计算map大小, 比如扩容或者操作了第一个object为止,参见8-11步:

代码(8)fh〉0 说明这个节点是一个链表的节点 不是树的节点.

代码(9)在这里遍历链表所有的结点

代码(10)如果hash值和key值相同 则修改对应结点的value值

代码(11)如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部

代码(12)如果这个节点是树节点,就按照树的方式插入值

代码(13)如果链表长度已经达到临界值8 就需要把链表转换为树结构。如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

代码(14)将当前ConcurrentHashMap的元素数量+1

4.2.1 spread()

进行hash计算。

jdk1.8的hash策略,与以往版本一样都是为了减少hash冲突:

	static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash   //01111111_11111111_11111111_11111111
	
    static final int spread(int h) {
	    //无符号右移加入高位影响,与HASH_BITS做与操作保留对hash有用的比特位,有让hash>0的意思
        return (h ^ (h >>> 16)) & HASH_BITS;
    }

4.2.2 initTable()

initTable()用于里面table数组的初始化。

值得一提的是table初始化是没有加锁的,那么如何处理并发呢?
由下面代码可以看到,当要初始化时会通过CAS操作将sizeCtl置为-1,而sizeCtl由volatile修饰,保证修改对后面线程可见。

这之后如果再有线程执行到此方法时检测到sizeCtl为负数,说明已经有线程在给扩容了,这个线程就会调用Thread.yield()让出一次CPU执行时间。

private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); 
                //正在初始化时将sizeCtl设为-1
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;  //DEFAULT_CAPACITY为16
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);   //扩容阈值为新容量的0.75倍
                    }
                } finally {
                    sizeCtl = sc;   //扩容保护
                }
                break;
            }
        }
        return tab;
    }

4.3 get方法分析

// GET方法(JAVA8)
public V get(Object key) {  
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;  
    //计算hash值  
    int h = spread(key.hashCode());  
    //根据hash值确定节点位置  
    if ((tab = table) != null && (n = tab.length) > 0 &&  
        (e = tabAt(tab, (n - 1) & h)) != null) {  
        //如果搜索到的节点key与传入的key相同且不为null,直接返回这个节点    
        if ((eh = e.hash) == h) {  
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))  
                return e.val;  
        }  
        //如果eh<0 说明这个节点在树上 直接寻找  
        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;  
}  

4.4 remove方法

接着再看看JDK1.8中ConcurrentHashMap的remove方法源码,源码如下:

// REMOVE OR REPLACE方法(JAVA8)
 final V replaceNode(Object key, V value, Object cv) {
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // 数组不为空,长度不为0,指定hash码值为0
        if (tab == null || (n = tab.length) == 0 ||
            (f = tabAt(tab, i = (n - 1) & hash)) == null)
            break;
        // 是一个 forwardNode
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            boolean validated = false;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        validated = true;
                        // 循环寻找
                        for (Node<K,V> e = f, pred = null;;) {
                            K ek;
                            // equal 相同 取出
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                V ev = e.val;
                                 // value为null或value和查到的值相等  
                                if (cv == null || cv == ev ||
                                    (ev != null && cv.equals(ev))) {
                                    oldVal = ev;
                                    if (value != null)
                                        e.val = value;
                                    else if (pred != null)
                                        pred.next = e.next;
                                    else
                                        setTabAt(tab, i, e.next);
                                }
                                break;
                            }
                            pred = e;
                            if ((e = e.next) == null)
                                break;
                        }
                    }
                    // 若是树 红黑树高效查找/删除
                    else if (f instanceof TreeBin) {
                        validated = true;
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> r, p;
                        if ((r = t.root) != null &&
                            (p = r.findTreeNode(hash, key, null)) != null) {
                            V pv = p.val;
                            if (cv == null || cv == pv ||
                                (pv != null && cv.equals(pv))) {
                                oldVal = pv;
                                if (value != null)
                                    p.val = value;
                                else if (t.removeTreeNode(p))
                                    setTabAt(tab, i, untreeify(t.first));
                            }
                        }
                    }
                }
            }
            if (validated) {
                if (oldVal != null) {
                    if (value == null)
                        addCount(-1L, -1);
                    return oldVal;
                }
                break;
            }
        }
    }
    return null;
}

可以看出 JDK1.8 和 JDK1.7 对 ConcurrentHashMap 的实现改变,笔者更喜欢 CAS 无锁机制,如果只是看我写以上代码注释明显不足以了解 JAVA8 的 ConcurrentHashMap 的实现,我也仅仅提供源码阅读的思路,其中 cas、volatile、final 等注意已经给解释,所以如果大家真的感兴趣还是写程序,打断点,一步步看看这个代码的实现.

1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

面试题,供测试掌握程度 (需要结合JDK版本信息,否则偏漏)

相信到这里为止,理解上面的内容,遇到面试,问题都迎刃而解,下面是网上找的面试题,如下:

(1)你知道 HashMap 的工作原理吗?你知道 HashMap 的 get() 方法的工作原理吗?

HashMap 是基于 hashing 的原理,我们使用 put(key, value) 存储对象到 HashMap 中,使用 get(key) 从 HashMap 中获取对象。当我们给 put() 方法传递键和值时,我们先对键调用 hashCode() 方法,返回的 hashCode 用于找到 bucket 位置来储存 Entry 对象。

(2)你知道 ConcurrentHashMap 的工作原理吗?你知道 ConcurrentHashMap 在 JAVA8 和 JAVA7 对比有哪些不同呢?

ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment 的结构,一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组,我们用下面这一幅图来看下 ConcurrentHashMap 的内部结构,从下面的结构我们可以了解到,ConcurrentHashMap 定位一个元素的过程需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上),所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。

JAVA7之前ConcurrentHashMap主要采用锁机制,在对某个Segment进行操作时,将该Segment锁定,不允许对其进行非查询操作,而在JAVA8之后采用CAS无锁算法,这种乐观操作在完成前进行判断,如果符合预期结果才给予执行,对并发操作提供良好的优化

(3)当两个对象的hashcode相同会发生什么?

因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为Map使用LinkedList存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在LinkedList中。(当向 Map 中添加 key-value 对,由其 key 的 hashCode() 返回值决定该 key-value 对(就是 Entry 对象)的存储位置。当两个 Entry 对象的 key 的 hashCode() 返回值相同时,将由 key 通过 eqauls() 比较值决定是采用覆盖行为(返回 true),还是产生 Entry 链(返回 false)),此时若你能讲解JDK1.8红黑树引入,面试官或许会刮目相看。

(4)如果两个键的 hashcode 相同,你如何获取值对象?

当我们调用get()方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,然后获取值对象。如果有两个值对象储存在同一个 bucket,将会遍历 LinkedList 直到找到值对象。找到 bucket 位置之后,会调用 keys.equals() 方法去找到 LinkedList 中正确的节点,最终找到要找的值对象。(当程序通过 key 取出对应 value 时,系统只要先计算出该 key 的 hashCode() 返回值,在根据该 hashCode 返回值找出该 key 在 table 数组中的索引,然后取出该索引处的 Entry,最后返回该 key 对应的 value 即可)。

(5)如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

(6)你了解重新调整HashMap大小存在什么问题吗?

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了(1.7版本有该问题,1.8解决了该问题)。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?

(7)请问ConcurrentHashMap中变量使用final和volatile修饰有什么用呢?其中链表是final的next属性,那么发生删除某个元素,如何实现的?

使用final来实现不变模式(immutable),他是多线程安全里最简单的一种保障方式。因为你拿他没有办法,想改变它也没有机会。不变模式主要通过final关键字来限定的。在JMM中final关键字还有特殊的语义。Final域使得确保初始化安全性(initialization safety)成为可能,初始化安全性让不可变形对象不需要同步就能自由地被访问和共享。

使用volatile来保证某个变量内存的改变对其他线程即时可见,在配合CAS可以实现不加锁对并发操作的支持

remove执行的开始就将table赋给一个局部变量tab,将tab依次复制出来,最后直到该删除位置,将指针指向下一个变量。

(8)描述一下ConcurrentHashMap中remove操作,有什么需要注意的?

需要注意如下几点。第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。

(9)HashTable与ConcurrentHashMap有什么区别,描述锁分段技术。

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

参考:

Java并发编程笔记之ConcurrentHashMap原理探究本文主体参考源自该文章

ConcurrentHashMap1.7源码分析 借鉴put方法说明

阿里面试官 ConcurrentHashMap的实现原理

深入理解HashMap+ConcurrentHashMap的扩容策略

从源码和面试官聊了半小时Java8的ConCurrentHashMap(两万字图文总结)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值