1. 程式人生 > >Java多執行緒(二) —— 執行緒安全、執行緒同步、執行緒間通訊(含面試題集)

Java多執行緒(二) —— 執行緒安全、執行緒同步、執行緒間通訊(含面試題集)

上一篇博文:Java多執行緒(一) —— 執行緒的狀態詳解中詳細介紹了執行緒的五種狀態及狀態間的轉換。本文著重介紹了執行緒安全的相關知識點,包括執行緒同步和鎖機制、執行緒間通訊以及相關面試題的總結

一、執行緒安全

多個執行緒在執行同一段程式碼的時候,每次的執行結果和單執行緒執行的結果都是一樣的,不存在執行結果的二義性,就可以稱作是執行緒安全的。

講到執行緒安全問題,其實是指多執行緒環境下對共享資源的訪問可能會引起此共享資源的不一致性。因此,為避免執行緒安全問題,應該避免多執行緒環境下對此共享資源的併發訪問。

執行緒安全問題多是由全域性變數和靜態變數引起的,當多個執行緒對共享資料只執行讀操作,不執行寫操作時,一般是執行緒安全的;當多個執行緒都執行寫操作時,需要考慮執行緒同步來解決執行緒安全問題。

二、執行緒同步(synchronized/Lock)

執行緒同步:將操作共享資料的程式碼行作為一個整體,同一時間只允許一個執行緒執行,執行過程中其他執行緒不能參與執行。目的是為了防止多個執行緒訪問一個數據物件時,對資料造成的破壞。

(1)同步方法(synchronized)

對共享資源進行訪問的方法定義中加上synchronized關鍵字修飾,使得此方法稱為同步方法。可以簡單理解成對此方法進行了加鎖,其鎖物件為當前方法所在的物件自身。多執行緒環境下,當執行此方法時,首先都要獲得此同步鎖(且同時最多隻有一個執行緒能夠獲得),只有當執行緒執行完此同步方法後,才會釋放鎖物件,其他的執行緒才有可能獲取此同步鎖,以此類推...格式如下:

public synchronized void run() {        
     // ....
}

(2)同步程式碼塊(synchronized)

使用同步方法時,使得整個方法體都成為了同步執行狀態,會使得可能出現同步範圍過大的情況,於是,針對需要同步的程式碼可以直接另一種同步方式——同步程式碼塊來解決。格式如下:

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

其中,obj為鎖物件,因此,選擇哪一個物件作為鎖是至關重要的。一般情況下,都是選擇此共享資源物件作為鎖物件。

(3)同步鎖(Lock)

 使用Lock物件同步鎖可以方便地解決選擇鎖物件的問題,唯一需要注意的一點是Lock物件需要與資源物件同樣具有一對一的關係。Lock物件同步鎖一般格式為:

class X {
    // 顯示定義Lock同步鎖物件,此物件與共享資源具有一對一關係
    private final Lock lock = new ReentrantLock();
    
    public void m(){
        // 加鎖
        lock.lock();
        
        //...  需要進行執行緒安全同步的程式碼
        
        // 釋放Lock鎖
        lock.unlock();
    }
}

什麼時候需要同步:

(1)可見性同步:在以下情況中必須同步: 1)讀取上一次可能是由另一個執行緒寫入的變數 ;2)寫入下一次可能由另一個執行緒讀取的變數

(2)一致性同步:當修改多個相關值時,您想要其它執行緒原子地看到這組更改—— 要麼看到全部更改,要麼什麼也看不到。

這適用於相關資料項(如粒子的位置和速率)和元資料項(如連結串列中包含的資料值和列表自身中的資料項的鏈)。

在某些情況中,您不必用同步來將資料從一個執行緒傳遞到另一個,因為 JVM 已經隱含地為您執行同步。這些情況包括:

  1. 由靜態初始化器(在靜態欄位上或 static{} 塊中的初始化器)
  2. 初始化資料時 
  3. 訪問 final 欄位時
  4. 在建立執行緒之前建立物件時 
  5. 執行緒可以看見它將要處理的物件時

