夜光序言:
人生若只如初见,何事秋风悲画扇;
天长地久有时尽,此恨绵绵无绝期;
正文:
以道御术 / 以术识道
✓ put 操作-生产者
与带超时时间的 poll 类似不同在于 put 时候如果当前队列满了它会一直等待其他线程调用 notFull.signal 才会被唤醒。
✓ take 操作-消费者
与带超时时间的 poll 类似不同在于 take 时候如果当前队列空了它会一直等待其他线程调用 notEmpty.signal()才
会被唤醒。
✓ size 操作-消费者
当前队列元素个数,如代码直接使用原子变量 count 获取。
public int size() {
return count.get();
}
✓ peek 操作
获取但是不移除当前队列的头元素,没有则返回 null。
public E peek() {
//队列空,则返回 null
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();
}
}
✓ remove 操作
删除队列里面的一个元素,有则删除返回 true,没有则返回 false,在删除操作时候由于要遍历队列所以加了双重锁,也就是在删除过程中不允许入队也不允许出队操作。
public boolean remove(Object o) {
if (o == null) return false;
//双重加锁
fullyLock();
try {
//遍历队列找则删除返回 true
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;
} }
//找不到返回 false
return false;
} finally {
//解锁
fullyUnlock();
} }
void fullyLock() {
putLock.lock();
takeLock.lock();
}
void fullyUnlock() {
takeLock.unlock();
putLock.unlock();
}
void unlink(Node<E> p, Node<E> trail) {
p.item = null;
trail.next = p.next;
if (last == p)
last = trail;
//如果当前队列满,删除后,也不忘记最快的唤醒等待的线程
if (count.getAndDecrement() == capacity)
notFull.signal();
}
✓ 开源框架的使用
tomcat 中任务队列 TaskQueue。
类结构图:
可知 TaskQueue继承了 LinkedBlockingQueue 并且泛化类型固定了为 Runnalbe.重写了 offer,poll,take 方法。
tomcat 中有个线程池 ThreadPoolExecutor,在 NIOEndPoint 中当 acceptor 线程接受到请求后,会把任务放入队列,然后 poller 线程从队列里面获取任务,然后就把任务放入线程池执行。
这个 ThreadPoolExecutor 中的的一个参
数就是 TaskQueue。
先看看 ThreadPoolExecutor 的参数如果是普通 LinkedBlockingQueue 是怎么样的执行逻辑:
当调用线程池方法 execute() 方法添加一个任务时:
如果当前运行的线程数量小于 corePoolSize,则创建新线程运行该任务
如果当前运行的线程数量大于或等于 corePoolSize,则将这个任务放入阻塞队列。
如果当前队列满了,并且当前运行的线程数量小于 maximumPoolSize,则创建新线程运行该任务;
如果当前队列满了,并且当前运行的线程数量大于或等于 maximumPoolSize,那么线程池将会抛出
RejectedExecutionException 异常。
如果线程执行完了当前任务,那么会去队列里面获取一个任务来执行,如果任务执行完了,并且当前线程数大于corePoolSize,那么会根据线程空闲时间 keepAliveTime 回收一些线程保持线程池 corePoolSize 个线程。
首先看下线程池中 exectue 添加任务时候的逻辑:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
//当前工作线程个数小于 core 个数则开新线程执行(1)
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
//放入队列(2)
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//如果队列满了则开新线程,但是个数要不超过最大值,超过则返回 false
//然后执行 reject handler(3)
else if (!addWorker(command, false))
reject(command);
}
可知当当前工作线程个数为 corePoolSize 后,如果在来任务会把任务添加到队列,队列满了或者入队失败了则开启新线程。
然后看看 TaskQueue 中重写的 offer 方法的逻辑:
public boolean offer(Runnable o) {
// 如果 parent 为 null 则直接调用父类方法
if (parent==null) return super.offer(o);
//如果当前线程池中线程个数达到最大,则无条件调用父类方法
if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o);
//如果当前提交的任务小于当前线程池线程数,说明线程用不完,没必要重新开线程
if (parent.getSubmittedCount()<(parent.getPoolSize())) return super.offer(o);
//如果当前线程池线程个数>core 个数但是小于最大个数,则开新线程代替放入队列
if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false;
//到了这里,无条件调用父类
return super.offer(o);
}
可知 parent.getPoolSize()<parent.getMaximumPoolSize()普通队列会把当前任务放入队列
TaskQueue 则是返回 false,因为这会开启新线程执行任务,当然前提是当前线程个数没有达到最大值。
LinkedBlockingQueue 安全分析总结
仔细思考下阻塞队列是如何实现并发安全的维护队列链表的,先分析下简单的情况就是当队列里面有多个元素时候
由于同时只有一个线程(通过独占锁 putLock 实现)入队元素并且是操作 last 节点(,而同时只有一个出队线程
(通过独占锁 takeLock 实现)操作 head 节点,所以不存在并发安全问题。
考虑当队列为空的时候队列状态为:
这 时 候 假 如 一 个 线 程 调 用 了 take 方 法 , 由 于 队 列 为 空
所 以 count.get()==0 所 以 当 前 线 程 会 调 用notEmpty.await() 把 自 己 挂 起 , 并 且 放 入 notEmpty 的 条 件 队 列
并 且 释 放 当 前 条 件 变 量 关 联 的 通 过 takeLock.lockInterruptibly()获取的独占锁。
由于释放了锁,所以这时候其他线程调用 take 时候就会通过
takeLock.lockInterruptibly()获取独占锁,然后同样阻塞到 notEmpty.await()
同样会被放入 notEmpty 的条件队列,也就说在队列为空的情况下可能会有多个线程因为调用 take 被放入了 notEmpty 的条件队列。
这时候如果有一个线程调用了 put 方法,那么就会调用 enqueue 操作,该操作会在 last 节点后面添加新元素并且设置 last 为新节点。
然后 count.getAndIncrement()先获取当前队列元个数为 0 保存到 c,然后自增 count 为 1,
由于 c==0 所以调用 signalNotEmpty 激活 notEmpty 的条件队列里面的阻塞时间最长的线程,这时候 take 中调用notEmpty.await()的线程会被激活 await 内部会重新去获取独占锁获取成功则返回,否者被放入 AQS 的阻塞队列
如
果获取成功,那么 count.get() >0 因为可能多个线程 put 了,所以调用 dequeue 从队列获取元素(这时候一定可以
获取到),然后调用 c = count.getAndDecrement() 把当前计数返回后并减去 1,如果 c>1 说明当前队列还有其他
元素
那么就调用 notEmpty.signal()去激活 notEmpty 的条件队列里面的其他阻塞线程。
考虑当队列满的时候:
当队列满的时候调用 put 方法时候,会由于 notFull.await()当前线程被阻塞放入 notFull 管理的条件队列里面,
同理可能会有多个调用 put 方法的线程都放到了 notFull 的条件队列里面。
这时候如果有一个线程调用了 take 方法,调用 dequeue()出队一个元素,c = count.getAndDecrement();
count 值减一;c==capacity;现在队列有一个空的位置,所以调用 signalNotFull()激活 notFull 条件队列里面等待最久的一个线程。
LinkedBlockingQueue 简单示例
并发库中的 BlockingQueue 是一个比较好玩的类,顾名思义,就是阻塞队列。
该类主要提供了两个方法 put() 和 take(),前者将一个对象放到队列中,如果队列已经满了,就等待直到有空闲节点;后者从 head 取一个对象,如果没有对象,就等待直到有可取的对象。
下面的例子比较简单,一个读线程,用于将要处理的文件对象添加到阻塞队列中, 另外四个写线程用于取出文件
对象,为了模拟写操作耗时长的特点,特让线程睡眠一段随机长度的时间。
另外,该 Demo 也使用到了线程池和原子
整型 (AtomicInteger),AtomicInteger 可以在并发情况下达到原子化更新,避免使用了 synchronized,而且性能
非常高。
由于阻塞队列的 put 和 take 操作会阻塞,为了使线程退出,特在队列中添加了一个“标识”,
算法中也叫“哨兵”,当发现这个哨兵后,写线程就退出。
当然线程池也要显式退出了。
package com.hy.多线程高并发;
import java.io.File;
import java.io.FileFilter;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;
public class TestBlockingQueue {
static long randomTime() {
return (long) (Math.random() * 1000);
}
public static void main(String[] args) {
// 能容纳 100 个文件
final BlockingQueue<File> queue = new LinkedBlockingQueue<File>(100);
// 线程池
final ExecutorService exec = Executors.newFixedThreadPool(5);
final File root = new File("e:\\JavaLib");
// 完成标志
final File exitFile = new File("");
// 读个数
final AtomicInteger rc = new AtomicInteger();
// 写个数
final AtomicInteger wc = new AtomicInteger();
// 读线程
Runnable read = new Runnable() {
public void run() {
scanFile(root);
scanFile(exitFile);
}
public void scanFile(File file) {
if (file.isDirectory()) {
File[] files = file.listFiles(new FileFilter() {
public boolean accept(File pathname) {
return pathname.isDirectory()
|| pathname.getPath().endsWith(".java");
}
});
for (File one : files)
scanFile(one);
} else {
try {
int index = rc.incrementAndGet();
System.out.println("Read0: " + index + " "
+ file.getPath());
queue.put(file);
} catch (InterruptedException e) {
}
}
}
};
exec.submit(read);
// 四个写线程
for (int index = 0; index < 4; index++) {
// write thread
final int NO = index;
Runnable write = new Runnable() {
String threadName = "Write" + NO;
public void run() {
while (true) {
try {
Thread.sleep(randomTime());
int index = wc.incrementAndGet();
File file = queue.take();
// 队列已经无对象
if (file == exitFile) {
// 再次添加"标志",以让其他线程正常退出
queue.put(exitFile);
break;
}
System.out.println(threadName + ": " + index + " "
+ file.getPath());
} catch (InterruptedException e) {
}
}
}
};
exec.submit(write);
}
exec.shutdown();
}
}
➢PriorityBlockingQueue 无界阻塞优先级队列
PriorityBlockingQueue 是带优先级的无界阻塞队列,每次出队都返回优先级最高的元素,是二叉树最小堆的实现,研究过数组方式存放最小堆节点的都知道,直接遍历队列元素是无序的。
PriorityBlockingQueue 类图结构
如图 PriorityBlockingQueue 内部有个数组 queue 用来存放队列元素
size 用来存放队列元素个数,allocationSpinLockOffset 是用来在扩容队列时候做 cas 的,目的是保证只有一个线程可以进行扩容。
由于这是一个优先级队列所以有个比较器 comparator 用来比较元素大小。
lock 独占锁对象用来控制同时只能有一个线程可以进行入队出队操作。
notEmpty 条件变量用来实现 take 方法阻塞模式。
这里没有 notFull 条件变量是因为这里的 put 操作是非阻塞的,为啥要设计为非阻塞的是因为这是无界队列。
最后 PriorityQueue q 用来搞序列化的。
如下构造函数,默认队列容量为 11,默认比较器为 null;
private static final int DEFAULT_INITIAL_CAPACITY = 11;
public PriorityBlockingQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
}public PriorityBlockingQueue(int initialCapacity) {
this(initialCapacity, null);
}
public PriorityBlockingQueue(int initialCapacity,
Comparator<? super E> comparator) {
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.lock = new ReentrantLock();
this.notEmpty = lock.newCondition();
this.comparator = comparator;
this.queue = new Object[initialCapacity];
}
PriorityBlockingQueue 方法
✓ Offer 操作
在队列插入一个元素,由于是无界队列,所以一直为成功返回 true;
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
int n, cap;
Object[] array;
//如果当前元素个数>=队列容量,则扩容(1)
while ((n = size) >= (cap = (array = queue).length))
tryGrow(array, cap);
try {
Comparator<? super E> cmp = comparator;
//默认比较器为 null
if (cmp == null)(2)
siftUpComparable(n, e, array);
else
//自定义比较器(3)
siftUpUsingComparator(n, e, array, cmp);
//队列元素增加 1,并且激活 notEmpty 的条件队列里面的一个阻塞线程
size = n + 1;(9)
notEmpty.signal();
} finally {
lock.unlock();
}
return true;
}
主流程比较简单,下面看看两个主要函数
private void tryGrow(Object[] array, int oldCap) {
lock.unlock(); //must release and then re-acquire main lock
Object[] newArray = null;
//cas 成功则扩容(4)
if (allocationSpinLock == 0 &&
UNSAFE.compareAndSwapInt(this, allocationSpinLockOffset,
0, 1)) {
try {
//oldGap<64 则扩容新增 oldcap+2,否者扩容 50%,并且最大为 MAX_ARRAY_SIZE
int newCap = oldCap + ((oldCap < 64) ?
(oldCap + 2) : // grow faster if small
(oldCap >> 1));
if (newCap - MAX_ARRAY_SIZE > 0) { // possible overflow
int minCap = oldCap + 1;
if (minCap < 0 || minCap > MAX_ARRAY_SIZE)
throw new OutOfMemoryError();
newCap = MAX_ARRAY_SIZE;
}
if (newCap > oldCap && queue == array)
newArray = new Object[newCap];
} finally {
allocationSpinLock = 0;
} }
//第一个线程 cas 成功后,第二个线程会进入这个地方,然后第二个线程让出 cpu,尽量让第一个线程执行下面点获取锁,但
是这得不到肯定的保证。(5)
if (newArray == null) // back off if another thread is allocating
Thread.yield();
lock.lock();(6)
if (newArray != null && queue == array) {
queue = newArray;
System.arraycopy(array, 0, newArray, 0, oldCap);
} }
tryGrow 目的是扩容,这里要思考下为啥在扩容前要先释放锁,然后使用 cas 控制只有一个线程可以扩容成功。
我的理解是为了性能,因为扩容时候是需要花时间的,如果这些操作时候还占用锁那么其他线程在这个时候是不能进行出队操作的,也不能进行入队操作,这大大降低了并发性。
所以在扩容前释放锁,这允许其他出队线程可以进行出队操作,但是由于释放了锁,所以也允许在扩容时候进行
入队操作,这就会导致多个线程进行扩容会出现问题,所以这里使用了一个 spinlock 用 cas 控制只有一个线程可以进
行扩容,失败的线程调用 Thread.yield()让出 cpu
目的意在让扩容线程扩容后优先调用 lock.lock 重新获取锁,但是
这得不到一定的保证,有可能调用 Thread.yield()的线程先获取了锁。
那 copy 元素数据到新数组为啥放到获取锁后面那?原因应该是因为可见性问题,因为 queue 并没有被 volatile 修饰。
另外有可能在扩容时候进行了出队操作,如果直接拷贝可能看到的数组元素不是最新的。
而通过调用 Lock 后,获取的数组则是最新的,并且在释放锁前数组内容不会变化。
具体建堆算法:
private static <T> void siftUpComparable(int k, T x, Object[] array) {
Comparable<? super T> key = (Comparable<? super T>) x;
//队列元素个数>0 则判断插入位置,否者直接入队(7)
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = array[parent];
if (key.compareTo((T) e) >= 0)
break;
array[k] = e;
k = parent;
}
array[k] = key;(8)
}
假设队列容量为 2
• 第一次 offer(2)时候
执行(1)为 false 所以执行(2),由于 k=n=size=0;所以执行(8)元素入队,然执行(9)size+1;
现在队列状态:
• 第二次 offer(4)时候
执行(1)为 false,所以执行(2)由于 k=1,所以进入 while 循环,parent=0;e=2;key=4;key>e 所以 break;
然后把 4 存到数据下标为 1 的地方,这时候队列状态为:
• 第三次 offer(4)时候
执行(1)为 true,所以调用 tryGrow,由于 2<64 所以 newCap=2 + (2+2)=6;然后创建新数组并拷贝
然后调用 siftUpComparable;k=2>0 进入循环 parent=0;e=2;key=6;key>e 所以 break;然后把 6 放入下标为 2 的地方,现在队列状态:
• 第四次 offer(1)时候
执行(1)为 false,所以执行(2)由于 k=3,所以进入 while 循环,parent=0;e=2;key=1; key<e;
所以把 2复制到数组下标为 3 的地方,然后 k=0 退出循环;然后把 2 存放到下标为 0 地方,现在状态: