ArrayBlockingQueue
1、简介
ArrayBlockingQueue
,顾名思义:基于数组
的阻塞
队列。数组是要指定长度的,所以使用ArrayBlockingQueue
时必须指定长度
,也就是它是一个有界队列。
它实现了BlockingQueue
接口,有着队列、集合以及阻塞队列的所有方法,队列类图如下图所示:
既然它在JUC包内,说明使用它是线程安全的,它内部使用ReentrantLock
来保证线程安全。ArrayBlockingQueue支持对生产者线程和消费者线程进行公平的调度,默认情况下是不保证公平性的。公平性通常会降低吞吐量,但是减少了可变性和避免了线程饥饿问题。
JUC包指java.util.concurrent目录下的类
2、数据结构
通常,队列的实现方式有数组和链表两种方式。对于数组这种实现方式来说,我们可以通过维护一个队尾指针,使得在入队的时候可以在O(1)的时间内完成;但是对于出队操作,在删除队头元素之后,必须将数组中的所有元素都往前移动一个位置,这个操作的复杂度达到了O(n),效果并不是很好。如下图所示:
为了解决这个问题,我们可以使用另外一种逻辑结构来处理数组中各个位置之间的关系。假设现在我们有一个数组A[1…n],我们可以把它想象成一个环型结构
,即A[n]之后是A[1],相信了解过一致性Hash算法的童鞋应该很容易能够理解。如下图所示:我们可以使用两个指针,分别维护队头和队尾两个位置,使入队和出队操作都可以在O(1)的时间内完成。当然,这个环形结构只是逻辑上的结构,实际的物理结构还是一个普通的数据。
因此ArrayBlockingQueue的实现是一个循环数组,使用takeIndex和putIndex来控制元素的出入队列,效率高。
讲完ArrayBlockingQueue的数据结构,接下来我们从源码层面看看它是如何实现阻塞的。
3、源码分析
3.1、属性
JDK1.8
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
final Object[] items; //队列的底层结构
int takeIndex; //队头指针
int putIndex; //队尾指针
int count; //队列中的元素个数
final ReentrantLock lock;
//并发时的两种状态
private final Condition notEmpty;
private final Condition notFull;
}
items是一个数组,用来存放入队的数据,count表示队列中元素的个数。takeIndex
和putIndex
分别代表队头和队尾指针。
说明:Lock
的作用是提供独占锁机制,来保护竞争的资源;而Condition
是为了更精细的对锁进行控制,但是依赖于lock,通过某个条件对多线程进行控制。
-
notEmpty
表示"锁的非空条件"。当某线程想从队列中获取数据的时候,而此时队列中的数据为空,则该线程通过notEmpty.await()方法进行等待;当其他线程向队列中插入元素之后,就调用notEmpty.signal()方法进行唤醒之前等待的线程。 -
同理,
notFull
表示“锁满的条件“。当某个线程向队列中插入元素,而此时队列已满时,该线程等待,即阻塞通过notFull.wait()方法;其他线程从队列中取出元素之后,就唤醒该等待的线程,这个线程调用notFull.signal()方法。
3.2、构造函数
public class ArrayBlockingQueue {
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
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();
}
public ArrayBlockingQueue(int capacity, boolean fair,
Collection<? extends E> c) {
this(capacity, fair);
final ReentrantLock lock = this.lock;
lock.lock(); // Lock only for visibility, not mutual exclusion
try {
int i = 0;
try {
for (E e : c) {
checkNotNull(e);
items[i++] = e;
}
} catch (ArrayIndexOutOfBoundsException ex) {
throw new IllegalArgumentException();
}
count = i;
putIndex = (i == capacity) ? 0 : i;
} finally {
lock.unlock();
}
}
-
第一个构造函数只需要制定队列大小,
默认为非公平锁
。 -
第二个构造函数可以
手动制定公平性
和队列大小。 -
第三个构造函数里面使用了ReentrantLock来加锁,然后把传入的集合元素按顺序一个个放入items中。这里
加锁目的不是使用它的互斥性,而是让items中的元素对其他线程可见
(用的是AQS里的state的volatile可见性)。
3.3、方法
3.3.1、入队方法
ArrayBlockingQueue 提供了多种入队操作的实现来满足不同情况下的需求,入队操作有如下几种:
- boolean add(E e);
- void put(E e); //阻塞,其余非阻塞
- boolean offer(E e);
- boolean offer(E e, long timeout, TimeUnit unit)。
add(E e)
public boolean add(E e) { // ArrayBlockingQueue.java
return super.add(e);
}
}
//super.add(e)
public boolean add(E e) { // AbstractQueue.java
if (offer(e)) //复用offer方法
return true;
else
throw new IllegalStateException("Queue full"); //抛出异常
}
可以看到add
方法调用的是父类,也就是AbstractQueue
的add
方法,它实际上调用的就是offer
方法,并进行封装,针对返回值false情况抛出异常。
offer(E e)
我们接着上面的add方法来看offer方法:
public boolean offer(E e) { //ArrayBlockingQueue.java
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count == items.length) //如果相等,则说明队列满
return false;
else {
enqueue(e);
return true;
}
} finally {
lock.unlock();
}
}
offer方法在队列满了的时候返回false,否则调用enqueue方法插入元素,并返回true。
private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x; //存放当前元素
// 圆环的index操作
if (++putIndex == items.length)
putIndex = 0; //putIndex 标记队尾,下一个元素可以存放的位置
count++; //数组内实际元素个数+1,count用来判断满队列或空队列
notEmpty.signal(); //唤醒等待获取元素的线程
}
enqueue
方法首先把元素放在items的putIndex
位置,接着判断在putIndex+1
等于队列的长度时把putIndex
设置为0
,也就是上面提到的圆环
的index操作。最后唤醒
等待获取元素的线程。
从
enqueue
方法,可以得知采用了圆环操作
,可以与后面的dequeue
方法对比来看。圆环操作的精髓就是当添加元素到队列最后一个位置时,重新从队列头开始循环,通过内部的putIndex
指针实现。
offer(E e, long timeout, TimeUnit unit)
offer(E e, long timeout, TimeUnit unit)方法只是在offer(E e)的基础上增加了超时时间的概念。
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
checkNotNull(e);
// 把超时时间转换成纳秒
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
// 获取一个可中断的互斥锁
lock.lockInterruptibly();
try {
// while循环的目的是防止在中断后没有到达传入的timeout时间,继续重试
while (count == items.length) {
if (nanos <= 0)
return false; //超时
// 等待nanos纳秒,返回剩余的等待时间(可被中断)
nanos = notFull.awaitNanos(nanos);
}
enqueue(e); //没有超时,继续插入数据
return true;
} finally {
lock.unlock();
}
}
该方法利用了Condition
的awaitNanos
方法,等待指定时间,因为该方法可中断,所以这里利用while循环来处理中断后还有剩余时间的问题,等待时间到了以后调用enqueue方法放入队列。
Condition
的awaitNanos
方法返回值是被唤醒后剩余的时间
,比如我预期等待1000ms,然后等待了200ms,那么返回值是800,说明没有超时;如果返回值<=0,说明超时了。
put(E e)
阻塞,直到队列有空位产生
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方法在count等于items长度时,一直等待,直到被其他线程唤醒。唤醒后调用enqueue方法放入队列。
3.3.2、出队方法
入队列的方法说完后,我们来说说出队列的方法。ArrayBlockingQueue提供了多种出队操作的实现来满足不同情况下的需求,如下:
- E poll();
- E poll(long timeout, TimeUnit unit);
- E take()。
poll()
public E poll() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
return (count == 0) ? null : dequeue(); //队列为空,返回null
} finally {
lock.unlock();
}
}
poll
方法是非阻塞方法,如果队列没有元素返回null
,否则调用dequeue
把队首的元素出队列。
private E dequeue() {
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0; //圆环原理,循环
count--; //队列中的实际元素个数-1
if (itrs != null)
itrs.elementDequeued();
notFull.signal();
return x;
}
dequeue
会根据takeIndex
获取到该位置的元素,并把该位置置为null,接着利用圆环原理,在takeIndex到达列表长度时设置为0,最后唤醒等待元素放入队列的线程。
poll(long timeout, TimeUnit unit)
该方法是poll()
的可配置超时等待方法,和上面的offer
一样,使用while
循环和Condition
的awaitNanos
来进行等待,等待时间到后执行dequeue
获取元素。
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0) { //超时策略
if (nanos <= 0)
return null;
nanos = notEmpty.awaitNanos(nanos);
}
return dequeue();
} finally {
lock.unlock();
}
}
take()
队列为空就阻塞
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await(); //阻塞
return dequeue();
} finally {
lock.unlock();
}
}
3.3.3、获取元素方法
peek()
查询元素,不会从队列中删除。
public E peek() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
return itemAt(takeIndex); // null when queue is empty
} finally {
lock.unlock();
}
}
final E itemAt(int i) {
return (E) items[i];
}
这里获取元素时上锁是为了避免脏数据的产生。
3.3.4、删除元素方法
remove(Object o)
删除指定对象,注意与 remove()无参的区别,后者是删除队首的元素。
我们可以想象一下,队列中删除某一个元素时,是不是要遍历整个数据找到该元素,并把该元素后的所有元素往前移一位?队列也一样, 只不过遍历的时候,起始点和结束点有区别。
该方法的时间复杂度为O(n)。
public boolean remove(Object o) {
if (o == null) return false;
final Object[] items = this.items;
final ReentrantLock lock = this.lock;
lock.lock();
try {
if (count > 0) {
final int putIndex = this.putIndex;
int i = takeIndex; //下标
// 从takeIndex一直遍历到putIndex,直到找到和元素o相同的元素,调用removeAt进行删除
do {
if (o.equals(items[i])) {
removeAt(i);
return true;
}
if (++i == items.length)
i = 0; // 环形结构,队尾需要重置到队头
} while (i != putIndex);
}
return false;
} finally {
lock.unlock();
}
}
remove
方法比较简单,它从takeIndex
一直遍历到putIndex
,直到找到和元素o相同的元素,调用removeAt
进行删除。我们重点来看一下removeAt方法。
void removeAt(final int removeIndex) {
final Object[] items = this.items;
if (removeIndex == takeIndex) { //刚好是队尾元素,直接删,不需要移位
// removing front item; just advance
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
} else {
// an "interior" remove
// slide over all others up through putIndex.
final int putIndex = this.putIndex; //队尾,需要把待删除元素后至队尾的所有元素前移
for (int i = removeIndex;;) {
int next = i + 1;
if (next == items.length)
next = 0; //环形原理,需要找到队头
if (next != putIndex) {
items[i] = items[next]; //非队尾元素,前移一位
i = next;
} else {
items[i] = null; //原来队尾元素置空,并标记为下次插入元素的下标
this.putIndex = i;
break;
}
}
count--;
if (itrs != null)
itrs.removedAt(removeIndex);
}
notFull.signal();
}
removeAt的处理方式和我想的稍微有一点出入,它内部分为两种情况来考虑
- removeIndex == takeIndex
- removeIndex != takeIndex
也就是我考虑的时候没有考虑边界问题。当removeIndex == takeIndex时就不需要后面的元素整体往前移了,而只需要把takeIndex的指向下一个元素即可(还记得前面说的ArrayBlockingQueue可以类比为圆环吗)。
当removeIndex != takeIndex时,通过putIndex将removeIndex后的元素往前移一位。
4、总结
ArrayBlockingQueue
是一个阻塞队列,内部由ReentrantLock
来实现线程安全,由Condition
的await
和signal
来实现等待唤醒的功能。
它的数据结构是数组,准确的说是一个循环数组(可以类比一个圆环),所有的下标在到达最大长度时自动从0继续开始。
LinkedBlockingQueue
1、简介
上篇我们介绍了ArrayBlockingQueue
的相关方法的原理,这一篇我们来学习一下ArrayBlockingQueue的“亲戚” LinkedBlockingQueue
。在集合框架里,想必大家都用过ArrayList和LinkedList,也经常在面试中问到他们之间的区别。ArrayList
和ArrayBlockingQueue
一样,内部基于数组来存放元素,而LinkedBlockingQueue
则和LinkedList
一样,内部基于链表来存放元素。
LinkedBlockingQueue实现了BlockingQueue接口,这里放一张类的继承关系图:LinkedBlockingQueue
不同于ArrayBlockingQueue
,它如果不指定容量,默认为Integer.MAX_VALUE
,也就是无界队列。所以为了避免队列过大造成机器负载或者内存爆满的情况出现,我们在使用的时候建议手动传一个队列的大小。
Integer.MAX_VALUE够大的了,值为2 的 31 次方 - 1 = 2147483648 - 1 = 2147483647
2、源码分析
2.1、属性
JDK1.8
/**
* 节点类,用于存储数据
*/
static class Node<E> {
E item;
Node<E> next;
Node(E x) { item = x; }
}
/** 阻塞队列的大小,默认为Integer.MAX_VALUE */
private final int capacity;
/** 当前阻塞队列中的元素个数 */
private final AtomicInteger count = new AtomicInteger();
/**
* 阻塞队列的头结点
*/
transient Node<E> head;
/**
* 阻塞队列的尾节点
*/
private transient Node<E> last;
/** 获取并移除元素时使用的锁,如take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** notEmpty条件对象,当队列没有数据时用于挂起执行删除的线程 */
private final Condition notEmpty = takeLock.newCondition();
/** 添加元素时使用的锁如 put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** notFull条件对象,当队列数据已满时用于挂起执行添加的线程 */
private final Condition notFull = putLock.newCondition();
从上面的属性我们知道,每个添加到LinkedBlockingQueue
队列中的数据都将被封装成Node
节点,添加的链表队列中,其中head
和last
分别指向队列的头结点
和尾结点
。与ArrayBlockingQueue
不同的是,LinkedBlockingQueue
内部分别使用了takeLock
和 putLock
对并发进行控制,也就是说,添加和删除操作并不是互斥操作,可以同时进行,这样也就可以大大提高吞吐量。
这里如果不指定队列的容量大小,也就是使用默认的Integer.MAX_VALUE
,如果存在添加速度大于删除速度时候,有可能会内存溢出
,这点在使用前希望慎重考虑。
另外,LinkedBlockingQueue对每一个lock
锁都提供了一个Condition
用来挂起和唤醒其他线程。
2.2、构造函数
public LinkedBlockingQueue() {
// 默认大小为Integer.MAX_VALUE
this(Integer.MAX_VALUE);
}
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}
public LinkedBlockingQueue(Collection<? extends E> c) { //利用给定的集合构造
this(Integer.MAX_VALUE);
final ReentrantLock putLock = this.putLock;
putLock.lock(); //Never contended, but necessary for visibility
try {
int n = 0;
for (E e : c) {
if (e == null)
throw new NullPointerException();
if (n == capacity)
throw new IllegalStateException("Queue full");
enqueue(new Node<E>(e));
++n;
}
count.set(n);
} finally {
putLock.unlock();
}
}
默认的构造函数和最后一个构造函数创建的队列大小都为Integer.MAX_VALUE
,只有第二个构造函数用户可以指定队列的大小。第二个构造函数最后初始化了last和head节点,让它们都指向了一个元素为null的节点。
最后一个构造函数使用了putLock来进行加锁,但是这里并不是为了多线程的竞争而加锁,只是为了放入的元素能立即对其他线程可见。
2.3、方法
同样,LinkedBlockingQueue也有着和ArrayBlockingQueue一样的方法,我们先来看看入队列的方法。
2.3.1、入队方法
LinkedBlockingQueue提供了多种入队操作的实现来满足不同情况下的需求,入队操作有如下几种:
void put(E e);
boolean offer(E e);
boolean offer(E e, long timeout, TimeUnit unit)。
put(E e)
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();
}
// 把node放入队列中
enqueue(node);
c = count.getAndIncrement(); //元素个数计数器+1
// 再次判断队列是否有可用空间,如果有唤醒下一个线程进行添加操作
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
// 如果队列中有一条数据,唤醒消费线程进行消费
if (c == 0)
signalNotEmpty();
}
小结put方法来看,它总共做了以下情况的考虑:
- 队列已满,阻塞等待。
- 队列未满,创建一个node节点放入队列中,如果放完以后队列还有剩余空间,继续唤醒下一个添加线程进行添加。如果放之前队列中没有元素,放完以后要唤醒消费线程进行消费。
很清晰明了是不是?
我们来看看该方法中用到的几个其他方法,先来看看enqueue(Node node)方法:
private void enqueue(Node<E> node) {
last = last.next = node;
}
我们在构造一个链表时,内部会先初始化一个空的node节点,并赋值给head和last
last = head = new Node<E>(null); //值为null,空节点
接下来我们看看signalNotEmpty
,顺带着看signalNotFull
方法。
private void signalNotEmpty() {
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
private void signalNotFull() {
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
notFull.signal();
} finally {
putLock.unlock();
}
}
为什么要这么写?因为signal的时候要获取到该signal对应的Condition对象的锁才行。
offer(E e)
public boolean offer(E e) {
if (e == null) throw new NullPointerException();
final AtomicInteger count = this.count;
if (count.get() == capacity)
return false; //队列满,直接返回false
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
// 队列有可用空间,放入node节点,判断放入元素后是否还有可用空间,
// 如果有,唤醒下一个添加线程进行添加操作。
if (count.get() < capacity) {
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
}
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
return c >= 0;
}
可以看到offer仅仅对put方法改动了一点
点,当队列满的时候,不同于put方法的阻塞等待,offer方法直接方法false。
offer(E e, long timeout, TimeUnit unit)
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
if (e == null) throw new NullPointerException();
long nanos = unit.toNanos(timeout);
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
// 等待超时时间nanos,超时时间到了返回false
while (count.get() == capacity) {
if (nanos <= 0)
return false;
nanos = notFull.awaitNanos(nanos);
}
enqueue(new Node<E>(e));
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
return true;
}
该方法只是对offer方法进行了阻塞超时处理,使用了Condition的awaitNanos来进行超时等待,这里为什么要用while循环?因为awaitNanos方法是可中断的,为了防止在等待过程中线程被中断,这里使用while循环进行等待过程中中断的处理,继续等待剩下需等待的时间。
2.3.2、出队方法
入队列的方法说完后,我们来说说出队列的方法。LinkedBlockingQueue提供了多种出队操作的实现来满足不同情况下的需求,如下:
E take();
E poll();
E poll(long timeout, TimeUnit unit);
take()
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();
}
x = dequeue();
c = count.getAndDecrement(); //元素计数器-1
// 队列中还有元素,唤醒下一个消费线程进行消费
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 移除元素之前队列是满的,唤醒生产线程进行添加元素
if (c == capacity)
signalNotFull();
return x;
}
take方法看起来就是put方法的逆向操作,它总共做了以下情况的考虑:
队列为空,阻塞等待。
队列不为空,从队首获取并移除一个元素,如果消费后还有元素在队列中,继续唤醒下一个消费线程进行元素移除。如果放之前队列是满元素的情况,移除完后要唤醒生产线程进行添加元素。
我们来看看dequeue
方法
private E dequeue() {
// 获取到head节点
Node<E> h = head;
// 获取到head节点指向的下一个节点
Node<E> first = h.next;
// head节点原来指向的节点的next指向自己,等待下次gc回收
h.next = h; // help GC
// head节点指向新的节点
head = first;
// 获取到新的head节点的item值
E x = first.item;
// 新head节点的item值设置为null
first.item = null;
return x;
}
可能有些同学链表算法不是很熟悉,我们可以结合注释和图来看就清晰很多了。
其实这个写法看起来很绕,我们其实也可以这么写:
private E dequeue() {
// 获取到head节点
Node<E> h = head;
// 获取到head节点指向的下一个节点,也就是节点A
Node<E> first = h.next;
// 获取到下下个节点,也就是节点B
Node<E> next = first.next;
// head的next指向下下个节点,也就是图中的B节点
h.next = next;
// 得到节点A的值
E x = first.item;
first.item = null; // help GC
first.next = first; // help GC
return x;
}
poll()
public E poll() {
final AtomicInteger count = this.count;
if (count.get() == 0)
return null;
E x = null;
int c = -1;
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
if (count.get() > 0) {
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
}
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
poll方法去除了take方法中元素为空后阻塞等待这一步骤,这里也就不详细说了。同理,poll(long timeout, TimeUnit unit)也和offer(E e, long timeout, TimeUnit unit)一样,利用了Condition的awaitNanos方法来进行阻塞等待直至超时。这里就不列出来说了。
2.3.3、获取元素方法
public E peek() {
if (count.get() == 0)
return null;
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
Node<E> first = head.next;
if (first == null)
return null;
else
return first.item;
} finally {
takeLock.unlock();
}
}
加锁后,获取到head节点的next节点,如果为空返回null,如果不为空,返回next节点的item值。
2.3.4、删除元素方法
public boolean remove(Object o) {
if (o == null) return false;
// 两个lock全部上锁
fullyLock();
try {
// 从head开始遍历元素,直到最后一个元素
for (Node<E> trail = head, p = trail.next;
p != null;
trail = p, p = p.next) {
// 如果找到相等的元素,调用unlink方法删除元素
if (o.equals(p.item)) {
unlink(p, trail);
return true;
}
}
return false;
} finally {
// 两个lock全部解锁
fullyUnlock();
}
}
void fullyLock() {
putLock.lock();
takeLock.lock();
}
void fullyUnlock() {
takeLock.unlock();
putLock.unlock();
}
因为remove方法使用两个锁全部上锁,所以其他操作都需要等待它完成,而该方法需要从head节点遍历到尾节点,所以时间复杂度为O(n)。我们来看看unlink方法。
void unlink(Node<E> p, Node<E> trail) {
// p的元素置为null
p.item = null;
// p的前一个节点的next指向p的next,也就是把p从链表中去除了
trail.next = p.next;
// 如果last指向p,删除p后让last指向trail
if (last == p)
last = trail;
// 如果删除之前元素是满的,删除之后就有空间了,唤醒生产线程放入元素
if (count.getAndDecrement() == capacity)
notFull.signal();
}
3、问题
看源码的时候,我给自己抛出了一个问题。
- 为什么dequeue里的h.next不指向null,而指向h?
- 为什么unlink里没有p.next = null或者p.next = p这样的操作?
这个疑问一直困扰着我,直到我看了迭代器的部分源码后才豁然开朗,下面放出部分迭代器的源码:
private Node<E> current;
private Node<E> lastRet;
private E currentElement;
Itr() {
fullyLock();
try {
current = head.next;
if (current != null)
currentElement = current.item;
} finally {
fullyUnlock();
}
}
private Node<E> nextNode(Node<E> p) {
for (;;) {
// 解决了问题1
Node<E> s = p.next;
if (s == p)
return head.next;
if (s == null || s.item != null)
return s;
p = s;
}
}
迭代器的遍历分为两步,第一步加双锁把元素放入临时变量中,第二部遍历临时变量的元素。也就是说remove可能和迭代元素同时进行,很有可能remove的时候,有线程在进行迭代操作,而如果unlink中改变了p的next,很有可能在迭代的时候会造成错误,造成不一致问题。这个解决了问题2。
而问题1其实在nextNode方法中也能找到,为了正确遍历,nextNode使用了 s == p的判断,当下一个元素是自己本身时,返回head的下一个节点。
4、总结
LinkedBlockingQueue是一个阻塞队列,内部由两个ReentrantLock来实现出入队列的线程安全,由各自的Condition对象的await和signal来实现等待和唤醒功能。它和ArrayBlockingQueue的不同点在于:
-
队列大小有所不同
ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。 -
数据存储容器不同
ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。
-
两者的实现队列添加或移除的锁不一样
ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
DelayQueue
前言
在深入之前先了解下下ReentrantLock 和 Condition:
重入锁ReentrantLock:
ReentrantLock锁在同一个时间点只能被一个线程锁持有;而可重入的意思是,ReentrantLock锁,可以被单个线程多次获取。
ReentrantLock分为“公平锁”和“非公平锁”。它们的区别体现在获取锁的机制上是否公平。“锁”是为了保护竞争资源,防止多个线程同时操作线程而出错,ReentrantLock在同一个时间点只能被一个线程获取(当某线程获取到“锁”时,其它线程就必须等待);ReentraantLock是通过一个FIFO的等待队列来管理获取该锁所有线程的。在“公平锁”的机制下,线程依次排队获取锁;而“非公平锁”在锁是可获取状态时,不管自己是不是在队列的开头都会获取锁。
主要方法:
- lock()获得锁
- lockInterruptibly()获得锁,但优先响应中断
- tryLock()尝试获得锁,成功返回true,否则false,该方法不等待,立即返回
- tryLock(long time,TimeUnit unit)在给定时间内尝试获得锁
- unlock()释放锁
Condition:await()、signal()方法分别对应之前的Object的wait()和notify()
- 和重入锁一起使用
- await()是当前线程等待
- awaitUninterruptibly()不会在等待过程中响应中断
- signal()用于唤醒一个在等待的线程,还有对应的singalAll()方法
一、阻塞队列
阻塞队列(BlockingQueue)与我们平常接触的普通队列(LinkedList或ArrayList等)的最大不同点,在于阻塞队列支出阻塞添加和阻塞获取方法。
- 阻塞添加
所谓的阻塞添加是指当阻塞队列元素已满时,队列会阻塞加入元素的线程,直队列元素不满时才重新唤醒线程执行元素加入操作。
- 阻塞获取
阻塞获取是指在队列元素为空时,获取队列元素的线程将被阻塞,直到队列不为空再执行获取操作(一般都会返回被获取的元素)
阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。
由于Java中的阻塞队列接口BlockingQueue继承自Queue接口,因此先来看看阻塞队列接口为我们提供的主要方法
public interface BlockingQueue<E> extends Queue<E> {
//将指定的元素插入到此队列的尾部(如果立即可行且不会超过该队列的容量)
//在成功时返回 true,如果此队列已满,则抛IllegalStateException。
boolean add(E e);
//将指定的元素插入到此队列的尾部(如果立即可行且不会超过该队列的容量)
// 将指定的元素插入此队列的尾部,如果该队列已满,
//则在到达指定的等待时间之前等待可用的空间,该方法可中断
boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException;
//将指定的元素插入此队列的尾部,如果该队列已满,则一直等到(阻塞)。
void put(E e) throws InterruptedException;
//获取并移除此队列的头部,如果没有元素则等待(阻塞),
//直到有元素将唤醒等待线程执行该操作
E take() throws InterruptedException;
//获取并移除此队列的头部,在指定的等待时间前一直等到获取元素, //超过时间方法将结束
E poll(long timeout, TimeUnit unit) throws InterruptedException;
//从此队列中移除指定元素的单个实例(如果存在)。
boolean remove(Object o);
//除了上述方法还有继承自Queue接口的方法
//获取但不移除此队列的头元素,没有则跑异常NoSuchElementException
E element();
//获取但不移除此队列的头;如果此队列为空,则返回 null。
E peek();
//获取并移除此队列的头,如果此队列为空,则返回 null。
E poll();
}
这里我们把上述操作进行分类
插入方法:
- add(E e) : 添加成功返回true,失败抛IllegalStateException异常
- offer(E e) : 成功返回 true,如果此队列已满,则返回 false。
- put(E e) :将元素插入此队列的尾部,如果该队列已满,则一直阻塞
删除方法:
- remove(Object o) :移除指定元素,成功返回true,失败返回false
- poll() : 获取并移除此队列的头元素,若队列为空,则返回 null
- take():获取并移除此队列头元素,若没有元素则一直阻塞。
检查方法
- element() :获取但不移除此队列的头元素,没有元素则抛异常
- peek() :获取但不移除此队列的头;若队列为空,则返回 null
阻塞队列的对元素的增删查操作主要就是上述的三类方法,通常情况下我们都是通过这3类方法操作阻塞队列
二、阻塞队列的成员
接下来重点介绍下:DelayQueue
三、DelayQueue
DelayQueue是一个没有边界BlockingQueue实现,加入其中的元素必需实现Delayed接口。当生产者线程调用put之类的方法加入元素时,会触发Delayed接口中的compareTo方法进行排序,也就是说队列中元素的顺序是按到期时间排序的,而非它们进入队列的顺序。排在队列头部的元素是最早到期的,越往后到期时间赿晚。
消费者线程查看队列头部的元素,注意是查看不是取出。然后调用元素的getDelay方法,如果此方法返回的值小0或者等于0,则消费者线程会从队列中取出此元素,并进行处理。如果getDelay方法返回的值大于0,则消费者线程wait返回的时间值后,再从队列头部取出元素,此时元素应该已经到期。
DelayQueue是Leader-Followr模式的变种,消费者线程处于等待状态时,总是等待最先到期的元素,而不是长时间的等待。消费者线程尽量把时间花在处理任务上,最小化空等的时间,以提高线程的利用效率。
以下通过队列及消费者线程状态变化大致说明一下DelayQueue的运行过程。
初始状态
因为队列是没有边界的,向队列中添加元素的线程不会阻塞,添加操作相对简单,所以此图不考虑向队列添加元素的生产者线程。假设现在共有三个消费者线程。
队列中的元素按到期时间排序,队列头部的元素2s以后到期。消费者线程1查看了头部元素以后,发现还需要2s才到期,于是它进入等待状态,2s以后醒来,等待头部元素到期的线程称为Leader线程。
消费者线程2与消费者线程3处于待命状态,它们不等待队列中的非头部元素。当消费者线程1拿到对象5以后,会向它们发送signal。这个时候两个中的一个会结束待命状态而进入等待状态。
消费者线程1已经拿到了对象5,从等待状态进入处理状态,处理它取到的对象5,同时向消费者线程2与消费者线程3发送signal。
消费者线程2与消费者线程3会争抢领导权,这里是消费者线程2进入等待状态,成为Leader线程,等待2s以后对象4到期。而消费者线程3则继续处于待命状态。
此时队列中加入了一个新元素对象6,它10s后到期,排在队尾。
又经过2S以后
先看线程1,如果它已经结束了对象5的处理,则进入待命状态。如果还没有结束,则它继续处理对象5。
消费线程2取到对象4以后,也进入处理状态,同时给处于待命状态的消费线程3发送信号,消费线程3进入等待状态,成为新的Leader。现在头部元素是新插入的对象7,因为它1s以后就过期,要早于其它所有元素,所以排到了队列头部。
又经过1S后
一种不好的结果:
消费线程3一定正在处理对象7。消费线程1与消费线程2还没有处理完它们各自取得的对象,无法进入待命状态,也更加进入不了等待状态。此时对象3马上要到期,那么如果它到期时没有消费者线程空下来,则它的处理一定会延期。
可以想见,如果元素进入队列的速度很快,元素之间的到期时间相对集中,而处理每个到期元素的速度又比较慢的话,则队列会越来越大,队列后边的元素延期处理的时间会越来越长。
另外一种好的结果:
消费线程1与消费线程2很快的完成对取出对象的处理,及时返回重新等待队列中的到期元素。一个处于等待状态(Leader),对象3一到期就立刻处理。另一个则处于待命状态。这样,每一个对象都能在到期时被及时处理,不会发生明显的延期。
所以,消费者线程的数量要够,处理任务的速度要快。否则,队列中的到期元素无法被及时取出并处理,造成任务延期、队列元素堆积等情况。
示例代码
DelayQueue的一个应用场景是定时任务调度。本例中先让主线程向DelayQueue添加10个任务,任务之间的启动间隔在1~2s之间,每个任务的执行时间固定为2s,代码如下:
package com.zhangdb.thread;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
class DelayTask implements Delayed {
private static long currentTime = System.currentTimeMillis();
protected final String taskName;
protected final int timeCost;
protected final long scheduleTime;
protected static final AtomicInteger taskCount = new AtomicInteger(0);
// 定时任务之间的启动时间间隔在1~2s之间,timeCost表示处理此任务需要的时间,本示例中为2s
public DelayTask(String taskName, int timeCost) {
this.taskName = taskName;
this.timeCost = timeCost;
taskCount.incrementAndGet();
currentTime += 1000 + (long) (Math.random() * 1000);
scheduleTime = currentTime;
}
@Override
public int compareTo(Delayed o) {
return (int) (this.scheduleTime - ((DelayTask) o).scheduleTime);
}
@Override
public long getDelay(TimeUnit unit) {
long expirationTime = scheduleTime - System.currentTimeMillis();
return unit.convert(expirationTime, TimeUnit.MILLISECONDS);
}
public void execTask() {
long startTime = System.currentTimeMillis();
System.out.println("Task " + taskName + ": schedule_start_time=" + scheduleTime + ",real start time="
+ startTime + ",delay=" + (startTime - scheduleTime));
try {
Thread.sleep(timeCost);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class DelayTaskComsumer extends Thread {
private final BlockingQueue<DelayTask> queue;
public DelayTaskComsumer(BlockingQueue<DelayTask> queue) {
this.queue = queue;
}
@Override
public void run() {
DelayTask task = null;
try {
while (true) {
task = queue.take();
task.execTask();
DelayTask.taskCount.decrementAndGet();
}
} catch (InterruptedException e) {
System.out.println(getName() + " finished");
}
}
}
public class DelayQueueExample {
public static void main(String[] args) {
BlockingQueue<DelayTask> queue = new DelayQueue<DelayTask>();
for (int i = 0; i < 10; i++) {
try {
queue.put(new DelayTask("work " + i, 2000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
ThreadGroup g = new ThreadGroup("Consumers");
for (int i = 0; i < 1; i++) {
new Thread(g, new DelayTaskComsumer(queue)).start();
}
while (DelayTask.taskCount.get() > 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
g.interrupt();
System.out.println("Main thread finished");
}
}
首先启动一个消费者线程。因为消费者线程处单个任务的时间为2s,而任务的调度间隔为1~2s。这种情况下,每当消费者线程处理完一个任务,回头再从队列中新取任务时,新任务肯定延期了,无法按给定的时间调度任务。而且越往后情况越严重。运行代码看一下输出:
Task work 0: schedule_start_time=1554203579096,real start time=1554203579100,delay=4
Task work 1: schedule_start_time=1554203580931,real start time=1554203581101,delay=170
Task work 2: schedule_start_time=1554203582884,real start time=1554203583101,delay=217
Task work 3: schedule_start_time=1554203584660,real start time=1554203585101,delay=441
Task work 4: schedule_start_time=1554203586075,real start time=1554203587101,delay=1026
Task work 5: schedule_start_time=1554203587956,real start time=1554203589102,delay=1146
Task work 6: schedule_start_time=1554203589041,real start time=1554203591102,delay=2061
Task work 7: schedule_start_time=1554203590127,real start time=1554203593102,delay=2975
Task work 8: schedule_start_time=1554203591903,real start time=1554203595102,delay=3199
Task work 9: schedule_start_time=1554203593577,real start time=1554203597102,delay=3525
Main thread finished
Thread-0 finished
最后一个任务的延迟时间已经超过3.5s了。
再作一次测试,将消费者线程的个数调整为2,这时任务应该能按时启动,延迟应该很小,运行程序看一下结果:
Task work 0: schedule_start_time=1554204395427,real start time=1554204395430,delay=3
Task work 1: schedule_start_time=1554204396849,real start time=1554204396850,delay=1
Task work 2: schedule_start_time=1554204398050,real start time=1554204398051,delay=1
Task work 3: schedule_start_time=1554204399590,real start time=1554204399590,delay=0
Task work 4: schedule_start_time=1554204401289,real start time=1554204401289,delay=0
Task work 5: schedule_start_time=1554204402883,real start time=1554204402883,delay=0
Task work 6: schedule_start_time=1554204404663,real start time=1554204404664,delay=1
Task work 7: schedule_start_time=1554204406154,real start time=1554204406154,delay=0
Task work 8: schedule_start_time=1554204407991,real start time=1554204407991,delay=0
Task work 9: schedule_start_time=1554204409540,real start time=1554204409540,delay=0
Main thread finished
Thread-0 finished
Thread-2 finished
基本上按时启动,最大延迟为3毫秒,大部分都是0毫秒。
将消费者线程个数调整成3个,运行看一下结果:
Task work 0: schedule_start_time=1554204499695,real start time=1554204499698,delay=3
Task work 1: schedule_start_time=1554204501375,real start time=1554204501376,delay=1
Task work 2: schedule_start_time=1554204503370,real start time=1554204503371,delay=1
Task work 3: schedule_start_time=1554204504860,real start time=1554204504861,delay=1
Task work 4: schedule_start_time=1554204506419,real start time=1554204506420,delay=1
Task work 5: schedule_start_time=1554204508191,real start time=1554204508192,delay=1
Task work 6: schedule_start_time=1554204509495,real start time=1554204509496,delay=1
Task work 7: schedule_start_time=1554204510663,real start time=1554204510664,delay=1
Task work 8: schedule_start_time=1554204512598,real start time=1554204512598,delay=0
Task work 9: schedule_start_time=1554204514276,real start time=1554204514277,delay=1
Main thread finished
Thread-0 finished
Thread-2 finished
Thread-4 finished
大部分延迟时间变成1毫秒,情况好像还不如2个线程的情况。
将消费者线程数调整成5,运行看一下结果:
Task work 0: schedule_start_time=1554204635015,real start time=1554204635019,delay=4
Task work 1: schedule_start_time=1554204636856,real start time=1554204636857,delay=1
Task work 2: schedule_start_time=1554204637968,real start time=1554204637970,delay=2
Task work 3: schedule_start_time=1554204639758,real start time=1554204639759,delay=1
Task work 4: schedule_start_time=1554204641089,real start time=1554204641090,delay=1
Task work 5: schedule_start_time=1554204642879,real start time=1554204642880,delay=1
Task work 6: schedule_start_time=1554204643941,real start time=1554204643942,delay=1
Task work 7: schedule_start_time=1554204645006,real start time=1554204645007,delay=1
Task work 8: schedule_start_time=1554204646309,real start time=1554204646310,delay=1
Task work 9: schedule_start_time=1554204647537,real start time=1554204647538,delay=1
Thread-2 finished
Thread-0 finished
Main thread finished
Thread-8 finished
Thread-4 finished
Thread-6 finished
与3个消费者线程的情况差不多。
结论
最优的消费者线程的个数与任务启动的时间间隔好像存在这样的关系:单个任务处理时间的最大值 / 相邻任务的启动时间最小间隔 = 最优线程数,如果最优线程数是小数,则取整数后加1,比如1.3的话,那么最优线程数应该是2。
本例中,单个任务处理时间的最大值固定为2s。
相邻任务的启动时间最小间隔为1s。
则消费者线程数为2/1=2。
如果消费者线程数小于此值,则来不及处理到期的任务。如果大于此值,线程太多,在调度、同步上花更多的时间,无益改善性能。
ConcurrentLinkedQueue
ConcurrentLinkedQueue是一个线程安全的队列,基于链表结构实现,是一个无界队列,理论上来说队列的长度可以无限扩大。
与其他队列相同,ConcurrentLinkedQueue 也采用的是先进先出(FIFO)入队规则,对元素进行排序。当我们向队列中添加元素时,新插入的元素会插入到队列的尾部;而当我们获取一个元素时,它会从队列的头部中取出。
因为 ConcurrentLinkedQueue 是链表结构,所以当入队时,插入的元素依次向后延伸,形成链表;而出队时,则从链表的第一个元素开始获取,依次递增。
ConcurrentLinkedQueue使用特点
- 不允许null入列
- 在入队的最后一个元素的next为null
- 队列中所有未删除的节点的item都不能为null且都能从head节点遍历到
- 删除节点是将item设置为null, 队列迭代时跳过item为null节点
- head节点跟tail不一定指向头节点或尾节点,可能存在滞后性
之所以有这奇葩约定,全因ConcurrentLinkedQueue是并发非阻塞队列决定的。
数据结构
ConcurrentLinkedQueue
从名称上来看就能够知道,它支持并发、由链表实现的队列
通过源码,我们可以看到**ConcurrentLinkedQueue
使用字段记录首尾节点、并且节点的实现是单向链表。
并且这些关键字段都被volatile修饰,在读场景下使用volatile保证可见性,不用“加锁”
还有一些其他字段,比如使用CAS的Unsafe和一些偏移量信息等,这里就不一一列举
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {
private static class Node<E> {
//记录数据
volatile E item;
//后继节点
volatile Node<E> next;
}
//首节点
private transient volatile Node<E> head;
//尾节点
private transient volatile Node<E> tail;
}
在初始化时,首尾节点会同时指向一个存储数据为空的节点
public ConcurrentLinkedQueue() {
head = tail = new Node<E>(null);
}
Node
Node节点是用于链表数据结构中,构成ConcurrentLinkedQueue的基本单元。节点包含了两个字段:一个是item,用于存储元素数据;另一个是next,用于指向下一个节点,从而实现链表结构。
在ConcurrentLinkedQueue中,由于要支持并发操作,因此使用了volatile关键字对节点的item和next字段进行修饰。volatile关键字可以保证在多线程环境下的可见性,从而避免了出现脏读等线程安全问题。
在ConcurrentLinkedQueue的构造函数中,会初始化头尾节点,同时将head和tail指向同一个初始节点。这个节点的item为空(null),表示队列中还没有任何元素。
通过不断添加和删除节点,就可以实现ConcurrentLinkedQueue,该队列是线程安全的,由于使用了无锁算法(CAS操作),因此具有较高的吞吐量。
操作Node的几个CAS操作
在ConcurrentLinkedQueue中,对Node节点的CAS操作(关于CAS操作可以看这篇文章),有以下几个方法:
casItem(E cmp, E val): 用于比较并交换节点的数据域item。这个操作会比较节点的item域是否等于cmp,如果相等则将其替换为val。返回true表示交换成功,返回false表示交换失败。
lazySetNext(Node<E> val): 用于延迟设置节点的下一个节点指针next。这个操作会将节点的next字段设置为val,但不保证立即可见。即使在执行这个操作之后,其他线程读取到的节点的next值可能仍然是旧值。这种操作通常在性能上比强制可见性要好。
casNext(Node<E> cmp, Node<E> val): 用于比较并交换节点的下一个节点指针next。这个操作会比较节点的next域是否等于cmp,如果相等则将其替换为val。返回true表示交换成功,返回false表示交换失败。
//更改Node中的数据域item
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
//更改Node中的指针域next
void lazySetNext(Node<E> val) {
UNSAFE.putOrderedObject(this, nextOffset, val);
}
//更改Node中的指针域next
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
需要注意的是,这些方法中都是通过调用sun.misc.Unsafe类的相关方法来实现CAS操作。Unsafe类是Java底层提供的一个类,用于支持直接操作内存和并发操作,它提供了一些原子性操作的方法,包括compareAndSwapObject()等方法。这些方法利用处理器指令集的CMPXCHG指令来实现原子性的比较和交换操作。
CAS操作在并发编程中非常重要,它可以避免使用锁机制而实现线程安全,提高并发性能。然而,需要注意的是CAS操作并不是适用于所有情况,有时候可能会存在ABA问题等需要注意的情况。
设计思想
延迟更新首尾节点
在查看实现原理前,我们先来说说ConcurrentLinkedQueue
的设计思想,否则实现原理可能会看不懂
ConcurrentLinkedQueue
写场景中采用乐观锁的思想,使用CAS+失败重试来保证操作的原子性
为了避免CAS开销过大,ConcurrentLinkedQueue
采用延迟更新首尾节点的思想,来减少CAS次数
也就是说ConcurrentLinkedQueue
中的首尾节点并不一定是最新的首尾节点
哨兵节点
ConcurrentLinkedQueue
的设计中使用哨兵节点
什么是哨兵节点?
哨兵节点又称虚拟节点,哨兵节点常使用在链表这种数据结构中
单向链表中如果要添加或者删除某个节点时,一定要获得这个节点的前驱节点再去进行操作
当操作的是第一个节点时,如果在第一个节点前面加个虚拟节点(哨兵节点),那么就不用特殊处理
换而言之使用哨兵节点可以减少代码复杂度,相信刷过链表相关算法的同学深有体会
哨兵节点还能够在只有一个节点时减少并发冲突
这一特点可能要看完后续实现和流程图才能理解
源码实现
ConcurrentLinkedQueue
主要的操作是入队、出队,我们使用offer
和poll
来对其进行分析
offer
在分析源码前,先来说明一些复杂变量的作用
t记录尾节点tail
p用于循环遍历的节点,当p节点为真正尾节点时才允许添加新节点
q 用于记录p的后继节点
在入队时分三种情况:
- 当p的后继节点为空时(p为真正尾节点),尝试CAS增加新节点,成功后尝试更新尾节点tail
- 当p等于p的后继节点时(p的next指向自己,说明构建成哨兵节点,出队poll时可能构造哨兵节点);此时判断尾节点是否被修改过,如果尾节点被修改过就定位到尾节点,如果未被修改过(使用next无法继续遍历),只能定位到头节点
- 其他情况时,说明此时的p并不是真正的尾节点,需要定位到真正尾节点;此时如果p不是原来的尾节点并且尾节点被修改过,那就定位到尾节点,否则定位到后继节点继续遍历
第二、三种情况的代码观赏性很好但是可读性不好,可以将总结的情况与源码分析一起观看,如果还是不理解后续有流程图方便理解
public boolean offer(E e) {
//检查空指针
checkNotNull(e);
//构建新节点
final Node<E> newNode = new Node<E>(e);
//失败重试的循环
//t:当前记录的尾节点
//p:真正的尾节点
//q:p的后继节点
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
//情况1:p的后继节点为空,说明当前p就是真正尾节点
if (q == null) {
//尝试CAS修改p的后继节点为新节点
//如果p的next是null 则替换成新节点newNode
//失败则说明其他线程cas添加节点成功,继续循环;成功则判断是否更新尾节点tail
if (p.casNext(null, newNode)) {
//如果p不等于t 说明此时的尾节点不是真正的尾节点
//尝试CAS:如果当前尾节点是t,那么就将新节点设置成尾节点
if (p != t)
casTail(t, newNode);
return true;
}
}
//情况2:p等于p的后继节点(p指向自己)
else if (p == q)
//t:旧的尾节点
//(t = tail):新的尾节点
//t != (t = tail): 说明尾节点被修改过,p等于新的尾节点;未被修改过,p等于头节点
p = (t != (t = tail)) ? t : head;
//情况3:此时p不是真正尾节点,需要去定位真正尾节点
else
//p!=t:p不再是原来的尾节点
//t != (t = tail):尾节点被修改过
//p不再是原来的尾节点 并且 尾节点被修改过 就让p等于修改过的尾节点;否则让p等于它的后继节点q
p = (p != t && t != (t = tail)) ? t : q;
}
}
poll
如果理解入队offer中的变量,那么出队poll也好理解,其中p和q都是类似的
h记录头节点head
p用于循环遍历的节点,当p节点为真正头节点时才允许出队
q 用于记录p的后继节点
出队的情况分为四种
- 当p为真正头节点时,CAS将数据设置为空,然后判断head是否为真正头节点,不是则更新头节点,然后将原来的头节点next指向它自己构建成哨兵节点
- 当p的后继节点为空时,说明队列为空,尝试CAS将头节点修改成p
- 如果p的后继节点是它自己,说明其他线程poll出队构建成哨兵节点,跳过本次循环
- 其他情况则向后遍历
public E poll() {
//方便退出双重循环
restartFromHead:
for (;;) {
//h记录头节点
//p真正头节点
//q为p的后继节点
for (Node<E> h = head, p = h, q;;) {
//获取p节点的数据
E item = p.item;
//情况1:
//如果数据不为空 说明p节点为真正头节点
//尝试CAS将数据设置为null,如果数据为item则替换为null,失败则说明其他线程以及出队,继续循环
if (item != null && p.casItem(item, null)) {
//如果当前头节点不是真正头节点则更新头节点
if (p != h)
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
//情况2:
//p的后继节点为空,说明当前为空队列,尝试CAS将头节点修改为p(p此时可能是哨兵节点)
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
//情况3:
//如果p的后继节点指向p自己,说明其他线程poll出队时构建成哨兵节点,跳过本次循环
else if (p == q)
continue restartFromHead;
//情况4:
//p定位为后继节点需要遍历
else
p = q;
}
}
}
在更新头节点方法中,会进行判断
如果当前头节点不是真正头节点,则尝试CAS将头节点设置成p真正头节点
CAS成功后将原来的头节点的next指向它自己,构建成哨兵节点
final void updateHead(Node<E> h, Node<E> p) {
if (h != p && casHead(h, p))
h.lazySetNext(h);
}
流程图实现
想要跟着debug的同学,需要把idea中的这两个设置关闭,否则debug会有误
为了更容易的理解,我们来看一段简单的代码,并附带其实现流程图
public void testConcurrentLinkedQueue() {
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("h1");
queue.offer("h2");
queue.offer("h3");
String p1 = queue.poll();
String p2 = queue.poll();
String p3 = queue.poll();
String p4 = queue.poll();
queue.offer("h4");
System.out.println(queue);
}
【声明:如果图中节点item没写数据,说明存储的数据为空;如果节点next没画指向关系,也说明为空】
执行构造时,会初始化首尾节点指向同一个数据为空的节点
在第一次入队时,一进入循环就满足第一种情况,此时的p就是真正尾节点,直接CAS设置next为新节点,但由于p与tail相同,就不会更新尾节点tail
因此首尾节点还是哨兵节点,而哨兵节点的next指向新入队的节点
在第二次入队时,由于此时的p(tail)不是真正尾节点,会来到第三种情况,由于tail没被修改过,p会被改成它的后继节点,继续向后遍历
在第二次循环时,p就是真正尾节点,于是尝试CAS添加新节点,由于此时p和尾节点tail不同,于是会更新tail
在第三次入队时,情况与第一次入队相同
此时队列中存在哨兵节点和h1、h2、h3四个节点
在第一次出队时,由于head指向的哨兵节点数据域为空,会来到第四种情况,即将p改为它的后继节点,继续向后遍历
在第二次循环时,p为h1节点,由于数据不为空,CAS将数据设置为空
p.casItem(item, null)
将原h1节点数据设置为空
此时head并不是真正头节点,于是会更新head
然后将原来的head指向它自己,构建成哨兵节点,方便中间两个不再使用的节点GC
在第二次出队时,满足第一种情况,直接CAS将h2节点数据设置为空,不会更新头节点
在第三次出队时,也类似与第一次出队,满足第四种情况
在第二次循环时,去CAS将数据设置为空,更新头节点,将原来的头节点设置成哨兵节点
在第四次出队时会满足第三种情况,但此时p就是首节点,因此不会更新首节点,然后返回Null
此时我们可以发现尾节点tail在哨兵节点上,如果往后遍历是永远无法到达队列的
再进行一次入队操作,发现它满足第二种情况,p的next指向自己,由于未被修改过,p等于头节点,又重新回到队列上
再进入一轮循环,会CAS添加h4再更新尾节点tail
至此,该简单示例覆盖大部分入队、出队的流程,再来聊聊哨兵节点
在此过程中,哨兵节点可以避免队列中只有一个节点而发生竞争
总结
ConcurrentLinkedQueue
基于单向链表实现,使用volatile保证可见性,使得在读场景下不需要使用其他同步机制;使用乐观锁CAS+失败重试保证写场景下操作的原子性
ConcurrentLinkedQueue
使用延迟更新首尾节点的思想,大大减少CAS次数,提升并发性能;使用哨兵节点,降低代码复杂度,避免一个节点时的竞争
在入队操作时,会在循环中找到真正的尾节点,使用CAS添加新节点,再判断是否CAS更新尾节点tail
在入队操作的循环期间一般情况下是向后遍历节点,由于出队操作会构建哨兵节点,当判断为哨兵节点(next指向自己)时,根据情况定位到尾节点或头节点(“跳出”)
在出队操作时,也是在循环中找到真正的头节点,使用CAS将真正头节点的数据设置为空,再判断是否CAS更新头节点,然后让旧的头节点next指向它自己构建成哨兵节点,方便GC
在出队操作的循环期间一般情况下也是向后遍历节点,由于出队会构建哨兵节点,当检测到当前是哨兵节点时,也要跳过本次循环
ConcurrentLinkedQueue
基于哨兵节点、延迟CAS更新首尾节点、volatile保证可见性等特点,拥有非常高的性能,相对于CopyOnWriteArrayList
来说适用于数据量大、并发高、频繁读写、操作队头、队尾的场景
HOPS的设计
通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:
HOPS的设计通过延迟更新的方式,减少CAS操作的频率,从而提升入队操作的性能。具体来说,tail的更新被延迟至插入节点之后,只有当tail指向的节点的下一个节点不为null时才会进行真正的队尾节点定位和更新操作。同样地,head的更新也被延迟至删除节点之后,只有当head指向的节点的item域为null时才会进行队头节点定位和更新操作。
这种延迟更新策略的设计意图是为了减少CAS操作对性能的影响。如果每次都立即更新tail或head,那么大量的入队或出队操作都需要执行CAS操作来更新tail或head,从而对性能产生较大的负担。通过延迟更新,在一定程度上减少了CAS操作的频率,提升了入队操作的效率。
尽管延迟更新会增加在循环中定位队尾节点的操作,但整体而言,读取操作的性能要远远高于写操作。因此,增加的在循环中定位尾节点的操作性能损耗相对较小。
总结来说,HOPS设计中延迟更新的策略通过减少CAS操作的频率,提升了入队操作的性能,同时在读取操作性能与写入操作性能的权衡中取得了较好的平衡。
扩展知识
tail和head是ConcurrentLinkedQueue队列中两个重要的指针。它们分别指向队列中的尾节点和头节点。
当需要往队列中插入元素时,我们需要使用tail指针指向的节点作为插入节点的前驱节点,并通过CAS(compare-and-swap)操作将插入节点加在其后面。如果插入节点成功地加入了队列尾部,则需要使用CAS操作将tail指针指向新的队尾节点。
当需要从队列中删除元素时,我们需要使用head指针指向的节点作为要删除的节点,并通过CAS操作将其指向下一个节点。如果删除成功,则需要使用updateHead方法将head指针指向真正的队头节点。
需要注意的是,tail和head指针的更新会存在延迟,只有在特定条件下才会进行更新。具体来说,当tail指向的节点的下一个节点不为null时,会执行定位队列真正的队尾节点的操作,并通过CAS操作完成tail的更新;当head指向的节点的item域为null时,会执行定位队列真正的队头节点的操作,并通过updateHead方法完成head的更新。这种延迟更新策略可以有效地减少CAS操作的频率,提高队列的性能。