線程安全

1.線程安全概述

使用多線程可以在一段時間內併發處理多個任務,在提高CPU運行效率的同時也爲我們批量處理這些任務帶來了便利。但是,使用多線程的時候要格外小心,多個線程在某一時間對同一個變量的處理,如果處理不當,就會造成數據不一致的問題,出現的這種數據不一致的現象就是非線程安全。非線程安全是多線程纔會出現的問題。

上面的情況只是非線程安全的一種。非線程安全出現的原因是各個線程的控制流彼此獨立,線程的執行需要線程調度程序管理,他們之間的執行順序是隨機的不確定的,而各個線程共享資源,所以多線程會帶來線程調度,同步,死鎖等一系列的問題。產生非線程安全問題的原因在於對共享數據訪問操作的不完整性。

非線程安全針對的是共享數據的情況。共享數據如全局變量,這些變量對多個線程來說可以同時訪問,同時訪問的過程中就會有出現問題的可能。私有數據如方法內部的實例變量,這種變量的作用域只在方法本身,出了方法體就無法訪問。對於私有數據,多線程同時訪問就不存在線程安全的問題,所以線程執行的結果總是線程安全的,這是方法內部的變量是私有的特性造成的。

2.和線程安全有關的實際例子

(1)一個工資管理人員正在修改僱員的工資表,而一些僱員同時正在領取工資,如果允許這樣做,必然會引起工資發放的混亂。

(2)實現投票功能時,多個線程可以同時處理同一個人的票數。

(3)兩個線程A和B在同時使用Stack的同一個實例對象。A正在往堆棧裏push一個數據,B則要從堆棧中pop一個數據。

class Stack{
         int idx=0;
         char[ ] data = new char[6];
         public void push(char c){
               data[idx] = c;
               idx++;
         }
         public char pop(){
               idx--;
               return data[idx];
         }
  }

(1)操作之前,棧中的數據如圖所示,此時idx = 2。

data 
 
 
 
 
          q     
          p

(2)A執行push中的第一個語句,將r推入堆棧,idx = 2;

data
 
 
 
          r
          q
          p

(3)A還未執行idx++語句,A的執行被B中斷,B執行pop( )方法,返回q,idx = 1,此時堆棧中數據同(2);

A繼續執行push的第二條語句,idx = 2;最後的結果相當於q沒有出棧,r也沒有入棧。此時堆棧中數據同(2)。

要解決類似的這些非線程安全問題,需要使用線程同步,那麼怎麼樣實現線程安全呢?通常使用synchronized關鍵字。

3.和線程安全有關的簡單實例

從上面的描述得知,自定義類中的實例變量針對其他線程有共享和不共享之分,這在多線程之間進行交互時是很重要的一個技術點。線程的數據不共享就是線程中的數據只有自己能訪問,別的線程不能訪問。非線程安全只是針對共享數據,私有數據不存在這個問題。

(1)下面通過一個實例看下數據不共享的情況:

創建一個線程類,類中定義了一個私有變量count。

public class MyThread extends Thread {
	
	    private int count = 5;
	    public MyThread(String name){
	    super();
	    this.setName(name);
	    }
	    @Override
            public void run(){
		super.run();
		while(count>0){
		count--;
		System.out.println("由"+this.currentThread().getName()+"計算,count="+count);
		}        
	    }	
		
}

在main( )方法中創建三個線程的實例並運行。

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		MyThread a = new MyThread("A");
		MyThread b = new MyThread("B");
		MyThread c = new MyThread("C");
		a.start();
		b.start();
		c.start();
	}
	
}

程序的執行結果:

因爲count變量是每個線程私有的,每個線程都有各自的線程變量。雖然線程是異步執行的,線程之間的執行順序不確定,但是單看一個線程,每次執行他們的count值都是從4遞減到0。在運行的時候每個線程自己減少自己count變量的值,一個線程的執行不會對其他線程的變量值有影響。這種情況就是變量不共享,此示例中並不存在多個線程訪問同一個實例變量的情況。如果一個線程多次執行之後的結果都是一樣的,那麼這個線程就是線程安全的。

如果是3個線程同時對同一個實例變量進行減法操作,那就屬於數據共享的情況。

(2)下面通過一個實例看下數據共享的情況:

和上面的類似,創建一個線程類,類中定義了一個私有變量count。

public class MyThread extends Thread {
	
