面經——多線程
- 創建線程和終止線程方式
- Runnable和callable區別
- synchronize問題詳解
- 樂觀鎖和悲觀鎖
- 線程安全和非線程安全區別
- JMM 內存模型
- volatile解析
- 公平鎖和非公平鎖區別?爲什麼公平鎖效率低?
- 鎖優化(自旋鎖、自適應自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖、重量級鎖解釋)
- AQS原理及應用
- CAS
- 線程同步方法
- ThreadLocal原理
- ReenTrantLock原理
- 線程狀態,start,run,wait,notify,yiled,sleep,join等方法的作用以及區別
- 關於 Atomic 原子類
- 線程池相關
- 手寫簡單的線程池,體現線程複用
- 手寫消費者生產者模式
- 手寫阻塞隊列
- 手寫多線程交替打印ABC
注:題目從牛客 Java部門面經整理而來。
2020秋招面經大彙總!(崗位劃分)
1. 創建線程和終止線程方式
創建線程有四種方式:
- 繼承Thread 重寫 run 方法。
- 實現 Runnable 接口。
- 實現 Callable 接口。
- 使用Executor框架來創建線程池
中斷線程方式:
- new Thread().isInterrupted() 方法用於獲取當前線程的中斷狀態
- new Thread().interrupted() 方法用於設置當前線程的中斷狀態,即中斷當前線程
- Thread.interrupted()用於獲取當前線程的中斷狀態,同時還會重置中斷狀態
2. Runnable和callable區別
- Runnable 沒有返回值,Callable 可以拿到有返回值,Callable 可以看作是 Runnable 的補充。
- Callable接口的call()方法允許拋出異常;Runnable的run()方法異常只能在內部消化,不能往上繼續拋
3. synchronize問題詳解
synchronized 問題新開了一遍筆記:synchronized面試五連擊
4. 樂觀鎖和悲觀鎖
1. 樂觀鎖和悲觀鎖區別
-
悲觀鎖:總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖(共享資源每次只給一個線程使用,其它線程阻塞,用完後再把資源轉讓給其它線程)。傳統的關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。
-
樂觀鎖:總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號機制和CAS算法實現。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。
2. 兩種鎖的使用場景
從上面對兩種鎖的介紹,我們知道兩種鎖各有優缺點,不可認爲一種好於另一種,像樂觀鎖適用於寫比較少的情況下(多讀場景),即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果是多寫的情況,一般會經常產生衝突,這就會導致上層應用會不斷的進行retry,這樣反倒是降低了性能,所以一般多寫的場景下用悲觀鎖就比較合適。
5. 線程安全和非線程安全區別
線程安全就是多線程訪問時,採用了加鎖機制,當一個線程訪問該類的某個數據時,進行保護,其他線程不能進行訪問直到該線程讀取完,其他線程纔可使用。不會出現數據不一致或者數據污染。
線程不安全就是不提供數據訪問保護,有可能出現多個線程先後更改數據造成所得到的數據是髒數據
比如ArrayList是非線程安全的,Vector是線程安全的;HashMap是非線程安全的,HashTable是線程安全的;StringBuilder是非線程安全的,StringBuffer是線程安全的。
6. JMM 內存模型
Java 內存模型試圖屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存
訪問效果。
主內存與工作內存
處理器上的寄存器的讀寫的速度比內存快幾個數量級,爲了解決這種速度矛盾,在它們之間加入了高速緩存。
加入高速緩存帶來了一個新的問題:緩存一致性。如果多個緩存共享同一塊主內存區域,那麼多個緩存的數據可能會不一致,需要一些協議來解決這個問題。
解決緩存一致性方案有兩種:
- 通過在總線加LOCK#鎖的方式;
- 通過緩存一致性協議。
但是方案1存在一個問題,它是採用一種獨佔的方式來實現的,即總線加LOCK#鎖的話,只能有一個CPU能夠運行,其他CPU都得阻塞,效率較爲低下。
方案二 緩存一致性協議(MESI協議)它確保每個緩存中使用的共享變量的副本是一致的。所以JMM就解決這個問題。
JMM(Java內存模型Java Memory Model,簡稱JMM)本身是一種抽象的概念並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。
JMM關於同步的規定:
- 線程解鎖前,必須把共享變量的值刷新回主內存
- 線程加鎖前,必須讀取主內存的最新值到自己的工作內存
- 加鎖解鎖是同一把鎖
由於JVM運行程序的實體是線程,而每個線程創建時JVM都會爲其創建一個工作內存(有些地方稱爲棧空間),工作內存是每個線程的私有數據區域,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量 的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝的自己的工作內存空間,然後對變量進行操作,操作完成 後再將變量寫回主內存,不能直接操作主內存中的變量,各個線程中的工作內存中存儲着主內存中的變量副本拷貝,因此不同的線程間無法去訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成
內存間交互操作
Java 內存模型定義了 8 個操作來完成主內存和工作內存的交互操作。
- read:把一個變量的值從主內存傳輸到工作內存中
- load:在 read 之後執行,把 read 得到的值放入工作內存的變量副本中
- use:把工作內存中一個變量的值傳遞給執行引擎
- assign:把一個從執行引擎接收到的值賦給工作內存的變量
- store:把工作內存的一個變量的值傳送到主內存中
- write:在 store 之後執行,把 store 得到的值放入主內存的變量中
- lock:作用於主內存的變量
- unlock
內存模型三大特性
1. 原子性
Java 內存模型保證了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如對一個 int 類型的變量執行 assign 賦值操作,這個操作就是原子性的。但是 Java 內存模型允許虛擬機將沒有被 volatile 修飾的64 位數據(long,double)的讀寫操作劃分爲兩次 32 位的操作來進行,即 load、store、read 和 write 操作可以不具備原子性。
有一個錯誤認識就是,int 等原子性的類型在多線程環境中不會出現線程安全問題。前面的線程不安全示例代碼中,
cnt 屬於 int 類型變量,1000 個線程對它進行自增操作之後,得到的值爲 997 而不是 1000。
爲了方便討論,將內存間的交互操作簡化爲 3 個:load、assign、store。
下圖演示了兩個線程同時對 cnt 進行操作,load、assign、store 這一系列操作整體上看不具備原子性,那麼在 T1修改 cnt 並且還沒有將修改後的值寫入主內存,T2 依然可以讀入舊值。可以看出,這兩個線程雖然執行了兩次自增運算,但是主內存中 cnt 的值最後爲 1 而不是 2。因此對 int 類型讀寫操作滿足原子性只是說明 load、assign、store 這些單個操作具備原子性。
AtomicInteger 能保證多個線程修改的原子性。
除了使用原子類之外,也可以使用 synchronized 互斥鎖來保證操作的原子性。它對應的內存間交互操作爲:lock 和
unlock,在虛擬機實現上對應的字節碼指令爲 monitorenter 和 monitorexit。
2. 可見性
可見性指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值來實現可見性的。
主要有三種實現可見性的方式:
- volatile
- synchronized,對一個變量執行 unlock 操作之前,必須把變量值同步回主內存。
- final,被 final 關鍵字修飾的字段在構造器中一旦初始化完成,並且沒有發生 this 逃逸(其它線程通過 this 引用訪問到初始化了一半的對象),那麼其它線程就能看見 final 字段的值。
對前面的線程不安全示例中的 cnt 變量使用 volatile 修飾,不能解決線程不安全問題,因爲 volatile 並不能保證操作的原子性
3. 有序性
有序性是指:在本線程內觀察,所有操作都是有序的。在一個線程觀察另一個線程,所有操作都是無序的,無序是因爲發生了指令重排序。在 Java 內存模型中,允許編譯器和處理器對指令進行重排序,重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性。
volatile 關鍵字通過添加內存屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到內存屏障之前。
也可以通過 synchronized 來保證有序性,它保證每個時刻只有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼。
擴展:內存屏障(Memory Barrier)又稱內存柵欄,是一個CPU指令,它的作用有兩個:
- 保證特定操作的執行順序,
- 保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。
由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能
和這條MemoryBarrier指令重排序,也就是說通過插入內存屏障禁止在內存屏障前後的指令執行重排序優化。內存屏障另外一個作
用是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。
7. volatile解析
volatile是 java虛擬機 提供的輕量級的同步機制(可以理解成乞丐版的synchronized)
特性有:
- 保證可見性
- 不保證原子性
- 禁止指令重排
1. 保證可見性
理解volatile特性之一保證可見性之前要先理解什麼是JMM內存模型的可見性(參考上面),JMM 內存模型就是 volatile保證可見性特性的原理。
2. 不保證原子性
i++;
這條代碼可以分爲3個步驟:
- 從主內存取值;
- 執行+1;
- 值重新寫回主內存
如果使用volatile修飾,它只能保證第一步是從主內存取得最新值和指令不被重新排序.
例如:從主內存取到最新的值a=1,線程A執行完+1操作(a=2),如果這個時候線程A讓出時間片,其他線程修改a的值爲5,線程A繼續執行,把a=2寫如到主內存,這個時候就線程不安全了。主要原因就是把值寫回到主內存時,並沒有判斷主內存的最新值和之前取到的值一樣就寫回主內存了。所以,volatile不保證原子性。
那麼如何解決volatile不保證原子性的問題?
我們可以用java.util.concurrent.atomic包下的 AtomicInteger解決這個問題。
3. 禁止指令重排
volatile實現禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象。volatile禁止指令重排功能 依賴內存屏障(內容見上)實現。
4. 單例模式下 volatile 的作用
在多線程環境下,底層爲了優化有指令重排,加入volatile可以禁止指令重排。
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
8. 公平鎖和非公平鎖區別?爲什麼公平鎖效率低?
1. 公平鎖和非公平鎖區別?
-
公平鎖:在併發壞境中.每個線程在獲取鎖時會先查看此鎖維護的等待隊列,如果爲空,或者當前線程是等待隊列的第一個,就佔有鎖。否則就會加入到等待隊列中,以後會按照FIFO的規則從隊列中取到鎖。
-
非公平鎖:上來就直接嘗試佔有鎖,如果嘗試失敗,就執行公平鎖邏輯。
2. 爲什麼公平鎖效率低?
公平鎖要維護一個隊列,後來的線程要加鎖,即使鎖空閒,也要先檢查有沒有其他線程在等待,如果有自己要掛起,加到隊列後面,然後喚醒隊列最前面的線程。這種情況下相比較非公平鎖多了一次掛起和喚醒,多了線程切換的開銷,這就是非公平鎖效率高於公平鎖的原因,因爲非公平鎖減少了線程掛起的機率,後來的線程有一定機率逃離被掛起的開銷。
9. 鎖優化(自旋鎖、自適應自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖、重量級鎖解釋)
這裏的鎖優化主要是指 JVM 對 synchronized 的優化。
1. 自旋鎖和自適應自旋鎖
互斥同步對性能的最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要轉入內核態中完成。在許多應用中,共享數據的鎖定狀態只會持續很短的一段時間。自旋鎖的思想是讓一個線程在請求一個共享數據的鎖時執行忙循環(自旋)一段時間,如果在這段時間內能獲得鎖,就可以避免進入阻塞狀態。
自旋鎖雖然能避免進入阻塞狀態從而減少開銷,但是它需要進行忙循環操作佔用 CPU 時間,它只適用於共享數據的鎖定狀態很短的場景。自旋等待的時間必須有一定的限度,超過了限定的次數仍然沒有成功獲取鎖,就應當使用傳統的方式掛起線程了。自旋次數的默認值是10,用戶可以通過-XX:PreBlockSpin來更改。
在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味着自旋的次數不再固定了,而是由前一次在同一個鎖上的自旋次數及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋線程之前剛剛獲得過鎖,且現在持有鎖的線程正在運行中,那麼虛擬機會認爲這次自旋也很有可能會成功,進而允許該線程等待持續相對更長的時間,比如100個循環。反之,如果某個鎖自旋很少獲得過成功,那麼之後再獲取鎖的時候將可能省略掉自旋過程,以避免浪費處理器資源。
2. 鎖消除
鎖消除是指對於被檢測出不可能存在競爭的共享數據的鎖進行消除。
鎖消除主要是通過逃逸分析來支持,如果堆上的共享數據不可能逃逸出去被其它線程訪問到,那麼就可以把它們當成私有數據對待,也就可以將它們的鎖進行消除。
對於一些看起來沒有加鎖的代碼,其實隱式的加了很多鎖。例如下面的字符串拼接代碼就隱式加了鎖:
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一個不可變的類,編譯器會對 String 的拼接自動優化。在 JDK 1.5 之前,會轉化爲StringBuffer 對象的連續 append() 操作:
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每個 append() 方法中都有一個同步塊。虛擬機觀察變量 sb,很快就會發現它的動態作用域被限制在 concatString() 方法內部。也就是說,sb 的所有引用永遠不會逃逸到 concatString() 方法之外,其他線程無法訪問到它,因此可以進行消除。
3. 鎖粗化
如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,頻繁的加鎖操作就會導致性能損耗。
上一節的示例代碼中連續的 append() 方法就屬於這類情況。如果虛擬機探測到由這樣的一串零碎的操作都對同一個對象加鎖,將會把加鎖的範圍擴展(粗化)到整個操作序列的外部。對於上一節的示例代碼就是擴展到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。
4. 偏向鎖
引入偏向鎖的目的和引入輕量級鎖的目的很像,它們都是爲了沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。但是不同是:輕量級鎖在無競爭的情況下使用 CAS 操作去代替使用互斥量,而偏向鎖在無競爭的情況下會把整個同步都消除掉。
偏向鎖,顧名思義,它會偏向於第一個訪問鎖的線程,如果在運行過程中,同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發同步的,這種情況下,就會給線程加一個偏向鎖。
如果在運行過程中,遇到了其他線程搶佔鎖,則持有偏向鎖的線程會被掛起,JVM會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。
5. 輕量級鎖
輕量級鎖的目標是,減少無實際競爭情況下,使用重量級鎖產生的性能消耗,包括系統調用引起的內核態與用戶態切換、線程阻塞造成的線程切換等。
如果沒有競爭,輕量級鎖使用 CAS 操作避免了使用互斥操作的開銷。但如果存在鎖競爭,除了互斥量開銷外,還會額外發生CAS操作,因此在有鎖競爭的情況下,輕量級鎖比傳統的重量級鎖更慢,如果鎖競爭激烈,那麼輕量級將很快膨脹爲重量級鎖。
6. 重量級鎖
內置鎖在Java中被抽象爲監視器鎖(monitor)。在JDK 1.6之前,監視器鎖可以認爲直接對應底層操作系統中的互斥量(mutex)。這種同步方式的成本非常高,包括系統調用引起的內核態與用戶態切換、線程阻塞造成的線程切換等。因此稱這種鎖爲“重量級鎖”。
10. AQS原理及應用
1. AQS 介紹
AQS的全稱爲(AbstractQueuedSynchronizer),這個類在java.util.concurrent.locks包下面。
AQS是一個用來構建鎖和同步器的框架,使用AQS能簡單且高效地構造出應用廣泛的大量的同步器,比如我們提到的 ReentrantLock,Semaphore,其他的諸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基於AQS的。
2. AQS 原理概覽
AQS核心思想是,如果被請求的共享資源空閒,則將當前請求資源的線程設置爲有效的工作線程,並且將共享資源設置爲鎖定狀態。如果被請求的共享資源被佔用,那麼就需要一套線程阻塞等待以及被喚醒時鎖分配的機制,這個機制AQS是用CLH隊列鎖實現的,即將暫時獲取不到鎖的線程加入到隊列中。
CLH(Craig,Landin,and Hagersten)隊列是一個虛擬的雙向隊列(虛擬的雙向隊列即不存在隊列實例,僅存在結點之間的關聯關係)。AQS是將每條請求共享資源的線程封裝成一個CLH鎖隊列的一個結點(Node)來實現鎖的分配。
看個AQS(AbstractQueuedSynchronizer)原理圖:
AQS使用一個int成員變量來表示同步狀態,通過內置的FIFO隊列來完成獲取資源線程的排隊工作。AQS使用CAS對該
同步狀態進行原子操作實現對其值的修改。
private volatile int state;//共享變量,使用volatile修飾保證線程可見性
狀態信息通過procted類型的getState,setState,compareAndSetState進行操作。
//返回同步狀態的當前值
protected final int getState() {
return state;
}
// 設置同步狀態的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)將同步狀態值設置爲給定值update如果當前同步狀態的值等於expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
3. AQS 對資源的共享方式
AQS定義兩種資源共享方式
- Exclusive(獨佔):只有一個線程能執行,如ReentrantLock。又可分爲公平鎖和非公平鎖:
公平鎖:按照線程在隊列中的排隊順序,先到者先拿到鎖
非公平鎖:當線程要獲取鎖時,無視隊列順序直接去搶鎖,誰搶到就是誰的 - Share(共享):多個線程可同時執行,如Semaphore/CountDownLatch。Semaphore、
ReentrantReadWriteLock 可以看成是組合式,因爲ReentrantReadWriteLock也就是讀寫鎖允許多個線程同時對某
一資源進行讀。
不同的自定義同步器爭用共享資源的方式也不同。自定義同步器在實現時只需要實現共享資源 state 的獲取與釋放方
式即可,至於具體線程等待隊列的維護(如獲取資源失敗入隊/喚醒出隊等),AQS已經在頂層實現好了。
4. AQS底層使用了模板方法模式
同步器的設計是基於模板方法模式的,如果需要自定義同步器一般的方式是這樣(模板方法模式很經典的一個應
用):
- 使用者繼承AbstractQueuedSynchronizer並重寫指定的方法。(這些重寫方法很簡單,無非是對於共享資源state的獲取和釋放)
- 將AQS組合在自定義同步組件的實現中,並調用其模板方法,而這些模板方法會調用使用者重寫的方法。
這和我們以往通過實現接口的方式有很大區別,這是模板方法模式很經典的一個運用。
AQS使用了模板方法模式,自定義同步器時需要重寫下面幾個AQS提供的模板方法:
isHeldExclusively()//該線程是否正在獨佔資源。只有用到condition才需要去實現它。
tryAcquire(int)//獨佔方式。嘗試獲取資源,成功則返回true,失敗則返回false。
tryRelease(int)//獨佔方式。嘗試釋放資源,成功則返回true,失敗則返回false。
tryAcquireShared(int)//共享方式。嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。
tryReleaseShared(int)//共享方式。嘗試釋放資源,成功則返回true,失敗則返回false。
默認情況下,每個方法都拋出 UnsupportedOperationException 。 這些方法的實現必須是內部線程安全的,並且
通常應該簡短而不是阻塞。AQS類中的其他方法都是final ,所以無法被其他類使用,只有這幾個方法可以被其他類
使用。
以ReentrantLock爲例,state初始化爲0,表示未鎖定狀態。A線程lock()時,會調用tryAcquire()獨佔該鎖並將state+1。此後,其他線程再tryAcquire()時就會失敗,直到A線程unlock()到state=0(即釋放鎖)爲止,其它線程纔有機會獲取該鎖。當然,釋放鎖之前,A線程自己是可以重複獲取此鎖的(state會累加),這就是可重入的概念。但要注意,獲取多少次就要釋放多麼次,這樣才能保證state是能回到零態的。
再以CountDownLatch以例,任務分爲N個子線程去執行,state也初始化爲N(注意N要與線程個數一致)。這N個子線程是並行執行的,每個子線程執行完後countDown()一次,state會CAS(Compare and Swap)減1。等到所有子線程都執行完後(即state=0),會unpark()主調用線程,然後主調用線程就會從await()函數返回,繼續後餘動作。
一般來說,自定義同步器要麼是獨佔方法,要麼是共享方式,他們也只需實現 tryAcquire-tryRelease 、tryAcquireShared-tryReleaseShared 中的一種即可。但AQS也支持自定義同步器同時實現獨佔和共享兩種方式,如 ReentrantReadWriteLock 。
5. AQS 組件總結
Semaphore(信號量)-允許多個線程同時訪問: synchronized 和 ReentrantLock 都是一次只允許一個線程訪問某個資源,Semaphore(信號量)可以指定多個線程同時訪問某個資源。
CountDownLatch (倒計時器): CountDownLatch是一個同步工具類,用來協調多個線程之間的同步。這個工具通常用來控制線程等待,讓一些線程阻塞直到另一些線程完成一系列操作後才被喚醒。
CyclicBarrier(循環柵欄): CyclicBarrier的字面意思是可循環使用(Cyclic)的屏障(Barrier)。作用是讓一組線程到達一個屏障(也可以叫
同步點)時被阻塞,直到最後一個線程到達屏障時,屏障纔會開門,所有被屏障攔截的線程纔會繼續幹活。CyclicBarrier默認的構造方法是 CyclicBarrier(int parties),其參數表示屏障攔截的線程數量,每個線程調用await方法告訴 CyclicBarrier 我已經到達了屏障,然後當前線程被阻塞。
11. CAS
1. CAS是什麼
CAS的全稱爲Compare-And-Swap,它是一條CPU併發原語。
它的功能是判斷內存某個位置的值是否爲期望值,如果是則更改爲新的值,這個過程是原子的。
CAS併發原語體現在JAVA語言中就是sun.misc.Unsafe類中的CAS方法,JVM會幫我們實現CAS彙編指令。這是一種完全依賴於硬件的功能,通過它實現了原子操作。再次強調,由於CAS是一種系統原語,原語屬於操作系統用語範疇範,是由若干條指令組成的,用於完成某個功能的一個過程,並且原語的執行必須是連續的,在執行過程中不允許被中斷,也就說CAS是一條CPU的原了指令,不會造成所謂的數據不一致問題。
2. CAS底層原理Unsafe深入解析
Unsafe是CAS的核心類,由於Java方法無法直接訪問底層系統,需要通過本地(native)方法來訪問,Unsafe相當於一個後門,基於該類可以直接操作特定內存的數據。Unsafe類存在於sun.misc包中,其內部方法操作可以像C的指針一樣直接操作內存,因爲Java中CAS操作的執行依賴於Unsafe類的方法。
注意Unsafe類中的所有方法都是native修飾的,也就是Unsafe類中的方法都直接調用操作系統底層資源執行相應任務。
原子整型在i++中操作多線程環境下不需要加synchronized,也能保證線程安全,是因爲它用的是Unsafe類,源代碼如下:
getAndAddInt()方法底層調用的是unsafe,傳三個參數,當前對象,內存地址偏移量,增量1。底層調用的是CAS思想,如果比較成功+1,失敗再重新獲得比較一次,直至成功爲止。
var1 AtomicInteger 對象本身
var2 該對象值的引用地址
var4 需要變動的數量
var5 是用 var1,var2 找出的主內存中真實的值,用該對象當前的值與 var5 比較:如果相同,更新var5+var4並返回 var5。如果不同,繼續取值然後再比較,直到更新完成。
3. CAS缺點
- 循環時間開銷很大
通過看源碼,我們發現有個do while,如果CAS失敗,會一直進行嘗試。如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷。 - 只能保證一個共享變量的原子操作
當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作。但是,對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可用鎖來保證原子性。ABA問題概述 - CAS會導致"ABA問題”
CAS算法實現一個重要前提需要取出內存中某時刻的數據並在當下時刻比較並替換,那麼在這個時間差類會導致數據的變化。
比如說一個線程1 從內存位置V中取出A,這時候另一個線程2 也從內存中取出A,並且線程2 了一些操作將值變成了B,然後線程2 又將V位置的數據變成A,這時候線程1 進行CAS作發現內存中仍然是A,然後線程One操作成功。
儘管線程One的CAS慢作成功,但是不代表這個過程就是沒有問題的。
解決:解決ABA問題只靠CAS不能解決,還需要用到原子引用技術。即AtomicReference
12. 線程同步方法
1. 同步方法
synchronized 關鍵字修飾方法。由於 java 的每個對象都有一個內置鎖,當用此關鍵字修飾方法時,內置鎖會保護整個方法。在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。synchronized關鍵字也可以修飾靜態方法,此時如果調用該靜態方法,將會鎖住整個類.
代碼如下:
public synchronized void save(){}
2. 同步代碼塊
synchronized關鍵字修飾的語句塊。被該關鍵字修飾的語句塊會自動加上內置鎖,從而實現同步。同步是一種高開銷的操作,因此應該儘量減少同步內容。通常沒有必要同步整個方法,使用synchronized代碼塊同步關鍵代碼即可。
代碼如下:
synchronized(object){}
3. 使用特殊域變量(volatile)實現線程同步
- volatile關鍵字爲域變量的訪問提供一種免鎖機制
- 使用volatile修飾域相當於告訴虛擬機該域可能被其他現象更新
- 因此每次使用該域就要重新計算,而不是使用寄存器中的值
- volatile不會提供任何原子操作,它也不能用來修飾final類型的變量
4. 使用重入鎖實現線程同步
在 javaSE5.0 新增了一個 java.concurrent 包來支持同步。ReentrantLock類可以重入、互斥、實現了Lock接口的鎖。
5. 使用局部變量實現線程同步
如果使用ThreadLocal管理變量,則每一個使用變量的線程都獲得該變量的副本,副本之間相互獨立,這樣每一個線程都可以隨意修改自己的變量副本,而不會對其他線程產生影響。
ThreadLocal與同步機制:
- ThreadLocal與同步機制都是爲了解決多線程中相同變量的訪問衝突問題
- 前者採用以“空間換時間”的方法,後者採用以“時間換空間”的方式
6.使用阻塞隊列實現線程同步
7.使用原子變量實現線程同步
需要使用線程同步的根本原因在於對普通變量的操作不是原子的。
原子操作就是指將讀取變量值、修改變量值、保存變量值看成一個整體來操作,即這幾種行爲要麼同時完成,要麼都不完成。
在java的util.concurrent.atomic包中提供了創建了原子類型變量的工具類,使用該類可以簡化線程同步,其中AtomicInteger 表可以用原子方式更新int的值。
13. ThreadLocal
早在JDK 1.2的版本中就提供java.lang.ThreadLocal,ThreadLocal爲解決多線程程序的併發問題提供了一種新的思路。
當使用ThreadLocal維護變量時,ThreadLocal爲每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立地改變自己的副本,而不會影響其它線程所對應的副本。
ThreadLocal是如何做到爲每一個線程維護變量的副本的呢?在ThreadLocal類中有一個Map,用於存儲每一個線程的變量副本,Map中元素的鍵爲線程對象,而值對應線程的變量副本。
ThreadLocal 和同步機制的比較
ThreadLocal和線程同步機制都是爲了解決多線程中相同變量的訪問衝突問題。
在同步機制中,通過對象的鎖機制保證同一時間只有一個線程訪問變量。這時該變量是多個線程共享的,使用同步機制要求程序慎密地分析什麼時候對變量進行讀寫,什麼時候需要鎖定某個對象,什麼時候釋放對象鎖等繁雜的問題,程序設計和編寫難度相對較大。
而ThreadLocal則從另一個角度來解決多線程的併發訪問。ThreadLocal會爲每一個線程提供一個獨立的變量副本,從而隔離了多個線程對數據的訪問衝突。因爲每一個線程都擁有自己的變量副本,從而也就沒有必要對該變量進行同步了。ThreadLocal提供了線程安全的共享對象,在編寫多線程代碼時,可以把不安全的變量封裝進ThreadLocal。
概括起來說,對於多線程資源共享的問題,同步機制採用了“以時間換空間”的方式,而ThreadLocal採用了“以空間換時間”的方式。前者僅提供一份變量,讓不同的線程排隊訪問,而後者爲每一個線程都提供了一份變量,因此可以同時訪問而互不影響。
14. ReenTrantLock原理
1. ReentrantLock基本概念
- 主要利用CAS+AQS隊列來實現。
- 是可重入鎖。可重入鎖是指同一個線程可以多次獲取同一把鎖。ReentrantLock和synchronized都是可重入鎖。
- 是可中斷鎖。可中斷鎖是指線程嘗試獲取鎖的過程中,是否可以響應中斷。synchronized是不可中斷鎖,而ReentrantLock則提供了中斷功能。
- 支持公平鎖與非公平鎖。synchronized是非公平鎖,而ReentrantLock的默認實現是非公平鎖,但是也可以設置爲公平鎖。
與 synchronized 區別見上面 synchronized 筆記部分。
15. 線程狀態,start,run,wait,notify,yiled,sleep,join等方法的作用以及區別
1. 線程狀態
- NEW:線程剛創建
- RUNNABLE:在 JVM 中正在運行的線程
- BLOCKED:線程處於阻塞狀態,等待監視鎖
- WAITING:等待狀態
- TIMED_WAITING:等待指定的時間後重新被喚醒的狀態
- TERMINATED:執行完成
2. start,run,wait,notify,yiled,sleep,join作用及區別
1. run()和start()
把需要處理的代碼放到run()方法中,start()方法啓動線程將自動調用run()方法,這個由java的內存機制規定的。並且run()方法必需是public訪問權限,返回值類型爲void。
2. wait()
wait()方法使當前線程暫停執行並釋放對象鎖標示,讓其他線程可以進入synchronized數據塊,當前線程被放入對象等待池中。
3. notify() 和 notifyAll()
- notifyAll()會喚醒所有的線程,notify()之後會喚醒一個線程。
- notifyAll()調用後,會將所有線程由等待池移到鎖池,然後參與鎖的競爭,競爭成功則繼續執行,如果不成功則留在鎖池等待鎖被釋放後再次參與競爭。而notify()只會喚醒一個線程,具體喚醒哪一個線程由虛擬機控制。
4. yiled()
使用 yield() 的目的是讓具有相同優先級或者更高優先級的線程之間能夠適當的輪換執行。當一個線程使用了yield( )方法之後,它就會把自己CPU執行的時間讓掉,讓自己或者其它的線程運行。
使當前線程從執行狀態(運行狀態)變爲可執行態(就緒狀態)。從而讓其它具有相同優先級的等待線程獲取執行權。但是,並不能保證在當前線程調用yield()之後,其它具有相同優先級的線程就一定能獲得執行權。也有可能是當前線程又進入到“運行狀態”繼續運行。
5. sleep()
- Thread類,必須帶一個時間參數。
- 使調用該方法的線程進入停滯狀態,所以執行 sleep() 的線程在指定的時間內肯定不會被執行。
- sleep(long)是不會釋放鎖標誌的,也就是說如果有synchronized同步塊,其他線程仍然不能訪問共享數據。
- sleep(long)可使優先級低的線程得到執行的機會,當然也可以讓同優先級的線程有執行的機會。
- 該方法要捕捉異常
- 用途:例如有兩個線程同時執行(沒有synchronized)一個線程優先級爲MAX_PRIORITY,另一個爲MIN_PRIORITY。如果沒有Sleep()方法,只有高優先級的線程執行完畢後,低優先級的線程才能夠執行;但是高優先級的線程sleep(500)後,低優先級就有機會執行了
- 總之,sleep()可以使低優先級的線程得到執行的機會,當然也可以讓同優先級、高優先級的線程有執行的機會。
6. join()
join方法的主要作用就是同步,它可以使得線程之間的並行執行變爲串行執行。在A線程中調用了B線程的 join() 方法時,表示只有當B線程執行完畢時,A線程才能繼續執行。
7. wait()和notify(),notifyAll()是Object類的方法,sleep()和yield()是Thread類的方法。
8. 爲什麼wait和notify方法要在同步塊中調用
wait()和notify()因爲是線程之間的通信,它們存在競態,會對對象的“鎖標誌”進行操作,所以它們必需在Synchronized函數或者 synchronized block 中進行調用。如果在non-synchronized 函數或 non-synchronized block 中進行調用,雖然能編譯通過,但在運行時會發生IllegalMonitorStateException的異常。
9. wait和sleep區別
- sleep()方法是Thread的靜態方法,而wait是Object實例方法
- wait()方法必須要在同步方法或者同步塊中調用,也就是必須已經獲得對象鎖。而sleep()方法沒有這個限制可以在任何地方種使用。另外,wait()方法會釋放佔有的對象鎖,使得該線程進入等待池中,等待下一次獲取資源。而sleep()方法只是會讓出CPU並不會釋放掉對象鎖;
- sleep()方法在休眠時間達到後如果再次獲得CPU時間片就會繼續執行,而wait()方法必須等待Object.notift/Object.notifyAll通知後,纔會離開等待池,並且再次獲得CPU時間片纔會繼續執行。
- sleep方法有可能會拋出異常,所以需要進行異常處理;wait方法不需要處理。
16. 關於 Atomic 原子類
1. 介紹一下Atomic 原子類
Atomic 翻譯成中文是原子的意思。 Atomic 是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一旦開始,就不
會被其他線程干擾。所以,所謂原子類說簡單點就是具有原子/原子操作特徵的類。
併發包 java.util.concurrent 的原子類都存放在 java.util.concurrent.atomic 下,如下圖所示。
2. AtomicInteger 類的原理
AtomicInteger 線程安全原理簡單分析
AtomicInteger 類的部分源碼:
// setup to use Unsafe.compareAndSwapInt for updates(更新操作時提供“比較並替換”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
AtomicInteger 類主要利用 CAS (compare and swap) + volatile 和 native 方法來保證原子操作,從而避免synchronized 的高開銷,執行效率大爲提升。
CAS的原理是拿期望的值和原本的一個值作比較,如果相同則更新成新的值。UnSafe 類的 objectFieldOffset() 方法是一個本地方法,這個方法是用來拿到“原來的值”的內存地址,返回值是 valueOffset。另外 value 是一個volatile變量,在內存中可見,因此 JVM 可以保證任何時刻任何線程總能拿到該變量的最新值。
17. 線程池相關
1. 爲什麼要用線程池?
線程池提供了一種限制和管理資源(包括執行一個任務)的方式。 每個線程池還維護一些基本統計信息,例如已完成任務的數量。
使用線程池的好處:
- 降低資源消耗。 通過重複利用已創建的線程降低線程創建和銷燬造成的消耗。
- 提高響應速度。 當任務到達時,任務可以不需要的等到線程創建就能立即執行。
- 提高線程的可管理性。 線程是稀缺資源,如果無限制的創建,不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一的分配,調優和監控。
2. 執行execute()方法和submit()方法的區別是什麼呢?
- execute() 方法用於提交不需要返回值的任務,所以無法判斷任務是否被線程池執行成功與否;
- submit()方法用於提交需要返回值的任務。線程池會返回一個future類型的對象,通過這個future對象可以判斷任務是否執行成功,並且可以通過future的get()方法來獲取返回值,get()方法會阻塞當前線程直到任務完成,而使用get(long timeout,TimeUnit unit) 方法則會阻塞當前線程一段時間後立即返回,這時候有可能任務沒有執行完。
3. 如何創建線程池
《阿里巴巴Java開發手冊》中強制線程池不允許使用 Executors
去創建,而是通過 ThreadPoolExecutor 的方式,這樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。
Executors 返回線程池對象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允許請求的隊列長度爲 Integer.MAX_VALUE,可能堆積大量的請求,從而導致OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允許創建的線程數量爲 Integer.MAX_VALUE ,可能會創建大量線程,從而導致OOM。
方式一:通過構造方法實現
方式二:通過Executor 框架的工具類Executors來實現,我們可以創建三種類型的ThreadPoolExecutor:
- FixedThreadPool : 該方法返回一個固定線程數量的線程池。該線程池中的線程數量始終不變。當有一個新的任務提交時,線程池中若有空閒線程,則立即執行。若沒有,則新的任務會被暫存在一個任務隊列中,待有線程空閒時,便處理在任務隊列中的任務。
- SingleThreadExecutor: 方法返回一個只有一個線程的線程池。若多餘一個任務被提交到該線程池,任務會被保存在一個任務隊列中,待線程空閒,按先入先出的順序執行隊列中的任務。
- CachedThreadPool: 該方法返回一個可根據實際情況調整線程數量的線程池。線程池的線程數量不確定,但若有空閒線程可以複用,則會優先使用可複用的線程。若所有線程均在工作,又有新的任務提交,則會創建新的線程處理任務。所有線程在當前任務執行完畢後,將返回線程池進行復用。
對應Executors工具類中的方法如圖所示:
4. 線程池構造函數7大參數
- corePoolSize:線程池中的常駐核心線程數
在創建了線程池後,當有請求任務來之後,就會安排池中的線程去執行請求任務,近似理解爲今日當值線程。當線程池中的線程數目達到corePoolSize後,就會把到達的任務放到緩存隊列當中。 - maximumPoolSize:線程池能夠容納同時執行的最大線程數,此值必須大於等於1
- keepAliveTime:多餘的空閒線程的存活時間
當空閒時間達到keepAIiveTime值時,多餘空閒線程會被銷燬直到只剩下corePoolSize個線程爲止 - unit:keepAIiveTime的單位
- workQueue:任務隊列,被提交但尚未被執行的任務。
- threadFactory: 表示生成線程池中工作線程的線程工廠,用於創建線程一般用默認的即可。
- handIer:拒絕策略,表示當隊列滿了並且工作線程大於等於線程池的最大線程數 (maximumPoolSize) 處理方式
5. 線程池處理任務過程
-
在創建了線程池後,等待提交過來的任務請求。
-
當調用execute()方法添加一個請求任務時,線程池會做如下判斷:
2.1 如果正在運行的線程數量小於corePoolSi,那麼馬上創建線程運行這個任務:
2.2 如果正在運行的線程數量大於或等於corePoolSize,那麼將這個任務放入隊列;
2.3 如果這時候隊列滿了且正在運行的線程數量還小maximumPoolSize,那麼還是要創建非核心線程立刻運行這個任務:
2.4 如果隊列滿了且正在運行的線程數量大於或等於maximumPoolSize,那麼線程池會啓動飽和拒絕策略來執行。 -
當一個線程完成在務時,它會從隊列中取下一個任務來執行。
-
當一個線程無事可做超過一定的時間(keepAliveTime)時,線程池會判斷:
如果當前運行的線程數大於corePoolSize,那麼這個線程就被停掉。所以線程池的所有任務完成後它最終會收縮到corePoolSize的大小。
6. 線程池的4種拒絕策略理論概述
拒絕策略概述:等待隊列已經滿了,再也塞不下新任務了,同時,線程池中的max線程也達到了,無法繼續爲新任務服務。這時候我們就需要拒絕策略機制合理處理這個問題。
4種JDK內置拒絕策略
- AbortPolicy(默認):直接拋出RejectedExecutionException異常阻止系統正常運行。
- CallerRunsPolicy:"調用者運行"一種調節機制,該策略既不會拋棄任務,也不會拋出異常,而是將某些任務回退到調用者,從而降低新任務的流量。
- DiscardOldestPolicy:拋棄隊列中等待最久的任務,然後把當前任務加入隊列中嘗試再次提交當前任務。
- DiscardPolicy:直接丟棄任務,不予任何處理也不拋出異常。如果允許任務丟失,這是最好的一種方案。
以上內置拒絕策略均實現了RejectedExecutionHandler接口
7. 線程池配置合理線程數
1. 要合理配置線程數首先要知道公司服務器或阿里雲是幾核的
代碼查看服務器核數:
System.out.println(Runtime.getRuntime().availableProcessors());
比如我的CPU核數4核,執行結果:
2. 看是CPU密集型還是 IO密集型任務線程
1. CPU密集型
- CPU密集的意思是該任務需要大量的運算,而沒有阻塞,CPU一直全速運行。
- CPU密集任務只有在真正的多核CPU上纔可能得到加速(通過多線程),而在單核CPU上,無論你開幾個模擬的多線程該任務都不可能得到加速,因爲CPU總的運算能力就那些。
- CPU密集型任務配置儘可能少的線程數量:
公式:CPU核數+1個線程的線程池
2. IO密集型
方法一:
由於IO密集型任務線程並不是一直在執行任務,則應配置儘可能多的線程,如 CPU核數*2
方法二:
- IO密集型,即該任務需要大量的IO,即大量的阻塞。
- 在單線程上運IO密集型的任務會導致浪費大量的CPU運算能力浪費在等待。所以在IO密集型任務中使用多線程可以大大的加速程序運行,即使在單核CPU上,這種加速主要就是利用了被浪費掉的阻塞時間。
- IO密集型時,大部分線程都阻塞,故需要多配置線程數:
參考公式:CPU核數/(1-阻係數)
比如8核CPU:8/(1-0.9)=80個線程數,阻塞係數在0.8~0.9之間