### 歡迎訪問我的GitHub > 這裡分類和彙總了欣宸的全部原創(含配套源碼):[https://github.com/zq2599/blog_demos](https://github.com/zq2599/blog_demos) ### 本篇概覽 - 本篇是《quarkus依賴註入》的第九篇 ...
歡迎訪問我的GitHub
這裡分類和彙總了欣宸的全部原創(含配套源碼):https://github.com/zq2599/blog_demos
本篇概覽
- 本篇是《quarkus依賴註入》的第九篇,目標是在輕鬆的氣氛中學習一個小技能:bean鎖
- quarkus的bean鎖本身很簡單:用兩個註解修飾bean和方法即可,但涉及到多線程同步問題,欣宸願意花更多篇幅與各位Java程式員一起暢談多線程,聊個痛快,本篇由以下內容組成
- 關於多線程同步問題
- 代碼復現多線程同步問題
- quarkus的bean讀寫鎖
關於讀寫鎖
- java的併發包中有讀寫鎖ReadWriteLock:在多線程場景中,如果某個對象處於改變狀態,可以用寫鎖加鎖,這樣所有做讀操作對象的線程,在獲取讀鎖時就會block住,直到寫鎖釋放
- 為了演示bean鎖的效果,咱們先來看一個經典的多線程同步問題,如下圖,餘額100,充值10塊,扣費5塊,正常情況下最終餘額應該是105,但如果充值和扣費是在兩個線程同時進行,而且各算各的,再分別用自己的計算結果去覆蓋餘額,最終會導致計算不准確
代碼復現多線程同步問題
- 咱們用代碼來複現上圖中的問題,AccountBalanceService是個賬號服務類,其成員變數accountBalance表示餘額,另外有三個方法,功能分別是:
- get:返回餘額,相當於查詢餘額服務
- deposit:充值,入參是充值金額,方法內將餘額放入臨時變數,然後等待100毫秒模擬耗時操作,再將臨時變數與入參的和寫入成員變數accountBalance
- deduct:扣費,入參是扣費金額,方法內將餘額放入臨時變數,然後等待100毫秒模擬耗時操作,再將臨時變數與入參的差寫入成員變數accountBalance
- AccountBalanceService.java源碼如下,deposit和deduct這兩個方法各算各的,絲毫沒有考慮當時其他線程對accountBalance的影響
package com.bolingcavalry.service.impl;
import io.quarkus.logging.Log;
import javax.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class AccountBalanceService {
// 賬戶餘額,假設初始值為100
int accountBalance = 100;
/**
* 查詢餘額
* @return
*/
public int get() {
// 模擬耗時的操作
try {
Thread.sleep(80);
} catch (InterruptedException e) {
e.printStackTrace();
}
return accountBalance;
}
/**
* 模擬了一次充值操作,
* 將賬號餘額讀取到本地變數,
* 經過一秒鐘的計算後,將計算結果寫入賬號餘額,
* 這一秒內,如果賬號餘額發生了變化,就會被此方法的本地變數覆蓋,
* 因此,多線程的時候,如果其他線程修改了餘額,那麼這裡就會覆蓋掉,導致多線程同步問題,
* AccountBalanceService類使用了Lock註解後,執行此方法時,其他線程執行AccountBalanceService的方法時就會block住,避免了多線程同步問題
* @param value
* @throws InterruptedException
*/
public void deposit(int value) {
// 先將accountBalance的值存入tempValue變數
int tempValue = accountBalance;
Log.infov("start deposit, balance [{0}], deposit value [{1}]", tempValue, value);
// 模擬耗時的操作
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
tempValue += value;
// 用tempValue的值覆蓋accountBalance,
// 這個tempValue的值是基於100毫秒前的accountBalance計算出來的,
// 如果這100毫秒期間其他線程修改了accountBalance,就會導致accountBalance不准確的問題
// 例如最初有100塊,這裡存了10塊,所以餘額變成了110,
// 但是這期間如果另一線程取了5塊,那餘額應該是100-5+10=105,但是這裡並沒有靠攏100-5,而是很暴力的將110寫入到accountBalance
accountBalance = tempValue;
Log.infov("end deposit, balance [{0}]", tempValue);
}
/**
* 模擬了一次扣費操作,
* 將賬號餘額讀取到本地變數,
* 經過一秒鐘的計算後,將計算結果寫入賬號餘額,
* 這一秒內,如果賬號餘額發生了變化,就會被此方法的本地變數覆蓋,
* 因此,多線程的時候,如果其他線程修改了餘額,那麼這裡就會覆蓋掉,導致多線程同步問題,
* AccountBalanceService類使用了Lock註解後,執行此方法時,其他線程執行AccountBalanceService的方法時就會block住,避免了多線程同步問題
* @param value
* @throws InterruptedException
*/
public void deduct(int value) {
// 先將accountBalance的值存入tempValue變數
int tempValue = accountBalance;
Log.infov("start deduct, balance [{0}], deposit value [{1}]", tempValue, value);
// 模擬耗時的操作
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
tempValue -= value;
// 用tempValue的值覆蓋accountBalance,
// 這個tempValue的值是基於100毫秒前的accountBalance計算出來的,
// 如果這100毫秒期間其他線程修改了accountBalance,就會導致accountBalance不准確的問題
// 例如最初有100塊,這裡存了10塊,所以餘額變成了110,
// 但是這期間如果另一線程取了5塊,那餘額應該是100-5+10=105,但是這裡並沒有靠攏100-5,而是很暴力的將110寫入到accountBalance
accountBalance = tempValue;
Log.infov("end deduct, balance [{0}]", tempValue);
}
}
- 接下來是單元測試類LockTest.java,有幾處需要註意的地方稍後會說明
package com.bolingcavalry;
import com.bolingcavalry.service.impl.AccountBalanceService;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import javax.inject.Inject;
import java.util.concurrent.CountDownLatch;
@QuarkusTest
public class LockTest {
@Inject
AccountBalanceService account;
@Test
public void test() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3);
int initValue = account.get();
final int COUNT = 10;
// 這是個只負責讀取的線程,迴圈讀10次,每讀一次就等待50毫秒
new Thread(() -> {
for (int i=0;i<COUNT;i++) {
// 讀取賬號餘額
Log.infov("current balance {0}", account.get());
}
latch.countDown();
}).start();
// 這是個充值的線程,迴圈充10次,每次存2元
new Thread(() -> {
for (int i=0;i<COUNT;i++) {
account.deposit(2);
}
latch.countDown();
}).start();
// 這是個扣費的線程,迴圈扣10次,每取1元
new Thread(() -> {
for (int i=0;i<COUNT;i++) {
account.deduct(1);
}
latch.countDown();
}).start();
latch.await();
int finalValue = account.get();
Log.infov("finally, current balance {0}", finalValue);
Assertions.assertEquals(initValue + COUNT, finalValue);
}
}
- 上述代碼中,有以下幾點需要註意
- 在主線程中新增了三個子線程,分別執行查詢、充值、扣費的操作,可見deposit和deduct方法是並行執行的
- 初始餘額100,充值一共20元,扣費一共10元,因此最終正確結果應該是110元
- 為了確保三個子線程全部執行完畢後主線程才退出,這裡用了CountDownLatch,在執行latch.await()的時候主線程就開始等待了,等到三個子線程把各自的latch.await()都執行後,主線程才會繼續執行
- 最終會檢查餘額是否等於110,如果不是則單元測試不通過
- 執行單元測試,結果如下圖,果然失敗了
- 來分析測試過程中的日誌,有助於我們理解問題的原因,如下圖,充值和扣費同時開始,充值先完成,此時餘額是102,但是扣費無視102,依舊使用100作為餘額去扣費,然後將扣費結果99寫入餘額,導致餘額與正確的邏輯產生差距
- 反覆運行上述單元測試,可以發現每次得到的結果都不一樣,這算是典型的多線程同步問題了吧...
- 看到這裡,經驗豐富的您應該想到了多種解決方式,例如下麵這五種都可以:
- 用傳統的synchronized關鍵字修飾三個方法
- java包的讀寫鎖
- deposit和deduct方法內部,不要使用臨時變數tempValue,將餘額的類型從int改成AtomicInteger,再使用addAndGet方法計算並設置
- 用MySQL的樂觀鎖
- 用Redis的分散式鎖
- 沒錯,上述方法都能解決問題,現在除了這些,quarku還從bean的維度為我們提供了一種新的方法:bean讀寫鎖,接下來細看這個bean讀寫鎖
Container-managed Concurrency:quarkus基於bean的讀寫鎖方案
- quarkus為bean提供了讀寫鎖方案:Lock註解,藉助它,可以為bean的所有方法添加同一把寫鎖,再手動將讀鎖添加到指定的讀方法,這樣在多線程操作的場景下,也能保證數據的正確性
- 來看看Lock註解源碼,很簡單的幾個屬性,要重點註意的是:預設屬性為Type.WRITE,也就是寫鎖,被Lock修飾後,鎖類型有三種選擇:讀鎖,寫鎖,無鎖
@InterceptorBinding
@Inherited
@Target(value = { TYPE, METHOD })
@Retention(value = RUNTIME)
public @interface Lock {
/**
*
* @return the type of the lock
*/
@Nonbinding
Type value() default Type.WRITE;
/**
* If it's not possible to acquire the lock in the given time a {@link LockException} is thrown.
*
* @see java.util.concurrent.locks.Lock#tryLock(long, TimeUnit)
* @return the wait time
*/
@Nonbinding
long time() default -1l;
/**
*
* @return the wait time unit
*/
@Nonbinding
TimeUnit unit() default TimeUnit.MILLISECONDS;
public enum Type {
/**
* Acquires the read lock before the business method is invoked.
*/
READ,
/**
* Acquires the write (exclusive) lock before the business method is invoked.
*/
WRITE,
/**
* Acquires no lock.
* <p>
* This could be useful if you need to override the behavior defined by a class-level interceptor binding.
*/
NONE
}
}
-
接下來看看如何用bean鎖解AccountBalanceService的多線程同步問題
-
為bean設置讀寫鎖很簡單,如下圖紅框1,給類添加Lock註解後,AccountBalanceService的每個方法都預設添加了寫鎖,如果想修改某個方法的鎖類型,可以像紅框2那樣指定,Lock.Type.READ表示將get方法改為讀鎖,如果不想給方法上任何鎖,就使用Lock.Type.NONE
- 這裡預測一下修改後的效果
- 在deposit和deduct都沒有被調用時,get方法可以被調用,而且可以多線程同時調用,因為每個線程都能順利拿到讀鎖
- 一旦deposit或者deduct被調用,其他線程在調用deposit、deduct、get方法時都被阻塞了,因為此刻不論讀鎖還是寫鎖都拿不到,必須等deposit執行完畢,它們才重新去搶鎖
- 有了上述邏輯,再也不會出現deposit和deduct同時修改餘額的情況了,預測單元測試應該能通過
- 這種讀寫鎖的方法雖然可以確保邏輯正確,但是代價不小(一個線程執行,其他線程等待),所以在併發性能要求較高的場景下要慎用,可以考慮樂觀鎖、AtomicInteger這些方式來降低等待代價
- 再次運行單元測試,如下圖,測試通過
- 再來看看測試過程中的日誌,如下圖,之前的幾個方法同時執行的情況已經消失了,每個方法在執行的時候,其他線程都在等待
- 至此,bean鎖知識點學習完畢,希望本篇能給您一些參考,為您的併發編程中添加新的方案
源碼下載
- 本篇實戰的完整源碼可在GitHub下載到,地址和鏈接信息如下表所示(https://github.com/zq2599/blog_demos)
名稱 | 鏈接 | 備註 |
---|---|---|
項目主頁 | https://github.com/zq2599/blog_demos | 該項目在GitHub上的主頁 |
git倉庫地址(https) | https://github.com/zq2599/blog_demos.git | 該項目源碼的倉庫地址,https協議 |
git倉庫地址(ssh) | [email protected]:zq2599/blog_demos.git | 該項目源碼的倉庫地址,ssh協議 |
- 這個git項目中有多個文件夾,本次實戰的源碼在quarkus-tutorials文件夾下,如下圖紅框
- quarkus-tutorials是個父工程,裡面有多個module,本篇實戰的module是basic-di,如下圖紅框