Java并发容器和框架

本文详细介绍了Java并发编程中的重要容器ConcurrentHashMap的实现原理与使用,包括为何使用ConcurrentHashMap、线程不安全的HashMap问题、ConcurrentHashMap的锁分段技术及其结构、初始化过程以及操作如get、put和size的实现。文章还涵盖了JDK1.8中CurrentHashMap的变化,以及Java中的其他并发容器如ConcurrentSkipListMap、CopyOnWriteArrayList、ConcurrentLinkedQueue和阻塞队列的概念及实现原理。最后讨论了Fork/Join框架,解释了工作窃取算法和其在并行计算中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

ConcurrentHashMap的实现原理与使用

为什么要使用ConcurrentHashMap

线程不安全的HashMap

效率低下的Hash Table

ConcurrentHashMap的锁分段技术可有效提升并发访问率

ConcurrentHashMap的结构

ConcurrentHashMap的初始化

初始化segments数组

初始化segmentShift和segmentMask

初始化每个segment

定位Segment

ConcurrentHashMap的操作

get操作

put操作

size橾作

JDK1.8版本的CurrentHashMap

1.8的节点

1.8的put

1.8的get

1.8的size

addCount()方法

1.7与1.8的区别

为什么1.8用Node替代Segment

为什么1.8用synchronized而不是ReentranLock

ConcurrentSkipListMap

CopyOnWriteArrayList

CopyOnWriteArrayList 是如何做到的?

CopyOnWriteArrayList 读取和写入源码简单分析

ConcurrentLinkedQueue

ConcurrentLinkedQueue的结构

入队列

入队列的过程

定位尾节点

设置入队节点为尾节点

HOPS的设计意图

出队列

Java中的阻塞队列

什么是阻塞队列

Java里的阻塞队列

ArrayBlockingQueue

LinkedBlockingQueue

PriorityBlockingQueue

DelayQueue

SynchronousQueue

LinkedTransferQueue

LinkedBlockingDeque

阻塞队列的实现原理

Fork/Join框架

什么是Fork/Join框架

工作窃取算法

Fork/Join框架的设计

使用Fork/Join框架

Fork/Join框架的异常处理

Fork/Join框架的实现原理

 ForkJoinTask的fork方法实现原理

ForkJoinTask的join方法实现原理


ConcurrentHashMap的实现原理与使用

为什么要使用ConcurrentHashMap

在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的Hash Table效率又非 常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会。

线程不安全的HashMap

在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%, 所 以在并发情况下不能使用HashMap。例如,执行以下代码会引起死循环。

HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry表 形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获 取Entry。

效率低下的Hash Table

Hash Table容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下Hash Table 的效率非常低下。因为当一个线程访问Hash Table的同步方法,其他线程也访问Hash Table的同 步方法时,会进入阻塞或轮询状态。如线程l使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。

ConcurrentHashMap的锁分段技术可有效提升并发访问率

Hash Table容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问Hash Table的 线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么 当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并 发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。

首先将数据分成一段一段地存 储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数 据也能被其他线程访问。

ConcurrentHashMap的结构

注意:本文描述的conurrenthashmap是jdk1.7版本的

通过ConcurrentHashMap的类图来分析ConcurrentHashMap的结构,如图所示。

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。

Segment是一种可重 入锁(ReentrantLock), 在ConcurrentHashMap里扮演锁的角色;

ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

HashEntry则用于存储键值对数 据。

一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种 数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元 素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时, 必须首先获得与它对应的Segment锁

ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图:

从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。

第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。

ConcurrentHashMap的初始化

会初始化一个Segment数组,容量为16,而每个Segment呢,都继承了ReentrantLock类,也就是说每个Segment类本身就是一个锁,之后Segment内部又有一个table数组,而每个table数组里的索引数据呢,又对应着一个Node链表.

ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel等几个 参数来初始化segment数组、段偏移量segmentShift, 段掩码segmentMask和每个segment里的 HashEntry数组来实现的。

ConcurrentHashMap的构造函数有3个参数,initialCapacity是整个map的容量,loadFactor是负载,concurrencyLevel是预估的并发线程数,决定了segement数组的长度(大于等于它的2的n次方=ssize)。

每个segement里的HashEntry数组长度可以理解为initialCapacity除以ssize 的倍数C, 如果c大于1, 就会取大于等于c的2的N次方值

初始化segments数组

让我们来看一下初始化segments数组的源代码。

由上面的代码可知,segments数组的长度ssize是通过concurrencyLevel计算得出的。为了能 通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方 (power-of-two size), 所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值 来作为segments数组的长度。假如concurrencyLevel等于14、15或16, ssize都会等于16, 即容器里 锁的个数也是16。

注意concurrencyLevel的最大值是65535, 这意味着segments数组的长度最大为65536, 对应的二进制是16位。

初始化segmentShift和segmentMask

这两个全局变量需要在定位segment时的散列算法里使用,sshift等于ssize从1向左移位的 次数,在默认情况下concurrencyLevel等于16, 1需要向左移位移动4次,所以sshift等于4。

