看完這篇多執行緒,再說多執行緒學不會,那你就收藏多看兩遍

Java架構師聯盟發表於2020-10-15

多執行緒併發問題,基本是面試必問的。

大部分同學應該都知道SynchronizedLock,部分同學能說到volatile併發包,優秀的同學則能在前面的基礎上,說出Synchronized、volatile的原理,以及併發包中常用的資料結構,例如ConcurrentHashMap的原理。

這篇文章將總結多執行緒併發的各種處理方式,希望對大家有所幫助。

一、多執行緒為什麼會有併發問題

為什麼多執行緒同時訪問(讀寫)同個變數,會有併發問題?

  1. Java 記憶體模型規定了所有的變數都儲存在主記憶體中,每條執行緒有自己的工作記憶體。

  2. 執行緒的工作記憶體中儲存了該執行緒中用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體。

  3. 執行緒訪問一個變數,首先將變數從主記憶體拷貝到工作記憶體,對變數的寫操作,不會馬上同步到主記憶體。

  4. 不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數的傳遞均需要自己的工作記憶體和主存之間進行資料同步進行。

二、Java 記憶體模型(JMM)

Java 記憶體模型(JMM) 作用於工作記憶體(本地記憶體)和主存之間資料同步過程,它規定了如何做資料同步以及什麼時候做資料同步,如下圖。

多執行緒!你只要看這一篇就夠了

三、併發三要素

原子性:在一個操作中,CPU 不可以在中途暫停然後再排程,即不被中斷操作,要麼執行完成,要麼就不執行。

可見性:多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

有序性:程式執行的順序按照程式碼的先後順序執行。

四、怎麼做,才能解決併發問題?(重點)

下面結合不同場景分析解決併發問題的處理方式。

一、volatile

1.1 volatile 特性

保證可見性,不保證原子性。

\1. 當寫一個volatile變數時,JVM會把本地記憶體的變數強制重新整理到主記憶體中。

\2. 這個寫操作導致其他執行緒中的快取無效,其他執行緒讀,會從主記憶體讀。volatile的寫操作對其它執行緒實時可見。

禁止指令重排序 指令重排序是指編譯器和處理器為了優化程式效能對指令進行排序的一種手段,需要遵守一定規則:

\1. 不會對存在依賴關係的指令重排序,例如 a = 1;b = a; a 和b存在依賴關係,不會被重排序。

\2. 不能影響單執行緒下的執行結果。比如:a=1;b=2;c=a+b這三個操作,前兩個操作可以重排序,但是c=a+b不會被重排序,因為要保證結果是3。

1.2 使用場景

對於一個變數,只有一個執行緒執行寫操作,其它執行緒都是讀操作,這時候可以用 volatile 修飾這個變數。

1.3 單例雙重鎖為什麼要用到volatile?

public class TestInstance {
private static volatile TestInstance mInstance;
public static TestInstance getInstance{ //1
if (mInstance == ){ //2
synchronized (TestInstance.class){ //3
if (mInstance == ){ //4
mInstance = new TestInstance; //5
}
}
}
return mInstance;
}

假如沒有用volatile,併發情況下會出現問題,執行緒A執行到註釋5 new TestInstance 的時候,分為如下幾個幾步操作:

  1. 分配記憶體

  2. 初始化物件

  3. mInstance 指向記憶體

這時候如果發生指令重排,執行順序是132,執行到第3的時候,執行緒B剛好進來了,並且執行到註釋2,這時候判斷mInstance 不為空,直接使用一個未初始化的物件。所以使用volatile關鍵字來禁止指令重排序。

1.4 volatile 原理

在JVM底層volatile是採用記憶體屏障來實現的,記憶體屏障會提供3個功能:

  1. 它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成;

  2. 它會強制將快取的修改操作立即寫到主記憶體

