Java并发包学习笔记ConcurrentHashMap

一、ConcurrentHashMap

ConcurrentHashMap相比HashMap是线程安全的,与Hashtable及Collections.synchronizedMap()相比,并发性能更好,但ConcurrentHashMap降低了对读一致性的要求(类似CAP原理)。

ConcurrentHashMap的设计,使用了大量的volatile, final, CAS等lock-free技术,以此来减少锁竞争对于性能的影响。

研究透ConcurrentHashMap的设计与源码,对于学习Java并发编程,及理解Java内存模型,都很有好处。

JDK1.7对ConcurrentHashMap的实现

1.设计思路

ConcurrentHashMap采用了分段锁,只有在同一个segment内才存在竞争关系,不同的segment之间没有锁竞争。

虽然比HashMap锁定整个HashMap并发性能好,但这样的设计导致了有些方法不得不扫描整个Map(如size(), containsValue()),另外一些方法如clear()设置放弃了对一致性的要求(ConcurrentHashMap是弱一致性的)

ConcurrentHashMap分成了很多Segment, 每个Segment类似于一个HashMap, 即内部拥有Entry数组,每个数组元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

ConcurrentHashMap中的每个元素叫HashEntry, 类似于HashMap中的Entry,但HashEntry有下面的独特设计:
HashEntry中的value及next都被volatile修饰,这样在多线程中能保持他们的可见性。
代码如下:

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

2. 并发度(Concurrency Level)

并发度指:程序运行时能够同时更新ConcurrentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的Segment个数。ConcurrentHashMap的默认并发度是16,用户也可以在构造器中自己设置。

如果并发度过小,会带来严重的锁竞争问题;如果并发度过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。

3. 创建分段锁

与JDK1.6不同,JDK1.7除了第一个Segment外,企图Segments都采用延迟加载的机制:每次put之前都要检查key对应的Segment是否为null, 是则调用ensureSegment()来创建对应的Segment.

ensureSegment可能在并发环境下被调用,但ensureSegment并没有使用锁来控制竞争,而是使用了Unsafe对象的getObjectVolatile()提供的原子读语义结合CAS来确保Segment创建的原子性。

JDK1.7的相关源代码为:

if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
    == null) { // recheck
    Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
    while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
           == null) {
        if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
            break;
    }
}

4. put/putIfAbsent/putAll

ConcurrentHashMap的put方法被代理到了对应的Segment中。相比JDK1.6,JDK1.7在获得Segment锁的过程中,做了一定的优化:在真正申请锁之前,put方法会通过tryLock()方法尝试获得锁,在尝试获得锁的过程中,会对对应的hashcode的链表进行遍历,如果遍历完仍然找不到key对应的HashEntry节点,则为后续的put操作提前创建一个HashEntry. 当tryLock一定次数后仍无法获得锁,则通过lock申请锁。

备注:tryLock与lock的区别:
lock -> 调用后一直阻塞到获得锁
tryLock -> 尝试是否能获得锁 如果不能获得立即返回

需要注意的是,在并发环境下,其他线程的put, rehash, remove等操作都可能会导致链表的表头节点的变化,因此在put的过程中,需要进行检查,如果头节点发生变化,则重新对表进行遍历。之所以要在获取锁的过程中对整个链表遍历,主要目的是希望遍历的链表被CPU cache所缓存,为后续put过程中的链表遍历操作提升性能。

在获得锁之后,Segment对链表进行遍历,如果某个HashEntry节点具有相同的key, 则更新该HashEntry的value值,否则新建一个HashEntry节点,将它设置为链表的新head节点,并将原头结点设置为新的head的下一个节点。新建过程中如果节点总数(含新建的HashEntry)超过了threshold,则调用rehash()方法对Segment进行扩容,最后讲新建的HashEntry写入到数组中。

在put方法中,链接新节点的下一个节点(HashEntry.setNext())以及将链表写入数组中(setEntryAt())都是通过Unsafe的putOrderedObject()方法来实现的。这里没有使用具有原子语义的putObjectVolatile是因为:JVM会保证获得锁到释放锁之间所有对象的状态更新都会在锁被释放之后更新到主存,从而保证这些变更对其他线程是可见的。

5. rehash

