詳解 JUC 包下的鎖
本文將針對JUC包下常見的鎖進行深入分析和演示,希望對你有幫助。
一、Java中的鎖
我們日常開發過程中為了保證臨界資源的線程安全可能會用到synchronized,但是synchronized局限性也是很強的,它無法做到以下幾點:
- 讓當前線程立刻釋放鎖。
- 判斷線程持有鎖的狀態。
- 線程爭搶的公平爭搶。
所以為了保證用戶能夠在合適的場景找到合適的鎖,Java設計者按照不同的維度為我們提供了各種鎖,鎖的分類按照不同的特征分為以下幾種:
二、Lock接口基本思想和規范
1. 為什么需要Lock接口式的鎖
鎖是一種解決資源共享問題的解決方案,相比于synchronized鎖,Lock鎖的自類增加了一些更高級的功能:
- 鎖等待。
- 鎖中斷。
- 可隨時中斷釋放。
- 鎖重入。
但這并不能表明,Lock鎖是synchronized鎖的替代品,它倆都有各自的適用場合。
2. Lock接口的基本規范
宏觀角度來看Lock接口不僅支持簡單的上鎖和釋放鎖,還支持超時等待鎖、上可中斷鎖,鎖中斷等操作:
public interface Lock {
//上鎖
void lock();
//上一把可中斷的鎖
void lockInterruptibly() throws InterruptedException;
//非阻塞嘗試取鎖
boolean tryLock();
//超時等待鎖
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//鎖釋放
void unlock();
//......
}
3. 使用Lock的優雅姿勢
我們以ReentrantLock來演示一下Lock類的加鎖和解鎖操作。細心的讀者在閱讀源碼時可能會發現下面這樣一段注釋,這就是lock類上鎖的解鎖的基本示例了。
* <pre> {@code
* class X {
* private final ReentrantLock lock = new ReentrantLock();
* // ...
*
* public void m() {
* lock.lock(); // block until condition holds
* try {
* // ... method body
* } finally {
* lock.unlock()
* }
* }
* }}
所以我們也按照上面這段示例編寫一下一段demo代碼。注意lock鎖必須手動釋放,所以為了保證釋放的安全我們常常會在finally語句塊中進行鎖釋放,如官方給出的代碼示例一樣:
ReentrantLock lock = new ReentrantLock();
//上鎖
lock.lock();
try {
System.out.println("當前線程" + Thread.currentThread().getName() + "獲得鎖,進行異常操作");
int i = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
} finally {
//語句塊中優雅釋放
lock.unlock();
}
log.info("當前鎖是否被鎖定:{}", lock.isLocked());
對應的我們也給出輸出結果:
當前線程main獲得鎖,進行異常操作
15:41:05.499 [main] INFO com.sharkChili.Main - 當前鎖是否被鎖定:false
java.lang.ArithmeticException: / by zero
at com.sharkChili.Main.main(Main.java:23)
4. tryLock
相比于普通的lock來說,tryLock相對更加強大一些,tryLock可以根據當前線程是否取得鎖進行一些定制化操作。 而且tryLock可以立即返回或者在一定時間內取鎖,如果拿得到就拿鎖并返回true,反之返回false。
我們現在創建一個任務給兩個線程使用,邏輯很簡單,在每個線程在while循環中,flag為1的先取鎖1,flag為2的先取鎖2。 flag為1的先在規定時間內獲取鎖1,獲得鎖1后再獲取鎖2,如果鎖2獲取失敗則釋放鎖1休眠一會。讓另一個先獲取鎖2在獲取鎖1的線程執行完再進行獲取鎖。
public class TryLockDemo implements Runnable {
//注意使用static 否則鎖的粒度用錯了會導致無法鎖住彼此
private static Lock lock1 = new ReentrantLock();
private static Lock lock2 = new ReentrantLock();
//flag為1的先取鎖1再去鎖2,反之先取鎖2在取鎖1
private int flag;
public int getFlag() {
return flag;
}
public void setFlag(int flag) {
this.flag = flag;
}
@Override
public void run() {
while (true) {
//flag為1先取鎖1再取鎖2
if (flag == 1) {
try {
//800ms內嘗試取鎖,如果失敗則直接輸出嘗試獲取鎖1失敗
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName()+"拿到了第一把鎖lock1");
//睡一會,保證線程2拿鎖鎖2
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName()+"取到鎖2");
System.out.println(Thread.currentThread().getName()+"拿到兩把鎖,執行業務邏輯了。。。。");
break;
} finally {
lock2.unlock();
}
} else {
System.out.println(Thread.currentThread().getName()+"獲取第二把鎖鎖2失敗");
}
} finally {
//休眠一會再次獲取鎖
lock1.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println(Thread.currentThread().getName()+"嘗試獲取鎖1失敗");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
try {
//3000ms內嘗試獲取鎖2,如果娶不到直接輸出失敗
if (lock2.tryLock(3000, TimeUnit.MILLISECONDS)) {
try{
System.out.println(Thread.currentThread().getName()+"先拿到了鎖2");
Thread.sleep(new Random().nextInt(1000));
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println(Thread.currentThread().getName()+"取到鎖1");
System.out.println(Thread.currentThread().getName()+"拿到兩把鎖,執行業務邏輯了。。。。");
break;
} finally {
lock1.unlock();
}
} else {
System.out.println(Thread.currentThread().getName()+"獲取第二把鎖1失敗");
}
}finally {
//休眠一會,順便把鎖釋放讓其他線程獲取
lock2.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println(Thread.currentThread().getName()+"獲取鎖2失敗");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
測試代碼:
public class TestTryLock {
public static void main(String[] args) {
//先獲取鎖1
TryLockDemo t1 = new TryLockDemo();
t1.setFlag(1);
//先獲取鎖2
TryLockDemo t2 = new TryLockDemo();
t2.setFlag(2);
new Thread(t1,"t1").start();
new Thread(t2,"t2").start();
}
}
輸出結果如下,可以看到tryLock的存在使得我們可以不再阻塞的去獲取鎖,而是可以根據鎖的持有情況進行下一步邏輯。
t1拿到了第一把鎖lock1
t2先拿到了鎖2
t1獲取第二把鎖鎖2失敗
t2取到鎖1
t2拿到兩把鎖,執行業務邏輯了。。。。
t1拿到了第一把鎖lock1
t1取到鎖2
t1拿到兩把鎖,執行業務邏輯了。。。。
5. 可被中斷的lock
為避免synchronized這種獲取鎖過程無法中斷,進而出現死鎖的情況。JUC包下的鎖提供了lockInterruptibly方法,即在獲取鎖過程中的線程可以被打斷。
public class LockInterruptiblyDemo implements Runnable {
private static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 嘗試取鎖");
try {
//設置為可被中斷的獲取鎖
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + " 取鎖成功");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " 執行業務邏輯時被中斷");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "嘗試取鎖時被中斷");
}
}
}
測試代碼如下,我們先讓線程1獲取鎖成功,此時線程2取鎖就會失敗,我們可以手動通過interrupt將其打斷。
public class LockInterruptiblyTest {
public static void main(String[] args) throws InterruptedException {
LockInterruptiblyDemo lockInterruptiblyDemo = new LockInterruptiblyDemo();
//線程1先獲取鎖,會成功
Thread thread0 = new Thread(lockInterruptiblyDemo);
thread0.start();
//線程2獲取鎖失敗,不會中斷
Thread thread1 = new Thread(lockInterruptiblyDemo);
thread1.start();
Thread.sleep(5000);
//手動調用interrupt將線程中斷
thread1.interrupt();
}
}
6. Lock鎖的可見性保證
可能很多人會對這些操作有這樣的疑問,我們lock的結果如何對之后操作該資源的線程保證可見性呢?
其實根據happens-before原則,前一個線程操作的結果,對后一個線程是都可見的原理即可保證鎖操作的可見性。
三、以不同分類的維度解析鎖
1. 按照是否鎖住資源分類
(1) 悲觀鎖
悲觀鎖認為自己在修改數據過程中,其他人很可能會過來修改數據,為了保證數據的準確性,他會在自己修改數據時候持有鎖,在釋放鎖之前,其他線程是無法持有這把鎖。在Java中synchronized鎖和lock鎖都是典型的悲觀鎖。
對應的我們給出悲觀鎖的使用示例:
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread t1 = new Thread(()->{
doSomething();
countDownLatch.countDown();
});
Thread t2 = new Thread(()->{
doSomething();
countDownLatch.countDown();
});
t1.start();
t2.start();
countDownLatch.await();
}
/**
* synchronized 悲觀鎖,保證上鎖成功后才能操作臨界資源
*/
public synchronized static void doSomething() {
log.info("{} do something", Thread.currentThread().getName());
}
(2) 樂觀鎖
樂觀鎖認為自己的修改數據時不會有其他人會修改數據,所以他每次修改數據后會判斷修改前的數據是否被修改過,如果沒有就將更新結果寫入,反之重新拉取數據的最新結果進行更新再重復之前步驟完成寫入,在Java中樂觀鎖常常用CAS原子類來實現:
如下代碼所示,原子類就是通過CAS樂觀鎖實現的:
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
}
我們可以看看cas原子類getAndIncrement的源碼,它會調用unsafe的getAndAddInt,將this和偏移量,還有1傳入。
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
查看getAndAddInt的工作流程我們即可知曉CAS樂觀鎖操作的實現細節:
- 通過getIntVolatile方法獲取到需要操作的變量的地址。
- 通過compareAndSwapInt的方式查看原有的值是否發生變化,如果沒有則將修改后的結果v + delta寫入到變量地址空間中。
- 如果發生變化則compareAndSwapInt會返回false,繼續從步驟1開始,直到修改操作成功。
對應的我們給出getAndAddInt的源碼實現:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
//拉取操作變量最新值
v = getIntVolatile(o, offset);
//比對拉取結果與最新結果是否一致,若一致則寫入最新結果,反之繼續循環直到修改成功
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
(3) 悲觀鎖和樂觀鎖的比較
該問題我們可以從以下兩個角度進行說明:
- 從資源開銷的角度:悲觀鎖的開銷遠高于樂觀鎖,但它確實一勞永逸的,臨界區持有鎖的時間就算越來越長也不會對互斥鎖有任何的影響。反之樂觀鎖假如持有鎖的時間越來越長的話,其他等待線程的自選時間也會增加,從而導致資源消耗愈發嚴重。
- 從場景適用角度:悲觀更適合那些經常操作修改的場景,而樂觀鎖更適合讀多修改少的情況。
2. 按照是否可重入進行鎖分類
(1) 可重入鎖示例
代碼如下所示,我們創建一個MyRecursionDemo ,這個類的邏輯很簡單,讓當前線程通過遞歸的方式連續獲得鎖5次。
public class MyRecursionDemo {
private ReentrantLock lock = new ReentrantLock();
public void accessResource() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 第" + lock.getHoldCount() + "次處理資源中");
if (lock.getHoldCount() < 5) {
System.out.println("當前線程是否是持有這把鎖的線程" + lock.isHeldByCurrentThread());
System.out.println("當前等待隊列長度" + lock.getQueueLength());
System.out.println("再次遞歸處理資源中........................................");
//再次遞歸調用該方法,嘗試重入這把鎖
accessResource();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("處理結束,釋放可重入鎖");
lock.unlock();
}
}
}
測試代碼:
public class MyRecursionDemoTest {
public static void main(String[] args) {
MyRecursionDemo myRecursinotallow=new MyRecursionDemo();
myRecursionDemo.accessResource();
}
}
從輸出結果來看main線程第一次成功取鎖之后,在不釋放的情況下,連續嘗試取ReentrantLock 5次都是成功的,是支持可重入的。
main 第1次處理資源中
當前線程是否是持有這把鎖的線程true
當前等待隊列長度0
再次遞歸處理資源中........................................
main 第2次處理資源中
當前線程是否是持有這把鎖的線程true
當前等待隊列長度0
再次遞歸處理資源中........................................
main 第3次處理資源中
當前線程是否是持有這把鎖的線程true
當前等待隊列長度0
再次遞歸處理資源中........................................
main 第4次處理資源中
當前線程是否是持有這把鎖的線程true
當前等待隊列長度0
再次遞歸處理資源中........................................
main 第5次處理資源中
處理結束,釋放可重入鎖
處理結束,釋放可重入鎖
處理結束,釋放可重入鎖
處理結束,釋放可重入鎖
處理結束,釋放可重入鎖
(2) 不可重入鎖
NonReentrantLock就是典型的不可重入鎖,代碼示例如下:
public class NonReentrantLockDemo {
public static void main(String[] args) {
NonReentrantLock lock=new NonReentrantLock();
lock.lock();
System.out.println(Thread.currentThread().getName()+"第一次獲取鎖成功");
lock.lock();
System.out.println(Thread.currentThread().getName()+"第二次獲取鎖成功");
}
}
從輸出結果來看,第一次獲取鎖之后就無法再次重入鎖了。
main第一次獲取鎖成功
(3) 源碼解析可重入鎖和非可重入鎖區別
查看ReentrantLock可重入鎖源碼可知,可重入鎖進行鎖定邏輯時,會判斷持有鎖的線程是否是當前線程,如果是則將c(即count的縮寫)自增:
final boolean nonfairTryAcquire(int acquires) {
.....
//如果當前線程仍然持有這把鎖,記錄一下持有鎖的次數 并返回拿鎖成功
else if (current == getExclusiveOwnerThread()) {
//增加上鎖次數
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
//更新當前鎖上鎖次數
setState(nextc);
return true;
}
return false;
}
相比之下不可重入鎖的邏輯就比較簡單了,如下源碼NonReentrantLock所示,通過CAS修改取鎖狀態,若成功則將鎖持有者設置為當前線程。 同一個線程再去取鎖時并沒有重入的處理,仍然是進行CAS操作,很明顯這種情況是會失敗的。
@Override
protected final boolean tryAcquire(int acquires) {
// 通過CAS修改鎖狀態
if (compareAndSetState(0, 1)) {
//若成功則將鎖持有者設置為當前線程
owner = Thread.currentThread();
return true;
}
return false;
}
3. 按照公平性進行鎖分類
公平鎖可以保證線程持鎖順序會有序進行,在線程爭搶鎖的過程中如果上鎖失敗是會統一提交到等待隊列中,后續由隊列統一管理喚醒:
非公平鎖的設計初衷也很明顯,非公平鎖的設計就是為了在線程喚醒期間的空檔期讓其他線程可以插隊,所以即使等待隊列中有線程,其他的不在隊列中的線程依然可以持有這把鎖:
(1) 公平鎖代碼示例
我們先創建一個任務類的代碼,run方法邏輯很簡單,上一次鎖打印輸出一個文件,這里會上鎖兩次打印兩次。構造方法中要求傳一個布爾值,這個布爾值如果為true則說明ReentrantLock 為公平,反之為非公平。
public class MyPrintQueue implements Runnable {
private boolean fair;
public MyPrintQueue(boolean fair) {
this.fair = fair;
}
/**
* true為公平鎖 false為非公平鎖
*/
private ReentrantLock lock = new ReentrantLock(fair);
/**
* 上鎖兩次打印輸出兩個文件
*/
public void printStr() {
lock.lock();
try {
int s = new Random().nextInt(10) + 1;
System.out.println("正在打印第一份文件。。。。當前打印線程:" + Thread.currentThread().getName() + " 需要" + s + "秒");
Thread.sleep(s * 1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
lock.lock();
try {
int s = new Random().nextInt(10) + 1;
System.out.println("正在打印第二份文件。。。。當前打印線程:" + Thread.currentThread().getName() + " 需要" + s + "秒");
Thread.sleep(s * 1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
@Override
public void run() {
printStr();
}
}
測試代碼:
public class FairLockTest {
public static void main(String[] args) {
//創建10個線程分別執行這個任務
MyPrintQueue task=new MyPrintQueue(true);
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
try{
Thread.sleep(100);
}catch (Exception e){
}
}
}
}
從輸出結果來看,線程是按順序執行的:
正在打印第一份文件。。。。當前打印線程:Thread-0 需要2秒
正在打印第二份文件。。。。當前打印線程:Thread-0 需要8秒
正在打印第一份文件。。。。當前打印線程:Thread-1 需要1秒
正在打印第二份文件。。。。當前打印線程:Thread-1 需要8秒
正在打印第一份文件。。。。當前打印線程:Thread-2 需要2秒
正在打印第二份文件。。。。當前打印線程:Thread-2 需要9秒
正在打印第一份文件。。。。當前打印線程:Thread-3 需要10秒
正在打印第二份文件。。。。當前打印線程:Thread-3 需要2秒
正在打印第一份文件。。。。當前打印線程:Thread-4 需要10秒
正在打印第二份文件。。。。當前打印線程:Thread-4 需要1秒
正在打印第一份文件。。。。當前打印線程:Thread-5 需要5秒
正在打印第二份文件。。。。當前打印線程:Thread-5 需要8秒
正在打印第一份文件。。。。當前打印線程:Thread-6 需要9秒
正在打印第二份文件。。。。當前打印線程:Thread-6 需要6秒
正在打印第一份文件。。。。當前打印線程:Thread-7 需要9秒
正在打印第二份文件。。。。當前打印線程:Thread-7 需要8秒
正在打印第一份文件。。。。當前打印線程:Thread-8 需要6秒
正在打印第二份文件。。。。當前打印線程:Thread-8 需要6秒
正在打印第一份文件。。。。當前打印線程:Thread-9 需要6秒
正在打印第二份文件。。。。當前打印線程:Thread-9 需要4秒
非公平鎖將標志調整為false即可,這里就不多做演示了。
(2) 通過源碼查看兩者實現邏輯
如下所示,我們可以在構造方法中看到公平鎖和非公平鎖是如何根據參數決定的。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
我們不妨看看ReentrantLock公平鎖的內部類公平鎖FairSync的源碼,如下所示,可以看到,他的取鎖邏輯必須保證當前取鎖的節點沒有前驅節點才能搶鎖,這也就是為什么我們的線程會排隊取鎖。
static final class FairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//當前節點沒有前驅節點的情況下才能進行取鎖
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
相比之下,非公平鎖就很粗暴了,我們看看ReentrantLock內部類NonfairSync,只要CAS成功就行了,所以鎖一旦空閑,所有線程都可以隨機爭搶。
final void lock() {
//無論隊列情況,直接CAS成功后即可持有鎖
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
相對之下公平鎖由于是有序執行,所以相對非公平鎖來說執行更慢,吞吐量更小一些。 而非公平鎖可以在特定場景下實現插隊,所以很有可能出現某些線程被頻繁插隊而導致"線程饑餓"的情況。
4. 按是否共享性進行分類
共享鎖最常見的使用就是ReentrantReadWriteLock,其讀鎖就是共享鎖,當某一線程使用讀鎖時,其他線程也可以使用讀鎖,因為讀不會修改數據,無論多少個線程讀都可以。而寫鎖就是獨占鎖的典型,當某個線程執行寫時,為了保證數據的準確性,其他線程無論使用讀鎖還是寫鎖,都得阻塞等待當前正在使用寫鎖的線程釋放鎖才能執行。
JUC下的讀寫鎖的本質上是通過CAS修改AQS狀態值來完成鎖的獲取,如下圖,因為讀鎖共享,所以多個線程獲取讀鎖是只要判斷鎖沒有被獨占(即沒有線程獲取讀鎖),則直接CAS修改state值,完成讀鎖獲取。而其它線程準備獲取寫鎖時如果感知到state非0且持有者非自己則說明有線程上讀鎖,則阻塞等待釋放:
對應我們給出讀鎖的持有邏輯即ReentrantReadWriteLock下的Sync的tryAcquireShared,本質邏輯如上文所說,即判斷是否存在非本線程的獨占,如果沒有則持有CAS累加狀態完成讀鎖獲取:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
//查看state的值
int c = getState();
//exclusiveCount非0說明有人上寫鎖,如果非自己的直接返回,說明上讀鎖失敗
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//獲取共享鎖持有者個數,然后CAS累加完成讀鎖記錄維護
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//......
return 1;
}
return fullTryAcquireShared(current);
}
同理寫鎖的獲取邏輯則是通過state判斷是否有人獲取讀鎖,然后基于如下幾種情況決定是否可以上鎖:
- 如果state為0,說明沒有人獲取讀鎖,直接CAS修改state完成上鎖返回
- 如果state非0,則判斷寫鎖是否是自己持有,如果是則說明是重入直接累加state,反之說明上鎖失敗
寫鎖獲取過程,對應源碼如下:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
//獲取state查看有多少線程獲取讀鎖
int c = getState();
//基于w查看是否有線程獲取寫鎖
int w = exclusiveCount(c);
if (c != 0) {
//若c非0說明有人獲取讀鎖,然后進入如下判斷,如果即如果寫鎖不是當前線程獲取則直接返回
if (w == 0 || current != getExclusiveOwnerThread())
return false;
//......
//來到這里說明是寫鎖重入,直接累加
setState(c + acquires);
return true;
}
//若沒有線程獲取讀鎖直接CAS修改獲取讀鎖返回
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
(1) 讀寫鎖使用示例
代碼的邏輯也很簡單,獲取讀鎖讀取數據,獲取寫鎖修改數據。
public class BaseRWdemo {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//讀鎖
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//寫鎖
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
//獲取讀鎖,讀取數據
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到讀鎖");
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}finally {
System.out.println(Thread.currentThread().getName() + "釋放了讀鎖");
readLock.unlock();
}
}
private static void write() {
//獲取寫鎖,寫數據
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到寫鎖");
Thread.sleep(1000);
} catch (Exception e) {
}finally {
System.out.println(Thread.currentThread().getName() + "釋放了寫鎖");
writeLock.unlock();
}
}
}
測試代碼:
public static void main(String[] args) {
//讀鎖可以一起獲取
new Thread(() -> read(), "thread1").start();
new Thread(() -> read(), "thread2").start();
//等上面讀完寫鎖才能用 從而保證線程安全問題
new Thread(() -> write(), "thread3").start();
//等上面寫完 才能開始寫 避免線程安全問題
new Thread(() -> write(), "thread4").start();
}
從輸出結果不難看出,一旦資源被上了讀鎖,寫鎖就無法操作,只有讀鎖操作結束,寫鎖才能操作資源。
thread1得到讀鎖
thread2得到讀鎖
thread1釋放了讀鎖
thread2釋放了讀鎖
# 寫鎖必須等讀鎖釋放了才能操作
thread3得到寫鎖
thread3釋放了寫鎖
thread4得到寫鎖
thread4釋放了寫鎖
(2) 讀寫鎖非公平場景下的插隊問題
讀寫鎖ReentrantReadWriteLock設置為true,即公平鎖,其底層也很上述的ReentrantLock類似,同樣是通過AQS管理線程流程控制,同樣是非公平情況下任意線程都可以直接嘗試爭搶鎖而非,對應的代碼示例如下,可以看到筆者初始化讀寫鎖ReentrantReadWriteLock并將fair標識設置為true即公平鎖:
//設置為false之后 非公平 等待隊列前是讀鎖 就可以讓讀鎖插隊
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
同時我們也給出讀寫鎖的實用代碼,邏輯比較簡單,上鎖后休眠1000毫秒,同時在嘗試上鎖、得到鎖、釋放鎖附近打印日志:
private static void read() {
log.info("嘗試獲取讀鎖");
readLock.lock();
try {
log.info("獲取讀鎖成功,執行業務邏輯......");
ThreadUtil.sleep(1000);
} catch (Exception e) {
//......
} finally {
readLock.unlock();
log.info("釋放讀鎖");
}
}
private static void write() {
log.info("嘗試獲取寫鎖");
writeLock.lock();
try {
log.info("獲取寫鎖成功,執行業務邏輯......");
Thread.sleep(1000);
} catch (Exception e) {
} finally {
writeLock.unlock();
log.info("釋放寫鎖");
}
}
對應的我們也給出使用的代碼示例,感興趣的讀者可以基于標識自行調試一下:
public static void main(String[] args) {
new Thread(() -> write(), "t0").start();
new Thread(() -> read(), "t1").start();
new Thread(() -> read(), "t2").start();
new Thread(() -> write(), "t3").start();
new Thread(() -> read(), "t4").start();
ThreadUtil.sleep(1, TimeUnit.DAYS);
}
(3) 源碼解析非公平鎖插隊原理
我們可以看到一個tryAcquireShared方法,因為我們設置的是非公平鎖,所以代碼最后只能會走到NonfairSync 的tryAcquireShared。
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -2694183684443567898L;
NonfairSync(int permits) {
super(permits);
}
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}
可以看到邏輯也很簡單,一旦隊首節點釋放鎖之后,就會通知其他節點進行爭搶,而其他節點都會走到這段邏輯,只要判斷到沒有人持有鎖,就直接進行CAS爭搶。這就應證了我們上述的觀點,等待隊列首節點是寫鎖占有鎖的情況下,一旦寫鎖釋放之后,后續的線程可以任意插隊搶占并上讀鎖或者寫鎖,這也就是為什么我們上文的線程3先于線程2上了讀鎖的原因。
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
//如果小于0則說明沒有人持有可以直接通過CAS進行爭搶
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
(4) 鎖降級思想
因為讀寫鎖互斥,所以某些修改操作需要獲取寫鎖后才能進行修改操作,這使得我們必須在持有寫鎖的情況下,完成修改后,通過鎖降級繼續讀取數據。
對應代碼示例如下,可以看到在當前線程獲取讀鎖情況下,整套鎖升級的步驟為:
- 先釋放讀鎖,嘗試獲取寫鎖更新數據
- 獲取寫鎖完成數據更新
- 因為獲取寫鎖成功就說明鎖被當前線程獨占,可直接獲取讀鎖(上述源碼已說明)
- 獲取讀鎖成功,釋放寫鎖,完成獨占鎖降級
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//讀鎖
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//寫鎖
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static volatile boolean update = false;
public static void main(String[] args) {
//模擬某些原因上了讀鎖
readLock.lock();
//因為數據更新的原因需要上寫鎖
if (!update) {
try {
//釋放讀鎖,獲取寫鎖更新數據
readLock.unlock();
writeLock.lock();
if (!update) {
//模擬數據更新
update = true;
}
//寫鎖被當前線程持有直接獲取讀鎖
readLock.lock();
} finally {
//釋放寫鎖,完成鎖降級
writeLock.unlock();
}
}
}
(5) 為什么讀寫鎖不支持鎖升級
讀寫鎖升級過程大體是:
- 持有讀鎖線程嘗試獲取寫鎖
- 如果沒有其它線程獲取讀鎖,則直接上互斥獨占的寫鎖,若其它線程上了讀鎖,則等待其它線程釋放讀鎖后,保證可獨占的情況下獲取寫鎖
- 獲取寫鎖操作數據,完成鎖升級
這就存在死鎖的風險,例如線程1和線程2同時獲取讀鎖,二者都希望完成鎖升級,各自等待雙方釋放讀鎖后獲取寫鎖
5. 按照是否自旋進行分類
我們都知道Java阻塞或者喚醒一個線程都需要切換CPU狀態的,這樣的操作非常耗費時間,而很多線程切換后執行的邏輯僅僅是一小段代碼,為了這一小段代碼而耗費這么長的時間確實是一件得不償失的事情。對此java設計者就設計了一種讓線程不阻塞,原地"稍等"即自旋一下的操作。
如下代碼所示,我們通過AtomicReference原子類實現了一個簡單的自旋鎖,通過compareAndSet嘗試讓當前線程持有資源,如果成功則執行業務邏輯,反之循環等待。
public class MySpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
public void lock() {
Thread curThread = Thread.currentThread();
//使用原子類自旋設置原子類線程,若線程設置為當前線程則說明當前線程上鎖成功
while (!sign.compareAndSet(null, curThread)) {
System.out.println(curThread.getName() + "未得到鎖,自旋中");
}
}
public void unLock() {
Thread curThread = Thread.currentThread();
sign.compareAndSet(curThread, null);
System.out.println(curThread.getName() + "釋放鎖");
}
public static void main(String[] args) {
MySpinLock mySpinLock = new MySpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "嘗試獲取自旋鎖");
mySpinLock.lock();
System.out.println(Thread.currentThread().getName() + "得到了自旋鎖");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
mySpinLock.unLock();
System.out.println(Thread.currentThread().getName() + "釋放了自旋鎖");
}
}
};
Thread t1=new Thread(runnable,"t1");
Thread t2=new Thread(runnable,"t2");
t1.start();
t2.start();
}
}
輸出結果:
t1嘗試獲取自旋鎖
t2嘗試獲取自旋鎖
t1得到了自旋鎖
t2未得到鎖,自旋中
t2未得到鎖,自旋中
t2未得到鎖,自旋中
t2未得到鎖,自旋中
t2未得到鎖,自旋中
t2未得到鎖,自旋中
t1釋放鎖
t2得到了自旋鎖
t1釋放了自旋鎖
t2釋放鎖
t2釋放了自旋鎖
6. 按是否可支持中斷進行分類
可中斷鎖上文lockInterruptibly上文已經演示過了,這里就不多做贅述了。
public class LockInterruptiblyDemo implements Runnable {
//設置為static,所有對象共享
private static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 嘗試取鎖");
try {
//設置鎖可以被打斷
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + " 取鎖成功");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " 執行業務邏輯時被中斷");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "嘗試取鎖時被中斷");
}
}
}
測試代碼:
public static void main(String[] args) throws InterruptedException {
LockInterruptiblyDemo lockInterruptiblyDemo = new LockInterruptiblyDemo();
//線程1啟動
Thread thread0 = new Thread(lockInterruptiblyDemo);
thread0.start();
//線程2啟動
Thread thread1 = new Thread(lockInterruptiblyDemo);
thread1.start();
//主線程休眠,讓上述代碼執行,然后執行打斷線程1邏輯 thread0.interrupt();
Thread.sleep(2000);
thread0.interrupt();
}
這里補充一下可中斷鎖的原理,可中斷鎖實現的可中斷的方法很簡單,通過acquireInterruptibly建立一個可中斷的取鎖邏輯。
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
我們不如源碼可以看到,對于沒有獲得鎖的線程,判斷走到interrupted看看當前線程是否被打斷,如果打斷了則直接拋出中斷異常。
public final void acquireInterruptibly(int arg)
throws InterruptedException {
//當線程被打斷時,直接拋出中斷異常
if (Thread.interrupted())
throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}