理解Java鎖機制

理解Java鎖機制

1. synchronized

synchronized是JVM內部實現的鎖機制,他是一種可重入的、互斥的、悲觀的、非公平的同步鎖。

synchronized的幾種使用方式

  • 鎖靜態方法
public class TestThread {
    public static void main(String[] args) {
        test();
    }

    //同步靜態方法,就是對當前類的該方法加鎖,
    //不同對象只有一個線程能獲取實例,不同對象也競爭鎖
    public synchronized static void test(){
        System.out.println("thread");
        
    }
}
  • 鎖普通方法
public class TestThread {
    Object lock = new Object();

    public static void main(String[] args) {
        new TestThread().test();
    }

    //修飾普通方法(非靜態方法),就是對當前對象實例的這個方法加鎖,
    //只有同一對象才競爭鎖
    public synchronized void test() {
        System.out.println("thread");
    }
}
  • 鎖類本身class
public class TestThread {
    public static void main(String[] args) {
        test();
    }

    
    public static void test() {
        //對代碼塊加鎖,synchronized(class),執行到此處對當前類加鎖
        //不同對象之間競爭鎖
        synchronized(TestThread.class){
            System.out.println("thread");
        }
    }
}
  • 鎖類實例this
public class TestThread {
    public static void main(String[] args) {
        new TestThread().test();
    }

    
    public void test() {
        //對代碼塊加鎖,synchronized(this),執行到此處,對當前類實例加鎖,
        //不同對象之間不競爭鎖
        synchronized(TestThread.this){
            System.out.println("thread");
        }
    }
}
  • 鎖對象實例變量
public class TestThread {
    Object lock = new Object();
    public static void main(String[] args) {
        new TestThread().test();
    }

    
    public void test() {
        //對代碼塊加鎖,synchronized(lock),執行到此處,對當前變量對象加鎖
        //同一變量對象lock競爭鎖
        synchronized(lock){
            System.out.println("thread");
        }
    }
}

synchronized的實現原理

首先,我們來看上面對象鎖使用方式所對應的部分類字符指令集:

在這裏插入圖片描述

上面有紅框的地方就是獲取鎖和釋放鎖的指令。實際上Java使用了Monitor對象(C++實現)來實現,當前monitor信息存儲在Java對象頭裏,頭裏麪包含了兩個部分-Mark Word和Klass Point:

  • Mark Word:自身運行時的數據,例如分代年齡和鎖狀態標誌,這些信息都是與對象自身定義無關的數據,所以Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的數據,它會根據對象的狀態複用自己的存儲空間,也就是說在運行期間Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。
  • Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

Monitor

Monitor是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯,同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。

Monitor是依賴於底層的操作系統的Mutex Lock(互斥鎖)來實現的線程同步。在Java1.6以前,採用的是重量級鎖,在Java1.6中引入了經輕量級鎖和偏量鎖來減少獲取鎖和釋放鎖帶來的性能消耗,同時默認開啓自旋鎖。

2. lock

