前言
個人珍藏的80道Java多線程/並發經典面試題,現在給出11-20的答案解析哈,並且上傳github哈~
11、為什么要用線程池?Java的線程池內部機制,參數作用,幾種工作阻塞隊列,線程池類型以及使用場景
回答這些點:
- 為什么要用線程池?
- Java的線程池原理
- 線程池核心參數
- 幾種工作阻塞隊列
- 線程池使用不當的問題
- 線程池類型以及使用場景
為什么要用線程池?
線程池:一個管理線程的池子。
- 管理線程,避免增加創建線程和銷毀線程的資源損耗。
- 提高響應速度。
- 重復利用。
Java的線程池執行原理
為了形象描述線程池執行,打個比喻:
- 核心線程比作公司正式員工
- 非核心線程比作外包員工
- 阻塞隊列比作需求池
- 提交任務比作提需求
線程池核心參數
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize: 線程池核心線程數最大值
- maximumPoolSize: 線程池最大線程數大小
- keepAliveTime: 線程池中非核心線程空閑的存活時間大小
- unit: 線程空閑存活時間單位
- workQueue: 存放任務的阻塞隊列
- threadFactory: 用於設置創建線程的工廠,可以給創建的線程設置有意義的名字,可方便排查問題。
- handler:線城池的飽和策略事件,主要有四種類型拒絕策略。
四種拒絕策略
- AbortPolicy(拋出一個異常,默認的)
- DiscardPolicy(直接丟棄任務)
- DiscardOldestPolicy(丟棄隊列里最老的任務,將當前這個任務繼續提交給線程池)
- CallerRunsPolicy(交給線程池調用所在的線程進行處理)
幾種工作阻塞隊列
- ArrayBlockingQueue(用數組實現的有界阻塞隊列,按FIFO排序量)
- LinkedBlockingQueue(基於鏈表結構的阻塞隊列,按FIFO排序任務,容量可以選擇進行設置,不設置的話,將是一個無邊界的阻塞隊列)
- DelayQueue(一個任務定時周期的延遲執行的隊列)
- PriorityBlockingQueue(具有優先級的無界阻塞隊列)
- SynchronousQueue(一個不存儲元素的阻塞隊列,每個插入操作必須等到另一個線程調用移除操作,否則插入操作一直處於阻塞狀態)
線程池使用不當的問題
線程池適用不當可能導致內存飆升問題哦
有興趣可以看我這篇文章哈:源碼角度分析-newFixedThreadPool線程池導致的內存飆升問題
線程池類型以及使用場景
- newFixedThreadPool
適用於處理CPU密集型的任務,確保CPU在長期被工作線程使用的情況下,盡可能的少的分配線程,即適用執行長期的任務。
- newCachedThreadPool
用於並發執行大量短期的小任務。
- newSingleThreadExecutor
適用於串行執行任務的場景,一個任務一個任務地執行。
- newScheduledThreadPool
周期性執行任務的場景,需要限制線程數量的場景
- newWorkStealingPool
建一個含有足夠多線程的線程池,來維持相應的並行級別,它會通過工作竊取的方式,使得多核的 CPU 不會閑置,總會有活着的線程讓 CPU 去運行,本質上就是一個 ForkJoinPool。)
有興趣可以看我這篇文章哈:面試必備:Java線程池解析
12、談談volatile關鍵字的理解
volatile是面試官非常喜歡問的一個問題,可以回答以下這幾點:
- vlatile變量的作用
- 現代計算機的內存模型(嗅探技術,MESI協議,總線)
- Java內存模型(JMM)
- 什么是可見性?
- 指令重排序
- volatile的內存語義
- as-if-serial
- Happens-before
- volatile可以解決原子性嘛?為什么?
- volatile底層原理,如何保證可見性和禁止指令重排(內存屏障)
vlatile變量的作用?
- 保證變量對所有線程可見性
- 禁止指令重排
現代計算機的內存模型
- 其中高速緩存包括L1,L2,L3緩存~
- 緩存一致性協議,可以了解MESI協議
- 總線(Bus)是計算機各種功能部件之間傳送信息的公共通信干線,CPU和其他功能部件是通過總線通信的。
- 處理器使用嗅探技術保證它的內部緩存、系統內存和其他處理器的緩存數據在總線上保持一致。
Java內存模型(JMM)
什么是可見性?
可見性就是當一個線程 修改一個共享變量時,另外一個線程能讀到這個修改的值。
指令重排序
指令重排是指在程序執行過程中,為了提高性能, 編譯器和CPU可能會對指令進行重新排序。
volatile的內存語義
- 當寫一個 volatile 變量時,JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存。
- 當讀一個 volatile 變量時,JMM 會把該線程對應的本地內存置為無效。線程接下來將從主內存中讀取共享變量。
as-if-serial
如果在本線程內觀察,所有的操作都是有序的;即不管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不會被改變。
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
步驟C依賴於步驟A和B,因為指令重排的存在,程序執行順訊可能是A->B->C,也可能是B->A->C,但是C不能在A或者B前面執行,這將違反as-if-serial語義。
Happens-before
Java語言中,有一個先行發生原則(happens-before):
- 程序次序規則:在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在后面的操作。
- 管程鎖定規則:一個unLock操作先行發生於后面對同一個鎖額lock操作
- volatile變量規則:對一個變量的寫操作先行發生於后面對這個變量的讀操作
- 線程啟動規則:Thread對象的start()方法先行發生於此線程的每個一個動作
- 線程終止規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
- 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
- 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始
- 傳遞性:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
volatile可以解決原子性嘛?為什么?
不可以,可以直接舉i++那個例子,原子性需要synchronzied或者lock保證
public class Test {
public volatile int race = 0;
public void increase() {
race++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
//等待所有累加線程結束
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.race);
}
}
volatile底層原理,如何保證可見性和禁止指令重排(內存屏障)
volatile 修飾的變量,轉成匯編代碼,會發現多出一個lock前綴指令。lock指令相當於一個內存屏障,它保證以下這幾點:
- 1.重排序時不能把后面的指令重排序到內存屏障之前的位置
- 2.將本處理器的緩存寫入內存
- 3.如果是寫入動作,會導致其他處理器中對應的緩存無效。
2、3點保證可見性,第1點禁止指令重排~
有興趣的朋友可以看我這篇文章哈:Java程序員面試必備:Volatile全方位解析
13、AQS組件,實現原理
AQS,即AbstractQueuedSynchronizer,是構建鎖或者其他同步組件的基礎框架,它使用了一個int成員變量表示同步狀態,通過內置的FIFO隊列來完成資源獲取線程的排隊工作。可以回答以下這幾個關鍵點哈:
- state 狀態的維護。
- CLH隊列
- ConditionObject通知
- 模板方法設計模式
- 獨占與共享模式。
- 自定義同步器。
- AQS全家桶的一些延伸,如:ReentrantLock等。
state 狀態的維護
- state,int變量,鎖的狀態,用volatile修飾,保證多線程中的可見性。
- getState()和setState()方法采用final修飾,限制AQS的子類重寫它們兩。
- compareAndSetState()方法采用樂觀鎖思想的CAS算法操作確保線程安全,保證狀態
設置的原子性。
對CAS有興趣的朋友,可以看下我這篇文章哈~
CAS樂觀鎖解決並發問題的一次實踐
CLH隊列
CLH(Craig, Landin, and Hagersten locks) 同步隊列 是一個FIFO雙向隊列,其內部通過節點head和tail記錄隊首和隊尾元素,隊列元素的類型為Node。AQS依賴它來完成同步狀態state的管理,當前線程如果獲取同步狀態失敗時,AQS則會將當前線程已經等待狀態等信息構造成一個節點(Node)並將其加入到CLH同步隊列,同時會阻塞當前線程,當同步狀態釋放時,會把首節點喚醒(公平鎖),使其再次嘗試獲取同步狀態。
ConditionObject通知
我們都知道,synchronized控制同步的時候,可以配合Object的wait()、notify(),notifyAll() 系列方法可以實現等待/通知模式。而Lock呢?它提供了條件Condition接口,配合await(),signal(),signalAll() 等方法也可以實現等待/通知機制。ConditionObject實現了Condition接口,給AQS提供條件變量的支持 。
ConditionObject隊列與CLH隊列的愛恨情仇:
- 調用了await()方法的線程,會被加入到conditionObject等待隊列中,並且喚醒CLH隊列中head節點的下一個節點。
- 線程在某個ConditionObject對象上調用了singnal()方法后,等待隊列中的firstWaiter會被加入到AQS的CLH隊列中,等待被喚醒。
- 當線程調用unLock()方法釋放鎖時,CLH隊列中的head節點的下一個節點(在本例中是firtWaiter),會被喚醒。
模板方法設計模式
什么是模板設計模式?
在一個方法中定義一個算法的骨架,而將一些步驟延遲到子類中。模板方法使得子類可以在不改變算法結構的情況下,重新定義算法中的某些步驟。
AQS的典型設計模式就是模板方法設計模式啦。AQS全家桶(ReentrantLock,Semaphore)的衍生實現,就體現出這個設計模式。如AQS提供tryAcquire,tryAcquireShared等模板方法,給子類實現自定義的同步器。
獨占與共享模式
- 獨占式: 同一時刻僅有一個線程持有同步狀態,如ReentrantLock。又可分為公平鎖和非公平鎖。
- 共享模式:多個線程可同時執行,如Semaphore/CountDownLatch等都是共享式的產物。
自定義同步器
你要實現自定義鎖的話,首先需要確定你要實現的是獨占鎖還是共享鎖,定義原子變量state的含義,再定義一個內部類去繼承AQS,重寫對應的模板方法即可啦
AQS全家桶的一些延伸。
Semaphore,CountDownLatch,ReentrantLock
可以看下之前我這篇文章哈,AQS解析與實戰
14、什么是多線程環境下的偽共享
- 什么是偽共享
- 如何解決偽共享問題
什么是偽共享
偽共享定義?
CPU的緩存是以緩存行(cache line)為單位進行緩存的,當多個線程修改相互獨立的變量,而這些變量又處於同一個緩存行時就會影響彼此的性能。這就是偽共享
現代計算機計算模型,大家都有印象吧?我之前這篇文章也有講過,有興趣可以看一下哈,Java程序員面試必備:Volatile全方位解析
- CPU執行速度比內存速度快好幾個數量級,為了提高執行效率,現代計算機模型演變出CPU、緩存(L1,L2,L3),內存的模型。
- CPU執行運算時,如先從L1緩存查詢數據,找不到再去L2緩存找,依次類推,直到在內存獲取到數據。
- 為了避免頻繁從內存獲取數據,聰明的科學家設計出緩存行,緩存行大小為64字節。
也正是因為緩存行,就導致偽共享問題的存在,如圖所示:
假設數據a、b被加載到同一個緩存行。
- 當線程1修改了a的值,這時候CPU1就會通知其他CPU核,當前緩存行(Cache line)已經失效。
- 這時候,如果線程2發起修改b,因為緩存行已經失效了,所以core2 這時會重新從主內存中讀取該 Cache line 數據。讀完后,因為它要修改b的值,那么CPU2就通知其他CPU核,當前緩存行(Cache line)又已經失效。
- 醬紫,如果同一個Cache line的內容被多個線程讀寫,就很容易產生相互競爭,頻繁回寫主內存,會大大降低性能。
如何解決偽共享問題
既然偽共享是因為相互獨立的變量存儲到相同的Cache line導致的,一個緩存行大小是64字節。那么,我們就可以使用空間換時間,即數據填充的方式,把獨立的變量分散到不同的Cache line~
共享內存demo例子:
public class FalseShareTest {
public static void main(String[] args) throws InterruptedException {
Rectangle rectangle = new Rectangle();
long beginTime = System.currentTimeMillis();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
rectangle.a = rectangle.a + 1;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
rectangle.b = rectangle.b + 1;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("執行時間" + (System.currentTimeMillis() - beginTime));
}
}
class Rectangle {
volatile long a;
volatile long b;
}
運行結果:
執行時間2815
一個long類型是8字節,我們在變量a和b之間不上7個long類型變量呢,輸出結果是啥呢?如下:
class Rectangle {
volatile long a;
long a1,a2,a3,a4,a5,a6,a7;
volatile long b;
}
運行結果:
執行時間1113
可以發現利用填充數據的方式,讓讀寫的變量分割到不同緩存行,可以很好挺高性能~
15、 說一下 Runnable和 Callable有什么區別?
- Callable接口方法是call(),Runnable的方法是run();
- Callable接口call方法有返回值,支持泛型,Runnable接口run方法無返回值。
- Callable接口call()方法允許拋出異常;而Runnable接口run()方法不能繼續上拋異常;
@FunctionalInterface
public interface Callable<V> {
/**
* 支持泛型V,有返回值,允許拋出異常
*/
V call() throws Exception;
}
@FunctionalInterface
public interface Runnable {
/**
* 沒有返回值,不能繼續上拋異常
*/
public abstract void run();
}
看下demo代碼吧,這樣應該好理解一點哈~
/*
* @Author 撿田螺的小男孩
* @date 2020-08-18
*/
public class CallableRunnableTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
Callable <String> callable =new Callable<String>() {
@Override
public String call() throws Exception {
return "你好,callable";
}
};
//支持泛型
Future<String> futureCallable = executorService.submit(callable);
try {
System.out.println(futureCallable.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("你好呀,runnable");
}
};
Future<?> futureRunnable = executorService.submit(runnable);
try {
System.out.println(futureRunnable.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
executorService.shutdown();
}
}
運行結果:
你好,callable
你好呀,runnable
null
16、wait(),notify()和suspend(),resume()之間的區別
- wait() 使得線程進入阻塞等待狀態,並且釋放鎖
- notify()喚醒一個處於等待狀態的線程,它一般跟wait()方法配套使用。
- suspend()使得線程進入阻塞狀態,並且不會自動恢復,必須對應的resume() 被調用,才能使得線程重新進入可執行狀態。suspend()方法很容易引起死鎖問題。
- resume()方法跟suspend()方法配套使用。
suspend()不建議使用,suspend()方法在調用后,線程不會釋放已經占有的資 源(比如鎖),而是占有着資源進入睡眠狀態,這樣容易引發死鎖問題。
17.Condition接口及其實現原理
- Condition接口與Object監視器方法對比
- Condition接口使用demo
- Condition實現原理
Condition接口與Object監視器方法對比
Java對象(Object),提供wait()、notify(),notifyAll() 系列方法,配合synchronized,可以實現等待/通知模式。而Condition接口配合Lock,通過await(),signal(),signalAll() 等方法,也可以實現類似的等待/通知機制。
對比項 | 對象監視方法 | Condition |
---|---|---|
前置條件 | 獲得對象的鎖 | 調用Lock.lock()獲取鎖,調用Lock.newCondition()獲得Condition對象 |
調用方式 | 直接調用,object.wait() | 直接調用,condition.await() |
等待隊列數 | 1個 | 多個 |
當前線程釋放鎖並進入等待狀態 | 支持 | 支持 |
在等待狀態中不響應中斷 | 不支持 | 支持 |
當前線程釋放鎖並進入超時等待狀態 | 支持 | 支持 |
當前線程釋放鎖並進入等待狀態到將來的某個時間 | 不支持 | 支持 |
喚醒等待隊列中的一個線程 | 支持 | 支持 |
喚醒等待隊列中的全部線程 | 支持 | 支持 |
Condition接口使用demo
public class ConditionTest {
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}
}
public void conditionSignal() throws InterruptedException {
lock.lock();
try {
condition.signal();
} finally {
lock.unlock();
}
}
}
Condition實現原理
其實,同步隊列和等待隊列中節點類型都是同步器的靜態內部類 AbstractQueuedSynchronizer.Node,接下來我們圖解一下Condition的實現原理~
等待隊列的基本結構圖
一個Condition包含一個等待隊列,Condition擁有首節點(firstWaiter)和尾節點 (lastWaiter)。當前線程調用Condition.await()方法,將會以當前線程構造節點,並將節點從尾部加入等待隊
AQS 結構圖
ConditionI是跟Lock一起結合使用的,底層跟同步器(AQS)相關。同步器擁有一個同步隊列和多個等待隊列~
等待
當調用await()方法時,相當於同步隊列的首節點(獲取了鎖的節點)移動到Condition的等待隊列中。
通知
調用Condition的signal()方法,將會喚醒在等待隊列中等待時間最長的節點(首節點),在
喚醒節點之前,會將節點移到同步隊列中。
18、線程池如何調優,最大數目如何確認?
在《Java Concurrency in Practice》一書中,有一個評估線程池線程大小的公式
Nthreads=NcpuUcpu(1+w/c)
- Ncpu = CPU總核數
- Ucpu =cpu使用率,0~1
- W/C=等待時間與計算時間的比率
假設cpu 100%運轉,則公式為
Nthreads=Ncpu*(1+w/c)
估算的話,醬紫:
- 如果是IO密集型應用(如數據庫數據交互、文件上傳下載、網絡數據傳輸等等),IO操作一般比較耗時,等待時間與計算時間的比率(w/c)會大於1,所以最佳線程數估計就是 Nthreads=Ncpu*(1+1)= 2Ncpu 。
- 如果是CPU密集型應用(如算法比較復雜的程序),最理想的情況,沒有等待,w=0,Nthreads=Ncpu。又對於計算密集型的任務,在擁有N個處理器的系統上,當線程池的大小為N+1時,通常能實現最優的效率。所以 Nthreads = Ncpu+1
有具體指參考呢?舉個例子
比如平均每個線程CPU運行時間為0.5s,而線程等待時間(非CPU運行時間,比如IO)為1.5s,CPU核心數為8,那么根據上面這個公式估算得到:線程池大小=(1+1.5/05)*8 =32。
參考了網上這篇文章,寫得很棒,有興趣的朋友可以去看一下哈:
19、 假設有T1、T2、T3三個線程,你怎樣保證T2在T1執行完后執行,T3在T2執行完后執行?
可以使用join方法解決這個問題。比如在線程A中,調用線程B的join方法表示的意思就是:A等待B線程執行完畢后(釋放CPU執行權),在繼續執行。
代碼如下:
public class ThreadTest {
public static void main(String[] args) {
Thread spring = new Thread(new SeasonThreadTask("春天"));
Thread summer = new Thread(new SeasonThreadTask("夏天"));
Thread autumn = new Thread(new SeasonThreadTask("秋天"));
try
{
//春天線程先啟動
spring.start();
//主線程等待線程spring執行完,再往下執行
spring.join();
//夏天線程再啟動
summer.start();
//主線程等待線程summer執行完,再往下執行
summer.join();
//秋天線程最后啟動
autumn.start();
//主線程等待線程autumn執行完,再往下執行
autumn.join();
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
class SeasonThreadTask implements Runnable{
private String name;
public SeasonThreadTask(String name){
this.name = name;
}
@Override
public void run() {
for (int i = 1; i <4; i++) {
System.out.println(this.name + "來了: " + i + "次");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
運行結果:
春天來了: 1次
春天來了: 2次
春天來了: 3次
夏天來了: 1次
夏天來了: 2次
夏天來了: 3次
秋天來了: 1次
秋天來了: 2次
秋天來了: 3次
20. LockSupport作用是?
- LockSupport作用
- park和unpark,與wait,notify的區別
- Object blocker作用?
LockSupport是個工具類,它的主要作用是掛起和喚醒線程, 該工具類是創建鎖和其他同步類的基礎。
public static void park(); //掛起當前線程,調用unpark(Thread thread)或者當前線程被中斷,才能從park方法返回
public static void parkNanos(Object blocker, long nanos); // 掛起當前線程,有超時時間的限制
public static void parkUntil(Object blocker, long deadline); // 掛起當前線程,直到某個時間
public static void park(Object blocker); //掛起當前線程
public static void unpark(Thread thread); // 喚醒當前thread線程
看個例子吧:
public class LockSupportTest {
public static void main(String[] args) {
CarThread carThread = new CarThread();
carThread.setName("勞斯勞斯");
carThread.start();
try {
Thread.currentThread().sleep(2000);
carThread.park();
Thread.currentThread().sleep(2000);
carThread.unPark();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static class CarThread extends Thread{
private boolean isStop = false;
@Override
public void run() {
System.out.println(this.getName() + "正在行駛中");
while (true) {
if (isStop) {
System.out.println(this.getName() + "車停下來了");
LockSupport.park(); //掛起當前線程
}
System.out.println(this.getName() + "車還在正常跑");
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void park() {
isStop = true;
System.out.println("停車啦,檢查酒駕");
}
public void unPark(){
isStop = false;
LockSupport.unpark(this); //喚醒當前線程
System.out.println("老哥你沒酒駕,繼續開吧");
}
}
}
運行結果:
勞斯勞斯正在行駛中
勞斯勞斯車還在正常跑
勞斯勞斯車還在正常跑
停車啦,檢查酒駕
勞斯勞斯車停下來了
老哥你沒酒駕,繼續開吧
勞斯勞斯車還在正常跑
勞斯勞斯車還在正常跑
勞斯勞斯車還在正常跑
勞斯勞斯車還在正常跑
勞斯勞斯車還在正常跑
勞斯勞斯車還在正常跑
LockSupport的park和unpark的實現,有點類似wait和notify的功能。但是
- park不需要獲取對象鎖
- 中斷的時候park不會拋出InterruptedException異常,需要在park之后自行判斷中斷狀態
- 使用park和unpark的時候,可以不用擔心park的時序問題造成死鎖
- LockSupport不需要在同步代碼塊里
- unpark卻可以喚醒一個指定的線程,notify只能隨機選擇一個線程喚醒
Object blocker作用?
方便在線程dump的時候看到具體的阻塞對象的信息。