小度分享-【多執行緒工作及執行緒安全】

小度同學發表於2019-07-20
*本篇文章適用於初學,入門的朋友,若有不足或有誤的地方望大家提出建議指正
複製程式碼

執行緒是什麼?

在講執行緒之前,我們需要了解一下什麼是 程式,程式是作業系統資源分配的基本單位,而執行緒是任務排程和執行的基本單位。

簡單來說,執行緒是組成程式的“單位元”。

一個執行緒只能屬於一個程式,但是一個程式可以擁有多個執行緒。多執行緒處理就是允許一個程式中在同一時間呼叫多個執行緒執行一個或多個任務。

為什麼需要多執行緒呢?

答:自從寢室衛生間的馬桶從一個馬桶(單執行緒)升級成5個馬桶(多執行緒)之後,媽媽再也不用擔心我早上搶不到廁所啦,美滋滋!

請看下面的?,帶你理解一些程式和執行緒中的概念。

假設現在有這樣一個場景 :

有一些工人工人執行緒),這些工人被要求分配到一個礦山去採礦,而礦山有很多開採的礦穴程式),要求工人們去搬運,而礦穴很窄,一次只能通過一個人。

  • 場景一:這些工人的關係都不怎麼好,一個工人負責一個礦穴,且只負責這個礦穴,互不干擾,多出的工人就圍觀。(序列
  • 場景二:這些工人的關係都不錯,對於一個礦穴來說有多個工人同時去排隊搬運。(併發
  • 場景三:對於多個洞穴有多個工人同時排隊去採礦(並行
  • 場景四:搬運工人見礦穴有工友進去採礦了,然後愣在洞口前,直到裡面的人出來(同步
  • 場景五:搬運工人見礦穴有工友進去採礦後,馬上去另外一個洞穴幫忙(非同步
  • 場景六:搬運工人得知這個洞穴裡面的開採隊沒有采到礦,就一直在洞口等,直到採到礦為止 (阻塞
  • 場景七:搬運工人得知這個洞穴裡面的開採隊沒有采到礦,果斷地回去了...(非阻塞

所以我們得到以下結論

• 執行緒之間可以是平行關係,即各個執行緒的工作處理沒有交叉,也可以是共點關係,即多個執行緒為一個或多個程式的執行做貢獻

但在程式實際執行過程中,一般情況下執行緒之間的執行順序是隨機的,被提到CPU上去處理也是隨緣的。

執行緒的執行大概就如圖所示

小度分享-【多執行緒工作及執行緒安全】

但是我們仍然能通過一些手段去協調、干預執行緒之間的執行。

比如利用sleep方法,對執行緒進行休眠,在多個執行緒併發執行時,一個執行緒被"睡"上個1ms,對其他執行緒來說已經得到了極大的機會去獲取Running的機會。

在實際應用中,還有很多工具可以協調執行緒之間的執行,去更合理和高效地協調執行緒之間資源利用,以及保證多執行緒之間的安全執行

執行緒是如何執行的

這就不得不提到JMM(Java Memory Model),Java記憶體模型。

在JVM內部使用的java記憶體模型(JMM)將執行緒堆疊和堆之間的記憶體分開 ,JMM決定了一個執行緒對共享變數的寫入何時對另一個執行緒可見。也就是說,通過這樣一種機制,消除了執行緒之間的差異,協調多執行緒工作,保證了在程式中的一致性。JMM的抽象示意圖如下。

小度分享-【多執行緒工作及執行緒安全】

上圖便是多執行緒工作的的執行流程,每個執行緒都可以主記憶體中的共享變數X進行更改操作,但是執行緒不能直接對主記憶體中的共享變數進行訪問 都有自己的工作記憶體,如果執行緒想要對主內存中的X進行操作,需要先將該變數拷貝至執行緒自己的工作內存中一份,然後在本地進行更改操作之後,再將最終的結果同步至主記憶體中。

線上程併發過程中,可見性原子性有序性,是JMM維持執行緒秩序的重要特性。

原子性:

即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。強調了執行緒執行的完整性

能夠很好體現出原子性的重要性的?:

銀行存取款問題

小明同學的賬戶中存有餘額1000元,某天他去銀行取存入100元,然後又馬上取200元,如此一來賬戶中應該剩餘900才對,但如果負責存取款操作的兩個執行緒不具備原子性的話,可能賬戶只剩下800元咯,欸?這是怎麼回事呢,我們一起來看下。

現在有 【銀行資料系統 ,程式->主記憶體】 、【存、取款系統,執行緒->工作記憶體

存取款流程(這裡以存款過程為例,取款同理): 存款系統獲取銀行資料系統中的賬戶餘額資訊,然後先將使用者存款額在存在存款系統中,隨後再同步銀行資料系統


存款執行緒:
複製程式碼
//getBalance得到的是銀行資料系統的賬戶餘額資訊
public void saveAccount() {

                //得當前賬戶餘額  ----------------A1
		int balance = getBalance();
		// 修改餘額,存100元 -------------A2
		balance += 100;
		// 修改賬戶餘額-------------------A3
		setBalance(balance);
	}
複製程式碼
取款執行緒:
複製程式碼
public void drawAccount() {

	   	// 獲得當前戶餘額---------------B1
		int balance = getBalance();
		// 修改餘額,取200 -------------B2
		balance = balance - 200;
		// 修改賬戶餘額 ----------------B3
		setBalance(balance);
		
	}
複製程式碼

好了,現在我們再來研究一下那不翼而飛的100元是怎麼回事吧,如果原子性不能被保證,任何執行緒任何時間執行到任何位置都可能被其他執行緒打斷。

  • 先執行的存款操作如果在執行完A2後被打斷

這時賬戶餘額資訊僅僅是存在balance中,並沒有同步至銀行資料庫(主記憶體)。

  • 然後執行取款操作

這時取款執行緒得到的getBalance,還是1000元,因為剛才取款執行緒並沒有將資料更新至setBalance。

  • 但是在執行完B2後又被打斷,繼續執行A3的操作

取款執行緒也沒有將運算後的balance同步至setBalance,然後轉調到存款操作,此時setBalance的餘額更新至1100元。

  • 此時執行完A3,再次返回取款執行緒執行最後的B3

在取款執行緒中的balance是800元,同步之後setBalance的數值再次被更新,最終餘額為800元。


可見,要保證程式的原子性,一定要保證執行緒程式碼的完整執行



可見性:

可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。強調了執行緒將執行結果同步至主記憶體的及時性

可見性與原子性其實關注的都是程式碼執行完整的問題,如果一個執行緒不能及時地將工作記憶體中的處理結果同步至主記憶體,那就和執行中途被打斷沒什麼區別了?。

?:

    //執行緒A
    int x = 0;
    x = 1;
    
    //執行緒B
    y = x;
複製程式碼

如果執行緒A執行完了所有操作,但是最後沒有及時地將結果同步至工作記憶體,那麼再執行執行緒B的值就會賦為0而不是1。



有序性:

即程式執行的順序按照程式碼的先後順序執行。但在多執行緒的環境下,CPU為了高執行效率,可能會可能會發生指令重排的情況,即執行緒間執行 無序化,可能難以保證寫入主記憶體最終結果的正確性

?:

    int a = 3;
    int b = 3;
    a++;
    b+=a;
複製程式碼

也許你會擔心指令重排對這段程式碼執行的影響,但是事實上發生重排絕對不會發生在第3、4行程式碼上,處理器在進行重排時會考慮資料之間的依賴性。

但是下面的?就沒這麼幸運了:

最典型的生產消費關係

    int store = 2;//庫存量
    //省略getter和setter方法
    //執行緒A
    public void Producer(){
        setStore +=1;·
    }
    //執行緒B
    public void Consumer(){
        setStore -=1;·
    }
複製程式碼

因為執行緒A和B沒有了直接資料依賴,因此可能會被重排序,但是考慮現實情況的結果,生產量應大於消費量,因此發生重排後會導致執行緒出錯



針對這些在多執行緒執行時的種種疑難雜症,JMM提供了很多方法和機制以確保我們在多執行緒程式設計時執行程式的正確性。

執行緒安全

到現在為止現在我們知道使執行緒間順利執行的保障就是 可見性原子性有序性,我們也針對這三條性質展開討論。

原子性:

這邊利用 Synchronized 方法解決奧

相當於在多執行緒執行時保護一個執行緒的完整執行
就比如剛才的例子,如果能把這兩行程式碼”包裝“起來,就不會被其他執行緒打斷了
複製程式碼
public synchronized void saveAccount() {
		// 獲得當前賬戶餘額
		int balance = getBalance();
		// 修改餘額,存100元
		balance += 100;
		// 修改賬戶餘額
		setBalance(balance);
	}

	public void drawAccount() {
		synchronized (this) {//this當前類
			// 在不同的位置處新增sleep方法
			// 獲得當前賬戶餘額
			int balance = getBalance();
			// 修改餘額,取200
			balance = balance - 200;
			// 修改賬戶餘額
			setBalance(balance);
		}
	}
複製程式碼

這樣就好啦。

但是仍然存在很多的問題,被synchronized關鍵字描述的方法或者程式碼塊,在多執行緒環境下同一時間只能由一個執行緒進行訪問,在其他被synchronized描述的執行緒完成之前,其他執行緒想要呼叫相關方法就必須進行排隊,直到那個執行緒執行結束。

而且Synchronized 還有很多侷限性,它只可以用在 o 成員方法 o 靜態方法 o 語句塊 比如這樣 public synchronized void saveAccount(){}

public static synchronized void saveAccount(){}

synchronized (obj){......}

所以不可以隨心所欲地操作,對執行緒鎖死的區域是固定的,只要有一個執行緒進入了,其他執行緒都要進入無線等待狀態。

這就要 LOCK 登場了

相比synchronizedLOCK更瀟灑一點 例如:

Lock lock = new ReentrantLock();

獲取鎖物件後,用Lock中的lock和unlock方法進行鎖死

    lock.lock();
 
    lock.unlock();
複製程式碼

使用lock的好處是鎖死區域會靈活一些

而且synchronized在發生異常時,會自動釋放執行緒佔有的鎖,因此不會導致死鎖現象發生;而lock在發生異常時,如果沒有主動通過unLock()去釋放鎖,則很可能造成死鎖現象,因此使用Lock時需要在finally塊中釋放鎖;

但是Lock可以讓等待鎖的執行緒響應中斷,而synchronized卻不行,使用synchronized時,等待的執行緒會一直等待下去,不能夠響應中斷,且通過Lock可以知道有沒有成功獲取鎖,而synchronized卻無法辦到。

總之在有大量執行緒併發的情況下 Lock是擁有絕對的效能優勢的



可見性:

那麼對可見性的保障就由volatile關鍵字來實現了

當一個共享變數被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他執行緒需要讀取時,它會去記憶體中讀取新值。

其實synchronizedlock也一樣可以保證可見性,只要保證在釋放鎖之前同步到主記憶體即可。



有序性:

很顯然synchronizedlock也一樣可以維持有序性,不過這裡我們也可以用一個最經典的機制處理

(wait/notify)兩個方法搭配使用的機制

最經典的套路

private int n;
boolean flag = false;
//為防止消費大於生產的情況,設定flag
public synchronized int get() {
    if(!flag) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("消費" + n);
    flag = false;//消費完畢,容器中沒有資料
    notifyAll();
    return n;
}
 
public synchronized void set(int n) {
    if(flag) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("生產:"+ n);
    this.n = n;
    flag = false; //生產完畢,容器中已經有資料
    notifyAll();
}
複製程式碼

可以利用這種交替進行的方式,來控制執行緒間的有序性 執行緒A進入wait狀態的時候,執行緒B進行,待執行緒B結束後,通知執行緒A結束wait狀態, 輪到執行緒B的時候也是重複這個過程,不斷交替進行。





總結: 雖然說執行緒之間的切換地,併發地執行可能會提高整個程式執行的效能,不過在特殊情況下確實是需要分別操作的,此篇文章也更多討論的是執行緒執行完整性的內容。

在一些限制工具的幫助下,對執行緒鎖死封裝,對執行緒的生命週期的狀態合理安排,就能對執行緒完整執行有了保障,又可以避免其他執行緒對齊的干擾

相關文章