1. 程式人生 > >金牌架構師圖解java併發(上)

金牌架構師圖解java併發(上)

為什麼要“併發”?

既然聊併發,我們首先會思考為什麼要引入這個技術。通常寫程式,我們習慣用單執行緒序列的思維理解程式執行,並寫業務邏輯。這樣可以減少複雜度,也便於測試,往往當需要效能提升,我們才會想到使用併發。那麼這個技術到底能夠給我們帶來什麼呢。

充分利用cpu資源

多核處理器的廣泛使用背景下,如果我們的程式還是單執行緒序列的執行,會對硬體資源浪費。比如有一個5核心的cpu,單執行緒對cpu的損耗不會超過1/5。這對硬體的使用明顯是中巨大浪費。

金牌架構師圖解java併發(上)


只有一半的cpu資源得到了利用。

更快

比如使用者在手機上下了一個貸款申請,它包括插入申請資料,社會稽核、金融信譽稽核、其他稽核、傳送郵件通知,生成分期賬單等等。使用者貸款申請,需要這些流程都完成,才能保證貸款申請流程完畢。如何能讓這些流程更快執行呢?可以使用併發,對資料弱一致性的業務並行處或者非同步處理,縮短響應時間 ,提升使用者體驗。

精講架構視訊資料獲取方式 轉發 轉發 轉發 關注我私信回覆“666”即可領取

併發的風險

我們都知道,執行緒在java中作為最小的執行單元,在java中我們通過Thread類去抽象每個執行緒個體。併發就是讓多個執行緒同時執行,每個執行緒作為一個獨立的個體去完成邏輯執行。

上邊說了我們使用併發技術的動機,每個硬幣都有兩面,併發技術也不例外,再給我們帶來益處的同時,也存在一些風險需要去謹慎注意。

效能損耗

  • 建立執行緒

  • 每個執行緒的建立需要堆疊資源,也需要佔用作業系統中一些資源來管理執行緒。即使執行緒什麼都不做的情況下。

  • 上下文切換多執行緒執行中,cpu會給每個執行緒分配時間片,也就是輪流佔用cpu。這樣會產生上線文切換——也就是保留當前執行緒狀態,切換到下一個執行緒,下一個執行緒載入上次的狀態,繼續執行——從儲存當下狀態到下次再載入的過程就是上下文切換。

金牌架構師圖解java併發(上)


上下文切換示意圖

更加複雜,有挑戰

併發程式設計比序列的程式設計更加複雜,要考慮鎖問題、執行緒安全、重排序問題、共享資料的一致性、執行緒池的設定等等

理解併發

從整體上來講,理解併發就是要理解多執行緒之間的通訊與同步。

通訊

java 中通過共享記憶體實現通訊,但也不侷限與記憶體,也可以是任何共享的儲存資料。通訊的同義詞有握手、互動,一個意思。

舉例來說:

比如,通訊即溝通,執行緒A需要讓執行緒B修改某些屬性然後去執行,那麼執行緒A該如何告訴執行緒B自己的需求呢?

金牌架構師圖解java併發(上)


執行緒A會更新某個變數,然後執行緒A將這個更新的變數刷入主存中去。執行緒B會到主存中獲取這個執行緒A更新過的共享變數。這兩個步驟就完成了一次通訊

實質就是執行緒A向執行緒B傳送了包含更新資料的訊息,這種通過共享主存的通訊方式是隱式的通訊,還有訊息傳遞的併發模型通過直接傳送訊息通訊。在java中對通訊的抽象模型就是JMM

JMM

JMM(Java memory model )描述了執行緒之間如何通過記憶體實現通訊。

金牌架構師圖解java併發(上)


同步

 執行緒同步指的是多個執行緒相互排序執行以及在某些特定時間進行握手,以完成一個共同的目標或者執行一系列有序的動作。
  • 1

金牌架構師圖解java併發(上)


同步就是保證按照正確的順序讓執行緒執行並完成通訊,類似現實生活中的紅綠燈,如果沒有紅綠燈,後果可想而知。

金牌架構師圖解java併發(上)


在java中通過使用 volatile關鍵字(無鎖實現同步)、Lock、synchronized關鍵字、原子類等手段來完成同步,以解決因為同步產生的競爭狀態。

哲學家進餐問題、讀寫者問題,生產消費者問題都是同步的經典問題,為了加深理解,讀者應該嘗試寫一下。

接下來,我們來詳細看看java中的同步手段。

volatile

可見性

說到volatile就要從可見性問題說起,那什麼是可見性呢?

金牌架構師圖解java併發(上)


示例程式碼:

金牌架構師圖解java併發(上)


金牌架構師圖解java併發(上)


為什麼不可見

計算機為了提高整體執行效率,使得CPU不會直接與記憶體(主存)進行通訊,會先使用快取替代主存。

