淺談Java多線程機制

淺談Java多線程機制

(-----文中重點信息將用紅色字體凸顯-----)


一、話題導入

       在開始簡述Java多線程機制之前,我不得不吐槽一下我國糟糕的IT界技術分享氛圍和不給力的互聯網技術解答深度。當一個初學java的小哥向我請教Java多線程機制相關問題時,我讓他去尋求度孃的幫助,讓他先學會自己嘗試解決問題;但是他告訴我在網上找不到他想要的信息,我也嘗試性的在網上收颳了半天,也確實找不到內容詳盡、表述清晰的文獻。更遺憾的是某些也許有一定參考價值的文檔都需要通過非正常手段下載,比如註冊、回覆甚至是花錢購買,這難免會讓不少人望而卻步,最後不了了之。

       我並不是蓄意抨擊,而是希望更多的人能夠向LINUX自由之父Stallman一樣,學會奉獻;如果大家都能夠嘗試去奉獻,最終每個人也將更易於索取。

       (以後得空將會陸續將Java各知識點歸類總結,並放在CSDN個人博客中;出Java之外還考慮介紹下其他方面的內容,屆時請保持關注喲^(  。。)^


二、現實中的類似問題

       假設你是某快餐店的老闆,隨着自己的苦心經營,終於讓快餐店門庭若市、生意興隆;爲了拓展銷路,你決定增加送餐上門服務,公司財務告訴你你可以爲拓展此業務支配12萬元,這個時候你會怎麼支配這筆錢呢?

       當然有很多種支配方式,並且在支配上需要考慮到人員數量、送餐範圍、送餐形式等多個問題;這裏我們集中討論下送餐形式這個細節:

       1)買一輛雪弗蘭賽歐;

       2)買15輛電瓶車;

       除去員工工資等基本成本過後剩餘的錢用於購買送餐工具,上面我給出了兩種送餐交通工具,他們都有各自的優點:首先,雪弗蘭賽歐能夠達到更快的送餐速度,而且可以供應的送餐範圍更廣;其次,用電瓶車作爲送餐交通工具可以同時爲多個顧客派送,並且運送成本顯然更加低廉。在這兩者之間,你會作何選擇呢?

       顯然是第二種送餐交通工具更加實用:相較之下,後者可以處理的顧客數量更多,靠後的顧客等待時間明顯縮短。試想一下,如果你打了電話定了午飯,就因爲你是第25個顧客,晚上六點纔給你送來,你會是什麼心情?

       其實,快餐店老闆選擇多輛電瓶車進行送餐的考慮同進程選擇多線程控制的思想是如出一轍的,單線程的程序往往功能非常有限,在某些特定領域甚至不能達到我們所期望的效能。例如,當你想讓服務器數據能夠被多個客戶同時訪問時,單線程將讓這一設想化爲泡影;單線程情況下,多個客戶的需求將存入一個棧隊,並且依次執行,靠後的客戶很難有較好的訪問體驗。

       Java語言提供了非常優秀的多線程支持,多線程的程序可以包含多個順序執行流,且多個順序執行流之間互不干擾。總的來說,使用多線程編程有如下多個優點:

       1)多個線程之間可以共享內存數據;

       2)多個線程是併發執行的,可以同時完成多個任務;

       3)Java語言內置了多線程功能支持,從而簡化了Java的多線程編程。


三、線程的創建和啓動

       Java使用Thread類代表線程,所有線程對象都是Thread類或者其子類的實例。創建線程的方式有三種,分別是:

       1)繼承Thread類創建線程;

       2)實現Runnable接口創建線程;

       3)使用Callable和Future創建線程。

       以上三種方式均可以創建線程,不過它們各有優劣,我將在分別敘述完每一種創建線程的方式後總結概括。

       3.1 繼承Thread類創建線程

       主要步驟爲:

       ① 定義一個類並繼承Thread類,此類中需要重寫Thread類中的run()方法,這個run()方法就是多線程都需要執行的方法;整個run()方法也叫做線程執行體;

       ② 創建此類的實例(對象),這時就創建了線程對象;

       ③ 調用線程對象的start()方法來啓動該線程。

       舉例說明:

<span style="font-size:12px;">
public class MyThread extends Thread 
{
	public static void main(String[] args) 
	{
		MyThread m1 = new MyThread();
		MyThread m2 = new MyThread();
		m1.start();//調用start()方法來開啓線程
		m2.start();</span>
	}

	private int a;
	public void run()//重寫run()方法
	{
		for ( ; a<100 ; a++ )
		{
			System.out.println(getName()+"-----"+a);
			//通過繼承Thread類來創建線程時,可以通過getName()方法來獲取線程的名稱
		}
	}
}
</span>
       上面通過一個簡單的例子演示了創建線程的第一種方法(通過繼承Thread類創建線程);通過運行以上代碼發現有兩個線程在併發執行,它們各自分別打印出0-99。由於沒有對線程進行顯示的命名,所以系統默認這兩個線程的名稱爲Thread-0和Thread-1,num會跟隨線程的個數依次遞增。具體怎樣定義線程名稱,我將在後面提及。

       那麼在上述例子中一共有多少個線程在運行呢?答案是三個!

       分別是main(主線程)、Thread-0和Thread-1;我們在多線程編程時一定不要忘記Java程序運行時默認的主線程,main()方法的方法體就是主線程的線程執行體;同理,run()方法就是新建線程的線程執行體。

      PS: 其實上述例子中創建線程的代碼(標紅)可以簡化,使用匿名對象來創建線程:

<span style="font-family:Microsoft YaHei;font-size:12px;"><span style="font-size:12px;">new MyThread().start();
new MyThread().start();
</span></span>

------------------------------------------------------------------------------------------------------------------------------------------      

     程序中如果想要獲取當前線程對象可以使用方法:Thread.currentThread();

      如果想要返回線程的名稱,則可以使用方法:getName();

      故如果想要獲取當前線程的名稱可以使用以上二者的搭配形式:Thread.currentThread().getName();

      此外,還可以通過setName(String name)方法爲線程設置名字;具體操作步驟是在定義線程後用線程對象調用setName()方法:

<span style="font-family:Microsoft YaHei;font-size:12px;">MyThread m1 = new MyThread();
m1.setName("xiancheng1");</span>
     如此便能將線程對象m1的名稱由Thread-0改變成xiancheng1。

------------------------------------------------------------------------------------------------------------------------------------------

      在討論完設置線程名稱及獲取線程名稱的話題後,我們來分析下變量的共享。從以上代碼運行結果來看,線程Thread0和線程Thread1分別輸出0-99,由此可以看出,使用繼承Thread類的方法來創建線程類時,多個線程之間無法共享線程類的實例變量。

      3.2 實現Runnable接口創建線程類

      主要步驟爲:

       ① 定義一個類並實現Runnable接口,重寫該接口的run()方法,run()方法的方法體依舊是該線程的線程執行體;

      ② 創建定義的類的實例,並以此實例作爲Thread的target來創建Thread對象,該Thread對象纔是真正的線程對象;

      ③ 調用線程的start()方法來啓動該線程。

      舉例說明:

public class MyThread implements Runnable
{
	public static void main(String[] args) 
	{
		MyThread m1 = new MyThread();
		Thread t1 = new Thread(m1,"線程1");
		Thread t2 = new Thread(m1,"線程2");
		t1.start();
		t2.start();
	}

	private int i;
	public void run()
	{
		for ( ; i<100 ; i++ )
		{
			System.out.println(Thread.currentThread().getName()+"  "+i);
		}
	}
}
     運行上面的程序可以看出:兩個子線程的i變量是連續的,也就是說採用Runnable接口的方式創建的兩個線程可以共享線程類的實例屬性,這是因爲我們創建的兩個線程共用同一個target(m1),所以多個線程可以共享同一個線程類的實例屬性。

      通過對以上兩種創建新線程的方法進行比較分析,可以知道兩種創建並啓動多線程方式的區別是:通過繼承Thread類創建的對象即是線程對象,而通過實現Runnable接口創建的類對象只能作爲線程對象的target。

      3.3 通過Callable和Future創建線程

      Callable接口是在Java5才提出的,它是Runnable接口的增強版;它提供了一個call()方法作爲線程執行體,且call()方法比run()方法更爲強大,主要體現在:

      ① call()方法可以有返回值;

      ② call()方法可以申明拋出異常。

      Java5提供了Future接口來代表Callable接口裏call()方法的返回值,併爲Futrue接口提供一個FutureTask實現類,此實現類實現了Future接口,並且實現了Runnable接口,可以作爲Thread類的target。不過需要提出的是,Callable接口有泛型限制,Callable接口裏的泛型形參類型於call()方法返回值類型相同。

      主要步驟爲:(創建並啓動有返回值的線程

      ① 創建Callable接口的實現類,並實現call()方法作爲線程的執行體,且該call()方法有返回值;

      //不再是void

      ② 創建Callable接口實現類的實例,使用FutureTask類來包裝Callable對象,該FutureTask對象封裝了該Callable對象的call()方法的返回值;

      ③ 使用FutureTask對象作爲Thread對象的target創建並啓動新線程;

      ④ 調用FutureTask對象的get()方法來獲得子線程執行結束後的返回值。

      舉例說明:

public class MyThread implements Callable<Integer>//泛型
{
	public static void main(String[] args)
	{
		MyThread m1 = new MyThread();//創建Callable對象
		//使用FutureTask來包裝Callable對象
		FutureTask<Integer> task = new FutureTask<Integer>(m1);
		Thread t1 = new Thread(task,"有返回值的線程");
		t1.start();//啓動線程
		//獲取線程返回值
		try
		{
			System.out.println("子線程的返回值:"+task.get());
		}
		catch (Exception ex)
		{
			ex.printStackTrace();
		}
	}
	public Integer call()//返回值類型爲Integer
			     //泛型在集合框架部分會詳細介紹
	{
		int i = 0;
		for ( ; i<100 ; i++ )
		{
			System.out.println(Thread.currentThread().getName()+"  "+i);
		}
		return i;//call()可以有返回值
	}
}

      其實,創建Callable實現類與創建Runnable實現類沒有太大區別,只是Callable的call()方法允許聲明拋出異常,而且允許帶返回值。

      3.4 三種創建線程方法的對比

      由於實現Runnable接口和實現Callable接口創建新線程方法基本一致,這裏我們姑且把他們看作是同一類型;這種方式同繼承Thread方式相比較,優劣分別爲:

      1.採用實現Runnable接口和Callable接口的方式創建多線程

      ① 優點:

            1)實現類只是實現了接口,所以它還可以繼承其他類;

            2)多個線程可以共享一個target,所以適合多線程處理同一資源的模式,從而可以將CPU、代碼和數據分開,較好的體現了面向對象的思想。

      ② 缺點:

            1)編程比較複雜,如果需要訪問當前線程,則必須使用Thread.currentThread()方法。

      2.採用繼承Thread類的方式來創建新線程

      ① 優點:

            1)編寫簡單,如果需要訪問當前線程,則只需要使用this即可。

      ② 缺點:

            1)因爲線程已經繼承了Thread類,所以不能再繼承其他類。

      3.總結

      ① 綜合分析,我們一般採用實現Runnable接口和實現Callable接口的方式來創建多線程。


