夜光序言:
人生若只如初見,何事秋風悲畫扇;
天長地久有時盡,此恨綿綿無絕期;
正文:
以道御術 / 以術識道
✓ 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 地方,現在狀態: