Java并发系列(8)——并发容器

接上一篇《Java并发系列(7)——AQS与显式锁实现原理

6 并发容器

众所周知,HashMap,ArrayList 等等这些容器不是线程安全的。

在多线程场景下,如果要使用这些容器,JDK 也提供了一些线程安全的容器类。

6.1 并发 HashMap

JDK 提供的并发安全的 HashMap 有两个:java.util.Hashtable 和 java.util.concurrent.ConcurrentHashMap。

顺便一提:并发场景下不要使用 HashMap,不仅仅是线程不安全可能访问错误数据的问题,还有可能导致死循环。JDK1.7 的实现在多个线程同时 resize 的时候可能会出现循环链表从而导致死循环;JDK1.8 的实现与 JDK1.7 不太一样,hash 冲突时链表头插改成了尾插。

6.1.1 HashTable

HashTable 实现线程安全的方式非常简单:

    public synchronized V get(Object key) {
    	//...
    }

    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

    protected void rehash() {
        //...
    }

    private void addEntry(int hash, K key, V value, int index) {
        //...
    }

    public synchronized V put(K key, V value) {
        //...
    }

    public synchronized V remove(Object key) {
        //...
    }

    public synchronized void putAll(Map<? extends K, ? extends V> t) {
        //...
    }

    public synchronized void clear() {
        //...
    }

    public synchronized Object clone() {
        //...
    }

    public synchronized String toString() {
        //...
    }

上面列举了 HashTable 的一些方法实现,所有对外暴露的 public 方法都使用 synchronized 进行了同步。

相当于直接锁全表,哪怕多个线程都是读操作,哪怕多个线程操作了不同的 key,还是存在锁竞争,因此效率很低,基本不会用它。

不过也并不是一定不能用,在 jdk1.6 之前 synchronized 是重量级锁,性能很差,是没法用的。就算没有 ConcurrentHashMap,手写一个 Lock 也比 HashTable 强。

但在 jdk1.6 之后,jdk 对 synchronized 做了大量优化。在没有多个线程同时执行临界区的代码,也就是虽然是多线程场景但实际上没有竞争时,synchronized 是偏向锁或轻量锁状态,几乎没有额外的性能损失(线程不会阻塞,也不会自旋,但依然会刷新工作内存)。所以 HashTable 在低并发场景用一用问题也不大,只不过我们有性能更好的 ConcurrentHashMap,所以还是没必要用它,除非要求数据强一致性。

6.1.2 ConcurrentHashMap

ConcurrentHashMap 在 jdk1.7 和 jdk1.8 的实现方式不同。

6.1.2.1 jdk1.7 的实现
6.1.2.1.1 数据结构

在这里插入图片描述

jdk1.7 的 ConcurrentHashMap 内部数据结构是一个 Segment 数组,每一个 Segment 又持有一个 HashEntry 数组。

Segment 实际上是一个锁,继承自 ReentrantLock。

Segment 下面的 HashEntry 数组则相当于是一个独立的普通 HashMap,发生 hash 碰撞的时候以链表的形式串在一起。

所以,它的设计思路很简单:

  • 为了提升性能,没有像 HashTable 那样对整个表加锁,而是通过多个 Segment 把一个表分成多个段,每段各自加锁;
  • 这样如果多个线程操作不同的 Segment,则可以并发进行,只有操作同一个 Segment 时才会竞争锁。
6.1.2.1.2 构造方法

构造方法有多个,都调用了下面这个:

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;
        // Find power-of-two sizes best matching arguments
        int sshift = 0;
        int ssize = 1;
        //ssize 实际上就是并发度,对传入的 concurrencyLevel 做 2 的 n 次幂处理
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        //每个 Segment 下面的 HashEntry[] 大小最小是 2,同样做 2 的 n 次幂处理
        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]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

这里有三个参数:

  • initialCapacity:初始化容量;
  • loadFactor:负载因子;
  • concurrencyLevel:并发度,即最大并发访问线程数,也就是 Segment 的数量。

需要注意的是,ConcurrentHashMap 会对初始化参数做一些处理,保证 Segment 的数量,以及 Segment 下面 HashEntry 的数量都是 2 的 n 次幂。

初始化动作如下:

  • 设置实际并发度为大于等于 concurrencyLevel 的最小的 2 的 n 次幂(最小是 1),如传入 3,则实际并发度为 4,并发度确定之后是不可更改的;
  • 传入的 initialCapacity 均分到各个 Segment,得到每个 Segment 下面 HashEntry 数组的初始容量(最小是 2),如实际并发度为 4,传入 initialCapacity 为 7,则 HashEntry 数组初始容量为 7/4 + 1 = 2;
  • 负载因子与 HashMap 中一样,这里每个 Segment 独立计算 rehash 阈值(capacity * loadFactor),也就是每个 Segment 下面的 HashEntry 数组的容量不一定相同;
  • 初始化 Segment 数组,及 Segment[0]。

为什么使用 2 的 n 次幂,也是有讲究的,主要是为了快速定位:

一般计算某个元素的 hash 会落在哪个 slot 是通过取余计算,即 index = hash % capacity,但是当 capacity == 2 ^ n 时,hash % capacity == hash & (capacity - 1),因此相对较慢的取余运算就可以转化为相对较快的位与运算。