segmentShift用于定位参与散列运算的位数,segmentShift等于32减sshift, 所以等于28, 这里之所 以用32是因为ConcurrentHashMap里的hash()方法输出的最大数是32位的后面的测试中我们 可以看到这点。segmentMask是散列运算的掩码,等于ssize减1, 即15, 掩码的二进制各个位的 值都是1。因为ssize的最大长度是65536, 所以segmentShift最大值是16, segmentMask大值是 65535, 对应的二进制是16位,每个位都是1。

初始化每个segment

输入参数initial Capacity是ConcurrentHashMap的初始化容量,loadfactor是每个segment的负 载因子,在构造方法里需要通过这两个参数来初始化数组中的每个segment。

上面代码中的变量Cap就是segment里HashEntry数组的长度,它等于initialCapacity除以ssize 的倍数C, 如果c大于1, 就会取大于等于c的2的N次方值,所以Cap不是1, 就是2的N次方。

segment的容量threshold= (int) Cap*loadFactor, 默认情况下initialCapacity等于16, loadfactor等于 0.'75, 通过运算Cap等于1, threshold等于零。

定位Segment

既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素 的时候,必须先通过散列算法定位到Segment。可以看到ConcurrentHashMap会首先使用 Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列。

之所以进行再散列,目的是减少散列冲突,使元素能够均匀地分布在不同的Segment上, 从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个Segment中, 不仅存取元素缓慢,分段锁也会失去意义。

通过这种再散列能让数字的每一位都参加到散列 运算当中,从而减少散列冲突。ConcurrentHashMap通过以下散列算法定位segment。

默认情况下segmentShift为28, segmentMask为15, 再散列后的数最大是32位二进制数据 向右无符号移动28位,意思是让高4位参与到散列运算中,(hash>> >segmentShift) &segmentMask的运算结果分别是4、15、7和8, 可以看到散列值没有发生冲突。

注意:确定segement后,在segment内部确定位置,可以认为是(tab.length - 1) & h ,h是经过再散列后的哈希,tab是segment内部的数组

ConcurrentHashMap的操作

get操作

Segment的get操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散 列运算定位到Segment, 再通过散列算法定位到元素,代码如下。

get橾作的高效之处在于整个get过程不需要加锁,除非读到的值是空才会加锁重读。

我们 知道Hash Table容器的get方法是需要加锁的,那么ConcurrentHashMap的get橾作是如何做到不 加锁的呢?

原因是它的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前 Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线 程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值

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

在定位元素的代码里我们可以发现,定位HashEntry和定位Segment的散列算法虽然一样, 都与数组的长度减去l再相”与“,但是相”与"的值不一样,定位Segment使用的是元素的 hashcode通过再散列后得到的值的高位,而定位HashEntry直接使用的是再散列后的值。其目的是避免两次散列后的值一样,虽然元素在Segment里散列开了,但是却没有在HashEntry里散列 开。

put操作

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

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

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

(1)是否需要扩容

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

(2)如何扩容

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

size橾作

如果要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小 后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时 可以获取每个Segment的count的最新值,但是可能累加前使用的count发生了变化,那么统计结 果就不准了。所以,最安全的做法是在统计size的时候把所有Segment的put、remove和clean方法 全部锁住,但是这种做法显然非常低效。

因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以 ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如 果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。

那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount 变量在put、remove和clean方法里操作元素前都会将变量modCount进行加1, 那么在统计size 前后比较modCount是否发生变化(累加segment的count时,还累加segment的modcount),从而得知容器的大小是否发生变化。

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

JDK1.8版本的CurrentHashMap

JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作

JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。

ConcurrentHashMap的基础操作还是和HashMap一样的,比如table取下标使用& (n-1)而不是% n,哈希值需要高位扰动,哈希桶分离分为低桶和高桶。

对于数据结构为红黑树的节点,TreeBin封装了真正的红黑色节点TreeNode,TreeBin起到一个dummy node的作用,避免了红黑树平衡造成的table槽位成员发生变化。

ConcurrentHashMap不允许key或者value为null。

对于所有的写操作(插入操作、插入后的树化操作、哈希桶分离操作…),在写操作之前,都需要对哈希桶的第一个节点加锁。这是有效的,因为在链表中,节点总是添加到末尾,头节点不会变,直到它被删除或哈希桶分离。红黑树的TreeBin也是总是不变的,除非哈希桶分离。

计数器的任务,交给了baseCount和counterCells,它们的实现类似于LongAdder,这比使用AtomicLong用作计数好多了。因为很大程度减小了因CAS失败而导致的自旋。

transfer是扩容的真正实现,这一方法被设计成可以被多个线程并发执行,以加快扩容的完成。使用ForwardingNode来连接旧table的节点和新table。

提供了一个Traverser用作只读迭代器,它在遇到ForwardingNode时进行table跳转,然后在新table上读取相应的低桶和高桶。

写操作的加锁粒度是每个哈希桶。

好处是读写操作可以最大程序并发执行,这样效率最高。

坏处是读写操作都是弱一致性,比如size()返回的大小可能已经与真实大小不一样,比如clear()调用返回后Map中却拥有着元素。

1.8的节点

Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    //... 省略部分代码
} 

 Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,不过保证线程安全性。

1.8的put

可以看到put操作时,如果i位置没有数据,直接调用casTabAt方法,cas替换i位置的节点。

如果有数据,进行加锁操作,synchronized(tab[i])

