1. 程式人生 > >【搞定Java併發程式設計】第2篇:併發基礎概述

【搞定Java併發程式設計】第2篇:併發基礎概述

上一篇:執行緒的五種可用狀態

目  錄

1、什麼是併發

2、Java的多執行緒和併發性

3、多執行緒的優點

4、多執行緒的代價

5、競態條件與臨界區

6、執行緒安全與共享資源

7、執行緒安全及不可變性


1、什麼是併發

在過去單CPU時代,單任務在一個時間點只能執行單一程式。之後發展到多工階段,計算機能在同一時間點並行執行多工或多程序。雖然並不是真正意義上的“同一時間點”,而是多個任務或程序共享一個CPU,並交由作業系統來完成多工間對CPU的執行切換,以使得每個任務都有機會獲得一定的時間片執行。

隨著多工對軟體開發者帶來的新挑戰,程式不在能假設獨佔所有的CPU時間、所有的記憶體和其他計算機資源。一個好的程式榜樣是在其不再使用這些資源時對其進行釋放,以使得其他程式能有機會使用這些資源。

再後來發展到多執行緒技術,使得在一個程式內部能擁有多個執行緒並行執行。一個執行緒的執行可以被認為是一個CPU在執行該程式。當一個程式執行在多執行緒下,就好像有多個CPU在同時執行該程式。

多執行緒比多工更加有挑戰。多執行緒是在同一個程式內部並行執行,因此會對相同的記憶體空間進行併發讀寫操作。這可能是在單執行緒程式中從來不會遇到的問題。其中的一些錯誤也未必會在單CPU機器上出現,因為兩個執行緒從來不會得到真正的並行執行。然而,更現代的計算機伴隨著多核CPU的出現,也就意味著不同的執行緒能被不同的CPU核得到真正意義的並行執行。

如果一個執行緒在讀一個記憶體時,另一個執行緒正向該記憶體進行寫操作,那進行讀操作的那個執行緒將獲得什麼結果呢?是寫操作之前舊的值?還是寫操作成功之後的新值?或是一半新一半舊的值?或者,如果是兩個執行緒同時寫同一個記憶體,在操作完成後將會是什麼結果呢?是第一個執行緒寫入的值?還是第二個執行緒寫入的值?還是兩個執行緒寫入的一個混合值?因此如沒有合適的預防措施,任何結果都是可能的。而且這種行為的發生甚至不能預測,所以結果也是不確定性的。


2、Java的多執行緒和併發性

Java是最先支援多執行緒的開發的語言之一,Java從一開始就支援了多執行緒功能,因此Java開發者能常遇到上面描述的問題場景。這也是我想為Java併發技術而寫這篇系列的原因。作為對自己的筆記,和對其他Java開發的追隨者都可獲益的。

該系列主要關注Java多執行緒,但有些在多執行緒中出現的問題會和多工以及分散式系統中出現的存在類似,因此該系列會將多工和分散式系統方面作為參考,所以叫法上稱為“併發性”,而不是“多執行緒”。


3、多執行緒的優點

儘管面臨很多挑戰,多執行緒有一些優點使得它一直被使用。這些優點是:

1、資源利用率更好;

2、程式設計在某些情況下更簡單,即更好的程式設計模型;

3、程式響應更快。

3.1、資源利用率更好

想象一下,一個應用程式需要從本地檔案系統中讀取和處理檔案的情景。比方說,從磁碟讀取一個檔案需要5秒,處理一個檔案需要2秒。處理兩個檔案則需要:

第一步:5秒讀取檔案A;

第二步:2秒處理檔案A;

第三步:5秒讀取檔案B;

第四步:2秒處理檔案B;

因此:處理A和B兩個檔案共需要14秒!

可以發現,從磁碟中讀取檔案的時候,大部分的CPU時間用於等待磁碟去讀取資料。在這段時間裡,CPU非常的空閒。它可以做一些別的事情。通過改變操作的順序,就能夠更好的使用CPU資源。看下面的順序:

第一步:5秒讀取檔案A;

第二步:5秒讀取檔案B + 2秒處理檔案A;

第三步:2秒處理檔案B;

因此:處理A和B兩個檔案共需要12秒!

CPU等待第一個檔案被讀取完。然後開始讀取第二個檔案。當第二檔案在被讀取的時候,CPU會同時去處理第一個檔案。記住,在等待磁碟讀取檔案的時候,CPU大部分時間是空閒的。

總的說來,CPU能夠在等待IO的時候做一些其他的事情。這個不一定就是磁碟IO。它也可以是網路的IO,或者使用者輸入。通常情況下,網路和磁碟的IO比CPU和記憶體的IO慢的多

3.2、程式設計更簡單

在單執行緒應用程式中,如果你想編寫程式手動處理上面所提到的讀取和處理的順序,你必須記錄每個檔案讀取和處理的狀態。

