巧妙的CAS與樂觀鎖思想

原創文章,轉載請私信.關注公衆號 tastejava 學習加思考,品味java之美

摘要

接下來我們先理解CAS怎麼保證安全的修改共享變量,然後查看JDK源碼分析其最佳實踐,再舉例實際企業開發中樂觀鎖思想的應用。最後總結CAS以及分析其侷限性。

什麼是CAS

CAS是CompareAndSwap,即比較和交換。爲什麼CAS沒有用到鎖還能保證併發情況下安全的操作數據呢,名字其實非常直觀的表明了CAS的原理,具體修改數據過程如下:

  1. 用CAS操作數據時,將數據原始值和要修改的值一併傳遞給方法
  2. 比較當前目標變量值與傳進去的原始值是否相同
  3. 如果相同,表示目標變量沒有被其他線程修改,直接修改目標變量值即可
  4. 如果目標變量值與原始值不同,那麼證明目標變量已經被其他線程修改過,本次CAS修改失敗

從上述過程可以看到CAS其實保證的是安全的修改數據,但是修改存在失敗的可能性,即目標變量數據修改不成功,這個時候我們要循環判斷CAS修改數據結果,如果失敗進行重試。

思維比較縝密的同學可能擔心CAS本身這個比較與替換的操作產生併發安全問題,實際應用中這種情況不會發生,比較與替換由JDK藉助硬件級別的CAS原語來保證比較替換是一個原子性動作。

CAS實現無鎖編程

無鎖編程指的是在不使用鎖的情況下保證安全的操作共享變量在併發編程中,我們用各種鎖來保證共享變量的安全性。即在保證一個線程未操作完共享變量的時候其他線程不能操作同一共享變量。
正確的使用鎖可以保證併發情況下數據安全,但是在併發程度不高,競爭不激烈的時候,獲取鎖和釋放鎖就成了沒必要的性能浪費。這種情況下可以可考慮利用CAS保證數據安全,實現無鎖編程

頭疼的ABA問題

上面我們已經瞭解了CAS保證安全操作共享變量的原理,但是上述CAS操作還存在缺陷。假設當前線程訪問的共享變量值爲A,在線程1訪問共享變量過程中,線程2操作共享變量將其賦值爲B,線程2處理完自己的邏輯後又將共享變量賦值爲A。這時線程1比較共享變量值A與原始值A相同,誤以爲沒有其他線程操作共享變量,直接返回操作成功。這就是ABA問題。
雖然大部分業務不需要關心共享變量是否有過其他更改,只要原始值與當前值一致就能得到正確的結果,但是有一些敏感場景不光要考慮共享變量結果上等同於沒有被修改過,同時也不能接受共享變量過程上被其他線程修改過。
幸運的是ABA問題也有成熟的解決方案,我們爲共享變量添加一個版本號,每當共享變量被修改這個版本號值就會自增。在CAS操作中我們比較的不是原始變量值,而是共享變量的版本號。每次操作共享變量更新的版本號都是唯一的,所以能夠避免ABA問題。

具體應用場景

JDK中的CAS應用

首先多個線程對普通變量進行併發操作是不安全的,一個線程的操作結果可能被其他線程覆蓋掉,比如現在我們用兩個線程,每個線程將初始值爲1的共享變量增加一,如果沒有同步機制的話共享變量結果很可能小於3。即可能線程1和線程2都讀到了初始值1,線程1將其賦值爲2,線程2所在內存讀取到的值還是1不會變,線程2也將變量增加1然後賦值成2,這樣最終結果是2小於預期結果3。自增操作不是原子性操作導致了這個共享變量操作不安全問題。爲了解決這個問題,JDK提供了一系列原子類提供相應的原子操作。
下面是AtomicInteger中的getAndIncrement方法源碼,讓我們從源碼來看是怎麼利用CAS實現線程安全的原子性的整形變量相加操作。

/**
 * 原子性的將當前值增加1
 *
 * @return 返回自增前的值
 */
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

可以看到getAndIncrement實際調用了UnSafe類的getAndAddInt方法實現原子操作,下面是getAndAddInt源代碼

/**
 * 原子的將給定值與目標字變量相加並重新賦值給目標變量
 * 
 * @param o 要更新的變量所在的對象
 * @param offset 變量字段的內存偏移值
 * @param delta 要增加的數字值
 * @return 更改前的原始值
 * @since 1.8
 */
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;
}

我們都對鎖很熟悉, 比如可重入鎖ReentrantLock, JDK提供的各種鎖基本都依賴AbstractQueuedSynchronizer這個類, 當多個線程嘗試獲取鎖時會進入一個隊列等待, 其中多線程入隊操作的原子性就是用CAS來保證的. 源代碼如下:

/**
 * 鎖底層等待獲取鎖的線程入隊操作
 * @param node 要入隊的線程節點
 * @return 入隊節點的前驅節點
 */
private Node enq(final Node node) {
	// 自旋等待節點入隊, 通過cas保證併發情況下node安全正確入隊
    for (;;) {
        Node t = tail;
        // head爲空時構造dummy node初始化head和tail
        if (t == null) {
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            // 如果cas設置tail失敗了
            // 下個循環取到了最新的其他線程搶先設置的tail
            // 繼續嘗試設置.
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
/**
 * 原子性的設置tail尾節點爲新入隊的節點
 */
private final boolean compareAndSetTail(Node expect, Node update) {
	// 可以看到此處又是調用了Unsafe類下的原子操作方法
	// 如果目標字段(tail尾節點字段)當前值是預期值
	// 即沒有被其他線程搶先修改成功, 那麼就設置成功
	// 返回true
    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

企業開發中的樂觀鎖應用

除了JDK中Uusafe類提供的各種原子性操作外,我們實際開發中可以用CAS思想保證併發情況下安全的操作數據庫。
假設有user表結構以及數據如下, version字段是實現樂觀鎖的關鍵

id user coupon_num version
1 朱小明 0 0

假設我們有一個用戶領取優惠券的按鈕,怎麼防止用戶快速點擊按鈕造成重複領取優惠券的情況呢。
我們要安全的更改id爲1的用戶的coupon_num優惠券數量,將version字段作爲CAS比較的版本號,即可避免重複增加優惠券數量,比較和替換這個邏輯通過WHERE條件來實現.
涉及sql如下:

UPDATE user 
SET coupon_num = coupon_num + 1, version = version + 1 
WHERE version = 0

可以看到,我們查詢出id爲1的數據, 版本號爲0,修改數據的同時把當前版本號當做條件即可實現安全修改,如果修改失敗,證明已經被其他線程修改過,然後看具體業務決定是否需要自旋嘗試再次修改。
這裏要注意考慮競爭激烈的情況下多個線程自旋導致過度的性能消耗,根據併發量選擇適合自己業務的方式

總結

在Java中我們是無法直接使用Unsafe類提供的CompareAndSwap原子操作方法,所以我們無法自己通過CAS操作變量,但是JDK底層鎖和Atomic系列類都應用了Unsafe提供的CAS操作,JDK提供的方法已經保證了良好的性能,所以我們正確的使用就好了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章