1. 程式人生 > >黑馬程式設計師——java基礎拾遺之多執行緒(二) 執行緒同步、執行緒通訊

黑馬程式設計師——java基礎拾遺之多執行緒(二) 執行緒同步、執行緒通訊

執行緒安全的概念:當多個執行緒同時執行一段程式碼時,如果結果和單執行緒執行時一致,而且其他變數也和預期的一致,說明是這段程式碼是執行緒安全的。
但是,多執行緒執行的過程中會出現單執行緒時候不會出現的問題,大多出現在多個執行緒同時操作全域性變數或者靜態變數的時候。當出現這種場景的時候,往往會出現和預期不一致的程式執行結果。比如下面的例子:

但是,多執行緒執行的過程中會出現單執行緒時候不會出現的問題,大多出現在多個執行緒同時操作全域性變數或者靜態變數的時候。當出現這種場景的時候,往往會出現和預期不一致的程式執行結果。比如下面的例子:

還是經典的賣票程式:

<span style="font-size:14px;"><span>		</span>class Ticket implements Runnable
		{
		    private int ticket = 100;
		    Object obj = new Object();
		    public void run(){
		        while(true){
		            if(ticket > 0){
		                try{Thread.sleep(10);}catch(Exception e){}
		                System.out.println(Thread.currentThread().getName()+"sell the ticket " + ticket--);
		            }
		        }
		    }
		}
		
		public class ThreadRunableTest
		{
		    public static void main(String[] args) {
		        Ticket t = new Ticket();
		        Thread t1 = new Thread(t);
		        Thread t2 = new Thread(t);
		        Thread t3 = new Thread(t);
		        Thread t4 = new Thread(t);
		        t1.start();
		        t2.start();
		        t3.start();
		        t4.start();
		    }
		}</span>
這裡在賣票數ticket--的前面加上這樣一條語句try{Thread.sleep(10);}catch(Exception e){},目的是讓執行緒在票數--前停10毫秒,這時執行緒安全的問題就暴露出來了,控制檯打印出如下的內容:
……
Thread-3sell the ticket 1
Thread-0sell the ticket 0
Thread-1sell the ticket -1
Thread-2sell the ticket -2


if(ticket > 0)的條件沒有生效,為什麼會這樣?其實很好分析:

1.Thread.sleep(10)這句話讓執行緒在執行--操作之前停止了10毫秒,當ticket為1時,後續的其他就緒態的執行緒獲得CPU的時間片,會繼續進入if(ticket > 0);

2.ticket為1的時候,進入if(ticket > 0)的執行緒可能不止一個,這些執行緒都因為Thread.sleep(10)停止而沒有去執行--操作。

3.被停止的執行緒10毫秒後繼續執行--操作,這時多個已經進入迴圈的執行緒對ticket多次--,就造成了最後三條資料——

Thread-0sell the ticket 0

Thread-1sell the ticket -1
Thread-2sell the ticket -2


這就是最常見的由於多個執行緒同時操作共享資料而造成的執行緒安全問題。


如何解決這個問題,java提供了很多方式去解決這個問題,下面依次對這些方式進行介紹:


1.同步程式碼塊

格式:

synchronized(同步物件){

需要被同步的程式碼

    }

   這裡需要注意幾個點:
1.兩個以上的執行緒才需要同步程式碼塊;
2.多個執行緒必須使用同一個鎖;
3.同步物件可以是任何類的物件,自定義的類物件,或者乾脆是Object類的物件;
4.需要被同步的程式碼是指執行緒執行程式碼中,操作共享資料的程式碼。同步程式碼塊會保證程式碼塊中的程式碼被在同一個鎖物件持有的時候,會持續擁有CPU資源直到程式碼執行完畢,這個過程中不會有其他執行緒插入;通俗點理解就是一個執行緒一旦進入同步程式碼塊會不被打斷的執行結束,保證了結果的正確。