使用快取好處主要兩點:一,快取讀寫資料比記憶體讀寫資料速度更快,能更好地被CPU使用。二,如果快取可以部分滿足CPU對主存的需要,那麼就會降低主存的讀寫頻率,意味著降低匯流排的繁忙程度,整體上提高機器的執行速度。

快取有優點,但是同樣也會帶來一些問題:因為執行緒之間通過主存(就是常說的記憶體,下文統一稱為“主存”)通訊,主存是可以被多個CPU共享訪問的,而快取只能供當前的CPU訪問,關鍵問題是一個快取與主存同步資料的頻率是沒有嚴格約束的,那麼也就是說CPU之間無法及時看到彼此最新更新的資料(因為可能某些資料還沒有同步到主存)。

回顧JMM結構圖,WorkingMemory包含此處說的快取之外,還包含暫存器、編譯器等。WorkingMemory不能線上程之間共享,類比於CPU不能在快取中共享,實際上JMM範圍更大,抽象程度更高。因此在上邊的程式中,如果對一個變數(非volatile)進行寫操作,會首先寫入workingMemory,”稍後”會更新到主記憶體。但是具體是什麼時候更新到主存去就很不確定了,這就導致了其他執行緒會出現資料(最新值)不可見的情況。

接著說上邊程式碼的例子,當我們將

static boolean isRunning = true;

改為

static volatile boolean isRunning = true;

使用volatile修飾,問題就解決了,可以自行嘗試下。

除了快取會影響可見性,重排序也會影響可見性(因為程式碼執行順序打亂),下文詳述重排序問題。

volatile 到底做了什麼

  • 有volatile變數修飾的共享變數進行寫操作的時候會使用lock彙編指令,而lock指令(預設場景為多核處理器下)會引發了三件事情:

  • 將當前處理器快取行的資料會寫回到系統主存。

  • 寫回主存操作會接著使其他儲存了這個變數的快取資料失效(快取一致性協議保證)。

  • 禁止某些指令的重排序(或者說建立關於volatile的happen-before規則:對volatile的寫操作必須對之後的這個變數的讀操作可見)

在一個volatile變數的寫操作中,JVM會同時向作業系統傳送lock指令(volatile的關鍵點),這會導致這個變數對應的快取被原子性的寫入到主存中。

光是寫入主存這個操作還不夠,因為其他執行緒下次從其他任何儲存了這個資料的快取中讀取這個變數,也是錯誤的。

因此,會使其他地方快取了這個資料的快取失效,下次就會直接從主從中讀取。

簡單來說,volatile在作業系統層面保證了變數單個操作(讀或寫)的原子性、可見性。另外需要注意:(volatile變數) i++並非是單個操作,所以並不能原子性完成。

(lock指令的更多細節不做展開。)

前文中說道,lock指令會禁止重排序,那麼我們通過對volatile的理解來聊一下“重排序”這個問題。

重排序

什麼是重排序

在JMM中,編譯器(包括JIT)、CPU、快取被允許做一些程式碼指令的重新排序以達到優化效能的目的。

比如:

public class ReorderDescribe {
 static int a = 0;
 static int b = 0;
 static int c = 0;
public static void main(String[] args) {
 a = 1;// 操作1
 b = 2;// 操作2
 c = 3;// 操作3
 }
}

從程式碼中來看,執行順序“應該是”操作1——>操作2——>操作3,但是JMM允許編譯器、JIT、CPU等硬體自由的改變這三個操作的順序。

在單執行緒情況下,我們感覺不到程式碼(以及程式碼對應的彙編指令)的重排序,這是因為JMM的約束:在單執行緒下,compiler、JIT、CPU可以任意的重排序,但是前提是不影響程式碼執行結果。也就是我們主管感覺的順序執行(“as-if-serial”)。

但是,在未正確同步的多執行緒程式碼中,這種重排序經常造成“非預期的結果”。

對策總比問題多,JMM中通過定義一些關鍵字的語義,禁止了某些重排序( a partial ordering ),實際上就是通過使用”記憶體屏障”的方式來禁止某些不受歡迎的重排序,使得程式按照我們的預期正確同步並執行。

happen-before

( a partial ordering )部分禁止重排序,也可以理解為限定好某些操作執行的先後順序,不允許其改變,換句話說也就是對這些操作做了同步處理。

這個因禁止某些重排序而保留下來的特定的先後順序稱為happen-before規則。

如果說A happen before B,那麼就保證A會在B之前執行,並且A操作對B可見。

