多執行緒的這些鎖知道嗎?手寫一個自旋鎖?

y浴血發表於2021-07-08

多執行緒中的各種鎖

1. 公平鎖、非公平鎖

1.1 概念:

公平鎖就是先來後到、非公平鎖就是允許加塞 Lock lock = new ReentrantLock(Boolean fair); 預設非公平

  • 公平鎖是指多個執行緒按照申請鎖的順序來獲取鎖,類似排隊打飯。
  • 非公平鎖是指多個執行緒獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的執行緒優先獲取鎖,在高併發的情況下,有可能會造成優先順序反轉或者節現象。

1.2 兩者區別?

  • 公平鎖:

    Threads acquire a fair lock in the order in which they requested it

    公平鎖,就是很公平,在併發環境中,每個執行緒在獲取鎖時,會先檢視此鎖維護的等待佇列,如果為空,或者當前執行緒就是等待佇列的第一個,就佔有鎖,否則就會加入到等待佇列中,以後會按照FIFO的規則從佇列中取到自己

  • 非公平鎖:

    a nonfair lock permits barging: threads requesting a lock can jump ahead of the queue of waiting threads if the lock happens to be available when it is requested

    非公平鎖比較粗魯,上來就直接嘗試佔有額,如果嘗試失敗,就再採用類似公平鎖那種方式。

1.3 如何體現公平非公平?

  • 對Java ReentrantLock而言,通過建構函式指定該鎖是否公平,預設是非公平鎖,非公平鎖的優點在於吞吐量比公平鎖大
  • 對Synchronized而言,是一種非公平鎖

image-20210707143515840

其中ReentrantLock和Synchronized預設都是非公平鎖,預設都是可重入鎖

2. 可重入鎖(遞迴鎖)

前文提到ReentrantLock和Synchronized預設都是非公平鎖,預設都是可重入鎖,那麼什麼是可重入鎖?

2.1 概念

指的時同一執行緒外層函式獲得鎖之後,內層遞迴函式仍然能獲取該鎖的程式碼,在同一個執行緒在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖,也就是說,執行緒可以進入任何一個它已經擁有的鎖所同步著的程式碼塊

2.2 為什麼要用到可重入鎖?

  1. 可重入鎖最大的作用是避免死鎖
  2. ReentrantLock/Synchronized 就是一個典型的可重入鎖

2.3 程式碼驗證可重入鎖?

首先我們先驗證ReentrantLock

package com.yuxue.juc.lockDemo;


import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 嘗試驗證ReentrantLock鎖的可重入性的Demo
 * */
public class ReentrantLockDemo {
    public static void main(String[] args) {

        mobile mobile = new mobile();

        new Thread(mobile,"t1").start();
        new Thread(mobile,"t2").start();
    }
}

/**
 * 輔助類mobile,首先繼承了Runnable介面,可以重寫run方法
 * 內部主要有兩個方法
 * run方法首先呼叫第一個方法
 * */
class mobile implements Runnable {

    Lock lock = new ReentrantLock();

    //run方法首先呼叫第一個方法
    @Override
    public void run() {
        testMethod01();
    }
    //第一個方法,目的是首先讓執行緒進入方法1
    public void testMethod01() {
        //加鎖
        lock.lock();
        try {
            //驗證執行緒進入方法1
            System.out.println(Thread.currentThread().getName() + "\t" + "get in the method1");
            //休眠
            Thread.sleep(2000);
            //進入方法2
            testMethod02();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    //第二個方法。目的是驗證ReentrantLock是否是可重入鎖
    public void testMethod02() {
        lock.lock();
        try {
            System.out.println("==========" + Thread.currentThread().getName() + "\t" + "leave the method1 get in the method2");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

因為同一個執行緒在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖,也就是說,執行緒可以進入任何一個它已經擁有的鎖所同步著的程式碼塊

之後觀察輸出結果為:

t1	get in the method1
==========t1	leave the method1 get in the method2
t2	get in the method1
==========t2	leave the method1 get in the method2

意味著執行緒t1進入方法1之後,再進入方法2,也就是說進入內層方法自動獲取鎖,之後釋放方法2的那把鎖,再釋放方法1的那把鎖,這之後執行緒t2才能獲取到方法1的鎖,才可以進入方法1

同樣地,如果我們在方法1中再加一把鎖,不給其解鎖,也就是

image-20210707153757001

那麼結果會是怎麼呢?我們執行程式碼可以得到

image-20210707153858881

我們發現執行緒是停不下來的,執行緒t1進入方法1加了兩把鎖,之後進入t2,但是退出t1的方法過程中沒有解鎖,這就導致了t2執行緒無法拿到鎖,也就驗證了鎖重入的問題

那麼為了驗證是同一把鎖,我們在方法1對其加鎖兩次,方法2對其解鎖兩次可以嗎?這鎖是相同的嗎?也就意味著:

image-20210707154054167

我們再次執行,發現結果為:

t1	get in the method1
==========t1	leave the method1 get in the method2
t2	get in the method1
==========t2	leave the method1 get in the method2

也就側面驗證了加鎖的是同一把鎖,更驗證了我們的鎖重入問題

那麼對於synchronized鎖呢?程式碼以及結果如下所示:

package com.yuxue.juc.lockDemo;

public class SynchronizedDemo {
    public static void main(String[] args) throws InterruptedException {
        phone phone = new phone();
        new Thread(()->{
            phone.phoneTest01();
        },"t1").start();
        Thread.sleep(1000);
        new Thread(()->{
            phone.phoneTest01();
        },"t2").start();
    }
}
class phone{
    public synchronized void phoneTest01(){
        System.out.println(Thread.currentThread().getName()+"\t invoked phoneTest01()");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        phoneTest02();
    }
    public synchronized void phoneTest02() {
        System.out.println(Thread.currentThread().getName()+"\t -----invoked phoneTest02()");
    }
}

結果為:

t1	 invoked phoneTest01()
t1	 -----invoked phoneTest02()
t2	 invoked phoneTest01()
t2	 -----invoked phoneTest02()

上述兩個實驗可以驗證我們的ReentrantLock以及synchronized都是可重入鎖!

3. 自旋鎖

3.1 自旋鎖概念

是指嘗試獲取鎖的執行緒不會立即阻塞,而是採用迴圈的方式去嘗試獲取鎖,這樣的好處是減少執行緒上下文切換的消耗,缺點是迴圈會消耗CPU

就是我們CAS一文當中提到的這段程式碼:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

其中while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4))這行程式碼更體現出了自旋鎖的核心,也就是當我嘗試去拿鎖的時候,一直迴圈,直到拿到鎖為止

3.2 手寫一個自旋鎖試試?

下面的AtomicReference可以去看CAS那篇有詳細講解

package com.yuxue.juc.lockDemo;

import java.util.concurrent.atomic.AtomicReference;

/**
 * 實現自旋鎖
 * 自旋鎖好處,迴圈比較獲取直到成功為止,沒有類似wait的阻塞
 *
 * 通過CAS操作完成自旋鎖,t1執行緒先進來呼叫mylock方法自己持有鎖2秒鐘,
 * t2隨後進來發現當前有執行緒持有鎖,不是null,所以只能通過自旋等待,直到t1釋放鎖後t2隨後搶到
 */

public class SpinLockDemo {
    public static void main(String[] args) {
        //資源類
        SpinLock spinLock = new SpinLock();
        //t1執行緒
        new Thread(()->{
            //加鎖
            spinLock.myLock();
            try {
                //休眠
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //解鎖
            spinLock.myUnlock();
        },"t1").start();

        //這裡主執行緒休眠,為了讓t1首先得到並加鎖
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //t2執行緒
        new Thread(()->{
            spinLock.myLock();
            try {
                //這裡休眠時間較長是為了讓輸出結果更加視覺化
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            spinLock.myUnlock();
        },"t2").start();
    }
}

class SpinLock {
    //構造原子引用類
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    //自己的加鎖方法
    public void myLock() {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "\t" + "come in myLock");
        //自旋核心程式碼!!當期望值是null並且主記憶體值也為null,就將其設定為自己的thread,否則就死迴圈,也就是一直自旋
        while (!atomicReference.compareAndSet(null, thread)) {

        }
    }

    public void myUnlock() {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "\t" + "===== come in myUnlock");
        //解鎖,用完之後,當期望值是自己的thread主實體記憶體的值也是自己的,也就是被自己的執行緒佔用
        //用完之後解鎖,將主實體記憶體中Thread的地方設定為空,供其他執行緒使用
        atomicReference.compareAndSet(thread, null);
    }
}

結果為:

//t1以及t2同時進入myLock方法,爭奪鎖使用權
t1	come in myLock
t2	come in myLock
//t1使用完首先釋放鎖
t1	===== come in myUnlock
//t2使用完釋放鎖,但是在5秒之後,因為在程式中我們讓其休眠了5s
t2	===== come in myUnlock

4. 讀寫鎖

分為:獨佔鎖(寫鎖)/共享鎖(讀鎖)/互斥鎖

4.1 概念

  • 獨佔鎖:指該鎖一次只能被一個執行緒所持有,對ReentrantLock和Synchronized而言都是獨佔鎖

  • 共享鎖:只該鎖可被多個執行緒所持有

    ReentrantReadWriteLock其讀鎖是共享鎖,寫鎖是獨佔鎖

  • 互斥鎖:讀鎖的共享鎖可以保證併發讀是非常高效的,讀寫、寫讀、寫寫的過程是互斥的

4.2 程式碼

首先是沒有讀寫鎖的程式碼,定義了一個資源類,裡面底層資料結構為HashMap,之後多個執行緒對其寫以及讀操作

package com.yuxue.juc.lockDemo;

import java.util.HashMap;
import java.util.Map;

class MyCache {
    //定義快取當中的資料結構為Map型,鍵為String,值為Object型別
    private volatile Map<String, Object> map = new HashMap<>();
    //向Map中新增元素的方法
    public void setMap(String key, Object value) {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "\t" + " put value:" + value);
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //底層的map直接put
        map.put(key, value);
        System.out.println(thread.getName() + "\t" + "put value successful");
    }
    //向Map中取出元素
    public void getMap(String key) {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "\t" + "get the value");
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //底層的map直接get,並且返回值為Object型別
        Object retValue = map.get(key);
        System.out.println(thread.getName() + "\t" + "get the value successful, is " + retValue);
    }

}

/**
 * 多個執行緒同時讀一個資源類沒有任何問題,所以為了滿足併發量,讀取共享資源應該可以同時進行。 * 但是
 * 如果有一個執行緒想取寫共享資源來,就不應該允許其他執行緒可以對資源進行讀或寫
 * 總結
 * 讀讀能共存
 * 讀寫不能共存
 * 寫寫不能共存
 */
public class ReadWriteLockDemo {
    public static void main(String[] args) {
        //建立資源類
        MyCache myCache = new MyCache();
        //建立5個執行緒
        for (int i = 0; i < 5; i++) {
            //lambda表達的特殊性,需要final變數
            final int tempInt = i;
            new Thread(() -> {
                //5個執行緒分別填值
                myCache.setMap(tempInt + "", tempInt + "");
            }, "thread-" + i).start();
        }
        //建立5個執行緒
        for (int i = 0; i < 5; i++) {
            final int tempInt = i;
            new Thread(() -> {
                //5個執行緒取值
                myCache.getMap(tempInt + "");
            }, "thread-" + i).start();
        }
    }
}

結果為:

thread-0	 put value:0
thread-1	 put value:1
thread-2	 put value:2
thread-3	 put value:3
thread-4	 put value:4
thread-0	get the value
thread-1	get the value
thread-2	get the value
thread-3	get the value
thread-4	get the value
thread-0	put value successful
thread-1	put value successful
thread-2	put value successful
thread-3	put value successful
thread-4	put value successful
...

上述執行結果看似沒有問題,但是違背了寫鎖最核心的本質,也就是如果有一個執行緒想取寫共享資源來,就不應該允許其他執行緒可以對資源進行讀或寫

所以出現問題,此時就需要用到我們的讀寫鎖,我們對我們自己的myLock()以及myUnlock()方法進行修改為使用讀寫鎖的版本:

class MyCache {
    //定義快取當中的資料結構為Map型,鍵為String,值為Object型別
    private volatile Map<String, Object> map = new HashMap<>();

    //讀寫鎖
    ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    /**
     *  寫操作:原子+獨佔
     *  整個過程必須是一個完整的統一體,中間不許被分割,不許被打斷 *
     * @param key
     * @param value
     * */
    //向Map中新增元素的方法
    public void setMap(String key, Object value) {
        try {
            readWriteLock.writeLock().lock();
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName() + "\t" + " put value:" + value);
            Thread.sleep(300);
            //底層的map直接put
            map.put(key, value);
            System.out.println(thread.getName() + "\t" + "put value successful");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }

    //向Map中取出元素
    public void getMap(String key) {
        try {
            readWriteLock.readLock().lock();
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName() + "\t" + "get the value");
            Thread.sleep(300);
            //底層的map直接get,並且返回值為Object型別
            Object retValue = map.get(key);
            System.out.println(thread.getName() + "\t" + "get the value successful, is " + retValue);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readWriteLock.readLock().unlock();
        }
    }
}

之後執行結果變成:

thread-1	 put value:1
thread-1	put value successful
thread-0	 put value:0
thread-0	put value successful
thread-2	 put value:2
thread-2	put value successful
thread-3	 put value:3
thread-3	put value successful
thread-4	 put value:4
thread-4	put value successful
thread-0	get the value
thread-1	get the value
thread-2	get the value
thread-4	get the value
thread-3	get the value
thread-2	get the value successful, is 2
thread-3	get the value successful, is 3
thread-0	get the value successful, is 0
thread-4	get the value successful, is 4
thread-1	get the value successful, is 1

也就對應上了寫鎖獨佔,必須當每一個寫鎖對應寫操作完成之後,才可以進行下一次寫操作,但是對於讀操作,就可以多個執行緒共享,一起去快取中讀取資料

相關文章