Java關鍵字(八)——synchronized

YSOcean發表於2021-05-24

  synchronized 這個關鍵字,我相信對於併發程式設計有一定了解的人,一定會特別熟悉,對於一些可能在多執行緒環境下可能會有併發問題的程式碼,或者方法,直接加上synchronized,問題就搞定了。

  但是用歸用,你明白它為什麼要這麼用?為什麼就能解決我們所說的執行緒安全問題?

  下面,可樂將和大家一起深入的探討這個關鍵字用法。

1、示例程式碼結果?

  首先大家看一段程式碼,大家想想最後的列印count結果是多少?

 1 package com.ys.algorithmproject.leetcode.demo.concurrency.synchronizedtest;
 2 
 3 
 4 /**
 5  * Create by ItCoke
 6  */
 7 public class SynchronizedTest implements Runnable{
 8 
 9     public static int count = 0;
10 
11     @Override
12     public void run() {
13         addCount();
14 
15     }
16 
17     public void addCount(){
18         int i = 0;
19         while (i++ < 100000) {
20             count++;
21         }
22     }
23 
24     public static void main(String[] args) throws Exception{
25         SynchronizedTest obj = new SynchronizedTest();
26         Thread t1 = new Thread(obj);
27         Thread t2 = new Thread(obj);
28         t1.start();
29         t2.start();
30         t1.join();
31         t2.join();
32         System.out.println(count);
33 
34     }
35 
36 
37 }

  程式碼很簡單,主執行緒中啟動兩個執行緒t1和t2,分別呼叫 addCount() 方法,將count的值都加100000,然後呼叫 join() 方法,表示主執行緒等待這兩個執行緒執行完畢。最後列印 count 的值。

  應該沒有答案一定是 200000 的同學吧,很好,大家都具備一定的併發知識。

  這題的答案是一定小於等於 200000,至於原因也很好分析,比如 t1執行緒獲取count的值為0,然後執行了加1操作,但是還沒來得及同步到主記憶體,這時候t2執行緒去獲取主記憶體的count值,發現還是0,然後繼續自己的加1操作。也就是t1和t2都執行了加1操作,但是最後count的值依然是1。

  那麼我們應該如何保證結果一定是 200000呢?答案就是用 synchronized。

2、修飾程式碼塊

  直接上程式碼:

 1 package com.ys.algorithmproject.leetcode.demo.concurrency.synchronizedtest;
 2 
 3 
 4 /**
 5  * Create by ItCoke
 6  */
 7 public class SynchronizedTest implements Runnable{
 8 
 9     public static int count = 0;
10 
11     private Object objMonitor = new Object();
12 
13     @Override
14     public void run() {
15         addCount();
16 
17     }
18 
19     public void addCount(){
20         synchronized (objMonitor){
21             int i = 0;
22             while (i++ < 100000) {
23                 count++;
24             }
25         }
26 
27     }
28 
29     public static void main(String[] args) throws Exception{
30         SynchronizedTest obj = new SynchronizedTest();
31         Thread t1 = new Thread(obj);
32         Thread t2 = new Thread(obj);
33         t1.start();
34         t2.start();
35         t1.join();
36         t2.join();
37         System.out.println(count);
38 
39     }
40 
41 
42 }
View Code

  我們在 addCount 方法體中增加了一個 synchronized 程式碼塊,將裡面的 while 迴圈包括在其中,保證同一時刻只能有一個執行緒進入這個迴圈去改變count的值。

3、修飾普通方法

 1 package com.ys.algorithmproject.leetcode.demo.concurrency.synchronizedtest;
 2 
 3 
 4 /**
 5  * Create by ItCoke
 6  */
 7 public class SynchronizedTest implements Runnable{
 8 
 9     public static int count = 0;
10 
11     private Object objMonitor = new Object();
12 
13     @Override
14     public void run() {
15         addCount();
16 
17     }
18 
19     public synchronized void addCount(){
20         int i = 0;
21         while (i++ < 100000) {
22             count++;
23         }
24 
25     }
26 
27     public static void main(String[] args) throws Exception{
28         SynchronizedTest obj = new SynchronizedTest();
29         Thread t1 = new Thread(obj);
30         Thread t2 = new Thread(obj);
31         t1.start();
32         t2.start();
33         t1.join();
34         t2.join();
35         System.out.println(count);
36 
37     }
38 
39 
40 }
View Code

  對比上面修飾程式碼塊,直接將 synchronized 加到 addCount 方法中,也能解決執行緒安全問題。