lock鎖的實現是ReentrantLock類,內部實現默認是非公平鎖,也可以根據構造函數開啓公平鎖。
構造函數:


    /**
     * 創建一個ReentrantLock實例,相當於調用ReentrantLock(false)
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * 創建一個帶公平參數的ReentrantLock
     *
     * @param fair {@code true} 公平鎖
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

構造函數中有個sync變量名對象,它實際上是Sync類對象,具體實現怎麼加鎖的就是在這個類中。公平鎖(FairSync)和非公平鎖(NonfairSync)都是這個類的子類。具體細節在下方公平鎖和非公平鎖詳細分析

3. volatile關鍵字

volatile用來修飾變量,只保證可見性,不保證原子性

  • 可見性:在所有線程對該對象進行讀操作的時候,獲取的值是最新的
  • 原子性:在寫操作中,讀-寫-讀是可以看做一次原子操作

例如

private volatile int x = 1;

volatile可以保證所有字段讀取是最新的值,但是當多個線程同時寫入的時候,它將讀-寫分別視爲一次單操作,所以當同時讀取時是相同值,但是執行完計算操作後,同時寫入,就不能保證是兩次計算疊加的值被更新。

例如兩個線程對x=x+1操作,一開始兩個線程同時讀取到x=1,然後各自線程執行完後均爲x=1;這個時候同時去寫入x,被更新後就爲2,與期望的值3是不一樣的。

說到這裏,就要了解一下CPU爲了優化、加快運算速度使用多線程執行運算,爲了對多線程的支持,使用了多級緩存(寄存器)來爲多個線程臨時保存數據,如一級緩存、二級緩存…等。線程更新了變量值後,會先寫入緩存中,然後在適當的時機更新主內存。

volatile字段會在變量更新後,馬上將值寫入到主內存中,這就保證了可見性,但是在寫的時候,不會鎖住當前對象,所以不會保證原子性。實際上這是一種樂觀鎖的實現。

3. 樂觀鎖-悲觀鎖(主線程鎖不鎖住同步資源)

樂觀鎖和悲觀鎖並不特指某個鎖,而是一種加鎖策略機制。

  • 樂觀鎖:自己在使用數據的時候,認爲不會有其他線程修改數據,所以在使用的時候不會加鎖。在更新的時候纔去判斷有沒有其他線程更新了數據,如果沒更新,就自己修改數據;已經被其他線程更新,就拋出失敗或重試。例如CAS

  • 悲觀鎖:自己在使用數據的時候,認爲有別的線程會修改當前數據,先將數據加鎖。

樂觀鎖在Java中是通過使用無鎖編程來實現,最常採用的是CAS算法,Java原子類中的遞增操作就通過CAS自旋實現的。

CAS機制

CAS,全稱Compare And Swap,比較並替換。CAS當中包含了三個基本操作數:

  • 內存地址V
  • 舊的預期值A
  • 要修改的新值B

更新一個變量值的時候,只有當變量的預期值A和內存地址V當中的實際值相同時纔會將內存地址V修改爲新值B。

在這裏插入圖片描述

再看原子類裏面的操作

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    //獲取並操作內存的數據。內部使用CAS實現
    private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
    //存儲value在AtomicInteger中的偏移量
    private static final long VALUE;

    static {
        try {
            VALUE = U.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }

    //存儲value在AtomicInteger中的偏移量。
    private volatile int value;
}

接下來我們來看自增函數incrementAndGet,實際使用的還是Unsafe的getAndAddInt方法

 public final int incrementAndGet() {
        return U.getAndAddInt(this, VALUE, 1) + 1;
    }

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            //navite方法,使用do-while自旋   
            v = this.getIntVolatile(o, offset);
        } while(!this.compareAndSwapInt(o, offset, v, v + delta));

        return v;
    }

getAndAddInt()循環獲取給定對象o中的偏移量處的值v,然後判斷內存值是否等於v。如果相等則將內存值設置爲 v + delta,否則返回false,繼續循環進行重試,直到設置成功才能退出循環,並且將舊值返回。整個“比較+更新”操作封裝在compareAndSwapInt()中,在JNI裏是藉助於一個CPU指令完成的,屬於原子操作,可以保證多個線程都能夠看到同一個變量的修改值。

問題

  • ABA問題:CAS需要在操作值的時候檢查內存值是否發生變化,沒有發生變化纔會更新內存值。但是如果內存值原來是A,後來變成了B,然後又變成了A,那麼CAS進行檢查時會發現值沒有發生變化,但是實際上是有變化的。ABA問題的解決思路就是在變量前面添加版本號,每次變量更新的時候都把版本號加一,這樣變化過程就從“A-B-A”變成了“1A-2B-3A”。jdk1.5提供了AtomicStampedReference來解決這一問題

  • 循環時間長開銷大。CAS操作如果長時間不成功,會導致其一直自旋,給CPU帶來非常大的開銷。

  • 只能保證一個共享變量的原子操作。對一個共享變量執行操作時,CAS能夠保證原子操作,但是對多個共享變量操作時,CAS是無法保證操作的原子性的。jdk1.5提供AtomicReference來解決原子性問題

悲觀鎖

上文講的lock和synchronized都是悲觀鎖,在使用時先加上鎖

public class TestThread {
    Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        new TestThread().test();
    }

    public void test() {
        //先加上鎖
        lock.lock();
        
        lock.unlock();
    }


    public synchronized void test2(){
        
    }


4. 自旋鎖和適應性自旋鎖(均不阻塞線程,是否有自旋次數限制)

阻塞和喚醒一個Java線程需要操作系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步代碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。

在許多場景中,同步鎖定的時間很短,如果使用阻塞(線程掛起)可能花費的時間太多。爲了避免這種線程切換,只需要讓當前線程多等待一會兒,這種操作我們稱之爲自旋。如果在自旋完成後前面鎖定同步資源的線程已經釋放了鎖,那麼當前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷。


//AtomicInteger.class類

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            //navite方法,使用do-while自旋 
            v = this.getIntVolatile(o, offset);
        } while(!this.compareAndSwapInt(o, offset, v, v + delta));

        return v;
    }

採用是do-while自旋方法來重複試探獲取鎖,這種只適合等待時間比較短的場景,因爲自旋中是使用循環去試探獲取鎖,還是會佔用CPU資源和執行時間,所以自旋不能代替阻塞。一般java中的自旋鎖會在10次左右就放棄。

還有一種是適應性自旋鎖,它和自旋鎖的區別就是它不會固定循環次數,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

在自旋鎖中,還有三種常見的鎖形式:TicketLock、CLHlock和MCSlock

  • TicketLock: 線程想要競爭某個鎖,需要先領一張ticket,然後監聽flag,發現flag被更新爲手上的ticket的值了,才能去佔領鎖

  • CLHlock:CLH鎖是一種基於鏈表的可擴展、高性能、公平的自旋鎖,申請線程只在本地變量上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。

  • MCSlock:和CLH鎖不同的是,CLH是輪訓前驅結點的鎖狀態,而MCSlock是輪訓自己的鎖狀態,當前驅結點釋放鎖的時候,會更新當前結點的鎖狀態

5. 公平鎖-非公平鎖(是否允許插隊)

公平鎖和非公平鎖的區別是,是否允許插隊。接來下我們從ReentrantLock實現來分析兩者的區別

    //FairSync.java公平鎖
    protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }


    //NonfairSync.java非公平鎖
    final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

區別就在於公平鎖多了hasQueuedPredecessors方法,這個方法主要是判斷當前線程是否位於同步隊列中的第一個。所以可以看出,公平鎖是按隊列排隊來獲取鎖,而非公平鎖則不判斷是否是在對頭,都可以獲取到鎖,這就達到了一個插隊的效果

6. 可重入鎖-非可重入鎖(是否同一線程可重複持有鎖)

public class TestThread {
    public synchronized void test2() {
        System.out.println("2");
        test3();
    }

    public synchronized void test3() {
        System.out.println("3");
    }
}

我們來看,如果是非可重入鎖,執行到test2()方法時,當前線程獲取到鎖,執行test3()時,由於當前線程已經獲取到了鎖,就獲取不到鎖,出現死鎖。但是是可重入鎖,test3()依然能獲取到鎖,然後繼續執行。

ReentrantLock繼承了中的Sync繼承了AbstractQueuedSynchronizer,在其中維護了一個status狀態來記錄重入的次數,初始值爲0。

當線程嘗試獲取鎖時,可重入鎖先嚐試獲取並更新status值,如果status == 0表示沒有其他線程在執行同步代碼,則把status置爲1,當前線程開始執行。如果status != 0,則判斷當前線程是否是獲取到這個鎖的線程,如果是的話執行status+1,且當前線程可以再次獲取鎖。而非可重入鎖是直接去獲取並嘗試更新當前status的值,如果status != 0的話會導致其獲取鎖失敗,當前線程阻塞。

釋放鎖時,可重入鎖同樣先獲取當前status的值,在當前線程是持有鎖的線程的前提下。如果status-1 == 0,則表示當前線程所有重複獲取鎖的操作都已經執行完畢,然後該線程纔會真正釋放鎖。而非可重入鎖則是在確定當前線程是持有鎖的線程之後,直接將status置爲0,將鎖釋放。

7. 共享鎖-排它鎖(是否獨佔鎖資源)

  • 共享鎖:在多線程讀取的時候共享讀鎖

  • 排它鎖:在寫操作進行時,獨佔當前鎖資源

ReentrantReadWriteLock是一種典型的共享鎖和排它鎖的結合

//ReentrantReadWriteLock.java
public class ReentrantReadWriteLock
        implements ReadWriteLock, java.io.Serializable {
    private static final long serialVersionUID = -6992448646407690164L;
    /** 內部類提供讀鎖 */
    private final ReentrantReadWriteLock.ReadLock readerLock;
    /** 內部類提供寫鎖 */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    /** 執行所有同步操作引擎 */
    final Sync sync;
}