这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述

  1. 如果没有初始化就先调用initTable()方法来进行初始化过程
  2. 如果没有hash冲突就直接CAS插入
  3. 如果还在进行扩容操作就先进行扩容
  4. 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
  5. 最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
  6. 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容
	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) {
		if (key == null || value == null)
			throw new NullPointerException();
		int hash = spread(key.hashCode()); // 两次hash,减少hash冲突,可以均匀分布
		int binCount = 0;
		for (Node<K, V>[] tab = table;;) { // 对这个table进行迭代
			Node<K, V> f;
			int n, i, fh;
			// 这里就是上面构造方法没有进行初始化,在这里进行判断,为null就调用initTable进行初始化,属于懒汉模式初始化
			if (tab == null || (n = tab.length) == 0)
				tab = initTable();
			else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 如果i位置没有数据,就直接无锁插入
				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) // 如果在进行扩容,则先进行扩容操作
				tab = helpTransfer(tab, f);
			else {
				V oldVal = null;
				// 如果以上条件都不满足,那就要进行加锁操作,也就是存在hash冲突,锁住链表或者红黑树的头结点
				synchronized (f) {
					if (tabAt(tab, i) == f) {
						if (fh >= 0) { // 表示该节点是链表结构
							binCount = 1;
							for (Node<K, V> e = f;; ++binCount) {
								K ek;
								// 这里涉及到相同的key进行put就会覆盖原先的value
								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;
								if ((e = e.next) == null) { // 插入链表尾部
									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) { // 如果链表的长度大于8时就会进行红黑树的转换
					if (binCount >= TREEIFY_THRESHOLD)
						treeifyBin(tab, i);
					if (oldVal != null)
						return oldVal;
					break;
				}
			}
		}
		addCount(1L, binCount); // 统计size,并且检查是否需要扩容
		return null;
	}

在JDK8中ConcurrentHashMap的结构,由于引入了红黑树,使得ConcurrentHashMap的实现非常复杂,我们都知道,红黑树是一种性能非常好的二叉查找树,其查找性能为O(logN),但是其实现过程也非常复杂,而且可读性也非常差,Doug
Lea的思维能力确实不是一般人能比的,早期完全采用链表结构时Map的查找时间复杂度为O(N),JDK8中ConcurrentHashMap在链表的长度大于某个阈值的时候会将链表转换成红黑树进一步提高其查找性能。

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

helpTransfer()方法的目的就是调用多个工作线程一起帮助进行扩容,这样的效率就会更高,而不是只有检查到要扩容的那个线程进行扩容操作,其他线程就要等待扩容操作完成才能工作

扩容过程有点复杂,这里主要涉及到多线程并发扩容,ForwardingNode的作用就是支持扩容操作,将已处理的节点和空节点置为ForwardingNode,并发处理时多个线程经过ForwardingNode就表示已经遍历了,就往后遍历,下图是多线程合作扩容的过程:

1.8的get

ConcurrentHashMap的get操作的流程很简单,也很清晰,可以分为三个步骤来描述

  1. 计算hash值,定位到该table索引位置,如果是首节点符合就返回
  2. 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
  3. 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null

get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。

既然volatile修饰数组对get操作没有效果那加在数组上的volatile的目的是什么呢?

其实就是为了使得Node数组在扩容的时候对其他线程具有可见性而加的volatile

	public V get(Object key) {
		Node<K, V>[] tab;
		Node<K, V> e, p;
		int n, eh;
		K ek;
		int h = spread(key.hashCode()); // 计算两次hash
		if ((tab = table) != null && (n = tab.length) > 0 &&
				(e = tabAt(tab, (n - 1) & h)) != null) { // 读取首节点的Node元素
			if ((eh = e.hash) == h) { // 如果该节点就是首节点就返回
				if ((ek = e.key) == key || (ek != null && key.equals(ek)))
					return e.val;
			}
			// hash值为负值表示正在扩容,这个时候查的是ForwardingNode的find方法来定位到nextTable来
			// 查找,查找到就返回
			else if (eh < 0)
				return (p = e.find(h, key)) != null ? p.val : null;
			while ((e = e.next) != null) { // 既不是首节点也不是ForwardingNode,那就往下遍历
				if (e.hash == h &&
						((ek = e.key) == key || (ek != null && key.equals(ek))))
					return e.val;
			}
		}
		return null;
	}

1.8的size

在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,因为size是实时在变的,只能计算某一刻的大小,但是某一刻太快了,人的感知是一个时间段,所以并不是很精确

addCount()方法

// 记录map元素总数的成员变量
private transient volatile long baseCount;

put方法的最后,有一个addCount方法,因为putVal执行到此处说明已经成功新增了一个元素,所以addCount方法的作用就是维护当前ConcurrentHashMap的元素总数,在ConcurrentHashMap中有一个变量baseCount用来记录map中元素的个数,如下图所示,如果同一时刻有n个线程通过CAS同时操作baseCount变量,有且仅有一个线程会成功,其他线程都会陷入无休止的自旋当中,那一定会带来性能瓶颈。

为了避免大量线程都在自旋等待写入baseCountConcurrentHashMap引入了一个辅助队列,如下图所示,现在操作baseCount的线程可以分散到这个辅助队列中去了,调用size()的时候只需要将baseCount和辅助队列中的数值相加即可,这样就实现了调用size()无需加锁。

辅助队列是一个类型为CounterCell的数组:

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

可以简单理解为只是包装了一个long型的变量value,还需要解决一个问题是,对于某个具体的线程它是如何知道操作辅助队列中的哪个值呢?答案是下面的这个方法:

static final int getProbe() {
    return UNSAFE.getInt(Thread.currentThread(), PROBE);
}

getProbe方法会返回当前线程的一个唯一身份码,这个值是不会变的,因此可以将getProbe的返回值与辅助队列的长度作求余运算得到具体的下标,它的返回值可能是0,如果返回0则需要调用ThreadLocalRandom.localInit()初始化。

size方法就是把baseCount与所有countercell相加

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

1.7与1.8的区别

1 数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。

2 保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。

3 锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。

4 链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。

5 查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

6.1.7的size方法在极端情况会锁住所有的segment,而1.8不会,只会相加baseCount和countCell

为什么1.8用Node替代Segment

1 锁的粒度

首先锁的粒度并没有变粗,甚至变得更细了。每当扩容一次,ConcurrentHashMap的并发度就扩大一倍。

2 Hash冲突

JDK1.7中,ConcurrentHashMap从过二次hash的方式(Segment -> HashEntry)能够快速的找到查找的元素。在1.8中通过链表加红黑树的形式弥补了put、get时的性能差距。

3 扩容

JDK1.8中,在ConcurrentHashmap进行扩容时,其他线程可以通过检测数组中的节点决定是否对这条链表(红黑树)进行扩容,减小了扩容的粒度,提高了扩容的效率。

为什么1.8用synchronized而不是ReentranLock

Synchronized上锁的对象,Synchronized是靠对象的对象头和此对象对应的monitor来保证上锁的,也就是对象头里的重量级锁标志指向了monitor,而monitor呢,内部则保存了一个当前线程,也就是抢到了锁的线程.

如果使用ReentrantLock其实也可以将锁细化成这样的,只要让Node类继承ReentrantLock就行了,这样的话调用f.lock()就能做到和Synchronized(f)同样的效果,但为什么不这样做呢?

请大家试想一下,锁已经被细化到这种程度了,那么出现并发争抢的可能性还高吗?还有就是,哪怕出现争抢了,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销.

但如果是ReentrantLock呢?它则只有在线程没有抢到锁,然后新建Node节点后再尝试一次而已,不会自旋,而是直接被挂起,这样一来,我们就很容易会多出线程上下文开销的代价.当然,你也可以使用tryLock(),但是这样又出现了一个问题,你怎么知道tryLock的时间呢?在时间范围里还好,假如超过了呢?

所以,在锁被细化到如此程度上,使用Synchronized是最好的选择了.这里再补充一句,Synchronized和ReentrantLock他们的开销差距是在释放锁时唤醒线程的数量,Synchronized是唤醒锁池里所有的线程+刚好来访问的线程,而ReentrantLock则是当前线程后进来的第一个线程+刚好来访问的线程.

如果是线程并发量不大的情况下,那么Synchronized因为自旋锁,偏向锁,轻量级锁的原因,不用将等待线程挂起,偏向锁甚至不用自旋,所以在这种情况下要比ReentrantLock高效

ConcurrentSkipListMap

ConcurrentSkipListMap是一个可以在高并发环境下执行的有序map容器,在单线程环境下我们应使用TreeMap,在低并发环境下我们可以使用Collections.synchronizedSortedMap包装TreeMap来得到一个线程安全的有序map。ConcurrentSkipListMap底层实现是一个SkipList跳表,简单的说就是一个稍微复杂一点的链表结构。

为了引出 ConcurrentSkipListMap,先带着大家简单理解一下跳表。

对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。

跳表的本质是同时维护了多个链表,并且链表是分层的,

最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。

跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。

查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。

从上面很容易看出,跳表是一种利用空间换时间的算法。

使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap

CopyOnWriteArrayList

public class CopyOnWriteArrayList<E>
extends Object
implements List<E>, RandomAccess, Cloneable, Serializable

在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。

这和我们之前在多线程章节讲过 ReentrantReadWriteLock 读写锁的思想非常类似,也就是读读共享、写写互斥、读写互斥、写读互斥。JDK 中提供了 CopyOnWriteArrayList 类比相比于在读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。那它是怎么做的呢?

CopyOnWriteArrayList 是如何做到的?

CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。

CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是满足 CopyOnWrite 的。所谓 CopyOnWrite 也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了。

CopyOnWriteArrayList 读取和写入源码简单分析

CopyOnWriteArrayList 读取操作的实现

读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。

    /** The array, accessed only via getArray/setArray. */
    private transient volatile Object[] array;
    public E get(int index) {
        return get(getArray(), index);
    }
    @SuppressWarnings("unchecked")
    private E get(Object[] a, int index) {
        return (E) a[index];
    }
    final Object[] getArray() {
        return array;
    }

CopyOnWriteArrayList 写入操作的实现

CopyOnWriteArrayList 写入操作 add()方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。

    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return {@code true} (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();//加锁
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);//拷贝新数组
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();//释放锁
        }
    }

ConcurrentLinkedQueue

在并发编程中,有时候需要使用线程安全的队列。如果要实现一个线程安全的队列有两种方式:

一种是使用阻塞算法,另一种是使用非阻塞算法。

使用阻塞算法的队列可以用一个锁 (入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。

非阻塞的实现方 式则可以使用循环CAS的方式来实现。我们一起来研究一下Doug Lea是如何使用非阻 塞的方式来实现线程安全队列ConcurrentLinkedQueue的,相信从大师身上我们能学到不少并 发编程的技巧。

ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,它采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部;当我们获取一个元素时,它会返回队列头部的元素。它采用了"wait-free"算法(即CAS算法)来实现,该算法在Michael&Scott算法上进行了一些修改。

大家知道 ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全就好了。

ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。

ConcurrentLinkedQueue的结构

通过ConcurrentLinkedQueue的类图来分析一下它的结构,如图

ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和 指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一 张链表结构的队列。默认情况下head节点存储的元素为空,tail节点等于head节点。

入队列

入队列的过程

入队列就是将入队节点添加到队列的尾部。为了方便理解入队时队列的变化,以及head节 点和tail节点的变化,这里以一个示例来展开介绍。假设我们想在一个队列中依次插入4个节 点,为了帮助大家理解,每添加一个节点就做了一个队列的快照图如图所示。

图所示的过程如下。

添加元素1。队列更新head节点的next节点为元素1节点。又因为tail节点默认情况下等于 head节点,所以它们的next节点都指向元素1节点。 (此时 head=tail->node1)

添加元素2。队列首先设置元素1节点的next节点为元素2节点,然后更新tail节点指向元素 2节点。(此时node1->node2=tail)

添加元素3, 设置tail节点的next节点为元素3节点。(此时node1->node2=tail->node3)

添加元素4, 设置元素3的next节点为元素4节点,然后将tail节点指向元素4节点。 (此时node1->node2->node3->node4=tail)

通过调试入队过程并观察head节点和tail节点的变化,发现入队主要做两件事情:

第一是 将入队节点设置成当前队列尾节点的下一个节点

第二是更新tail节点,如果tail节点的next节 点不为空,则将入队节点设置成tail节点,

如果tail节点的next节点为空,则将入队节点设置成 tail的next节点(此时不更新tail节点),所以tail节点不总是尾节点(理解这一点对于我们研究源码会非常有帮助)。

通过对上面的分析,我们从单线程入队的角度理解了入队过程,但是多个线程同时进行 入队的情况就变得更加复杂了,因为可能会出现其他线程插队的情况。如果有一个线程正在 入队,那么它必须先获取尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另 外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队橾作,然后重 新获取尾节点。让我们再通过源码来详细分析一下它是如何使用CAS算法来入队的。

从源代码角度来看,整个入队过程主要做两件事情:第一是定位出尾节点;第二是使用 CAS算法将入队节点设置成尾节点的next节点,如不成功则重试。 (具体解析看下面)

定位尾节点

tail节点并不总是尾节点,所以每次入队都必须先通过tail节点来找到尾节点。尾节点可能 是tail节点,也可能是tail节点的next节点。

代码中循环体中的第一个if是判断tail是否有next节 点,没有说明p一定是尾结点,有则表示next节点可能是尾节点。获取tail节点的next节点需要注意的是p节点等于p的next 节点的情况,只有一种可能就是p节点和p的next节点都等于空,表示这个队列刚初始化,正准备添加节点,所以需要返回head节点。获取p节点的next节点代码如下。

设置入队节点为尾节点

p.casNext(null, n)方法用于将入队节点设置为当前队列尾节点的next节点,如果p是null, 表示p是当前队列的尾节点,

如果不为null, 表示有其他线程更新了尾节点,则需要重新获取当 前队列的尾节点。

HOPS的设计意图

上面分析过对于先进先出的队列入队所要做的事情是将入队节点设置成尾节点,douglea 写的代码和逻辑还是稍微有点复杂。那么,我用以下方式来实现是否可行?

让tail节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑清晰和易懂。但是, 这么做有个缺点,

每次都需要使用循环CAS更新tail节点。如果能减少CAS更新tail节点的次 数,就能提高入队的效率,

所以douglea使用hops变量来控制并减少tail节点的更新频率,并不 是每次节点入队后都将tail节点更新成尾节点,而是当tail节点和尾节点的距离大于等于常量 HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长,使用CAS更新tail节点的次 数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为 循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,

因为从本质上来 看它通过增加对volatile变量的读橾作来减少对volatile变量的写操作,而对volatile变量的写操 作开销要远远大于读操作,所以入队效率会有所提升。

出队列

出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用。让我们通过每 个节点出队的快照来观察一下head节点的变化,如图所示。

从图中可知,并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head 节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head 节点

这种做法也是通过hops变量来减少使用CAS更新head节点的消耗,从而提高出队效率。 让我们再通过源码来深入分析下出队过程。

首先获取头节点的元素,然后判断头节点元素是否为空,

如果为空,表示另外一个线程已 经进行了一次出队橾作将该节点的元素取走,就获取p的下一个节点,继续循环(如果下一个节点也为空,说明队列空了,更新头结点,返回null)

如果不为空,则使用CAS的方式将头节点的引 用设置成null,

如果CAS成功,则直接返回头节点的元素,

如果不成功,表示另外一个线程已经 进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。

Java中的阻塞队列

什么是阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞 的插入和移除方法。

1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不 满

2)支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是 从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。

在阻塞队列不可用时,这两个附加操作提供了4种处理方式,如表

抛出异常:当队列满时如果再往队列里插入元素,会抛出IllegalStateException ("Queue full")异常。当队列空时,从队列里获取元素会抛出N oSuchElementException异常。

返回特殊值当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移 除方法,则是从队列里取出一个元素,如果没有则返回null。

一直阻塞:当阻塞队列满时如果生产者线程往队列里put元素,队列会一直阻塞生产者 线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队 列会阻塞住消费者线程,直到队列不为空。

超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程 一段时间,如果超过了指定的时间,生产者线程就会退出。

这两个附加操作的4种处理方式不方便记忆,所以我找了一下这几个方法的规律。

add,remove,element,是普通的列表都有的,抛出异常。

put和 take分别尾首含有字母t,阻塞。

offer和poll都含有字母o,返回特殊值。(t阻o特)

如果是无界阻塞队列,队列不可能会出现满的情况,所以使用put或offer方法永远不会被阻塞,而且使用offer方法时,该方法永远返回true。

Java里的阻塞队列

JDK7提供了7个阻塞队列,如下。

ArrayBlockingQueue: 一个由数组结构组成的有界阻塞队列。

LinkedBlockingQueue : 一个由链表结构组成的有界阻塞队列。

PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列。

DelayQueue : 一个使用优先级队列实现的无界阻塞队列。

Synchronous Queue : 一个不存储元素的阻塞队列。

LinkedTransferQueue: 一个由链表结构组成的无界阻塞队列。

LinkedBlockingDeque: 一个由链表结构组成的双向阻塞队列。

ArrayBlockingQueue

ArrayBlockingQueue是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原 则对元素进行排序。

ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。

ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:

访问者的公平性是使用可重入锁实现的,代码如下。

LinkedBlockingQueue

LinkedBlockingQueue是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。

LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE

    /**
     *某种意义上的无界队列
     * Creates a {@code LinkedBlockingQueue} with a capacity of
     * {@link Integer#MAX_VALUE}.
     */
    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

    /**
     *有界队列
     * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
     *
     * @param capacity the capacity of this queue
     * @throws IllegalArgumentException if {@code capacity} is not greater
     *         than zero
     */
    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);
    }
    /** 容量,毕竟这是一个有界队列 */
    private final int capacity;
 
    /** 大小,元素的个数 */
    private final AtomicInteger count = new AtomicInteger();
 
    //队首指针
    transient Node<E> head;
 
    //队尾指针
    private transient Node<E> last;