4、修飾靜態方法

  這個我們就不貼程式碼演示了,將 addCount() 宣告為一個 static 修飾的方法,然後在加上 synchronized ,也能解決執行緒安全問題。

5、原子性、可見性、有序性

  通過 synchronized 修飾的方法或程式碼塊,能夠同時保證這段程式碼的原子性、可見性和有序性,進而能夠保證這段程式碼的執行緒安全。

  比如通過 synchronized 修飾的程式碼塊:

  

 

   其中 objMonitor 表示鎖物件(下文會介紹這個鎖物件),只有獲取到這個鎖物件之後,才能執行裡面的程式碼,執行完畢之後,在釋放這個鎖物件。那麼同一時刻就會只有一個執行緒去執行這段程式碼,把多執行緒變成了單執行緒,當然不會存在併發問題了。

  這個過程,大家可以想象在公司排隊上廁所的情景。

  對於原子性,由於同一時刻單執行緒操作,肯定能夠保證原子性。

  對於有序性,在JMM記憶體模型中的Happens-Before規定如下,所以也是能夠保證有序性的。

程式的順序性規則(Program Order Rule):在一個執行緒內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。

  最後對於可見性,JMM記憶體模型也規定了:

對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)。

  大家可能會奇怪,synchronized 並沒有lock和unlock操作啊,怎麼也能夠保證可見性,大家不要急,其實JVM對於這個關鍵字已經隱式的實現了,下文看位元組碼會明白的。

6、鎖物件

  大家要注意,我在通過synchronized修飾同步程式碼塊時,使用了一個 Object 物件,名字叫 objMonitor。而對於修飾普通方法和靜態方法時,只是在方法宣告時說明了,並沒有鎖住什麼物件,其實這三者都有各自的鎖物件,只有獲取了鎖物件,執行緒才能進入執行裡面的程式碼。

1、修飾程式碼塊:鎖定鎖的是synchonized括號裡配置的物件
2、修飾普通方法:鎖定呼叫當前方法的this物件
3、修飾靜態方法:鎖定當前類的Class物件

   多個執行緒之間,如果要通過 synchronized 保證執行緒安全,獲取的要是同一把鎖。如果多個執行緒多把鎖,那麼就會有執行緒安全問題。如下:

 1 package com.ys.algorithmproject.leetcode.demo.concurrency.synchronizedtest;
 2 
 3 
 4 /**
 5  * Create by ItCoke
 6  */
 7 public class SynchronizedTest implements Runnable{
 8 
 9     public static int count = 0;
10 
11 
12 
13     @Override
14     public void run() {
15         addCount();
16 
17     }
18 
19     public void addCount(){
20         Object objMonitor = new Object();
21         synchronized(objMonitor){
22             int i = 0;
23             while (i++ < 100000) {
24                 count++;
25             }
26         }
27     }
28 
29     public static void main(String[] args) throws Exception{
30         SynchronizedTest obj = new SynchronizedTest();
31         Thread t1 = new Thread(obj);
32         Thread t2 = new Thread(obj);
33         t1.start();
34         t2.start();
35         t1.join();
36         t2.join();
37         System.out.println(count);
38 
39     }
40 
41 
42 }
View Code

  我們把原來的鎖 objMonitor 物件從全域性變數移到 addCount() 方法中,那麼每個執行緒進入每次進入addCount() 方法都會新建一個 objMonitor 物件,也就是多個執行緒用多把鎖,肯定會有執行緒安全問題。

7、可重入

  可重入什麼意思?字面意思就是一個執行緒獲取到這個鎖了,在未釋放這把鎖之前,還能進入獲取鎖,如下:

  

  在 addCount() 方法的 synchronized 程式碼塊中繼續呼叫 printCount() 方法,裡面也有一個 synchronized ,而且都是獲取的同一把鎖——objMonitor。

  synchronized 是能夠保證這段程式碼正確執行的。至於為什麼具有這個特性,可以看下文的實現原理。

8、實現原理

   對於如下這段程式碼:

 1 package com.ys.algorithmproject.leetcode.demo.concurrency.synchronizedtest;
 2 
 3 /**
 4  * Create by YSOcean
 5  */
 6 public class SynchronizedByteClass {
 7     Object objMonitor = new Object();
 8 
 9     public synchronized void method1(){
10         System.out.println("Hello synchronized 1");
11     }
12 
13     public synchronized static void method2(){
14         System.out.println("Hello synchronized 2");
15     }
16 
17     public void method3(){
18         synchronized(objMonitor){
19             System.out.println("Hello synchronized 2");
20         }
21 
22     }
23 
24     public static void main(String[] args) {
25 
26     }
27 }
View Code

  我們可以通過兩種方法檢視其class檔案的彙編程式碼。

  ①、IDEA下載 jclasslib 外掛

  

   然後點選 View——Show Bytecode With jclasslib

  

  ②、通過 javap 命令

javap -v 檔名(不要字尾)

  注意:這裡生成彙編的命令是根據編譯之後的位元組碼檔案(class檔案),所以要先編譯。

  ③、修飾程式碼塊彙編程式碼

  我們直接看method3() 的彙編程式碼:

  

 

   對於上圖出現的 monitorenter 和 monitorexit 指令,我們檢視 JVM虛擬機器規範:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html,可以看到對這兩個指令的介紹。

  下面我們說明一下這兩個指令:

  一、monitorenter

  

  每個物件與一個監視器鎖(monitor)關聯。當monitor被佔用時就會處於鎖定狀態,執行緒執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:

  1、如果 monitor的進入數為0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者。

  2、如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.

  3.如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權。

  二、monitorexit

 

 

  執行monitorexit的執行緒必須是object ref所對應的monitor的所有者。

  指令執行時,monitor的進入數減1,如果減1後進入數為0,那執行緒退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的執行緒可以嘗試去獲取這個 monitor 的所有權。

  通過上面介紹,我們可以知道 synchronized 底層就是通過這兩個命令來執行的同步機制,由此我們也可以看出synchronized 具有可重入性

  ③、修飾普通方法和靜態方法彙編程式碼

  

 

   

   可以看到都是通過指令 ACC_SYNCHRONIZED 來控制的,雖然沒有看到方法的同步並沒有通過指令monitorenter和monitorexit來完成,但其本質也是通過這兩條指令來實現。

  當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。 其實和修飾程式碼塊本質上沒有區別,只是方法的同步是一種隱式的方式來實現。

9、異常自動unlock

  可能會有細心的朋友發現,我在介紹 synchronized 修飾程式碼塊時,給出的彙編程式碼,用紅框圈住了兩個 monitorexit,根據我們前面介紹,獲取monitor加1,退出monitor減1,等於0時,就沒有鎖了。那為啥會有兩個 monitorexit,而只有一個 monitorenter 呢?

  

 

  第 6 行執行 monitorenter,然後第16行執行monitorexit,然後執行第17行指令 goto 25,表示跳到第25行程式碼,第25行是 return,也就是直接結束了。

  那第20-24行程式碼中是什麼意思呢?其中第 24 行指令 athrow 表示Java虛擬機器隱式處理方法完成異常結束時的監視器退出,也就是執行發生異常了,然後去執行 monitorexit。

  進而可以得到結論:

synchronized 修飾的方法或程式碼塊,在執行過程中丟擲異常了,也能釋放鎖(unlock)

  我們可以看如下方法,手動丟擲異常:

  

 

   然後獲取其彙編程式碼,就只有一個 monitorexit 指令了。

  

 

相關文章