要真正理解如何解決線程資源共用衝突的問題,還真有點複雜,但是這個又是線程的精華所在,也是線程中最重要的知識,我要儘力講清楚它,因此內容比較多了,從中篇裡還出了個中篇。
上篇博文的末尾我寫了一段執行個體代碼,想表現線程搶佔資源時候所發生的資源衝突問題,不知道大家真的看明白了那段代碼的意思嗎?反正我對這段代碼琢磨了半天才領悟了其中的含義。這裡我還是先把前面那段代碼貼出來:
package cn.com.sxia;
public class Semaphore implements Invariant {
private volatile int semaphore = 0;
public void acquire(){
++semaphore;
}
public boolean available(){
return semaphore == 0;
}
public void release(){
--semaphore;
}
@Override
public InvariantState invariant() {
int val = semaphore;
if (val == 0 || val == 1){
return new InvariantOK();
}else{
return new InvariantFailure(new Integer(val));
}
}
}
package cn.com.sxia;
public class SemaphoreTester extends Thread {
private volatile Semaphore semaphore;
public SemaphoreTester(Semaphore semaphore){
this.semaphore = semaphore;
setDaemon(true);
start();
}
public void run(){
while(true){
if (semaphore.available()){
yield();
semaphore.acquire();
yield();
semaphore.release();
yield();
}
}
}
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore();
new SemaphoreTester(semaphore);
new SemaphoreTester(semaphore);
new InvariantWatcher(semaphore).join();
}
}
在main函數裡我們建立了兩個SemaphoreTester對象,也就是啟動了兩個線程,兩個線程操作的是同一個Semaphore對象,換句話說操作的是同一個資源了,我們不斷運行main函數監控程式總會列印出一次失敗的資訊,比如下面的資訊:
Invariant violated: -1
我們再看看所寫的代碼,我們改寫下程式,我們沒有啟動兩個線程,只啟動了一個Semaphore線程時候,SemaphoreTester裡的run方法使得Semaphore裡的semaphore的值總不會變成非0或1的數值,那麼監控的程式也就不會報出我們失敗資訊來。那麼到底是什麼原因產生了失敗了?
原因在於一個線程可能從對available()的調用中返回真,但是當此線程調用acquire()時候,第二個線程可能已經調用了acquire()並且增加或者減少了semaphore欄位的值了,此時的InvariantWatcher就可能發現我們的數值違反了我們制定的約束,不能為非0或1的值導致程式被停止了。
大家還要注意:監控程式線程我調用了join()方法,大家還記得這個方法嗎?我在基礎篇上篇裡面介紹過他,加上這個方法會讓前面兩個線程一直執行直到發生了失敗才會調用我們寫的監控程式,join方法讓我們的監控程式不會干擾兩個線程的正常運行。
另外對於volatile關鍵字,這個也不是一句兩句說的清楚的,我在後面會做進一步的解釋的。
上面的執行個體代碼很好的說明了多線程裡資源競爭的難題,我學到這裡,個人覺得根本原因還是線上程調度機制CPU時間片的隨機切換所導致的。
寫上面的執行個體代碼我提到這段代碼是模仿資訊量的程式的簡化版,這個又怎麼理解了?
前面我提到過簡單“訊號量”的概念,它可以看做兩個線程之間的通訊標誌對象。假如訊號量的值為0,則訊號量所監控的資源是可用的,如果非0,則監控資源不可用,那麼其他線程就要等待了,當資源可以被線程使用時候,線程就會增加訊號量的值,該線程然後繼續執行並使用這個監控資源,但是其他線程是不能使用監控資源的。我們的例子就是按這個原理寫出來的,屬性semaphore的初始值是0,這就是說各個線程在初始化狀態下都是被啟用的,線程們的趕緊搶佔資源啊。acquire方法就是給訊號量增加值,release方法釋放訊號量的數值,available方法就是對訊號量值進行判斷,如果值為0,其他線程就可以搶佔資源,不為零其他線程就得等待了。為了很好的示範我寫的程式功能,同時程式裡面還加入了我寫好的監控程式的代碼。
下面我將講述java裡如何來解決資源衝突的方案,答案很多人都知道那就是:synchronized關鍵字了,但是大家使用synchronnied來解決資源衝突時候,我們想過它的原理嗎?java到底是運用什麼樣的演算法解決了資源衝突了?
其實現在所有主流程式解決線程衝突也就是共用資源競爭的問題都是採用一種叫做序列化共用資源的方案。這個方案的內容就是:在給定的時刻裡只准許一個線程訪問共用資源,通常這個是通過在代碼前面加上一段能建立一個鎖的語句,這就保證了在一定時間內只有一個線程運行這段代碼。鎖的作用讓不同的線程之間產生相互排斥的效果,因此這種做法也叫做“互斥量”(mutex).
Java也是採用這樣的方案來解決線程衝突的問題,在java語言裡,java提供了關鍵字synchronized,這個關鍵字為了防止資源衝突提供了內建支援,換句話說,當我們使用了synchronized關鍵字時候就告訴了java語言,你要幫我解決資源衝突的問題了。其實java語言內部,準確的說法應該是java虛擬機器內部就是按照“資訊量”的原理解決了這個難題,而內部的行為很像我們寫的Semaphore類:有一個方法活動訊號量的值,根據值的不同授予線程不同的狀態(例如available方法),有一個方法會用來增加訊號量的值(例如acquire方法),還有一個方法會減少訊號量的值(例如release方法),最後當然也有一個監控程式了例如我們寫的那樣的監控架構。
我們到底如何使用synchronized關鍵字的,這個問題是不是很搞笑了?我覺得有時看起來很簡單的東西裡面所蘊含的精髓可能相當豐富或者這個簡單背後有我們難以理解的高深之處了?因此我要好好談談如何使用synchronized關鍵字的問題,當然synchronized的用法豐富多彩,我這裡只講用synchronized來解決資源共用時候的用法。這裡要聲明下,我對synchronized的理解還是比較有限的,而且這個關鍵字其他的用法我還沒深入研究過,自己也寫得少,假如後面的內容有些說法過於絕對或者不太正確的話還請大家多多包涵了,反正有錯誤希望博友們能及時指出來了。
Synchronized的產生是為瞭解決線程衝突換句話說是共用資源共用的問題。從這個概念裡面我們發現它包含了兩個實體,一個是共用資源,一個是使用共用資源的方法。我在這裡設定一個情境,把這一切的要素都放到一個類中,那麼共用資源就是這個類的一個屬性(不是靜態,我們談論這個問題前提是所有的屬性和方法屬於對象,而不是屬於類),而且一般為了保護類裡面的屬性,這個屬性往往是私人的(private),而所有在這個類中的能訪問到這個屬性的方法都加synchronized,這麼一看我們就明白了,synchronized在為所有方法加鎖了,這種做法的結果就是當某一個線程正在使用帶有synchronized關鍵字的方法時候,只要這個方法還在運行沒有結束,其他所有該類帶有synchronized的方法的線程都會被鎖住。
這裡要強調一下,鎖的機制一定是在對象的不同方法上的,如果是不同對象的同一個方法對於同一個資源的訪問是不存在衝突的問題,換句話說線程裡線程的調度是以方法為單位進行調度的,例如方法A搶佔到了CPU的時間片,那麼方法B就被掛起了,但是在記憶體中方法A應該是唯一的,因此不存在一個對象的A使用另一個對象的方法A是被掛起的,也許這個說法大家可能不太好理解,我舉個例子吧,大家看下面的代碼:
package cn.com.sxia;
public class SingleMethodThread{
private volatile int i = 10;
public void release(){
System.out.println("前數值是:" + i);
if (i != 0){
--i;
}else{
System.exit(0);
}
System.out.println("後數值是:" + i);
}
}
package cn.com.sxia;
public class SingleMethodThreadTester extends Thread {
private volatile SingleMethodThread smt;
public SingleMethodThreadTester(SingleMethodThread smt){
this.smt = smt;
start();
}
public void run(){
while(true){
smt.release();
yield();
}
}
public static void main(String[] args) {
SingleMethodThread smt = new SingleMethodThread();
new SingleMethodThreadTester(smt);
new SingleMethodThreadTester(smt);
}
}
結果如下:
前數值是:10
前數值是:10
後數值是:9
後數值是:8
前數值是:8
前數值是:8
後數值是:7
後數值是:6
前數值是:6
前數值是:6
後數值是:5
後數值是:4
前數值是:4
前數值是:4
後數值是:3
後數值是:2
前數值是:2
前數值是:2
後數值是:1
後數值是:0
前數值是:0
前數值是:0
這個代碼裡我建了兩個線程,線程都傳入同樣的資料也就是同一個資源即共用資源了,運行程式,程式的結果都是正常的,沒有資料違反了我們設定的約束條件既然還在運行。我們在看一段代碼:
package cn.com.sxia;
public class MutiMethodThread {
private volatile int i = 10;
public boolean isExit() {
if (i == 0)
return false;
else
return true;
}
public void release() {
System.out.println("前數值是:" + i);
--i;
System.out.println("後數值是:" + i);
}
}
package cn.com.sxia;
public class MutiMethodThreadTester extends Thread {
private volatile MutiMethodThread mmt;
public MutiMethodThreadTester(MutiMethodThread mmt){
this.mmt = mmt;
start();
}
public void run(){
while(true){
mmt.release();
yield();
}
}
public static void main(String[] args) {
MutiMethodThread smt = new MutiMethodThread();
new MutiMethodThreadTester(smt);
new MutiMethodThreadTester(smt);
}
}
結果如下:
前數值是:-18572
後數值是:-18573
前數值是:-18573
後數值是:-18574
前數值是:-18574
後數值是:-18575
前數值是:-18575
後數值是:-18576
前數值是:-18550
後數值是:-18577
前數值是:-18577
後數值是:-18578
前數值是:-18578
後數值是:-18579
前數值是:-18579
後數值是:-18580
。。。。。。。。。
當線程run方法裡調用了對象兩個不同方法也就產生了線程衝突的問題了。
上面的問題是一個很小的細節,不過我認為它是一個很關鍵的細節,在我做過對這個技術交流的人中我發現很多人其實對該處的知識大多都有錯誤的認識,而這種錯誤又常被人忽略結果導致對自己寫出的線程程式有了錯誤的解讀,每一個知識點都是結構嚴密的邏輯體,半天馬虎就會把驢子當馬用了,看起來沒錯其實差之千裡了。
我在前面一直都強調synchronized關鍵字會給代碼加鎖,那麼這個鎖到底存在哪個地方啊,是方法還是對象還是類了?假如有面試官問你這個問題,你又當如何回答呢?
解決資源共用的鎖在對象裡(也在類裡,這個我後面會提到就是不在方法上),每個對象都包含一個單一的鎖,有的地方會把這個鎖稱為監視器,它本身也是對象的一部分,當該對象的任意一個帶有synchronized關鍵字方法被調用的時候,對象都會被上鎖,加鎖的對象除了現在被調用的方法可以運行,其他所有帶synchronized方法只有在對象釋放掉鎖後才能執行。所以,在java語言裡一個對象所有帶synchronized方法都是共用同一個鎖。
講了這麼多估計還是有許多的童鞋感覺還是在雲裡霧裡,我想換個角度解釋解決衝突的問題,可能會開闊一下大家的思路。首先是共用的資源,也就是同一個時間很多線程會搶奪的資源到底是啥東東,共用的資源在我的理解裡就是一塊一堆線程都可以訪問儲存資料的記憶體地區,然而不同線程對這塊記憶體地區的修改都是獨立的,不會有互動,就像有一碗飯,大家排隊輪流吃一口,可以前面吃完一口的的那個人不會告訴下一個人這碗飯吃了多少還剩多少,就算吃完了飯也只有當事人知道,其他人不知道,終於某個人吃完了最後一口,下一位又來吃,但是飯已經吃完了,沒有飯了我們還說吃飯就不符合邏輯了,根據邏輯我們是希望在飯吃完時候大家都知道,大家就不用排隊等飯吃了,線程衝突的問題就和這個類似,我們設定的約束條件該如何被執行了?線上程中到底誰是吃飯的人呢?根據我上面的代碼,我是在對象的範疇裡討論資源衝突,對象裡的一個方法就是吃飯的人,不同的方法就是不同的人了。有些人認為不同對象調用同一個方法去訪問共用資源也會有衝突,大家看我上面寫的執行個體代碼,這種不是會有衝突的,為什麼呢?其實java裡的某一方法也是唯一的,這個不難理解,我們寫的方法說白了就是一段代碼,程式運行時候代碼進入記憶體,記憶體的代碼還是唯一的,因為我們就寫了那一段,聰明的電腦不會肆意去copy裡的代碼,不同的對象執行同一個方法,這個方法在執行時候是唯一的,不可能同時有兩個相同的方法在被調用,所以同一個方法是不會產生線程衝突的。但是不相同的方法調用共用資源就會產生衝突的問題了。
我們回到對象的鎖,一個線程執行時候我們可以獲得這個線程調用對象的鎖多次,這個可能不太好理解,我舉個例子:我們調用對象的一個方法,這個方法裡又調用了對象的另一個方法,那麼對象的鎖就被調用兩次了。Java虛擬機器會記錄下我們調用對象鎖的次數了,一個對象的所有的鎖都被解開了,那麼鎖的計數就為0了,如果對象調用了n次方法鎖的計數就是n了。在程式中只有首先獲得鎖的那個線程才有機會獲得多個鎖的特權。
對象可以調用內部的屬性和方法,構建對象的類也是可以調用屬於類的屬性和方法,那麼類層級的操作也會存線上程衝突的問題,雖然屬於類,但是解決方案和對象是一致的。
解決理論知識的講解結束了,我們可以改寫下上篇博文裡的代碼了,代碼如下:
package cn.com.sxia;
public class SynchronizedEvenGenerator implements Invariant {
private int i;
public synchronized void next(){
i++;
i++;
}
public synchronized int getValue(){
return i;
}
@Override
public InvariantState invariant() {
int val = getValue();
if (val % 2 == 0)
return new InvariantOK();
else
return new InvariantFailure(new Integer(val));
}
public static void main(String[] args) throws InterruptedException {
SynchronizedEvenGenerator gen = new SynchronizedEvenGenerator();
new InvariantWatcher(gen,4000);
while(true){
gen.next();
}
}
}
代碼裡我把所有的方法都加上了synchronized關鍵字了,有的童鞋會不會這樣想過,我只加一個了,或者我加上自己認為要加的方法,我建議大家不要這麼做,在一個類裡要加就全加,有的方法不加,線程的隨機調度將會成為最大的安全隱患了。對於我們寫的監控程式就沒必要加synchronized關鍵字了,我們需要它的隨機,它的隨機讓我們隨時掌控資料的變化。