//ReadLock
public static class ReadLock implements Lock, java.io.Serializable {
        private static final long serialVersionUID = -5992448646407690164L;
        private final Sync sync;

        /**
         * Constructor for use by subclasses
         *
         * @param lock the outer lock object
         * @throws NullPointerException if the lock is null
         */
        protected ReadLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
}   

//WriteLock
public static class WriteLock implements Lock, java.io.Serializable {
        private static final long serialVersionUID = -4992448646407690164L;
        private final Sync sync;

        /**
         * Constructor for use by subclasses
         *
         * @param lock the outer lock object
         * @throws NullPointerException if the lock is null
         */
        protected WriteLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
}

在ReentrantReadWriteLock裏面,讀鎖和寫鎖的鎖主體都是Sync,但讀鎖和寫鎖的加鎖方式不一樣。讀鎖是共享鎖,寫鎖是獨享鎖。讀鎖的共享鎖可保證併發讀非常高效,而讀寫、寫讀、寫寫的過程互斥,因爲讀鎖和寫鎖是分離的。所以ReentrantReadWriteLock的併發性相比一般的互斥鎖有了很大提升。

在最開始提及AQS的時候我們也提到了state字段(int類型,32位),該字段用來描述有多少線程獲持有鎖。

在獨享鎖中這個值通常是0或者1(如果是重入鎖的話state值就是重入的次數),在共享鎖中state就是持有鎖的數量。但是在ReentrantReadWriteLock中有讀、寫兩把鎖,所以需要在一個整型變量state上分別描述讀鎖和寫鎖的數量(或者也可以叫狀態)。於是將state變量“按位切割”切分成了兩個部分,高16位表示讀鎖狀態(讀鎖個數),低16位表示寫鎖狀態(寫鎖個數)。

