1. 程式人生 > >Java表示式的陷阱——多執行緒的陷阱

Java表示式的陷阱——多執行緒的陷阱

6、多執行緒的陷阱

       Java語言提供了非常優秀的多執行緒支援,使得開發者能以簡單的程式碼來建立、啟動多執行緒,而且Java語言內建了多執行緒支援極好地簡化了多執行緒程式設計。雖然如此,Java多執行緒程式設計中依然存在一些容易混淆的陷阱。

6、1 不要呼叫run()方法

       從Java5開始,Java提供了三種方式來建立、啟動多執行緒。
  1. 繼承Thread類建立執行緒類,重寫run()方法作為執行緒執行體。
  2. 實現Runnable介面建立執行緒類,重寫run()方法作為執行緒執行體。
  3. 實現Callable介面建立執行緒類,重寫call()方法作為執行緒執行體。
       其中第一種方式的效果最差,有以下兩點壞處。
  1. 執行緒類繼承了Thread類,無法再繼承其他父類。
  2. 因為每條執行緒都是Thread子類的例項,因此可以將多條執行緒的執行流程式碼與業務資料分離。
       對於第二種和第三種方式,它們本質都是一樣的,只是Callable藉口裡包含的call()方法既可以宣告丟擲異常,也可以擁有返回值。
public class InvokeRun extends Thread {
	private int i;
	
	public void run() {
		for ( ; i < 100 ; i ++ ) {
			//直接呼叫run方法時,Thread的this.getName返回的是該物件名字,而不是當前執行緒的名字
			//使用Thread.currentThread().getName()總是獲取當前執行緒的名字
			System.out.println(Thread.currentThread().getName() + " " + i);
		}
	}
	
	public static void main(String[] args) {
		for ( int i = 0 ; i < 100 ; i ++ ) {
			System.out.println(Thread.currentThread().getName() + " " + i);
			if ( i == 20 ) {
				//直接呼叫執行緒的run()方法,系統會把執行緒物件當成普通物件,把run方法當成普通方法
				new InvokeRun().run();
				new InvokeRun().run();
			}
		}
	}
}
        程式執行的大致過程如下所示。
  1. 輸出main20之後,又重新開始輸出main0.
  2. 從main0一直輸出到main99,再次從main0開始輸出。
  3. 從main0一直輸出到main99,再次從main21開始輸出,直到main99結束。
            上面程式始終只有一條執行緒,並沒有啟動任何新執行緒,關鍵是因為呼叫的是run()方法,而不是start()方法。啟動執行緒應該使用start()方法,而不是run()方法。        如果程式中從未呼叫物件的start()方法來啟動它,那麼這個執行緒物件將一直處於“新建”狀態,永遠不會作為執行緒獲得執行的機會,只是一個普通的Java物件。當程式呼叫執行緒物件的run()方法時,與呼叫普通Java物件的普通方法並無任何區別,因此絕不會啟動一條新執行緒。

6、2 靜態的同步方法

       Java提供了synchronized關鍵字用於修飾方法,使用 synchronized修飾的方法被稱為同步方法。當然,synchronized關鍵字除了修飾方法之外,還可以修飾普通程式碼塊,使用synchronized修飾的程式碼塊被稱為同步程式碼塊。        Java語法規定:任何執行緒進入同步方法、同步程式碼塊之前,必須先獲取同步方法、同步程式碼塊對應的同步監視器。對於同步程式碼塊而言,程式必須顯示地為它指定同步監視器;對於同步非靜態方法而言,該方法的同步監視器是this,即呼叫該方法的Java物件;對於靜態的同步方法而言,該方法的同步監視器不是this,而是該類本身。
public class SynchronizedStatic implements Runnable {
	static boolean staticFlag = true;
	
	public static synchronized void test0() {
		for ( int i = 0 ;  i < 100 ; i ++ ) {
			System.out.println("test0:" + Thread.currentThread().getName() + " " + i);
		}
	}
	
	public void test1() {
		synchronized (this) {
			for ( int i = 0 ;  i < 100 ; i ++ ) {
				System.out.println("test1:" + Thread.currentThread().getName() + " " + i);
			}
		}
	}

	@Override
	public void run() {
		if (staticFlag) {
			staticFlag = false;
			test0();
		}
		else {
			staticFlag = true;
			test1();
		}
	}
	
	public static void main(String[] args) throws Exception {
		SynchronizedStatic ss = new SynchronizedStatic();
		new Thread(ss).start();
		Thread.sleep(10);
		new Thread(ss).start();
	}
}
       上面程式中提供了一個靜態同步方法和一個同步程式碼塊。同步程式碼塊使用this作為同步監視,即這兩個同步程式單元並沒有使用相同的同步監視器,因此 可以同時併發執行,相互之間不會有任何影響。S ynchronizedStatic類通過staticFlag來控制執行緒使用哪個方法作為執行緒執行體。程式第一次執行SynchronizedStatic物件作為target的執行緒時,staticFlag初始值為true,因此程式將以test0()方法作為執行緒執行體,而且會把staticFlag修改為false;這使得第二次執行SynchronizedStatic物件作為target的執行緒時,程式將以test1()方法作為執行緒執行體。        靜態同步方法可以和以this為同步監視器的程式碼塊同時執行,當第一條執行緒(以test0()方法作為執行緒執行體的執行緒)進入同步程式碼塊執行以後,該執行緒獲得了對同步監視器(SynchronizedStatic類)的鎖定;第二條執行緒(以test1()方法作為執行緒執行體的執行緒)嘗試進入同步程式碼塊執行,進入同步程式碼塊之前,該執行緒必須獲得對this引用(也就是ss變數所引用的物件)的鎖定。
       public void test1() {
		synchronized (SynchronizedStatic.class) {
			for ( int i = 0 ;  i < 100 ; i ++ ) {
				System.out.println("test1:" + Thread.currentThread().getName() + " " + i);
			}
		}
	}
       將test1()方法改為上面的形式之後,該同步程式碼塊的同步監視器也是 S ynchronizedStatic類,也就是與同步靜態方法test0()具有相同的同步監視器。在上述程式碼中,靜態同步方法和以當前類為同步監視器的同步程式碼塊不能同時執行當第一條執行緒(以test0()方法作為執行緒執行體的執行緒)進入同步程式碼塊執行以後,該執行緒獲得了對同步監視器(SynchronizedStatic類)的鎖定;第二條執行緒(以test1()方法作為執行緒執行體的執行緒)嘗試進入同步程式碼塊執行,進入同步程式碼塊之前,該執行緒必須獲得對SynchronizedStatic類的鎖定。因為第一條執行緒已經鎖定了SynchronizedStatic類,在第一條執行緒執行結束之前,它不會釋放SynchronizedStatic類的鎖定,因此只有等第一條執行緒執行結束後才可以切換到執行第二條執行緒。

6、3 靜態初始化塊啟動新執行緒執行初始化

public class StaticThreadInit {
	static {
		Thread t = new Thread() {
			public void run() {
				System.out.println("進入run方法");
				System.out.println(website);
				website = "www.sohu.com";
				System.out.println("退出run方法");
			}
		};
		t.start();
		try {
			t.join();
		}
		catch (Exception e) {
			e.printStackTrace();
		}
	}
	
	static String website = "www.baidu.com";
	public static void main(String[] args) {
		System.out.println(StaticThreadInit.website);
	}
}
輸出結果為: 進入run方法
       上面程式定義了一個靜態的website,併為其指定了初試值,但程式也在靜態初始化塊中也為website進行賦值,且靜態初始化塊排在前面。如果只是保留這樣的程式結果,那麼程式的結果將會非常的清晰:靜態初始化先將為website賦值為www.sohu.com,然後初始化機制再將website賦值為www.baidu.com。但是程式中的靜態初始化塊是啟動一條新的執行緒來執行初始化塊操作,就導致最終只輸出“ 進入run方法”,不會繼續向下執行。        下面詳細分析該程式的執行細節。程式總是先從main方法開始執行,main方法只有一行程式碼,訪問StaticThreadInit類的website靜態field的值。當某條執行緒試圖訪問一個類的靜態field時,根據該類的狀態可能出現如下四種情況。
  1. 該類尚未被初始化:當前執行緒開始對其執行初始化。
  2. 該類正在被當前執行緒執行初始化:這是對初始化的遞迴請求。
  3. 該類正在被其他執行緒執行初始化:當前執行緒暫停,等待其他執行緒初始化完成。
  4. 這個類已經被初始化:直接得到該靜態field的值。
       main執行緒試圖訪問 StaticThreadInit.website的值,此時StaticThreadInit尚未被初始化,因此main執行緒開始對該類執行初始化。初始化過程主要完成如下兩個步驟。
  1. 為該類的所有靜態field分配記憶體。
  2. 呼叫靜態初始化塊的程式碼執行初始化。
       因此,main執行緒首先會為 StaticThreadInit類的website分配記憶體空間,此時的website的值為null。接著,main執行緒開始執行StaticThreadInit類的靜態初始化塊。該程式碼塊建立並啟動了一條新執行緒,並呼叫了新執行緒的join()方法,這就意味著main執行緒必須等待新執行緒執行結束後才能向下執行。        新執行緒開始執行之後,首先執行System.out.println("進入run方法");程式碼,接著,程式試圖執行System.out.println(website);,此時問題就出現了:StaticThreadInit類正由main執行緒執行初始化,因此新執行緒會等待main執行緒對StaticThreadInit類執行初始化結束。這時候滿足了死鎖條件:兩條執行緒互相等待對方執行,因此都不能向下執行。因此程式執行到此處就出現了死鎖,程式沒法向下執行,也就是執行該程式所看到的結果。        經過上面的分析可以看出,上面程式出現死鎖的關鍵在於程式呼叫的t.join(),這導致了main執行緒必須等待新執行緒執行結束才能向下執行。下面將t.join()方法去掉,將靜態程式碼塊部分改為如下所示。
       static {
		Thread t = new Thread() {
			public void run() {
				System.out.println("進入run方法");
				System.out.println(website);
				website = "www.sohu.com";
				System.out.println("退出run方法");
			}
		};
		t.start();
	}
輸出結果為: www.baidu.com
進入run方法
www.baidu.com
退出run方法
       兩次訪問website的值都是www.baidu.com。再來分析一下執行的過程,main執行緒進入StaticThreadInit靜態初始化塊之後,同樣也是建立並啟動了新執行緒,由於此時並未呼叫新執行緒的join()方法,因此新執行緒出於就緒狀態,還未進入到執行狀態。main執行緒繼續執行初始化操作,它會將website的值初始化為www.baidu.com,至此StaticThreadInit類初始化完成。程式也就會輸出www.baidu.com。接下來新執行緒才進入執行狀態,依次執行run()方法裡的每行程式碼,此時訪問道德website的值依然是www.baidu.com,run方法()最後將website的值改為www.sohu.com,但程式已經不再訪問它了。        產生上面執行結果的原因是呼叫一條執行緒的start()方法後,該執行緒並不會立即進入執行狀態,它只是保持在就緒狀態。為了改變這種狀態,再次將StaticThreadInit類的靜態初始化塊程式碼,如下所示。
       static {
		Thread t = new Thread() {
			public void run() {
				System.out.println("進入run方法");
				System.out.println(website);
				website = "www.sohu.com";
				System.out.println("退出run方法");
			}
		};
		t.start();
		try {
			Thread.sleep(1);
		}
		catch (Exception e) {
			e.printStackTrace();
		}
	}
輸出結果為: 進入run方法
www.baidu.com
www.baidu.com
退出run方法
       上面程式呼叫新執行緒的start()方法啟動新執行緒後,立即呼叫Thread.sleep(1)暫停當前執行緒,使得新執行緒立即獲得執行機會。即使讓新執行緒立即啟動,新執行緒為website指定的值依然沒有起作用。這依然和類初始化機制有關。當main執行緒進入StaticThreadInit類的靜態初始化塊後,main執行緒建立、啟動一天新執行緒,然後主執行緒呼叫Thread.sleep(1)暫停自己,是的新執行緒獲得執行機會,於是看到了執行結果的第一行輸出“進入run方法”。然後,新執行緒檢視執行System.out.println(website);來輸出website的值,但由於StaticThreadInit類還未初始化完成,因此新執行緒不得不放棄執行。執行緒排程器再次切換到main執行緒,於是main執行緒將website初始化為www.baidu.com,至此StaticThreadInit類初始化完成。        通常main執行緒不會立刻切換回來執行新執行緒,它會執行main方法裡的第一行程式碼,也就是輸出website的值,於是看到輸出結果第二行“www.baidu.com”。main執行緒執行完後,系統切換回來執行新執行緒,新執行緒訪問website時也會輸出www.baidu.com,也就是輸出結果第三行。run方法()最後將website的值改為www.sohu.com,但程式已經不再訪問它了。                實際上有一個問題:靜態初始化塊裡啟動新執行緒對靜態field賦值根本不是初始化,它只是一次普通的賦值。
public class StaticThreadInit2 {
	static {
		Thread t = new Thread() {
			public void run() {
				website = "www.sohu.com";  //報錯:The final field StaticThreadInit2.website cannot be assigned
			}
		};
		t.start();
	}
	
	final static String website;  //報錯:The blank final field website may not have been initialized
	public static void main(String[] args) {
		System.out.println(StaticThreadInit2.website);
	}
}
       上面程式定義了一個final靜態變數website,沒有為它指定初始值,接著試圖在靜態初始化塊中為website指定初始值。在正常情況下,這個程式沒有任何問題,不過當靜態初始化塊啟動了一條新執行緒為website指定初始值時就會有問題。從上面的錯誤提示可以看出,靜態初始化塊啟動的新執行緒根本不允許為website賦值。這表明,新執行緒為website賦值根本不是初始化操作,只是一次普通的賦值。 總結:不要認為所有放在靜態初始化塊中的程式碼就一定是類初始化操作,靜態初始化塊中啟動新執行緒的run()方法程式碼只是新執行緒的執行緒執行體,並不是類初始化操作。類似地,不要認為所有放在非靜態初始化塊中的程式碼就一定是物件初始化操作,非靜態初始化快中啟動的新執行緒的run()方法程式碼只是新執行緒的執行緒執行體,並不是物件初始化操作。

6、4 多執行緒執行環境

       在不考慮多執行緒環境的情況下,很多程式碼都是完全正確的。但一旦將它們放在多執行緒環境下,這個類就變得非常的容易出錯,這種類被稱為執行緒不安全類。在多執行緒環境下使用執行緒不安全的類總是危險的,多執行緒環境下應該使用執行緒安全的類。
public class Account {
	private String accountNo;
	private double balance;  //賬戶餘額
	
	public Account() {}
	public Account(String accountNo , double balance) {
		this.accountNo = accountNo;
		this.balance = balance;
	}
	
	public double getBalance() {
		return this.balance;
	}
	
	public void draw(double drawAmount) {
		if ( balance >= drawAmount ) {
			System.out.println(Thread.currentThread().getName() + "取錢成功!吐出鈔票:" + drawAmount);
			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 == this) {
			return true;
		}
		if (obj.getClass() == Account.class) {
			Account target = (Account)obj;
			return accountNo.equals(target.accountNo);
		}
		return false;
	}
}
       上面程式定義了Account類,該類代表一個銀行賬戶,實現了一個draw()方法用於取錢。這個取錢過程從邏輯上來說沒有任何問題:系統先判斷賬戶餘額是否大於取款金額,當賬戶餘額大於取款金額時,取錢成功;否則,系統提示餘額不足。但是由於它只是一個執行緒不安全的類,因此Account類不適用於多執行緒的環境。
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.draw(drawAmount);
	}
}
public class DrawTest {
	public static void main(String[] args) {
		Account acc = new Account("123456" , 1000);
		new DrawThread("甲", acc, 800).start();
		new DrawThread("乙", acc, 800).start();
	}
}
可能出現的結果為: 乙取錢成功!吐出鈔票:800.0
餘額為:200.0
甲取錢成功!吐出鈔票:800.0
餘額為:-600.0
       從結果來看這個程式出現了問題,該賬戶餘額只有1000元,但這兩條執行緒各自取走了800元,這就是有Account類執行緒不安全導致的。為了將Account類能更好地適用於多執行緒的環境,可以將Account類修改為執行緒安全的形式。執行緒安全的類具有如下特徵。
  1. 該類的物件可以被多個執行緒安全地訪問。
  2. 每個執行緒呼叫該物件的任意方法之後都將得到正確結果。
  3. 每個執行緒呼叫該物件的任意方法之後,該物件狀態依然保持合理狀態。
       在之前的學習中知道Vector、StringBuffer都是執行緒安全的類,通過檢視原始碼可以發現執行緒安全類的大量方法都使用了synchronized關鍵字進行修飾,也就是說,通過同步方法可以得到執行緒安全的類。
public class Account {
	private String accountNo;
	private double balance;  //賬戶餘額
	
	public Account() {}
	public Account(String accountNo , double balance) {
		this.accountNo = accountNo;
		this.balance = balance;
	}
	
	public synchronized double getBalance() {  //使用synchronized關鍵字修飾
		return this.balance;
	}
	
	public synchronized void draw(double drawAmount) {  //使用synchronized關鍵字修飾
		if ( balance >= drawAmount ) {
			System.out.println(Thread.currentThread().getName() + "取錢成功!吐出鈔票:" + drawAmount);
			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 == this) {
			return true;
		}
		if (obj.getClass() == Account.class) {
			Account target = (Account)obj;
			return accountNo.equals(target.accountNo);
		}
		return false;
	}
}
       將Account類中的getBalance()方法和draw(double drawAmount)方法使用synchronized關鍵字修飾,使得可以訪問共享資源balance,因此使得Account類成為一個執行緒安全的類。