Java多線程同步問題:一個小Demo完全搞懂


版權聲明:本文出自汪磊的博客,轉載請務必注明出處。

Java線程系列文章只是自己知識的總結梳理,都是最基礎的玩意,已經掌握熟練的可以繞過。

一、一個簡單的Demo引發的血案

關於線程同步問題我們從一個簡單的Demo現象說起。Demo特別簡單就是開啟兩個線程打印字符串信息。

OutPutStr類源碼:

1 public class OutPutStr { 2 
3     public void out(String str) { 4         for (int i = 0; i < str.length(); i++) { 5  System.out.print(str.charAt(i)); 6  } 7  System.out.println(); 8  } 9 }

很簡單吧,就是一個方法供外界調用,調用的時候傳進來一個字符串,方法逐個取出字符串的字符並打印到控制台。

接下來,我們看main方法中邏輯:

 1 public static void main(String[] args) {
 2         //
 3         final OutPutStr o = new OutPutStr();
 4         new Thread(new Runnable() {
 5 
 6             @Override
 7             public void run() {
 8                 //
 9                 while(true){
10                     o.out("111111111111");
11                 }
12             }
13         }).start();
14         new Thread(new Runnable() {
15 
16             @Override
17             public void run() {
18                 //
19                 while(true){
20                     o.out("222222222222");
21                 }
22             }
23         }).start();
24 }

也很簡單,就是開啟兩個線程分別調用OutPutStr中out方法不停打印字符串信息,運行程序打印信息如下:

1 222222222222
2 222222222222
3 22222222222111111111
4 2
5 111111111111
6 111111111111
7 1111222222222211111111
8 111111111111

咦?和我們想的不一樣啊,怎么還會打印出22222222222111111111這樣子的信息,這是怎么回事呢?

二、原因解析

我們知道線程的執行是CPU隨機調度的,比如我們開啟10個線程,這10個線程並不是同時執行的,而是CPU快速的在這10個線程之間切換執行,由於切換速度極快使我們感覺同時執行罷了。發生上面問題的本質就是CPU對線程執行的隨機調度,比如A線程此時正在打印信息還沒打印完畢此時CPU切換到B線程執行了,B線程執行完了又切換回A線程執行就會導致上面現象發生。

線程同步問題往往發生在多個線程調用同一方法或者操作同一變量,但是我們要知道其本質就是CPU對線程的隨機調度,CPU無法保證一個線程執行完其邏輯才去調用另一個線程執行。

三、同步方法解決上述問題

既然知道了問題發生的原因,記下來我們就要想辦法解決問題啊,解決的思路就是保證一個線程在調用out方法的時候如果沒執行完那么另一個不能執行此方法,換句話說就是只能等待別的線程執行完畢才能執行。

針對線程同步問題java早就有解決方法了,最簡單的就是給方法加上synchronized關鍵字,如下:

1 public synchronized void out(String str) {
2         for (int i = 0; i < str.length(); i++) {
3             System.out.print(str.charAt(i));
4         }
5         System.out.println();
6 }

這是什么意思呢?加上synchronized關鍵字后,比如A線程執行out方法就相當於拿到了一把鎖,只有獲取這個鎖才能執行此方法,如果在A線程執行out方法過程中B線程也想插一腳進來執行out方法,對不起此時這是不能夠的,因為此時鎖在A線程手里,B線程無權拿到這把鎖,只有等到A線程執行完后放棄鎖,B線程才能拿到鎖執行out方法。

為out方法加上synchronized后其就變成了同步方法,普通同步方法的鎖是this,也就是當前對象,比如demo中,外部要想調用out方法就必須創建OutPutStr類實例對象o,此時out同步方法的鎖就是這個o。

四、同步代碼塊解決上述問題

我們也可以利用同步代碼塊解決上述問題,修改out方法如下:

1 public void out(String str) {
2         synchronized (this) {
3             for (int i = 0; i < str.length(); i++) {
4                 System.out.print(str.charAt(i));
5             }
6             System.out.println();
7         }
8 }

