在上一篇部落格中,我“蜻蜓點水”般的介紹了下Java記憶體模型,在這一篇部落格,我將帶著大家看下Synchronized關鍵字的那些事,其實把Synchronized關鍵字放到上一篇部落格中去介紹,也是符合 “Java記憶體模型”這個標題的,因為Synchronized關鍵字和Java記憶體模型有著密不可分的關係。但是這樣,上一節的內容就太多了。同樣的,這一節的內容也相當多。
好了,廢話不多說,讓我們開始吧,
Synchronized基本使用
首先從一個最簡單的例子開始看:
public class Main {
private int num = 0;
private void test() {
for (int i = 0; i < 50; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
main.test();
}).start();
}
try {
TimeUnit.SECONDS.sleep(5);
System.out.println(main.num);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Main方法中開啟了20個執行緒,每個執行緒執行50次的累加操作,最後列印出來的應該是50*20,也就是1000,但是每次列印出來的都不是1000,而是比1000小的數字。相信這個例子,大家早就爛熟於心了,對解決方案也是手到擒來:
public class Main {
private int num = 0;
private synchronized void test() {
for (int i = 0; i < 50; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
main.test();
}).start();
}
try {
TimeUnit.SECONDS.sleep(5);
System.out.println(main.num);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
只要在test方法上加一個synchronized關鍵字,就OK了。
Synchronized與原子性
為什麼會出現這樣的問題呢,可能就有一小部分人不知道其中的原因了。
這和Java的記憶體模型有關係:在Java的記憶體模型中,保證併發安全的三大特性是 原子性,可見性,有序性。導致這問題出現的原因 便是 num++ 不是原子性操作,它至少有三個操作:
1.把i讀取出來
2.做自增計算
3.把值寫回i
讓我們設想有這樣的一個場景:
當num=5
-
A執行緒執行到num++這一步,讀到了num的值為5(因為還沒進行自增操作)。
-
B執行緒也執行到了num++這一步,讀到了num的值還是為5(因為A執行緒中的num還沒有來得及進行自增操作)。
-
A執行緒中的num終於進行了自增操作,num為6。
-
B執行緒的num也進行了自增操作,num也為6。
可能光用文字描述,還是有點懵,所以我畫了一張圖來幫助大家理解:
結合文字和圖片,應該就可以理解了。
可以看出來,雖然執行了兩次自增操作,但是實際的效果只是自增了一次。
所以在第一段程式碼中,執行的結果並不是1000,而是比1000小的數字。
對於在多執行緒環境中,出現奇怪的結果或者情況,我們也稱為“執行緒不安全”。
而第二段程式碼,就是通過Synchronized關鍵字,把test方法序列化執行了,也就是 A執行緒執行完test方法,B執行緒才可以執行test方法。兩個執行緒是互斥的。這樣就保證了執行緒的安全性,最後的結果就是1000。如果從Java記憶體模型的角度來說,就是保證了操作的“原子性”。
Synchronized幾種使用方法
上面的例子是Synchronized關鍵字的使用方式之一,此時,synchronized標記的是類的例項方法,鎖物件是類的例項物件。當然還有其他使用方式:
private static synchronized void test() {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
此時,synchronized標記的是類的靜態方法,鎖物件是類。
以上兩種,是直接標記在方法上。
還可以包裹程式碼塊:
private void test() {
synchronized (Main.class) {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
}
此時鎖的物件是 類。
private void test() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
}
此時鎖的物件是類的例項物件。
private Object object = new Object();
private void test() {
synchronized (object) {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
}
此時,鎖物件是Object的物件。
JConsole探究Synchronized關鍵字
我們需要用到JDK自帶的一個工具:JConsole,它位於JDK的bin目錄下。
為了讓觀察更加方便,我們需要給執行緒起一個名字,每個執行緒內sleep的時間稍微長一點:
public class Main {
private synchronized void test() {
try {
TimeUnit.SECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
main.test();
}, "Hello,Thread " + i).start();
}
}
}
我們先啟動專案,然後開啟JConsole,找到你專案的程式,就可以連線上去了。
可以看到,5個執行緒已經顯示在JConsole裡面了:
點選某個執行緒,可以看到關於執行緒的一些資訊:
其中四個執行緒都處於BLOCKED,只有一個處於TIME_WAITING,說明只有一個執行緒獲得了鎖,並在TIME_WAITING,其餘的執行緒都沒有獲得鎖,沒有進入到方法,說明了Synchronized的互斥性。關於執行緒的狀態,這篇不會深入,以後可能會介紹這方面的知識。
因為我是一邊寫部落格,一邊執行各種操作的,所以速度上有些跟不上,導致截圖和描述不同,大家可以自己去試試。
javap探究Synchronized關鍵字
為了把問題簡單化,讓大家看的清楚,我只保留synchronized相關的程式碼:
public class Main {
public static void main(String[] args) {
synchronized (Main.class) {
}
}
}
編譯後,用javap命令檢視位元組碼檔案:
javap -v Main.class
用紅圈圈出來的就是新增synchronized後帶來的命令了。執行同步程式碼塊,先是呼叫monitorenter命令,執行完畢後,再呼叫monitorexit命令,為什麼會有兩個monitorexit呢,一個是正常執行辦法後的monitorexit,一個是發生異常後的monitorexit。
synchronized標記方法會是什麼情況呢?
public class Main {
public synchronized void Hello(){
System.out.println("Hellol");
}
public static void main(String[] args) {
}
}
鎖與Monitor
JVM為每個物件都分配了一個monitor,syncrhoized就是利用monitor來實現加鎖,解鎖。同一時刻,只有一個執行緒可以獲得monitor,並且執行被包裹的程式碼塊或者方法,其他執行緒只能等待monitor釋放,整個過程是互斥的。monitor擁有一個計數器,當執行緒獲取monitor後,計數器便會+1,釋放monitor後,計數器便會-1。那麼為什麼會是+1,-1 的操作,而不是“獲得monitor,計數器=1,釋放monitor後,計數器=0”呢?這就涉及到 鎖的重入性了。我們還是通過一段簡單的程式碼來看:
public static void main(String[] args) {
synchronized (Main.class){
System.out.println("第一個synchronized");
synchronized (Main.class){
System.out.println("第二個synchronized");
}
}
}
結果:
主執行緒獲取了類鎖,列印出 “第一個synchronized”,緊接著主執行緒又獲取了類鎖,列印出“第二個synchronized”。
問題來了,第一個類鎖明明還沒有釋放,下面又獲取了這個類鎖。如果沒有“鎖的重入性”,這裡應該只會列印出 “第一個synchronized”,然後程式就死鎖了,因為它會一直等待釋放第一個類鎖,但是卻永遠等不到那一刻。
這也就是解釋了為什麼會是“當執行緒獲取monitor後,計數器便會+1,釋放monitor後,計數器便會-1“這樣的設計。只有當計數器=0,才代表monitor已經被釋放。第二個執行緒才能再次獲取monitor。
當然,鎖的重入性是針對於同一個執行緒來說。
Synchronized與有序性,可見性
在上一篇中,我們簡單的介紹了指令重排,知道了三大特性之一的有序性,但是介紹的太簡單。這一次,我們把上一次的內容補充下。
其實,指令重排分為兩種:
- 編譯器重排
- 執行時CPU指令排序
為什麼編譯器和CPU會做“指令重排”這個“吃力不討好”的事情呢?當然是為了效率。
指令重排會遵守兩個規則:即 self-if-serial 和 happens-before。
我們來舉一個例子:
int a=1;//1
int b=5;//2
int c=a+b;//3
這結果顯而易見:c=6。
但是這段程式碼真正交給CPU去執行是按照什麼順序呢,大部分人會認為 ”從上到下”。是的,從大家開始學程式設計第一天就被灌輸了這個思想,但是這僅僅是一個幻覺,真正交給CPU執行,可能是 先執行第二行,然後再執行第一行,最後是第三行。因為第一行和第二行,哪一行先執行,並不影響最終的結果,但是第三行的執行順序就不能改變了,因為資料存在依懶性。如果改變了第三行的執行順序,那不亂套了。
編譯器,CPU會在不影響單執行緒程式最終執行的結果的情況下進行“指令重排”。
這就是“ self-if-serial”規則。
這個規則就給程式設計師造給一種假象,在單執行緒中,程式碼都是從上到下執行的,殊不知,編譯器和CPU其實在背後偷偷的做了很多事情,而做這些事情的目的只有一個“提高執行的速度”。
在單執行緒中,我們可能並不需要關心指令重排,因為無論背後進行了多麼翻天覆地的“指令重排”都不會影響到最終的執行結果,但是self-if-serial是針對於單執行緒的,對於多執行緒,會有第二個規則:happens-before。
happens-before用來表述兩個操作之間的關係。如果A happens-before B,也就代表A發生在B之前。
由於兩個操作可能處於不同的執行緒,happens-before規定,如果一個執行緒A happens-before另外一個執行緒B,那麼A對B可見,正是由於這個規定,我們說Synchronized保證了執行緒的“可見性”。Synchronized具體是怎麼做的呢?當我們獲得鎖的時候,執行同步程式碼,執行緒會被強制從主記憶體中讀取資料,先把主記憶體的資料複製到本地記憶體,然後在本地記憶體進行修改,在釋放鎖的時候,會把資料寫回主記憶體。
而Synchronized的同步特性,顯而易見的保證了“有序性”。
總結一下,Synchronized既可以保證“原子性”,又可以保證“可見性”,還可以保證“有序性”。
Synchronized與單例模式
Synchronized最經典的應用之一就是 懶漢式單例模式 了,如下:
public class Main {
private static Main main;
private Main() {
}
public static Main getInstance() {
if (main == null) {
synchronized (Main.class) {
if (main == null) {
main = new Main();
}
}
}
return main;
}
}
相信這程式碼,大家已經熟悉的不能再熟悉了,但是在極端情況下,可能會產生意想不到的情況,這個時候,Synchronized的好基友Volatile就出現了,這是我們下一節中要講的內容。
Synchronized可以說是每次面試必定會出現的問題,平時在多執行緒開發的時候也會用到,但是真正要理解透徹,還是有不小難度。雖說Synchronized的互斥性,很影響效能,Java也提供了不少更好用的的併發工具,但是Synchronized是併發開發的基礎,所以值得花點時間去好好研究。
好了,本節的內容到這裡結束了,文章已經相當長了,但是還有一大塊東西沒有講:JDK1.6對Synchronized進行的優化,有機會,會再抽出一節的內容來講講這個。