【併發編程】面試官:有沒有比讀寫鎖更快的鎖?

文章開始之前先做個找工作的介紹吧,由於這次疫情影響,我一個UI朋友的公司破產之後他現在處於找工作階段,一直沒有找到自己合適的,三年工作經驗左右,座標深圳,如果有招UI的朋友可以聯繫我。

作品: http://yiming.zcool.com.cn/

面試三連

面試官:瞭解鎖嗎?

小明:瞭解,還經常用過。

面試官:說說synchronized和lock的區別吧

小明:synchronized是可重入鎖,由於lock是一個接口,重入性取決於實現,synchronized不支持中斷,而lock可以。。。。。。。。。。。。。。。。

面試官:好了,那有沒有比這兩種鎖更快的鎖呢?

小明:在讀多寫少的情況下,讀寫鎖比他們的效率更高。

面試官:那有沒有比讀寫鎖更快的鎖呢?

小明:。。。。。。。。。。

我靠,問的這麼深的嗎?小明當時就矇蔽了,因爲它項目中使用比較多的就是synchronized,讀寫鎖都很少用到,因爲很少牽扯到多線程問題,這個面試讓他知道了多線程的重要性。

什麼是讀寫鎖

讀寫鎖:允許多個線程同時讀,但是隻允許一個線程寫,在線程獲取到寫鎖的時候,其他寫操作和讀操作都會處於阻塞狀態,讀鎖和寫鎖也是互斥的,所以在讀的時候是不允許寫的,那如何實現一個讀寫鎖呢?

請參考:java併發編程之ReentrantReadWriteLock讀寫鎖

讀寫鎖比傳統的synchronized速度要快很多,原因就是在於讀寫鎖支持讀併發,而synchronized要求所有操作都是串行化,舉個例子,我需要查詢某個用戶的基本信息,這些信息很少發生變化,所以我們會將這部分信息存放到緩存中,我們的查詢操作爲:

Created with Raphaël 2.2.0開始查詢緩存緩存中是否存在數據?結束查詢數據庫添加到緩存yesno

按照上面流程圖,如果使用synchronized的時候,查詢緩存都會阻塞,但是使用讀寫鎖,查詢緩存時併發的,查詢數據庫是阻塞的,所以,讀寫鎖在讀多寫少的情況下,性能明顯要優於synchronized。

人類的文明在進步,java也在進步,對知識的渴望也在不斷的增加,所以我們就不斷的在想這麼一個問題,讀寫鎖的讀和寫是互斥,那我們能不能做到讀和寫支持併發呢?
在這裏插入圖片描述

StampedLock橫空出世

StampedLock其實是對讀寫鎖的一種改進,它支持在讀同時進行一個寫操作,也就是說,它的性能將會比讀寫鎖更快。

更通俗的講就是在讀鎖沒有釋放的時候是可以獲取到一個寫鎖,獲取到寫鎖之後,讀鎖阻塞,這一點和讀寫鎖一致,唯一的區別在於讀寫鎖不支持在沒有釋放讀鎖的時候獲取寫鎖。

StampedLock三種模式

  • 悲觀讀:與讀寫鎖的讀寫類似,允許多個線程獲取悲觀讀鎖
  • 寫鎖:與讀寫鎖的寫鎖類似,寫鎖和悲觀讀是互斥的。
  • 樂觀讀:無鎖機制,類似於數據庫中的樂觀鎖,它支持在不釋放樂觀讀的時候是可以獲取到一個寫鎖的,這點和讀寫鎖不同。

基本語法

我們先來看看悲觀讀於與寫鎖的基本語法

//獲取悲觀讀
        long stamp = lock.readLock();
        try{
            String info = mapCache.get(name);
            if(null != info){
                return info;
            }
        }finally {
            //釋放悲觀讀
            lock.unlock(stamp);
        }
        
        //獲取寫鎖
        stamp = lock.writeLock();
        try{
            //判斷一下緩存中是否被插入了數據
            String info = mapCache.get(name);
            if(null != info){
                return info;
            }
            //這裏是往數據庫獲取數據
            String infoByDb = mapDb.get(name);
            //講數據插入緩存
            mapCache.put(name,infoByDb);
        }finally {
            //釋放寫鎖
            lock.unlock(stamp);
        }

