1. 程式人生 > 實用技巧 >Java併發-Synchronized關鍵字

Java併發-Synchronized關鍵字

一、多執行緒下的i++操作的併發問題

package passtra;

public class SynchronizedDemo implements Runnable{

    private static int count=0;
    
    @Override
    public void run() {

        for(int i=0;i<10000000;i++){
            count++;
        }
    }
    
    public static void main(String[] args) {
        for(int i=0;i<10;i++){
            
new Thread(new SynchronizedDemo()).start(); } try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.err.println("result:"+count); } }

開啟了10個執行緒,每個執行緒都累加了10000000次,如果結果正確的話總數應該是10*10000000=1000000000.可是執行多次結果都不是這個數,而且每次執行結果都不一樣。

執行緒安全問題主要來源於JMM的設計,主要集中在主記憶體和執行緒的工作記憶體而導致的記憶體可見性問題,以及重排序導致的問題。

執行緒執行時擁有自己的棧空間,會在自己的棧空間執行,如果多執行緒間沒有共享的資料也就是說多執行緒間並沒有協作完成一件事情,那麼多執行緒就不能發揮優勢,不能帶來巨大的價值。

那麼共享資料的執行緒安全問題怎麼處理?就是讓每個執行緒依次去讀寫這個共享變數,這樣就不會有任何資料安全的問題,因為每個執行緒所操作的都是當前最新版本資料。那麼,在Java中synchronized就具有使每個執行緒依次排隊操作共享變數的功能,很顯然,這種同步機制相率很低,但synchronized是其他併發容器實現的基礎。

package passtra;

public class SynchronizedDemo implements Runnable{

    private static int count=0;
    
    @Override
    public void run() {

        synchronized(SynchronizedDemo.class){
            for(int i=0;i<10000000;i++){
                count++;
            }
            
        }
    }
    
    public static void main(String[] args) {
        for(int i=0;i<10;i++){
            new Thread(new SynchronizedDemo()).start();
        }
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.err.println("result:"+count);
        
    }

}

二、synchronized最主要的使用方式

在Java程式碼中使用synchronized可使用在程式碼塊和方法中,根據synchronized使用位置可以有這些使用場景:

synchronized可以用在方法上也可以用在程式碼塊上。其中方法還是例項方法和靜態方法分別鎖的的是該類的例項物件和該類的物件。修飾例項方法,作用於當前的例項物件加鎖,進入程式碼同步程式碼前要獲得當前物件的例項的鎖。修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前物件的鎖。

也就是給當前類加鎖,會作用於類的所有物件例項,因為靜態成員不屬於任何一個例項物件,是類成員(static表明這是該類的一個靜態資源,不管new了多少個物件,只有一份,所有對該類的所有物件都加了鎖)。所以如果一個執行緒A呼叫一個例項物件的非靜態synchronized方法,而執行緒B需要呼叫這個例項物件的所屬類的靜態synchronized方法,是允許的,不會發生互斥現象,因為訪問靜態synchronized方法佔用的鎖時當前的類,而訪問非靜態synchronized方法佔用的是當前例項物件鎖

修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼快庫前要獲得給定物件的鎖。

和synchronized方法一樣,synchronized(this)程式碼塊也是鎖定當前物件的。synchronized關鍵字加到static關鍵字和synchronized(class)程式碼塊上都是給Class類上鎖。另外需要注意的是:儘量不要用synchronized(String a)因為JVM中,字串常量池具有緩衝功能

三、寫一個synchronized實現的雙重校驗實現的單例模式

package passtra;

public class Singlethon{
    
    private volatile static Singlethon uniqueInstance;
    
    private Singlethon(){
        
    }
    