每次扩容时,capacity 乘 2 也是有讲究的,主要是为了快速扩容:

(hash % (2 * capacity)) - (hash % capacity ) 也就是 newIndex 与 oldIndex 之差,只有两种结果,要么是 0,要么是 capacity,利用这个特性,扩容之后,没有必要对每个元素重新处理 hash 碰撞,分别搬到 oldIndex 和 oldIndex + capacity 的位置即可。

6.1.2.1.3 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);
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        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 方法流程:

  • 利用 key 的 hash 算出来这个元素在哪个 Segment 里面;
  • 利用 key 的 hash 算出来这个元素在 Segment 里面 HashEntry 数组的哪个位置;
  • 去这个位置下面找这个元素,如果这个位置存在 hash 碰撞,则遍历链表。

可以看到,get 操作是不加锁的,仅通过 volatile 变量保证元素变更的可见性。

这里需要注意 volatile 的一个链路:

    /**
     * The segments, each of which is a specialized hash table.
     */
    final Segment<K,V>[] segments;

首先,Segment 数组是 final 的,这个引用不会变更。Segment 数组元素实际也被实现成了不可变的。

        /**
         * The per-segment table. Elements are accessed via
         * entryAt/setEntryAt providing volatile semantics.
         */
        transient volatile HashEntry<K,V>[] table;

Segment 里面的 HashEntry 数组是 volatile 的,所以 HashEntry 数组对象的引用变更对其它线程是可见的。

但,需要注意的是 volatile 修饰数组对象,只能保证数组对象本身的可见性,并不能保证数组元素的引用变更的可见性。

所以 get 方法里访问 HashEntry 数组元素没有直接使用下标访问,而是使用了 Unsafe 类的 getObjectVolatile 方法。

6.1.2.1.4 put
    public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        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);
    }

还是先根据 hash 找到 Segment,然后调用 Segment 的 put 方法,当然如果有必要会先初始化:

    private Segment<K,V> ensureSegment(int k) {
        final Segment<K,V>[] ss = this.segments;
        long u = (k << SSHIFT) + SBASE; // raw offset
        Segment<K,V> seg;
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            Segment<K,V> proto = ss[0]; // use segment 0 as prototype
            int cap = proto.table.length;
            float lf = proto.loadFactor;
            int threshold = (int)(cap * lf);
            HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
            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;
                }
            }
        }
        return seg;
    }

因为在 ConcurrentHashMap 初始化的时候,只初始化了 Segment[0],这里初始化其它 Segment 时沿用 Segment[0] 的容量和负载因子。初始化 Segment 由于是写操作,所以这里用了 cas 自旋。

Segment 初始化完成之后,就执行 Segment 的 put 操作:

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                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;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

这里只需要关注开始的 tryLock() 和最后的 unlock() 即可。因为 Segment 本身继承自 ReentrantLock,所以它自己就是一个锁,直接调用自己的加锁方法。

加锁之后,其它操作就跟 HashMap 的 put 操作没什么两样了。

如果元素数量超过阈值(容量 * 负载因子 )就会 rehash,当然如果容量已经扩容到最大,就没办法再 rehash,只能把链表挂得越来越长了。

不过为了提升性能,这里在加锁的时候还做了一些小动作,竞争锁失败的线程走到这里进行重试:

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; // to recheck first below
                if (retries < 0) {
                    if (e == null) {
                        if (node == null) // speculatively create node
                            node = new HashEntry<K,V>(hash, key, value, null);
                        retries = 0;
                    }
                    else if (key.equals(e.key))
                        retries = 0;
                    else
                        e = e.next;
                }
                else if (++retries > MAX_SCAN_RETRIES) {
                    lock();
                    break;
                }
                else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) {
                    e = first = f; // re-traverse if entry changed
                    retries = -1;
                }
            }
            return node;
        }

这里有两个小动作:

  • 前几次竞争锁都是通过不阻塞的 tryLock 方法进行的,当重试大于一定次数(多核 64 次,单核 1 次)时才会使用 lock 方法进行阻塞式加锁;
  • 在重试获得锁的过程中,会同时把 HashEntry 先 new 出来,这样等拿到锁就可以直接用了,节约时间。
6.1.2.1.5 remove
final V remove(Object key, int hash, Object value) {
            if (!tryLock())
                scanAndLock(key, hash);
            V oldValue = null;
            try {
                HashEntry<K,V>[] tab = table;
                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)
                                setEntryAt(tab, index, next);
                            else
                                pred.setNext(next);
                            ++modCount;
                            --count;
                            oldValue = v;
                        }
                        break;
                    }
                    pred = e;
                    e = next;
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

remove 方法与 put 方法类似,都是加锁操作,加锁过程也是一样,先重试非阻塞式加锁,不行再阻塞加锁。