ConcurrentHashMap的rehash类似于HashMap的resize, 只是做了些优化,避免让所有的节点都进行复制操作:由于扩容时基于2的n次幂,假设扩容前某HashEntry元素在Segment数组中的index为i, 数组的大小为capacity, 那么扩容后,该HashEntry对应到新数组中的index值可能为i或者i+capacity, 因此绝大多数的HashEntry在扩容前后的index可以保持不变。这样,rehash方法会定位到第一个后续所有节点在扩容前后index不变的节点,然后将这个节点之前的所有节点重新计算index即可。

6. remove

与put类似,remove在获得锁之前,也会对链表进行遍历,以提供缓存命中率。

7. get与containsKey

get与containsKey两个方法很相似:他们都没有使用锁,而是通过Unsafe对象的getObjectVolatile()方法提供的原子读语义,来获得Segment及对应的链表,然后对链表遍历来判断是否存在与Key相同的节点及获得该节点的value。

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

8. size, containsValue方法

这两个方法都是基于整个ConcurrentHashMap的,他们的原理也类似:先不加锁循环执行以下操作:循环所有的Segment(通过Unsafe的getObjectVolatile()以保证原子读语义),获得对应的值及所有的Segment的modcount之和。如果连续两次所有的Segment的modcount和相等,则过程中没有发生其他线程修改ConcurrentHashMap的情况,返回获得的值。

当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获得返回值后再依次解锁。

值得注意的是,加锁过程中要强制创建所有的Segment(默认为16个Segment都要new出来),否则容易出现其他线程创建Segment并进行put, remove等操作。

备注:
1)modcount在put, replace, remove, clear等方法中都会被修改。
2)对于containsValue方法,如果在循环中发现匹配的value的HashEntry,则直接返回true.

  1. 与HashMap不同的是,ConcurrentHashMap不允许Key或Value为null, 这样设计的原因是:在ConcurrentHashMap中,一旦value为null, 则代表HashEntry的key/value没有映射完成就被其他线程所见,需要特殊处理。

JDK8中ConcurrentHashMap的实现

在JDK8中,ConcurrentHashMap进行了巨大的改动:它摒弃了Segment(分段锁)的概念,而是启用了一种全新的方式实现,利用了CAS算法。它沿用了JDK8中的HashMap的思想,底层依然由“数组+链表+红黑树”实现,但为了做到并发,又增加了很多辅助的类,如TreeBin, Traverser等内部类。

1. 重要的属性sizeCtl :

1)负数代表正在进行初始化或扩容
2)-1代表正在初始化
3)-N表示有N-1个线程正在进行扩容操作
4)正数或0表示hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。

2. 重要的类

2.1 Node

Node是ConcurrentHashMap最核心的内部类,类似于HashMap中的Entry,不同的地方在于它对value和next设置了volatile同步锁(与JDK7的Segment相同), 他增加了find方法辅助map.get()方法。

2.2 TreeNode

树节点类, 另外一个核心的数据结构。当链表长度过长的时候,会转换为TreeNode。但与HashMap不同的是,它并不是直接转换为红黑树,而是把这些节点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。TreeNode继承自Node类,并并非如HashMap中的继承自LinkedHashMap.Entry<K,V>类,也就是说,TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问。

2.3 TreeBin

这个类并不负责包装用户的Key, Value信息,而是包装了很多的TreeNode节点,它代替了TreeNode的根节点。也就是说,实际的ConcurrentHashMap”数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。
另外,这个类带有读写锁。

2.4 ForwardingNode

用于连接两个table的节点类,他包含一个nextTable指针,用于指向下一张表。而且这个节点的key, value, next指针全部为Null,他的hash值为-1. 这里面定义的find方法是从nextTable里进行查询节点,而不是以自身为头结点进行查找。

3. Unsafe与CAS

在ConcurrentHashMap中,随处可见U, 大量使用了U.compareAndSwapXXX方法,这个方法利用CAS算法实现无锁的修改值的操作,他可以大大降低锁代理的性能消耗。该算法的基本思想就是不断地区比较当前内存中的变量值与你指定的一个变量值是否相等,如果相当,则接受你的指定的修改的值,否则拒绝你的操作,因为当前线程中的值已经不是最新的值了,你的修改可能会覆盖掉其他线程修改的结果。