優點:解決了執行緒安全問題;
弊端:每個執行緒都要判斷鎖,效率有所下降;


修改後的Ticket類

<span style="font-size:14px;"><span>	</span>class Ticket implements Runnable
	{
	    private int ticket = 100;
	    Object obj = new Object();
	    public void run(){
	        while(true){
	            synchronized (obj) {
	                if(ticket > 0){
	                    try{Thread.sleep(10);}catch(Exception e){}
	                    System.out.println(Thread.currentThread().getName()+"sell the ticket " + ticket--);
	                }
	            }
	        }
	    }
	}</span>

執行結果:

Thread-0sell the ticket 4
Thread-0sell the ticket 3
Thread-0sell the ticket 2
Thread-0sell the ticket 1


結果正確,這裡多個執行緒用的都是同一個鎖obj。

注意:不要將run()方法中的所有程式碼都加入到同步程式碼塊中,這樣本質上就變成了單執行緒,失去了多執行緒的意義。

2.同步函式

先看下面一個例項程式:
<span style="font-size:14px;"><span>		</span>class Bank
		{
		    private int sum;
		    public void add(int n){
		        sum = sum + n;
		        System.out.println("sum="+sum);
		    }
		}
		
		class Cus implements Runnable
		{
		    private Bank b = new Bank();    
		    public void run(){
		        for(int x = 0; x < 3; x++){
		            b.add(100);
		        }
		    }
		}
		
		class BankDemo
		{
		    public static void main(String[] args) {
		        Cus c = new Cus();
		        Thread t1 = new Thread(c);
		        Thread t2 = new Thread(c);
		        t1.start();
		        t2.start();
		    }
		}</span>

這個小程式模擬兩個儲戶往銀行裡存錢,分別存300,每個儲戶分三次存,每次100;
執行結果:
sum=100
sum=200
sum=300
sum=400
sum=500
sum=600


這個程式也是有執行緒安全隱患的,可以看出Bank類的add()方法,sum = sum + n;和 System.out.println("sum="+sum);兩句都是操作共享資料的,但是這兩句話沒有同步,意味著這兩句執行的空隙可能被其他執行緒插入。用sleep函式來模擬這個過程,可以看到問題所在;

修改後的Bank類

<span style="font-size:14px;"><span>	</span>class Bank
	{
	    private int sum;
	    public void add(int n){
	        sum = sum + n;
	        try{Thread.sleep(10);}catch(Exception e){}
	        System.out.println("sum="+sum);
	    }
	}</span>
執行結果:
sum=200
sum=200
sum=400
sum=500
sum=600
sum=600


可以看出,一旦sum = sum + n;執行完,發生中斷,沒有立即輸出,而是CPU時間片分配給其他的執行緒,則可能出現兩個執行緒在都執行完sum = sum + n;後,一起輸出sum的情況,此時sum的值是已經變動過不止一次的,所以會出現輸出兩個200,兩個600的情況。

這個時候就需要同步函數了,在這個小例子中,需要同步的資料都在Bank的add()方法中,因此可以將add()函式定義為同步函式。定義方法很簡單,就是在方法上加synchronized關鍵字修飾。

修改過的add函式:
<span style="font-size:14px;"><span style="white-space:pre">	</span>public synchronized void add(int n){
<span style="white-space:pre">		</span>sum = sum + n;
       <span style="white-space:pre">		</span>try{Thread.sleep(10);}catch(Exception e){}
       <span style="white-space:pre">		</span>System.out.println("sum="+sum);
<span style="white-space:pre">	</span>}</span>

之前買票的例子,也可以改成用同步函式的方式,只要將while(true)中的程式碼單獨定義為一個同步函式即可,修改後的Ticket類程式碼:
  
<span style="font-size:14px;"><span style="white-space:pre">	</span>class Ticket implements Runnable
	{
	    private int ticket = 100;
	    Object obj = new Object();
	    public void run(){
	        while(true){
	            show();
	        }
	    }
	    public synchronized void show(){
	        if(ticket > 0){
	            try{Thread.sleep(10);}catch(Exception e){}
	            System.out.println(Thread.currentThread().getName()+"sell the ticket " + ticket--);
	        }
	    }
	}</span>

