Java篇 | 巧妙的CAS與樂觀鎖
摘要
接下來我們先理解CAS怎么保證安全的修改共享變量,然后查看JDK源碼分析其最佳實(shí)踐,再舉例實(shí)際企業(yè)開發(fā)中樂觀鎖思想的應(yīng)用。最后總結(jié)CAS以及分析其局限性。
什么是CAS
CAS是CompareAndSwap,即比較和交換。為什么CAS沒有用到鎖還能保證并發(fā)情況下安全的操作數(shù)據(jù)呢,名字其實(shí)非常直觀的表明了CAS的原理,具體修改數(shù)據(jù)過程如下:
用CAS操作數(shù)據(jù)時(shí),將數(shù)據(jù)原始值和要修改的值一并傳遞給方法 比較當(dāng)前目標(biāo)變量值與傳進(jìn)去的原始值是否相同 如果相同,表示目標(biāo)變量沒有被其他線程修改,直接修改目標(biāo)變量值即可 如果目標(biāo)變量值與原始值不同,那么證明目標(biāo)變量已經(jīng)被其他線程修改過,本次CAS修改失敗
從上述過程可以看到CAS其實(shí)保證的是安全的修改數(shù)據(jù),但是修改存在失敗的可能性,即目標(biāo)變量數(shù)據(jù)修改不成功,這個(gè)時(shí)候我們要循環(huán)判斷CAS修改數(shù)據(jù)結(jié)果,如果失敗進(jìn)行重試。
思維比較縝密的同學(xué)可能擔(dān)心CAS本身這個(gè)比較與替換的操作產(chǎn)生并發(fā)安全問題,實(shí)際應(yīng)用中這種情況不會(huì)發(fā)生,比較與替換由JDK借助硬件級(jí)別的CAS原語來保證比較替換是一個(gè)原子性動(dòng)作。
CAS實(shí)現(xiàn)無鎖編程
無鎖編程指的是在不使用鎖的情況下保證安全的操作共享變量在并發(fā)編程中,我們用各種鎖來保證共享變量的安全性。即在保證一個(gè)線程未操作完共享變量的時(shí)候其他線程不能操作同一共享變量。
正確的使用鎖可以保證并發(fā)情況下數(shù)據(jù)安全,但是在并發(fā)程度不高,競爭不激烈的時(shí)候,獲取鎖和釋放鎖就成了沒必要的性能浪費(fèi)。這種情況下可以可考慮利用CAS保證數(shù)據(jù)安全,實(shí)現(xiàn)無鎖編程
頭疼的ABA問題
上面我們已經(jīng)了解了CAS保證安全操作共享變量的原理,但是上述CAS操作還存在缺陷。假設(shè)當(dāng)前線程訪問的共享變量值為A,在線程1訪問共享變量過程中,線程2操作共享變量將其賦值為B,線程2處理完自己的邏輯后又將共享變量賦值為A。這時(shí)線程1比較共享變量值A(chǔ)與原始值A(chǔ)相同,誤以為沒有其他線程操作共享變量,直接返回操作成功。這就是ABA問題。雖然大部分業(yè)務(wù)不需要關(guān)心共享變量是否有過其他更改,只要原始值與當(dāng)前值一致就能得到正確的結(jié)果,但是有一些敏感場景不光要考慮共享變量結(jié)果上等同于沒有被修改過,同時(shí)也不能接受共享變量過程上被其他線程修改過。幸運(yùn)的是ABA問題也有成熟的解決方案,我們?yōu)楣蚕碜兞刻砑右粋€(gè)版本號(hào),每當(dāng)共享變量被修改這個(gè)版本號(hào)值就會(huì)自增。在CAS操作中我們比較的不是原始變量值,而是共享變量的版本號(hào)。每次操作共享變量更新的版本號(hào)都是唯一的,所以能夠避免ABA問題。
具體應(yīng)用場景
JDK中的CAS應(yīng)用
首先多個(gè)線程對(duì)普通變量進(jìn)行并發(fā)操作是不安全的,一個(gè)線程的操作結(jié)果可能被其他線程覆蓋掉,比如現(xiàn)在我們用兩個(gè)線程,每個(gè)線程將初始值為1的共享變量增加一,如果沒有同步機(jī)制的話共享變量結(jié)果很可能小于3。即可能線程1和線程2都讀到了初始值1,線程1將其賦值為2,線程2所在內(nèi)存讀取到的值還是1不會(huì)變,線程2也將變量增加1然后賦值成2,這樣最終結(jié)果是2小于預(yù)期結(jié)果3。自增操作不是原子性操作導(dǎo)致了這個(gè)共享變量操作不安全問題。為了解決這個(gè)問題,JDK提供了一系列原子類提供相應(yīng)的原子操作。下面是AtomicInteger中的getAndIncrement方法源碼,讓我們從源碼來看是怎么利用CAS實(shí)現(xiàn)線程安全的原子性的整形變量相加操作。
/**
* 原子性的將當(dāng)前值增加1
*
* @return 返回自增前的值
*/
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
可以看到getAndIncrement實(shí)際調(diào)用了UnSafe類的getAndAddInt方法實(shí)現(xiàn)原子操作,下面是getAndAddInt源代碼
/**
* 原子的將給定值與目標(biāo)字變量相加并重新賦值給目標(biāo)變量
*
* @param o 要更新的變量所在的對(duì)象
* @param offset 變量字段的內(nèi)存偏移值
* @param delta 要增加的數(shù)字值
* @return 更改前的原始值
* @since 1.8
*/
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 獲取當(dāng)前目標(biāo)目標(biāo)變量值
v = getIntVolatile(o, offset);
// 這句代碼是關(guān)鍵, 自旋保證相加操作一定成功
// 如果不成功繼續(xù)運(yùn)行上一句代碼, 獲取被其他
// 線程搶先修改的變量值, 在新值基礎(chǔ)上嘗試相加
// 操作, 保證了相加操作的原子性
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
我們都對(duì)鎖很熟悉, 比如可重入鎖ReentrantLock, JDK提供的各種鎖基本都依賴AbstractQueuedSynchronizer這個(gè)類, 當(dāng)多個(gè)線程嘗試獲取鎖時(shí)會(huì)進(jìn)入一個(gè)隊(duì)列等待, 其中多線程入隊(duì)操作的原子性就是用CAS來保證的. 源代碼如下:
/**
* 鎖底層等待獲取鎖的線程入隊(duì)操作
* @param node 要入隊(duì)的線程節(jié)點(diǎn)
* @return 入隊(duì)節(jié)點(diǎn)的前驅(qū)節(jié)點(diǎn)
*/
private Node enq(final Node node) {
// 自旋等待節(jié)點(diǎn)入隊(duì), 通過cas保證并發(fā)情況下node安全正確入隊(duì)
for (;;) {
Node t = tail;
// head為空時(shí)構(gòu)造dummy node初始化head和tail
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
// 如果cas設(shè)置tail失敗了
// 下個(gè)循環(huán)取到了最新的其他線程搶先設(shè)置的tail
// 繼續(xù)嘗試設(shè)置.
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
/**
* 原子性的設(shè)置tail尾節(jié)點(diǎn)為新入隊(duì)的節(jié)點(diǎn)
*/
private final boolean compareAndSetTail(Node expect, Node update) {
// 可以看到此處又是調(diào)用了Unsafe類下的原子操作方法
// 如果目標(biāo)字段(tail尾節(jié)點(diǎn)字段)當(dāng)前值是預(yù)期值
// 即沒有被其他線程搶先修改成功, 那么就設(shè)置成功
// 返回true
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
企業(yè)開發(fā)中的樂觀鎖應(yīng)用
除了JDK中Uusafe類提供的各種原子性操作外,我們實(shí)際開發(fā)中可以用CAS思想保證并發(fā)情況下安全的操作數(shù)據(jù)庫。假設(shè)有user表結(jié)構(gòu)以及數(shù)據(jù)如下, version字段是實(shí)現(xiàn)樂觀鎖的關(guān)鍵
| id | user | coupon_num | version |
|---|---|---|---|
| 1 | 朱小明 | 0 | 0 |
假設(shè)我們有一個(gè)用戶領(lǐng)取優(yōu)惠券的按鈕,怎么防止用戶快速點(diǎn)擊按鈕造成重復(fù)領(lǐng)取優(yōu)惠券的情況呢。我們要安全的更改id為1的用戶的coupon_num優(yōu)惠券數(shù)量,將version字段作為CAS比較的版本號(hào),即可避免重復(fù)增加優(yōu)惠券數(shù)量,比較和替換這個(gè)邏輯通過WHERE條件來實(shí)現(xiàn). 涉及sql如下:
UPDATE user
SET coupon_num = coupon_num + 1, version = version + 1
WHERE version = 0
可以看到,我們查詢出id為1的數(shù)據(jù), 版本號(hào)為0,修改數(shù)據(jù)的同時(shí)把當(dāng)前版本號(hào)當(dāng)做條件即可實(shí)現(xiàn)安全修改,如果修改失敗,證明已經(jīng)被其他線程修改過,然后看具體業(yè)務(wù)決定是否需要自旋嘗試再次修改。這里要注意考慮競爭激烈的情況下多個(gè)線程自旋導(dǎo)致過度的性能消耗,根據(jù)并發(fā)量選擇適合自己業(yè)務(wù)的方式
總結(jié)
在Java中我們是無法直接使用Unsafe類提供的CompareAndSwap原子操作方法,所以我們無法自己通過CAS操作變量,但是JDK底層鎖和Atomic系列類都應(yīng)用了Unsafe提供的CAS操作,JDK提供的方法已經(jīng)保證了良好的性能,所以我們正確的使用就好了。
(END)
最近好文分享
MySQL如何實(shí)現(xiàn)每秒 570000 的寫入?
Hibernate 和 MyBatis 哪個(gè)更好用?
更多請掃碼關(guān)注???Java后端編程