而使用多執行緒時,你可以啟動兩個執行緒,每個執行緒處理一個檔案的讀取和操作。執行緒會在等待磁碟讀取檔案的過程中被阻塞。在等待的時候,其他的執行緒能夠使用CPU去處理已經讀取完的檔案。其結果就是,磁碟總是在繁忙地讀取不同的檔案到記憶體中。這會帶來磁碟和CPU利用率的提升。而且每個執行緒只需要記錄一個檔案,因此這種方式也很容易程式設計實現。

3.3、程式響應更快

將一個單執行緒應用程式變成多執行緒應用程式的另一個常見的目的是實現一個響應更快的應用程式。設想一個伺服器應用,它在某一個埠監聽進來的請求。當一個請求到來時,它去處理這個請求,然後再返回去監聽。

伺服器的流程如下所述:

while(server is active){
    
    listen for request;  // 監聽

    process request;     // 處理請求    
}

如果一個請求需要佔用大量的時間來處理,在這段時間內新的客戶端就無法傳送請求給服務端。只有伺服器只有在監聽的時候,請求才能被接收。

另一種設計是,監聽執行緒把請求傳遞給工作者執行緒(worker thread),然後立刻返回去監聽。而工作者執行緒則能夠處理這個請求併發送一個回覆給客戶端。這種設計如下所述:

while(server is active){
    
    listen for request;              // 監聽
    hand request to worker thread;   // 將請求轉交給工作者執行緒處理 
}

這種方式,服務端執行緒迅速地返回去監聽。因此,更多的客戶端能夠傳送請求給服務端。這個服務也變得響應更快。

桌面應用也是同樣如此。如果你點選一個按鈕開始執行一個耗時的任務,這個執行緒既要執行任務又要更新視窗和按鈕,那麼在任務執行的過程中,這個應用程式看起來好像沒有反應一樣。相反,任務可以傳遞給工作者執行緒(word thread)。當工作者執行緒在繁忙地處理任務的時候,視窗執行緒可以自由地響應其他使用者的請求。當工作者執行緒完成任務的時候,它傳送訊號給視窗執行緒。視窗執行緒便可以更新應用程式視窗,並顯示任務的結果。對使用者而言,這種具有工作者執行緒設計的程式顯得響應速度更快。


4、多執行緒的代價

從一個單執行緒的應用到一個多執行緒的應用並不僅僅帶來好處,它也會有一些代價。不要僅僅為了使用多執行緒而使用多執行緒。而應該明確在使用多執行緒時能多來的好處比所付出的代價大的時候,才使用多執行緒。如果存在疑問,應該嘗試測量一下應用程式的效能和響應能力,而不只是猜測。

4.1、設計更復雜

雖然有一些多執行緒應用程式比單執行緒的應用程式要簡單,但其他的一般都更復雜。在多執行緒訪問共享資料的時候,這部分程式碼需要特別的注意。執行緒之間的互動往往非常複雜。不正確的執行緒同步產生的錯誤非常難以被發現,並且重現以修復。

4.2、上下文切換的開銷

當CPU從執行一個執行緒切換到執行另外一個執行緒的時候,它需要先儲存當前執行緒的本地的資料,程式指標等,然後載入另一個執行緒的本地資料,程式指標等,最後才開始執行。這種切換稱為“上下文切換”(“context switch”)。CPU會在一個上下文中執行一個執行緒,然後切換到另外一個上下文中執行另外一個執行緒。上下文切換並不廉價。如果沒有必要,應該減少上下文切換的發生。

你可以通過維基百科閱讀更多的關於上下文切換相關的內容:http://en.wikipedia.org/wiki/Context_switch

4.3、增加資源消耗

執行緒在執行的時候需要從計算機裡面得到一些資源。除了CPU,執行緒還需要一些記憶體來維持它本地的堆疊它也需要佔用作業系統中一些資源來管理執行緒。我們可以嘗試編寫一個程式,讓它建立100個執行緒,這些執行緒什麼事情都不做,只是在等待,然後看看這個程式在執行的時候佔用了多少記憶體。


5、競態條件與臨界區

在同一程式中執行多個執行緒本身不會導致問題,問題在於多個執行緒訪問了相同的資源。如,同一記憶體區(變數,陣列,或物件)、系統(資料庫,web services等)或檔案。實際上,這些問題只有在一或多個執行緒向這些資源做了寫操作時才有可能發生,只要資源沒有發生變化,多個執行緒讀取相同的資源就是安全的。

多執行緒同時執行下面的程式碼可能會出錯:

public class Counter{
    
    protected long count = 0;
    
    public void add(long value){
        this.count = this.count + value;
    }
}