	    private int count = 5;
	    @Override
            public void run(){
		super.run();
		//while(count>0){
		//此示例不要使用for或while循環語句,使用循環語句後首先獲得機會運行的線程會同步運行,
                //同步後其他線程就得不到運行的機會了,一直由這個線程進行減法運算
		count--;
		System.out.println("由"+this.currentThread().getName()+"計算,count="+count);
		//}        
	    }	
		
}

 在main( )方法中創建一個MyThread線程,這個線程被其他的線程調用執行。

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		MyThread myThread = new MyThread();
		Thread a = new Thread(myThread,"A");
		Thread b = new Thread(myThread,"B");
		Thread c = new Thread(myThread,"C");
		Thread d = new Thread(myThread,"D");
		Thread e = new Thread(myThread,"E");
		a.start();
		b.start();
		c.start();
		d.start();
		e.start();
	}
	
}

	 
	

程序運行結果:

從線程的運行結果我們可以看出線程是異步執行的,線程的執行順序和他們的書寫順序沒有直接關係。“B”和“C”計算出來的結果都是3,說明 “B”和“C”同時對count進行處理,產生了“非線程安全”的問題。非線程安全就是運行的程序是固定的,但是程序每一次對變量值的修改結果卻是不確定的。我們想要打印的結果是不重複的,依次遞減的,這種結果也是執行線程安全的程序出現的結果,每一次對變量值的修改結果唯一。但是非線程安全運行的結果不唯一,線程安全執行的唯一的結果只是非線程安全運行結果的一種。

對比上面的例子,都是對MyThread類中的count變量進行操作,count變量都是線程私有的,運行結果卻不同,這是爲什麼呢?

在上個實例中,main( )方法中創建了三個不同的MyThread實例,每個MyThread實例擁有一個count變量,因此這三個線程實例就有三個count變量,那麼這些線程對各自count變量的操作是互不影響的。在本實例中,main( )方法中值創建了一個MyThread線程實例,那麼就只有一個count變量。然後創建了幾個不同的線程,把MyThread實例作爲構造參數用來實例化這些線程。這些線程的其中一個構造參數相同,都是MyThread實例。那麼這些線程調用start( )方法,最終線程調度程序執行的run( )方法都是構造參數中的同一個MyThread線程實例對象的run( )方法,相當於多個對象操作同一個變量。而上個實例是每個線程都操作的是自己私有變量。上個實例的變量私有,這個實例的變量共享。這是他們之間的區別,同時也驗證了只有共享變量纔會有非線程安全問題,私有變量不存在這個問題。

這裏注意到:在MyThread的run( )方法中執行了一個i--的操作。i--是一個非原子性操作,也就是非線程安全的。原子性操作是不可劃分的操作,那麼即便是所有線程都能訪問到,也都是線程安全的。非原子性操作的一行語句也可以劃分爲幾個步驟。雖然看上去是一行語句,但是在某些JVM中,i--的操作要分爲3個步驟:

<1>取得原有i值。

<2>計算i-1。

<3>對i賦值。

在這三個步驟中,如果有多個線程同時訪問,那麼一定會出現非線程安全問題。

那麼出現“B”和“C”計算出來的結果都是3的原因,我們可以分析一下。

假如在第2步計算值的時候,另一個線程也在修改i的值,那麼這個時候就會出現髒數據。

假設“B”,“C”沒有運行之前count的值是5,執行i--分三個步驟,這時“B”和“C”首先執行前兩步,對i進行了兩次減1的操作,然後接着“執行第三步,此時“B”和“C”讀出來的count變量值都是減了兩個1之後的,就會出現讀取的數據是“髒數據”的現象,其他運行結果的分析和“B”,“C”運行分析的思路類似。

這個例子的一個實例就是典型的銷售場景。5個售貨員,每個售貨員賣出一個貨品後不可以得出相同的剩餘數量,必須在一個售貨員賣出一個貨品後其他售貨員纔可以在新的剩餘物品上繼續做減1操作。

要解決非線程安全問題,保證線程安全就需要在多個線程之間進行同步。 所謂同步就是在一段時間內只有一個線程運行,其他的線程必須等到這個線程運行結束之後才能繼續執行。線程安全就是獲得實例變量的值是經過同步處理的,不會出現“髒讀”的現象。髒讀”就是在讀取實例變量時,該變量的值已經被其他線程更改過了。

爲了避免了非線程安全的問題,實現線程同步,Java引入了對象互斥鎖的概念,來保證數據共享操作的完整性。Java中每個對象都對應一個稱爲“互斥鎖”的標記。

兩個對象訪問同一個對象的同步方法時一定是線程安全的。同步方法通常就是被synchronized關鍵字修飾的方法。

針對這個問題的線程同步策略就是按順序排隊的方式進行減1操作。可以在線程的run( )方法前加一個synchronized關鍵字進行同步。關鍵字synchrnized與對象互斥鎖聯合起來使用保證對象在任意時刻只能由一個線程訪問。

public class MyThread extends Thread {
	