鎖的原理:

  • Java中每個物件都有一個內建鎖
  • 當程式執行到非靜態的synchronized同步方法上時,自動獲得與正在執行程式碼類的當前例項(this例項)有關的鎖。獲得一個物件的鎖也稱為獲取鎖、鎖定物件、在物件上鎖定或在物件上同步。
  • 當程式執行到synchronized同步方法或程式碼塊時才該物件鎖才起作用。
  • 一個物件只有一個鎖。所以,如果一個執行緒獲得該鎖,就沒有其他執行緒可以獲得鎖,直到第一個執行緒釋放(或返回)鎖。這也意味著任何其他執行緒都不能進入該物件上的synchronized方法或程式碼塊,直到該鎖被釋放。
  • 釋放鎖是指持鎖執行緒退出了synchronized同步方法或程式碼塊。
鎖與同步要點: 1)、只能同步方法,而不能同步變數和類; 2)、每個物件只有一個鎖;當提到同步時,應該清楚在什麼上同步?也就是說,在哪個物件上同步? 3)、不必同步類中所有的方法,類可以同時擁有同步和非同步方法。 4)、如果兩個執行緒要執行一個類中的synchronized方法,並且兩個執行緒使用相同的例項來呼叫方法,那麼一次只能有一個執行緒能夠執行方法,另一個需要等待,直到鎖被釋放。也就是說:如果一個執行緒在物件上獲得一個鎖,就沒有任何其他執行緒可以進入(該物件的)類中的任何一個同步方法。 5)、如果執行緒擁有同步和非同步方法,則非同步方法可以被多個執行緒自由訪問而不受鎖的限制。 6)、執行緒睡眠時,它所持的任何鎖都不會釋放。 7)、執行緒可以獲得多個鎖。比如,在一個物件的同步方法裡面呼叫另外一個物件的同步方法,則獲取了兩個物件的同步鎖。 8)、同步損害併發性,應該儘可能縮小同步範圍。同步不但可以同步整個方法,還可以同步方法中一部分程式碼塊。 9)、在使用同步程式碼塊時候,應該指定在哪個物件上同步,也就是說要獲取哪個物件的鎖。 10)、同步靜態方法,需要一個用於整個類物件的鎖,這個物件是就是這個類(XXX.class)。 執行緒不能獲得鎖會怎麼樣:如果執行緒試圖進入同步方法,而其鎖已經被佔用,則執行緒在該物件上被阻塞。實質上,執行緒進入該物件的的一種池中,必須在哪裡等待,直到其鎖被釋放,該執行緒再次變為可執行或執行為止。 執行緒死鎖:當兩個執行緒被阻塞,每個執行緒都在等待另一個執行緒時就發生死鎖。有一些設計方法能幫助避免死鎖,如始終按照預定義的順序獲取鎖這一策略。 執行緒同步小結 1、執行緒同步的目的是為了保護多個執行緒反問一個資源時對資源的破壞。 2、執行緒同步方法是通過來實現,每個物件都有且僅有一個鎖,這個鎖與一個特定的物件關聯,執行緒一旦獲取了物件鎖,其他訪問該物件的執行緒就無法再訪問該物件的其他同步方法。 3、對於靜態同步方法,鎖是針對這個類的,鎖物件是該類的Class物件。靜態和非靜態方法的鎖互不干預。一個執行緒獲得鎖,當在一個同步方法中訪問另外物件上的同步方法時,會獲取這兩個物件鎖。 4、對於同步,要時刻清醒在哪個物件上同步,這是關鍵。 5、編寫執行緒安全的類,需要時刻注意對多個執行緒競爭訪問資源的邏輯和安全做出正確的判斷,對“原子”操作做出分析,並保證原子操作期間別的執行緒無法訪問競爭資源。 6、當多個執行緒等待一個物件鎖時,沒有獲取到鎖的執行緒將發生阻塞。 7、死鎖是執行緒間相互等待鎖鎖造成的,在實際中發生的概率非常的小。真讓你寫個死鎖程式,不一定好使,呵呵。但是,一旦程式發生死鎖,程式將死掉。

