阻塞队列非阻塞队列原理分析

989 阅读10分钟

前面的博客中 并发编程-阻塞队列及使用 介绍了常见的阻塞队列的特点以及基本的使用,这里以ArrayBlockingQueue为例,首先分析 BlockingQueue 即阻塞队列的线程安全原理,然后再看看非阻塞队列的并发安全原理。

BlockingQueue 源码分析

ArrayBlockingQueue

首先看一下ArrayBlockingQueue的源码,ArrayBlockingQueue有以下几个重要的属性:


// 用于存放元素的数组

final Object[] items;

// 下一次读取操作的位置

int takeIndex;

// 下一次写入操作的位置

int putIndex;

// 队列中的元素数量

int count;

第一个就是最核心的、用于存储元素的Object类型的数组;然后它还会有两个位置变量,分别是takeIndex和 putIndex,这两个变量就是用来标明下一次读取和写入位置的;另外还有一个count用来计数,它所记录的就是队列中的元素个数。

另外,再来看下面这三个变量:


// ReentrantLock lock

final ReentrantLock lock;

// ReentrantLock.newCondition()

private final Condition notEmpty;

// ReentrantLock.newCondition()

private final Condition notFull;

// 构造方法

public ArrayBlockingQueue(int capacity, boolean fair) {

if (capacity <= 0)

throw new IllegalArgumentException();

this.items = new Object[capacity];

lock = new ReentrantLock(fair);

notEmpty = lock.newCondition();

notFull = lock.newCondition();

}

这三个变量也非常关键,第一个就是一个 ReentrantLock,而下面两个 Condition 分别是由 ReentrantLock 产生出来的,这三个变量就是我们实现线程安全最核心的工具。

利于一张网络上的图,描述一下我们的同步机制:

image.png

ArrayBlockingQueue 正是利用了 ReentrantLock 和它的两个 Condition 实现的并发安全,真正执行在读写操作前,都需要先获取到锁才行。

下面,我们来分析一下最重要的 put 方法:


public void put(E e) throws InterruptedException {

checkNotNull(e);

final ReentrantLock lock = this.lock;

lock.lockInterruptibly();

try {

while (count == items.length)

notFull.await();

enqueue(e);

} finally {

lock.unlock();

}

}

在 put 方法中,首先用 checkNotNull 方法去检查插入的元素是不是 null。如果不是 null,我们会用 ReentrantLock 上锁,并且上锁方法是 lock.lockInterruptibly()。这个方法我们在第 23 课时的时候讲过,在获取锁的同时是可以响应中断的,这也正是我们的阻塞队列在调用 put 方法时,在尝试获取锁但还没拿到锁的期间可以响应中断的底层原因。

紧接着 ,是一个非常经典的 try finally 代码块,finally 中会去解锁,try 中会有一个 while 循环,它会检查当前队列是不是已经满了,也就是 count 是否等于数组的长度。如果等于就代表已经满了,于是我们便会进行等待,直到有空余的时候,我们才会执行下一步操作,调用 enqueue 方法让元素进入队列,最后用 unlock 方法解锁。

ArrayBlockingQueue 实现并发同步的原理就是,读操作和写操作都需要获取到 AQS 独占锁才能进行操作。如果队列为空,这个时候读操作的线程进入到读线程队列排队,等待写线程写入新的元素,然后唤醒读线程队列的第一个等待线程。如果队列已满,这个时候写操作的线程进入到写线程队列排队,等待读线程将队列元素移除腾出空间,然后唤醒写线程队列的第一个等待线程。

对于 ArrayBlockingQueue,我们可以在构造的时候指定以下三个参数:

  • 队列容量,其限制了队列中最多允许的元素个数;

  • 指定独占锁是公平锁还是非公平锁。非公平锁的吞吐量比较高,公平锁可以保证每次都是等待最久的线程获取到锁;

  • 可以指定用一个集合来初始化,将此集合中的元素在构造方法期间就先添加到队列中。