同步代碼塊寫法:synchronized(obj){},其中obj為鎖對象,此處我們傳入this,同樣方法的鎖也為當前對象,如果此處我們傳入str,那么這里的鎖就是str對象了。

為了說明不同鎖帶來的影響我們修改OutPutStr代碼如下:

 1 public class OutPutStr {
 2 
 3     public synchronized void out(String str) {
 4         for (int i = 0; i < str.length(); i++) {
 5             System.out.print(str.charAt(i));
 6         }
 7         System.out.println();
 8     }
 9     
10     public void out1(String str) {
11         
12         synchronized (str) {
13             for (int i = 0; i < str.length(); i++) {
14                 System.out.print(str.charAt(i));
15             }
16             System.out.println();
17         }
18     }
19 }

很簡單我們就是加入了一個out1方法,out方法用同步函數保證同步,out1用同步代碼塊保證代碼塊,但是鎖我們用的是str。

main代碼:

 1 public static void main(String[] args) {
 2         //
 3         final OutPutStr o = new OutPutStr();
 4         new Thread(new Runnable() {
 5 
 6             @Override
 7             public void run() {
 8                 //
 9                 while(true){
10                     o.out("111111111111");
11                 }
12             }
13         }).start();
14         new Thread(new Runnable() {
15 
16             @Override
17             public void run() {
18                 //
19                 while(true){
20                     o.out1("222222222222");
21                 }
22             }
23         }).start();
24     }

也沒什么,就是其中一個線程調用out方法,另一個調用out1方法,運行程序:

111111111111222
222222222222

111111111111222222222222
222222222222

看到了吧,打印信息又出問題了,就是因為out與out1方法的鎖不一樣導致的,線程A調用out方法拿到this這把鎖,線程B調用out1拿到str這把鎖,二者互不影響,解決辦法也很簡單,修改out1方法如下即可:

1 public void out1(String str) {
2         
3         synchronized (this) {
4             for (int i = 0; i < str.length(); i++) {
5                 System.out.print(str.charAt(i));
6             }
7             System.out.println();
8         }
9 }

五、靜態函數的同步問題

我們繼續修改OutPutStr類,加入out2方法:

 1 public class OutPutStr {
 2 
 3     public synchronized void out(String str) {
 4         for (int i = 0; i < str.length(); i++) {
 5             System.out.print(str.charAt(i));
 6         }
 7         System.out.println();
 8     }
 9 
10     public void out1(String str) {
11 
12         synchronized (this) {
13             for (int i = 0; i < str.length(); i++) {
14                 System.out.print(str.charAt(i));
15             }
16             System.out.println();
17         }
18     }
19 
20     public synchronized static void out2(String str) {
21 
22         for (int i = 0; i < str.length(); i++) {
23             System.out.print(str.charAt(i));
24         }
25         System.out.println();
26     }
27 }

main中兩個子線程分別調用out1,ou2打印信息,運行程序打印信息如下;

1 222222222222
2 222222222222
3 222222222111111111111
4 111111111111

咦?又出錯了,out2與out方法唯一不同就是out2就是靜態方法啊,不是說同步方法鎖是this嗎,是啊,沒錯,但是靜態方法沒有對應類的實例對象依然可以調用,那其鎖是誰呢?顯然靜態方法鎖不是this,這里就直說了,是類的字節碼對象,類的字節碼對象是優先於類實例對象存在的。

將ou1方法改為如下:

1 public void out1(String str) {
2 
3         synchronized (OutPutStr.class) {
4             for (int i = 0; i < str.length(); i++) {
5                 System.out.print(str.charAt(i));
6             }
7             System.out.println();
8         }
9 }

再次運行程序,就會發現信息能正常打印了。

六、synchronized同步方式總結

到此我們就該小小的總結一下了,普通同步函數的鎖是this,當前類實例對象,同步代碼塊鎖可以自己定義,靜態同步函數的鎖是類的字節碼文件。總結完畢,就是這么簡單。說了一大堆理解這一句就夠了。

