1. 程式人生 > >JAVA 併發與高併發知識筆記(一)

JAVA 併發與高併發知識筆記(一)

一、併發與高併發基本概念

併發:

      從業務上簡單解釋就是多個使用者(編碼層面就是多個執行緒)共同競爭(修改或讀取)一個資源,併發問題更多體現在業務程式碼操作資料上,例如:秒殺場景,瞬間會有大量使用者共同搶購一個商品,這時候如果沒有併發控制,則極有可能出現超賣情況,即庫存被扣成了負數。

    從作業系統以及硬體層面解釋併發:有多個執行緒執行在CPU上,當在單核處理上執行的時候,多個執行緒在單核處理上交替執行(偽並行),不斷的從記憶體中換入換出,在多核處理器上每個執行緒會被分配到某一個核心上執行(並行),我覺得更適合叫平行計算大笑

高併發(High Concurrency):

     高併發更多是指系統級別的解決方案,解決方案中會包含併發相關的業務程式碼,同樣是秒殺場景,根據使用者量級對程式設計、資料庫設計、硬體佈局等等綜合起來用於滿足高併發場景(也可以理解為支援更多的平行計算)。

二、併發安全的程式碼演示(基本演示)

public class CountDownLatchExample1 {
	// 模擬併發2000
	private final static int threadCount = 2000;
	// 模擬有100000個請求
	private final static int threadClient = 100000;
	// 計數器(資源)
	private static int count=0;

