問題
在前面的文章中,所涉及的線程大多都是獨立的,而且異步執行。也就是說每個線程都包含了運行時自身所需要的數據或方法,而不需要外部的資源或方法,也不必關心其他線程的狀態或行為。但是,有時候在進行多線程的程序設計中需要實現多個線程共享同一段代碼,從而實現共享同一個私有成員或類的靜態成員的目的。這時,由於線程和線程之間互相競爭CPU資源,使得線程無序地訪問這些共享資源,最終可能導致無法得到正確的結果。例如,一個多線程的火車票預訂程序中將已經預訂過的火車票再次售出,這是由於當該車票被預訂以後沒有及時更新數據庫中的信息而導致在同一時刻購買該火車票的另一乘客也將其預訂。這一問題通常稱為線程安全問題,為了解決這個問題,必須要引入同步機制,那麼什麼是同步,如何實現在多線程訪問同一資源的時候保持同步呢?
解決思路
首先分析一個多線程的程序,在這個程序中各線程之間共享同一數據資源,從而模擬出火車站訂票系統的處理程序,但是最後程序卻出現了意料不到的結果。這個火車站訂票系統不同步的模擬程序代碼如下。
// 例4.5.1 NoSynchronizeDemo.Java
class SaleTickets implements Runnable
{
private String ticketNo = "100750"; // 車票編號
private int ticket = 1; // 共享私有成員,編號為100750的車票數量為1
public void run()
{
System.out.println(Thread.currentThread().getName()+" is saling
Ticket "+ticketNo); // 當前系統正在處理訂票業務
if(ticket>0)
{
try // 休眠0-1000毫秒,用來模擬網絡延遲
{
Thread.sleep((int)(Math.random()*1000));
}catch(InterruptedException e){
e.printStackTrace();
}
ticket=ticket-1; // 修改車票數據庫的信息
// 顯示當前該車票的預訂情況
System.out.println("ticket's amount is left: "+ticket);
}
else
// 顯示該車票已被預訂
System.out.println("Sorry,Ticket "+ticketNo+" is saled");
}
}
class NoSynchronizeDemo // 創建兩個線程模擬兩個訂票系統的訂票過程
{
public static void main(String[] args)
{
SaleTickets m = new SaleTickets();
Thread t1 = new Thread(m,"System 1");
Thread t2 = new Thread(m,"System 2");
t1.start();
t2.start();
}
}
程序的要求是創建兩個線程模擬兩個預訂票子系統,這兩個訂票子系統將共享同一個數據庫信息,只不過在這裡數據庫中只有一張編號為100750的車票。當該車票在其中一個系統中被預定以後,則不能再被其他訂票系統預訂,並且能夠提示抱歉信息。
運行這個程序,觀察輸出的結果,如圖4.5.1所示:
圖 4.5.1 模擬兩個訂票系統的訂票過程可以發現同一時間有兩個訂票系統在處理編號為100750的車票的預訂請求,結果由於網絡延遲的影響,該車票被預訂了兩次,使得車票的數量變成了負數。出現這一嚴重問題的原因就是因為兩個線程同時進入了共享代碼區域。這個共享代碼區域就是程序中的if…else語句的內容。
當線程t1執行到if(ticket>0)時,立即休眠若干毫秒,而此時線程t2得到執行,也執行到if(ticket>0),此時線程t1休眠結束繼續執行下面的代碼。經過兩個線程的交替執行,最終完成預訂服務,結果就出現了車票被預訂了兩次的嚴重錯誤。
程序中之所以在run()方法中使用sleep()方法,是因為在實際聯網的預訂票系統中確實存在著因為某些原因使得預定過程暫時中止的情況,而這一情況也極有可能發生在執行了if(ticket>0)語句之後,修改數據庫信息之前。那麼,如何避免這種情況發生呢?實際上在預訂票系統中的這種暫時中止,瞬時延遲的情況是很普遍的。因此只能對程序做一些處理,使得一個預訂票操作完全結束以後才能處理下一個預訂票操作,以此來保證數據庫的一致性和操作的正確性。Java提供了這樣的方法,只需要在程序中引入同步機制就可以完全解決這類線程安全問題。
什麼是同步呢?當兩個或多個線程需要訪問同一資源時,它們需要以某種順序來確保該資源某一時刻只能被一個線程使用的方式稱為同步。
要想實現同步操作,必須要獲得每一個線程對象的鎖。獲得它可以保證在同一時刻只有一個線程訪問對象中的共享關鍵代碼,並且在這個鎖被釋放之前,其他線程就不能再進入這個共享代碼。此時,如果還有其他線程想要獲得該對象的鎖,只得進入等待隊列等待。只有當擁有該對象鎖的線程退出共享代碼時,鎖被釋放,等待隊列中第一個線程才能獲得該鎖,從而進入共享代碼區。
Java在同步機制中提供了語言級的支持,可以通過對關鍵代碼段使用synchronized關鍵字修飾來實現針對該代碼段的同步操作。實現同步的方式有兩種,一種是利用同步代碼塊來實現同步,一種是利用同步方法來實現同步。下面將分別介紹這兩種方法,並給出實際的例子。
具體步驟
(1)使用同步代碼塊
為了防止多個線程無序地訪問共享資源,只需將對共享資源操作的關鍵代碼放入一個同步代碼塊中即可。
同步代碼塊的語法形式如下所示:
synchronized(Object)
{
// 關鍵代碼
}
其中,Object是需要同步的對象的引用。當一個線程欲進入該對象的關鍵代碼時,JVM將檢查該對象的鎖是否被其他線程獲得,如果沒有,則JVM把該對象的鎖交給當前請求鎖的線程,該線程獲得鎖後就可以進入花括弧之間的關鍵代碼區域。
對例4.5.1的程序進行修改,得到如下代碼。
// 例 4.5.2 SynchronizeDemo.Java
class SaleTickets implements Runnable
{
private String ticketNo = "100750"; // 車票編號
private int ticket = 1; // 共享私有成員,編號為100750的車票數量為1
public void run()
{
System.out.println(Thread.currentThread().getName()+" is saling
Ticket "+ticketNo); // 當前系統正在處理訂票業務
// 下面同步代碼塊中的代碼為關鍵代碼,用synchronized關鍵字來標識
synchronized(this)
{
if(ticket>0)
{
try{ // 休眠0-1000毫秒,用來模擬網絡延遲
Thread.sleep((int)(Math.random()*1000));
}catch(InterruptedException e){}
ticket=ticket-1; // 修改車票數據庫的信息
System.out.println("ticket is saled by
"+Thread.currentThread().getName()+", amount is: "+ticket);
// 顯示當前該車票的預訂情況
}
else
System.out.println("Sorry "+Thread.currentThread().getName()+",
Ticket "+ticketNo+" is saled"); // 顯示該車票已被預訂
}
}
}
class SynchronizeDemo
{
public static void main(String[] args)
{
SaleTickets m = new SaleTickets();
Thread t1 = new Thread(m,"System 1");
Thread t2 = new Thread(m,"System 2");
t1.start();
t2.start();
}
}
本程序中,線程t1先獲得該關鍵代碼的對象的鎖,因此,當線程t2也開始執行並欲獲得關鍵代碼的對象的鎖時,發現該鎖已被線程t1獲得,只好進行等待。當線程t1執行完關鍵代碼後,會將鎖釋放並通知線程t2,此時線程t2才獲得鎖並開始執行關鍵代碼。運行這個程序,將看到如圖4.5.2所示的結果。
圖4.5.2 使用了同步代碼塊
可以看到幾乎在同一時間兩個系統都獲得了預訂票的指令,但是由於預訂票子系統System1比System2 先得到處理,因此編號為100750的車票就必須先售給在System1提交預定請求的乘客。而在System2提交預定請求的乘客並沒有得到編號為100750的車票,因此系統提示抱歉信息。
這一切正確地顯示都源於引入了同步機制,將程序中的關鍵代碼放到了同步代碼塊中,才使得同一時刻只能有一個線程訪問該關鍵代碼塊。可見,同步代碼塊的引入保持了關鍵代碼的原子性,保證了數據訪問的安全。
程序中用到了this來作為同步的參數,這種方式會將整個對象都上鎖,因為this代表了當前線程對象。因此同一時刻只能有一個線程訪問共享資源。不過,也可以使用虛擬對象來上鎖:
class SaleTickets implements Runnable
{
…
String str = " "; // 創建一個空字符串來作為虛擬對象上鎖
public void run()
{
…
synchronized(str) // 同步代碼塊中的代碼為關鍵代碼
{
…
}
}
}
當需要判斷關鍵代碼的鎖是否被某一線程所獲取時,可以使用Thread類的靜態布爾型方法holdsLock(Object o)來進行測試,其中參數o是與判斷的關鍵代碼鎖所對應的對象的引用。如果某一線程已進入同步代碼塊或者同步方法,正在訪問該對象的關鍵代碼段,那麼holdsLock()方法將返回一個布爾真值,否則,返回一個布爾假值。
(2)使用同步方法
同步方法和同步代碼塊的功能是一樣的,都是利用互斥鎖實現關鍵代碼的同步訪問。只不過在這裡通常關鍵代碼就是一個方法的方法體,此時只需要調用synchronized關鍵字修飾該方法即可。一旦被synchronized關鍵字修飾的方法已被一個線程調用,那麼所有其他試圖調用同一實例中的該方法的線程都必須等待,直到該方法被調用結束後釋放其鎖給下一個等待的線程。將例4.5.2的程序作一些改動得到下面的代碼。
// 例 4.5.3 SynchronizeDemo2.Java
class SaleTickets implements Runnable
{
private String ticketNo = "100750"; // 車票編號
private int ticket = 1; // 共享私有成員,編號為100750的車票數量為1
public void run()
{
System.out.println(Thread.currentThread().getName()+" is saling
Ticket "+ticketNo); // 當前系統正在處理訂票業務
sale();
}
public synchronized void sale() // 同步方法中的代碼為關鍵代碼
{
if(ticket>0)
{
try // 休眠0-1000毫秒,用來模擬網絡延遲
{
Thread.sleep((int)(Math.random()*1000));
}catch(InterruptedException e){
e.printStackTrace();
}
ticket=ticket-1; // 修改車票數據庫的信息
System.out.println("ticket is saled by
"+Thread.currentThread().getName()+", amount is: "+ticket);
// 顯示當前該車票的預訂情況
}
else
System.out.println("Sorry "+Thread.currentThread().getName()+",
Ticket "+ticketNo+" is saled"); // 顯示該車票已被預訂
}
}
class SynchronizeDemo2
{
public static void main(String[] args)
{
SaleTickets m = new SaleTickets();
Thread t1 = new Thread(m,"System 1");
Thread t2 = new Thread(m,"System 2");
t1.start();
t2.start();
}
}
運行程序,可以發現結果顯示和例4.5.2完全相同,這就說明了利用同步方法也可以實現針對關鍵代碼的同步訪問。那麼這兩個方法有什麼區別呢?
簡單地說,用synchronized關鍵字修飾的方法不能被繼承。或者說,如果父類的某個方法使用了synchronized關鍵字來修飾,那麼在其子類中該方法的重載方法是不會繼承其同步特征的。如果需要在子類中實現同步,應該重新使用synchronized關鍵字來修飾。
在多線程的程序中,雖然可以使用synchronized關鍵字來修飾需要同步的方法,但是並不是每一個方法都可以被其修飾。比如,不要同步一個線程對象的run()方法,因為每一個線程運行都是從run()方法開始的。在需要同步的多線程程序中,所有線程共享這一方法,由於該方法又被synchronized關鍵字所修飾,因此一個時間內只能有一個線程能夠執行run()方法,結果所有線程都必須等待前一個線程結束後才能執行。
顯然,同步方法的使用要比同步代碼塊顯得簡潔。但在實際解決這類問題時,還需要根據實際情況來考慮具體使用哪一種方法來實現同步比較合適。
專家說明
通過本節的學習,解決了多個線程之間共享同一數據資源時發生沖突的問題。解決的方法就是實現同步機制,解決的手段就是將欲訪問的共享資源所在的關鍵代碼封裝到一個同步代碼塊或者同步方法中,使所有針對這一共享資源的操作成為互斥操作,這樣就保證了同一時刻只能有一個線程訪問其共享資源,從而保證了數據資源在多個線程之間的一致性和正確性,解決了線程安全問題。
專家指點
現在,雖然已經知道多個線程訪問同一共享資源的時候會發生不同步的問題,但是還應該知道引起不同步的現象的根本原因是什麼。
那麼,到底是什麼引起了不同步呢?這個問題可以從兩個方面來回答。第一,在一個單處理器的計算機中,線程的執行由一個處理器來調度,由於處理器的時間片調度原則,決定了一個線程僅能執行一定的時間。這樣,在其他時間裡,其他線程也可以執行。第二,在一個單處理器的計算機中,一個線程的執行時間可能沒有足夠長到其他線程開始執行關鍵代碼前執行完自己的關鍵代碼。
解決這個問題的辦法就是將多個線程間所共享的關鍵代碼封裝起來,放到一個同步代碼塊或者同步方法中,把其當作一個原子操作來實現就可以了,因為原子操作是安全的。那麼什麼是原子操作呢?原子操作就是計算機在執行指令過程中不可分割的最小指令單元。比如聲明變量的操作、給變量直接賦值的操作,這些都是原子操作,這些操作是安全的。在多線程的程序中,一旦將某個關鍵代碼封裝成一個原子操作,那麼對它們的操作就不會存在不同步的情況。
對於原子操作的要求是很苛刻的,有些讀者可能以為像i=i+1這樣的操作也是原子級的操作,便隨意的在多個線程所共享的關鍵代碼中使用,殊不知這樣做也是很危險的。因為雖然i是int型的,而這個類型的變量也是個原子型的變量,但是對於i=i+1這樣的操作,通過右圖的執行過程就可以看到,整個操作在中間部分還是能夠被中斷的。
在執行過程中,能夠發現,第三步到第五步之間的操作是可以在多線程的程序中被打斷的。
解決的辦法就是將這些操作封裝在同步代碼塊中或者同步方法中。例如:
public synchronized add(){i=i+1; }
或者
synchronized(Object){ i=i+1;}
有些Java的數據類型也不能保證原子性的特點,因而也會出現不同步的情況,比如長整型和雙精度浮點型。JVM是32位的,它只能用臨近的兩個32位的步長訪問一個64位的長整型數據或者一個64位的雙精度浮點型數據。這樣一來,就有可能出現問題。
比如,一個線程訪問了第一個32位以後被阻塞,這時其他線程執行,當該線程再次得到執行去完成訪問第二個32位時,這個64位的數據可能已經被其他線程改變,結果可想而知,數據處理發生了錯誤。解決這個問題的辦法就是在long和double變量之前使用volatile修飾符。因為volatile修飾符可以使其修飾的變量保持本地的拷貝與主內存一致,因此有時候還將使用volatile修飾符修飾變量的方法稱為“變量的同步”。
使用同步機制使得一個完整的操作被封裝起來,就像一個盒子,一次只能有一個線程進入,這樣做有助於防止對象的狀態受到破壞,提高了安全性。但是,使用同步機制也有其不足的地方,比如,同步的時間代價很高。這是因為,同步機制中線程之間在獲得和釋放互斥鎖的時候需要花費相當的時間,這些時間的消耗會降低程序的性能。因此,當問題中可以確定不需要使用同步機制來解決時,一定不要引入同步方法或者同步代碼塊。
其實,大部分同步是可以避免的,比如,只訪問本地變量(即在方法體內聲明的變量),而不操作類成員,也不去修改外部對象的方法,就不需要使用synchronized關鍵字來修飾。對於那些非原子級的對象,也可以通過將其聲明為final類型來保證該對象不可改變,從而避免同步。當然,並不是要為了避免同步而把所有的對象都聲明為final,因為有些對象本身就是原子級的,比如一個String對象就是原子級對象。此外,還可以通過將一些對象聲明為不變性(volatile)對象來避免同步,比如當程序中需要頻繁地只讀訪問一個對象時,將其聲明為一個不可變對象就可以很好地避免同步發生。
相關問題
在處理線程同步時還需要注意一個問題,那就是死鎖。死鎖是多線程程序最常見的問題之一,那麼什麼是死鎖,為什麼會發生死鎖呢?
死鎖問題:即由於兩個或多個線程都無法得到相應的鎖而造成的兩個線程都等待的現象。這種現象主要是因為相互嵌套的synchronized代碼段而造成。
例如,在某一多線程的程序中有兩個共享資源A和B,並且每一個線程都需要獲得這兩個資源後才可以執行。這是一個同步問題,但是如果沒有合理地安排獲取這些資源的順序,很有可能發生下面的情況:
線程1已經獲取資源A的鎖,由於某種原因被阻塞,此時線程2啟動並獲得資源B的鎖,再去獲得資源A的鎖時發現線程1已經獲取,因此等待線程1釋放A鎖。線程1從阻塞中恢復以後繼續執行,欲獲取資源B的鎖,卻發現B鎖已被線程2獲得,因此也陷入等待。在這種情況下,程序已無法向前推進,在沒有外力的情況下,也不會自動退出,因而造成了嚴重的死鎖問題。來看下面這個程序,在這個程序中,創建了兩個獨立的線程,但是這兩個線程都需要同時擁有資源A和資源B方可執行,由於資源獲取的順序不合理,從而造成了死鎖的發生。
// 例4.5.4 DeadLockDemo.Java
class ShareString // 封裝了兩個共享資源
{
public static String str1="A";
public static String str2="B";
}
class SynchDeadLock1 extends Thread
{
SynchDeadLock1(String name)
{
super(name);
}
public void run()
{
synchronized(ShareString.str1)
{
System.out.println(getName()+" have resource A");
System.out.println(getName()+" is waitiing resource B...");
try
{
Thread.sleep(1000);
}catch(InterruptedException e){ }synchronized(ShareString.str2)
{
System.out.println(getName()+" have resource B");
System.out.println("Result is "+ShareString.str1
+ShareString.str2);
}
}
}
}
class SynchDeadLock2 extends Thread
{
SynchDeadLock2(String name)
{
super(name);
}
public void run()
{
synchronized(ShareString.str2)
{
System.out.println(getName()+" have resource B");
System.out.println(getName()+" is waitiing resource A...");
try
{
Thread.sleep(500);
}catch(InterruptedException e){ }synchronized(ShareString.str1)
{
System.out.println(getName()+" have resource A");
System.out.println("Result is "+ShareString.str2
+ShareString.str1);
}
}
}
}
class DeadLockDemo
{
public static void main(String[] args) // 創建兩個獨立的線程
{
SynchDeadLock1 s1 = new SynchDeadLock1("Mission 1");
SynchDeadLock2 s2 = new SynchDeadLock2("Mission 2");
s1.start();
s2.start();
}
}
程序運行的結果如圖4.5.3所示:
圖4.5.3 死鎖演示
可以看到,兩個任務由於獲取資源的順序不合理,從而造成了互相不能推進的局面,也就是這裡所說的死鎖。欲打破這種局面,需要按Ctrl+C來終止程序。為了避免這個問題,可以改善獲取資源的順序,以合理的方式獲取資源。但是,有時候,現實問題又要求必須按照這種順序獲取資源,即線程1必須先獲取資源A後才可以獲取資源B,而線程2又必須先獲取資源B後方可獲取資源A,那麼此時死鎖將不可避免。就語言本身來說,Java尚未直接提供防止死鎖的幫助措施,需要程序員通過謹慎地設計來避免。例如,使用同步封裝器,其原理來自設計模式中的Decorator模式,具體實現請讀者參考有關書籍。此外,也可以通過在程序中盡可能少用嵌套的synchronized代碼段來避免線程死鎖。