3.執行緒間通訊


執行緒間通訊常用在多個執行緒操作同一個資源,但是操作不同的情況下;不同的操作之間有先後的次序,java中用執行緒間通訊的方式,來解決這種情況下的執行緒安全問題。

先看下面這段程式碼:
<span style="font-size:14px;"><span style="white-space:pre">	</span>class Res
	{
	    String name;
	    String sex;
	    
	    @Override
	    public String toString(){
	        return name+"....."+sex;
	    }
	}
	
	class input implements Runnable
	{
	    private Res r;
	    input(Res r){
	        this.r = r;
	    }
	    
	    public void run(){
	        
	        int x = 0;
	        while(true){
              if(0 == x){
                  r.name = "小明";
                  r.sex = "男";
              }else{
                  r.name = "小紅";
                  r.sex = "女";
              }
              x = (x+1)%2;
	        }
	    }
	}
	
	class output implements Runnable
	{
	    private Res r;
	    output(Res r){
	        this.r = r;
	    }
	    public void run(){
	        while(true){
	           System.err.println(r.toString());
	        }
	    }
	}
	
	public class NotifyDemo
	{
	    
	    public static void main(String[] args) {
        Res  r = new Res();
        Thread t1 = new Thread(new input(r));
        Thread t2 = new Thread(new output(r));
        t1.start();
        t2.start();
	    }
	}</span>

這段程式碼模擬了兩個物件同時操作一個共享資源Res的過程,input物件一個負責交替給Res賦值小明男小紅女,還有一個物件output負責輸出Res的值。程式執行一段時間以後,輸出了以下的值:
……
小紅.....女
小紅.....男
小明.....女
小明.....男

這裡可以看出,程式的目的是為了輸出成對的小紅.....女和小明.....男,而出現錯誤的原因很明顯,是操作Res的共享資料name和sex時,沒有將成對的賦值操作一起結束,就被output輸出了,導致了小紅小明時男時女。

這裡用之前說過的同步程式碼塊的形式,可以解決這裡資料不一致的問題,修改過的程式碼:
<span style="font-size:14px;"><span style="white-space:pre">	</span>class input implements Runnable
	{
	    private Res r;
	    input(Res r){
	        this.r = r;
	    }
	    
	    public void run(){
	        
	        int x = 0;
	        while(true){
	            synchronized(r){
	                if(0 == x){
	                    r.name = "小明";
	                    r.sex = "男";
	                }else{
	                    r.name = "小紅";
	                    r.sex = "女";
	                }
	                x = (x+1)%2;
	                r.flag = true;//放入資料
	            }
	        }
	    }
	}
	
	class output implements Runnable
	{
	    private Res r;
	    output(Res r){
	        this.r = r;
	    }
	    public void run(){
	        while(true){
	            synchronized (r) {
	                System.err.println(r.toString());
	            }
	        }
	    }
	}</span>

輸出的結果是:
小明.....男
小明.....男
小明.....男
小明.....男
小紅.....女
小紅.....女
小紅.....女
小紅.....女


資料正確性的問題解決了,但是和程式想要的結果還是有所區別的。因為這樣一個存,一個取的過程,一般都希望Res內容變化後,就被output知曉並且列印,而不是連續的列印一片沒有變化過的小紅.....女和小明.....男。這個時候,就需要引入執行緒間通訊的機制。

