【Java并发】——并发容器(一)

目录

一、concurrentHashMap

1、concurrentHashMap概述

2、concurrentHashMap数据结构

3、put执行流程

4、get操作

5、ConcurrentHashMap 存取小结

二、jdk1.8中的ConcurrentHashMap 结构

1、put方法

三、总结


一、concurrentHashMap

1、concurrentHashMap概述

我们都知道HashMap 是 Java Collection Framework 的重要成员,也是Map族(如下图所示)中我们最为常用的一种。不过遗憾的是,HashMap不是线程安全的。也就是说,在多线程环境下,操作HashMap会导致各种各样的线程安全问题,比如在HashMap扩容重哈希时出现的死循环问题,脏读问题等。在ConcurrentHashMap中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。特别地,在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设为16),及任意数量线程的读操作。

2、concurrentHashMap数据结构

这里写图片描述

Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,Segment数组中每一个元素就是一把锁,每一个Segment元素存储的是HashEntry数组+链表,这个和HashMap的数据存储结构一样。 
默认ConcurrentHashMap长度是16。

HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?原因是它的get方法里将要使用的共享变量都定义成volatile,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。

······
transient volatile int count;
·······
volatile V value;
  • 1
  • 2
  • 3
  • 4

定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。

3、put执行流程

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。Put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放在HashEntry数组里。

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

如何扩容?扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

put执行流程

1.如果当前数组还未初始化,先进行初始化

2.spread方法重哈希(高16位和低16为异或操作),将哈希值与数组长度与运算,确定带插入结点的索引为i

3.当前哈希桶中i处为空,直接插入

4.i处节点不为null的话并且节点hash>0,说明i处为链表头结点。遍历链表,遇 到与key相同的结点则覆盖其value,如果遍历完没有找到,则尾插入新结点

5.i处结点不为null的话并且结点状态为MOVED,则说明在扩容,帮助扩容

6.i处结点不为null的话并且结点为TreeBin,则使用红黑树的方式插入结点

7.插入新结点后,检查链表长度是否大于8,若大于,则转换成红黑树

8.检测数组长度,若超过临界值,则扩容

ConcurrentHashMap的put操作对应的源码如下:

 /**
     * Maps the specified key to the specified value in this table.
     * Neither the key nor the value can be null.
     *
     * <p> The value can be retrieved by calling the <tt>get</tt> method
     * with a key that is equal to the original key.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>
     * @throws NullPointerException if the specified key or value is null
     */
    public V put(K key, V value) {
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key.hashCode());
        return segmentFor(hash).put(key, hash, value, false);
    }

从上面的源码我们看到,ConcurrentHashMap不同于HashMap,它既不允许key值为null,也不允许value值为null。此外,我们还可以看到,实际上我们对ConcurrentHashMap的put操作被ConcurrentHashMap委托给特定的段来实现。也就是说,当我们向ConcurrentHashMap中put一个Key/Value对时,首先会获得Key的哈希值并对其再次哈希,然后根据最终的hash值定位到这条记录所应该插入的段,定位段的segmentFor()方法源码如下:

/**
     * Returns the segment that should be used for key with given hash
     * @param hash the hash code for the key
     * @return the segment
     */
    final Segment<K,V> segmentFor(int hash) {
        return segments[(hash >>> segmentShift) & segmentMask];
    }

ConcurrentHashMap对Segment的put操作是加锁完成的。我们已经知道,Segment是ReentrantLock的子类,因此Segment本身就是一种可重入的Lock,所以我们可以直接调用其继承而来的lock()方法和unlock()方法对代码进行上锁/解锁。需要注意的是,这里的加锁操作是针对某个具体的Segment,锁定的也是该Segment而不是整个ConcurrentHashMap。因为插入键/值对操作只是在这个Segment包含的某个桶中完成,不需要锁定整个ConcurrentHashMap。因此,其他写线程对另外15个Segment的加锁并不会因为当前线程对这个Segment的加锁而阻塞。故而 相比较于 HashTable 和由同步包装器包装的HashMap每次只能有一个线程执行读或写操作,ConcurrentHashMap 在并发访问性能上有了质的提高。在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设置为 16),及任意数量线程的读操作。

4、get操作

put操作类似,当我们从ConcurrentHashMap中查询一个指定Key的键值对时,首先会定位其应该存在的段,然后查询请求委托给这个段进行处理,源码如下:

/**
     * Returns the value to which the specified key is mapped,
     * or {@code null} if this map contains no mapping for the key.
     *
     * <p>More formally, if this map contains a mapping from a key
     * {@code k} to a value {@code v} such that {@code key.equals(k)},
     * then this method returns {@code v}; otherwise it returns
     * {@code null}.  (There can be at most one such mapping.)
     *
     * @throws NullPointerException if the specified key is null
     */
    public V get(Object key) {
        int hash = hash(key.hashCode());
        return segmentFor(hash).get(key, hash);
    }

我们紧接着研读Segment中get操作的源码:

 V get(Object key, int hash) {
            if (count != 0) {            // read-volatile,首先读 count 变量
                HashEntry<K,V> e = getFirst(hash);   // 获取桶中链表头结点
                while (e != null) {
                    if (e.hash == hash && key.equals(e.key)) {    // 查找链中是否存在指定Key的键值对
                        V v = e.value;
                        if (v != null)  // 如果读到value域不为 null,直接返回
                            return v;   
                        // 如果读到value域为null,说明发生了重排序,加锁后重新读取
                        return readValueUnderLock(e); // recheck
                    }
                    e = e.next;
                }
            }
            return null;  // 如果不存在,直接返回null
        }

了解了ConcurrentHashMap的put操作后,上述源码就很好理解了。但是有一个情况需要特别注意,就是链中存在指定Key的键值对并且其对应的Value值为null的情况。在剖析ConcurrentHashMap的put操作时,我们就知道ConcurrentHashMap不同于HashMap,它既不允许key值为null,也不允许value值为null。但是,此处怎么会存在键值对存在且的Value值为null的情形呢?JDK官方给出的解释是,这种情形发生的场景是:初始化HashEntry时发生的指令重排序导致的,也就是在HashEntry初始化完成之前便返回了它的引用。这时,JDK给出的解决之道就是加锁重读,源码如下:

/**
         * Reads value field of an entry under lock. Called if value
         * field ever appears to be null. This is possible only if a
         * compiler happens to reorder a HashEntry initialization with
         * its table assignment, which is legal under memory model
         * but is not known to ever occur.
         */
        V readValueUnderLock(HashEntry<K,V> e) {
            lock();
            try {
                return e.value;
            } finally {
                unlock();
            }
        }

5、ConcurrentHashMap 存取小结

在ConcurrentHashMap进行存取时,首先会定位到具体的段,然后通过对具体段的存取来完成对整个ConcurrentHashMap的存取。特别地,无论是ConcurrentHashMap的读操作还是写操作都具有很高的性能:在进行读操作时不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。

二、jdk1.8中的ConcurrentHashMap 结构

ConcurrentHashMap在1.8中的实现,相比于1.7的版本基本上全部都变掉了。首先,取消了Segment分段锁的数据结构,取而代之的是数组+链表(红黑树)的结构。而对于锁的粒度,调整为对每个数组元素加锁(Node)。然后是定位节点的hash算法被简化了,这样带来的弊端是Hash冲突会加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。这样一来,查询的时间复杂度就会由原先的O(n)变为O(logN)。

1、put方法

public V put(K key, V value) {
        return putVal(key, value, false);
    }
 
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)// 若table[]未创建,则初始化
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {// table[i]后面无节点时,直接创建Node(无锁操作)
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)// 如果当前正在扩容,则帮助扩容并返回最新table[]
                tab = helpTransfer(tab, f);
            else {// 在链表或者红黑树中追加节点
                V oldVal = null;
                synchronized (f) {// 这里并没有使用ReentrantLock,说明synchronized已经足够优化了
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {// 如果为链表结构
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {// 找到key,替换value
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {// 在尾部插入Node
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        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) {
                    if (binCount >= TREEIFY_THRESHOLD)// 到达阀值,变为红黑树结构
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

从上面代码可以看出,put的步骤大致如下:

  1. 参数校验。
  2. 若table[]未创建,则初始化。
  3. 当table[i]后面无节点时,直接创建Node(无锁操作)。
  4. 如果当前正在扩容,则帮助扩容并返回最新table[]。
  5. 然后在链表或者红黑树中追加节点。
  6. 最后还回去判断是否到达阀值,如到达变为红黑树结构。

        除了上述步骤以外,还有一点我们留意到的是,代码中加锁片段用的是synchronized关键字,而不是像1.7中的ReentrantLock。这一点也说明了,synchronized在新版本的JDK中优化的程度和ReentrantLock差不多了。

三、总结

  1. JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
  2. JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
  3. JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
  4. JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
    1. 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
    2. JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
    3. 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

猜你喜欢

转载自blog.csdn.net/qq_36125072/article/details/81531231
今日推荐