上面就是些基于单链表的队列的必备成员。之所以需要使用AtomicInteger,是因为有两个线程(入队线程、出队线程)可能同时在修改它,所以用原子类来保持count的正确性。

    /** 出队线程需要竞争这把锁,竞争到了才能出队,也就是说同时只有一个线程能出队 */
    private final ReentrantLock takeLock = new ReentrantLock();
 
    /** 出队线程可能会暂时阻塞在这个AQS条件队列里,当发现队列已空时 */
    private final Condition notEmpty = takeLock.newCondition();
 
    /** 入队线程需要竞争这把锁,竞争到了才能入队,也就是说同时只有一个线程能入队 */
    private final ReentrantLock putLock = new ReentrantLock();
 
    /** 入队线程可能会暂时阻塞在这个AQS条件队列里,当发现队列已满时 */
    private final Condition notFull = putLock.newCondition();

为了保证last的正确性,只有竞争到putLock的入队线程才能执行入队动作。这样就只有一个线程在修改last。

上图展示了入队线程的通用流程。当入队线程从notFull.await()处恢复执行时,已经又重新获得了putLock,然后入队线程即将执行入队动作,别的线程也不可能和它竞争入队了。

    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();//可能抛出中断异常
        try {
            while (count.get() == capacity) {//从下一句恢复后,需要检查是否为虚假唤醒
                notFull.await();//可能抛出中断异常
            }
            //执行到这里,count肯定只会比capacity小
            enqueue(node);
            c = count.getAndIncrement();//执行count++
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }

为了保证head的正确性,只有竞争到takeLock的出队线程才能执行出队动作。这样就只有一个线程在修改head。

 

上图展示了出队线程的通用流程。当出队线程从notEmpty.await()处恢复执行时,已经又重新获得了takeLock,然后出队线程即将执行出队动作,别的线程也不可能和它竞争出队了。

    public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();//可能抛出中断异常
        try {
            while (count.get() == 0) {//从下一句恢复执行后,需要检查是否为虚假唤醒
                notEmpty.await();//可能抛出中断异常
            }
            //执行到这里,count肯定只会比0大
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }

PriorityBlockingQueue

PriorityBlockingQueue是一个支持优先级的无界阻塞队列。

默认情况下元素采取自然顺序 升序排列。也可以自定义类实现compare To()方法来指定元素排序规则,或者初始化 PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证 同优先级元素的顺序。

PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。

简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

DelayQueue

DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。

队 列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。 只有在延迟期满时才能从队列中提取元素。

DelayQueue非常有用,可以将DelayQueue运用在以下应用场景。

缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询 DelayQueue, 一旦能从DelayQueue中获取元素时,表示缓存有效期到了。

定时任务调度使用DelayQueue保存当天将会执行的任务和执行时间,一旦从 DelayQueue中获取到任务就开始执行,比如TimerQueue就是使用DelayQueue实现的。

