Java 線程同步

線程安全問題

關於線程安全問題,有一個經典的問題——銀行取錢的問題。銀行取錢的基本流程基本上能夠分爲以下幾個步驟。java

  1. 用戶輸入帳戶、密碼,系統判斷用戶的帳戶、密碼是否匹配。
  2. 用戶輸入取款金額。
  3. 系統判斷帳戶餘額是否大於取款金額。
  4. 若是餘額大於取款金額,則取款成功;若是餘額小於取款金額,則取款失敗。

乍一看上去,這個流程確實就是平常生活中的取款流程,這個流程沒有任何問題。但一旦將這個流程放在多線程併發的場景下,就有可能出現問題。注意此處說的是有可能,並非說必定。也許你的程序運行了一百萬次都沒有出現問題,但沒有出現問題並不等於沒有問題!編程

按上面的流程去編寫取款程序,並使用兩個線程來模擬取錢操做,模擬兩我的使用同一個帳戶併發取錢的問題。此處忽略檢查帳戶和密碼的操做,僅僅模擬後面三步操做。下面先定義一個帳戶類,該帳戶類封裝了帳戶編號和餘額兩個實例變量。安全

public class Account{ //封裝帳戶編號、帳戶餘額兩個屬性
    private String accountNo; private double balance; public Account(){} //構造器
    public Account(String accountNo , double balance){ this.accountNo = accountNo; this.balance = balance; } //省略getter、setter方法 //下面兩個方法根據accountNo來計算Account的hashCode和判斷equals
    public int hashCode(){ return accountNo.hashCode(); } public boolean equals(Object obj){ if (obj != null && obj.getClass() == Account.class){ Account target = (Account)obj; return target.getAccountNo().equals(accountNo); } return false; } }

接下來提供一個取錢的線程類,該線程類根據執行帳戶、取錢數量進行取錢操做,取錢的邏輯是當其他額不足時沒法提取現金,當餘額足夠時系統吐出鈔票,餘額減小。多線程

public class DrawThread extends Thread{ //模擬用戶帳戶
    private Account account; //當前取錢線程所但願取的錢數
    private double drawAmount; public DrawThread(String name, Account account, double drawAmount){ super(name); this.account = account; this.drawAmount = drawAmount; } //當多條線程修改同一個共享數據時,將涉及到數據安全問題。
    public void run(){ //帳戶餘額大於取錢數目
        if (account.getBalance() >= drawAmount){ //吐出鈔票
            System.out.println(getName() + "取錢成功!吐出鈔票:" + drawAmount); /* try{ Thread.sleep(1); } catch (InterruptedException ex){ ex.printStackTrace(); } */
            //修改餘額
            account.setBalance(account.getBalance() - drawAmount); System.out.println("\t餘額爲: " + account.getBalance()); } else{ System.out.println(getName() + "取錢失敗!餘額不足!"); } } }

讀者先不要管程序中那段被註釋掉的粗體字代碼,上面程序是一個很是簡單的取錢邏輯,這個取錢邏輯與實際的取錢操做也很類似。程序的主程序很是簡單,僅僅是建立一個帳戶,並啓動兩個線程從該帳戶中取錢。程序以下。併發

public class TestDraw{ public static void main(String[] args) { //建立一個帳戶
        Account acct = new Account("1234567" , 1000); //模擬兩個線程對同一個帳戶取錢
        new DrawThread("甲" , acct , 800).start(); new DrawThread("乙" , acct , 800).start(); } }

屢次運行上面程序,頗有可能都會看到以下圖所示的錯誤結果。工具

運行結果並非銀行所指望的結果(不過有可能看到運行正確的效果),這正是多線程編程忽然出現的「偶然」錯誤——由於線程調度的不肯定性。假設系統線程調度器在粗體字代碼處暫停,讓另外一個線程執行——爲了強制暫停,只要取消上面程序中粗體字代碼的註釋便可。取消註釋後再次編譯 DrawThread.java,並再次運行 DrawTest 類,將總能夠看到如上圖所示的錯誤結果。性能