三、執行緒通訊:wait()/notify()/notifyAll()

wait():導致當前執行緒等待並使其進入到等待阻塞狀態。直到其他執行緒呼叫該同步鎖物件的notify()或notifyAll()方法來喚醒此執行緒。

  • void wait(long timeout) -- 導致當前執行緒等待,直到其他執行緒呼叫此物件的 notify() 方法或 notifyAll() 方法,或者超過指定的時間量。 
  • void wait(long timeout, int nanos) -- 導致當前執行緒等待,直到其他執行緒呼叫此物件的 notify() 方法或 notifyAll() 方法,或者其他某個執行緒中斷當前執行緒,或者已超過某個實際時間量。

notify():喚醒在此同步鎖物件上等待的單個執行緒,如果有多個執行緒都在此同步鎖物件上等待,則會任意選擇其中某個執行緒進行喚醒操作,只有當前執行緒放棄對同步鎖物件的鎖定,才可能執行被喚醒的執行緒。

notifyAll():喚醒在此同步鎖物件上等待的所有執行緒,只有當前執行緒放棄對同步鎖物件的鎖定,才可能執行被喚醒的執行緒。

  這三個方法主要都是用於多執行緒中,但實際上都是Object類中的本地方法。因此,理論上,任何Object物件都可以作為這三個方法的主調,在實際的多執行緒程式設計中,只有同步鎖物件調這三個方法,才能完成對多執行緒間的執行緒通訊。

注意點:

1.wait()方法執行後,當前執行緒立即進入到等待阻塞狀態,其後面的程式碼不會執行;

2.notify()/notifyAll()方法執行後,將喚醒此同步鎖物件上的(任意一個-notify()/所有-notifyAll())執行緒物件,但是,此時還並沒有釋放同步鎖物件,也就是說,如果notify()/notifyAll()後面還有程式碼,還會繼續進行,知道當前執行緒執行完畢才會釋放同步鎖物件;

3.notify()/notifyAll()執行後,如果右面有sleep()方法,則會使當前執行緒進入到阻塞狀態,但是同步物件鎖沒有釋放,依然自己保留,那麼一定時候後還是會繼續執行此執行緒,接下來同2;

4.wait()/notify()/nitifyAll()完成執行緒間的通訊或協作都是基於不同物件鎖的,因此,如果是不同的同步物件鎖將失去意義,同時,同步物件鎖最好是與共享資源物件保持一一對應關係;

5.當wait執行緒喚醒後並執行時,是接著上次執行到的wait()方法程式碼後面繼續往下執行的。

四、相關面試題

1. 執行緒和程序有什麼區別?
答:一個程序是一個獨立(self contained)的執行環境,它可以被看作一個程式或者一個應用。而執行緒是在程序中執行的一個任務。執行緒是程序的子集,一個程序可以有很多執行緒,每條執行緒並行執行不同的任務。不同的程序使用不同的記憶體空間,而所有的執行緒共享一片相同的記憶體空間。別把它和棧記憶體搞混,每個執行緒都擁有單獨的棧記憶體用來儲存本地資料。

2. 如何在Java中實現執行緒?比較這種種方式
答:建立執行緒有兩種方式:
(1)繼承 Thread 類,擴充套件執行緒。
(2)實現 Runnable 介面。

繼承Thread類的方式有它固有的弊端,因為Java中繼承的單一性,繼承了Thread類就不能繼承其他類了;同時也不符合繼承的語義,Dog跟Thread沒有直接的父子關係,繼承Thread只是為了能擁有一些功能特性。

而實現Runnable介面,①避免了單一繼承的侷限性,②同時更符合面向物件的程式設計方式,即將執行緒物件進行單獨的封裝,③而且實現介面的方式降低了執行緒物件(Dog)和執行緒任務(run方法中的程式碼)的耦合性,④如上面所述,可以使用同一個Dog類的例項來建立並開啟多個執行緒,非常方便的實現資源的共享。實際上Thread類也是實現了Runnable介面。實際開發中多是使用實現Runnable介面的方式。

