JAVA 無鎖隊列/棧

概述

在多線程應用場景下,一般需要在代碼中實現 加鎖/解鎖 操作來確保線程安全。在某些情況下,鎖的處理會給程序帶了很大的性能影響。在這種情況下, 我們就考慮無鎖操作即在不加鎖的情況下,依舊能保證線程的安全執行。


JAVA 無鎖隊列/棧 的實現

本篇就以下六個模塊展開討論:

  1. 無鎖的原理
  2. 無鎖隊列的實現
  3. CountDownLatch簡單介紹
  4. 無鎖隊列的測試
  5. 無鎖棧的實現
  6. 無鎖棧的測試

1、無鎖的原理

無鎖的實現原理是 “CAP”(Campare and swap)。翻譯過來即“比較和交換”。

關於 CAP 的實現原理本篇我們不詳細展開論述,在後面的文章專門介紹。下面主要介紹一下 CAP 的核心方法:compareAndSet ()

在代碼中該方法一般是這樣調用的:

a.compareAndSet(oldValue, newValue)

其中 oldValue 表示老值,newValue 表示新值,該方法執行的結果有以下兩種:

  1. 如果變量a的值此時和 oldValue 相等,那麼將 newValue 賦予它,並返回 True。
  2. 如果變量a的值此時和 oldValue 不相等,那麼它不做任何操作,並返回 False。

其中爲了進行循環判斷,該方法一般和while循環結合使用,這也就是我們常稱的 自旋鎖

的作用是保證同一時刻最多只能有一個線程操作某塊內存,其他線程只能等到該線程處理完畢後進行讀寫操作。

無鎖 是無法保證某塊內存在同一時刻不被多個線程共享,它是在寫內存過程前提供一個期望值,如果期望值與當前值相等,說明該線程上一次讀取值之後,沒有其他線程對這塊內存進行處理,也就是說該操作是線程安全的

通過上面的概述其實我們不難發現,無鎖還是存在以下隱患的:即操作的內存被其他線程操作兩次,最終又返回原值時,無鎖是無法判斷是否有線程處理過該塊內存的,但這種場景一般不影響結果

舉例:變量a = 5,現在有兩個線程處理變量a,線程1要將它的值+5,線程2要將它的值+10,我們使用無鎖來模擬這種場景下可能產生的情況:

Created with Raphaël 2.2.0開始線程1讀取值5線程2讀取值5線程2進行運算,得到新值15線程2判斷舊值爲5,將新值15賦予變量並返回true線程1進行運算,得到新值10線程1判斷舊值不爲5,停止操作並返回false結束

2、無鎖隊列的實現

這裏我直接貼出代碼,通過註釋的方式列舉出每種操作對應的場景:

public class CapQueue<V> {

    /**
     * 模擬隊列中的節點,通過 next屬性相連接
     *
     * @param <V>
     */
    private class Node<V> {
        // 具體的值
        private V value;
        // 模擬隊列中節點相連接
        private AtomicReference<Node<V>> next;

        public Node(V value, Node<V> next) {
            this.value = value;
            this.next = new AtomicReference<>(next);
        }
    }

    /**
     * 隊列頭,其中 head不計算在隊列中
     */
    private AtomicReference<Node<V>> head = null;
    /**
     * 隊列尾,標記隊尾元素
     */
    private AtomicReference<Node<V>> tail = null;
    /**
     * 隊列大小
     */
    private AtomicInteger queueSize = new AtomicInteger(0);

    public CapQueue() {
        // 初始化隊列頭和尾
        Node<V> node = new Node<>(null, null);
        head = new AtomicReference<>(node);
        tail = new AtomicReference<>(node);
    }

    /**
     * 入隊操作
     *
     * @param value
     */
    public void enQueue(V value) {
        Node<V> newNode = new Node<>(value, null);
        Node<V> oldNode = null;
        while (true) {
            oldNode = tail.get();
            AtomicReference<Node<V>> next = oldNode.next;
            // 將當前隊列尾節點的next置爲新添加的節點
            if (next.compareAndSet(null, newNode)) {
                break;
            } else {
                // 如果當前隊列尾節點的next不爲空,說明此時有其他線程添加新節點入隊
                // 個人感覺去掉這一步也可以,因爲在其他線程會更新tail的值
                tail.compareAndSet(oldNode, next.get());
            }
        }
        queueSize.getAndIncrement();
        // 將尾節點設置爲新添加的節點
        tail.compareAndSet(oldNode, newNode);
    }

