[Java併發]Lock

Duancf發表於2024-07-09

鎖的分類

  • 公平鎖/非公平鎖
  • 可重入鎖
  • 獨享鎖/共享鎖
  • 互斥鎖/讀寫鎖
  • 樂觀鎖/悲觀鎖
  • 分段鎖
  • 偏向鎖/輕量級鎖/重量級鎖
  • 自旋鎖

公平鎖/非公平鎖

公平鎖是指多個執行緒按照申請鎖的順序來獲取鎖。
非公平鎖是指多個執行緒獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的執行緒比先申請的執行緒優先獲取鎖。有可能,會造成優先順序反轉或者飢餓現象。

對於Java ReentrantLock而言,透過建構函式指定該鎖是否是公平鎖,預設是非公平鎖。非公平鎖的優點在於吞吐量比公平鎖大。
對於Synchronized而言,也是一種非公平鎖。由於其並不像ReentrantLock是透過AQS的來實現執行緒排程,所以並沒有任何辦法使其變成公平鎖。

可重入鎖

可重入鎖又名遞迴鎖,是指在同一個執行緒在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖。說的有點抽象,下面會有一個程式碼的示例。
對於Java ReentrantLock而言, 他的名字就可以看出是一個可重入鎖,其名字是Re entrant Lock重新進入鎖。
對於Synchronized而言,也是一個可重入鎖。可重入鎖的一個好處是可一定程度避免死鎖。

synchronized void setA() throws Exception{
	Thread.sleep(1000);
	setB();
}

synchronized void setB() throws Exception{
	Thread.sleep(1000);
}

上面的程式碼就是一個可重入鎖的一個特點,如果不是可重入鎖的話,setB可能不會被當前執行緒執行,可能造成死鎖。

獨享鎖/共享鎖

獨享鎖是指該鎖一次只能被一個執行緒所持有。
共享鎖是指該鎖可被多個執行緒所持有。

對於Java ReentrantLock而言,其是獨享鎖。但是對於Lock的另一個實現類ReadWriteLock,其讀鎖是共享鎖,其寫鎖是獨享鎖。
讀鎖的共享鎖可保證併發讀是非常高效的,讀寫,寫讀 ,寫寫的過程是互斥的。
獨享鎖與共享鎖也是透過AQS來實現的,透過實現不同的方法,來實現獨享或者共享。
對於Synchronized而言,當然是獨享鎖。

互斥鎖/讀寫鎖

上面講的獨享鎖/共享鎖就是一種廣義的說法,互斥鎖/讀寫鎖就是具體的實現。
互斥鎖在Java中的具體實現就是ReentrantLock
讀寫鎖在Java中的具體實現就是ReadWriteLock

樂觀鎖/悲觀鎖

樂觀鎖與悲觀鎖不是指具體的什麼型別的鎖,而是指看待併發同步的角度。
悲觀鎖認為對於同一個資料的併發操作,一定是會發生修改的,哪怕沒有修改,也會認為修改。因此對於同一個資料的併發操作,悲觀鎖採取加鎖的形式。悲觀的認為,不加鎖的併發操作一定會出問題。
樂觀鎖則認為對於同一個資料的併發操作,是不會發生修改的。在更新資料的時候,會採用嘗試更新,不斷重新的方式更新資料。樂觀的認為,不加鎖的併發操作是沒有事情的。

從上面的描述我們可以看出,悲觀鎖適合寫操作非常多的場景,樂觀鎖適合讀操作非常多的場景,不加鎖會帶來大量的效能提升。
悲觀鎖在Java中的使用,就是利用各種鎖。
樂觀鎖在Java中的使用,是無鎖程式設計,常常採用的是CAS演算法,典型的例子就是原子類,透過CAS自旋實現原子操作的更新。

分段鎖

分段鎖其實是一種鎖的設計,並不是具體的一種鎖,對於ConcurrentHashMap而言,其併發的實現就是透過分段鎖的形式來實現高效的併發操作。
我們以ConcurrentHashMap來說一下分段鎖的含義以及設計思想,ConcurrentHashMap中的分段鎖稱為Segment,它即類似於HashMap(JDK7與JDK8中HashMap的實現)的結構,即內部擁有一個Entry陣列,陣列中的每個元素又是一個連結串列;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。
當需要put元素的時候,並不是對整個hashmap進行加鎖,而是先透過hashcode來知道他要放在那一個分段中,然後對這個分段進行加鎖,所以當多執行緒put的時候,只要不是放在一個分段中,就實現了真正的並行的插入。
但是,在統計size的時候,可就是獲取hashmap全域性資訊的時候,就需要獲取所有的分段鎖才能統計。
分段鎖的設計目的是細化鎖的粒度,當操作不需要更新整個陣列的時候,就僅僅針對陣列中的一項進行加鎖操作。