8. 無鎖-偏向鎖-輕量級鎖-重量級鎖(鎖級別)

jdk1.5之前,只有無鎖狀態和重量級鎖,在jdk1.6之後,增加了偏向鎖,輕量級鎖。鎖狀態記錄在對象頭的Mark Word中

鎖狀態 存儲內容 標誌位
無鎖狀態 對象的hashCode、對象分代年齡、是否是偏向鎖(0) 01
偏量鎖 偏向線程ID、偏向時間戳、對象分代年齡、是否是偏向鎖(1) 01
輕量級鎖 指向棧中鎖記錄的指針 00
重量級鎖 指向互斥量(重量級鎖)的指針 10

無鎖

就是沒有對資源進行鎖定,所有線程都能訪問並修改同一個資源,但是隻有一個線程能修改成功,其他線程都會拋出錯誤或者重試。

無鎖的典型實現就是CAS機制,即修改操作在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有衝突就修改成功並退出,否則就會繼續循環嘗試。

偏向鎖

偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖,降低獲取鎖的代價。

在大部分情況下,鎖總是同一資源獲得,如果像1.5之前使用重量級鎖,會造成資源的浪費,如果只有一個線程訪問同步鎖時,會在Mark Word裏存儲鎖偏向的線程ID。在線程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裏是否存儲着指向當前線程的偏向鎖。引入偏向鎖是爲了在無多線程競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因爲輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。

偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態。撤銷偏向鎖後恢復到無鎖(標誌位爲“01”)或輕量級鎖(標誌位爲“00”)的狀態。

輕量級鎖

是指當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。

在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,然後拷貝對象頭中的Mark Word複製到鎖記錄中。

拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock Record裏的owner指針指向對象的Mark Word。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,表示此對象處於輕量級鎖定狀態。

如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。

若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級爲重量級鎖。

重量級鎖

升級爲重量級鎖時,鎖標誌的狀態值變爲“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態。

整體的鎖狀態升級流程如下:

在這裏插入圖片描述

綜上,偏向鎖通過對比Mark Word解決加鎖問題,避免執行CAS操作。而輕量級鎖是通過用CAS操作和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能。重量級鎖是將除了擁有鎖的線程以外的線程都阻塞。

總結

上面就是我簡易分析的java鎖相關機制,限於時間和個人水平,暫未進行更全面深層次的講解。讀者可根據情況去熟悉鎖的底層原理,多閱讀源碼

本文部分摘錄自https://mp.weixin.qq.com/s?__biz=MzU0OTE4MzYzMw==&mid=2247485524&idx=1&sn=2807a248ab60ce21b22dc07ec1b0ee0c&chksm=fbb281aaccc508bc404611ee11b057bf4b3e02fbbb2916c472fe586cf9ee989eab2be1c84e49&mpshare=1&scene=1&srcid=#rd
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章