    /**
     * 出隊操作
     *
     * @return
     */
    public V outQueue() {
        while (true) {
            Node<V> oldNode = head.get();
            Node<V> oldTail = tail.get();
            AtomicReference<Node<V>> next = oldNode.next;
            // 隊列爲空
            if (next.get() == null) {
                return null;
            }
            // 表示此時正好只入隊一個元素,並且尾節點還沒有更新
            if (oldNode == oldTail) {
                tail.compareAndSet(oldTail, oldTail.next.get());
                continue;
            }
            // 將頭節點置換爲當前頭的下一個節點
            if (head.compareAndSet(oldNode, next.get())) {
                queueSize.getAndDecrement();
                // 返回當前頭節點的值,也就是應該出隊的值
                return oldNode.next.get().value;
            }
        }
    }

    /**
     * 返回隊列長度
     *
     * @return
     */
    public int size() {
        return queueSize.get();
    }

    /**
     * 判斷是否爲空
     *
     * @return
     */
    public boolean isEmpty() {
        return queueSize.get() == 0;
    }
}

3、CountDownLatch簡單介紹

下面測試中需要用到 CountDownLatch 來保證線程執行順序,我們這裏簡單介紹一下CountDownLatch的使用,後面專門出博客詳細介紹 CountDowmLatch 的原理。

這裏我們主要介紹 CountDownLatch 的核心方法 await()countDown()。一般在代碼通過這兩個方法的配合使用來保證線程的執行順序

countDown() 方法可以喚醒被阻塞的線程

await() 方法會使當前線程阻塞,需要執行一定次數的 countDown() 方法來喚醒線程

具體執行多少次 countDown() 才能喚醒是在CountDownLatch對象初始化時決定的,下面我們看看 CountDownLatch 的構造方法:

public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

這裏我們可以看到,創建該對象時需要一個 int 類型的參數,該參數的值也就是喚醒線程需要執行的 countDown() 方法的次數。下面我們通過僞代碼的形式介紹該對象的使用:

CountDownLatch countDownLatch = new CountDownLatch(2);
// 線程 1 中:
countDownLatch.await();
System.out.println("我被喚醒了");

// 線程 2 中:
countDownLatch.countDown();

// 線程 3 中:
countDownLatch.countDown();

在線程1中,countDownLatch 對象調用 await() 方法阻塞當前線程

只有線程2和線程3都執行 countDownLatch 對象的 countDown() 方法,也就是說執行兩次該方法後,線程1纔會被喚醒,纔會打印出“我被喚醒了”

上述代碼通過 countDownLatch對象 控制 了線程的執行順序:也就是說線程1必須等到線程2和線程3執行指定方法後才能執行。

在下面的測試例子中,我們再介紹在實際應用場景中,該對象一般如何使用:


4、無鎖隊列的測試

關於無鎖隊列,我們分別測試出隊和入隊操作的線程安全性:

4-1、出隊測試

方案:我們首先通過單線程入隊5000個元素,分100個線程出隊。其中每個線程出隊50次,100個線程併發執行。如果所有線程執行完畢後,隊列中所有元素出隊成功,說明線程安全。爲了保證測試結果的準確性,我們進行100次測試:

public class CapQueueOutTest {

    static class Consumer implements Runnable {

        // 該線程執行的次數
        private int size;
        // 無鎖隊列
        private CapQueue<String> queue;
        // 開始信號器
        private CountDownLatch start;
        // 結束信號器
        private CountDownLatch end;

        public Consumer(int size, CapQueue<String> queue, CountDownLatch start, CountDownLatch end) {
            this.size = size;
            this.queue = queue;
            this.start = start;
            this.end = end;
        }

        @Override
        public void run() {
            try {
                start.await();
            } catch (InterruptedException e) {
                return;
            }
            for (int i = 0; i < size; i++) {
                queue.outQueue();
            }
            end.countDown();
        }
    }