3. 啟動一個執行緒是呼叫run()還是start()方法?
答:啟動一個執行緒是呼叫start()方法,使執行緒所代表的虛擬處理機處於可執行狀態,這意味著它可以由JVM 排程並執行,這並不意味著執行緒就會立即執行。run()方法是執行緒啟動後要進行回撥(callback)的方法。

4. wait()和sleep()比較

共同點: 
1). 他們都是在多執行緒的環境下,sleep()方法和物件的wait()方法都可以讓執行緒暫停執行,都可以在程式的呼叫處阻塞指定的毫秒數,並返回。 
2). wait()和sleep()都可以通過interrupt()方法打斷執行緒的暫停狀態 ,從而使執行緒立刻丟擲InterruptedException。 
如果執行緒A希望立即結束執行緒B,則可以對執行緒B對應的Thread例項呼叫interrupt方法。如果此刻執行緒B正在wait/sleep /join,則執行緒B會立刻丟擲InterruptedException,在catch() {} 中直接return即可安全地結束執行緒。 需要注意的是,InterruptedException是執行緒自己從內部丟擲的,並不是interrupt()方法丟擲的。對某一執行緒呼叫 interrupt()時,如果該執行緒正在執行普通的程式碼,那麼該執行緒根本就不會丟擲InterruptedException。但是,一旦該執行緒進入到 wait()/sleep()/join()後,就會立刻丟擲InterruptedException 。 

不同點: 
1). Thread類的方法:sleep(),yield()等 
     Object類的方法:wait()和notify()等 
2). 每個物件都有一個鎖來控制同步訪問。Synchronized關鍵字可以和物件的鎖互動,來實現執行緒的同步。 
     sleep()方法讓當前執行緒暫停執行指定的時間,將執行機會(CPU)讓給其他執行緒,但是物件的鎖依然保持,休眠結束後執行緒會自動回到就緒狀態;

     wait()方法導致當前執行緒放棄物件的鎖(執行緒暫停執行),進入物件的等待池(wait pool),只有呼叫物件的notify()方法(或notifyAll()方法)時才能喚醒等待池中的執行緒進入等鎖池(lock pool),如果執行緒重新獲得物件的鎖就可以進入就緒狀態。
3). wait,notify和notifyAll只能在同步控制方法或者同步控制塊裡面使用,而sleep可以在任何地方使用 
4). sleep必須捕獲異常,而wait,notify和notifyAll不需要捕獲異常
所以sleep()和wait()方法的最大區別是:
  sleep()睡眠時,保持物件鎖,仍然佔有該鎖;
  而wait()睡眠時,釋放物件鎖。
但是wait()和sleep()都可以通過interrupt()方法打斷執行緒的暫停狀態,從而使執行緒立刻丟擲InterruptedException(但不建議使用該方法)。

5. sleep()方法和yield()方法有什麼區別?
① sleep()方法給其他執行緒執行機會時不考慮執行緒的優先順序,因此會給低優先順序的執行緒以執行的機會;yield()方法只會給相同優先順序或更高優先順序的執行緒以執行的機會;
② 執行緒執行sleep()方法後轉入阻塞(blocked)狀態,而執行yield()方法後轉入就緒(ready)狀態;
③ sleep()方法需要宣告丟擲InterruptedException,而yield()方法沒有宣告任何異常;
④ sleep()方法比yield()方法(跟作業系統CPU排程相關)具有更好的可移植性。