  3. 寫操作會導致其它CPU中的快取行失效,寫之後,其它執行緒的讀操作會從主記憶體讀。

1.5 volatile 的侷限性

volatile 只能保證可見性不能保證原子性寫操作對其它執行緒可見,但是不能解決多個執行緒同時寫的問題。

二、Synchronized

2.1 Synchronized 使用場景

多個執行緒同時寫一個變數。

例如售票,餘票是100張,視窗A和視窗B同時各賣出一張票, 假如餘票變數用 volatile 修飾,是有問題的。

A視窗獲取餘票是100,B視窗獲取餘票也是100,A賣出一張變成99,重新整理回主記憶體,同時B賣出一張變成99,也重新整理回主記憶體,會導致最終主記憶體餘票是99而不是98。

前面說到 volatile 的侷限性,就是多個執行緒同時寫的情況,這種情況一般可以使用Synchronized。

Synchronized 可以保證同一時刻,只有一個執行緒可執行某個方法或某個程式碼塊。

2.2 Synchronized 原理

public class SynchronizedTest {
public static void main(String[] args) {
synchronized (SynchronizedTest.class) {
System.out.println("123");
}method;}private static void method {
}}

將這段程式碼先用javac命令編譯,再java p -v SynchronizedTest.class命令檢視位元組碼,部分位元組碼如下

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:stack=2, locals=3, args_size=1
0: ldc #2 // class com/lanshifu/opengldemo/test/SynchronizedTest
2: dup3: astore_14: monitorenter5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String 123
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_114: monitorexit
15: goto 23
18: astore_2
19: aload_120: monitorexit
21: aload_222: athrow23: invokestatic #6 // Method method:V
26: return

可以看到 4: monitorenter 和 14: monitorexit,中間是列印的語句。

執行同步程式碼塊,首先會執行monitorenter指令,然後執行同步程式碼塊中的程式碼,退出同步程式碼塊的時候會執行monitorexit指令 。

使用Synchronized進行同步,其關鍵就是必須要對物件的監視器monitor進行獲取,當執行緒獲取monitor後才能繼續往下執行,否則就進入同步佇列,執行緒狀態變成BLOCK,同一時刻只有一個執行緒能夠獲取到monitor,當監聽到monitorexit被呼叫,佇列裡就有一個執行緒出隊,獲取monitor。詳情參考:

https://www.jianshu.com/p/d53bf830fa09

每個物件擁有一個計數器,當執行緒獲取該物件鎖後,計數器就會加一,釋放鎖後就會將計數器減一,所以只要這個鎖的計數器大於0,其它執行緒訪問就只能等待。

2.3 Synchronized 鎖的升級

大家對Synchronized的理解可能就是重量級鎖,但是Java1.6對 Synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java1.6 中為了減少獲得鎖和釋放鎖帶來的效能消耗而引入的偏向鎖和輕量級鎖。

偏向鎖: 大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。

當一個執行緒A訪問加了同步鎖的程式碼塊時,會在物件頭中存 儲當前執行緒的id,後續這個執行緒進入和退出這段加了同步鎖的程式碼塊時,不需要再次加鎖和釋放鎖。

輕量級鎖: 在偏向鎖情況下,如果執行緒B也訪問了同步程式碼塊,比較物件頭的執行緒id不一樣,會升級為輕量級鎖,並且通過自旋的方式來獲取輕量級鎖。

重量級鎖: 如果執行緒A和執行緒B同時訪問同步程式碼塊,則輕量級鎖會升級為重量級鎖,執行緒A獲取到重量級鎖的情況下,執行緒B只能入隊等待,進入BLOCK狀態。

2.4 Synchronized 缺點

  1. 不能設定鎖超時時間

  2. 不能通過程式碼釋放鎖

