前言
JDK 1.5 之後,Doug Lea 大神為我們寫了很多的工具,整個 concurrent 包基本都是他寫的。也為我們程式設計師寫好了很多工具,包括我們之前說的執行緒池,重入鎖,執行緒協作工具,ConcurrentHashMap 等等,今天我們要講的是和 ConcurrentHashMap 類似的資料結構,LinkedBolckingQueue,阻塞佇列。在生產者消費者模型中,該類可以幫助我們快速的實現業務功能。
- 如何使用?
- 原始碼分析
1. 如何使用?
我們在生產者消費者模型,生產者向一個資料共享通道存放資料,消費者從相同的資料共享通道獲取資料,將生產和消費完全隔離,不僅是生產者消費者,現在流行的訊息佇列,比如各種MQ,kafka,和這個都差不多。廢話不多說,直接來個demo ,看看怎麼使用:
public static void main(String[] args) {
LinkedBlockingQueue linkedBlockingQueue = new LinkedBlockingQueue(1024);
for (int i = 0; i < 5; i++) {
final int num = i;
new Thread(() -> {
try {
for (int j = 0; ; j++) {
linkedBlockingQueue.put(num + "號執行緒的" + j + "號商品");
Thread.sleep(5000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
for (; ; ) {
System.out.println("消費了" + linkedBlockingQueue.take());
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
複製程式碼
執行結果:
消費了0號執行緒的0號商品
消費了3號執行緒的0號商品
消費了2號執行緒的0號商品
消費了1號執行緒的0號商品
消費了4號執行緒的0號商品
消費了2號執行緒的1號商品
消費了1號執行緒的1號商品
消費了0號執行緒的1號商品
消費了3號執行緒的1號商品
消費了4號執行緒的1號商品
消費了1號執行緒的2號商品
消費了0號執行緒的2號商品
消費了2號執行緒的2號商品
消費了3號執行緒的2號商品
消費了4號執行緒的2號商品
·········
複製程式碼
從上面的程式碼中,我們使用了5條執行緒分別向佇列中插入資料,也就是一個字串,然後讓5個執行緒從佇列中取出資料並列印,可以看到,生產者插入的資料從消費者執行緒中被列印,沒有漏掉一個。
注意,這裡的 put 方法和 take 方法都是阻塞的,不然就不是阻塞佇列了,什麼意思呢?如果佇列滿了,put 方法就會等待,直到佇列有空為止,因此該方法使用時需要注意,如果業務即時性很高,那麼最好使用帶有超時選項的 offer (V,long,TimeUnit),方法,同樣, take 方法也是如此,當佇列中沒有的時候,就會阻塞,直到佇列中有資料為止。同樣可以使用 poll(long, TimeUnit)方法超時退出。
當然不止這幾個方法,樓主將常用的方法總結一下:
插入方法:
// 如果滿了,立即返回false
boolean b = linkedBlockingQueue.offer("");
// 如果滿了,則等待到給定的時間,如果還滿,則返回false
boolean b2 = linkedBlockingQueue.offer("", 1000, TimeUnit.MILLISECONDS);
// 阻塞直到插入為止
linkedBlockingQueue.put("");
複製程式碼
取出方法:
// 如果佇列為空,直接返回null
Object o3 = linkedBlockingQueue.poll();
// 如果佇列為空,一直阻塞到給定的時間
Object o1 = linkedBlockingQueue.poll(1000, TimeUnit.MILLISECONDS);
// 阻塞,直到取出資料
Object o = linkedBlockingQueue.take();
// 獲取但不移除此佇列的頭;如果此佇列為空,則返回 null。
Object peek = linkedBlockingQueue.peek();
複製程式碼
那麼這些方法內部是如何實現的呢?
2. 原始碼分析
阻塞佇列,重點看 put 阻塞方法和 take 阻塞方法。
put 方法:
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;
putLock.lockInterruptibly();
try {
/*
* Note that count is used in wait guard even though it is
* not protected by lock. This works because count can
* only decrease at this point (all other puts are shut
* out by lock), and we (or some other waiting put) are
* signalled if it ever changes from capacity. Similarly
* for all other uses of count in other wait guards.
*/
while (count.get() == capacity) {
notFull.await();
}
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
複製程式碼
該方法步驟如下:
- 根據給定的值建立一個 Node 物件,該物件有2個屬性,一個是 item,一個是 Node 型別的 next,是連結串列結構的節點。
- 獲取 put 的鎖,注意,這裡,put 鎖和 take 鎖是分開的。也就是說,當你插入的時候和取出的時候用的不是一把鎖,可以高效併發,但是如果兩個執行緒同時插入就會阻塞。
- 獲取連結串列的長度。
- 使用中斷鎖,如果呼叫了執行緒的中斷方法,那麼,處於阻塞中的執行緒就會丟擲異常。
- 判斷如果當前連結串列長度達到了設定的長度,預設是 int 最大型,就呼叫 put 鎖的夥伴 Condition 物件 notFull 讓當前執行緒掛起等待。 直到 take 方法中會呼叫 notFull 物件的 signal 方法喚醒。
- 呼叫 enqueue 方法,將剛剛建立的 Node 節點連線到連結串列上。
- 將連結串列長度變數 count 加一。 判斷如果加一後,連結串列長度還小於連結串列規定的容量,那麼就喚醒其他等待在 notFull 物件上的執行緒,告訴他們可以取資料了。
- 放開鎖,讓其他執行緒爭奪鎖(非公平鎖)。
- 如果c是0,表示佇列已經有一個資料了,通知喚醒掛在 notEmpty 的執行緒,告訴他們可以取資料了。
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();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
複製程式碼
步驟如下:
- 獲取鏈長度,獲取 take 鎖。
- 呼叫可中斷的 lock 方法。開始鎖住。
- 如果佇列是空,則掛起執行緒。開始等待。
- 如果不為空,則呼叫 dequeue 方法,拿到頭節點的資料,並將頭節點更新。
- 將佇列長度減一。判斷如果佇列長度大於1,通知等待在 notEmpty 上的執行緒,可以拿資料了。
- 解鎖。
- 如果變數 c 和 容量相同,而剛剛又消費了一個節點,說明佇列不滿了,則通知生產者可以新增資料了。
- 返回資料。
boolean 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 {
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;
}
複製程式碼
該方法會阻塞給定的時間,如果時間到了,則返回false。 和 put 方法很相似,步驟如下:
- 將時間轉成納秒。
- 獲取 put 鎖。
- 呼叫可中斷鎖方法。
- 如果容量滿了,並且設定的等待時間小於0,返回 false,表示插入失敗,反之,呼叫 notFull 方法等待給定的時間,並返回一個負數,當第二次迴圈的時候,繼續判斷,如果還是滿的並且小於0,返回false。
- 如果容量沒有滿,或者等待過程被喚醒,則呼叫 enqueue 插入資料。
- 獲取當前連結串列長度。
- 判斷連結串列長度+1是否小於設定的容量。如果小於,則連結串列沒有滿,通知生產者可以新增資料了。
- 釋放鎖。 如果 c 等於 0,表示之前沒有資料,但是現在已經加入一個資料了,可以通知其他的消費者來消費了。
- 返回 true。
E poll(long timeout, TimeUnit unit) 原始碼分析
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
E x = null;
int c = -1;
long nanos = unit.toNanos(timeout);
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
if (nanos <= 0)
return null;
nanos = notEmpty.awaitNanos(nanos);
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
複製程式碼
該方法會阻塞給定的時間,如果取不到資料,返回null。
步驟其實和上面的差不多,樓主偷個懶,就不解釋了。
總結
從原始碼分析中,我們可以看到,整個阻塞佇列就是由重入鎖和Condition 組合實現的,和我們之前用 synchronized 加上 wait 和 notify 實現很相似,只是樓主的那個例子沒有使用佇列,因此無法將鎖分開,也就是我們之前說的鎖分離的技術。那麼,整體的效能當然不能和 Doug Lea 大神的比了。
好了。今天的併發原始碼分析,就到這裡。
good luck!!!!