Java線程的學習_線程同步

多線程編程很容易突然出現“錯誤情況”,這是由系統的線程調度具有一定隨機性造成的,不過即使程序偶然出現問題,那也是由於編程不當引起的。當使用多個線程 來訪問數據時,很容易“偶然”出現線程安全問題。

一個線程安全問題——銀行取錢問題

實現功能:
-系統判斷賬戶餘額是否大於取款金額,如果大於取款金額,則取款成功,否則取款失敗。
-模擬兩個人使用同一個賬戶併發取錢。
賬戶類代碼:

public class Account {

    //封裝賬戶編號,賬戶餘額的兩個成員變量
    private String accountNo;
    private double balance;
    //構造器
    public Account(String accountNo, double balance){
        this.accountNo = accountNo;
        this.balance = balance;
    }
    //set,get方法
    public String getAccountNo() {
        return accountNo;
    }
    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }
    public double getBalance() {
        return balance;
    }
    public void setBalance(double balance) {
        this.balance = balance;
    }
}

取錢的線程類:

public class DrawThread extends Thread{

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

啓動:

public class DrawTest {

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

    }

}

代碼結果:

甲取錢成功,取錢:800.0
乙取錢成功,取錢:800.0
     餘額爲:200.0
     餘額爲:-600.0

這裏可以看出線程出現了錯誤,餘額僅有1000卻能取出1600,並且餘額爲負值。出現這種錯誤是因爲run()方法的方法體不具有同步安全性——程序中有兩個併發線程在修改Account對象。
爲了解決這個問題,java的多線程支持引入了同步監視器來解決這個問題。

同步代碼塊

使用同步監視器的通用方法就是同步代碼塊。同步代碼塊的語法格式:

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

在上面語法中,括號內的obj就是同步監視器,線程開始執行同步代碼塊之前,必須先獲得對同步監視器的鎖定。

需要注意的一點是,任何時刻只能有一個線程可以獲得對同步監視器的鎖定,當同步代碼塊執行完成後,該線程會釋放對該同步監視器的鎖定。

同步監視器最大目的:阻止兩個線程對同一個共享資源進行併發訪問,因此通常把可能被併發訪問的共享資源充當同步監視器。在這裏我選擇account作爲同步監視器,修改DrawThread的代碼:

public class DrawThread extends Thread {

    //模擬用戶賬戶
    private Account account;
    //模擬取錢線程所希望取錢數
    private double drawAmout;

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

運行結果:

甲取錢成功,取錢:800.0
     餘額爲:200.0
乙取錢失敗,餘額不足

同步方法

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

public class Account {

    //封裝賬戶編號,賬戶餘額的兩個成員變量
    private String accountNo;
    private double balance;
    //構造器
    public Account(String accountNo, double balance){
        this.accountNo = accountNo;
        this.balance = balance;
    }
    //AccountNo set,get方法
    public String getAccountNo() {
        return accountNo;
    }
    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }
    //因爲賬戶餘額不允許隨便修改,所以只爲balance提供getter方法
    public double getBalance() {
        return 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() + "取錢失敗,餘額不足");
        }
    }
}

上面程序中增加了一個代表取錢的draw()方法,並使用了synchronized關鍵字修飾該方法,把該方法變成同步方法,該同步方法的同步監視器是this,因此對於同一個Account賬戶而言,任意時刻只能有一個線程獲得對Account對象的鎖定,然後進入draw()方法執行取錢操作。
因爲Account類中提供了draw()方法,而且取消了setBalance()方法,所以需要改寫DrawThread線程類,該線程類的run()方法只要調用Account對象的draw()方法即可執行取錢操作。run()方法代碼片段如下。

public void run(){
        //直接調用account對象的draw()方法來執行取錢操作
        //同步方法的同步監視器是this,this代表調用draw方法對象
        //線程進入draw()方法之前,必須先對account對象加鎖
        account.draw(drawAmout);
    }
此外synchronized關鍵字可以修飾方法,可以修飾代碼塊,但不能修飾構造器、成員變量。

既然獲得對同步監視器的鎖定,那麼什麼時候會釋放同步監視器的鎖定呢?

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

以下是線程不會釋放同步監視器的情況:

–線程執行同步代碼塊或同步方法時,程序調用了Thread.sleep()、Thread.yield()方法來暫停當前線程的執行,當前線程不會釋放同步監視器。
–線程執行同步代碼塊時,其他線程調用了該線程的suspend()方法將該線程掛起,該線程不會釋放同步監視器。


同步鎖(Lock)

    從Java5開始,Java提供了一種功能更加強大的線程同步機制——通過顯示定義同步鎖對象來實現同步,在這種機制下,同步鎖由Lock對象充當。
    Lock提供了比synchronized方法和synchronized代碼塊更加廣泛的鎖定操作,Lock允許實現更靈活的結構,可以具有差別很大的屬性沒並且支持多個相關的Condition對象。
    Lock是控制多個線程對共享資源進行訪問的工具。通常鎖提供了對共享資源的獨佔訪問,每次只能有一個線程對Lock對象加鎖,線程開始訪問共享資源之前應先獲得Lock對象。
    在實現線程安全的控制中比較常用的是ReentrantLock(可重入鎖)。使用該Lock對象可以顯式地加鎖、釋放鎖,通常使用ReentrantLock的代碼如下:
class X{
    //定義鎖對象
    private final ReentrantLock lock = new ReentrantLock();
    ...
    //定義需要保證線程安全的方法
    public void m(){
        //加鎖
        lock.lock();
        try{
            //需要保證線程安全的代碼
        } finally{
            lock.unlock();
        }
    }
}

對Account類使用同步鎖:

public class Account {

    //定義鎖對象
    private final ReentrantLock lock = new ReentrantLock();
    //封裝賬戶編號,賬戶餘額的兩個成員變量
    private String accountNo;
    private double balance;
    //構造器
    public Account(String accountNo, double balance){
        this.accountNo = accountNo;
        this.balance = balance;
    }
    //AccountNo set,get方法
    public String getAccountNo() {
        return accountNo;
    }
    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }
    //因爲賬戶餘額不允許隨便修改,所以只爲balance提供getter方法
    public double getBalance() {
        return balance;
    }
    //提供一個線程安全的draw()方法來完成取錢操作
    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();
        }
    }
}

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();
    }
}

代碼結果:

當前線程名:副線程進入了B實例的bar()方法
當前線程名:主線程進入了A實例的foo()方法
當前線程名:主線程企圖調用B實例的last()方法
當前線程名:副線程企圖調用A實例的last()方法

上面代碼出現了主線程保持着A對象德爾鎖,等待對B對象加鎖,而副線程保持着B對象的鎖,等待對A對象加鎖,兩個線程互相等待對方先釋放,所以出現了死鎖。

發佈了28 篇原創文章 · 獲贊 3 · 訪問量 7898
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章