java的執行緒通訊機制,主要是通過兩個方法來實現的,wait()和notify(),這兩個方法可以查API,都是由監視器來呼叫的,這裡說的監視器就是上面說的鎖,因為鎖可以是任意的物件,所以這裡wait()和notify()是定義在Object物件中的方法。wait()是讓當前執行緒等待,notify()方法是喚醒執行緒池中第一個等待的執行緒。
用這兩個方法改造這個程式碼的原理就是,給資源一個標誌位,比如起名flag,一旦賦值,修改標誌位的狀態為已賦值,一旦輸出,則修改狀態為未賦值。在同步程式碼塊中先對falg進行判斷,如果input判斷flag為已賦值,則呼叫wait方法讓input執行緒等待、直到output輸出後呼叫notify方法喚醒input執行緒,讓input繼續賦值,否則說明沒有賦值,這時執行賦值操作,修改狀態為已賦值,最後執行notify方法喚醒等待中的output執行緒。output同理,執行時先判斷是否已經賦值,是則執行輸出後呼叫notify方法喚醒等待的input執行緒,最後修改狀態為未賦值,否則呼叫wait()方法,等待賦值後方能被喚醒執行輸出。


修改後的程式碼如下:
<span style="font-size:14px;"><span style="white-space:pre">	</span>class Res
	{
	    String name;
	    String sex;
	    boolean flag = false;
	    
	    @Override
	    public String toString(){
	        return name+"....."+sex;
	    }
	}
	
	class input implements Runnable
	{
	    private Res r;
	    input(Res r){
	        this.r = r;
	    }
	    
	    public void run(){
	        
	        int x = 0;
	        while(true){
	            synchronized(r){
	                if(r.flag){
	                    try{r.wait();}catch(Exception e){}
	                }
	                if(0 == x){
	                    r.name = "小明";
	                    r.sex = "男";
	                }else{
	                    r.name = "小紅";
	                    r.sex = "女";
	                }
	                x = (x+1)%2;
	                r.flag = true;//放入資料
	                r.notify();
	            }
	        }
	    }
	}
	
	class output implements Runnable
	{
	    private Res r;
	    output(Res r){
	        this.r = r;
	    }
	    public void run(){
	        while(true){
	            synchronized (r) {
	                if(!r.flag){
	                    try{r.wait();}catch(Exception e){}
	                }
	                System.err.println(r.toString());
	                r.flag = false;//取出資料
	                r.notify();
	            }
	        }
	    }
	}
	
	public class NotifyDemo
	{
	    
	    public static void main(String[] args) {
	        Res  r = new Res();
	        Thread t1 = new Thread(new input(r));
	        Thread t2 = new Thread(new output(r));
	        t1.start();
	        t2.start();
	    }
	
	}</span>

修改後執行的結果變為:
小紅.....女
小明.....男
小紅.....女
小明.....男
小紅.....女


說明輸入輸出執行緒交替執行,相互喚醒。直到這裡,這個說明執行緒通訊的例子就說完了。
最後,執行緒通訊還有注意幾個點:
1)等待和喚醒必須是同一個鎖,只有同一個鎖上的notify方法能喚醒這個鎖上的等待程序;
2)除了notify()方法外,還有 notifyAll()方法,用法一樣,區別的是,它會喚醒這個鎖上面所有的等待程序;notifyAll方法一般用於多個生產者和多個消費者的情況,這時執行程式碼時候的判斷就需要用迴圈while(r.flag)的形式,防止重複生產或者重複消費;


4.JDK 5.0以後對鎖的升級,Lock類