(1)如何实现Delayed接口

DelayQueue队列的元素必须实现Delayed接口。我们可以参考ScheduledThreadPoo !Executor 里ScheduledFutureTask类的实现一共有三步。

第一步在对象创建的时候,初始化基本数据。使用time记录当前对象延迟到什么时候可 以使用,使用sequenceNumber来标识元素在队列中的先后顺序。代码如下。

第二步:实现getDelay方法,该方法返回当前元素还需要延时多长时间单位是纳秒,代码 如下。

通过构造函数可以看出延迟时间参数ns的单位是纳秒,自己设计的时候最好使用纳秒,因 为实现getDelay()方法时可以指定任意单位,一旦以秒或分作为单位,而延时时间又精确不到 纳秒就麻烦了。使用时请注意当time小于当前时间时,getDe1ay会返回负数。

第三步:实现compareTo方法来指定元素的顺序。例如,让延时时间最长的放在队列的末 尾。实现代码如下。

(2)如何实现延时阻塞队列

延时阻塞队列的实现很简单,当消费者从队列里获取元素时,如果元素没有达到延时时 间,就阻塞当前线程。

代码中的变量leader是一个等待获取队列头部元素的线程。如果leader不等于空,表示已 经有线程在等待获取队列的头元素。所以,使用await()方法让当前线程等待信号。如果leader 等于空,则把当前线程设置成leader, 并使用awaitNanos()方法让当前线程等待接收信号或等 待delay时间。