偏向鎖/輕量級鎖/重量級鎖

這三種鎖是指鎖的狀態,並且是針對Synchronized。在Java 5透過引入鎖升級的機制來實現高效Synchronized。這三種鎖的狀態是透過物件監視器在物件頭中的欄位來表明的。
偏向鎖是指一段同步程式碼一直被一個執行緒所訪問,那麼該執行緒會自動獲取鎖。降低獲取鎖的代價。
輕量級鎖是指當鎖是偏向鎖的時候,被另一個執行緒所訪問,偏向鎖就會升級為輕量級鎖,其他執行緒會透過自旋的形式嘗試獲取鎖,不會阻塞,提高效能。
重量級鎖是指當鎖為輕量級鎖的時候,另一個執行緒雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的執行緒進入阻塞,效能降低。

自旋鎖

在Java中,自旋鎖是指嘗試獲取鎖的執行緒不會立即阻塞,而是採用迴圈的方式去嘗試獲取鎖,這樣的好處是減少執行緒上下文切換的消耗,缺點是迴圈會消耗CPU。
典型的自旋鎖實現的例子,可以參考自旋鎖的實現

Lock類

之前已經說道,JVM提供了synchronized關鍵字來實現對變數的同步訪問以及用wait和notify來實現執行緒間通訊。在jdk1.5以後,JAVA提供了Lock類來實現和synchronized一樣的功能,並且還提供了Condition來顯示執行緒間通訊。
Lock類是Java類來提供的功能,豐富的api使得Lock類的同步功能比synchronized的同步更強大。本文章的所有程式碼均在Lock類例子的程式碼
本文主要介紹一下內容:

  1. Lock類
  2. Lock類其他功能
  3. Condition類
  4. Condition類其他功能
  5. 讀寫鎖

Lock類實際上是一個介面,我們在例項化的時候實際上是例項化實現了該介面的類Lock lock = new ReentrantLock();。用synchronized的時候,synchronized可以修飾方法,或者對一段程式碼塊進行同步處理。
前面講過,針對需要同步處理的程式碼設定物件監視器,比整個方法用synchronized修飾要好。Lock類的用法也是這樣,透過Lock物件lock,用lock.lock來加鎖,用lock.unlock來釋放鎖。在兩者中間放置需要同步處理的程式碼。
具體的例子如下:

public class MyConditionService {

    private Lock lock = new ReentrantLock();
    public void testMethod(){
        lock.lock();
        for (int i = 0 ;i < 5;i++){
            System.out.println("ThreadName = " + Thread.currentThread().getName() + (" " + (i + 1)));
        }
        lock.unlock();
    }
}

測試的程式碼如下:

MyConditionService service = new MyConditionService();
new Thread(service::testMethod).start();
new Thread(service::testMethod).start();
new Thread(service::testMethod).start();
new Thread(service::testMethod).start();
new Thread(service::testMethod).start();

Thread.sleep(1000 * 5);

不加lock的結果

ThreadName = Thread-0 1
ThreadName = Thread-0 2
ThreadName = Thread-2 1
ThreadName = Thread-1 1
ThreadName = Thread-3 1
ThreadName = Thread-4 1
ThreadName = Thread-0 3
ThreadName = Thread-2 2
ThreadName = Thread-1 2
ThreadName = Thread-3 2
ThreadName = Thread-4 2
ThreadName = Thread-0 4
ThreadName = Thread-2 3
ThreadName = Thread-1 3
ThreadName = Thread-3 3
ThreadName = Thread-4 3
ThreadName = Thread-0 5
ThreadName = Thread-2 4
ThreadName = Thread-1 4
ThreadName = Thread-3 4
ThreadName = Thread-4 4
ThreadName = Thread-2 5
ThreadName = Thread-1 5
ThreadName = Thread-3 5
ThreadName = Thread-4 5

加了lock的結果

ThreadName = Thread-0 1
ThreadName = Thread-0 2
ThreadName = Thread-0 3
ThreadName = Thread-0 4
ThreadName = Thread-0 5
ThreadName = Thread-1 1
ThreadName = Thread-1 2
ThreadName = Thread-1 3
ThreadName = Thread-1 4
ThreadName = Thread-1 5
ThreadName = Thread-2 1
ThreadName = Thread-2 2
ThreadName = Thread-2 3
ThreadName = Thread-2 4
ThreadName = Thread-2 5
ThreadName = Thread-3 1
ThreadName = Thread-3 2
ThreadName = Thread-3 3
ThreadName = Thread-3 4
ThreadName = Thread-3 5
ThreadName = Thread-4 1
ThreadName = Thread-4 2
ThreadName = Thread-4 3
ThreadName = Thread-4 4
ThreadName = Thread-4 5

總之,就是每個執行緒的列印1-5都是同步進行,順序沒有亂。
透過下面的例子,可以看出Lock物件加鎖的時候也是一個物件鎖,持有物件監視器的執行緒才能執行同步程式碼,其他執行緒只能等待該執行緒釋放物件監視器。

public class MyConditionMoreService {

    private Lock lock = new ReentrantLock();
    public void methodA(){
        try{
            lock.lock();
            System.out.println("methodA begin ThreadName=" + Thread.currentThread().getName() +
                    " time=" + System.currentTimeMillis());
            Thread.sleep(1000 * 5);

            System.out.println("methodA end ThreadName=" + Thread.currentThread().getName() +
                    " time=" + System.currentTimeMillis());
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public void methodB(){
        try{
            lock.lock();
            System.out.println("methodB begin ThreadName=" + Thread.currentThread().getName() +
                    " time=" + System.currentTimeMillis());
            Thread.sleep(1000 * 5);

            System.out.println("methodB end ThreadName=" + Thread.currentThread().getName() +
                    " time=" + System.currentTimeMillis());
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}

測試程式碼如下:

 public void testMethod() throws Exception {
        MyConditionMoreService service = new MyConditionMoreService();
        ThreadA a = new ThreadA(service);
        a.setName("A");
        a.start();

        ThreadA aa = new ThreadA(service);
        aa.setName("AA");
        aa.start();

        ThreadB b = new ThreadB(service);
        b.setName("B");
        b.start();

        ThreadB bb = new ThreadB(service);
        bb.setName("BB");
        bb.start();

        Thread.sleep(1000 * 30);
    }
    
public class ThreadA extends Thread{

    private MyConditionMoreService service;

    public ThreadA(MyConditionMoreService service){
        this.service = service;
    }

    @Override
    public void run() {
        service.methodA();
    }
}

public class ThreadB extends Thread{

    private MyConditionMoreService service;

    public ThreadB(MyConditionMoreService service){
        this.service = service;
    }

    @Override
    public void run() {
        super.run();
        service.methodB();
    }
}

結果如下:

methodA begin ThreadName=A time=1485590913520
methodA end ThreadName=A time=1485590918522
methodA begin ThreadName=AA time=1485590918522
methodA end ThreadName=AA time=1485590923525
methodB begin ThreadName=B time=1485590923525
methodB end ThreadName=B time=1485590928528
methodB begin ThreadName=BB time=1485590928529
methodB end ThreadName=BB time=1485590933533

可以看出Lock類加鎖確實是物件鎖。針對同一個lock物件執行的lock.lock是獲得物件監視器的執行緒才能執行同步程式碼 其他執行緒都要等待。
在這個例子中,加鎖,和釋放鎖都是在try-finally。這樣的好處是在任何異常發生的情況下,都能保障鎖的釋放。

Lock類其他的功能

如果Lock類只有lock和unlock方法也太簡單了,Lock類提供了豐富的加鎖的方法和對加鎖的情況判斷。主要有

  • 實現鎖的公平
  • 獲取當前執行緒呼叫lock的次數,也就是獲取當前執行緒鎖定的個數
  • 獲取等待鎖的執行緒數
  • 查詢指定的執行緒是否等待獲取此鎖定
  • 查詢是否有執行緒等待獲取此鎖定
  • 查詢當前執行緒是否持有鎖定
  • 判斷一個鎖是不是被執行緒持有
  • 加鎖時如果中斷則不加鎖,進入異常處理
  • 嘗試加鎖,如果該鎖未被其他執行緒持有的情況下成功

實現公平鎖

在例項化鎖物件的時候,構造方法有2個,一個是無參構造方法,一個是傳入一個boolean變數的構造方法。當傳入值為true的時候,該鎖為公平鎖。預設不傳引數是非公平鎖。

公平鎖:按照執行緒加鎖的順序來獲取鎖
非公平鎖:隨機競爭來得到鎖
此外,JAVA還提供isFair()來判斷一個鎖是不是公平鎖。

獲取當前執行緒鎖定的個數

Java提供了getHoldCount()方法來獲取當前執行緒的鎖定個數。所謂鎖定個數就是當前執行緒呼叫lock方法的次數。一般一個方法只會呼叫一個lock方法,但是有可能在同步程式碼中還有呼叫了別的方法,那個方法內部有同步程式碼。這樣,getHoldCount()返回值就是大於1。

下面的方法用來判斷等待鎖的情況

獲取等待鎖的執行緒數

Java提供了getQueueLength()方法來得到等待鎖釋放的執行緒的個數。

查詢指定的執行緒是否等待獲取此鎖定

Java提供了hasQueuedThread(Thread thread)查詢該Thread是否等待該lock物件的釋放。

查詢是否有執行緒等待獲取此鎖定

同樣,Java提供了一個簡單判斷是否有執行緒在等待鎖釋放即hasQueuedThreads()

下面的方法用來判斷持有鎖的情況

查詢當前執行緒是否持有鎖定

Java不僅提供了判斷是否有執行緒在等待鎖釋放的方法,還提供了是否當前執行緒持有鎖即isHeldByCurrentThread(),判斷當前執行緒是否有此鎖定。

判斷一個鎖是不是被執行緒持有

同樣,Java提供了簡單判斷一個鎖是不是被一個執行緒持有,即isLocked()

下面的方法用來實現多種方式加鎖

加鎖時如果中斷則不加鎖,進入異常處理

Lock類提供了多種選擇的加鎖方法,lockInterruptibly()也可以實現加鎖,但是當執行緒被中斷的時候,就會加鎖失敗,進行異常處理階段。一般這種情況出現在該執行緒已經被打上interrupted的標記了。

嘗試加鎖,如果該鎖未被其他執行緒持有的情況下成功0

Java提供了tryLock()方法來進行嘗試加鎖,只有該鎖未被其他執行緒持有的基礎上,才會成功加鎖。

上面介紹了Lock類來實現程式碼的同步處理,下面介紹Condition類來實現wait/notify機制。

鎖膨脹

Condition類

Condition是Java提供了來實現等待/通知的類,Condition類還提供比wait/notify更豐富的功能,Condition物件是由lock物件所建立的。但是同一個鎖可以建立多個Condition的物件,即建立多個物件監視器。這樣的好處就是可以指定喚醒執行緒。notify喚醒的執行緒是隨機喚醒一個。
下面,看一個例子,顯示簡單的等待/通知

public class ConditionWaitNotifyService {

    private Lock lock = new ReentrantLock();
    public Condition condition = lock.newCondition();


    public void await(){
        try{
            lock.lock();
            System.out.println("await的時間為 " + System.currentTimeMillis());
            condition.await();
            System.out.println("await結束的時間" + System.currentTimeMillis());
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }


    public void signal(){
        try{
            lock.lock();
            System.out.println("sign的時間為" + System.currentTimeMillis());
            condition.signal();
        }finally {
            lock.unlock();
        }
    }
}

測試的程式碼如下:

        ConditionWaitNotifyService service = new ConditionWaitNotifyService();
        new Thread(service::await).start();
        Thread.sleep(1000 * 3);
        service.signal();
        Thread.sleep(1000);

結果如下:

await的時間為 1485610107421
sign的時間為1485610110423
await結束的時間1485610110423

condition物件透過lock.newCondition()來建立,用condition.await()來實現讓執行緒等待,是執行緒進入阻塞。用condition.signal()來實現喚醒執行緒。喚醒的執行緒是用同一個conditon物件呼叫await()方法而進入阻塞。並且和wait/notify一樣,await()和signal()也是在同步程式碼區內執行。
此外看出await結束的語句是在獲取通知之後才執行,確實實現了wait/notify的功能。下面這個例子是展示喚醒制定的執行緒。

ConditionAllService service = new ConditionAllService();
Thread a = new Thread(service::awaitA);
a.setName("A");
a.start();

Thread b = new Thread(service::awaitB);
b.setName("B");
b.start();

Thread.sleep(1000 * 3);

service.signAAll();

Thread.sleep(1000 * 4);

結果如下:

begin awaitA時間為 1485611065974ThreadName=A
begin awaitB時間為 1485611065975ThreadName=B
signAll的時間為1485611068979ThreadName=main
end awaitA時間為1485611068979ThreadName=A

該結果確實展示用同一個condition物件來實現等待通知。
對於等待/通知機制,簡化而言,就是等待一個條件,當條件不滿足時,就進入等待,等條件滿足時,就通知等待的執行緒開始執行。為了實現這種功能,需要進行wait的程式碼部分與需要進行通知的程式碼部分必須放在同一個物件監視器裡面。執行才能實現多個阻塞的執行緒同步執行程式碼,等待與通知的執行緒也是同步進行。對於wait/notify而言,物件監視器與等待條件結合在一起 即synchronized(物件)利用該物件去呼叫wait以及notify。但是對於Condition類,是物件監視器與條件分開,Lock類來實現物件監視器,condition物件來負責條件,去呼叫await以及signal。

Condition類的其他功能

和wait類提供了一個最長等待時間,awaitUntil(Date deadline)在到達指定時間之後,執行緒會自動喚醒。但是無論是await或者awaitUntil,當執行緒中斷時,進行阻塞的執行緒會產生中斷異常。Java提供了一個awaitUninterruptibly的方法,使即使執行緒中斷時,進行阻塞的執行緒也不會產生中斷異常。

讀寫鎖

Lock類除了提供了ReentrantLock的鎖以外,還提供了ReentrantReadWriteLock的鎖。讀寫鎖分成兩個鎖,一個鎖是讀鎖,一個鎖是寫鎖。讀鎖與讀鎖之間是共享的,讀鎖與寫鎖之間是互斥的,寫鎖與寫鎖之間也是互斥的。
看下面的讀讀共享的例子:

public class ReadReadService {
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    public void read(){
        try{
            try{
                lock.readLock().lock();
                System.out.println("獲得讀鎖" + Thread.currentThread().getName() +
                " " + System.currentTimeMillis());
                Thread.sleep(1000 * 10);
            }finally {
                lock.readLock().unlock();
            }
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

測試的程式碼和結果如下:

        ReadReadService service = new ReadReadService();
        Thread a = new Thread(service::read);
        a.setName("A");

        Thread b = new Thread(service::read);
        b.setName("B");

        a.start();
        b.start();

結果如下:

獲得讀鎖A 1485614976979
獲得讀鎖B 1485614976981

兩個執行緒幾乎同時執行同步程式碼。
下面的例子是寫寫互斥的例子

public class WriteWriteService {
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void write(){
        try{
            try{
                lock.writeLock().lock();
                System.out.println("獲得寫鎖" + Thread.currentThread().getName() +
                        " " +System.currentTimeMillis());
                Thread.sleep(1000 * 10);
            }finally {
                lock.writeLock().unlock();
            }
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

測試程式碼和結果如下:

        WriteWriteService service = new WriteWriteService();
        Thread a = new Thread(service::write);
        a.setName("A");
        Thread b = new Thread(service::write);
        b.setName("B");

        a.start();
        b.start();
        Thread.sleep(1000 * 30);

結果如下:

獲得寫鎖A 1485615316519
獲得寫鎖B 1485615326524

兩個執行緒同步執行程式碼

讀寫互斥的例子:

public class WriteReadService {

    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void read(){
        try{
            try{
                lock.readLock().lock();
                System.out.println("獲得讀鎖" + Thread.currentThread().getName()
                        + " " + System.currentTimeMillis());
                Thread.sleep(1000 * 10);
            }finally {
                lock.readLock().unlock();
            }
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }

    public void write(){
        try{
            try{
                lock.writeLock().lock();
                System.out.println("獲得寫鎖" + Thread.currentThread().getName()
                        + " " + System.currentTimeMillis());
                Thread.sleep(1000 * 10);
            }finally {
                lock.writeLock().unlock();
            }
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }

}

測試的程式碼如下:

        WriteReadService service = new WriteReadService();
        Thread a = new Thread(service::write);
        a.setName("A");
        a.start();
        Thread.sleep(1000);

        Thread b = new Thread(service::read);
        b.setName("B");
        b.start();

        Thread.sleep(1000 * 30);

結果如下:

獲得寫鎖A 1485615633790
獲得讀鎖B 1485615643792

兩個執行緒讀寫之間也是同步執行程式碼。

可重入鎖 ReentrantLock

無鎖

偏向鎖

輕量級鎖

重量級鎖

相關文章