    @Test
    public void testOutQueue() {
        // 測試的次數
        int times = 100;
        // 創建線程的數量
        int threadCount = 100;
        // 隊列元素的數量
        int queueSize = 5000;
        // 每個線程執行的次數
        int threadSize = queueSize / threadCount;
        // 無鎖隊列
        CapQueue<String> queue = new CapQueue<>();
        CountDownLatch start = null;
        CountDownLatch end = null;
        for (int i = 0; i < times; i++) {
            // 開始信號器(只需要執行一次就喚醒線程,確保所有消費者線程都在運行)
            start = new CountDownLatch(1);
            // 結束信號旗(需要執行threadCount此才能喚醒,確保主線程等到所有消費者着都執行完畢)
            end = new CountDownLatch(threadCount);
            // 入隊 queueSize 個元素
            for (int j = 0; j < queueSize; j++) {
                queue.enQueue(String.valueOf(j));
            }
            for (int j = 0; j < threadCount; j++) {
                new Thread(new Consumer(threadSize, queue, start, end)).start();
            }
            start.countDown();
            try {
                end.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("所有線程執行完畢後,無鎖隊列的長度爲:" + queue.size());
        }
    }
}

測試結果
無鎖隊列出隊測試結果


4-2、入隊測試

方案:創建100個線程同時入隊,每個線程入隊50次。如果所有線程執行完畢後,隊列的長度等於5000說明所有節點入隊成功,即這些入隊線程之間線程安全。

public class CapQueueEnTest {

    static class Product implements Runnable {
    
        // 該線程執行的次數
        private int size;
        // 無鎖隊列
        private CapQueue<String> queue;
        // 開始信號器
        private CountDownLatch start;
        // 結束信號器
        private CountDownLatch end;

        public Product(int size, CapQueue<String> queue, CountDownLatch start, CountDownLatch end) {
            this.size = size;
            this.queue = queue;
            this.start = start;
            this.end = end;
        }

        @Override
        public void run() {
            try {
                start.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            String threadName = Thread.currentThread().getName();
            for (int i = 0; i < size; i++) {
                queue.enQueue(threadName + ":" + i);
            }
            end.countDown();
        }
    }

    @Test
    public void testEnQueue() {
        // 測試的次數
        int times = 100;
        // 創建線程的數量
        int threadCount = 100;
        // 每個線程執行的次數
        int threadSize = 50;
        // 無鎖隊列
        CapQueue<String> queue = null;
        CountDownLatch start = null;
        CountDownLatch end = null;
        for (int i = 0; i < times; i++) {
            queue = new CapQueue<>();
            // 開始信號器(只需要執行一次就喚醒線程,確保所有消費者線程都在運行)
            start = new CountDownLatch(1);
            // 結束信號旗(需要執行threadCount此才能喚醒,確保主線程等到所有消費者着都執行完畢)
            end = new CountDownLatch(threadCount);
            for (int j = 0; j < threadCount; j++) {
                new Thread(new Product(threadSize, queue, start, end)).start();
            }
            start.countDown();
            try {
                end.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("所有線程執行完畢後,無鎖隊列的長度爲:" + queue.size());
        }
    }
}

測試結果
無鎖隊列入隊測試結果


5、無鎖棧的實現

無鎖棧相比無鎖隊列簡單很多,因爲入棧和出棧操作都在棧頂執行,因此只需要維護一個變量記錄棧頂元素即可。和無鎖隊列相同,這裏我主要通過註釋的方式在代碼中解釋:

public class CapStack<V> {

    /**
     * 模擬隊列中的節點,通過 next屬性相連接
     *
     * @param <V>
     */
    private class Node<V> {
        // 具體的值
        private V value;
        // 模擬隊列中節點相連接
        private AtomicReference<Node<V>> next;

        public Node(V value, Node<V> next) {
            this.value = value;
            this.next = new AtomicReference<>(next);
        }
    }

    /**
     * 指向棧頂元素
     */
    private AtomicReference<Node<V>> head = new AtomicReference<>(null);
    /**
     * 記錄棧的大小
     */
    private AtomicInteger stackSize = new AtomicInteger(0);

    /**
     * 入棧操作
     *
     * @param value
     */
    public void enStack(V value) {
        Node<V> newNode = new Node<>(value, null);
        Node<V> oldHead = null;
        // 將新節點的next指向頭結點,如果此時頭結點沒有被處理,就讓頭結點指向這個新節點
        do {
            oldHead = head.get();
            newNode.next.set(oldHead);
        } while (!head.compareAndSet(oldHead, newNode));
        stackSize.getAndIncrement();
    }

