Java實現鎖的幾種方式

鎖和同步,學習多線程避不開的兩個問題,Java提供了synchronized關鍵字來同步方法和代碼塊,還提供了很多方便易用的併發工具類,例如:LockSupport、CyclicBarrier、CountDownLatch、Semaphore…

有沒有想過自己實現一個鎖呢?

筆者通過一個“搶票”的程序,分別用幾種不同的方式來實現方法的同步和加鎖,並分析它們的優劣。

自旋

就是讓加鎖失敗的線程死循環,不要去執行邏輯代碼。

/**
 * @author 潘
 * @Description 搶票-自旋鎖
 */
public class Ticket {
    //加鎖標記
    private AtomicBoolean isLock = new AtomicBoolean(false);
    //票庫存
    private int ticketCount = 10;

    //搶票
    public void bye(){
        while (!lock()) {
            //加鎖失敗,自旋
        }
        String name = Thread.currentThread().getName();
        //加鎖成功,執行業務邏輯
        System.out.println(name + ":加鎖成功...");
        System.out.println(name + ":開始搶票...");
        //SleepUtil.sleep(1000);
        ticketCount--;
        System.out.println(name + ":搶到了,庫存:" + ticketCount);
        System.out.println(name + ":釋放鎖.");
        unlock();
    }

    //加鎖的過程必須是原子操作,否則會導致多個線程同時加鎖成功。
    public boolean lock(){
        return isLock.compareAndSet(false, true);
    }

    //釋放鎖
    public void unlock() {
        isLock.set(false);
    }

    public static void main(String[] args) {
        Ticket lock = new Ticket();
        //開啓10個線程去搶票
        for (int i = 0; i < 10; i++) {
            new Thread(() -> lock.bye()).start();
        }
    }
}

輸出如下:

Thread-0:加鎖成功...
Thread-0:開始搶票...
Thread-0:搶到了,庫存:9
Thread-0:釋放鎖.
Thread-3:加鎖成功...
Thread-3:開始搶票...
Thread-3:搶到了,庫存:8
Thread-3:釋放鎖.
Thread-4:加鎖成功...
Thread-4:開始搶票...
Thread-4:搶到了,庫存:7
Thread-4:釋放鎖.
......

加鎖的過程必須是原子操作,否則會導致多個線程同時加鎖成功。

自旋是實現加鎖最簡單的方式,但是缺點也很明顯:

  • 自旋時CPU空轉,浪費CPU資源。
  • 如果使用不當,線程一直獲取不到鎖,會造成CPU使用率極高,甚至系統崩潰。

yield+自旋

要解決自旋鎖的性能問題,首先就是儘可能的防止CPU空轉,讓獲取不到鎖的線程主動讓出CPU資源。

獲取不到鎖的線程主動讓出CPU資源,可以通過Thread.yield()實現。

bye()可以做如下優化:

public void bye(){
    while (!lock()) {
        //獲取不到鎖,主動讓出CPU資源
        Thread.yield();
    }
    String name = Thread.currentThread().getName();
    //加鎖成功,執行業務邏輯
    System.out.println(name + ":加鎖成功...");
    System.out.println(name + ":開始搶票...");
    //SleepUtil.sleep(1000);
    ticketCount--;
    System.out.println(name + ":搶到了,庫存:" + ticketCount);
    System.out.println(name + ":釋放鎖.");
    unlock();
}

Thread.yield()雖然讓出了CPU資源,但還是會繼續爭奪,很可能CPU下次還會繼續分配時間片給該線程。

yield+自旋適用於兩個線程競爭的情況,如果線程太多,頻繁的yield也會增加CPU的調度開銷。

Sleep+自旋

除了使用yield讓出CPU資源外,還可以使用Sleep將獲取不到鎖的線程暫時休眠,不佔用CPU的資源。

bye()可以做如下優化:

public void bye(){
    while (!lock()) {
       //獲取不到鎖的線程,暫時休眠1ms,釋放CPU資源
        SleepUtil.sleep(1);
    }
    String name = Thread.currentThread().getName();
    //加鎖成功,執行業務邏輯
    System.out.println(name + ":加鎖成功...");
    System.out.println(name + ":開始搶票...");
    //SleepUtil.sleep(1000);
    ticketCount--;
    System.out.println(name + ":搶到了,庫存:" + ticketCount);
    System.out.println(name + ":釋放鎖.");
    unlock();
}

使用Sleep可以減輕CPU的壓力,但是缺點也很明顯:

  • sleep時間不可控

使用多線程的目的就是爲了提升性能,減少響應時間,我們無法預估線程運行結束的時間,sleep的時間是不可控的,在高併發的場景下,哪怕1毫秒、1納秒都應該分秒必爭。

性能測試

筆者進行了簡單的測試,搶奪一億張票,結果如下:

  • 自旋:耗時21806ms。
  • yield+自旋:耗時2543ms。
  • sleep+自旋:耗時1593ms。

測試結果僅供參考。

park+自旋

相較於前幾種,是比較好的一種實現方式,需要藉助於LockSupport來完成。

/**
 * @author 潘
 * @Description 搶票-park+自旋
 */
public class TicketPark {
    //加鎖標記
    private AtomicBoolean isLock = new AtomicBoolean(false);
    //票庫存
    private int ticketCount = 10;
    //等待線程隊列
    private final Queue<Thread> WAIT_THREAD_QUEUE = new LinkedBlockingQueue<>();

    //搶票
    public void bye(){
        while (!lock()) {
            //獲取不到鎖的線程,添加到隊列,並休眠
            lockWait();
        }
        String name = Thread.currentThread().getName();
        //加鎖成功,執行業務邏輯
        System.out.println(name + ":加鎖成功...");
        System.out.println(name + ":開始搶票...");
        ticketCount--;
        System.out.println(name + ":搶到了,庫存:" + ticketCount);
        System.out.println(name + ":釋放鎖.");
        unlock();
    }

    //加鎖的過程必須是原子操作,否則會導致多個線程同時加鎖成功。
    public boolean lock(){
        return isLock.compareAndSet(false, true);
    }

    //釋放鎖
    public void unlock() {
        isLock.set(false);
        //喚醒隊列中的第一個線程
        LockSupport.unpark(WAIT_THREAD_QUEUE.poll());
    }

    public void lockWait(){
        //將獲取不到鎖的線程添加到隊列
        WAIT_THREAD_QUEUE.add(Thread.currentThread());
        //並休眠
        LockSupport.park();
    }
}

java.util.concurrent包下很多類都是採用park+自旋來實現同步的,ReentrantLock也不例外!

尾巴

Java實現鎖大致分爲這麼幾種方式,感興趣的同學也可以自己動手寫一個Lock。

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