假如現在,執行緒A和執行緒B同時執行同一個Counter物件的add()方法,我們無法知道作業系統何時會在兩個執行緒之間切換。JVM並不是將這段程式碼視為單條指令來執行的,而是按照下面的順序:

1、從記憶體獲取 this.count 的值放到暫存器;

2、將暫存器中的值增加value;

3、將暫存器中的值寫回記憶體。

假設現在有這樣一種場景:觀察執行緒A和B交錯執行會發生什麼:

        this.count = 0;
   A:	讀取 this.count 到一個暫存器 (0)
   B:	讀取 this.count 到一個暫存器 (0)
   B: 	將暫存器的值加2
   B:	回寫暫存器值(2)到記憶體. this.count 現在等於 2
   A:	將暫存器的值加3
   A:	回寫暫存器值(3)到記憶體. this.count 現在等於 3

兩個執行緒分別加了2和3到count變數上,兩個執行緒執行結束後count變數的值應該等於5。然而由於兩個執行緒是交叉執行的,兩個執行緒從記憶體中讀出的初始值都是0。然後各自加了2和3,並分別寫回記憶體。最終的值並不是期望的5,而是最後寫回記憶體的那個執行緒的值,上面例子中最後寫回記憶體的是執行緒A,但實際中也可能是執行緒B。如果沒有采用合適的同步機制,執行緒間的交叉執行情況就無法預料。

  • 競態條件 & 臨界區:

當兩個執行緒競爭同一資源時,如果對資源的訪問順序敏感,就稱存在競態條件。導致競態條件發生的程式碼區稱作臨界區。上例中add()方法就是一個臨界區,它會產生競態條件。在臨界區中使用適當的同步就可以避免競態條件。


6、執行緒安全與共享資源

允許被多個執行緒同時執行的程式碼稱作執行緒安全的程式碼。執行緒安全的程式碼不包含競態條件。當多個執行緒同時更新共享資源時會引發競態條件。因此,瞭解Java執行緒執行時共享了什麼資源很重要。

6.1、區域性變數

區域性變數儲存線上程自己的棧中。也就是說,區域性變數永遠也不會被多個執行緒共享。所以,基礎型別的區域性變數是執行緒安全的。下面是基礎型別的區域性變數的一個例子:

public void someMethod(){
    
    long threadSafeInt = 0;
    
    threadSafeInt++;
}

6.2、區域性的物件引用

物件的區域性引用和基礎型別的區域性變數不太一樣。儘管引用本身沒有被共享,但引用所指的物件並沒有儲存線上程的棧內。所有的物件都存在共享堆中。如果在某個方法中建立的物件不會逃逸出(譯者注:即該物件不會被其它方法獲得,也不會被非區域性變數引用到)該方法,那麼它就是執行緒安全的。實際上,哪怕將這個物件作為引數傳給其它方法,只要別的執行緒獲取不到這個物件,那它仍是執行緒安全的。下面是一個執行緒安全的區域性引用樣例:

public void someMethod(){

    LocalObject localObject = new LocalObject();

    localObject.callMethod();

    method2(localObject);
}

public void method2(LocalObject localObject){

    localObject.setValue("value");  // 還是localObject物件,沒有其他物件
}

樣例中LocalObject物件沒有被方法返回,也沒有被傳遞給someMethod()方法外的物件。每個執行someMethod()的執行緒都會建立自己的LocalObject物件,並賦值給localObject引用。因此,這裡的LocalObject是執行緒安全的。事實上,整個someMethod()都是執行緒安全的。即使將LocalObject作為引數傳給同一個類的其它方法或其它類的方法時,它仍然是執行緒安全的。當然,如果LocalObject通過某些方法被傳給了別的執行緒,那它就不再是執行緒安全的了。

6.3、物件成員

物件成員儲存在堆上。如果兩個執行緒同時更新同一個物件的同一個成員,那這個程式碼就不是執行緒安全的。

下面是一個樣例:

public class NotThreadSafe(){

    StringBuilder builder = new StringBuilder();

    public void add(String text){
        this.builder.append(text);
    }
}

如果兩個執行緒同時呼叫同一個NotThreadSafe例項上的add()方法,就會有競態條件問題。例如:

NotThreadSafe sharedInstance = new NotThreadSafe();

new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();

public class MyRunnable implements Runnable{

    NotThreadSafe instance = null;
    
    public MyRunnable(NotThreadSafe instance){
    
        this.instance = instance;
    }

    public void run(){
        this.instance.add("some text");
    }
}

注意兩個MyRunnable共享了同一個NotThreadSafe物件。因此,當它們呼叫add()方法時會造成競態條件。當然,如果這兩個執行緒在不同的NotThreadSafe例項上呼叫call()方法,就不會導致競態條件。下面是稍微修改後的例子:

new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();