6.1.2.1.6 rehash
private void rehash(HashEntry<K,V> node) {
            /*
             * Reclassify nodes in each list to new table.  Because we
             * are using power-of-two expansion, the elements from
             * each bin must either stay at same index, or move with a
             * power of two offset. We eliminate unnecessary node
             * creation by catching cases where old nodes can be
             * reused because their next fields won't change.
             * Statistically, at the default threshold, only about
             * one-sixth of them need cloning when a table
             * doubles. The nodes they replace will be garbage
             * collectable as soon as they are no longer referenced by
             * any reader thread that may be in the midst of
             * concurrently traversing table. Entry accesses use plain
             * array indexing because they are followed by volatile
             * table write.
             */
            HashEntry<K,V>[] oldTable = table;
            int oldCapacity = oldTable.length;
            int newCapacity = oldCapacity << 1;
            threshold = (int)(newCapacity * loadFactor);
            HashEntry<K,V>[] newTable =
                (HashEntry<K,V>[]) new HashEntry[newCapacity];
            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;
                    int idx = e.hash & sizeMask;
                    if (next == null)   //  Single node on list
                        newTable[idx] = e;
                    else { // Reuse consecutive sequence at same slot
                        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;
                            }
                        }
                        newTable[lastIdx] = lastRun;
                        // Clone remaining nodes
                        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;
        }

rehash 时将原来 HashEntry 数组容量乘 2,然后遍历旧的 HashEntry 数组里面的元素,复制到新的 HashEntry 数组。

rehash 不用加锁,因为它只会在 put 的时候被触发,而 put 方法是加锁的。

这里也有一个小动作:在遍历链表的时候,会在链表上找出一个元素 lastRun,在这个元素后面,所有的元素 rehash 之后仍然跟这个元素落在 HashEntry 数组的同一个 index 上面。这样就只需要把 lastRun 元素复制到新的 HashEntry 数组就可以了,后面的元素直接复用不需要复制,因为反正在同一个 index 上面,链表指针也不会变。这样可以提高一些效率。

6.1.2.1.7 isEmpty
public boolean isEmpty() {
        /*
         * Sum per-segment modCounts to avoid mis-reporting when
         * elements are concurrently added and removed in one segment
         * while checking another, in which case the table was never
         * actually empty at any point. (The sum ensures accuracy up
         * through at least 1<<31 per-segment modifications before
         * recheck.)  Methods size() and containsValue() use similar
         * constructions for stability checks.
         */
        long sum = 0L;
        final Segment<K,V>[] segments = this.segments;
        for (int j = 0; j < segments.length; ++j) {
            Segment<K,V> seg = segmentAt(segments, j);
            if (seg != null) {
                if (seg.count != 0)
                    return false;
                sum += seg.modCount;
            }
        }
        if (sum != 0L) { // recheck unless no modifications
            for (int j = 0; j < segments.length; ++j) {
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    if (seg.count != 0)
                        return false;
                    sum -= seg.modCount;
                }
            }
            if (sum != 0L)
                return false;
        }
        return true;
    }

modCount 是 Segment 被更改的次数。

算两次,如果在两次计算中,每个 Segment 都没有元素,并且没有线程对 ConcurrentHashMap 做过增删操作,那么可以认为 isEmpty 。

不加锁,通过对比两次计算结果得出结论。

6.1.2.1.8 size
    public int size() {
        // Try a few times to get accurate count. On failure due to
        // continuous async changes in table, resort to locking.
        final Segment<K,V>[] segments = this.segments;
        int size;
        boolean overflow; // true if size overflows 32 bits
        long sum;         // sum of modCounts
        long last = 0L;   // previous sum
        int retries = -1; // first iteration isn't retry
        try {
            for (;;) {
                if (retries++ == RETRIES_BEFORE_LOCK) {
                    for (int j = 0; j < segments.length; ++j)
                        ensureSegment(j).lock(); // force creation
                }
                sum = 0L;
                size = 0;
                overflow = false;
                for (int j = 0; j < segments.length; ++j) {
                    Segment<K,V> seg = segmentAt(segments, j);
                    if (seg != null) {
                        sum += seg.modCount;
                        int c = seg.count;
                        if (c < 0 || (size += c) < 0)
                            overflow = true;
                    }
                }
                if (sum == last)
                    break;
                last = sum;
            }
        } finally {
            if (retries > RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    segmentAt(segments, j).unlock();
            }
        }
        return overflow ? Integer.MAX_VALUE : size;
    }

通过遍历累加每个 Segment 的元素数量,连续统计两次 size 和 modCount。如果两次 modCount 相等,即在两次统计期间,没有线程对 ConcurrentHashMap 做增删操作,那么就认为统计的结果是对的。

如果两次 modCount 不等,说明有线程在更新 ConcurrentHashMap,那么再继续重新统计。

需要注意的是,如果重试一定次数后仍然没有统计出来,也就是不断有线程更新 ConcurrentHashMap 干扰 size 的统计,那么就要加锁,并且锁的是全表。

因此,size 方法的开销可能会比较大,在并发写较多的场景下谨慎使用。