四、線程的生命週期

      4.1 CPU運行機制簡介

      一般情況下,計算機在一段時間內同時處理着多個進程,然而多個線程的執行順序並不是依次進行的,而是“同時”在進行;其實這是我們產生的“錯覺”。CPU有自己的工作頻率,我們稱之爲主頻;它的意思是CPU單位時間(一般定義爲1s)內處理單元運算的次數。一般來說,頻率越高,CPU的性能就更加優越。正是因爲CPU有着很高的工作頻率,才能在不同進程之間進行快速的切換,纔會給我們造成一種多個任務在同時進行的假象。可以這麼說,計算機在某一時刻只能處理單個進程的某一段運算單元(多核處理器的計算機除外)。

      4.2 線程的狀態

      當新線程被創建後,他並不是一建立就進入運行狀態,也不是一直在運行;由於CPU工作時是在多個進程間不停的切換運行,所以線程會處於多種運行狀態,它們包括:新建、就緒、運行、阻塞和死亡(不同的人可能對線程狀態的分類持不同意見,這裏我們就不深究了)。

      1. 新建和就緒狀態

      當程序使用了new關鍵字創建了一個線程之後,該線程就處於新建狀態。當線程對象調用了start()方法之後,該線程便處於就緒狀態,處於這個狀態的線程並沒有開始運行,而只是表示它可以運行了;不過該線程具體什麼時候開始運行,完全取決於JVM裏線程調度器的調度,這是具有隨機性的,這是一種搶佔式的調度策略。

      需要注意的是:我們啓動一個線程使用的是start()方法,而不是調用線程對象的run()方法。調用start()方法來啓動線程,系統會把該run()方法當作線程執行體來處理,但如果直接調用線程對象的run()方法,則run()方法會直接被執行,並且在run()方法返回之前其他線程無法併發執行;此時系統會把線程對象當成一個普通對象,而run()方法也是一個普通方法,而不是線程執行體。

      2. 運行和阻塞狀態

      如果處於就緒狀態的線程獲得了CPU執行權,開始執行run()方法的線程執行體,則該線程便處於運行狀態。

      阻塞狀態只能由運行狀態進入,而處於阻塞狀態的線程只有重新回到就緒狀態才能開始下一次運行;換句話說:進入阻塞狀態的線程不能直接再運行。當然,運行狀態的線程並不是只能通過“運行—》阻塞—》就緒—》運行”方法才能重新運行,它可以直接從運行狀態恢復到就緒狀態,這裏要用到yield()方法。

      線程進入阻塞狀態的情況有:

      ① 線程調用sleep()方法,主動放棄了可執行資格;

      ② 當前線程想獲取的鎖被另一個線程所持有;

      ③ 線程在等待被喚醒;

      ④ 線程調用了suspend()方法將該線程掛起。(此方法容易產生死鎖,不推薦使用)

      線程從阻塞狀態進入就緒狀態的情況有:

      ① 線程sleep()時間已到;

      ② 線程成功獲取了鎖;

      ③ 線程被喚醒;

      ④ 處於掛起狀態的線程被調用了resume()恢復方法。

      可以看出線程進入阻塞狀態和線程進入就緒狀態的方法或途徑是一一對應的。

      3. 線程死亡狀態

      線程死亡的情況有:

      ① run()或call()方法執行完成,線程正常結束;

      ② 線程拋出異常或者錯誤;

      ③ 調用線程的stop()方法結束線程。(此方法容易導致死鎖,故不推薦使用)

      主線程和其他線程之間的關係:

      一旦我們建立新線程,它將獨立於主線程運行,不受主線程的約束和影響,他們擁有相同的地位;當主線程結束時,其他線程不會受其影響而結束。後面會介紹另外一種線程—後臺線程,只要前臺線程全部結束,後臺線程也會自動結束;後臺線程充當的是輔助前臺線程的角色,所以後臺線程也叫“守護線程”。

     爲了測試某線程是否已經死亡,可以調用其isAlive()方法,當線程處於就緒、運行和阻塞三種狀態時,方法返回值爲true,當線程處於其他兩種狀態時,此方法返回值爲false。

     需要注意的是:

      ① 不要試圖對一個已經死亡的線程調用start()方法,否則會拋出“IllegalThreadStateException”異常;

      ② 不要試圖對一個線程進行多次start()方法調用,否則也會拋出“IllegalThreadStateException”異常

      4. 線程狀態轉換關係

      關於線程多個狀態之間的轉換關係,可以用以下轉換圖來表示:線程狀態轉換關係

五、控制線程

      5.1 join線程

      Thread提供線程“插隊”的方法,就是讓一個線程等待另一個線程完成的方法-----join()方法,目的是讓當下線程等待插入線程運行完成後再繼續運行。它一般用於將大問題劃分成許多小問題,每個小問題用一個小線程完成;這有點像“坐公交車”,公共汽車就是主線線程,乘客就是插入輔助小線程,小線程在“某站”上車,待到目的地就下車,許多小線程爲完成自己的目的在特定時間插入又在特定時間結束。

      舉例說明:

public class JoinThreadTest extends Thread//也可通過實現接口來定義
{
	public JoinThreadTest(String name)
	{
		super(name);
	}
	public void run()
	{
		int i=0;
		for ( ; i<200 ; i++ )
		{
			System.out.println(getName()+"--第--"+i+"--次");
			//由於這裏是繼承Thread類,所以可以直接使用getName()方法來獲取線程名稱
		}
	}
	public static void main(String[] args) throws Exception
	{
		for ( int a = 0 ; a<200 ; a++ )
		{
			if (a==20)
			{
				JoinThreadTest jt1 = new JoinThreadTest("插入線程");
				jt1.start();//啓動子線程
				jt1.join();
			}
			System.out.println(Thread.currentThread().getName()+"--第--"+a+"--次");
		}
	}
}
     上例中共有兩個線程存在,分別是主線程和新建線程jt1,由於虛擬機首先從main()主函數開始讀取,所以主函數開始執行,等到變量a等於20時,開始執行if內部代碼塊,此段代碼新建一個線程jt1並啓動該線程。由於jt1線程使用了join()方法,則主線程會等待jt1線程執行完成後才能繼續執行。需要指出的是,通過繼承Thread()類建立新線程,獲取線程名稱可以直接使用getName()方法,但是由於此方法是非靜態方法,所以在主函數執行體中獲取主線程名稱不能直接使用getName()方法,必須使用完整的獲取線程名稱的方法-----Thread.currentThread().getName(),否則會報錯。

      join()方法有一定的靈活性。由於它的“強制性”,我們在調用此方法後插入線程需要執行完成後原線程才能繼續執行,但是有的時候我們並不需要這樣的效果,我們可能希望設定插入線程執行一定的時間然後返回原線程繼續執行。join()方法的重載形式便應運而生了:

      ① join():等待被插入的線程執行完成;

      ② join(long millis):等待被插入的線程的時間最長爲millis毫秒。

      對於第二種方法,會出現兩種情況:如果在特定時間內插入線程提前完成,則原線程還是需要等待直到特定時間後才能繼續執行;第二種情況是如果插入線程在特定的時間內沒有完成執行任務,則原線程不再等待並開始繼續執行,如此原線程和插入線程又處於並列運行狀態。

      還有另外一種重載形式,但是對時間精度要求過高,幾乎沒有“用武之地”,這裏就不細說了。

      5.2 後臺線程

      顧名思義,後臺線程就是在後臺運行的線程,它是爲其他線程提供服務的,所以後臺線程也叫做“守護線程”。Java的垃圾回收線程就是典型的後臺線程。

      後臺線程較爲特殊,如果所有的前臺線程都死亡,則後臺線程也會隨之自動死亡;就如無本之木,沒有了實際的意義和存在的必要。

      調用Thread對象的setDaemon(true)方法可以將指定線程設置成後臺線程。

      舉例說明:

public class DaemonThreadTest extends Thread
{
	static int i=0;
	public void run()
	{
		for ( ; i<100 ; i++ )
		{
			System.out.println(getName()+"----"+i);
		}
	}
	public static void main(String[] args) 
	{
		DaemonThreadTest dtt = new DaemonThreadTest();
		//設置此線程爲後臺線程
		dtt.setDaemon(true);
		dtt.start();
		int a=0;
		while (a<10)
		{
			System.out.println(Thread.currentThread().getName()+"~~~~"+a);
			a++;
		}
		if (i<99)
		{
			System.out.println("後臺線程dtt沒有執行完成就退出了!"+i);
		}
	}
}
        筆者在運行以上代碼的時候會出現想要的結果:主線程main執行完成後,新建線程Thread-0還沒有執行完,最後if語句中的文字”後臺線程dtt沒有執行完就退出了!“輸出;可以證明輔助線程在主線程執行完成後就隨之死亡,哪怕自己還沒有執行完成。但是筆者在運行上述代碼的時候發生了一件看似詫異的事情:if語句輸出的i的值比run()方法裏的i值要小一點,這其實是容易理解的----前臺線程死亡後,JVM會通知後臺線程死亡,但是從後臺線程接收指令並做出反應需要一定的時間,所以導致run()方法在這個時間差裏繼續運行,才導致了兩個i值不同。

      在這裏筆者要重申一點:由於上述代碼中選用的i變量範圍較小,故有的時候可能看到的情況是dtt線程執行完成了,這並不是代碼錯誤了,而是由於執行內容少代碼瞬間就執行完成了,這是由於處理器性能和隨機性決定的。如果我們把變量i的範圍調整到1000,出現想要結果的可能性就會大很多。

      Thread類提供了一個判斷線程是否爲後臺線程的方法----isDaemon()方法。

      需要指出的是,前臺線程如果創建子線程依舊默認是前臺線程;同理,後臺線程創建的子線程默認是後臺線程。此外,setDaemon(true)必須在start()方法之前調用,否則會引發IllegalThreadStateException異常。

      5.3 線程等待---sleep()

      如果需要讓當前正在執行的線程暫停一段時間,並進入阻塞狀態,則可以通過調用Thread類的靜態方法sleep()來實現,sleep()方法主要形式爲:

<span style="font-family:Microsoft YaHei;font-size:12px;"> static void sleep(long millis);</span>
      括號裏的參數表示的是線程等待的時間。當線程進入等待狀態,在暫停時間範圍內線程無法”提前“開始執行,哪怕系統中沒有其他線程運行。下面的例子將說明這一點:

public class SleepTest
{
	public static void main(String[] args) throws Exception
	{
		for (int a=0 ;a<200 ; a++ )
		{
			System.out.println("a的值是: "+a);
			if (a==50)
			{
				Thread.sleep(2000);
				//括號中參數的單位是毫秒
			}
		}
	}
}
         運行上面代碼我們可以發現,程序中只有一個線程----主線程,當for循環執行到a等於50的時候會停頓兩秒然後再接着執行直到進程結束。

      5.4 線程讓步----yield()

      其實yield()方法同sleep()方法比較類似,它們的共同點就是放棄當前執行權。但是它們也有明顯的區別:sleep()方法是讓線程放棄當前執行權並轉入阻塞狀態,而yield()方法是讓當前線程放棄執行權後進入就緒狀態;此外,前者是規定了線程等待的具體時間,而後者只是讓當前線程暫停一下,讓線程調度器重新調度,完全可能發生的情況是:當某個線程調用了yield()方法暫停後,線程調度器又將其調度出來重新執行,而期間沒有其他線程插入。

      需要指出的是,某個線程執行了yield()方法後,只有優先級大於或等於當前線程優先級的線程纔會獲得執行機會,等待會介紹了設置線程優先級後筆者會用例子加以說明。

      總結兩方法的異同,sleep()方法和yield()方法區別如下:

      ① sleep()方法暫停當前線程後將執行權讓出給其他線程,而yield()方法只會把執行權讓給優先級大於或等於自己優先級的線程;

      ② sleep()方法將當前線程轉入阻塞狀態,而yield()方法則把當前線程轉入就緒狀態;

      ③ sleep()方法聲明拋出了InterruptedException異常,所以調用sleep()方法時需要對異常進行相應,要麼處理要麼拋出,而yield()方法沒有聲明拋出任何異常;

      ④ sleep()方法比yield()方法有更好的移植性,通常不建議使用yield()方法來控制併發線程的執行。

      5.5 改變線程的優先級

      簡單的說,線程有一定的優先級,優先級從1到10不等;而主線程和新建線程默認優先級爲普通,用數字表示就是優先級爲5。優先級越高,或的執行權的可能也就越大。Thread類提供了setPriority(int newPriority)、getPriority()方法來設置和返回指定線程的優先級;一般情況下,線程優先級如果用數字表示相差不大的情況下效果不是很明顯,而且由於用數字表示優先級移植性不佳,故我們一般只取三種優先級,並賦予特殊的名稱:

      ① MAX_PRIORITY:其值是10;

      ② MIN_PRIORTY:其值是1;

      ③ NORM_PRIORITY:其值是5。

      結合yield()方法和線程優先級知識,筆者舉例加以鞏固:

public class YieldTest extends Thread
{
	public YieldTest(String name)
	{
		super(name);
	}
	static int a=0;
	public void run()
	{
		for ( ; a<100 ; a++ )
		{
			System.out.println(getName()+"----"+a);
			if (a==50)
			{
				Thread.yield();
			}
		}
	}
	public static void main(String[] args) 
	{
		YieldTest yt1 = new YieldTest("高級線程");
		yt1.setPriority(Thread.MAX_PRIORITY);
		yt1.start();
		YieldTest yt2 = new YieldTest("低級線程");
		yt2.setPriority(Thread.MIN_PRIORITY);
		yt2.start();
	}
}
         上述代碼共創建了兩個新線程,兩個線程共用一個變量a;當運行以上代碼時可以清楚的看見,在for循環100次的執行過程中,線程yt1(也就是高級線程)獲得執行的次數要多餘yt2(也就是低級線程)所執行的次數。此外,由於yield()方法的特殊性,我們幾乎感覺不到調用了yield()方法帶來的線程切換。


六、線程同步

      6.1 線程安全問題分析

      使用多線程可以提高進程的執行效率,但是它也伴隨着一定的風險;這是由系統的線程調度具有一定的隨機性造成的,我們首先通過一個大家耳熟能詳的例子來說明多線程引發的同步問題----銀行取錢。

      我們按照生活中正常的取、存錢操作編寫如下代碼:

public class DrawTest
{
	public static void main(String[] args)
	{
		//創建賬戶
		Account acct=new Account("公共賬戶",1000);
		//模擬兩個線程對同一個賬戶取錢
		new DrawThread("客戶甲",acct,800).start();//匿名對象
		new DrawThread("客戶乙",acct,800).start();
	}
}
class Account
{
	//建立並封裝用戶編號和賬戶餘額兩個變量
	private String number;
	private double balance;
	//建立構造器進行初始化
	public Account(String number,double balance)
	{
		this.number = number;
		this.balance = balance;
	}
	public void setNumber(String number)
	{
		this.number=number;
	}
/*
        public void setBalance(double balance)
	{
		this.balance=balance;
	}
*/
        public String getNumber()
	{
		return number;
	}
	public double getBalance()
	{
		return balance;
	}
	//爲了判斷用戶是否是同一個用戶,我們重寫hashCode()和equals()方法來進行判斷
	public int hashCode()
	{
		return number.hashCode();
	}
	public boolean equals(Object obj)
	{
		if (this==obj)
		{
			return true;
		}
		if (obj!=null&&obj.getClass()==Account.class)
		{
			Account target = (Account)obj;
			return target.getNumber().equals(number);
		}
		else 
			return false;
	}
}
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()
	{
		if (account.getBalance()>=drawAmount)//判斷餘額是否大於取錢數
		{
			System.out.println(getName()+"取錢成功!"+drawAmount);
			try
			{
				Thread.sleep(10);
			}
			catch (InterruptedException ex)
			{
				ex.printStackTrace();//打印異常信息
			}
			//修改餘額
			account.setBalance(account.getBalance()-drawAmount);
			System.out.println("餘額爲:"+account.getBalance());
		}
		else 
		{
			System.out.println(getName()+"取錢失敗,餘額不足!");
		}
	}
}
         運行上面代碼會發現不符合實際的情況發生:賬戶餘額只有1000卻取出了1600,而且賬戶餘額出現了負值,這不是銀行希望的結果。這種滑稽的錯誤是因爲線程調度的不確定性,run()方法的方法體不具有同步安全性;程序中有兩個併發線程在修改Account對象。

      6.2 同步代碼塊

      由銀行取錢“風波”可以瞭解到,當有兩個進程併發修改同一個文件時就有可能造成異常。爲了解決這個問題,Java的多線程支持引入了同步監視器來解決這個問題,使用同步監視器的通用方法就是同步代碼塊,同步代碼塊的語法格式如下:

<span style="font-family:Microsoft YaHei;font-size:12px;">       synchronized (對象)
	   {
		   需要被同步的代碼;
	   }
</span>
         上面代碼中,synchronized後括號中的對象就是同步監視器,線程在執行同步代碼塊之前需要先獲得同步監視器的鎖。同步代碼塊的同步監視器爲對象,我們一般選用Object類來創建對象,這個對象就是鎖。