3.1 unsafe静态块

unsafe代码块控制了一些属性的修改工作,比如最常用的SIZECTL。在这一版本的concurrentHashMap中,大量应用的CAS方法进行变量、属性的修改工作。利用CAS进行无锁操作,可以大大提高性能。

3.2 三个核心方法

ConcurrentHashMap定义了三个原子操作,用于对指定位置的节点进行操作。正是这些原子操作,保证了ConcurrentHashMap的线程安全。

//获得在i位置上的Node节点
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
//利用CAS算法设置i位置上的Node节点。之所以能实现并发是因为他指定了原来这个节点的值是多少
//在CAS算法中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受你的修改,否则拒绝你的修改
//因此当前线程中的值并不是最新的值,这种修改可能会覆盖掉其他线程的修改结果 有点类似于SVN
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//利用volatile方法设置节点位置的值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

4. 初始化方法initTable

对于ConcurrentHashMap来说,调用他的构造器仅仅是设置了一些参数而已。而整个table的初始化是在向ConcurrentHashMap中插入元素时候发生的。如put, computeIfAbsent, compute, merge等方法的时候,调用时机是检查table==null.

初始化方法主要应用了关键属性sizeCtl, 如果这个值<0, 表示其他线程正在进行初始化,就放弃这个操作。在这里也可以看出,ConcurrentHashMap的初始化只能由一个线程完成。如果获得了初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入。初始化数组后,将sizeCtl的值改为0.75*n.

/**
 * Initializes table, using the size recorded in sizeCtl.
 */
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
            //sizeCtl表示有其他线程正在进行初始化操作,把线程挂起。对于table的初始化工作,只能有一个线程在进行。
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {//利用CAS方法把sizectl的值置为-1 表示本线程正在进行初始化
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);//相当于0.75*n 设置一个扩容的阈值
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

5. 扩容方法transfer

ConcurrentHashMap的扩容方式跟HashMap是很相似的,不同的是,ConcurrentHashMap支持并发扩容,所以要复杂很多。原因是ConcurrentHashMap支持多线程扩容,且没有加锁。

扩容分为两部分:

1)构建一个nextTable, 他的容量是原来的两倍,这个操作是单线程完成的。这个单线程的保证是通过RESIZE_STAMP_SHIFT这个常量经过一次运算来保证的。
2)将原来的table中的元素复制到nextTable中,这里允许多线程并行操作。

我们先看下单线程是如何完成的:

其基本思想就是遍历、复制的过程。首先根据运算得到需要遍历的次数i, 然后利用tabAt方法获得i位置的元素。
1)如果这个位置为null, 就在元table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点。
2)如果这个位置是Node节点(fh>=0),如果它是一个链表的头结点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上。
3)如果这个位置是TreeBin节点(fn<0),也做一个反序处理,并且判断是否需要unfreefi, 把处理的结果范别放在nextTable的i和i+n位置上。
4)遍历过所有的节点以后,就完成了复制工作,这时让nextTable作为新的table, 并且更行sizeCtl为新容量的0.75倍,完成扩容。

再看一下多线程是如何完成的:

如果遍历到的节点是forward节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。多线程遍历节点,处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。这样交叉就完成了复制工作。而且还很好地解决了线程安全问题。

具体源码看transfer方法。

6. put方法

ConcurrentHashMap最常用的方法就是put和get方法。ConcurrentHashMap的put方法的基本思想与HashMap的put类似,不同点在于,ConcurrentHashMap不允许key或value为null。另外, ConcurrentHashMap涉及到多线程,就要复杂一些,在多线程中有下面两种情况:

1)如果一个或多个线程正在对ConcurrentHashMap进行扩容,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检查到,是因为transfer方法中在空节点上插入了forward节点,如果检测到需要插入的位置被forward节点战友,就说明正在扩容,需要帮助扩容。

2)如果检查到要插入的节点是非空,且不是forward节点,就对这个节点加锁,这样就保证了线程安全(注意,加锁的粒度是对节点,而不是像JDK1.7那样对Segment加锁)。