七、JDK1.5中Lock鎖機制解決線程同步

大家是不是覺得上面說的鎖這個玩意咋這么抽象,看不見,摸不着的。從JDK1.5起我們就可以根據需要顯性的獲取鎖以及釋放鎖了,這樣也更加符合面向對象原則。

Lock接口的實現子類之一ReentrantLock,翻譯過來就是重入鎖,就是支持重新進入的鎖,該鎖能夠支持一個線程對資源的重復加鎖,也就是說在調用lock()方法時,已經獲取到鎖的線程,能夠再次調用lock()方法獲取鎖而不被阻塞,同時還支持獲取鎖的公平性和非公平性,所謂公平性就是多個線程發起lock()請求,先發起的線程優先獲取執行權,非公平性就是獲取鎖與是否優先發起lock()操作無關。默認情況下是不公平的鎖,為什么要這樣設計呢?現實生活中我們都希望公平的啊?我們想一下,現實生活中要保證公平就必須額外開銷,比如地鐵站保證有序公平進站就必須配備額外人員維持秩序,程序中也是一樣保證公平就必須需要額外開銷,這樣性能就下降了,所以公平與性能是有一定矛盾的,除非公平策略對你的程序很重要,比如必須按照順序執行線程,否則還是使用不公平鎖為好。

接下來我們修改OutPutStr類,添加out3方法:

 1 //true表示公平鎖,false非公平鎖
 2     private Lock lock = new ReentrantLock();
 3     
 4     public void out3(String str) {
 5         
 6         lock.lock();//如果有其它線程已經獲取鎖,那么當前線程在此等待直到其它線程釋放鎖。
 7         try {
 8             for (int i = 0; i < str.length(); i++) {
 9                 System.out.print(str.charAt(i));
10             }
11             System.out.println();
12         } finally {
13             lock.unlock();//釋放鎖資源,之所以加入try{}finally{}代碼塊,
14             //是為了保證鎖資源的釋放,如果代碼發生異常也可以保證鎖資源的釋放,
15             //否則其它線程無法拿到鎖資源執行業務邏輯,永遠處於等待狀態。
16         }
17     }

關鍵注釋都在代碼中有所體現了,使用起來也很簡單。

八、Lock與synchronized同步方式優缺點

Lock 的鎖定是通過代碼實現的,而 synchronized 是在 JVM 層面上實現的(所有對象都自動含有單一的鎖。JVM負責跟蹤對象被加鎖的次數。如果一個對象被解鎖,其計數變為0。在線程第一次給對象加鎖的時候,計數變為1。每當這個相同的線程在此對象上獲得鎖時,計數會遞增。只有首先獲得鎖的線程才能繼續獲取該對象上的多個鎖。每當線程離開一個synchronized方法,計數遞減,當計數為0的時候,鎖被完全釋放,此時別的線程就可以使用此資源)。

synchronized 在鎖定時如果方法塊拋出異常,JVM 會自動將鎖釋放掉,不會因為出了異常沒有釋放鎖造成線程死鎖。但是 Lock 的話就享受不到 JVM 帶來自動的功能,出現異常時必須在 finally 將鎖釋放掉,否則將會引起死鎖。

在資源競爭不是很激烈的情況下,偶爾會有同步的情形下,synchronized是很合適的。原因在於,編譯程序通常會盡可能的進行優化synchronize,另外可讀性非常好。在資源競爭激烈情況下,Lock同步機制性能會更好一些。

 

關於線程同步問題到這里就結束了,java多線程文章只是本人工作以來的一次梳理,都比較基礎,但是卻很重要的,最近招人面試的最大體會就是都喜歡那些所謂時髦的技術一問基礎說的亂七八糟,浪費彼此的時間。好啦,吐槽了幾句,本文到此為止,很基礎的玩意,希望對你有用。

聲明:文章將會陸續搬遷到個人公眾號,以后文章也會第一時間發布到個人公眾號,及時獲取文章內容請關注公眾號

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM