多執行緒與高併發(三)synchronized關鍵字

茶底世界發表於2019-07-03

上一篇中學習了執行緒安全相關的知識,知道了執行緒安全問題主要來自JMM的設計,集中在主記憶體和執行緒的工作記憶體而導致的記憶體可見性問題,及重排序導致的問題。上一篇也提到共享資料會出現可見性和競爭現象,如果多執行緒間沒有共享的資料也就是說多執行緒間並沒有協作完成一件事情,那麼,多執行緒就不能發揮優勢,不能帶來巨大的價值。而共享資料如何處理,一個很簡單的想法就是依次去讀寫共享變數,這樣就能保證讀寫的資料是最新的,就不會出現資料安全性問題,java中我們使用synchronized關鍵字去做讓每個執行緒依次排隊操作共享變數的功能。很明顯這樣做效率不高,但是這是基礎。

一、使用synchronized

我們從使用開始學習,synchronized是一個關鍵字,在我們需要進行同步處理的地方加上synchronized即可。

分類 具體分類 被鎖物件 虛擬碼
方法 例項方法 類的例項物件 public synchronized void method(){}
靜態方法 類物件 public static synchronized void method(){}
程式碼塊 例項物件 類的例項物件 synchronized(this){}
class物件 類物件 synchronized(Demo.class){}
任意例項物件的Object 例項物件Object String lock="";synchronized(lock){}

synchronized可以用在方法上也可以使用在程式碼塊中,其中方法是例項方法和靜態方法分別鎖的是該類的例項物件和該類的物件。而使用在程式碼塊中也可以分為三種,具體的可以看上面的表格。這裡的需要注意的是:如果鎖的是類物件的話,儘管new多個例項物件,但他們仍然是屬於同一個類依然會被鎖住,即執行緒之間保證同步關係

1.1 同步方法

先看下下面這段程式碼:

public class SynchronizedDemo1 {
private int count;

public void countAdd() {
    for (int i = 0; i < 5; i++) {
        try {
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public static void main(String[] args) {
    SynchronizedDemo1 demo1 = new SynchronizedDemo1();
    new Thread(new Runnable() {
        @Override
        public void run() {
            demo1.countAdd();
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            demo1.countAdd();
        }
    }).start();
}
}

這是未使用synchronized時,執行結果:

1.1.1 普通方法

 這裡可以看出,兩個執行緒是同時執行的,而且出現了一定的執行緒安全問題,共享變數的資料存在問題,這個時候我們加上關鍵字synchronized,只要改一行程式碼。

public synchronized void countAdd()

執行結果如下:

這裡可以看到Thread-1增加資料是等Thread-0增加完資料之後才進行的,說明Thread-0和Thread-1是順序執行countAdd方法的。

其實我們要理解這裡的原理,一個物件只有一把鎖,當一個執行緒獲取了該物件的鎖之後,其他執行緒無法獲取該物件的鎖,所以無法訪問該物件的其他synchronized方法,所以上面說被鎖的是這個例項物件,在想深入一點,我們再新增一個不加鎖的方法,會怎麼樣:

public void print() {
    System.out.println(Thread.currentThread().getName() + "的列印方法:" + count);
}

然後改下主方法:

public static void main(String[] args) {
        SynchronizedDemo1 demo1 = new SynchronizedDemo1();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.print();
                demo1.countAdd();
            }
        }).start();
    }

很明顯這裡Thread-1的列印方法不需要等待Thread-0的增加方法結束,這個我們要理解非synchronized方法是不需要獲取到物件鎖就可以執行的。

還有一點,這裡鎖住的是例項物件,如果我們生成了多個例項,那他們之間是不受影響的,也就是多個例項多個鎖。這裡可能需要自己理解理解,並不是那麼簡單。

1.1.2 靜態方法

上面的方法主要是針對的普通方法,普通方法鎖住的是例項物件,我們知道靜態方法是屬於類的,也就是說靜態方法鎖定的是這個類的所有物件,即不管建立多少個例項,都需要等待鎖釋放:

public class SynchronizedDemo2 {
    private static int count;

    public static synchronized void countAdd() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo2 demo1 = new SynchronizedDemo2();
        SynchronizedDemo2 demo2 = new SynchronizedDemo2();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                demo2.countAdd();

            }
        }).start();
    }
}

結果:

這裡可以看到雖然是建立了兩個例項,但是他們之間還是用了同一把鎖。

1.2 同步程式碼塊

synchronized不僅可以作用某個方法,也可以作用程式碼塊,在某些情況下,我們編寫的方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的程式碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步程式碼塊的方式對需要同步的程式碼進行包裹,這樣就無需對整個方法進行同步操作了。

public class SynchronizedDemo3 {

    private int count;

    public void countAdd() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo3 demo1 = new SynchronizedDemo3();
        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                demo1.countAdd();

            }
        }).start();
    }
}

執行結果與之前相同,這裡使用的是this,說明鎖住的是例項物件,結合前面的理解,如果建立多個例項也是不會鎖住的。

但是如果將所 synchronized (this)改成synchronized (SynchronizedDemo3.class),那鎖住的就是類,那即使建立多個例項,也依然會被鎖住。

二、原理分析

synchronized的使用最重要的是理解鎖住的物件是啥,下面來分析下底層的實現原理。

2.1 物件鎖(monitor)機制

先看一段簡單的程式碼:

public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
        }
        method();
    }

    private synchronized static void method() {
    }
}

程式碼中有同步程式碼,鎖住類物件,編譯完成後,切至SynchronizedDemo.class目錄中,執行javap -v SynchronizedDemo.class檢視位元組碼檔案。

在紅色框中我們看到執行同步程式碼塊後首先要先執行monitorenter指令,退出的時候monitorexit指令。使用Synchronized進行同步,其關鍵就是必須要對物件的監視器monitor進行獲取,當執行緒獲取monitor後才能繼續往下執行,否則就只能等待。而這個獲取的過程是互斥的,即同一時刻只有一個執行緒能夠獲取到monitor。上面的demo中在執行完同步程式碼塊之後緊接著再會去執行一個靜態同步方法,而這個方法鎖的物件依然就這個類物件,那麼這個正在執行的執行緒還需要獲取該鎖嗎?答案是不必的,從上圖中就可以看出來,執行靜態同步方法的時候就只有一條monitorexit指令,並沒有monitorenter獲取鎖的指令。這就是鎖的重入性,即在同一鎖程中,執行緒不需要再次獲取同一把鎖。Synchronized先天具有重入性。每個物件擁有一個計數器,當執行緒獲取該物件鎖後,計數器就會加一,釋放鎖後就會將計數器減一

任意一個物件都擁有自己的監視器,當這個物件由同步塊或者這個物件的同步方法呼叫時,執行方法的執行緒必須先獲取該物件的監視器才能進入同步塊和同步方法,如果沒有獲取到監視器的執行緒將會被阻塞在同步塊和同步方法的入口處,進入到BLOCKED狀態。

任意執行緒對Object的訪問,首先要獲得Object的監視器,如果獲取失敗,該執行緒就進入同步狀態,執行緒狀態變為BLOCKED,當Object的監視器佔有者釋放後,在同步佇列中得執行緒就會有機會重新獲取該監視器。

2.2 synchronized的happens-before關係

我們再看看Synchronized的happens-before規則,看下面程式碼:

 

public class MonitorDemo {
    private int a = 0;
    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }
 }

該程式碼的happens-before關係如圖所示:

在圖中每一個箭頭連線的兩個節點就代表之間的happens-before關係,黑色的是通過程式順序規則推匯出來,紅色的為監視器鎖規則推導而出:執行緒A釋放鎖happens-before執行緒B加鎖,藍色的則是通過程式順序規則和監視器鎖規則推測出來happens-befor關係,通過傳遞性規則進一步推導的happens-before關係。現在我們來重點關注2 happens-before 5,通過這個關係我們可以得出什麼?

根據happens-before的定義中的一條:如果A happens-before B,則A的執行結果對B可見,並且A的執行順序先於B。執行緒A先對共享變數A進行加一,由2 happens-before 5關係可知執行緒A的執行結果對執行緒B可見即執行緒B所讀取到的a的值為1。

2.3 鎖獲取和鎖釋放的記憶體語義

在上一篇文章提到過JMM核心為兩個部分:happens-before規則以及記憶體抽象模型。我們分析完Synchronized的happens-before關係後,還是不太完整的,我們接下來看看基於java記憶體抽象模型的Synchronized的記憶體語義。

廢話不多說依舊先上圖。

從上圖可以看出,執行緒A會首先先從主記憶體中讀取共享變數a=0的值然後將該變數拷貝到自己的本地記憶體,進行加一操作後,再將該值重新整理到主記憶體,整個過程即為執行緒A 加鎖-->執行臨界區程式碼-->釋放鎖相對應的記憶體語義。

執行緒B獲取鎖的時候同樣會從主記憶體中共享變數a的值,這個時候就是最新的值1,然後將該值拷貝到執行緒B的工作記憶體中去,釋放鎖的時候同樣會重寫到主記憶體中。

從整體上來看,執行緒A的執行結果(a=1)對執行緒B是可見的,實現原理為:釋放鎖的時候會將值重新整理到主記憶體中,其他執行緒獲取鎖時會強制從主記憶體中獲取最新的值。另外也驗證了2 happens-before 5,2的執行結果對5是可見的。

從橫向來看,這就像執行緒A通過主記憶體中的共享變數和執行緒B進行通訊,A 告訴 B 我們倆的共享資料現在為1啦,這種執行緒間的通訊機制正好吻合java的記憶體模型正好是共享記憶體的併發模型結構。

3.4 物件頭

在同步的時候是獲取物件的monitor,即獲取到物件的鎖。那麼物件的鎖怎麼理解?無非就是類似對物件的一個標誌,那麼這個標誌就是存放在Java物件的物件頭。

