Synchronized的那些事

CodeBear發表於2018-12-24

在上一篇部落格中,我“蜻蜓點水”般的介紹了下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

  1. A執行緒執行到num++這一步,讀到了num的值為5(因為還沒進行自增操作)。

  2. B執行緒也執行到了num++這一步,讀到了num的值還是為5(因為A執行緒中的num還沒有來得及進行自增操作)。

  3. A執行緒中的num終於進行了自增操作,num為6。

  4. B執行緒的num也進行了自增操作,num也為6。

可能光用文字描述,還是有點懵,所以我畫了一張圖來幫助大家理解:

image.png

結合文字和圖片,應該就可以理解了。

可以看出來,雖然執行了兩次自增操作,但是實際的效果只是自增了一次。

所以在第一段程式碼中,執行的結果並不是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裡面了:

image.png

點選某個執行緒,可以看到關於執行緒的一些資訊:

image.png

image.png

其中四個執行緒都處於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

image.png

用紅圈圈出來的就是新增synchronized後帶來的命令了。執行同步程式碼塊,先是呼叫monitorenter命令,執行完畢後,再呼叫monitorexit命令,為什麼會有兩個monitorexit呢,一個是正常執行辦法後的monitorexit,一個是發生異常後的monitorexit。

synchronized標記方法會是什麼情況呢?

public class Main {
    public synchronized void Hello(){
        System.out.println("Hellol");
    }
    public static void main(String[] args) {
    }
}

image.png

鎖與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");
            }
        }
    }

結果:
image.png

主執行緒獲取了類鎖,列印出 “第一個synchronized”,緊接著主執行緒又獲取了類鎖,列印出“第二個synchronized”。

問題來了,第一個類鎖明明還沒有釋放,下面又獲取了這個類鎖。如果沒有“鎖的重入性”,這裡應該只會列印出 “第一個synchronized”,然後程式就死鎖了,因為它會一直等待釋放第一個類鎖,但是卻永遠等不到那一刻。

這也就是解釋了為什麼會是“當執行緒獲取monitor後,計數器便會+1,釋放monitor後,計數器便會-1“這樣的設計。只有當計數器=0,才代表monitor已經被釋放。第二個執行緒才能再次獲取monitor。

當然,鎖的重入性是針對於同一個執行緒來說。

Synchronized與有序性,可見性

在上一篇中,我們簡單的介紹了指令重排,知道了三大特性之一的有序性,但是介紹的太簡單。這一次,我們把上一次的內容補充下。

其實,指令重排分為兩種:

  1. 編譯器重排
  2. 執行時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進行的優化,有機會,會再抽出一節的內容來講講這個。