6.1.2.1.9 contains 和 containsValue
    public boolean containsValue(Object value) {
        // Same idea as size()
        if (value == null)
            throw new NullPointerException();
        final Segment<K,V>[] segments = this.segments;
        boolean found = false;
        long last = 0;
        int retries = -1;
        try {
            outer: for (;;) {
                if (retries++ == RETRIES_BEFORE_LOCK) {
                    //重试一定次数后,锁全表
                    for (int j = 0; j < segments.length; ++j)
                        ensureSegment(j).lock(); // force creation
                }
                long hashSum = 0L;
                int sum = 0;
                for (int j = 0; j < segments.length; ++j) {
                    HashEntry<K,V>[] tab;
                    Segment<K,V> seg = segmentAt(segments, j);
                    if (seg != null && (tab = seg.table) != null) {
                        for (int i = 0 ; i < tab.length; i++) {
                            HashEntry<K,V> e;
                            for (e = entryAt(tab, i); e != null; e = e.next) {
                                V v = e.value;
                                if (v != null && value.equals(v)) {
                                    found = true;
                                    break outer;
                                }
                            }
                        }
                        sum += seg.modCount;
                    }
                }
                if (retries > 0 && sum == last)
                    break;
                last = sum;
            }
        } finally {
            if (retries > RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    segmentAt(segments, j).unlock();
            }
        }
        return found;
    }

contains 方法调用了 containsValue 方法。

这个方法不但要遍历所有 Segment 的所有元素,同样在重试一定次数后还可能会锁全表遍历,谨慎使用。

6.1.2.1.10 总结

在 jdk1.7 中 ConcurrentHashMap 的实现:

  • 保证并发安全的方式:加锁,使用的是显式锁 ReentrantLock;
  • 涉及的主要数据类型:分段锁 Segment,节点 HashEntry;
  • 数据结构:数组,hash 冲突时用链表;
  • 锁优化:把 map 切分成多个 Segment(段),各 Segment 使用不同的锁,从而减少锁竞争;
  • 负载因子和扩容的设定与 HashMap 相同,但各 Segment 独立扩容;
  • 虽然 map 可扩容,但 Segment 不可扩容,ConcurrentHashMap 创建时设置了多大就多大;
  • 不加锁的操作:get,isEmpty,containsKey;
  • 锁单个 Segment 的操作:put,remove,rehash;
  • (可能会)锁全部 Segment 的操作:size,contains,containsValue;
  • 弱一致性:由于 get 不加锁,所以有可能 get 到过期数据;而 HashTable 是强一致;
  • 不可靠的方法:isEmpty,size,contains 等,如,刚判断完 containsKey(“xxx”) 返回 true,还没有做任何接下来的操作,其它线程可能就把这个 key 删掉了;
  • 可靠的方法:put,如,并发 put 两个 hash 冲突的数据,一定会形成链表;而 HashMap 的并发 put,则有可能 put 完只有一个数据。
6.1.2.2 jdk1.8 的实现
6.1.2.2.1 数据结构

在这里插入图片描述

jdk1.8 中 ConcurrentHashMap 内部就是一个 Node 数组。

hash 冲突时则使用链表或红黑树:

  • put 时,如果链表长度达到 8(不含新 put 的节点),并且 capacity 达到 64,将链表转化为红黑树;
  • remove 时,在 remove 第三层最左一个元素时会发生红黑树转链表,所以可能发生在 remove 剩余的第 4 ~7 个元素时;
  • rehash 时,如果原来的红黑树拆分到 Node[] 的两个不同的 index 之后,节点数量 <= 6,则在迁移节点的时候转换为链表。

与 jdk1.7 所有的元素都封装在 HashEntry 中不同,在 jdk1.8 中共有四种数据类型:

  • Node:普通节点,刚开始所有的节点都是 Node;
  • TreeNode:将链表转换为红黑树之后,红黑树上的所有节点就变成了 TreeNode(Node 子类);
  • TreeBin:红黑树的封装类,内部持有红黑树的 root 节点。也是 Node 子类;
  • ForwardingNode:和 TreeBin 一样也是只会出现在数组元素中,它表示 ConcurrentHashMap 当前正在扩容,并且数组上 ForwardingNode 所表示的这个 slot 已经搬迁到新的 Node 数组了。
6.1.2.2.2 构造方法

构造方法主要有两个。

    public ConcurrentHashMap(int initialCapacity) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException();
        int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
        this.sizeCtl = cap;
    }

可以并没有在构造方法里面初始化 Node[],就初始化了一个 sizeCtl 变量,这是一个多功能变量:

  • 在 Node[] 初始化之前,sizeCtl = 初始容量;
  • 在 Node[] 初始化开始到完成之前,sizeCtl = -1,作为正在初始化的一个标识位;
  • 在 Node[] 初始化完成之后,没有在扩容的时候,sizeCtl = 容量 * 0.75,也就是扩容阈值;
  • 在 Node[] 扩容期间,sizeCtl 是一个(比 -1 还小的)负数,并且记录了参与并发扩容的线程数。

这里设置的初始容量比较奇怪,是比入参 initialCapacity * 1.5 大的最小的 2 的 n 次幂,如:

  • initialCapacity = 4,则 initialCapacity * 1.5 = 6,初始容量为 2^3 = 8;

  • initialCapacity = 10,则 initialCapacity * 1.5 = 15,初始容量为 2^4 = 16。

    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)   // Use at least as many bins
            initialCapacity = concurrencyLevel;   // as estimated threads
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }

这个构造方法看上去完全是为了兼容低版本的 jdk。因为 concurrencyLevel 参数除了会对初始容量造成一点很小的影响,没其它作用。而 loadFactor 也是除了对初始容量造成一些影响以外再无其它作用。

从第一次 put 元素时会调用的初始化方法可以看到 loadFactor 其实是写死为 0.75 的:

    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(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -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);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

主要在这两行代码:

sc = n - (n >>> 2); // 相当于 n - 0.25n = 0.75n
sizeCtl = sc; //即下次触发扩容的阈值是 sc = 0.75n
6.1.2.2.3 put

主要逻辑在下面这个方法,这里贴出来的源码省略了一些细节:

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        //1.key,value 都不允许为 null
        if (key == null || value == null) throw new NullPointerException();
        //2.计算 hash
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //3.第一次 put 的时候初始化 Node[]
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //4.Node[] 当前 slot 为 null,可直接 put,结束
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            //5.MOVED 表示当前 Node[] 正在扩容,此时暂停 put,协助扩容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //6.锁当前这一个 slot
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            //7.Node[] 上的元素 hash > 0,表示这个 slot 下面挂着一个链表,
                            //执行链表的插入操作
                        }
                        else if (f instanceof TreeBin) {
                            //8. Node[] 上的元素类型为 TreeBin,表示这个 slot 下面挂着一个红黑树,
                            //执行红黑树的插入操作
                        }
                    }
                }
                if (binCount != 0) {
                    //9.当链表元素 > 8 个(正在 put 第 9 个元素)时,链表转红黑树
                    //实际上当 capacity < 64 的时候会直接扩容而不是转红黑树
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        //10.统计元素个数,size + 1,超出阈值会进行扩容
        addCount(1L, binCount);
        return null;
    }

上面的注释里面已经标注了 10 个关键逻辑,需要详细说明的是:

  • 第 4 步:如果当前 slot 是空的,直接 put,不用加锁了,当然自旋 + cas 还是需要的。这是相比 jdk1.7 的一个优化点,put 操作不一定会加锁。
  • 第 6 步:如果要加锁,仅仅锁了当前一个 slot,用的是 synchronized 关键字。相比 jdk1.7 锁了一个 Segment,锁粒度变小了,这也是一个优化。
  • 第 5 步:一个线程执行 put 操作,“碰巧”发现此时另一个线程正在对 ConcurrentHashMap 扩容,于是暂停 put,利用当前线程的资源协助其它线程进行扩容,所以扩容操作实际可能是并发进行的。jdk1.7 也是并发扩容的,但 jdk1.7 的并发扩容仅限于不同的 Segment,而 jdk1.8 的并发扩容对整个 ConcurrentHashMap 都是并发进行的。触发协助扩容的时机在后面扩容部分再讲。
6.1.2.2.4 并发扩容

扩容会在 put 元素时,在 addCount 方法里面触发:

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            //1.cas 操作,根据增加(或删除)的元素数量更新 baseCount 变量
            //省略部分代码...
        }
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //2.元素数量大于阈值,扩容
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                int rs = resizeStamp(n);
                if (sc < 0) {
                    //省略部分代码...
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        //3.sc < 0,其它线程正在扩容,当前线程加入一起扩容
                        transfer(tab, nt);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    //4.当前线程扩容
                    transfer(tab, null);
                //5.当前线程扩容完成,统计当前元素数量,继续 while 循环判断是否又要继续扩容
                s = sumCount();
            }
        }
    }

所谓扩容,就是新建一个 Node[],把原来的 Node[] 上面所有的节点转移过去,转移完成后用新的 Node[] 替换原来的 Node[]。

具体的扩容操作,在 transfer 方法:

    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        //1.结合运行环境 cpu 数量,决定并发扩容步长,
        //即每个线程每次会从 Node[] 领取多少个 slot 作为自己迁移节点的任务
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        if (nextTab == null) {            // initiating
            //2.初始化另一个 Node[],容量为当前两倍,作为迁移 Node 用
            //省略具体逻辑...
        }
        int nextn = nextTab.length;
        //注意这个 ForwardingNode
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        boolean advance = true;
        boolean finishing = false; // to ensure sweep before committing nextTab
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            while (advance) {
                //3.根据前面计算的步长,从原 Node[] 选取下标从 bound 到 i 的 slot,逐一迁移,
                //迁移的时候是倒着进行的,所以下标 bound 会比 i 小,
                //省略具体逻辑...
            }
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                //4.扩容完成,原 Node[] 都已经转移到新的 Node[],退出
                if (finishing) {
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                //...
            }
            //5.1.当前 slot 为空,不用转移节点,直接设为 ForwardingNode,
            //表示当前 slot 转移完成。
            else if ((f = tabAt(tab, i)) == null)
                advance = casTabAt(tab, i, null, fwd);
            //5.2.注意 ForwardingNode 的 hash 写死的就是 MOVED,
            //所以这里意味着当前 slot 已经被转移完成设为 ForwardingNode 了。
            else if ((fh = f.hash) == MOVED)
                advance = true; // already processed
            else {
                //5.3.具体的节点迁移逻辑,锁当前 slot
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        if (fh >= 0) {
                            //5.3.1.链表迁移逻辑...
                        }
                        else if (f instanceof TreeBin) {
                            //5.3.2.红黑树迁移逻辑...
                            //...
                            //6.这里有一个红黑树转链表的逻辑,阈值是 6
                            ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                                (hc != 0) ? new TreeBin<K,V>(lo) : t;
                            hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                                (lc != 0) ? new TreeBin<K,V>(hi) : t;
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            //5.3.3.迁移完成同样把当前 slot 设为 ForwardingNode
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                    }
                }
            }
        }
    }

