Java多執行緒詳解

  • 2020 年 1 月 19 日
  • 筆記

今天我們聊一聊多執行緒,談到多執行緒,很多人就開始難受,這是一個一聽就頭疼的話題,但是,我希望你在看完這篇文章後能對多執行緒有一個深入的了解。

案例

那麼,首先我就舉一個電影院賣票的例子來模擬多執行緒。 復仇者聯盟4上映的那段時間電影院那可是門庭若市啊,那麼我們假設現在有一個電影院正在上映復仇者聯盟4,共有100張票,而它有三個售票窗口,我們來模擬一下這個電影院的售票情況。 首先創建SellTicket類繼承Thread:

public class SellTicket extends Thread {  	@Override  	public void run() {  		// 定義100張票  		int tickets = 100;    		while (true) {  			if (tickets > 0) {  				System.out.println(getName() + "正在出售" + (tickets--) + "張票");  			}  		}  	}  }

然後編寫測試程式碼:

public class SellTicketDemo {  	public static void main(String[] args) {  		//創建三個售票窗口  		SellTicket st1 = new SellTicket();  		SellTicket st2 = new SellTicket();  		SellTicket st3 = new SellTicket();    		st1.setName("窗口1");  		st2.setName("窗口2");  		st3.setName("窗口3");    		st1.start();  		st2.start();  		st3.start();  	}  }

現在我們運行程式,控制台輸出資訊如下:

...  窗口1正在出售第100張票  窗口3正在出售第100張票  窗口2正在出售第100張票  窗口3正在出售第99張票  窗口1正在出售第99張票  窗口3正在出售第98張票  窗口2正在出售第99張票  窗口3正在出售第97張票  窗口1正在出售第98張票  窗口3正在出售第96張票  窗口2正在出售第98張票  ...

那麼問題出現了,每張票都被賣了三次,很顯然這是不符合事實的。那麼問題就出現在這個tickets變數的定義位置上,如果將tickets變數定義在了run()方法內,很顯然三個執行緒就都具有了100張票,那麼現在來改進一下我們的程式:

public class SellTicket extends Thread {    	// 定義100張票  	private int tickets = 100;    	@Override  	public void run() {    		while (true) {  			if (tickets > 0) {  				System.out.println(getName() + "正在出售第" + (tickets--) + "張票");  			}  		}  	}  }

這次我們將tickets定義為成員變數,其它程式碼不作修改,然後重新運行程式:

...  窗口1正在出售第100張票  窗口3正在出售第100張票  窗口2正在出售第100張票  窗口3正在出售第99張票  窗口1正在出售第99張票  窗口3正在出售第98張票  窗口2正在出售第99張票  窗口3正在出售第97張票  窗口1正在出售第98張票  窗口3正在出售第96張票  窗口2正在出售第98張票  窗口3正在出售第95張票  ...

很顯然,這次又出現了問題,三個窗口仍然賣出了同一張票,那麼這是為什麼呢?原因很簡單,tickets雖然作為了成員變數,但是我們創建了三個執行緒,這樣每個執行緒就都會擁有一個tickets變數,所以剛才的問題其實並沒有得到解決。那麼為了解決這個問題,也為了使邏輯更加合理,我們應該採用實現Runnable介面的方式來模擬這一過程。 創建SellTicket類實現Runnable介面:

public class SellTicket implements Runnable {    	// 定義100張票  	private int tickets = 100;    	@Override  	public void run() {    		while (true) {  			if (tickets > 0) {  				System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");  			}  		}  	}  }

編寫測試程式碼:

public class SellTicketDemo {  	public static void main(String[] args) {  		// 創建三個售票窗口  		SellTicket st = new SellTicket();    		Thread t1 = new Thread(st,"窗口1");  		Thread t2 = new Thread(st,"窗口2");  		Thread t3 = new Thread(st,"窗口3");    		t1.start();  		t2.start();  		t3.start();  	}  }

運行程式:

...  窗口2正在出售第99張票  窗口1正在出售第100張票  窗口3正在出售第98張票  窗口2正在出售第97張票  窗口3正在出售第95張票  窗口1正在出售第96張票  窗口3正在出售第93張票  窗口2正在出售第94張票  窗口3正在出售第91張票  ...

感覺好像沒問題了,然而在實際生活中, 售票網路是不可能實時傳輸的,總是存在延時的情況,所以,在出售一張票以後,需要一點時間的延遲。那麼我們修改一下程式:

public class SellTicket implements Runnable {    	// 定義100張票  	private int tickets = 100;    	@Override  	public void run() {    		while (true) {  			if (tickets > 0) {  				//延遲  				try {  					Thread.sleep(100);  				} catch (InterruptedException e) {  					e.printStackTrace();  				}  				System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");  			}  		}  	}  }

在賣票之前延遲了100毫秒,其它程式碼不作修改,然後運行程式:

...  窗口3正在出售第4張票  窗口1正在出售第3張票  窗口3正在出售第2張票  窗口2正在出售第2張票  窗口1正在出售第1張票  窗口2正在出售第0張票  窗口3正在出售第-1張票  ...

會發現出現了賣同一張票和負數票的情況,顯然這段程式的問題很大。我們說CPU的一次執行必須是一個原子性的操作,原子性就是最簡單基本的操作,很顯然tickets–並不是一個原子性的操作。那麼當某幾個執行緒同時輸出ticket值的時候,就出現了賣同一張票的情況;然而當某一個執行緒在延遲100毫秒的過程中,因為該執行緒並沒有執行到tickets–的步驟,所以其它執行緒此時也通過了if判斷,就出現了賣負數票的情況。

如何解決執行緒安全問題

要想解決問題,我們首先得知道哪些原因會導致執行緒安全問題,通過上面的分析,總結如下:

  • 是否為多執行緒環境
  • 是否有共享數據
  • 是否有多條語句操作共享數據

那我們回頭看看案例,會發現這三條原因我們全佔了,那麼出現問題也就不足為奇了。既然找出了問題所在,我們就試著去解決它。 既然多執行緒環境和共享數據我們無法操縱,但是我們能夠使多條語句操作共享數據不成立。這就引出了今天的主題,「同步機制」。 格式:synchronized(對象){ 需要同步的程式碼; } 那麼括弧里的對象是什麼呢?我們創建一個對象給它試試。

public class SellTicket implements Runnable {    	// 定義100張票  	private int tickets = 100;    	@Override  	public void run() {    		while (true) {  			synchronized (new Object()) {  				if (tickets > 0) {  					// 延遲  					try {  						Thread.sleep(100);  					} catch (InterruptedException e) {  						e.printStackTrace();  					}  					System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");  				}  			}  		}  	}  }

現在運行程式:

...  窗口2正在出售第5張票  窗口1正在出售第4張票  窗口3正在出售第3張票  窗口2正在出售第2張票  窗口1正在出售第1張票  窗口3正在出售第0張票  窗口2正在出售第-1張票  ...

然後問題然是出現了,我們需要注意這個對象,同步機制解決執行緒安全問題的根本就在這個對象上,我們稱其為鎖,那麼鎖住程式碼的鎖只能是同一把,然而上面的事例明顯創建了三把鎖。 我們再次修改程式碼:

public class SellTicket implements Runnable {    	// 定義100張票  	private int tickets = 100;  	//創建鎖對象  	private Object obj = new Object();    	@Override  	public void run() {    		while (true) {  			synchronized (obj) {  				if (tickets > 0) {  					// 延遲  					try {  						Thread.sleep(100);  					} catch (InterruptedException e) {  						e.printStackTrace();  					}  					System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");  				}  			}  		}  	}  }

現在運行程式:

窗口1正在出售第13張票  窗口1正在出售第12張票  窗口1正在出售第11張票  窗口1正在出售第10張票  窗口1正在出售第9張票  窗口1正在出售第8張票  窗口1正在出售第7張票  窗口3正在出售第6張票  窗口3正在出售第5張票  窗口3正在出售第4張票  窗口3正在出售第3張票  窗口3正在出售第2張票  窗口2正在出售第1張票

這樣關於執行緒安全的問題就迎刃而解了。 那麼同步機制的原理就是當某個執行緒開始執行並執行到同步的程式碼之後,就會通過鎖對象將該段程式碼進行一個封鎖,當該執行緒執行完同步程式碼後就釋放鎖,然後在程式碼被鎖住的情況下其它執行緒即使搶佔了執行權仍然無法繼續執行,它只能等待鎖釋放才能繼續執行。 那麼總結一下同步的特點: 前提:

  • 多個執行緒

解決問題的時候要注意:

  • 多個執行緒使用的是用一個鎖對象

同步的好處:

  • 解決了多執行緒的安全問題

同步的弊端:

  • 當執行緒相當多時,因為每個執行緒都會去判斷同步上的鎖,這是很耗資源的,無形中會降低程式的運行效率。

我們繼續深入研究一下同步機制。 我們剛才使用的是Object對象作為鎖,這說明任意對象都可以作為同步鎖。而如果我們將程式碼做一些修改:

public class SellTicket implements Runnable {    	// 定義100張票  	private int tickets = 100;  	// 創建鎖對象  	private Object obj = new Object();  	private int x = 0;    	@Override  	public void run() {    		while (true) {  			if (x % 2 == 0) {  				synchronized (obj) {  					if (tickets > 0) {  						try {  							Thread.sleep(100);  						} catch (InterruptedException e) {  							e.printStackTrace();  						}  						System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票 ");  					}  				}  			} else {  				sellTicket();  			}  			x++;  		}  	}    	private synchronized void sellTicket() {  		if (tickets > 0) {  			try {  				Thread.sleep(100);  			} catch (InterruptedException e) {  				e.printStackTrace();  			}  			System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票 ");  		}  	}  }

此時運行程式的話,賣出同一張票的情況就又出現了,我們說同步的鎖對象只能是同一個,那麼同步方法的鎖對象是什麼呢? 同步方法的鎖對象就是this,所以如果將括弧內的鎖對象替換為this,該程式就並不會出現問題了。 而靜態方法的鎖對象就是類的位元組碼文件對象(.class)。

死鎖問題

那麼在同步中有一個致命的問題,死鎖問題。 死鎖問題是指兩個或兩個以上的執行緒在執行的過程中,因爭奪資源產生的一種互相等待的現象。 死鎖問題中比較經典的問題就是哲學家吃飯問題。在哲學家吃飯問題中,每個哲學家都有可能拿起了左手邊的筷子而永遠在等右邊的筷子,事實上,他永遠也等不到。 在程式中,不恰當的嵌套也有可能導致死鎖問題,我們看一個例子: 創建MyLock類:

public class MyLock {  	//創建兩個鎖對象  	public static final Object objA = new Object();  	public static final Object objB = new Object();  }

然後創建DieLock類:

public class DieLock extends Thread {    	private boolean flag;    	public DieLock(boolean flag) {  		this.flag = flag;  	}    	@Override  	public void run() {  		if (flag) {  			synchronized (MyLock.objA) {  				System.out.println("if objA");  				synchronized (MyLock.objB) {  					System.out.println("if objB");  				}  			}  		}else {  			synchronized (MyLock.objB) {  				System.out.println("else objB");  				synchronized (MyLock.objA) {  					System.out.println("else objA");  				}  			}  		}  	}  }

接著編寫測試程式碼:

public class DieLockDemo {  	public static void main(String[] args) {  		DieLock dl1 = new DieLock(true);  		DieLock dl2 = new DieLock(false);    		dl1.start();  		dl2.start();  	}  }

多次運行之後,死鎖現象出現了。

原因是當某個執行緒執行if判斷時使用了鎖A,當該執行緒想繼續執行時,第二條執行緒執行else使用了鎖B,此時第一條執行緒需第二條執行緒執行完釋放鎖B,而第二條執行緒因為也在等待第一條執行緒釋放鎖A從而無法釋放鎖B,進而造成了死鎖。

如何避免死鎖

在有些情況下死鎖是可以避免的。三種用於避免死鎖的技術:

加鎖順序(執行緒按照一定的順序加鎖) 加鎖時限(執行緒嘗試獲取鎖的時候加上一定的時限,超過時限則放棄對該鎖的請求,並釋放自己佔有的鎖) 死鎖檢測 加鎖順序 當多個執行緒需要相同的一些鎖,但是按照不同的順序加鎖,死鎖就很容易發生。 加鎖時限 另外一個可以避免死鎖的方法是在嘗試獲取鎖的時候加一個超時時間,這也就意味著在嘗試獲取鎖的過程中若超過了這個時限該執行緒則放棄對該鎖請求。若一個執行緒沒有在給定的時限內成功獲得所有需要的鎖,則會進行回退並釋放所有已經獲得的鎖,然後等待一段隨機的時間再重試。這段隨機的等待時間讓其它執行緒有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續運行(譯者註:加鎖超時後可以先繼續運行干點其它事情,再回頭來重複之前加鎖的邏輯)。 死鎖檢測 死鎖檢測是一個更好的死鎖預防機制,它主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的場景。

每當一個執行緒獲得了鎖,會在執行緒和鎖相關的數據結構中(map、graph等等)將其記下。除此之外,每當有執行緒請求鎖,也需要記錄在這個數據結構中。 當然,死鎖一般要比兩個執行緒互相持有對方的鎖這種情況要複雜的多。執行緒A等待執行緒B,執行緒B等待執行緒C,執行緒C等待執行緒D,執行緒D又在等待執行緒A。執行緒A為了檢測死鎖,它需要遞進地檢測所有被B請求的鎖。從執行緒B所請求的鎖開始,執行緒A找到了執行緒C,然後又找到了執行緒D,發現執行緒D請求的鎖被執行緒A自己持有著。這是它就知道發生了死鎖。

我們可以通過破壞死鎖產生的4個必要條件來 預防死鎖,由於資源互斥是資源使用的固有特性是無法改變的。

破壞「不可剝奪」條件:一個進程不能獲得所需要的全部資源時便處於等待狀態,等待期間他佔有的資源將被隱式的釋放重新加入到 系統的資源列表中,可以被其他的進程使用,而等待的進程只有重新獲得自己原有的資源以及新申請的資源才可以重新啟動,執行。 破壞」請求與保持條件「:第一種方法靜態分配即每個進程在開始執行時就申請他所需要的全部資源。第二種是動態分配即每個進程在申請所需要的資源時他本身不佔用系統資源。 破壞「循環等待」條件:採用資源有序分配其基本思想是將系統中的所有資源順序編號,將緊缺的,稀少的採用較大的編號,在申請資源時必須按照編號的順序進行,一個進程只有獲得較小編號的進程才能申請較大編號的進程。