和ArrayBlockingQueue 类似,其他各种阻塞队列如 LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、DelayedWorkQueue 等一系列 BlockingQueue 的内部也是利用了 ReentrantLock 来保证线程安全,只不过细节有差异,比如 LinkedBlockingQueue 的内部有两把锁,分别锁住队列的头和尾,比共用同一把锁的效率更高,不过总体思想都是类似的。

LinkedBlockingQueue

底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用。构造方法:


// 无界队列

public LinkedBlockingQueue() {

this(Integer.MAX_VALUE);

}


// 有界队列

public LinkedBlockingQueue(int capacity) {

if (capacity <= 0) throw new IllegalArgumentException();

this.capacity = capacity;

last = head = new Node<E>(null);

}

注意,这里会初始化一个空的头结点,那么第一个元素入队的时候,队列中就会有两个元素。读取元素时,也总是获取头节点后面的一个节点。count 的计数值不包括这个头节点。

读操作是排好队的,写操作也是排好队的,唯一的并发问题在于一个写操作和一个读操作同时进行,只要控制好这个就可以了。

源码主要属性


// 队列容量

private final int capacity;

// 队列中的元素数量

private final AtomicInteger count = new AtomicInteger(0);

// 队头

private transient Node<E> head;

// 队尾

private transient Node<E> last;

// take, poll, peek 等读操作的方法需要获取到这个锁

private final ReentrantLock takeLock = new ReentrantLock();

// 如果读操作的时候队列是空的,那么等待 notEmpty 条件

private final Condition notEmpty = takeLock.newCondition();

// put, offer 等写操作的方法需要获取到这个锁

private final ReentrantLock putLock = new ReentrantLock();

// 如果写操作的时候队列是满的,那么等待 notFull 条件

private final Condition notFull = putLock.newCondition();

这里用了两个锁,两个 Condition,简单介绍如下:

takeLock 和 notEmpty 怎么搭配:如果要获取(take)一个元素,需要获取 takeLock 锁,但是获取了锁还不够,如果队列此时为空,还需要队列不为空(notEmpty)这个条件(Condition)。

putLock 需要和 notFull 搭配:如果要插入(put)一个元素,需要获取 putLock 锁,但是获取了锁还不够,如果队列此时已满,还需要队列不是满的(notFull)这个条件(Condition)。

  • 来看下 put() 方法是怎么将元素插入到队尾的:

public void put(E e) throws InterruptedException {

if (e == null) throw new NullPointerException();

// 如果你纠结这里为什么是 -1,可以看看 offer 方法。这就是个标识成功、失败的标志而已。

int c = -1;

Node<E> node = new Node(e);

final ReentrantLock putLock = this.putLock;

final AtomicInteger count = this.count;

// 必须要获取到 putLock 才可以进行插入操作

putLock.lockInterruptibly();

try {

// 如果队列满,等待 notFull 的条件满足。

while (count.get() == capacity) {

notFull.await();

}

// 入队

enqueue(node);

// count 原子加 1,c 还是加 1 前的值

c = count.getAndIncrement();

// 如果这个元素入队后,还有至少一个槽可以使用,调用 notFull.signal() 唤醒等待线程。

// 哪些线程会等待在 notFull 这个 Condition 上呢?

if (c + 1 < capacity)

notFull.signal();

} finally {

// 入队后,释放掉 putLock

putLock.unlock();

}

// 如果 c == 0,那么代表队列在这个元素入队前是空的(不包括head空节点),

// 那么所有的读线程都在等待 notEmpty 这个条件,等待唤醒,这里做一次唤醒操作

if (c == 0)

signalNotEmpty();

}

// 入队的代码非常简单,就是将 last 属性指向这个新元素,并且让原队尾的 next 指向这个元素

// 这里入队没有并发问题,因为只有获取到 putLock 独占锁以后,才可以进行此操作

private void enqueue(Node<E> node) {

// assert putLock.isHeldByCurrentThread();

// assert last.next == null;

last = last.next = node;

}

// 元素入队后,如果需要,调用这个方法唤醒读线程来读

private void signalNotEmpty() {

final ReentrantLock takeLock = this.takeLock;

takeLock.lock();

try {

notEmpty.signal();

} finally {

takeLock.unlock();

}

}

  • take()方法

public E take() throws InterruptedException {

E x;

int c = -1;

final AtomicInteger count = this.count;

final ReentrantLock takeLock = this.takeLock;

// 首先,需要获取到 takeLock 才能进行出队操作

takeLock.lockInterruptibly();

try {

// 如果队列为空,等待 notEmpty 这个条件满足再继续执行

while (count.get() == 0) {

notEmpty.await();

}

// 出队

x = dequeue();

// count 进行原子减 1

c = count.getAndDecrement();

// 如果这次出队后,队列中至少还有一个元素,那么调用 notEmpty.signal() 唤醒其他的读线程

if (c > 1)

notEmpty.signal();

} finally {

// 出队后释放掉 takeLock

takeLock.unlock();

}

// 如果 c == capacity,那么说明在这个 take 方法发生的时候,队列是满的

// 既然出队了一个,那么意味着队列不满了,唤醒写线程去写

if (c == capacity)

signalNotFull();

return x;

}

// 取队头,出队

private E dequeue() {

// assert takeLock.isHeldByCurrentThread();

// assert head.item == null;

// 之前说了,头结点是空的

Node<E> h = head;

Node<E> first = h.next;

h.next = h; // help GC

// 设置这个为新的头结点

head = first;

E x = first.item;

first.item = null;

return x;

}

// 元素出队后,如果需要,调用这个方法唤醒写线程来写

private void signalNotFull() {

final ReentrantLock putLock = this.putLock;

putLock.lock();

try {

notFull.signal();

} finally {

putLock.unlock();

}

}

源码中已标注相应的注释,这里源码展示到这里。

SynchronousQueue

它是一个特殊的队列,它的名字其实就蕴含了它的特征 - - 同步的队列。为什么说是同步的呢?这里说的并不是多线程的并发问题,而是因为当一个线程往队列中写入一个元素时,写入操作不会立即返回,需要等待另一个线程来将这个元素拿走;同理,当一个读线程做读操作的时候,同样需要一个相匹配的写线程的写操作。这里的 Synchronous 指的就是读线程和写线程需要同步,一个读线程匹配一个写线程。

比较少使用到SynchronousQueue这个类,不过它在线程池的实现类ThreadPoolExecutor中得到了应用,感兴趣的读者可以在看完这个后去看看相应的使用。

虽然上面我说了队列,但是SynchronousQueue的队列其实是虚的,其不提供任何空间(一个都没有)来存储元素。数据必须从某个写线程交给某个读线程,而不是写到某个队列中等待被消费。

你不能在SynchronousQueue 中使用 peek 方法(在这里这个方法直接返回 null),peek 方法的语义是只读取不移除,显然,这个方法的语义是不符合 SynchronousQueue 的特征的。SynchronousQueue 也不能被迭代,因为根本就没有元素可以拿来迭代的。虽然 SynchronousQueue 间接地实现了 Collection 接口,但是如果你将其当做 Collection 来用的话,那么集合是空的。当然,这个类也是不允许传递 null 值的(并发包中的容器类好像都不支持插入 null 值,因为 null 值往往用作其他用途,比如用于方法的返回值代表操作失败)。

源码:略

PriorityBlockingQueue

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

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

主要属性源码:


// 构造方法中,如果不指定大小的话,默认大小为 11

private static final int DEFAULT_INITIAL_CAPACITY = 11;

// 数组的最大容量

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

// 这个就是存放数据的数组

private transient Object[] queue;

// 队列当前大小

private transient int size;

// 大小比较器,如果按照自然序排序,那么此属性可设置为 null

private transient Comparator<? super E> comparator;

// 并发控制所用的锁,所有的 public 且涉及到线程安全的方法,都必须先获取到这个锁

private final ReentrantLock lock;

// 这个很好理解,其实例由上面的 lock 属性创建

private final Condition notEmpty;

// 这个也是用于锁,用于数组扩容的时候,需要先获取到这个锁,才能进行扩容操作

// 其使用 CAS 操作

private transient volatile int allocationSpinLock;