  3. 容易造成死鎖

三、ReentrantLock

上面說到Synchronized的缺點,不能設定鎖超時時間和不能通過程式碼釋放鎖,ReentranLock就可以解決這個問題。

在多個條件變數和高度競爭鎖的地方,用ReentrantLock更合適,ReentrantLock還提供了Condition,對執行緒的等待和喚醒等操作更加靈活,一個ReentrantLock可以有多個Condition例項,所以更有擴充套件性。

3.1 ReentrantLock 的使用

lock 和 unlock

ReentrantLock reentrantLock = new ReentrantLock;
System.out.println("reentrantLock->lock");
reentrantLock.lock;
try {
System.out.println("睡眠2秒...");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace;}finally {
reentrantLock.unlock;System.out.println("reentrantLock->unlock");
}

實現可定時的鎖請求:tryLock

public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock;
Thread thread1 = new Thread_tryLock(reentrantLock);
thread1.setName("thread1");
thread1.start;Thread thread2 = new Thread_tryLock(reentrantLock);
thread2.setName("thread2");
thread2.start;}static class Thread_tryLock extends Thread {
ReentrantLock reentrantLock;public Thread_tryLock(ReentrantLock reentrantLock) {
this.reentrantLock = reentrantLock;
}@Overridepublic void run {
try {
System.out.println("try lock:" + Thread.currentThread.getName);
boolean tryLock = reentrantLock.tryLock(3, TimeUnit.SECONDS);
if (tryLock) {
System.out.println("try lock success :" + Thread.currentThread.getName);
System.out.println("睡眠一下:" + Thread.currentThread.getName);
Thread.sleep(5000);
System.out.println("醒了:" + Thread.currentThread.getName);
} else {
System.out.println("try lock 超時 :" + Thread.currentThread.getName);
}} catch (InterruptedException e) {
e.printStackTrace;} finally {
System.out.println("unlock:" + Thread.currentThread.getName);
reentrantLock.unlock;}}}

列印的日誌:

try lock:thread1
try lock:thread2
try lock success :thread2
睡眠一下:thread2try lock 超時 :thread1
unlock:thread1Exception in thread "thread1" java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
at com.lanshifu.demo_module.test.lock.ReentranLockTest$Thread_tryLock.run(ReentranLockTest.java:60)
醒了:thread2unlock:thread2

上面演示了trtLock的使用,trtLock設定獲取鎖的等待時間,超過3秒直接返回失敗,可以從日誌中看到結果。有異常是因為thread1獲取鎖失敗,不應該呼叫unlock。

3.2 Condition 條件

public static void main(String[] args) {
Thread_Condition thread_condition = new Thread_Condition;
thread_condition.setName("測試Condition的執行緒");
thread_condition.start;try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace;}thread_condition.singal;}static class Thread_Condition extends Thread {
@Overridepublic void run {
await;
}private ReentrantLock lock = new ReentrantLock;
public Condition condition = lock.newCondition;
public void await {
try {
System.out.println("lock");
lock.lock;
System.out.println(Thread.currentThread.getName + ":我在等待通知的到來...");
condition.await;//await 和 signal 對應
//condition.await(2, TimeUnit.SECONDS); //設定等待超時時間
System.out.println(Thread.currentThread.getName + ":等到通知了,我繼續執行>>>");
} catch (Exception e) {
e.printStackTrace;
} finally {
System.out.println("unlock");
lock.unlock;
}
}
public void singal {
try {
System.out.println("lock");
lock.lock;
System.out.println("我要通知在等待的執行緒,condition.signal");
condition.signal;//await 和 signal 對應
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace;
} finally {
System.out.println("unlock");
lock.unlock;
}
}
}

執行列印日誌

lock
測試Condition的執行緒:我在等待通知的到來...lock
我要通知在等待的執行緒,condition.signalunlock測試Condition的執行緒:等到通知了,我繼續執行>>>unlock

上面演示了Condition的 await 和 signal 使用,前提要先lock。

3.3 公平鎖與非公平鎖

ReentrantLock 建構函式傳true表示公平鎖。

公平鎖表示執行緒獲取鎖的順序是按照執行緒加鎖的順序來分配的,即先來先得的順序。而非公平鎖就是一種鎖的搶佔機制,是隨機獲得鎖的,可能會導致某些執行緒一致拿不到鎖,所以是不公平的。

3.4 ReentrantLock 注意點

  1. ReentrantLock使用lock和unlock來獲得鎖和釋放鎖

  2. unlock要放在finally中,這樣正常執行或者異常都會釋放鎖

  3. 使用condition的await和signal方法之前,必須呼叫lock方法獲得物件監視器

四、併發包

通過上面分析,併發嚴重的情況下,使用鎖顯然效率低下,因為同一時刻只能有一個執行緒可以獲得鎖,其它執行緒只能乖乖等待。

Java提供了併發包解決這個問題,接下來介紹併發包裡一些常用的資料結構。

4.1 ConcurrentHashMap

