鎖和同步,學習多線程避不開的兩個問題,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。