BAT面試官:你先手動用LockSupport實現一個先進先出的不可重入鎖?吊炸天

引言

不知道大家面試的過程有沒有遇到過吊炸天的面試官,一上來就說,你先手動實現一個先進先出的不可重入鎖。驚不驚喜?激不激動?大展身手的時刻到了,來,我們一起看看下面這個例子

public class FIFOMutex {

    private final AtomicBoolean locked = new AtomicBoolean(false);
    private final Queue<Thread> waiters
        = new ConcurrentLinkedQueue<Thread>();

    public void lock() {
        boolean wasInterrupted = false;
        Thread current = Thread.currentThread();
        waiters.add(current);

        // 只有自己在隊首纔可以獲得鎖,否則阻塞自己
        //cas 操作失敗的話說明這裏有併發,別人已經捷足先登了,那麼也要阻塞自己的
        //有了waiters.peek() != current判斷如果自己隊首了,爲什麼不直接獲取到鎖還要cas 操作呢?
        //主要是因爲接下來那個remove 操作把自己移除掉了額,但是他還沒有真正釋放鎖,鎖的釋放在unlock方法中釋放的
        while (waiters.peek() != current ||
            !locked.compareAndSet(false, true)) {
            //這裏就是使用LockSupport 來阻塞當前線程
            LockSupport.park(this);
            //這裏的意思就是忽略線程中斷,只是記錄下曾經被中斷過
            //大家注意這裏的java 中的中斷僅僅是一個狀態,要不要退出程序或者拋異常需要程序員來控制的
            if (Thread.interrupted()) {
                wasInterrupted = true;
            }
        }
        // 移出隊列,注意這裏移出後,後面的線程就處於隊首了,但是還是不能獲取到鎖的,locked 的值還是true,
        // 上面while 循環的中的cas 操作還是會失敗進入阻塞的
        waiters.remove();
        //如果被中斷過,那麼設置中斷狀態
        if (wasInterrupted) {
            current.interrupt();
        }

    }

    public void unlock() {
        locked.set(false);
        //喚醒位於隊首的線程
        LockSupport.unpark(waiters.peek());
    }

}

上面這個例子其實就是jdk中LockSupport 提供的一個例子。LockSupport 是提供線程的同步原語的非常底層的一個類,如果一定要深挖的話,他的實現又是借用了Unsafe這個類來實現的,Unsafe 類中的方法都是native 的,真正的實現是C++的代碼

通過上面這個例子,分別調用了以下兩個方法

    public static void park(Object blocker) 
    public static void unpark(Thread thread) 

LockSupport的等待和喚醒是基於許可的,這個許可在C++ 的代碼中用一個變量count來保存,它只有兩個可能的值,一個是0,一個是1。初始值爲0

調用一次park

  1. 如果count=0,阻塞,等待count 變成1
  2. 如果count=1,修改count=0,並且直接運行,整個過程沒有阻塞

調用一次unpark

  1. 如果count=0,修改count=1
  2. 如果count=1,保持count=1

多次連續調用unpark 效果等同於一次

所以整個過程即使你多次調用unpark,他的值依然只是等於1,並不會進行累加

源碼分析

park

    public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        //設置當前線程阻塞在blocker,主要是爲了方便以後dump 線程出來排查問題,接下來會講
        setBlocker(t, blocker);
        //調用UNSAFE來阻塞當前線程,具體行爲看下面解釋
        UNSAFE.park(false, 0L);
        //被喚醒之後來到這裏
        setBlocker(t, null);
    }

這裏解釋下UNSAFE.park(false, 0L)。調用這個方法會有以下情況

  1. 如果許可值爲1(也就是之前調用過unpark,並且後面沒有調用過park來消耗許可),立即返回,並且整個過程不阻塞,修改許可值爲0
  2. 如果許可值爲0,進行阻塞等待,直到以下三種情況發生會被喚醒
    1. 其他線程調用了unpark 方法指定喚醒該線程
    2. 其他線程調用該線程的interrupt方法指定中斷該線程
    3. 無理由喚醒該線程(就是耍流氓,下面會解析)

unpark

    public static void unpark(Thread thread) {
        if (thread != null)
        //通過UNSAFE 來喚醒指定的線程
        //注意我們需要保證該線程還是存活的
        //如果該線程還沒啓動或者已經結束了,調用該方法是沒有作用的
            UNSAFE.unpark(thread);
    }

源碼非常簡單,直接通過UNSAFE 來喚醒指定的線程,但是要注意一個非常關鍵的細節,就是這裏指定了喚醒的線程,這個跟Object 中的notify 完全不一樣的特性,synchronized 的鎖是加在對象的監視鎖上的,線程會阻塞在對象上,在喚醒的時候沒辦法指定喚醒哪個線程,只能通知在這個對象監視鎖 上等待的線程去搶這個鎖,具體是誰搶到這把鎖是不可預測的,這也就決定了synchronized 是沒有辦法實現類似上面這個先進先出的公平鎖。

park 和unpark 的調用不分先後順序

先來個列子


public class LockSupportTest {

    private static final Logger logger = LoggerFactory.getLogger(LockSupportTest.class);