    /**
     * 出棧操作
     *
     * @return
     */
    public V outStack() {
        Node<V> oldNode = null;
        Node<V> next = null;
        // 如果head沒有被處理,就將head指向它的next
        do {
            oldNode = head.get();
            // 說明棧爲空
            if (oldNode == null) {
                return null;
            }
            // 獲取出棧後的節點元素
            next = oldNode.next.get();
        } while (!head.compareAndSet(oldNode, next));
        stackSize.getAndDecrement();
        return oldNode.value;
    }

    /**
     * 返回棧長度
     *
     * @return
     */
    public int size() {
        return stackSize.get();
    }

    /**
     * 判斷是否爲空
     *
     * @return
     */
    public boolean isEmpty() {
        return stackSize.get() == 0;
    }
}

6、無鎖棧的測試

因爲無所棧的實現比較簡單,這裏我們把入棧測試和出棧測試一起進行:先創建100個線程,每個線程入棧50次,打印出當前棧大小。然後再創建100個線程,每個線程出棧50次,再打印出當前棧大小。每次入棧和每次出棧都併發執行,如果第一次輸出5000,第二次輸出0說明線程安全

public class CapStackTest {

    static class Product implements Runnable {

        private int size;
        private CapStack<String> stack;
        private CountDownLatch start;
        private CountDownLatch end;

        public Product(int size, CapStack<String> stack, CountDownLatch start, CountDownLatch end) {
            this.size = size;
            this.stack = stack;
            this.start = start;
            this.end = end;
        }

        @Override
        public void run() {
            try {
                start.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < size; i++) {
                stack.enStack(String.valueOf(i));
            }
            end.countDown();
        }
    }

    static class Customer implements Runnable {

        private int size;
        private CapStack<String> stack;
        private CountDownLatch start;
        private CountDownLatch end;

        public Customer(int size, CapStack<String> stack, CountDownLatch start, CountDownLatch end) {
            this.size = size;
            this.stack = stack;
            this.start = start;
            this.end = end;
        }

        @Override
        public void run() {
            try {
                start.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < size; i++) {
                stack.outStack();
            }
            end.countDown();
        }
    }

    @Test
    public void testCapStack() {
        int times = 100;
        int threadCount = 100;
        int threadSize = 50;
        CapStack<String> stack = new CapStack<>();
        CountDownLatch start = null;
        CountDownLatch end = null;
        for (int i = 0; i < times; i++) {
            start = new CountDownLatch(1);
            end = new CountDownLatch(threadCount);
            for (int j = 0; j < threadCount; j++) {
                new Thread(new Product(threadSize, stack, start, end)).start();
            }
            start.countDown();
            try {
                end.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("所有生產線程執行完畢後,無鎖棧的長度爲:" + stack.size());
            start = new CountDownLatch(1);
            end = new CountDownLatch(threadCount);
            for (int j = 0; j < threadCount; j++) {
                new Thread(new Customer(threadSize, stack, start, end)).start();
            }
            start.countDown();
            try {
                end.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("所有消費線程執行完畢後,無鎖棧的長度爲:" + stack.size());
        }
    }

}

測試結果
無鎖棧的測試結果
通過測試結果我可以看出,在沒有加鎖的情況下,通過無鎖的方式也可以保證各線程的安全性。


最後有一個疑問:爲什麼每次 CountDownLatch 對象使用完畢後都需要使用 new() 方法再次創建?

原因是這樣的:如果有線程調用 CountDownLatch 對象的 await() 方法阻塞並通過一定次數的 countDown() 方法喚醒後,再有其他線程調用該對象的 await() 方法是不會被阻塞的。因爲該對象已經執行足夠次數的 countDown()方法。當然這只是目前我測試的結果,關於 CountDownLatch 的原理,後序我們開新的篇章展開。

CountDownLatch 測試案例

public class CountDownLatchTest {

    static class WorkerA implements Runnable {

        private CountDownLatch start;

        public WorkerA(CountDownLatch start) {
            this.start = start;
        }

        @Override
        public void run() {
            try {
                start.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Worker啓動了");
        }
    }

    public static void main(String[] args) {
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch stop = new CountDownLatch(1);
        new Thread(new WorkerA(start)).start();
        start.countDown();
        new Thread(new WorkerA(start)).start();
        try {
            stop.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

測試結果
CountDownLatch測試結果
通過結果我們可以看出,第二次創建的線程,在調用 await() 方法後沒有阻塞,而是直接向下執行。


參考:
https://blog.csdn.net/kalikrick/article/details/18265755
https://www.cnblogs.com/cuglkb/p/8572239.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章