并发扩容逻辑非常复杂,上面已经去掉的很多实现细节,另外还有一个 helpTransfer 方法会进入 transfer 方法,不再细说。

另外单独看一下并发扩容的开始与结束的逻辑:

    private final void addCount(long x, int check) {
        //...
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                int rs = resizeStamp(n);
                if (sc < 0) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    //2.协助其它线程发起扩容,sizeCtl ++
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                //1.自己线程发起的扩容,
                //sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }
    }

    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        //...
        boolean advance = true;
        boolean finishing = false; // to ensure sweep before committing nextTab
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            while (advance) {
                //...
            }
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                if (finishing) {
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                //3.当前线程节点迁移结束,且所有的节点迁移任务都已经分配,sizeCtl--
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                    //sizeCtl 又回到了发起扩容时设置的初始值,说明当前是最后一个线程
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }
            //...            
        }
    }

并发扩容开始时,sizeCtl 会被设置为一个初始的负值,(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2,每增加一个线程参与并发扩容就 +1。

并发扩容结束时,每有一个线程完成扩容任务,不再参与并发扩容,sizeCtl 就减 1,哪个线程减 1 之后 sizeCtl 又回到了初始的负值,它就是最后一个完成扩容任务的线程,它要负责把 finishing 变量改为 true,其它线程不用管直接 return。

并发扩容的过程举例(以容量 64,cpu*4 为例,算出来步长应该是 16)来说就是:

  • 线程 1:put 的时候发现当前元素数量超过阈值,需要扩容,于是选取了数组下标 63~48 的 slot,逐一转移到新的数组,每转移完成一个 slot 就把原数组上的 slot 设为 ForwardingNode;
  • 线程 2:put 的元素 hash 取余之后假设是 62,数组下标 62 的元素发现是一个 ForwardingNode,可知其它线程正在扩容,于是协助扩容,下标 63~48 的迁移任务已被线程 1 领取了,于是选取下标 47~32 的任务;
  • 线程 3:put 的元素 hash 取余之后假设是 33,线程 2 还没有完成下标 33 的 slot 的转移,slot 还没有被设为 ForwardingNode,所以线程 3 并不知道现在正在扩容,继续向原数组下标 33 的 slot 插入元素,当然这里会加锁;
  • 线程 1:由于没有更多的线程加入协助扩容,这时线程 1 完成了 63~48 的迁移,于是选取下标 31~16 的 slot 进行转移;
  • 线程 2:完成了下标 47~32 的 slot 迁移任务,选取下标 15~0 的 slot 进行转移;
  • 线程 1:完成了 31~16 的 slot 迁移任务,没有更多的 slot 需要迁移,并且发现其它线程还没有完成任务,直接 return;
  • 线程 2:完成了 15~0 的 slot 迁移任务,没有更多的 slot 需要迁移,并且发现自己是最后一个完成任务的,于是扩容结束,要负责把原来的数组扔掉,以新的数组取代。

链表和红黑树的迁移这里也有个小技巧:

  • 基于一个事实:设 hash % capacity = index,那么两倍扩容后,hash % (2 * capacity)的值,要么是 index,要么是 index + capacity;
  • 所以,只要把同一个 slot 上的链表或红黑树,按照扩容后 index 是否与原来相等拆分成两份,直接放到扩容后下标 index 和 Index + capacity 的位置即可。
6.1.2.2.5 get
    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            //1.hash < 0,要么是 TreeBin,要么是 ForwardingNode
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            //2.链表
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

get 方法比较简单,不需要加锁,一共有三种情况:

  • hash > 0:链表查找;
  • hash < 0,TreeBin:红黑树查找;
  • hash < 0,ForwardingNode,正在扩容并且当前 slot 已经迁移到了新的数组,查找新的数组。
6.1.2.2.6 remove

remove 方法与 put 方法几乎一样,主要有两个区别:

  • remove 完之后调用 addCount 方法,入参是两个 -1,表示元素数量增加 -1,并且不需要检查扩容;
  • remove 红黑树上的元素可能触发红黑树转链表,不过与扩容不同,这里红黑树转链表的阈值并不是 6,而是红黑树第三层最左一个元素,如果它被删了,就要转链表。
6.1.2.2.7 size
    public int size() {
        long n = sumCount();
        return ((n < 0L) ? 0 :
                (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
                (int)n);
    }

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

size 方法相对 jdk1.7 做了优化,不可能再锁整个数组了,而且全程不加锁。

size 的统计分为两部分:

  • baseCount:记录 put 或 remove 元素时 cas 增减 count 成功的;
  • CounterCell[]:记录 put 或 remove 元素时,cas 增减 count 失败的。

以上这两部分累加即可得到 size,当然并发场景下获取 size 仍然意义不大。

6.1.2.2.8 containsValue
    public boolean containsValue(Object value) {
        if (value == null)
            throw new NullPointerException();
        Node<K,V>[] t;
        if ((t = table) != null) {
            Traverser<K,V> it = new Traverser<K,V>(t, t.length, 0, t.length);
            for (Node<K,V> p; (p = it.advance()) != null; ) {
                V v;
                if ((v = p.val) == value || (v != null && value.equals(v)))
                    return true;
            }
        }
        return false;
    }

粗略看一下,相比 jdk1.7 也优化过了,不再加锁了,但还是意义不大。

6.1.2.2.9 putIfAbsent,compute

并发场景下,因为非原子性的问题,isEmpty,size,contains 等操作意义都不大。

比较有用的可能是下面这几个方法,它们是对整个读写过程加锁的:

  • putIfAbsent;
  • compute;
  • computeIfAbsent;
  • computeIfPresent。
6.1.2.2.10 总结

数据结构:

  • 数组、链表、红黑树;
  • 链表转红黑树:put 时链表长度超过 8;
  • 红黑树转链表:remove 时发现第三层最左元素已被删除;rehash 时红黑树节点数小于 6;

怎样保证并发安全:

  • cas:值替换用 cas,如数组设值,size 计数;
  • synchronized:找到要操作的元素落在数组的哪个 slot 后,对这个 slot 的操作都是 synchronized;

锁粒度:

  • 更改操作(put,remove,compute 等)锁数组上的一个 slot;
  • 查询操作(get,size,contains 等)不加锁;

一些细节:

  • capacity 永远是 2 的 n 次幂;
  • loadfactor 永远是 0.75,指定了也没用;
  • 链表与红黑树的切换:
    • put 时,节点数大于 8,且 capacity < 64,扩容;
    • put 时,节点数大于 8,且 capacity >= 64,链表转红黑树;
    • remove 时,红黑树第三层最左元素被删除时,红黑数转链表;
    • 扩容时,红黑树节点数 <= 6,红黑树转链表。
6.1.2.3 jdk1.7 与 jdk1.8 实现对比

jdk1.8 中 ConcurrentHashMap 实现的一些变化:

  • 去掉了 Segment,进一步减小锁粒度;
  • ReentrantLock 改为 cas + synchronized;
  • 数组链表结构改为数组链表红黑树结构;
  • 一些方法实现优化:扩容,size,contains 等。

6.2 写时复制容器

特点:

  • 更新:每一次更新(add,remove 等)都会将旧表数据复制到新表;
  • 读取:从旧表读取。

数据结构:数组;

加锁方式:ReentrantLock;

锁粒度:

  • 更新操作,锁整个表;
  • 查询操作,不加锁;

实现类:

  • CopyOnWriteArrayList
  • CopyOnWriteArraySet(内部实现直接使用 CopyOnWriteArrayList);

适用场景:读多写少。

6.3 并发有序容器

特点:有序;

数据结构:跳表;

加锁方式:无锁,cas + 自旋;

实现类:

  • ConcurrentSkipListMap;
  • ConcurrentSkipListSet(内部是一个 ConcurrentSkipListMap);

适用场景:有排序要求。

6.4 并发非阻塞队列

特点:

  • 无界(容量无限,意味着 add/offer 永远成功,理论上无限,实际受硬件资源限制,比如可能会 OutOfMemory);
  • 不阻塞(存取都是直接返回,没有阻塞方法);

数据结构:链表;

加锁方式:无锁,cas + 自旋;

实现类:

  • ConcurrentLinkedQueue;
  • ConcurrentLinkedDeque。

6.5 并发阻塞队列

阻塞:

  • 增加阻塞方法 put:如果队列已满,阻塞等待其它线程取走元素;
  • 增加阻塞方法 take:如果队列已空,阻塞等待其它线程放入元素。
6.5.1 ArrayBlockingQueue

特点:

  • 有界;
  • 数组结构;
  • ReentrantLock,put 和 take 是同一个 Lock;
  • await 和 signal 实现等待和通知。
6.5.2 LinkedBlockingQueue

特点:

  • 有界;
  • 链表结构;
  • ReentrantLock,put 和 take 是两个不同的 Lock(吞吐量比 ArrayBlockingQueue 高);
  • await 和 signal 实现等待和通知。
6.5.3 LinkedBlockingDeque

特点:

  • 双向队列;
  • 有界;
  • 双向链表结构
  • ReentrantLock,put 和 take 是同一个 Lock;
  • await 和 signal 实现等待和通知。
6.5.4 LinkedTransferQueue

特点:

  • 增加 transfer 方法:使用 transfer 方法放入元素时,必须要有线程在等待取出元素,否则阻塞;
  • 无界;
  • 链表结构;
  • 无锁,cas + 自旋(虽说是无锁,但 cas + 自旋 + park/unpark 基本相当于 Lock);
  • park 和 unpark 实现等待和通知。
6.5.5 PriorityBlockingQueue

特点:

  • 优先级:并非先进先出,每次都是剩下的元素中最小的先出队;
  • 无界;
  • 小顶堆结构;
  • ReentrantLock 加锁;
  • await 和 signal 实现等待和通知。
6.5.6 DelayQueue

特点:

  • 延迟出队:DelayQueue 中的元素必须实现 Delayed 接口,必须过期才出队(但未必先过期的先出队,这一点很奇怪,后面讲);
  • 无界;
  • 小顶堆结构,DelayQueue 内部就是一个 PriorityQueue,延迟队列算是 PriorityQueue 的一个典型应用;
  • ReentrantLock 加锁;
  • await 和 signal 实现等待和通知。

为什么说它并非一定是先过期的先出队呢?

示例,下面个 demo 会让最后过期的先出队,以至于先过期的所有元素被堵在队列里出不来:

package per.lvjc.concurrent.collection;

import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

public class DelayQueueTest {

    // DelayQueue 中的元素必须实现 Delayed 接口
    private static class Ele implements Delayed {

        public Ele(long value) {
            this.value = value;
            this.deadline = System.currentTimeMillis() + value;
        }

        private long value;
        private long deadline;

        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(deadline - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }

        //Delayed 接口继承了 Comparable 接口,这个接口方法是从 Comparable 那里继承来的
        @Override
        public int compareTo(Delayed o) {
            // 正确的实现应该是 Long.compare(this.getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS))
            // 这里故意反过来比较
            return Long.compare(o.getDelay(TimeUnit.MILLISECONDS), this.getDelay(TimeUnit.MILLISECONDS));
        }
    }

    public static void main(String[] args) throws InterruptedException {
        DelayQueue<Ele> delayQueue = new DelayQueue<>();
        //放入第一个元素, 10 秒后过期
        delayQueue.offer(new Ele(10000));
        //放入第二个元素, 0.5 秒后过期
        delayQueue.offer(new Ele(500));
        //放入第三个元素, 0.02 后过期
        delayQueue.offer(new Ele(20));
        //取出一个元素,并不会取出 20 的那个,而是会阻塞 10 秒,然后取出 10000 的那个
        System.out.println(delayQueue.take().value);
    }
}

出现这种现象的原因是,DelayQueued 里面维护的一个 PriorityQueue 竟然不是依据 getDelay 方法来排序,而是依赖于 compareTo 方法。这就导致,如果我们 compareTo 方法瞎实现,那么 DelayQueue 就会出现问题——最先过期的没有最先出队。

从 DelayQueue 的功能——最先过期的元素先出队——来看,PriorityQueue 里面放在堆顶的元素一定是最先过期的,也就是 getDelay 返回值最小的。所以,暴露一个 compareTo 接口出来让用户自己实现就显得很多余(因为这个逻辑是固定的),反而增加了用户的工作量以及出错的风险。很奇怪 Doug Lea 大师为什么会这么写。

6.5.7 SynchronousQueue

特点:

  • 同步队列:队列 capacity = 0,所以队列里面没有空间存放哪怕一个元素,存放元素时必须直接交给获取元素的线程,否则阻塞;获取元素时必须直接从存放元素的线程获取,否则阻塞;
  • 有界,且 capacity = 0;
  • cas + 自旋 + park/unpark。

示例:

package per.lvjc.concurrent.collection;

import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;

public class SynchronousQueueTest {

    public static void main(String[] args) throws InterruptedException {
        SynchronousQueue<String> queue = new SynchronousQueue<>();
        //SynchronousQueue<String> queue = new SynchronousQueue<>(true);

        for (int i = 0; i < 5; i++) {
            // new 5 个线程去 take,会阻塞直到有线程 put
            new Thread(() -> {
                String threadName = Thread.currentThread().getName();
                System.out.println(threadName + " begin...");
                try {
                    System.out.println(threadName + " take: " + queue.take());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(threadName + " end...");
            }, "thread-" + i).start();
            TimeUnit.MILLISECONDS.sleep(200);
        }

        // new 一个线程 put 5 个元素,take 的线程会被依次唤醒
        new Thread(() -> {
            System.out.println("put thread begin");
            for (int i = 0; i < 5; i++) {
                try {
                    queue.put("element" + i);
                    TimeUnit.MILLISECONDS.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("put thread end");
        }).start();
    }
}

SynchronousQueue 有 fair 和 unfair 两种模式:

  • fair:先等待的线程先被唤醒(队列模式);
  • unfair:先等待的线程后被唤醒(栈模式,默认);

上面的代码,unfair 模式输出:

thread-0 begin...
thread-1 begin...
thread-2 begin...
thread-3 begin...
thread-4 begin...
put thread begin
thread-4 take: element0
thread-4 end...
thread-3 take: element1
thread-3 end...
thread-2 take: element2
thread-2 end...
thread-1 take: element3
thread-1 end...
thread-0 take: element4
thread-0 end...
put thread end

fair 模式输出:

thread-0 begin...
thread-1 begin...
thread-2 begin...
thread-3 begin...
thread-4 begin...
put thread begin
thread-0 take: element0
thread-0 end...
thread-1 take: element1
thread-1 end...
thread-2 take: element2
thread-2 end...
thread-3 take: element3
thread-3 end...
thread-4 take: element4
thread-4 end...
put thread end
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值