我們都知道HashMap是執行緒不安全的資料結構,HashTable則在HashMap基礎上,get方法和put方法加上Synchronized修飾變成執行緒安全,不過在高併發情況下效率底下,最終被ConcurrentHashMap替代。

ConcurrentHashMap 採用分段鎖,內部預設有16個桶,get和put操作,首先將key計算hashcode,然後跟16取餘,落到16個桶中的一個,然後每個桶中都加了鎖(ReentrantLock),桶中是HashMap結構(陣列加連結串列,連結串列過長轉紅黑樹)。

所以理論上最多支援16個執行緒同時訪問。

4.2 LinkBlockingQueue

連結串列結構的阻塞佇列,內部使用多個ReentrantLock

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock;
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition;
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock;
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition;
private void signalNotEmpty {
final ReentrantLock takeLock = this.takeLock;
takeLock.lock;try {
notEmpty.signal;} finally {takeLock.unlock;}}/**
* Signals a waiting put. Called only from take/poll.
*/
private void signalNotFull {
final ReentrantLock putLock = this.putLock;
putLock.lock;try {
notFull.signal;} finally {putLock.unlock;}}

原始碼不貼太多,簡單說一下LinkBlockingQueue 的邏輯:

\1. 從佇列獲取資料,如果佇列中沒有資料,會呼叫notEmpty.await;進入等待。

\2. 在放資料進去佇列的時候會呼叫notEmpty.signal;,通知消費者,1中的等待結束,喚醒繼續執行。

\3. 從佇列裡取到資料的時候會呼叫notFull.signal;,通知生產者繼續生產。

\4. 在put資料進入佇列的時候,如果判斷佇列中的資料達到最大值,那麼會呼叫notFull.await;,等待消費者消費掉,也就是等待3去取資料並且發出notFull.signal;,這時候生產者才能繼續生產。

LinkBlockingQueue 是典型的生產者消費者模式,原始碼細節就不多說。

4.3 原子操作類:**AtomicInteger**

內部採用CAS(compare and swap)保證原子性

舉一個int自增的例子

AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.incrementAndGet;//自增

原始碼看一下

/**
* Atomically increments by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet {
return U.getAndAddInt(this, VALUE, 1) + 1;
}

U 是 Unsafe,看下 Unsafe#getAndAddInt

public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}

通過compareAndSwapInt保證原子性。

五、總結

面試中問到多執行緒併發問題,可以這麼答:

  1. 當只有一個執行緒寫,其它執行緒都是讀的時候,可以用volatile修飾變數

  2. 當多個執行緒寫,那麼一般情況下併發不嚴重的話可以用Synchronized,Synchronized並不是一開始就是重量級鎖,在併發不嚴重的時候,比如只有一個執行緒訪問的時候,是偏向鎖;當多個執行緒訪問,但不是同時訪問,這時候鎖升級為輕量級鎖;當多個執行緒同時訪問,這時候升級為重量級鎖。所以在併發不是很嚴重的情況下,使用Synchronized是可以的。不過Synchronized有侷限性,比如不能設定鎖超時,不能通過程式碼釋放鎖。

  3. ReentranLock 可以通過程式碼釋放鎖,可以設定鎖超時。

  4. 高併發下,Synchronized、ReentranLock 效率低,因為同一時刻只有一個執行緒能進入同步程式碼塊,如果同時有很多執行緒訪問,那麼其它執行緒就都在等待鎖。這個時候可以使用併發包下的資料結構,例如ConcurrentHashMap,LinkBlockingQueue,以及原子性的資料結構如:AtomicInteger。

面試的時候按照上面總結的這個思路回答基本就ok了。既然說到併發包,那麼除了ConcurrentHashMap,其它一些常用的資料結構的原理也需要去了解下,例如HashMap、HashTable、TreeMap原理,ArrayList、LinkedList對比,這些都是老生常談的,自己去看原始碼或者一些部落格。

關於多執行緒併發就先總結到這裡,如果是應付面試的話按照這篇文章的思路來準備應該是沒太大問題的。

關注公眾號:Java架構師聯盟,每日更新技術好文,下方導圖獲取方式亦在

 

相關文章