// 用于序列化和反序列化的时候用,对于 PriorityBlockingQueue 我们应该比较少使用到序列化

private PriorityQueue q;

此类实现了 CollectionIterator 接口中的所有接口方法,对其对象进行迭代并遍历时,不能保证有序性。如果你想要实现有序遍历,建议采用 Arrays.sort(queue.toArray()) 进行处理。PriorityBlockingQueue 提供了 drainTo 方法用于将部分或全部元素有序地填充(准确说是转移,会删除原队列中的元素)到另一个集合中。还有一个需要说明的是,如果两个对象的优先级相同(compare 方法返回 0),此队列并不保证它们之间的顺序。

PriorityBlockingQueue 使用了基于数组的二叉堆来存放元素,所有的 public 方法采用同一个 lock 进行并发控制。

二叉堆:一颗完全二叉树,它非常适合用数组进行存储,对于数组中的元素 a[i],其左子节点为 a[2i+1],其右子节点为 a[2i + 2],其父节点为 a[(i-1)/2],其堆序性质为,每个节点的值都小于其左右子节点的值。二叉堆中最小的值就是根节点,但是删除根节点是比较麻烦的,因为需要调整树。

阻塞队列总结:

  • ArrayBlockingQueue 底层是数组,有界队列,如果我们要使用生产者-消费者模式,这是非常好的选择。

  • LinkedBlockingQueue 底层是链表,可以当做无界和有界队列来使用,所以大家不要以为它就是无界队列。

  • SynchronousQueue 本身不带有空间来存储任何元素,使用上可以选择公平模式和非公平模式。

  • PriorityBlockingQueue 是无界队列,基于数组,数据结构为二叉堆,数组第一个也是树的根节点总是最小值。

非阻塞队列ConcurrentLinkedQueue

看完阻塞队列之后,我们就来看看非阻塞队列 ConcurrentLinkedQueue。顾名思义,ConcurrentLinkedQueue 是使用链表作为其数据结构的,我们来看一下关键方法 offer 的源码:


public boolean offer(E e) {

checkNotNull(e);

final Node<E> newNode = new Node<E>(e);

for (Node<E> t = tail, p = t;;) {

Node<E> q = p.next;

if (q == null) {

// p is last node

if (p.casNext(null, newNode)) {

// Successful CAS is the linearization point

// for e to become an element of this queue,

// and for newNode to become "live".

if (p != t) // hop two nodes at a time

casTail(t, newNode); // Failure is OK.

return true;

}

// Lost CAS race to another thread; re-read next

}

else if (p == q)

// We have fallen off list. If tail is unchanged, it

// will also be off-list, in which case we need to

// jump to head, from which all live nodes are always

// reachable. Else the new tail is a better bet.

p = (t != (t = tail)) ? t : head;

else

// Check for tail updates after two hops.

p = (p != t && t != (t = tail)) ? t : q;

}

}

在这里我们不去一行一行分析具体的内容,而是把目光放到整体的代码结构上,在检查完空判断之后,可以看到它整个是一个大的 for 循环,而且是一个非常明显的死循环。在这个循环中有一个非常亮眼的 p.casNext 方法,这个方法正是利用了 CAS 来操作的,而且这个死循环去配合 CAS 也就是典型的乐观锁的思想。我们就来看一下 p.casNext 方法的具体实现,其方法代码如下:


boolean casNext(Node<E> cmp, Node<E> val) {

return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);

}

可以看出这里运用了 UNSAFE.compareAndSwapObject 方法来完成 CAS 操作,而 compareAndSwapObject 是一个 native 方法,最终会利用 CPU 的 CAS 指令保证其不可中断。

可以看出,非阻塞队列 ConcurrentLinkedQueue 使用 CAS 非阻塞算法 + 不停重试,来实现线程安全,适合用在不需要阻塞功能,且并发不是特别剧烈的场景。

最后我们来做一下总结。本课时我们分析了阻塞队列和非阻塞队列的并发安全原理,其中阻塞队列最主要是利用了 ReentrantLock 以及它的 Condition 来实现,而非阻塞队列则是利用 CAS 方法实现线程安全。