	    private int count = 5;
	    @Override
            synchronized public void run(){
		super.run();
		//while(count>0){
		//此示例不要使用for或while循環語句,使用循環語句後首先獲得機會運行的線程會同步運行,同步後其他線程就得不到運行的機會了,一直由這個線程進行減法運算
		  count--;
		  System.out.println("由"+this.currentThread().getName()+"計算,count="+count);
		//}        
	    }	
		
}

在run( )方法前面添加了synchronized關鍵字之後程序運行的結果:

從運行結果可以看出,雖然線程之間的執行順序是亂序的,但每次對count值的修改卻都是依次遞減的,這樣就保證了線程安全。

通過在run( )方法前加synchrionized關鍵字,使多個線程執行run( )方法時以排隊形式進行處理,通過synchrionized關鍵字修飾的方法就可以在該方法執行的時候給對象上鎖。比如一個被synchrionized關鍵字修飾的run( )方法,在一個線程調用run( )方法前,要先判斷run( )方法有沒有被上鎖,如果上鎖,說明有其他線程在調用run( )方法,必須等待run( )方法執行完畢才能再有機會搶佔run( )方法的鎖執行。這樣也就實現排隊調用run( )方法的目的,也就達到按順序對count變量進行減1操作的效果。synchrionized可以在任意對象及方法上加鎖,加鎖的這段代碼成爲“互斥區”或“臨界區”。

當一個線程想要執行同步方法裏面的代碼時,線程首先嚐試去拿這把鎖,如果能拿到這把鎖,那麼這個線程就可以執行synchrionized裏面的代碼。如果不能拿到這把鎖,那麼這個線程就會不斷的嘗試拿這把鎖,直到能夠拿到爲止,而且是有多個線程同時去爭搶這把鎖。關於synchrionized關鍵字的更多用法和線程同步更多的內容,參見:https://blog.csdn.net/kongmin_123/article/details/81301317

(3)非線程安全主要是指多個線程對同一個對象中的同一個實例變量進行操作時會出現值被更改,值不同步的情況,進而影響程序的執行流程。下面再來看一個非線程安全的實例。

創建一個LoginServlet類實現一個非線程安全的環境。

//本類模擬成一個Servlet組件
public class LoginServlet {
    private static String usernameRef;
    private static String passwordRef;
    public static void doPost(String username,String password){
    	try {
    	    usernameRef = username;   	
    	    if(username.equals("a")){    		
		Thread.sleep(5000);			
    	    }
    	    passwordRef = password;
    	    System.out.println("username="+usernameRef+" password="+passwordRef);
         } catch (InterruptedException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
	 }
    }
	
}
public class ALogin extends Thread {
       @Override
       public void run(){
    	   LoginServlet.doPost("a", "aa");
       }
}
public class BLogin extends Thread {
    @Override
    public void run(){
 	   LoginServlet.doPost("b", "bb");
    }
}

main( )方法中調用使用了LoginServlet的兩個線程: 

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		ALogin a = new ALogin();
		a.start();
		BLogin b = new BLogin();
		b.start();
	}
	
}

程序運行結果:

可以看出運行結果不確定, 執行結果是非線程安全的。

出現第一種運行情況的原因分析:A和B同時進入doPost( )方法,B首先給username賦值,A再給username賦值,導致B賦給username的值被A覆蓋。A執行到username的判斷語句睡眠,B首先執行,給password賦予了正確的值,最後先執行完畢返回“a”和“bb”,A睡眠結束接着執行返回“a”和“aa”。

出現第二種運行情況的原因分析:A和B同時進入doPost( )方法,A首先給username賦值,B再給username賦值,導致A賦給username的值被B覆蓋。A執行到username的判斷語句睡眠,B首先執行,給password賦予了正確的值,最後先執行完畢返回“b”和“bb”,A睡眠結束接着執行返回“b”和“aa”。

解決這個非線程安全問題的方法也是使用synchronized關鍵字。在LoginServlet類中的doPost( )方法前加上synchronized關鍵字,修改後的執行結果如下:

使用synchronized關鍵字之後,執行doPost( )方法時就排隊進入方法,哪個線程進入該方法還沒執行完的時候別的線程不能進入,只能排隊等候,從而實現了線程的順序執行,保證了多線程執行的安全性。

4.總結

如果多個線程共同訪問對象中的一個實例變量,則有可能出現非線程安全問題:有可能出現實例變量的值被覆蓋的情況,這在對MyThread類的訪問中可以體現;如果多個線程共同訪問的對象中有多個實例變量,則運行的結果有可能出現交叉的情況,可以對LoginServlet類的訪問中可以體現。

單例模式中的實例變量也呈非線程安全狀態。關於單例模式與線程安全,可以參考單例模式與多線程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章