48、LinkedBlockingQueue
ConcurrentLinkedQueue是使用CAS算法实现的非阻塞对列,而下面我们要介绍的LinkedBlockingQueue由使用独占锁实现。
由类图可知,LinkedBlockingQueue也是使用单向链表实现的,也有两个Node,分别用来存放首、尾节点,还有一个初始值为0的原子变量count,用来记录队列元素的个数。另外还有两个ReentrantLock实例,分别用来控制元素出队和入队的原子性,其中takeLock用来控制同时只有一个线程可以从队列头获取元素,其他线程必须等待,putLock控制同时只能有一个线程可以控制锁,在队列尾添加元素,其他线程必须等待。另外,notEmpty和notFull是条件变量,它们内部都有一个条件队列用来存放进队和出队时被阻塞的线程,其实这是生产者-消费者模型。
/** 执行take, poll, 等操作时需要获取该锁,从而保证同时只有一个线程可以操作表头节点
由于条件变量notEmpty 内部的条件队列的维护使用的是takeLock的锁状态管理机制,所以在调用
notEmpty的await和signal方法前调用线程必须先获取到takeLock锁 */
private final ReentrantLock takeLock = new ReentrantLock();
/** 当队列为空时,执行出队操作(比如说take)的线程会被放入这个条件队列进行等待
notEmpty 维护着一个条件队列,当线程获取到takeLock锁后调用notEmpty的await方法时,
调用线程会被阻塞,然后该线程会被放到notEmpty内部的条件队列进行等待,直到有线程
调用了notEmpty的signal 方法。*/
private final Condition notEmpty = takeLock.newCondition();
/** 执行put, offer,等操作是需要获取该锁,从而保证同时只有一个线程可以操作链表尾结点, */
private final ReentrantLock putLock = new ReentrantLock();
/** 当队列满时,执行进队操作(比如put)的线程会被放入这个条件队列进行等待 */
private final Condition notFull = putLock.newCondition();
/** 当前队列元素个数 */
private final AtomicInteger count = new AtomicInteger();
如下是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);
}
由代码可知,默认队列容量为Integer.MAX_VALUE,用户也可以自己制定扩容,所以从一定程度上说LinkedBlockingQueue是有界阻塞队列。
1、offer操作
向队列尾部插入一个元素,如果队列中有空闲则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false。该方法是非阻塞的
public boolean offer(E e) {
// (1)为空元素则抛出空指针异常
if (e == null) throw new NullPointerException();
// (2)如果当前队列满则丢弃将要放入的元素,返回false
final AtomicInteger count = this.count;
if (count.get() == capacity)
return false;
// (3)构造新节点,获取putLock独占锁
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
// (4)如果队列不满则进队列,并递增元素计数
if (count.get() < capacity) {
enqueue(node);
c = count.getAndIncrement();
// (5)
if (c + 1 < capacity)
notFull.signal();
}
} finally {
// (6)释放锁
putLock.unlock();
}
// (7)
if (c == 0)
// 该方法的作用就是激活notEmpty的条件队列中因为调用netEmpty的await方法(比如调用take方法并且队列为空的时候)
// 而被阻塞的一个线程,这也说明了调用条件变量的方法前要获取对应的锁
signalNotEmpty();
// (8)
return c >= 0;
}
- 代码3获取到putLock锁,当前线程获取到该锁后,则其它调用put和offer操作的线程会被阻塞(阻塞的线程被放到putLock锁的AQS阻塞队列)
- 代码4这里判断当前队列是否满,这是因为在执行代码2和获取到putLock锁期间可能其它线程通过put或者offer操作向队列里面添加了新元素。重新判断队列确实不满则新元素入队,并递增计数器
- 代码5判断如果新元素入队后队列还有空闲空间,则唤醒 notFull 的条件队列里面因为调用了 notFull的await 操作(比如执行 put 方法而队列满了的时候)而被阻塞的一个线程,因为队列现在有空闲所以这里可提前唤醒一个入队线程。
- 代码7中c==0表示代码6释放锁时队列至少有一个元素,有元素则执行signalNotEmpty();操作,该方法的作用就是激活notEmpty的条件队列中因为调用notEmpty的await方法而被阻塞的一个线程,这也说明了调用条件变量的方法前要获取对应的锁。
综上可知,offer方法通过使用putLock锁保证了在队尾新增元素操作的原子性。另外,调用条件变量的方法前一定要记得获取对应的锁,并且注意进队时只操作队列链表的尾结点。
put操作
向队列尾部插入一个元素,如果队列中有空闲则插入成功后返回true,如果队列已满则阻塞当前线程,知道队列有空闲插入后成功返回。
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
// Note: convention in all put/take/etc is to preset local var
// holding count negative to indicate failure unless set.
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
// 相比offer方法中获取独占锁的方法,这个方法可以被中断
putLock.lockInterruptibly();
try {
/*
* 如果当前队列已满,则调用notFull的await()方法把当前进程放入notFull的条件队列,当其它线程被阻塞
* 挂起后会释放获取到的putLock锁。由于putLock锁被释放了,所以其它线程就有机会获取到putLock锁了
*/
// (3)
while (count.get() == capacity) {
notFull.await();
}
// (4)
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
代码(3)在判断队列是否为空时之所以使用while而不是用if,这是考虑到当前线程被虚假唤醒的情况,也就是其它线程没有调用notFull的signal方法时notFull.await()在某种情况下会自动返回。如果使用if语句那么唤醒后会执行代码(4)的元素入队操作,并且递增计数器,而这时候队列已经满了,从而导致队列元素个数大于队列被设置的容量,进而程序出错。而使用while循环时,假如notFull.await()被虚假唤醒了,那么再次循环检查当前队列是否已满,如果是则再次等待
add,offer,put的区别
- add方法在添加元素的时候,若超出了队列的长度会直接抛出异常
- put方法,若向队尾添加元素的时候发现队列已经满了会发生阻塞一直等待空间,以加入元素
- offer方法在添加元素时,如果发现队列已满无法添加的话,会直接返回false
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 {
// (3)队列不空则出队并递减计数
if (count.get() > 0) { // A
x = dequeue(); // B
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
}
} finally {
takeLock.unlock();
}
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;
}
代码(3)判断如果当前队列不为空则进行出队操作,然后递减计数器。这里需要思考,如何保证执行A时队列不为空,而执行B时也一定不会空呢?毕竟这不是原子性操作,会不会出现A判断队列为空,但是执行B时队列为空了呢?那么我们看在执行B前在哪些地方会修改count 的计数。在调用put或者offer时,只会增加计数值所以安全,我们只需要考虑什么时候计数值会递减,那么也就是只有poll、take、remove方法时会递减,但是在执行更改计数器操作前都需要先获取takeLock,但是由于当前线程已经获取了takeLock,所以其它线程没有机会在当前情况下递减count,所以虽然A和B看起来不是原子性的,但是实际上他们是线程安全的。
peek操作
后去头部元素但是不从队列里面移除它,如果队列为空为返回bull。该方法是不阻塞的
public E peek() {
//(1)
if (count.get() == 0)
return null;
//(2)
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
Node<E> first = head.next;
//(3)
if (first == null)
return null;
else
//(4)
return first.item;
} finally {
takeLock.unlock();
}
}
需要注意的是,代码(3)这里还是需要判断first是否为null,不能直接执行代码(4)。正常情况下到代码(2)说明队列部位空,但是(1)和(2)不是原子性操作,也就是在执行点(1)判断队列不为空后,在代码(2)获取到锁前有可能其他线程执行了poll或者take导致队列变为空。然后当前队列线程获取锁后,执行到(4)会抛出空指针异常。
take操作
与poll类似,获取当前队列头部元素并从队列里面移除它。如果队列为空则阻塞当前线程知道队列不为空然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。
remove操作
删除队列里面指定的元素,有则删除并返回true,没有则返回false
public boolean remove(Object o) {
if (o == null) return false;
//(1)双重加锁,获取后,其它线程进行入队或者出队操作时就会被阻塞挂起,代码见下方
fullyLock();
try {
//(2)遍历队列,找不到则直接返回false,找到则执行unlink操作
for (Node<E> trail = head, p = trail.next;
p != null;
trail = p, p = p.next) {
if (o.equals(p.item)) {
unlink(p, trail);
return true;
}
}
return false;
} finally {
fullyUnlock();
}
}
void fullyLock() {
putLock.lock();
takeLock.lock();
}
由于remove操作方法在删除制定元素前加了两把锁,所以在遍历队列查找指定元素的过程中是线程安全的,并且此时其它调用入队、出队操作的线程全部会被阻塞。另外,获取多个资源锁的顺序与释放的顺序是相反的。
小结
LinkedBlockingQueue的内部是通过单向链表实现的,使用头、尾节点来进行入队和出队操作,也就是入队操作都是对为节点进行操作,出队操作都是对头结点进行操作。
如图所示,对头、尾节点的操作分别使用了单独的独占锁从而保证了原子性,所以入队和出队操作是可以同时进行的。另外对头、尾节点的独占锁都配备了一个条件队列,用来存放被阻塞的线程,并结合入队、出队操作实现了一个生产消费模型。