具體的happen-before規則如下:

 同一個執行緒中的操作,都是按照程式碼編寫的順序執行(從執行結果的角度來看)。

 一個物件鎖的釋放 一定會發生在 這個鎖隨後被獲取的操作 之前(同一個鎖先要被釋放,才能接著獲取到)。

 對一個volatile變數的寫操作 一定會發生在 隨後的這個volatile變數的讀操作 之前(不允許把volatile寫操作之後的程式碼重排序到它之前;並且volatile寫操作立即可見)。

 對一個執行緒的start()方法的呼叫一定會發生在 這個執行緒被啟動後執行的任何動作 之前

 一個執行緒中的所有操作 一定會發生在 其他執行緒成功的從這個執行緒的join方法返回 之前

Double-check locking

底層的記憶體屏障對於java語言的使用者來說,主要就是volatile關鍵字、鎖。我們接下來通過一個經典的例子來具體分析一下重排序問題。

以下是一種典型的double-Check錯誤:

/*
 * Broken multithreaded version
 */
class Foo {
 private Helper helper = null;
 public Helper getHelper() {
 if (helper == null) {
 synchronized (this) {
 if (helper == null) {
 helper = new Helper();
 }
 }
 }
 return helper;
 }
 // other functions and members...
}

為什麼會錯誤呢?看了又看都沒發現錯誤在哪裡。 其實這裡的錯誤會出現在helper=new Helper中,因為這句程式碼並不是原子操作,實際上分為三個操作,並且三個操作允許被重排序。

操作1:分配記憶體空間
操作2:初始化Helper物件
操作3:將helper引用指向記憶體空間

但是從單執行緒來看,這三個操作如果是這樣的順序:1——>3——>2,也並不會被我們感知到,也就是說滿足as-if-serial的”順序執行”要求。但是在為正確同步的多執行緒中,就會發生問題(如圖):

使用volatile禁止重排序,正確同步執行緒。只要在宣告helper引用時使用volatile修飾即可正確同步程式碼。

 private volatile Helper helper = null;

volatile禁止寫操作之前的任何操作被重排序後邊,所以我們得到的結果就是:

操作1:分配記憶體空間
操作2:初始化Helper物件
操作3:將helper引用指向記憶體空間(寫操作,禁止之前的操作重排序到這個寫操作之後)。

金牌架構師圖解java併發(上)


synchronized

互斥執行(mutual exclusion)

synchronized為人熟知的特點是“互斥執行”。我們先看看synchronized塊的位元組碼:

// Java code:
Synchronized (this) { 
//stuff 
} 
 
//bytecode
Public some Method()V ALOAD 0 DUP MONITORENTER MONITOREXIT RETURN

synchronized使用monitor機制(monitorenter/monitorexit),通過獲取獲取釋放同一個物件鎖來完成臨界區(也稱為同步塊)互斥執行。

同一時刻只有一個執行緒可以獲得一個monitor(可以理解為物件鎖,獲得鎖對應指令為(monitorenter),所以在這個monitor上的阻塞的程式碼塊只允許獲得這個monitor的執行緒進入執行,其他執行緒都無法獲得這個monitor,當然也無法進入同步塊,必須等到當前同步塊中的執行緒退出同步塊,並釋放這個monitor後才可嘗試進入。

synchronized的“可見性”

Synchronized確保了一個執行緒在進入同步塊中(或進入同步塊之前)的寫操作對其他執行緒立即可見。

在一個執行緒進入synchronized 塊之前,首先要獲取物件鎖(執行monitorenter)。這個執行緒獲取物件鎖成功的同時,會使得當前CPU快取的資料失效,那麼接下來的讀操作,就會重新從系統主存中讀取(並填充快取)。

當一個執行緒在退出synchronized同步塊時,釋放物件鎖(執行monitorexit),同時會保證當前執行緒的快取資料被刷入主記憶體,所以這個執行緒在退出同步塊之前的寫操作對其他執行緒可見。

可以看到同一個monitor物件鎖的釋放和獲取都會導致快取資料刷入主存、快取資料被重新從主存更新,那麼快取資料都會被即使更新並同步主存,很明顯消除了可見性問題。

總結

本文試圖用圖文並茂、例項模擬等方式向大家闡述併發的核心難點。

開頭先對比了java併發技術的優劣以及挑戰,然後從執行緒間通訊與執行緒間同步這兩個本質的問題切入,詳細描述了什麼是執行緒間通訊、什麼是執行緒間同步,並因此引入了JMM這個模型概念。

從JMM的講解繼續深入,引出了可見性問題、重排序問題、happen-before。並用經典的double-check locking問題作為例項以加強理解。最後,關於JMM中的語義細節,用底層的實現原理講解了java語言層面的兩個重要關鍵字volatile、synchronized。

下文我們將著重理解JDK中的併發元件的實現原理、還原曾遇到過的高併發系統的線上問題,以及目前業界對併發系統的一些處理手段等等。


金牌架構師圖解java併發(上)