    public static void main(String[] args) throws Exception {
        LockSupportTest test = new LockSupportTest();
        Thread park = new Thread(() -> {
            logger.info(Thread.currentThread().getName() + ":park線程先休眠一下,等待其他線程對這個線程執行一次unpark");
            try {
                Thread.sleep(4000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            logger.info(Thread.currentThread().getName() + ":調用park");
            LockSupport.park(test);
            logger.info(Thread.currentThread().getName() + ": 被喚醒");
        });

        Thread unpark = new Thread(() -> {
            logger.info(Thread.currentThread().getName() + ":調用unpark喚醒線程" + park.getName());
            LockSupport.unpark(park);
            logger.info(Thread.currentThread().getName() + ": 執行完畢");
        });
        park.start();
        Thread.sleep(2000);
        unpark.start();
    }
}

輸出結果:

18:52:42.065 Thread-0:park線程先休眠一下,等待其他線程對這個線程執行一次unpark
18:52:44.064 Thread-1:調用unpark喚醒線程Thread-0
18:52:44.064 Thread-1: 執行完畢
18:52:46.079 Thread-0:調用park
18:52:46.079 Thread-0:被喚醒

從結果中可以看到,即使先調用unpark,後調用park,線程也可以馬上返回,並且整個過程是不阻塞的。這個跟Object對象的wait()和notify()有很大的區別,Object 中的wait() 和notify()順序錯亂的話,會導致線程一直阻塞在wait()上得不到喚醒。正是LockSupport這個特性,使我們並不需要去關心線程的執行順序,大大的降低了死鎖的可能性。

支持超時

//nanos 單位是納秒,表示最多等待nanos 納秒,
//比如我最多等你1000納秒,如果你還沒到,就不再等你了,其他情況跟park 一樣
public static void parkNanos(Object blocker, long nanos)
//deadline 是一個絕對時間,單位是毫秒,表示等待這個時間點就不再等
//(比如等到今天早上9點半,如果你還沒到,我就不再等你了) ,其他情況跟park 一樣
public static void parkUntil(Object blocker, long deadline)

正是有了這個方法,所以我們平時用的ReentrantLock 等各種lock 纔可以支持超時等待,底層其實就是借用了這兩個方法來實現的。這個也是synchronized 沒有辦法實現的特性

支持查詢線程在哪個對象上阻塞

    public static Object getBlocker(Thread t) {
        if (t == null)
            throw new NullPointerException();
        return UNSAFE.getObjectVolatile(t, parkBlockerOffset);
    }

以前沒看源碼的時候有個疑問,線程都已經阻塞了,爲什麼還可以查看指定線程的阻塞在相關的對象上呢?不應該是調用的話也是沒有任何反應的的嗎?直到看了源碼,才知道它其實不是用該線程去直接獲取線程的屬性,而是通過UNSAFE.getObjectVolatile(t, parkBlockerOffset) 來獲取的。這個方法的意思就是獲取內存區域指定偏移量的對象

最佳實踐

阻塞語句LockSupport.park() 需要在循環體,例如本文一開始的例子

        while (waiters.peek() != current ||
            !locked.compareAndSet(false, true)) {
            //在循環體內
            LockSupport.park(this);
            //喚醒後來到這裏
            //忽略其他無關代碼
        }

如果不在循環體內會有什麼問題呢?假如變成以下代碼片段

        if (waiters.peek() != current ||
            !locked.compareAndSet(false, true)) {
            //在循環體內
            LockSupport.park(this);
            //喚醒後來到這裏
            //忽略其他無關代碼
        }

這裏涉及一個線程無理由喚醒的概念,也就是說阻塞的線程並沒有其他線程調用unpark() 方法的時候就被喚醒

假如先後來了兩個線程A和B,這時候A先到鎖,這個時候B阻塞。但是在A還沒釋放鎖的時候,同時B被無理由喚醒了,如果是if,那麼
線程B就直接往下執行獲取到了鎖,這個時候同時A和B都可以訪問臨界資源,這樣是不合法的,如果是while 循環的話,會判斷B不是
在隊首或者CAS 失敗的會繼續調用park 進入阻塞。所以大家記得park方法一定要放在循環體內

LockSupport中的 park ,unpark 和Object 中的wait,notify 比較

  1. 他們都可以實現線程之間的通訊
  2. park 和wait 都可以讓線程進入阻塞狀態
  3. park 和unpark 可以在代碼的任何地方使用
  4. wait 和notify,notifyAll 需要和synchronized 搭配使用,必須在獲取到監視鎖之後纔可以使用,例如
synchronized (lock){
 lock.wait()
}
  1. wait 和notify 需要嚴格控制順序,如果wait 在notify 後面執行,則這個wait 會一直得不到通知
  2. park 和unpark 通過許可來進行通訊,無需保證順序
  3. park 支持超時等待,但是wait 不支持
  4. unpark 支持喚醒指定線程,但是notify 不支持
  5. wait 和park 都可以被中斷喚醒,wait 會獲得一箇中斷異常

思考題

LockSupport 本質上也是一個Object,那麼調用LockSupport的unpark 可以喚醒調用LockSupport.wait() 方法的線程嗎?請把你的答案寫在留言區

看完兩件事

如果你覺得這篇內容對你挺有啓發,我想邀請你幫我2個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注公衆號「面試bat」,不定期分享原創知識,原創不易,請多支持(裏面還提供刷題小程序哦)。

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