我們看到,StampedLock語法和讀寫鎖ReentrantReadWriteLock有了一點點區別,
獲取鎖的返回值:
StampedLock:long
ReentrantReadWriteLock:Lock

釋放鎖的方式:
StampedLock:unlock(stamp),需要傳入獲取鎖返回的那個long值。
ReentrantReadWriteLock:unlock(),直接調用unlock方法即可。

StampedLock完整的demo

package com.ymy.test;


import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.StampedLock;

public class StampedLockTest {

    private static final StampedLock lock = new StampedLock();

    //緩存中存儲的數據
    private static Map<String,String> mapCache = new HashMap<String, String>();

    //模擬數據庫存儲的數據
    private static Map<String,String> mapDb = new HashMap<String, String>();
    static {
        mapDb.put("zhangsan","你好,我是張三");
        mapDb.put("sili","你好,我是李四");
    }

    private static String getInfo(String name){
        //獲取悲觀讀
        long stamp = lock.readLock();
        try{
            String info = mapCache.get(name);
            if(null != info){
                System.out.println("在緩存中獲取到了數據");
                return info;
            }
        }finally {
            //釋放悲觀讀
            lock.unlock(stamp);
        }
        
        //獲取寫鎖
        stamp = lock.writeLock();
        try{
            //判斷一下緩存中是否被插入了數據
            String info = mapCache.get(name);
            if(null != info){
                System.out.println("獲取到了寫鎖,再次確認在緩存中獲取到了數據");
                return info;
            }
            //這裏是往數據庫獲取數據
            String infoByDb = mapDb.get(name);
            //講數據插入緩存
            mapCache.put(name,infoByDb);
            System.out.println("緩存中沒有數據,在數據庫獲取到了數據");
        }finally {
            //釋放寫鎖
            lock.unlock(stamp);
        }
        return null;
    }

    public static void main(String[] args) {
//線程1
        Thread t1 = new Thread(() ->{
            getInfo("zhangsan");
        });

        //線程2
        Thread t2 = new Thread(() ->{
            getInfo("zhangsan");
        });

        //線程啓動
        t1.start();
        t2.start();

        //線程同步
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }


    }

}

這是悲觀讀+寫鎖的使用方式,達到的效果與讀寫鎖(ReentrantReadWriteLock) 是一樣的,我們一起來驗證一下,我將代碼稍微做了一點改動,打印了兩個線程的執行日誌,同時當調用線程是zhangsan的時候休眠三秒,目的是爲了看lisi的線程能否成功的獲取到寫鎖,代碼如下

package com.ymy.test;


import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.StampedLock;
import java.util.logging.Logger;

public class StampedLockTest {

   private static Logger log = Logger.getLogger(StampedLockTest.class.getName());

    private static final StampedLock lock = new StampedLock();

    //緩存中存儲的數據
    private static Map<String,String> mapCache = new HashMap<String, String>();

    //模擬數據庫存儲的數據
    private static Map<String,String> mapDb = new HashMap<String, String>();
    static {
        mapDb.put("zhangsan","你好,我是張三");
        mapDb.put("sili","你好,我是李四");
    }

    private static String getInfo(String name){
        //獲取悲觀讀
        long stamp = lock.readLock();
        log.info("線程名:"+Thread.currentThread().getName()+" 獲取了悲觀讀鎖"  +"    用戶名:"+name);
        try{
            if("zhangsan".equals(name)){
                log.info("線程名:"+Thread.currentThread().getName()+" 休眠中"  +"    用戶名:"+name);
                Thread.sleep(3000);
                log.info("線程名:"+Thread.currentThread().getName()+" 休眠結束"  +"    用戶名:"+name);
            }
            String info = mapCache.get(name);
            if(null != info){
                log.info("在緩存中獲取到了數據");
                return info;
            }
        } catch (InterruptedException e) {
            log.info("線程名:"+Thread.currentThread().getName()+" 釋放了悲觀讀鎖");
            e.printStackTrace();
        } finally {
            //釋放悲觀讀
            lock.unlock(stamp);
        }
        
        //獲取寫鎖
        stamp = lock.writeLock();
        log.info("線程名:"+Thread.currentThread().getName()+" 獲取了寫鎖"  +"    用戶名:"+name);
        try{
            //判斷一下緩存中是否被插入了數據
            String info = mapCache.get(name);
            if(null != info){
                log.info("獲取到了寫鎖,再次確認在緩存中獲取到了數據");
                return info;
            }
            //這裏是往數據庫獲取數據
            String infoByDb = mapDb.get(name);
            //講數據插入緩存
            mapCache.put(name,infoByDb);
            log.info("緩存中沒有數據,在數據庫獲取到了數據");
        }finally {
            //釋放寫鎖
            log.info("線程名:"+Thread.currentThread().getName()+" 釋放了寫鎖" +"     用戶名:"+name);
            lock.unlock(stamp);
        }
        return null;
    }

    public static void main(String[] args) {
//線程1
        Thread t1 = new Thread(() ->{
            getInfo("zhangsan");
        });

        //線程2
        Thread t2 = new Thread(() ->{
            getInfo("lisi");
        });

        //線程啓動
        t1.start();
        t2.start();

        //線程同步
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }


    }

}

如果在zhansan的線程休眠階段李四的線程獲取到了寫鎖,那麼代表悲觀讀和寫鎖不是互斥的,反之互斥,請看代碼運行結果:

三月 29, 2020 11:30:58 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-2 獲取了悲觀讀鎖    用戶名:lisi
三月 29, 2020 11:30:58 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-1 獲取了悲觀讀鎖    用戶名:zhangsan
三月 29, 2020 11:30:58 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-1 休眠中    用戶名:zhangsan
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-1 休眠結束    用戶名:zhangsan
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-1 獲取了寫鎖    用戶名:zhangsan
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 緩存中沒有數據,在數據庫獲取到了數據
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-1 釋放了寫鎖     用戶名:zhangsan
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-2 獲取了寫鎖    用戶名:lisi
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 緩存中沒有數據,在數據庫獲取到了數據
三月 29, 2020 11:31:01 上午 com.ymy.test.StampedLockTest getInfo
信息: 線程名:Thread-2 釋放了寫鎖     用戶名:lisi

我們仔細看打印日誌的輸出時間, 11:30:58 lisi和zhangsan都獲取到了悲觀讀鎖,並且zhangsan開始休眠,然後11:31:01的時候休眠結束,zhangsan獲取到了寫鎖,所以悲觀讀與寫鎖肯定是互斥的,那這樣的效率不是和讀寫鎖一樣嗎?爲什麼說它比讀寫鎖更快呢?這不是矛盾嗎?

客官,別急啊,要記住精彩的永遠在最後,StampedLock特鎖模式我們只用了其中的兩個,還有一個沒有出場呢,下面我們來看看樂觀讀。

讓StampedLock性能更上一樓的樂觀讀

樂觀讀並不是一種鎖,所以請不要和悲觀讀聯繫在一起,它是一種無鎖機制,相當於java的原子類操作,所以理論上性能會比讀寫鎖(ReentrantReadWriteLock)更快一點,但不絕對。

當樂觀讀讀取了成員變量的時候,需要將變量賦值給局部變量,然後再判斷程序運行期間是否存在寫鎖,如果存在,升級爲悲觀讀。

我們一起來看一下樂觀讀的實現

package com.ymy.test;

import java.util.concurrent.locks.StampedLock;

public class NumSumTest {

    private static final StampedLock lock = new StampedLock();


    private static int num1 = 1;


    private static int num2 = 1;

    /**
     * 修改成員變量的值,+1
     *
     * @return
     */
    private static int sum() {
        System.out.println("求和方法被執行了");
        //獲取樂觀讀
        long stamp = lock.tryOptimisticRead();
        int cnum1 = num1;
        int cnum2 = num2;
        System.out.println("獲取到的成員變量值,cnum1:" + cnum1 + "   cnum2:" + cnum2);
        try {
            //休眠3秒,目的是爲了讓其他線程修改掉成員變量的值。
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //判斷在運行期間是否存在寫操作   true:不存在   false:存在
        if (!lock.validate(stamp)) {
            System.out.println("存在寫操作!");
            //存在寫鎖
            //升級悲觀讀鎖
            stamp = lock.readLock();
            try {
                System.out.println("升級悲觀讀鎖");
                cnum1 = num1;
                cnum2 = num2;
                System.out.println("重新獲取了成員變量的值=========== cnum1="+cnum1  +"    cnum2="+cnum2);
            } finally {
                //釋放悲觀讀鎖
                lock.unlock(stamp);
            }
        }
         return cnum1 + cnum2;
    }

    //使用寫鎖修改成員變量的值
    private static void updateNum() {
        long stamp = lock.writeLock();
        try {
            num1 = 2;
            num2 = 2;
        } finally {
            lock.unlock(stamp);
        }
    }


    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int sum = sum();
            System.out.println("求和結果:" + sum);
        });
        t1.start();
        //休眠1秒,目的爲了讓線程t1能執行到獲取成員變量之後
        Thread.sleep(1000);
        updateNum();
        t1.join();
        System.out.println("執行完畢");

    }

}


解釋代碼,定義了兩個成員變量,讓後利用t1線程去計算兩個成員變量的和,爲了能體現出樂觀讀的效果,我在sum()中休眠了3秒,目的是讓main主線程去修改掉成員變量的值,main函數中的休眠是爲了讓t1線程能準確地執行到讀取成員變量階段。

我們來看看執行的結果:

求和方法被執行了
獲取到的成員變量值,cnum1:1   cnum2:1
存在寫操作!
升級悲觀讀鎖
重新獲取了成員變量的值=========== cnum1=2    cnum2=2
求和結果:4
執行完畢

我們發現,t1首先讀取了兩個成員變量的值,然後發現了存在寫操作,那是因爲main函數利用寫鎖修改了兩個成員變量的值,這個時候升級爲了悲觀讀,再次獲取成員變量的值,然後再計算兩個值的和,爲什麼要升級悲觀讀鎖呢?因爲再文章開頭的時候說過悲觀讀鎖與寫鎖互斥,悲觀讀鎖之前並行,所以樂觀讀升級到悲觀讀鎖之後再獲取一次成員變量,可以保證再當前悲觀讀鎖中數據是線程安全的。

你瞭解樂觀讀的應用場景嗎

樂觀讀並不是StampedLock的專利,有很多地方都使用到了樂觀讀,比如數據庫的樂觀鎖悲觀鎖,java併發工具的原子類工具。

數據庫悲觀鎖與樂觀鎖可以參考:mysql:悲觀鎖與樂觀鎖

java 併發工具原子類參考:java併發編程:CAS(Compare and Swap)

使用StampedLock的注意事項

1.StampedLock屬於ReadWriteLock的子類,ReentrantReadWriteLock也是屬於ReadWriteLock的子類,你們發現他們的區別了嗎?看名字就能看出來StampedLock不支持重入鎖。

2.它適用於讀多寫少的情況,如果不是這中情況,請慎用,性能可能還不如synchronized。

3.StampedLock的悲觀讀鎖、寫鎖不支持條件變量。

4.千萬不能中斷阻塞的悲觀讀鎖或寫鎖,如果調用阻塞線程的interrupt(),會導致cpu飆升,如果希望StampedLock支持中斷操作,請使用readLockInterruptibly(悲觀讀鎖)與writeLockInterruptibly(寫鎖)。

總結

在讀多寫少的情況下推薦使用StampedLock,因爲它的樂觀讀,性能比讀寫鎖提升了很多,但是再其他應用場景中,使用它還需要慎重。

樂觀讀支持併發一個寫鎖,而悲觀讀和寫鎖互斥,所以在使用過程中,我們可以先使用樂觀讀。然後判斷是否存在寫鎖,如果存在,可以升級悲觀讀鎖,由於悲觀讀鎖和寫鎖的互斥性,他能保證線程的安全性問題,如果小明再平時的時候多看看我的博客的話,可能就不會被這個問題難住了。

感謝各位靚仔/靚女的觀看!
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章