SynchronousQueue

Synchronous Queue是一个不存储元素的阻塞队列。每一个put橾作必须等待一个take橾作, 否则不能继续添加元素。

它支持公平访问队列。默认情况下线程采用非公平性策略访问队列。使用以下构造方法 可以创建公平性访问的Synchronous Queue, 如果设笸为true, 则等待的线程会采用先进先出的 顺序访问队列。

Synchronous Queue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费 者线程。队列本身并不存储任何元素,非常适合传递性场景。Synchronous Queue的吞吐量高于 LinkedBlockingQueue和ArrayBlockingQueue。

LinkedTransferQueue

LinkedTransferQueue 是一个由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻 塞队列,LinkedTransferQueue 多了tryTransfer和transfer方法。

(1) transfer方法

如果当前有消费者正在等待接收元素(消费者使用take()方法或带时间限制的poll()方法 时),transfer方法可以把生产者传入的元素立刻transfer(传输)给消费者。如果没有消费者在等 待接收元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者消费了才返 回。transfer方法的关键代码如下。

第一行代码是试图把存放当前元素的s节点作为tail节点。第二行代码是让CPU自旋等待 消费者消费元素。因为自旋会消耗CPU, 所以自旋一定的次数后使用Thread. yield()方法来暂停 当前正在执行的线程,并执行其他线程。

(2) tryTransfer方法

tryTransfer方法是用来试探生产者传入的元素是否能直接传给消费者。如果没有消费者等 待接收元素,则返回false。

和transfer方法的区别是tryTransfer方法无论消费者是否接收,回而transfer方法是必须等到消费者消费了才返回。

对于带有时间限制的tryTransfer (Ee, long timeout, Time Unit unit)方法,试图把生产者传入 的元素直接传给消费者,但是如果没有消费者消费该元素则等待指定的时间再返回,如果超时还没消费元素,则返回false, 如果在超时时间内消费了元素,则返回true。

LinkedBlockingDeque

LinkedBlockingDeque是一个由链表结构组成的双向阻塞队列。

所谓双向队列指的是可以 从队列的两端插入和移出元素。

双向队列因为多了一个橾作队列的入口,在多线程同时入队时,也就减少了一半的竞争。相比其他的阻塞队列LinkedBlockingDeque多了addFirst、addLast、offer First、offerLast、peekFirst和peekLast等方法,以First单词结尾的方法,表示插入、 获取(peek)或移除双端队列的第一个元素。以Last单词结尾的方法,表示插入、获取或移除双 端队列的最后一个元素。另外,插入方法add等同于addLast, 移除方法remove等效于 removeFirst。但是take方法却等同于takeFirst, 不知道是不是JDK的bug, 使用时还是用带有First 和Last后缀的方法更清楚。

在初始化LinkedBlockingDeque时可以设置容量防止其过度膨胀。

另外,双向阻塞队列可以 运用在“工作窃取“模式中。

阻塞队列的实现原理

如果队列是空的,消费者会一直等待,当生产者添加元素时,消费者是如何知道当前队列 有元素的呢?如果让你来设计阻塞队列你会如何设计,如何让生产者和消费者进行高效率的 通信呢?让我们先来看看JDK是如何实现的。

使用通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生 产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK源码 发现ArrayBlockingQueue使用了Condition来实现,代码如下。

当往队列里插入一个元素时,如果队列不可用,那么阻塞生产者主要通过 LockSupport.park(this)来实现。