<span style="font-family:Microsoft YaHei;font-size:12px;">       Object obj = new Object();</span>
         任何時刻只能有一個線程獲得對同步監視器的鎖定,當同步代碼塊執行完成後,該線程會釋放鎖。如此,我們將銀行取錢問題的代碼稍加修改,就能達到我們想要的運算結果:

	public void run()
	{
		synchronized(account)
		{
			if (account.getBalance()>=drawAmount)//判斷餘額是否大於取錢數
			{
				System.out.println(getName()+"取錢成功!"+drawAmount);
				try
				{
					Thread.sleep(10);
				}
				catch (InterruptedException ex)
				{
					ex.printStackTrace();//打印異常信息
				}
				//修改餘額
				account.setBalance(account.getBalance()-drawAmount);
				System.out.println("餘額爲:"+account.getBalance());
			}
			else 
			{
				System.out.println(getName()+"取錢失敗,餘額不足!");
			}
		}
	}
        上面程序使用synchronized將run()方法裏的方法體修改爲同步代碼塊,該同步代碼塊的同步監視器就是account對象,這樣的做法符合“加鎖-修改-解鎖”的邏輯;通過這種方式可以保證併發線程在任意時刻只有一個線程可以進入修改共享資源的代碼區,從而保證了線程的安全性。

      6.3 同步函數

      同步函數就是使用synchronized關鍵字修飾的方法,同步函數的同步監視器是this,也就是調用方法的對象本身。需要指出的是,synchronized關鍵字可以修飾方法和代碼塊,但是不能修飾構造器、屬性等。

      同步的前提:

      ① 必須要有兩個或兩個以上的線程;

      ② 必須是多個線程使用同一個鎖;

      ③ 必須保證同步中只有一個線程在運行;

      爲了減少保證線程安全而帶來的負面影響(例如更加消耗資源),程序可以進行優化和控制:

      ① 只對那些會改變競爭資源的方法或代碼進行同步;

      ② 如果可變類有兩種運行環境:單線程和多線程環境,則應該爲該可變類提供兩種版本,即線程安全版本和線程不安全版本。

      如果同步函數爲靜態同步,則其同步監視器就是:類名.class。

      6.4 同步在單例設計模式中的應用

      單例設計模式,顧名思義就是一個類只能創建一個對象;單例設計模式一共分爲兩種,分別是餓漢式和懶漢式。由於餓漢式在一開始就建立了對象並初始化提供了調用的方法,故餓漢式在多線程情況下沒有安全隱患,不會引起多線程異常;而懶漢式由於需要對對象是否爲空進行判斷,所以可能導致多線程異常。

      餓漢式單例設計模式:

	   class single
	   {
		   private static single s = new single;
		   private single(){}
		   public static single getInstance()
		   {
				return s;
		   }
	   }
        懶漢式單例設計模式:
	   class single
	   {
		   private static single s = null;
		   private single(){}
		   public static single getInstance{}
		   {
				if (s==null)
				{
					synchronized(single.class)
					{
						if (s==null)//二次判斷
						{
							s=new single();
						}
					}
				}
				return s;
		   }
	   }

      6.5 釋放同步監視器的鎖定

      線程會在如下幾種情況釋放對同步監視器的鎖定:

      ① 當前線程的同步方法或者同步代碼塊執行完畢;

      ② 當前線程的同步方法或者同步代碼塊中遇到break、return終止了該代碼塊或該方法的繼續執行;

      ③ 當前線程的同步方法或者同步代碼塊中遇到未處理的error或Exception,導致了該代碼塊或該方法異常而結束;

      ④ 程序執行了同步監視器對象的wait()方法。

      線程在如下情況下不會釋放同步監視器:

      ① 程序調用Thread.sleep()、Thread.yield()方法來暫停當前線程的執行;

      ② 其他線程調用了當前線程的suspend()方法將當前線程掛起。

      6.6 同步鎖

      從Java5開始,Java提供了一種功能更加強大的線程同步機制-----通過顯示定義同步鎖對象來實現同步,在這種機制下,同步鎖採用Lock對象充當。

      Lock提供了比synchronized方法和synchronized代碼塊更廣泛的鎖定操作,Lock實現了允許更靈活的結構,Lock是控制多個線程對共享資源進行訪問的工具。通常,鎖提供了對共享資源的獨佔訪問,每次只能有一個線程對Lock對象加鎖,線程開始訪問共享資源之前應先獲得Lock對象。在實現線程安全的控制中,比較常用的是ReentrantLock(可重入鎖),使用該Lock對象可以顯示的加鎖、釋放鎖。

      舉例說明:

class LockTest 
{
	//用ReentrantLock類定義鎖對象
	private final ReentrantLock lock= new ReentrantLock();
	//將此鎖應用在需要保證線程安全的方法上
	public void test()
	{
		//加鎖
		lock.lock();
		try
		{
			//需要保證線程安全的代碼
		}
		catch (Exception e)
		{
			System.out.println("發生錯誤信息,請重新確認代碼!");
		}
		finally
		{
			//釋放鎖
			lock.unlock();
		}
	}
}
        使用ReentrantLock對象來進行同步,加鎖和釋放鎖出現在不同的作用範圍內時,通常建議使用finally塊來確保在必要時釋放鎖。前面介紹的銀行存取錢例子中,可以使用ReentrantLock類定義的鎖來保證線程安全,而且相較於synchronized代碼塊或synchronized方法更加簡潔方便。

      使用Lock與使用同步方法有點類似,只是使用Lock時顯示使用Lock對象作爲同步鎖,而使用同步方法時系統隱式使用當前對象作爲同步監視器。此外,Lock提供了同步方法和同步代碼塊所沒有的其他功能:

      ① 用於非結構塊的tryLock()方法;

      ② 試圖獲取可中斷鎖的lockInterruptibly()方法;

      ③ 獲取超時失效鎖的tryLock(long,TimeUnit)方法。

      ReentrantLock鎖具有可重入性,一個線程可以對已被加鎖的ReentrantLock鎖再次加鎖,ReentrantLock對象會維持一個計數器來追蹤lock()方法的嵌套使用,線程在每次調用lock()加鎖後,必須顯示調用unlock()來釋放鎖,所以一段被鎖保護的代碼可以調用另一個被相同鎖保護的方法。

      6.7 死鎖

      當兩個線程相互等待對方釋放同步監視器時就會發生死鎖,Java虛擬機沒有監測,也沒有採取措施來處理死鎖情況,所以多線程編程時應採取措施避免死鎖出現;一旦出現死鎖,整個程序既不會發生任何異常,也不會給出提示,只是所有線程處於阻塞狀態,無法繼續。

      舉例說明:

