在上一篇博客中,我“蜻蜓點水”般的介紹了下Java記憶體模型,在這一篇博客,我將帶著大家看下Synchronized關鍵字的那些事,其實把Synchronized關鍵字放到上一篇博客中去介紹,也是符合 “Java記憶體模型”這個標題的,因為Synchronized關鍵字和Java記憶體模型有著密不可分的關 ...
在上一篇博客中,我“蜻蜓點水”般的介紹了下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進行的優化,有機會,會再抽出一節的內容來講講這個。