	public static void main(String[] args) throws InterruptedException {
		// 執行緒池
		ExecutorService executor = Executors.newCachedThreadPool();
		// 訊號量,用於模擬併發
		final Semaphore semaphore = new Semaphore(threadCount);
		for (int i = 0; i < threadClient; i++) {
			executor.execute(new Runnable() {
				public void run() {
					try {
						// 獲取一個資源
						semaphore.acquire();
						add();
						// 釋放一個資源
						semaphore.release();
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			});
		}
		executor.shutdown();
		System.out.println(count);
	}
	// 加了同步關鍵字的方法,如果不加同步則最終會因為併發問題導致結果不固定
	public synchronized static void add(){
		count++;
	}
}

三、CPU 多級快取、快取一致性、亂序執行優化

1、CPU 多級快取,如一下兩張圖,是簡化的結構圖


            左圖為早期的快取結構示意圖,右圖為後來演變的多級快取結構示意圖

      為什麼需要CPU快取呢?因為CPU處理速度太快了,主存跟不上CPU的處理速度,在CPU處理時鐘週期內經常會等待,浪費處理器資源,所以在主存與CPU之間增加了Cache,用於增加處理器利用率。

     快取的意義:

    a、時間區域性性:如果某個資料被訪問,那麼將來它可能還會被訪問。

    b、空間區域性性:如果某個資料被訪問,那麼與它相鄰的資料也可能會被訪問。

    由於快取容量遠遠小於主存容量,所以快取的資料也會有不命中的情況,即使這樣也比直接訪問主存要效能高。

    通過右圖看到,後來發展到多級快取,級數越高快取的資料內容越多,極大的提高了處理器利用率,越靠近CPU的快取,使用頻率越高,資料是從主存>Ln>L2>L1 這樣被寫入,CPU訪問的時候則是L1>L2>Ln>主存

2、快取一致性

    快取一致性有個專有名詞,叫 MESI (Modified Exclusive Shared Or Invalid),這個協議為了保證多個CPU(或CPU核心)之間快取共享資料的一致性定義了Cache line 的四種狀態,被修改的,獨享的,共享的,無效的。

a、被修改的(Modified)

該狀態只被快取在該CPU的快取中,並且是被修改過,與主存中的資料不一致,該快取需要在未來的某個時間點(允許其它CPU讀取請主存中相應記憶體之前)寫回主存。當被寫回主存之後,該快取行的狀態會變成獨享(exclusive)狀態。

b、獨享的(Exclusive)

該狀態只被快取在當前CPU快取中,未被修改過的並且與主存中的資料一致,該狀態下可以被其它CPU讀取,從而變成共享狀態,該狀態可以變更為 Modified

c、共享的(Shared)

該狀態,快取可能存在於多個CPU 中,並且與主存中的資料一致,當某個CPU修改了資料後,其它CPU的快取可以變為 Invalid 狀態

d、無效的(Invalid)

該狀態,當前CPU的快取可能已經失效了,被其它CPU修改過,並且已經寫回主存

MESI 狀態轉換圖:


local read 讀本地快取

local write 寫本地快取

remote read 讀主存資料

remote write 寫主存資料

在一個典型系統中,可能會有幾個快取(在多核系統中,每個核心都會有自己的快取)共享主存匯流排,每個相應的CPU會發出讀寫請求,而快取的目的是為了減少CPU讀寫共享主存的次數。

一個快取除在Invalid狀態外都可以滿足cpu的讀請求,一個invalid的快取行必須從主存中讀取(變成S或者 E狀態)來滿足該CPU的讀請求。

一個寫請求只有在該快取行是M或者E狀態時才能被執行,如果快取行處於S狀態,必須先將其它快取中該快取行變成Invalid狀態(也既是不允許不同CPU同時修改同一快取行,即使修改該快取行中不同位置的資料也不允許)。該操作經常作用廣播的方式來完成。

快取可以隨時將一個非M狀態的快取行作廢,或者變成Invalid狀態,而一個M狀態的快取行必須先被寫回主存。

一個處於M狀態的快取行必須時刻監聽所有試圖讀該快取行相對應的主存操作,這種操作必須在快取將該快取行寫回主存並將狀態變成S狀態之前被延遲執行。

一個處於S狀態的快取行也必須監聽其它快取使該快取行無效或者獨享該快取行的請求,並將該快取行變成無效(Invalid)。

一個處於E狀態的快取行也必須監聽其它快取讀主存中該快取行的操作,一旦有這種操作,該快取行需要變成S狀態。

對於M和E狀態而言資料總是精確的,他們在和該快取行的真正狀態是一致的。而S狀態可能是非一致的,如果一個快取將處於S狀態的快取行作廢了,而另一個快取實際上可能已經獨享了該快取行,但是該快取卻不會將該快取行升遷為E狀態,這是因為其它快取不會廣播他們作廢掉該快取行的通知,同樣由於快取並沒有儲存該快取行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該快取行。

從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的快取行,匯流排事務需要將所有該快取行的copy變成invalid狀態,而修改E狀態的快取不需要使用匯流排事務。

MESI 轉換關係表


MESI 原文(自備梯子):https://en.wikipedia.org/wiki/MESI_protocol

3、亂序執行優化

亂序執行優化解釋:處理器為了提高執行速度而做出的一些違背程式碼原有執行順序的優化。

例如:

(1) int a=10;
(2) int b=20;
(3) int c=a+b;
(4) System.out.println(c);

上述程式碼中本意的執行順序是1->2->3->4,但是CPU為了提高效率,可能是 2->1->3->4 這樣執行的。

這樣的優化對於我們編寫的程式來說在一些場景下不進行特殊處理,可能會產生與預期不符的結果。

例如(例子可能不對,歡迎讀者指正):

private static boolean flag = false;

public static void main(String[] args) throws InterruptedException {
        // 執行一個執行緒(想先輸出false)
	new Thread(new Runnable() {
	     public void run() {
		System.out.println(flag);
	    }}).start();
	// 改變值
	flag=!flag;
}

四、JAVA 記憶體模型(JMM)、同步操作與規則

1、JMM 簡介 (Java Memory Model)

      為了遮蔽各種硬體、系統之間訪問記憶體的差異,以及讓 JAVA 程式在各個平臺上的併發處理保持一致,JVM(Java虛擬機器) 中規定了JAVA記憶體模型,它規範了Java 虛擬機器與記憶體是如何協同工作的,規定了一個執行緒如何以及何時可以看到其它執行緒修改過的共享變數的值,以及執行緒在必須時如何同步的訪問共享變數。

a)  JVM 記憶體分配簡述


                綠色區為棧,藍色區為堆

     堆為JAVA程式執行時的資料區域,堆是動態分配記憶體的(執行時分配),由於是執行時分配記憶體,所以存取效率上會有所損耗,堆也是GC回收的主要區域。

     棧為JAVA程式執行時存放程式碼、原始型別的區域,棧的存取效率要高的多,僅次於CPU暫存器,棧的資料可以共享,棧中的資料大小以及生命週期必須是確定的,棧中儲存的是JAVA當中的基本型別(byte,char,short,int,long,float,double,boolean)以及物件控制代碼(引用)。

    JVM記憶體模型規定,呼叫棧與本地變數存放到執行緒棧上(Thread Stack),物件本身還是存在堆上(如圖所示 Object 3),一個物件可能包含方法,這些方法中的本地變數也可能指向的是物件,這個物件同樣存放在堆上,非物件型別的的本地變數都會存在於棧中。

     靜態成員變數跟隨著類的定義也被存放到堆上,存放在堆上的物件可以被持有這個物件的執行緒訪問,同時持有這個物件的執行緒可以訪問這個物件中的成員變數,如果兩個執行緒同時擁有一個物件的訪問權,則兩個執行緒都可以訪問這個物件中的成員變數,但是對這個成員變數的訪問都會在各自執行緒中生成私有拷貝,當兩個執行緒都修改了各自的私有拷貝時,都會寫回主存,在沒有做特殊處理的時候執行結果往往都不是預期結果。

JMM 與硬體之間的關聯關係(左圖JMM,右圖硬體結構)


從圖中可以看到,在硬體層面上是不區分執行緒棧與堆的,而JMM中的棧、堆則可能分佈在主存、快取記憶體或者暫存器中。

JMM 抽象結構圖(非計算機物理結構)


JMM主要是為了規定了執行緒和記憶體之間的一些關係。根據JMM的設計,系統存在一個主記憶體(Main Memory),Java中所有變數都儲存在主存中,對於所有執行緒都是共享的。每條執行緒都有自己的工作記憶體(Working Memory),工作記憶體中儲存的是主存中某些變數的拷貝,執行緒對所有變數的操作都是在工作記憶體中進行,執行緒之間無法相互直接訪問,變數傳遞均需要通過主存完成。

2、JMM規定的八種操作以及同步規則

lock將主記憶體中的變數鎖定,標記為一個執行緒所獨佔
unclock將主記憶體中之前lock的變數解除鎖定,此時其它的執行緒可以有機會訪問此變數
read將主記憶體中的變數值讀到工作記憶體當中
load將read讀取的值儲存到工作記憶體中的變數副本中。
use將工作記憶體中的值傳遞給執行緒的程式碼執行引擎
assign將執行引擎處理返回的值重新賦值給工作記憶體中的變數副本
store將工作記憶體中變數副本的值儲存到主記憶體中。
write將store儲存的值寫入到主記憶體的共享變數當中。

規則一、

    如果把一個變數從主存載入到工作記憶體中,必須按順序執行read-load ,如果把工作記憶體中的變數寫回主存,則必須按照順序執行 store-write ,雖然說必須按照順序執行,但是沒有說明是必須連續執行,所以read/store 後可能會中斷然後執行其它處理後再執行 load/write 。

規則二、

    不允許 read&load 或 store&write 的單獨執行,例如 read 後必須有load 操作,store 必須有 write,反之,load 前必須先read,write 必須先有store。

規則三、

   不允許執行緒丟棄它最近的 assign 操作,既變數在工作記憶體改變後必須寫回主存,如果沒有發生 assign 則不允許執行 store-write 操作。

規則四、

   一個新變數只能從主存中誕生,不允許工作執行緒使用未被 read-load 或 assign 的變數,既,在use前必須 read-load,在store 前必須 assign。

規則五、

   一個變數在同一時刻只允許一個執行緒對其執行lock 操作,但lock可以反覆被同一個執行緒執行多次,有多少次lock,則有多少次unlock,這樣才能保證變數被解鎖,lock 與 unlock 必須成對出現

規則六、

   如果對一個變數進行lock 操作,則會清空該執行緒工作記憶體中對應的變數副本,在執行引擎使用這個變數前要重新進行 load 或 assgin 初始化變數

規則七、

    如果一個變數事先沒有lock ,則不能對其進行unlock 操作,也不允許unlock 其它執行緒lock的變數

規則八、

   一個執行緒在執行unlock 之前必須先執行 store-write 操作,把資料同步回主存。

圖形描述


五、volatile 的特殊性

Java 記憶體模型對 volatile 做了特殊規定,當一個變數被定義為了 volatile 後會具備兩種特性。

a、保證變數對所有執行緒可見,volatile變數的寫操作除了對它本身的讀操作可見外,volatile寫操作之前的所有共享變數對volatile讀操作之後的操作可見。

b、禁止指令重排序

這裡要注意一點,volatile 不保證原子性,例如 count++,count +=1 這樣的操作在多執行緒下計算結果是不確定的。

六、併發的優勢與風險

優勢:

a、處理速度,比如同時處理多個請求或者大任務拆分成小任務進行並行處理

b、提高資源利用率,CPU可以在等待IO(磁碟、網路等)的時候去操作別的事情,充分發揮多核處理效能

風險:

a、安全性,主要體現在多個執行緒操作共享資源,沒有合理使用加鎖,則會造成未知結果

b、活躍性,某個執行緒無法繼續執行下去時就容易產生該問題,例如多個執行緒競爭資源可能會造成死鎖問題,或者由於編碼不當導致死鎖

c、效能,過多的執行緒會造成CPU頻繁排程,返回會降低處理效率,而且執行緒過多會消耗更多的記憶體,因此要合理使用執行緒,比如使用執行緒池