概述
在多線程應用場景下,一般需要在代碼中實現 加鎖/解鎖 操作來確保線程安全。在某些情況下,鎖的處理會給程序帶了很大的性能影響。在這種情況下, 我們就考慮無鎖操作即在不加鎖的情況下,依舊能保證線程的安全執行。
JAVA 無鎖隊列/棧 的實現
本篇就以下六個模塊展開討論:
- 無鎖的原理
- 無鎖隊列的實現
- CountDownLatch簡單介紹
- 無鎖隊列的測試
- 無鎖棧的實現
- 無鎖棧的測試
1、無鎖的原理
無鎖的實現原理是 “CAP”(Campare and swap)。翻譯過來即“比較和交換”。
關於 CAP 的實現原理本篇我們不詳細展開論述,在後面的文章專門介紹。下面主要介紹一下 CAP 的核心方法:compareAndSet ()。
在代碼中該方法一般是這樣調用的:
a.compareAndSet(oldValue, newValue)
其中 oldValue 表示老值,newValue 表示新值,該方法執行的結果有以下兩種:
- 如果變量a的值此時和 oldValue 相等,那麼將 newValue 賦予它,並返回 True。
- 如果變量a的值此時和 oldValue 不相等,那麼它不做任何操作,並返回 False。
其中爲了進行循環判斷,該方法一般和while循環結合使用,這也就是我們常稱的 自旋鎖。
鎖 的作用是保證同一時刻最多只能有一個線程操作某塊內存,其他線程只能等到該線程處理完畢後進行讀寫操作。
無鎖 是無法保證某塊內存在同一時刻不被多個線程共享,它是在寫內存過程前提供一個期望值,如果期望值與當前值相等,說明該線程上一次讀取值之後,沒有其他線程對這塊內存進行處理,也就是說該操作是線程安全的
通過上面的概述其實我們不難發現,無鎖還是存在以下隱患的:即操作的內存被其他線程操作兩次,最終又返回原值時,無鎖是無法判斷是否有線程處理過該塊內存的,但這種場景一般不影響結果。
舉例:變量a = 5,現在有兩個線程處理變量a,線程1要將它的值+5,線程2要將它的值+10,我們使用無鎖來模擬這種場景下可能產生的情況:
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();
}
}
}
測試結果:
通過結果我們可以看出,第二次創建的線程,在調用 await() 方法後沒有阻塞,而是直接向下執行。