继续进入源码,发现调用setBlocker先保存一下将要阻塞的线程,然后调用unsafe.park阻塞 当前线程。

unsafe.park是native方法,代码如下。

park这个方法会阻塞当前线程,只有以下4种情况中的一种发生时,该方法才会返回。

  • 与park对应的unpark执行或已经执行时。”已经执行“是指park先执行,然后再执行unpark的情况。
  • 线程被中断时。
  • 等待完time参数指定的毫秒数时。
  • 异常现象发生时,这个异常现象没有任何原因。

当线程被阻塞队列阻塞时,线程会进入WAITING (parking)状态。我们可以使用jstackdump 阻塞的生产者线程看到这点,如下。

Fork/Join框架

什么是Fork/Join框架

Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

我们再通过Fork和Join两个单词来理解一下Fork/Join框架。

Fork就是把一个大任务切分 为若干子任务并行的执行,

Join就是合并这些子任务的执行结果,最后得到这个大任务的结 果。

比如计算1 +2+ ... + 10000, 可以分割成10个子任务,每个子任务分别对1000个数进行求和, 最终汇总这10个子任务的结果。Fork/Join的运行流程如图

工作窃取算法

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。

那么,为什么 需要使用工作窃取算法呢?

假如我们需要做一个比较大的任务,可以把这个任务分割为若干 互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个 队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。比如A线程负责处理A 队列里的任务。

但是,有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有 任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列 里窃取一个任务来执行。

而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被 窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿 任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

工作窃取的运行流程如图

工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争。

工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并 且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列

Fork/Join框架的设计

我们已经很清楚Fork/Join框架的需求了,那么可以思考一下,如果让我们来设计一个 Fork/Join框架,该如何设计?这个思考有助于你理解Fork/Join框架的设计。

步骤1分割任务。首先我们需要有一个fork类来把大任务分割成子任务,有可能子任务还 是很大,所以还需要不停地分割,直到分割出的子任务足够小。

步骤2执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分 别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程 从队列里拿数据,然后合并这些数据。

Fork/Join使用两个类来完成以上两件事情。

(1)ForkJoinTask

我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务 中执行fork()和join()操作的机制。通常情况下,我们不需要直接继承ForkJoinTask类,只需要继 承它的子类,Fork/Join框架提供了以下两个子类。

RecursiveAction: 用于没有返回结果的任务。

RecursiveTask: 用于有返回结果的任务。

(2)ForkJoinPool

ForkJoinTask需要通过ForkJoinPool来执行。

任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当 一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任 务。

使用Fork/Join框架

让我们通过一个简单的需求来使用Fork/Join框架,需求是:计算1+2+3+4的结果。

使用Fork/Join框架首先要考虑到的是如何分割任务,如果希望每个子任务最多执行两个 数的相加,那么我们设置分割的阅值是2, 由于是4个数字相加,所以Fork/Join框架会把这个任 务fork成两个子任务,子任务一负责计算1+2, 子任务二负责计算3+4, 然后再join两个子任务 的结果。因为是有结果的任务,所以必须继承Recursive Task, 实现代码如下。

通过这个例子,我们进一步了解F orkJ oinTask, F orkJ oinTask与一般任务的主要区别在于它 需要实现compute方法.

在compute这个方法里,首先需要判断任务是否足够小,如果足够小就直接执 行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用fork方法时,又会进入 compute方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当 前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果。

Fork/Join框架的异常处理

ForkJoinTask在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常, 所以ForkJ oinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被 取消了,并且可以通过ForkJ oinTask的getException方法获取异常。使用如下代码。

getException方法返回Throwable对象,如果任务被取消了则返回CancellationException。如 果任务没有完成或者没有抛出异常则返回null。

Fork/Join框架的实现原理

ForkJoinPool由ForkJoinTask数组和ForkJoin Worker Thread数组组成,ForkJoinTask数组负责 将存放程序提交给F orkJ oinPool的任务,而F orkJ oin WorkerThread数组负责执行这些任务。

 ForkJoinTask的fork方法实现原理

当我们调用ForkJoinTask的fork方法时程序会调用F orkJ oin Worker Thread的pushTask方法 异步地执行这个任务,然后立即返回结果。代码如下。

pushTask方法把当前任务存放在F orkJ oinTask数组队列里。然后再调用ForkJoinPool的 signal Work()方法唤醒或创建一个工作线程来执行任务。代码如下。

ForkJoinTask的join方法实现原理

Join方法的主要作用是阻塞当前线程并等待获取结果。让我们一起看看ForkJoinTask的join 方法的实现代码如下。

首先,它调用了doJoin()方法,通过doJoin()方法得到当前任务的状态来判断返回什么结 果,任务状态有4种:

已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常 (EXCEPTlONAL)。

如果任务状态是已完成,则直接返回任务结果。

如果任务状态是被取消,则直接抛出Cancellati onExcepti on。

如果任务状态是抛出异常,则直接抛出对应的异常。

让我们再来分析一下doJoin()方法的实现代码。

在doJoin()方法里,首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成, 则直接返回任务状态;如果没有执行完,则从任务数组里取出任务并执行。如果任务顺利执行 完成,则设置任务状态为NORMAL, 如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值