    public static Singlethon getUniqueInstance(){
        //先判斷物件是否例項化過,沒有例項化進入加鎖程式碼
        if(uniqueInstance==null){
            //類物件加鎖
            synchronized(Singlethon.class){
                if(uniqueInstance ==null){
                    uniqueInstance=new Singlethon();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance採用volatile關鍵字修飾也是很有必要的,可以禁止JVM的指令重排,保證在多執行緒環境下也能正常執行,uniqueInstance=new Singletion();這段程式碼其實是分三步執行的:

1、為uniqueInstance分配記憶體空間

2、初始化uniqueInstance

3、將uniqueInstance指向分配的記憶體地址

但由於JVM具有指令重排的特性,執行順序有可能變成1->3->2.指令重排在單執行緒下不會出現問題,但在多執行緒下會導致一個執行緒互毆的還沒有初始化的例項。例如,執行緒A執行了1和3,此時執行緒B呼叫getuniqueInstance()後發現uniqueInstance不為空,因此返回uniqueInstance,但此時uniqueInstance還未被初始化。

四、synchronized在JDK1.6之後有哪些底層優化

在早期的版本中,synchronized屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層作業系統的Mntex Lock來實現的,Java的執行緒是對映到作業系統的原生執行緒之上的。如果要掛起或者喚醒一個執行緒,都需要作業系統幫忙完成,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的synchronized效率低的原因。

在JDK1.6之後從JVM層面對synchronized較大優化,所以現在的synchronized鎖效率也優化得很不錯了。

JDK1.6對鎖的實現引入了大量的優化,如:自旋鎖、適應性自旋鎖、所消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

五、CAS操作

什麼是CAS

使用鎖時,執行緒獲取鎖是一種悲觀鎖策略,即假設每一次執行臨界區程式碼都會產生衝突,所以當前執行緒獲取到鎖的時候同時也會阻塞其他執行緒獲取該鎖。而CAS操作(又稱為無鎖操作)是一種樂觀鎖,它假設所有執行緒訪問共享資源的時候不會出現衝突,既然不會出現衝突自然就不會阻塞其他執行緒的操作,因此執行緒就不會出現阻塞停頓的狀態.如果出現了衝突怎麼辦?無鎖操作是使用CAS(compare and swap)又叫做比較交換來鑑別執行緒是否出現衝突,出現衝突就重試當前操作直到沒有衝突為止。

CAS操作過程

CAS比較交換的過程可以通俗的理解為CAS(V,O,N),包含三個值分別為:V:記憶體地址存放的實際值;O:預期的值(舊值);N:更新的值。當V和O相同時,也就是說舊值和記憶體中實際的值相同表明該值沒有被其他執行緒更改過,即該舊值O就是目前來說最新的值,就可以將新值N賦值給V。反之,V和O不相同,表明該值已經被其他執行緒改過了則該舊值O不是最新版本的值了,所以不能將新值N賦給V,返回V即可。當對個執行緒使用CAS操作一個變數時,只有一個執行緒會成功,並更新成功,其餘執行緒會失敗。失敗的執行緒會重新嘗試,當然也可以選擇掛起執行緒。

CAS的實現需要硬體指令集的支撐,在JDK1.5後虛擬機器才可以使用處理器提供的CMPXCHG指令實現。

CAS應用場景

在J.U.C包中利用CAS實現類有很多,在Lock實現中會由CAS改變state變數,在atomic中的實現類中幾乎都是用CAS實現,JDK1.8中的concurrentHashMap等等。

CAS問題

1、ABA問題

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

2、自旋時間過長

使用CAS非阻塞同步,會自旋進行下一次嘗試,如果這裡自旋時間過長對效能是很大的消耗。JDK1.6後加入了適應性自旋:如果某個鎖自旋很少成功獲得,那麼下一次就會減少自旋。

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

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

六、偏向鎖

大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一個執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。

偏向鎖的獲取

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

偏向鎖的撤銷

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

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

執行緒1展示了偏向鎖獲取的過程,執行緒2 展示了偏向鎖撤銷的過程。

如何關閉偏向鎖

偏向鎖在Java6和7裡是預設開啟的,但是它在應用程式啟動幾秒鐘之後才啟用,如有必要可以使用JVM引數來關閉延遲:-XX:BiasedLockingStartupDelay=0

如果確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖: -XX:UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態。

七、輕量級鎖

加鎖

執行緒在執行同步塊之前,JVM會先在當期執行緒的棧幀中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中,官方稱為Displace的 Mark Word。

然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。

如果更新成功,當前執行緒獲得鎖。

如果更新失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。

解鎖

輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到物件頭。

如果成功,則表示沒有競爭發生

如果失敗,便是當前鎖存在競爭,鎖就會膨脹成重量級鎖。

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞住,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭

八、偏向鎖,輕量級鎖、重量級鎖的比較

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於只有一個執行緒訪問同步塊場景
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度 如果始終得不到鎖競爭的執行緒,只用自旋會消耗CPU 追求響應時間,同步塊執行速度非常快
重量級鎖 執行緒競爭不會使用自旋,不會消耗CPU 執行緒阻塞,響應時間緩慢 追求吞吐量,同步塊執行速度較長

九、為什麼wait、notify、notifyAll要與synchronized一起使用

wait、notify、notifyAll方法都是Object的方法,也就是說,每個類中都有這些方法。

Object.wait():釋放當前物件鎖,並進入到阻塞佇列

Object.notify():喚醒當前物件阻塞佇列裡的任一執行緒(並不保證是哪個)

Object.notigyAll():喚醒當前阻塞佇列裡的所有執行緒

為什麼這三個方法要和synchronized一起使用呢?我們要先了解到:

每一個物件都有一個與之對應的監視器

每一個監視器裡面都有一個該物件的鎖和一個等待佇列和一個同步佇列

wait()方法的語義有兩個,一個是釋放當前鎖,另一個是進入阻塞佇列,可以看到,這些操作都是與監視器相關的,當然要指定一個監視器才能完成這個操作了。

notify()方法也是一樣,用來喚醒一個執行緒,你要去喚醒,首先你要知道它在哪,所以必須先找到該物件,也就是獲取該物件的鎖,當獲取物件的鎖之後,才能去該物件的對應的等待佇列去喚醒一個執行緒,值得注意的是,只有執行喚醒工作的執行緒離開同步塊,即釋放鎖之後,被喚醒的執行緒才能去競爭鎖。

notifyAll()方法和notify()方法一樣,只不過是喚醒等到佇列中的所有執行緒。

因wait()而阻塞的執行緒是放在阻塞佇列中的,因競爭失敗導致的阻塞是放在同步佇列中的,notify和notifyAll實質上是把阻塞佇列中的執行緒放到同步佇列中。