class  A
{
	public synchronized void foo(B b)
	{
		System.out.println("當前線程名稱爲:"+Thread.currentThread().getName()+"進入了A實例的foo方法");
		try
		{
			Thread.sleep(200);
		}
		catch (InterruptedException e)
		{
			e.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 e)
		{
			e.printStackTrace();
		}
		System.out.println("當前線程名稱爲:"+Thread.currentThread().getName()+"企圖調用A實例的last方法");
		a.last();
	}
	public synchronized void last()
	{
		System.out.println("進入了B類的last方法內部");
	}
}
public class DeadLockTest implements Runnable
{
	A a=new A();
	B b=new B();
	public void init()
	{
		Thread.currentThread().setName("主線程");
		a.foo(b);
		System.out.println("進入了主線程之後");
	}
	public void run()
	{
		Thread.currentThread().setName("副線程");
		b.bar(a);
		System.out.println("進入了副線程之後");
	}
	public static void main(String[] args)
	{
		DeadLockTest d1=new DeadLockTest();
		new Thread(d1).start();
		d1.init();
	}
}

      6.8 線程通信

      線程間通信方法:

      ① wait():導致當前線程等待,括號中可以定義等待時間,若不定義等待時間,則需要等待至被喚醒;

      ② notify():喚醒在此同步監視器上等待的單個線程,如果多個線程在等待,則隨機喚醒其中一個線程;

      ③ notifyAll():喚醒在此同步監視器上的所有線程。

      需要注意的是,以上三個方法並不屬於Thread類,而是屬於Object類。對於使用synchronized修飾的同步方法,因爲該類的默認實例(this)就是同步監視器,所以可以在同步方法中直接調用這三個方法;而同步代碼塊中同步監視器是synchronized後括號裏的對象,所以必須使用該對象調用這三個方法。

      如果程序不是通過synchronized關鍵字來保證同步,而是使用Lock對象來保證同步,則系統中不存在隱式的同步監視器,也就不能使用上述三個方法來進行線程間通信了,Java提供了一個Condition類來保持協調,使用Condition可以讓那些已經得到Lock對象卻無法繼續執行的線程釋放Lock對象,Condition對象也可以喚醒其他處於等待的線程。Condition實例被綁定在一個Lock對象上,要活的特定Lock實例的Condition實例,調用Lock對象的newCondition()方法即可。同樣的,Condition類提供瞭如下3個方法:

      ① await():類似於wait()方法,導致當前線程等待;

      ② signal():喚醒在此Lock對象上等待的單個線程;

      ③ signalAll():喚醒在此Lock對象上等待的所有線程。   

      這裏還是以取錢的例子來說明:

public class Account
{
	private final Lock lock=new ReentrantLock();
	private final Condition cond=lock.newCondition();
	private String accountNo;
	private double balance;
	private boolean flag=false;
	public Account(){}
	public Account(String accountNo,double balance)
	{
		this.accountNo=accountNo;
		this.balance=balance;
	}
	public void setAccountNo(String accountNo)
	{
		this.accountNo=accountNo;
	}
	public String getAccountNo(String accountNo)
	{
		return accountNo;
	}
	public double getBalance(double balance)
	{
		return balance;
	}
	public void draw(double drawAmount)
	{
		lock.lock();
		try
		{
			if (!flag)
			{
				cond.await();
			}
			else
			{
				System.out.println(Thread.currentThread().getName()+"取錢:"+drawAmount);
				balance -= drawAmount;
				System.out.println("賬戶餘額爲:"+balance);
				flag=false;
				cond.signalAll();
			}
		}
		catch (InterruptedException e)
		{
			e.printStackTrace();
		}
		finally
		{
			lock.unlock();
		}
	}
	public void deposit(double depositAmount)
	{
		lock.lock();
		try
		{
			if (flag)
			{
				cond.await();
			}
			else
			{
				System.out.println(Thread.currentThread().getName()+"存款:"+depositAmount);
				balance+=depositAmount;
				System.out.println("賬戶餘額爲:"+balance);
				flag=true;
				cond.signalAll();
			}
		}
		catch (InterruptedException e )
		{
			e.printStackTrace();
		}
		finally
		{
			lock.unlock();
		}
	}
}

七、其他內容

      以上是關於多線程機制的基礎內容,除此之外,還有關於"線程組和未處理的異常"、"線程池"等基礎概念及內容,在這裏筆者就不詳細闡述了,讀者打好多線程機制的基礎後,可以自行學習這些拓展內容。

      多線程處理機制,就介紹到這裏啦!


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