6. 執行緒類的一些常用方法: 

  • sleep(): 強迫一個執行緒睡眠N毫秒,是一個靜態方法,呼叫此方法要處理InterruptedException異常;
  • join():  讓一個執行緒等待另一個執行緒完成才繼續執行;
  • yeild(): 執行緒讓步,暫停當前正在執行的執行緒物件讓出CPU資源,將當前執行緒從執行狀態轉換到就緒狀態並執行其他優先順序相同或更高的執行緒;
  • isAlive(): 判斷一個執行緒是否存活。 
  • activeCount(): 程式中活躍的執行緒數。 
  • enumerate(): 列舉程式中的執行緒。 
  • currentThread(): 得到當前執行緒。 
  • isDaemon(): 一個執行緒是否為守護執行緒。 
  • setDaemon(): 設定一個執行緒為守護執行緒。(使用者執行緒和守護執行緒的區別在於,是否等待主執行緒依賴於主執行緒結束而結束) 
  • setName(): 為執行緒設定一個名稱。 
  • setPriority(): 設定一個執行緒的優先順序。
  • wait():使一個執行緒處於等待(阻塞)狀態,並且釋放所持有的物件的鎖;
  • notify():喚醒一個處於等待狀態的執行緒,當然在呼叫此方法的時候,並不能確切的喚醒某一個等待狀態的執行緒,而是由JVM確定喚醒哪個執行緒,而且與優先順序無關;
  • notityAll():喚醒所有處於等待狀態的執行緒,該方法並不是將物件的鎖給所有執行緒,而是讓它們競爭,只有獲得鎖的執行緒才能進入就緒狀態;

7. 同步程式碼塊和同步方法的區別

  兩者的區別主要體現在同步鎖上面。對於例項的同步方法,因為只能使用this來作為同步鎖,如果一個類中需要使用到多個鎖,為了避免鎖的衝突,必然需要使用不同的物件,這時候同步方法不能滿足需求,只能使用同步程式碼塊(同步程式碼塊可以傳入任意物件);或者多個類中需要使用到同一個鎖,這時候多個類的例項this顯然是不同的,也只能使用同步程式碼塊,傳入同一個物件。

8. 對比synchronized和Lock

1)、synchronized是關鍵字,就和if...else...一樣,是語法層面的實現,因此synchronized獲取鎖以及釋放鎖都是Java虛擬機器幫助使用者完成的;ReentrantLock是類層面的實現,因此鎖的獲取以及鎖的釋放都需要使用者自己去操作。特別再次提醒,ReentrantLock在lock()完了,一定要手動unlock(),一般放在finally語句塊中。

2)、synchronized簡單,簡單意味著不靈活,而ReentrantLock的鎖機制給使用者的使用提供了極大的靈活性。這點在Hashtable和ConcurrentHashMap中體現得淋漓盡致。synchronized一鎖就鎖整個Hash表,而ConcurrentHashMap則利用ReentrantLock實現了鎖分離,鎖的只是segment而不是整個Hash表

3)、synchronized是不公平鎖,而ReentrantLock可以指定鎖是公平的還是非公平的

4)、synchronized實現等待/通知機制通知的執行緒是隨機的,ReentrantLock實現等待/通知機制可以有選擇性地通知

5)、和synchronized相比,ReentrantLock提供給使用者多種方法用於鎖資訊的獲取,比如可以知道lock是否被當前執行緒獲取、lock被同一個執行緒呼叫了幾次、lock是否被任意執行緒獲取等等

總結起來,我認為如果只需要鎖定簡單的方法、簡單的程式碼塊,那麼考慮使用synchronized,複雜的多執行緒處理場景下可以考慮使用ReentrantLock

Voiatile關鍵字:

volatile關鍵字是Java併發的最輕量級實現,本質上有兩個功能,在生成的彙編語句中加入LOCK關鍵字和記憶體屏障

作用就是保證每一次執行緒load和write兩個操作,都會直接從主記憶體中進行讀取和覆蓋,而非普通變數從執行緒內的工作空間(預設各位已經熟悉Java多執行緒記憶體模型)

但它有一個很致命的缺點,導致它的使用範圍不多,就是他只保證在讀取和寫入這兩個過程是執行緒安全的。如果我們對一個volatile修飾的變數進行多執行緒 下的自增操作,還是會出現執行緒安全問題。根本原因在於volatile關鍵字無法對自增進行安全性修飾,因為自增分為三步,讀取-》+1-》寫入。中間多 個執行緒同時執行+1操作,還是會出現執行緒安全性問題。

參考連結:

http://www.importnew.com/21136.html

http://lavasoft.blog.51cto.com/62575/99155/

http://www.cnblogs.com/lwbqqyumidi/p/3821389.html