插入过程不允许null作为Key或value, 对于每次放入的值,利用spread方法对key的hashcode进行一次hash计算,由此确定该值在table中的位置。如果这个位置的空的,那么直接放入,而不必加锁。如果这个位置存在节点,说明发生了hash碰撞,首先判断节点的类型,如果链表节点(fh > 0), 则得到的节点就是hash相同的节点组成的链表的头节点,需要依次向后遍历,知道链表尾插入这个节点。如果加入这个节点以后,链表的长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是数节点的话,直接调用树节点的插入方法进行插入新的值。

put方法JDK8比JDK7优化的地方:JDK8也采用了锁分离的思想,但JDK1.8仅锁定一个Node, 而不是像JDK1.7中锁定单个Segment,而锁住Node之前的操作是无锁的,并且也是线程安全的,建立在之前提到的3个原子操作中。

6.1 helpTransfer方法

这是一个协助扩容的方法。这个方法被调用的时候,当前ConcurrentHashMap一定已经有了nextTable对象,首先拿到这个nextTable对象,调用transfer方法。

6.2 treeifyBin方法

这个方法用于将过长的链表转换为TreeBin对象,但他不是直接转换,而是进行一次容量判断,如果容量没有达到转换的要求,直接进行扩容操作并返回;如果满足条件才将链表转换为TreeBin。与HashMap不同的是,他并没有把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装了所有的TreeNode.

7. get方法

给定一个Key来确定value的时候,必须满足两个条件:1. key相同,2. key的hash值相同,对于一个节点可能在链表或树上的情况,需要分别去查找。

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;
}

8. size相关方法

对于ConcurrentHashMap来说,table里面到底装了多少个元素,其实是个不确定的数量,因为不可能在调用size()方法的时候像GC的”stop the world”一样,让其他线程都停下来,让你去统计。因此只能说这个数量是个估计值。但就是对于这个估计值,ConcurrentHashMap也是大费周章才算出来的。

8.1 辅助定义

为了统计元素个数,ConcurrentHashMap定义了一些变量和一个内部类。

/**
 * A padded cell for distributing counts.  Adapted from LongAdder
 * and Striped64.  See their internal docs for explanation.
 */
@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

/******************************************/ 

/**
 * 实际上保存的是hashmap中的元素个数  利用CAS锁进行更新
 但它并不用返回当前hashmap的元素个数 

 */
private transient volatile long baseCount;
/**
 * Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
 */
private transient volatile int cellsBusy;

/**
 * Table of counter cells. When non-null, size is a power of 2.
 */
private transient volatile CounterCell[] counterCells;

8.2 mappingCount与size方法

mappingCount与size方法类似,官方注释说应该使用mappingCount来代替size方法,两个方法都没有直接返回basecount, 而是统计一次这个值,而这个值其实也是一个大概的数值,因此可能在统计的时候,有其他线程正在执行插入或删除操作。

这点JDK8比JDK7的最终可能加锁的机制,性能要好。

8.3 addCount方法

put方法在结尾处调用了addCount方法,把当前ConcurrentHashMap的元素个数+1. 这个方法一共做了2件事:更新baseCount, 检查是否需要扩容。

总结

JDK6,7中的ConcurrentHashMap通过使用Segment来实现减少锁粒度,把HashMap分割成多个Segment, 在put的时候,需要锁住Segment, get时不加锁,使用volatile来保证可见性,当要统计全局是(比如size), 首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size, 如果有,则需要依次锁住每个Segment来计算。

JDK7中的ConcurrentHashMap, 当长度过程碰撞会很频繁,建表的增改查操作都会消耗很长的时间,影响性能。所以JDK8中完全重新了ConcurrentHashMap,代码了从原来的1000多行变成了6000多行,实际上也和原来的分段式存储有了很大的区别。

相比JDK7, JDK8在设计上有一下几点改进:
不采用Segment,而采用Node, 粒度从Segment改进到Node.
设计了MOVED状态,当resize过程中,线程1还在put数据,线程2会帮助resize数据。
使用了3个CAS操作来确保Node的一些操作的原子性,用这种方式代替了锁。
sizeCtl的不同值来代表不同的含义,起到了控制的作用。

至于为什么JDK8中使用了synchronized而不是ReentrantLock, 应该是JDK8中对synchronized有了足够的优化吧。

猜你喜欢

转载自blog.csdn.net/shijinghan1126/article/details/86500559