不止八股:阿里内部语雀一些有趣的并发编程笔试题1——一半容量才可以出队的阻塞队列
系列文章目錄和關于我
0丶引入
筆者社招一年半經驗跳槽加入阿里約1年時間,無意間發現一些阿里語雀上的一些面試題題庫,出于學習目的在此進行記錄。
- 這一篇主要寫一些有趣的筆試題(非leetcode),這些有的考驗并發編程,有的考驗設計能力。
- 筆者不是什么技術大牛,此處筆試題充滿主觀思考,并不一定是滿分答案,歡迎評論區一起探討。
- 不止八股:面試題之外,筆者會更多的思考下底層原理,不只是簡單的背誦。
下面這個題目也是筆者面試阿里筆試做過的一道筆試題,現在回想自己那時候寫的也是一坨
1 題目
通過List實現一個阻塞隊列類,該隊列有最大長度限制,以下是該隊列的一些特性
- take方法:獲取隊列中隊頭元素,當隊列中元素數量超過一半時,則可以出隊,否則阻塞當前線程
- offer(E element)方法:插入元素到隊尾,當隊列大小已經超過最大限制時,則阻塞當前線程
- size()返回當前隊列大小
2 筆者的題解
感覺這個隊列現實中沒啥實際用途,為啥超過一半才能出隊
這一題主要考察候選人對并發編程的理解,可是筆者工作也沒用到這些知識呀doge
在juc相關筆記中,我們學習了Condition和Object#wait notify的原理和基本使用,也學習了juc中常見的阻塞隊列原理,這里可用使用等待喚醒進行實現。
2.1 基于Object等待喚醒的簡單版本
2.1.1代碼
import java.util.List;
public class HalfTakeAvailableBlockingQueueV1<E> {
private List<E> delegateList;
private int maxSize;
private final Object lock;
public HalfTakeAvailableBlockingQueueV1(List<E> delegateList, int maxSize) {
this.delegateList = delegateList;
this.maxSize = maxSize;
lock = new Object();
}
/***
* take方法:獲取隊列中隊頭元素,當隊列中元素數量超過一半時,則可以出隊,否則阻塞當前線程
* @return 隊列頭部元素
* @throws InterruptedException
*/
public E take() throws InterruptedException {
synchronized (lock) {
// 使用while 避免虛假喚醒
while (size() <= maxSize / 2) {
lock.wait();
}
// 這里要使用remove,得刪去
E e = delegateList.remove(0);
// notifyAll 不能和上面的remove換位置
lock.notifyAll();
return e;
}
}
/**
* offer(E element)方法:插入元素到隊尾,當隊列大小已經超過最大限制時,則阻塞當前線程
*
* @param e e
*/
public void offer(E e) throws InterruptedException {
synchronized (lock) {
while (size() > maxSize) {
lock.wait();
}
delegateList.add(e);
// notifyAll 和上面的不能調換位置
lock.notifyAll();
}
}
/**
* 需要加鎖,否則有線程可見性問題
* @return
*/
public int size(){
synchronized (lock){
return delegateList.size();
}
}
}
2.1.2細節
雖然這個版本似乎很簡單,但是還是存在一些細節。
-
命名
HalfTakeAvailable是不是很見名知意,一半的容量才能獲取。
-
使用while而不是if
while (條件)為什么使用的while,而不是使用if?想想一種case,線程A和線程B阻塞于offer,線程C獲取鎖正在執行take,執行完take后進行notifyAll。隨后線程A獲得鎖結束了if進行元素的塞入,此時size大于了maxSize,但是線程A的喚醒導致了線程B繼續執行,線程B也結束了if,繼續塞入了元素。
這種情況被稱作虛假喚醒
-
size方法為什么要加鎖
保證線程可見性,本質上size方法就是讀沒用并發修改的問題,這里加synchronized只是為了保證線程可見性當線程釋放鎖時,它所做的更改會被刷新到主內存中,同樣,當線程獲取鎖時,它會從主內存中讀取共享變量的最新值。這意味著通過synchronized 塊的同步變量的更新對所有其他線程是可見的 -
notifyAll不能和數據操作調換位置
為什么不能先notifyAll然后再remove昵?
在涉及等待/通知模式的同步代碼中,典型的模式是
先改變狀態或條件(比如執行 remove(0) 移除隊列頭部元素),然后調用 notifyAll() 或 notify() 來喚醒等待該條件的線程。如果你先調用 notifyAll(),然后再改變狀態(執行 remove(0)),可能會出現以下問題:
-
之前被喚醒的線程可能無法察覺到狀態改變: 如果你先發送通知,那些等待的線程可能會在狀態實際改變之前被喚醒(比如,某個元素還沒從隊列中移除)。那些線程可能檢查到狀態尚未改變(因為 remove(0) 還沒執行),然后無意義地再次進入等待狀態。
-
可能會發生競態條件: 如果一個線程執行完 notifyAll() 并且在 remove(0) 執行之前失去了鎖,那么等待的線程將會開始運行。此時,由于元素還未被移除,它們可能看到錯誤的狀態,或者在執行它們各自的操作時與其他線程發生沖突。
-
降低了效率: 當線程被過早地喚醒,它們可能只是進行一次無效的狀態檢查然后再次掛起。這不僅浪費了CPU資源,還增加了線程上下文切換的開銷。
-
2.2.3 這個版本的不足
-
為什么size需要加鎖
這里加鎖不是因為存在并發更新的問題,而是需要保證線程可見性,但是這里的加鎖卻導致offer和take的阻塞,以及多個size不能并行。
如何優化——volatile
-
一個等待條件的弊端
想象一種case,當前容量為maxSize - 1,線程AB都在執行offer,線程A執行成功了進行喚醒了線程B,線程B喚醒后自旋while繼續wait。
有什么問題:線程B不應該被喚醒,應該是執行take的線程來喚醒執行offer的線程
如何解決:多個等待條件,仿照ArrayBlockingQueue
2.2 使用volatile優化size
我們在HalfTakeAvailableBlockingQueueV2中,使用private volatile int size來記錄當前容量,然后offer和take的時候修改加鎖修改size。
然后size方法就不需要加鎖了。
2.3 使用Condition實現多等待條件
我們使用juc中的ReentrantLock來解決并發修改問題,ReentrantLock#newCondition可創建多種等待喚醒條件,我們下面的代碼創建了兩個Condition
- takeAvailable:如果無法take那么在此Condition上進行等待
- offerAvailable:如果無法offer那么在此Condition上進行等待
從而實現offer的線程來喚醒阻塞在take上的線程,減少無意義的喚醒,這里也可以看出ReentrantLock相對于synchronized更牛的一點
import java.util.List;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class HalfTakeAvailableBlockingQueueV3<E> {
private List<E> delegateList;
private int maxSize;
private volatile int size;
private final ReentrantLock lock;
/**
* 可拿取條件
*/
private final Condition takeAvailable;
/**
* 可塞入條件
*/
private final Condition offerAvailable;
public HalfTakeAvailableBlockingQueueV3(List<E> delegateList, int maxSize) {
this.delegateList = delegateList;
this.maxSize = maxSize;
lock = new ReentrantLock();
takeAvailable = lock.newCondition();
offerAvailable = lock.newCondition();
size = 0;
}
/***
* take方法:獲取隊列中隊頭元素,當隊列中元素數量超過一半時,則可以出隊,否則阻塞當前線程
* @return 隊列頭部元素
* @throws InterruptedException
*/
public E take() throws InterruptedException {
lock.lock();
try {
// 使用while 避免虛假喚醒
while (size() <= maxSize / 2) {
takeAvailable.await();
}
// 這里要使用remove,得刪去
E e = delegateList.remove(0);
size--;
offerAvailable.signalAll();
return e;
} finally {
lock.unlock();
}
}
/**
* offer(E element)方法:插入元素到隊尾,當隊列大小已經超過最大限制時,則阻塞當前線程
*
* @param e e
*/
public void offer(E e) throws InterruptedException {
lock.lock();
try {
while (size() > maxSize) {
// 無法offer 那么await
offerAvailable.await();
}
delegateList.add(e);
size++;
// 可拿去喚醒
takeAvailable.signalAll();
} finally {
lock.unlock();
}
}
public int size() {
return size;
}
}
2.3.1繼續優化
這里其實還有一個點可優化,那就是
這里可用判斷以下,如果容量大于等于一半才進行喚醒!
public void offer(E e) throws InterruptedException {
lock.lock();
try {
while (size() > maxSize) {
// 無法offer 那么await
offerAvailable.await();
}
delegateList.add(e);
size++;
if (size>=maxSize/2){
// 可拿喚醒
takeAvailable.signalAll();
}
} finally {
lock.unlock();
}
}
3.不止八股
3.1 synchronized 原理
ObjectMonitor的結構:
ObjectMonitor() {
_header = NULL;
_count = 0; //鎖計數器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處于wait狀態的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀態的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
當線程想要進入同步塊(即被 synchronized 關鍵字修飾的方法或代碼塊)時,它必須首先獲取該對象的內置鎖。如果鎖已經被另一個線程持有,該線程將會被阻塞,直到鎖被釋放。鎖機制確保在任何時刻,只有一個線程能持有對象的內置鎖,從而保障了同步塊內代碼的線程安全性。
ObjectMonitor 是如何實現線程安全的:
-
互斥(Mutual Exclusion):
當一個線程(稱為線程A)嘗試執行 synchronized 代碼塊時,它會嘗試獲取與對象關聯的內置鎖。
如果該鎖已經被另一個線程(稱為線程B)持有,則線程A會被放入等待鎖(_entryList)的隊列中,并被阻塞。
當線程B釋放鎖時,線程A或其他等待鎖的線程將有機會獲取鎖。
操作原子性(Operation Atomicity):在持有鎖的過程中,線程可以原子性地執行同步代碼塊中的操作。意味著這些操作不會被其他同步代碼塊并發執行的線程打斷。 -
可見性(Visibility):
當線程釋放鎖時,其所做的更改會立即對其他線程可見。即將工作內存中所作的更改同步到主內存,這確保了對共享變量的更改會及時地被其他線程察覺。
-
等待/通知機制(Wait/Notify Mechanism):
wait()、notify() 和 notifyAll() 方法提供了線程間的協調機制。
當一個線程調用 wait() 方法時,它會釋放當前持有的鎖并進入等待狀態,直到另一個線程調用 notify() 或 notifyAll()。
調用 notify() 會隨機喚醒等待隊列中的一個線程,而 notifyAll() 會喚醒所有等待的線程,但這些線程仍需要在鎖可用時競爭獲取鎖
3.2 ReentrentLock&Conditio原理
1. ReentrentLock 如何實現線程安全
JUC源碼學習筆記1——AQS獨占模式和ReentrantLock
ReentrentLock 基于AQS的獨占模式,本質上是使用一個雙向鏈表實現了 同步隊列
AQS內部是state來標識當前同步狀態,在不同的juc工具類中,state的含義各不相同。在ReentrentLock 中state標識占有鎖線程加鎖的次數(從而實現可重入)當state=0的時候,ReentrentLock 會使用CAS來改變state的值,cas成功的線程意味著加鎖成功,反之加鎖失敗
-
加鎖失敗的線程將自旋+cas的入隊——將自己封裝成node鏈接到隊列尾部,然后調用LockSupport#park進行阻塞等待
-
加鎖成功的線程將成為同步隊列的頭部,繼續執行后續的業務邏輯,執行完成后調用unlock解鎖將修改state的值-1(重入多少次就要釋放多少次),如果state的值為0意味著鎖被釋放,這時候將
喚醒后續節點,如果后續節點為null or 后續節點阻塞等待的時候被打斷那么從隊列尾部找最靠近頭部的阻塞于鎖的線程調用LockSupport#unpark進行喚醒為什么要從尾部找?=>JUC源碼學習筆記1——AQS獨占模式和ReentrantLock
那么為什么cas可保證執行過程的原子性?LockSupport#park和unpark是如何實現線程的掛起和喚醒的?
-
CPU硬件級別的CAS是作為單個原子指令執行的,這意味著一旦CAS指令開始執行,它的整個狀態轉換(讀取-比較-交換)過程就會在一個不可分割的步驟中完成。這個過程是連續的,CPU不會在CAS指令執行到一半時中斷并切換到另一個線程。
那么,CPU是如何保證這一點的呢?
-
鎖總線(Locking the Bus):
通過鎖定CPU和內存間的總線。在執行這樣的原子操作時,處理器會向總線發出一個鎖定的信號,沒有其他的處理器可以訪問內存,直到原子操作完成。
-
緩存鎖定(Cache Locking):
在多核處理器中通常使用MESI(Modified, Exclusive, Shared, Invalid)協議來確保緩存一致性。當執行原子操作的時候,如果數據被緩存在當前核的緩存中并且是以獨占狀態存在的,那么該核可以直接對這個緩存行進行操作,而無需鎖定總線。
重要的是,雖然線程可能因為時間片用盡而被掛起,但CPU內部的原子指令一旦開始執行,就會完成整個指令序列,中間不會被操作系統的線程調度打斷。
-
-
LockSupport是Java并發包里一個提供基本線程同步原語的工具類,其中park和unpark是它的兩個核心方法。這兩個方法為線程提供了阻塞(等待)和喚醒的能力,它們不需要同步器對象(比如鎖或者信號量),可以非常靈活地使用。
-
park方法:
當一個線程調用park時,如果已經有一個unpark操作授予了該線程一個許可(permit),它會立即返回。否則,它可能會阻塞。線程調用park時會被掛起(進入等待狀態),等待unpark的喚醒,或者響應中斷。
-
unpark方法:
unpark方法用于“喚醒”或者“重新調度”一個被park掛起的線程。這個方法會給指定的線程提供一個許可(如果之前沒有許可的話),使得當該線程調用park方法時不會被阻塞,或者如果該線程已經在調用park時掛起了,那么它將被喚醒。
在Java中,LockSupport的實現依賴了本地方法,調用了操作系統層面的同步機制。簡單來說,就是基于操作系統提供的掛起(suspend)和恢復(resume)線程的原語來實現的。
在Linux平臺下,park和unpark是通過pthread庫中的條件變量(condition variables)來實現的。當線程調用park方法時,實際上它調用了本地方法,該本地方法會使用條件變量進行等待。當另一個線程調用unpark時,它實際上會發送信號到條件變量來喚醒掛起的線程。
-
2. Condition如何實現等待喚醒
JUC源碼學習筆記3——AQS等待喚醒機制Condition和CyclicBarrier,BlockingQueue
-
等待(await方法)
如果當前線程未持有與Condition相關聯的Lock,則將拋出IllegalMonitorStateException。
將當前線程封裝成一個節點,和其他等待的線程一起加入到AQS的條件隊列。條件隊列是一個單向隊列,用來存放調用await()進入等待狀態的線程。釋放已持有的鎖并將當前線程掛起。在AQS中叫做"fullyRelease",意味著可能會釋放多個重入鎖。這一操作必須確保至少能原子地釋放鎖并掛起線程,不然就可能出現死鎖。
如果在等待過程中線程被中斷,根據不同的await()實現,線程可能會拋出InterruptedException,或者線程的中斷狀態將被設置。 -
喚醒(signal或signalAll方法)
如果當前線程未持有與Condition相關聯的Lock,則將拋出IllegalMonitorStateException。
對于signal()方法,AQS將從條件隊列中選擇一個等待最長時間的線程節點,并將其轉移到同步隊列。同步隊列是用來競爭獲取鎖的隊列。
對于signalAll()方法,AQS將把條件隊列的所有線程節點全部轉移到同步隊列。
被移動到同步隊列的線程節點將在鎖釋放時競爭獲取鎖,一旦獲取鎖成功,它們可以從await()方法返回并繼續執行。
3. ReentrentLock如何實現線程可見性
這里說的不是AQS state屬性的可見性,因為state是volatile修飾,自然保證了其可見性
看下面這個代碼,為什么list沒用被volatile修飾,但是保證了其線程可見性,main線程可用讀到writeThread對list的修改
這一切都是是因為unlock釋放鎖,會修改volatile修飾的state變量
對于volatile變量的寫操作,會在其后插入一個內存屏障。在Java中,volatile變量的寫操作后通常會插入一個"store-store"屏障(Store Barrier),以及一個"store-load"屏障。這些內存屏障確保了以下幾點:
- Store-Store Barrier:這個屏障防止volatile寫與之前普通寫的指令重排序。也就是說,對volatile變量的寫操作之前的所有普通寫操作都必須在volatile寫之前完成,確保了volatile寫之前的修改對于其他線程是可見的。
- Store-Load Barrier:這個屏障防止volatile寫與之后的讀寫操作重排序。它確保了volatile變量寫之后的讀取操作不會在volatile寫之前發生。這保證了volatile寫操作的結果對后續操作是可見的。
在x86架構中,由于其內存模型的特性,每次對volatile變量的寫操作通常會伴隨著一個lock前綴的指令,該指令實際上會執行一個"全能型"的內存屏障(Memory Fence),它包括了上述的store-store和store-load屏障,同時還會兼具load-store屏障的效果。這種屏障確保了指令執行的順序性和數據的可見性。
volatile的這些內存屏障特性是由Java內存模型(JMM)強制實施的,JVM負責在編譯時期和運行時期將這些規則映射到具體的硬件指令上。
也就是說對volatile變量的寫操作確實可以確保在這次寫操作之前的所有普通變量的修改對其他線程是可見的。這是因為對volatile變量的寫操作會在其寫操作后插入一個內存屏障,防止之前的寫入操作被重排序到這個內存屏障之后。
簡單來說,按照下面的順序執行操作:
- 線程A修改普通變量x。
- 線程A寫入volatile變量v。
這時,根據happens-before原則,當線程A寫入volatile變量v后,任何線程B讀取同一個volatile變量v,并看到線程A寫入的值,那么它也保證看到線程A在寫入volatile變量v之前對普通變量x所做的修改。
這個特性在并發編程中經常用來確保重要信號、狀態或數據的傳遞是準確且及時的。當使用volatile變量作為狀態標志或鎖的一部分時,這個特性特別有用。
4.感想
看似很簡單的一題,其實知識點也是蠻多的,結合這些原理可以看出——編程沒用魔法,都是操作系統提供的機制吧,操作系統牛逼!
總結
以上是生活随笔為你收集整理的不止八股:阿里内部语雀一些有趣的并发编程笔试题1——一半容量才可以出队的阻塞队列的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: DNS解析中CNAME和MX记录冲突
- 下一篇: Feign源码解析:初始化过程(三)