用Lock類來實現經典的生產者消費者例子:
<span style="font-size:14px;"><span style="white-space:pre">	</span>class Resource
	{
	    private String name;
	    private int count = 1;
	    private boolean flag = false;
	    private Lock lock = new ReentrantLock();
	
	    private Condition condition = lock.newCondition();
	
	    public void set(String name) {
	        lock.lock();
	        try {
	            while (flag) {
	                condition.await();
	            }
	            this.name = name + "--" + count++;
	            System.out.println(Thread.currentThread().getName() + "...生產者.."
	                    + this.name);
	            flag = true;
	            condition.signal();
	        } catch (Exception e) {
	        } finally {
	            lock.unlock();
	        }
	    }
	    public void out(){
	        lock.lock();
	        try {
	            while(!flag){
	                condition.await();
	            }
	            System.out.println(Thread.currentThread().getName() + "...消費者......."+ this.name);
	            flag = false;
	            condition.signal();
	        } catch (Exception e) {
	        }finally{
	            lock.unlock();
	        }
	    }
	
	}
	    
	class Producer implements Runnable
	{
	    private Resource res;
	    Producer(Resource res){
	        this.res = res;
	    }
	    public void run(){
	        while(true){
	            try {
	                res.set("+商品+");
	            } catch (Exception e) {
	            }
	        }
	    }
	}
	class Consumer implements Runnable
	{
	    private Resource res;
	    Consumer(Resource res){
	        this.res = res;
	    }
	    public void run(){
	        while(true){
	            try {
	                res.out();
	            } catch (Exception e) {
	            }
	        }
	    }
	}
	
	public class ProductorConsumer
	{
	    public static void main(String[] args) {
	        Resource r = new Resource();
	        Producer pro = new Producer(r);
	        Consumer con = new Consumer(r);
	        Thread t1 = new Thread(pro);
	        Thread t2 = new Thread(pro);
	        Thread t3 = new Thread(con);
	        Thread t4 = new Thread(con);
	        t1.start();
	        t2.start();
	        t3.start();
	        t4.start();
	        
	    }
	}</span>

在這個例子中,有兩個生產者,兩個消費者,同時對資源進行操作,生產者負責count++,消費者負責列印++後的結果。
執行後,發現結果列印結果沒有繼續,列印一段之後就停住,
……
Thread-0...生產者..+商品+--19
Thread-2...消費者.......+商品+--19
Thread-0...生產者..+商品+--20
Thread-3...消費者.......+商品+--20


分析原因,在於Condition的signal()方法,這個方法不會指定喚醒的是生產者還是消費者,因此如果消費者程序喚醒的還是消費者程序,則flag始終為true,程式中所有程序都會處於等待狀態。
這時,有兩種解決問題的辦法,一個是使用signalAll()方法,但是這種方式和JDK 5.0之前的notifyAll()方法本質是相同的,體現不出JDK 5.0方法在鎖上的優勢,因此這裡用Lock類特有的方式去解決這個問題:

Lock類可以在一個Lock物件上支援多個Condition物件,這樣就可以辦到生產者只喚醒消費者,消費者只喚醒生產者,程式碼如下:
<span style="font-size:14px;"><span style="white-space:pre">	</span>class Resource
	{
	    private String name;
	    private int count = 1;
	    private boolean flag = false;
	    private Lock lock = new ReentrantLock();
	
	    private Condition condition_pro = lock.newCondition();//生產者
	    private Condition condition_con = lock.newCondition();//消費者
	
	    public void set(String name) {
	        lock.lock();
	        try {
	            while (flag) {
	                condition_pro.await();
	            }
	            this.name = name + "--" + count++;
	            System.out.println(Thread.currentThread().getName() + "...生產者.."
	                    + this.name);
	            flag = true;
	            condition_con.signal();
	        } catch (Exception e) {
	        } finally {
	            lock.unlock();
	        }
	    }
	    public void out(){
	        lock.lock();
	        try {
	            while(!flag){
	                condition_con.await();
	            }
	            System.out.println(Thread.currentThread().getName() + "...消費者......."+ this.name);
	            flag = false;
	            condition_pro.signal();
	        } catch (Exception e) {
	        }finally{
	            lock.unlock();
	        }
	    }
	
	}</span>

在上面的程式碼中condition_pro.await()生產者的等待,只能由condition_pro.signal()生產者的condition_pro喚醒,condition_con.await()消費者的等待只能由消費者的condition_con喚醒。可以由不同的condition指定喚醒的執行緒。

注意:在使用Lock類最後釋放鎖的時候,需要將unlock方法放在finally中執行。使得每個鎖都必須被釋放。