jvm中採用2個字來儲存物件頭(如果物件是陣列則會分配3個字,多出來的1個字記錄的是陣列長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明如下表:

其中Mark Word在預設情況下儲存著物件的HashCode、分代年齡、鎖標記位等以下是32位JVM的Mark Word預設儲存結構

由於物件頭的資訊是與物件自身定義的資料沒有關係的額外儲存成本,因此考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的資料結構,以便儲存更多有效的資料,它會根據物件本身的狀態複用自己的儲存空間,如32位JVM下,除了上述列出的Mark Word預設儲存結構外,還有如下可能變化的結構:

三、優化

從上面學習的內容來看,synchronized具有互斥性(排它性),這種方式效率很低下,因為每次都只能通過一個執行緒。

synchronized會讓沒有得到鎖資源的執行緒進入BLOCKED狀態,而後在爭奪到鎖資源後恢復為RUNNABLE狀態,這個過程中涉及到作業系統使用者模式和核心模式的轉換,代價比較高。

我們每天擠地鐵過安檢的時候,都需要排隊,排隊的時候我們需要將包放入安檢機,但這些都非常耗時,想要加快速度,那這個時候就有了人工檢查,人工檢查大大的減少了檢查的時間,這樣我們排隊速度也就更快,這就是一個優化思路。

3.1 CAS

CAS是一個非常重要的概念,但是又很抽象,沒辦法,只能死磕了。

3.1.1 CAS是什麼

從思想上來說,Synchronized屬於悲觀鎖,悲觀地認為程式中的併發情況嚴重,所以嚴防死守。CAS屬於樂觀鎖,它假設所有執行緒訪問共享資源的時候不會出現衝突,既然不會出現衝突自然而然就不會阻塞其他執行緒的操作。因此,執行緒就不會出現阻塞停頓的狀態。那麼,如果出現衝突了怎麼辦?無鎖操作是使用CAS(compare and swap)又叫做比較交換來鑑別執行緒是否出現衝突,出現衝突就重試當前操作直到沒有衝突為止。

3.1.2 CAS的操作過程

CAS是英文單詞Compare And Swap的縮寫,翻譯過來就是比較並替換。

CAS機制當中使用了3個基本運算元:記憶體地址V,舊的預期值O,要修改的新值N。

更新一個變數的時候,只有當變數的預期值O和記憶體地址V當中的實際值相同時,才會將記憶體地址V對應的值修改為N。

如果O和V不相同,表明該值已經被其他執行緒改過了則該舊值O不是最新版本的值了,所以不能將新值N賦給V,返回V即可。當多個執行緒使用CAS操作一個變數時,只有一個執行緒會成功,併成功更新,其餘會失敗。失敗的執行緒會重新嘗試,當然也可以選擇掛起執行緒。

元老級的Synchronized(未優化前)最主要的問題是:在存線上程競爭的情況下會出現執行緒阻塞和喚醒鎖帶來的效能問題,因為這是一種互斥同步(阻塞同步)。而CAS並不是武斷的間執行緒掛起,當CAS操作失敗後會進行一定的嘗試,而非進行耗時的掛起喚醒的操作,因此也叫做非阻塞同步。這是兩者主要的區別。

在J.U.C包中利用CAS實現類有很多,可以說是支撐起整個concurrency包的實現,在Lock實現中會有CAS改變state變數,在atomic包中的實現類也幾乎都是用CAS實現,關於這些具體的實現場景在之後會詳細聊聊,現在有個印象就好了(微笑臉)。

3.1.3 CAS的問題

1. ABA問題 因為CAS會檢查舊值有沒有變化,這裡存在這樣一個有意思的問題。比如一箇舊值A變為了成B,然後再變成A,剛好在做CAS時檢查發現舊值並沒有變化依然為A,但是實際上的確發生了變化。解決方案可以沿襲資料庫中常用的樂觀鎖方式,新增一個版本號可以解決。原來的變化路徑A->B->A就變成了1A->2B->3C。

2. 自旋時間過長

使用CAS時非阻塞同步,也就是說不會將執行緒掛起,會自旋(無非就是一個死迴圈)進行下一次嘗試,如果這裡自旋時間過長對效能是很大的消耗。如果JVM能支援處理器提供的pause指令,那麼在效率上會有一定的提升。

3. 只能保證一個共享變數的原子操作

當對一個共享變數執行操作時CAS能保證其原子性,如果對多個共享變數進行操作,CAS就不能保證其原子性。有一個解決方案是利用物件整合多個共享變數,即一個類中的成員變數就是這幾個共享變數。然後將這個物件做CAS操作就可以保證其原子性。atomic中提供了AtomicReference來保證引用物件之間的原子性。

3.2 優化

Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。

3.2.1 偏向鎖

經過研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。

獲取偏向鎖

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖。如果測試成功,表示執行緒已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設定成1(表示當前是偏向鎖):如果沒有設定,則使用CAS競爭鎖;如果設定了,則嘗試使用CAS將物件頭的偏向鎖指向當前執行緒。

撤銷偏向鎖

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。

如圖,偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有正在執行的位元組碼)。它會首先暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著,如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態;如果執行緒仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向物件的鎖記錄,棧中的鎖記錄和物件頭的Mark Word要麼重新偏向於其他執行緒,要麼恢復到無鎖或者標記物件不適合作為偏向鎖,最後喚醒暫停的執行緒。

關閉偏向鎖

偏向鎖在Java 6和Java 7裡是預設啟用的,但是它在應用程式啟動幾秒鐘之後才啟用,如有必要可以使用JVM引數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態。

偏向鎖的核心思想是,如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程式的效能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個執行緒申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的執行緒都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失。

3.2.2 輕量級鎖

倘若偏向鎖失敗,虛擬機器並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(1.6之後加入的),此時Mark Word 的結構也變為輕量級鎖的結構。輕量級鎖能夠提升程式效能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗資料。需要了解的是,輕量級鎖所適應的場景是執行緒交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹為重量級鎖。

3.2.3 自旋鎖

輕量級鎖失敗後,虛擬機器為了避免執行緒真實地在作業系統層面掛起,還會進行一項稱為自旋鎖的優化手段。這是基於在大多數情況下,執行緒持有鎖的時間都不會太長,如果直接掛起作業系統層面的執行緒可能會得不償失,畢竟作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的執行緒可以獲得鎖,因此虛擬機器會讓當前想要獲取鎖的執行緒做幾個空迴圈(這也是稱為自旋的原因),一般不會太久,可能是50個迴圈或100迴圈,在經過若干次迴圈後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將執行緒在作業系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級為重量級鎖了。

3.2.4 各種鎖的比較

這篇的內容有些抽象,主要參考的是《Java併發程式設計藝術》

相關文章