現在兩個執行緒都有自己單獨的NotThreadSafe物件,呼叫add()方法時就會互不干擾,再也不會有競態條件問題了。所以非執行緒安全的物件仍可以通過某種方式來消除競態條件。

6.4、執行緒控制逃逸規則

執行緒控制逃逸規則可以幫助你判斷程式碼中對某些資源的訪問是否是執行緒安全的。

如果一個資源的建立、使用、銷燬都在同一個執行緒內完成,且永遠不會脫離該執行緒的控制,則該資源的使用就是執行緒安全的。

資源可以是物件,陣列,檔案,資料庫連線,套接字等等。Java中你無需主動銷燬物件,所以“銷燬”指不再有引用指向物件。

即使物件本身執行緒安全,但如果該物件中包含其他資源(檔案,資料庫連線),整個應用也許就不再是執行緒安全的了。比如2個執行緒都建立了各自的資料庫連線,每個連線自身是執行緒安全的,但它們所連線到的同一個資料庫也許不是執行緒安全的。比如,2個執行緒執行如下程式碼:

檢查記錄X是否存在,如果不存在,插入X

如果兩個執行緒同時執行,而且碰巧檢查的是同一個記錄,那麼兩個執行緒最終可能都插入了記錄:

執行緒1檢查記錄X是否存在。檢查結果:不存在
執行緒2檢查記錄X是否存在。檢查結果:不存在
執行緒1插入記錄X
執行緒2插入記錄X

同樣的問題也會發生在檔案或其他共享資源上。因此,區分某個執行緒控制的物件是資源本身,還是僅僅到某個資源的引用很重要


7、執行緒安全及不可變性

當多個執行緒同時訪問同一個資源,並且其中的一個或者多個執行緒對這個資源進行了寫操作,才會產生競態條件。多個執行緒同時讀同一個資源不會產生競態條件。

我們可以通過建立不可變的共享物件來保證物件線上程間共享時不會被修改,從而實現執行緒安全。如下示例:

public class ImmutableValue{

    private int value = 0;

    public ImmuttableValue(int value){
        this.value = value;   // 通過建構函式給value賦值
    }
    
    public int getValue(){
        return this.value;
    }
}

請注意ImmutableValue類的成員變數value是通過建構函式賦值的,並且在類中沒有set方法。這意味著一旦ImmutableValue例項被建立,value變數就不能再被修改,這就是不可變性。但你可以通過getValue()方法讀取這個變數的值。

譯者注:注意,“不變”(Immutable)和“只讀”(Read Only)是不同的。當一個變數是“只讀”時,變數的值不能直接改變,但是可以在其它變數發生改變的時候發生改變。比如,一個人的出生年月日是“不變”屬性,而一個人的年齡便是“只讀”屬性,但是不是“不變”屬性。隨著時間的變化,一個人的年齡會隨之發生變化,而一個人的出生年月日則不會變化。這就是“不變”和“只讀”的區別。(摘自《Java與模式》第34章)

如果你需要對ImmutableValue類的例項進行操作,可以通過得到value變數後建立一個新的例項來實現,下面是一個對value變數進行加法操作的示例:

public class ImmutableValue{

    private int value = 0;

    public ImmutableValue(int value){
        this.value = value;
    }

    public int getValue(){
        return this.value;
    }

    public ImmutableValue add(int valueToAdd){
        
        return new ImmutableValue(this.value + valueToAdd);
    }
}

請注意add()方法以加法操作的結果作為一個新的ImmutableValue類例項返回,而不是直接對它自己的value變數進行操作。

引用不是執行緒安全的!重要的是要記住,即使一個物件是執行緒安全的不可變物件,指向這個物件的引用也可能不是執行緒安全的。看這個例子:

public void Calculator{

    private ImmutableValue currentValue = null;

    public ImmutableValue getValue(){
        return currentValue;
    }

    public void setValue(ImmutableValue newValue){
        this.currentValue = newValue;
    }

    public void add(int newValue){
        this.currentValue = this.currentValue.add(newValue);
    }
}

Calculator類持有一個指向ImmutableValue例項的引用。注意,通過setValue()方法和add()方法可能會改變這個引用。因此,即使Calculator類內部使用了一個不可變物件,但Calculator類本身還是可變的,因此Calculator類不是執行緒安全的。換句話說:ImmutableValue類是執行緒安全的,但使用它的類不是。當嘗試通過不可變性去獲得執行緒安全時,這點是需要牢記的。

要使Calculator類實現執行緒安全,將getValue()、setValue()和add()方法都宣告為同步方法即可。


推薦及參考:

上一篇:執行緒的五種可用狀態

1、併發基礎與Java多執行緒:https://blog.csdn.net/a724888/article/details/60867044

2、Java多執行緒系列目錄https://www.cnblogs.com/skywang12345/p/java_threads_category.html

說明:本文轉發自:https://blog.csdn.net/a724888/article/details/60867044