問題出現了:帳戶餘額只有1000時取出了 1600,並且帳戶餘額出現了負值,這不是銀行但願的結果。雖然上面程序是人爲地使用 Thread.sleep(1) 來強制線程調度切換,但這種切換也是徹底可能發生的——100000次操做只要有1次出現了錯誤,那就是編程錯誤引發的。ui

同步代碼塊

之因此出現如上圖所示的結果,是由於 run() 方法的方法體不具備同步安全性——程序中有兩個併發線程在修改 Account 對象;並且系統剛好在粗體字代碼處執行線程切換,切換給另外一個修改 Account 對象的線程,因此就出現了問題。this

提示:就像前面介紹的文件併發訪問,當有兩個進程併發修改同一個文件時就有可能形成異常。spa

爲了解決這個問題, Java 的多線程支持引入了同步監視器來解決這個問題,使用同步監視器的通用方法就是同步代碼塊。同步代碼塊的語法格式以下:

synchronized(obj){ //此處的代碼就是同步代碼塊 }

上面語法格式中 synchronized 後括號裏的 obj 就是同步監視器,上面代碼的含義是:線程開始執行同步代碼塊以前,必須先得到對同步監視器的鎖定。

注意:任什麼時候刻只能有一個線程能夠得到對同步監視器的鎖定,當同步代碼塊執行完成後,該線程會釋放對該同步監視器的鎖定。

雖然 Java 程序容許使用任何對象做爲同步監視器,但想一下同步監視器的目的:阻止兩個線程對同一個共享資源進行併發訪問,所以一般推薦使用可能被併發訪問的共享資源充當同步監視器。對於上面的取錢模擬程序,應該考慮使用帳戶(account )做爲同步監視器,把程序修改爲以下形式

public class DrawThread extends Thread{ //模擬用戶帳戶
    private Account account; //當前取錢線程所但願取的錢數
    private double drawAmount; public DrawThread(String name, Account account, double drawAmount){ super(name); this.account = account; this.drawAmount = drawAmount; } //當多條線程修改同一個共享數據時,將涉及到數據安全問題。
    public void run(){ //使用account做爲同步監視器,任何線程進入下面同步代碼塊以前, //必須先得到對account帳戶的鎖定——其餘線程沒法得到鎖,也就沒法修改它 //這種作法符合:加鎖-->修改完成-->釋放鎖 邏輯
        synchronized (account){ //帳戶餘額大於取錢數目
            if (account.getBalance() >= drawAmount) { //吐出鈔票
                System.out.println(getName() + 
                    "取錢成功!吐出鈔票:" + drawAmount); try{ Thread.sleep(1); }catch (InterruptedException ex){ ex.printStackTrace(); } //修改餘額
                account.setBalance(account.getBalance() - drawAmount); System.out.println("\t餘額爲: " + account.getBalance()); } else{ System.out.println(getName() + "取錢失敗!餘額不足!"); } } } }

上面程序使用 synchronized 將 run() 方法裏的方法體修改爲同步代碼塊,該同步代碼塊的同步監視器是 account 對象,這樣的作法符合「加鎖一修改一釋放鎖」的邏輯,任何線程在修改指定資源以前,首先對該資源加鎖,在加鎖期間其餘線程沒法修改該資源,當該線程修改完成後,該線程釋放對該資源的鎖定。經過這種方式就能夠保證併發線程在任一時刻只有一個線程能夠進入修改共享資源的代碼區(也被稱爲臨界區),因此同一時刻最多隻有一個線程處於臨界區內,從而保證了線程的安全性。

將 DrawThread 修改成上面所示的情形以後,屢次運行該程序,總能夠看到以下圖所示的正確結果。

同步方法

與同步代碼塊對應, Java 的多線程安全支持還提供了同步方法,同步方法就是使用 synchronized 關鍵字來修飾某個方法,則該方法稱爲同步方法。對於 synchronized 修飾的實例方法(非 static 方法)而言,無須顯式指定同步監視器,同步方法的同步監視器是 this ,也就是調用該方法的對象。

經過使用同步方法能夠很是方便地實現線程安全的類,線程安全的類具備以下特徵。

  • 該類的對象能夠被多個線程安全地訪問。
  • 每一個線程調用該對象的任意方法以後都將獲得正確結果。
  • 每一個線程調用該對象的任意方法以後,該對象狀態依然保持合理狀態。

前面介紹了可變類和不可變類,其中不可變類老是線程安全的,由於它的對象狀態不可改變;但可變對象須要額外的方法來保證其線程安全。例如上面的 Account 就是一個可變類,它的 accountNo 和 balance 兩個成員變量均可以被改變,當兩個線程同時修改 Account 對象的 balance 成員變量的值時,程序就出現了異常。下面將 Account 類對 balance 的訪問設置成線程安全的,那麼只要把修改 balance 的方法變成同步方法便可。程序以下所示

public class Account { // 封裝帳戶編號、帳戶餘額的兩個成員變量
    private String accountNo; private double balance; // 構造器
    public Account(String accountNo, double balance) { this.accountNo = accountNo; this.balance = balance; } public void setAccountNo(String accountNo) { this.accountNo = accountNo; } public String getAccountNo() { return this.accountNo; } // 由於帳戶餘額不容許隨便修改,因此只爲balance提供getter方法
    public double getBalance() { return this.balance; } // 提供一個線程安全的draw()方法來完成取錢操做
    public synchronized void draw(double drawAmount) { // 帳戶餘額大於取錢數目
        if (balance >= drawAmount) { // 吐出鈔票
            System.out.println(Thread.currentThread().getName() + "取錢成功!吐出鈔票:" + drawAmount); try { Thread.sleep(1); } catch (InterruptedException ex) { ex.printStackTrace(); } // 修改餘額
            balance -= drawAmount; System.out.println("\t餘額爲: " + balance); } else { System.out.println(Thread.currentThread().getName() + "取錢失敗!餘額不足!"); } } public int hashCode() { return accountNo.hashCode(); } public boolean equals(Object obj) { if (obj != null && obj.getClass() == Account.class) { Account target = (Account) obj; return target.getAccountNo().equals(accountNo); } return false; } }

上面程序中增長了一個表明取錢的 draw() 方法,並使用了 synchronized 關鍵字修飾該方法,把該方法變成同步方法,該同步方法的同步監視器是 this ,所以對於同一個 Account 帳戶而言,任意時刻只能有一個線程得到對 Account 對象的鎖定,而後進入 draw() 方法執行取錢操做——這樣也能夠保證多個線程併發取錢的線程安全。

由於 Account 類中已經提供了 draw() 方法,並且取消了 setBalance() 方法, DrawThread 線程類須要改寫,該線程類的 run() 方法只要調用 Account 對象的 draw() 方法便可執行取錢操做。 run() 方法代碼片斷以下。

注意:synchronized 關鍵字能夠修飾方法,能夠修飾代碼塊,但不能修飾構造器、成員變量等。

public void run(){   account.draw(drawAmount); }

上面的 DrawThread 類無須本身實現取錢操做,而是直接調用 account 的 draw() 方法來執行取錢操做。因爲已經使用 synchronized 關鍵字修飾了 draw() 方法,同步方法的同步監視器是 this,而 this 總表明調用該方法的對象——在上面示例中,調用 draw() 方法的對象是 account ,所以多個線程併發修改同一份 account 以前,必須先對 account 對象加鎖。這也符合了 「 加鎖——修改——釋放鎖 」 的邏輯 。

提示:在 Account 裏定義 draw() 方法,而不是直接在 run() 方法中實現取錢邏輯,這種作法更符合面向對象規則。在面向對象裏有一種流行的設計方式: Domain Driven Design (領域驅動設計, DDD ),這種方式認爲每一個類都應該是完備的領域對象,例如 Account 表明用戶帳戶,應該提供用戶帳戶的相關方法;經過 draw() 方法來執行取錢操做(實際上還應該提供 transfer() 等方法來完成轉帳等操做),而不是直接將  setBalance() 方法暴露出來任人操做,這樣才能夠更好地保證 Account 對象的完整性和一致性。

可變類的線程安全是以下降程序的運行效率做爲代價的,爲了減小線程安全所帶來的負面影響,程序能夠採用以下策略。

  • 不要對線程安全類的全部方法都進行同步,只對那些會改變競爭資源(競爭資源也就是共享資源)的方法進行同步。例如上面 Account 類中的 accountNo 實例變量就無須同步,因此程序只對 draw() 方法進行了同步控制。
  • 若是可變類有兩種運行環境:單線程環境和多線程環境,則應該爲該可變類提供兩種版本,即線程不安全版本和線程安全版本。在單線程環境中使用線程不安全版本以保證性能,在多線程環境中使用線程安全版本。

提示:JDK 所提供的 StringBuilder、StringBuffer 就是爲了照顧單線程環境和多線程環境所提供的類,在單線程環境下應該使用 StringBuilder 來保證較好的性能;當須要保證多線程安全時,就應該使用 StringBuffer 。

釋放同步監視器的鎖定

任何線程進入同步代碼塊、同步方法以前,必須先得到對同步監視器的鎖定,那麼什麼時候會釋放對同步監視器的鎖定呢?程序沒法顯式釋放對同步監視器的鎖定,線程會在以下幾種狀況下釋放對同步監視器的鎖定。

  • 當前線程的同步方法、同步代碼塊執行結束,當前線程即釋放同步監視器。
  • 當前線程在同步代碼塊、同步方法中遇到 break 、 return 終止了該代碼塊、該方法的繼續執行,當前線程將會釋放同步監視器。
  • 當前線程在同步代碼塊、同步方法中出現了未處理的 Error 或 Exception,致使了該代碼塊、該方法異常結束時,當前線程將會釋放同步監視器。
  • 當前線程執行同步代碼塊或同步方法時,程序執行了同步監視器對象的 wait() 方法,則當前線程暫停,並釋放同步監視器。

在以下所示的狀況下,線程不會釋放同步監視器。

  • 線程執行同步代碼塊或同步方法時,程序調用 Thread.sleep()、 Thread.yield() 方法來暫停當前線程的執行,當前線程不會釋放同步監視器。
  • 線程執行同步代碼塊時,其餘線程調用了該線程的 suspend() 方法將該線程掛起,該線程不會釋放同步監視器。固然,程序應該儘可能避免使用 suspend() 和 resume() 方法來控制線程。

同步鎖(Lock)

從 Java5 開始,Java 提供了一種功能更強大的線程同步機制——經過顯式定義同步鎖對象來實現同步,在這種機制下,同步鎖由 Lock 對象充當。

Lock 提供了比 synchronized 方法和 synchronized 代碼塊更普遍的鎖定操做,Lock 容許實現更靈活的結構,能夠具備差異很大的屬性,而且支持多個相關的 Condition 對象。

Lock 是控制多個線程對共享資源進行訪問的工具。一般,鎖提供了對共享資源的獨佔訪問,每次只能有一個線程對 Lock 對象加鎖,線程開始訪問共享資源以前應先得到 Lock 對象。

某些鎖可能容許對共享資源併發訪問,如 ReadWriteLock(讀寫鎖),Lock、ReadWriteLock 是 Java5 提供的兩個根接口,併爲 Lock 提供了 ReentrantLock (可重入鎖)實現類,爲 ReadWriteLock 提供了 ReentrantReadWriteLock 實現類。

Java8 新增了新型的 StampedLock 類,在大多數場景中它能夠替代傳統的 ReentrantReadWriteLock 。ReentrantReadWriteLock 爲讀寫操做提供了三種鎖模式:Writing、ReadingOptimistic、Reading。

在實現線程安全的控制中,比較經常使用的是 ReentrantLock (可重入鎖)。使用該 Lock 對象能夠顯式地加鎖、釋放鎖,一般使用 ReentrantLock 的代碼格式以下:

class X{   // 定義鎖對象
    private final ReentrantLock  lock  = new ReentrantLock(); // ... // 定義須要保證線程安全的方法
    public void m(){ // 加鎖 lock.lock(); try{ // 須要保證線程安全的代碼 // ...method body } // 使用finally塊來保證釋放鎖
        finally{      lock.unlock(); } } } 

使用 ReentrantLock  對象來進行同步,加鎖和釋放鎖出如今不一樣的做用範圍內時,一般建議使用 finally 塊來確保在必要時釋放鎖。一般使用 ReentrantLock 對象,能夠把 Account 類改成以下形式,它依然是線程安全的。

public class Account{ //定義鎖對象
    private final ReentrantLock lock = new ReentrantLock(); private String accountNo; private double balance; public Account(){} public Account(String accountNo , double balance){ this.accountNo = accountNo; this.balance = balance; } public void setAccountNo(String accountNo){ this.accountNo = accountNo; } public String getAccountNo(){ return this.accountNo; } public double getBalance(){ return this.balance; } public void draw(double drawAmount){ lock.lock(); try{ //帳戶餘額大於取錢數目
            if (balance >= drawAmount){ //吐出鈔票
                System.out.println(Thread.currentThread().getName() + "取錢成功!吐出鈔票:" + drawAmount); try{ Thread.sleep(1); }catch (InterruptedException ex){ ex.printStackTrace(); } //修改餘額
                balance -= drawAmount; System.out.println("\t餘額爲: " + balance); }else{ System.out.println(Thread.currentThread().getName() +
                    "取錢失敗!餘額不足!"); } }finally{ lock.unlock(); } } public int hashCode(){ return accountNo.hashCode(); } public boolean equals(Object obj){ if (obj != null && obj.getClass() == Account.class){ Account target = (Account)obj; return target.getAccountNo().equals(accountNo); } return false; } }

上面程序中的第一行粗體字代碼定義了一個 ReentrantLock 對象,程序中實現 draw() 方法時,進入方法開始執行後當即請求對 ReentrantLock 對象進行加鎖,當執行完 draw() 方法的取錢邏輯以後,程序使用  finally 塊來確保釋放鎖。

提示:使用 Lock 與使用同步方法有點類似,只是使用 Lock 時顯式使用 Lock 對象做爲同步鎖,而使用同步方法時系統隱式使用當前對象做爲同步監視器,一樣都符合「加鎖——修改——釋放鎖」的操做模式,並且使用 Lock 對象時每一個 Lock 對象對應一個 Account 對象, 一樣能夠保證對於同一個 Account 對象,同一時刻只能有一個線程能進入臨界區。

同步方法或同步代碼塊使用與競爭資源相關的、隱式的同步監視器,而且強制要求加鎖和釋放鎖要出如今一個塊結構中,並且當獲取了多個鎖時,它們必須以相反的順序釋放,且必須在與全部鎖被獲取時相同的範圍內釋放全部鎖。

雖然同步方法和同步代碼塊的範圍機制使得多線程安全編程很是方便,並且還能夠避免不少涉及鎖的常見編程錯誤,但有時也須要以更爲靈活的方式使用鎖。 Lock 提供了同步方法和同步代碼塊所沒有的其餘功能,包括用於非塊結構的 tryLock() 方法,以及試圖獲取可中斷鎖的 locklntermptibly() 方法,還有獲取超時失效鎖的 tryLock(long, TimeUnit) 方法。

ReentrantLock 鎖具備可重入性,也就是說,一個線程能夠對已被加鎖的 ReentrantLock 鎖再次加鎖,ReentrantLock 對象會維持一個計數器來追蹤 lock() 方法的嵌套調用,線程在每次調用 lock() 加鎖後,必須顯式調用 unlock() 來釋放鎖,因此一段被鎖保護的代碼能夠調用另外一個被相同鎖保護的方法。

死鎖

當兩個線程相互等待對方釋放同步監視器時就會發生死鎖, Java 虛擬機沒有監測,也沒有采起措施來處理死鎖狀況,因此多線程編程時應該採起措施避免死鎖出現 。一旦出現死鎖,整個程序既不會發生任何異常,也不會給出任何提示,只是全部線程處於阻塞狀態,沒法繼續。

死鎖是很容易發生的,尤爲在系統中出現多個同步監視器的狀況下,以下程序將會出現死鎖。

class A{ public synchronized void foo(B b){ System.out.println("當前線程名: " + Thread.currentThread().getName() + " 進入了A實例的foo方法" ); try{ Thread.sleep(200); }catch (InterruptedException ex){ ex.printStackTrace(); } System.out.println("當前線程名: " + Thread.currentThread().getName() + " 企圖調用B實例的last方法"); b.last(); } public synchronized void last(){ System.out.println("進入了A類的last方法內部"); } } class B{ public synchronized void bar(A a){ System.out.println("當前線程名: "+ Thread.currentThread().getName() + " 進入了B實例的bar方法" ); try{ Thread.sleep(200); }catch (InterruptedException ex){ ex.printStackTrace(); } System.out.println("當前線程名: " + Thread.currentThread().getName() + " 企圖調用A實例的last方法"); a.last(); } public synchronized void last(){ System.out.println("進入了B類的last方法內部"); } } public class DeadLock implements Runnable{ A a = new A(); B b = new B(); public void init(){ Thread.currentThread().setName("主線程"); //調用a對象的foo方法
 a.foo(b); System.out.println("進入了主線程以後"); } public void run(){ Thread.currentThread().setName("副線程"); //調用b對象的bar方法
 b.bar(a); System.out.println("進入了副線程以後"); } public static void main(String[] args){ DeadLock dl = new DeadLock(); //以dl爲target啓動新線程
        new Thread(dl).start(); //執行init方法做爲新線程
 dl.init(); } }

運行上面的程序,將會看到以下圖所示的效果。

從上圖能夠看出,程序既沒法向下執行,也不會拋出任何異常,一直「僵持」着,究其緣由,是由於:上面的程序 A 對象和 B 對象的方法都是同步方法,也就是 A 對象和 B 對象都是同步鎖。程序中兩個線程執行,一個線程的執行體是 DeadLock 類的 run() 方法,另外一個線程的線程執行體是 DeadLock 的 init() 方法(主線程調用了 init() 方法)。其中 run() 方法中讓 B 對象調用 bar() 方法,而 init() 方法讓 A 對象調用  foo() 方法。上圖顯示 init() 方法先執行,調用了 A 對象的 foo() 方法,進入 foo() 方法以前,該線程對 A 對象加鎖——當程序執行到①號代碼時,主線程暫停200ms;CPU 切換到執行另外一個線程,讓 B 對象執行  bar() 方法,因此看到副線程開始執行 B 實例的 bar() 方法,進入 bar() 方法以前,該線程對 B 對象加鎖——當程序執行到②號代碼時,副線程也暫停200ms;接下來主線程會先醒過來,繼續向下執行,直到③號代碼處但願調用 B 對象的 last() 方法——執行該方法以前必須先對 B 對象加鎖,但此時副線程正保持着 B 對象的鎖,因此主線程阻塞;接下來副線程應該也醒過來了,繼續向下執行,直到④號代碼處但願調用 A 對象的 last() 方法一一執行該方法以前必須先對 A 對象加鎖,但此時主線程沒有釋放對 A 對象的鎖——至此,就出現了主線程保持着 A 對象的鎖,等待對 B 對象加鎖,而副線程保持着 B 對象的鎖,等待對 A 對象加鎖,兩個線程互相等待對方先釋放,因此就出現了死鎖。

注意:因爲 Thread 類的 suspend() 方法也很容易致使死鎖,因此 Java 再也不推薦使用該方法來暫停線程的執行。

相關文章
相關標籤/搜索