金牌架構師圖解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併發(上)


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章