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 }
我們在 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 }
對比上面修飾程式碼塊,直接將 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 }
我們把原來的鎖 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 }
我們可以通過兩種方法檢視其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 指令了。