Java 多線程與高併發之CAS(樂觀鎖)深入解讀

目錄

what is CAS

簡介

背景介紹

源碼分析

AtomicInteger 

unsafe.cpp 

解決什麼問題?

存在哪些缺陷?

1.ABA問題(鏈表會丟數據)

2.長時間自旋非常消耗CPU資源

3.只能保證一個共享變量的原子操作

應用場景

Java 8 incrementAndGet 優化

僞共享

 


what is CAS

  • CAS(compare and swap) 比較並替換,比較和替換是線程併發算法時用到的一種技術

  • CAS是原子操作,保證併發安全,而不是保證併發同步

  • CAS是CPU的一個指令

  • CAS是非阻塞的、輕量級的樂觀鎖

簡介

CAS是全稱是CompareAndSwap ,是一種在多線程環境下實現同步功能的機制。CAS操作包括三個操作數---內存位置、預期數值和新值。CAS的實現邏輯是將內存位置處的數值與預期數值向比較,若相等則將內存位置處的值替換爲新值。若不相等,則不做任何操作【網上很多文章解釋爲循環,這樣是不準確的】

背景介紹

CPU是通過總線和內存進行的數據傳輸,在多核心時代下,多個核心通過一條總線和內存以及其他硬件進行通信。如下圖:

圖片出處《深入理解計算機系統》

上圖是一個較爲簡單的計算機結構圖,雖然簡單,但足以說明問題。在上圖中,CPU通過兩個藍色箭頭標註的總線與內存進行通信。現在考慮一個問題,CPU的多核心同時對一片內存進程操作,做不加以控制,會導致什麼問題?

假設核心1 經32位帶寬的總線向內存寫入64位的數據,核心1要進行兩次寫入擦能完成整個操作。若核心1第一次寫入32位的數據後,核心2從核心11寫入的內存位置讀取了64位數據,由於核心1還未完全將64位的數據全部寫入到內存中,核心2就開始從內存位置讀取數據,那麼讀取出來的數據必定是換亂的。 不過對於這個問題,實際上不需要擔心。自奔騰處理器開始,Intel處理器會保證以原子的方式讀寫按64位邊界對齊的四字(quadword)。

根據上面的說明,Intel處理器可以保證單次訪問內存對齊的指令以原子的方式執行。但如果是兩次訪存指令呢?答案是無法保證的。比如遞增指令 inc dword ptr [...] ,等價於DEST =DEST +1 ,該指令包含三個操作,讀->改->寫,涉及兩次訪問。考慮這樣一種情況,在內存指定位置處,存放了一個爲1的數值。現在CPU兩個核心同時執行該條指令。兩個核心交替執行流程如下:

1.核心1從內存指定位置讀出數值1,並加載到寄存器中

2.核心2從內存指定位置讀取數值1,並加載到寄存器中

3.核心1將寄存器中值遞增1

4.核心2將寄存器中值遞增1

5.核心1將修改後的值寫回內存

6.核心2將修改後的值寫回內存

經過上述流程,內存中的最終值是2,而我們期待的是3,這就出現了問題。要處理這個問題就要避免兩個或者多個核心同時操作同一片區域內存。如何避免呢?這就要引入本文的主角-lock前綴。

LOCK—Assert LOCK# Signal Prefix
Causes the processor’s LOCK# signal to be asserted during execution of the accompanying instruction (turns the instruction into an atomic instruction). In a multiprocessor environment, the LOCK# signal ensures that the processor has exclusive use of any shared memory while the signal is asserted.

在多處理器環境下,LOCK#信號可以確保處理器獨佔使用某些共享內存。lock可以被添加在下面的指令前 ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG.

通過在inc指令前添加lock 前綴,即可讓該指令具備原子性。多個核心同時執行同一條inc指令時,會以串行的方式進行,也就必滿了上面所說的那種情況。

lock 前綴是怎樣保證核心獨佔某片內存區域呢?

在Intel處理器中,與兩種方式保證處理器的某一個核心獨佔某片內存區域。第一種方式是通過鎖定總線,讓某個核心獨佔使用總線,但是這樣的代價太大,總線被鎖定後其他核心就不能訪問內存了,可能會導致其他核心短時間內停止工作;第二種方式是鎖定緩存,若某處內存數據被緩存在處理器緩存中,處理器會發出的lock#信號不會鎖定總線,而是鎖定緩存對應的內存區域。其他處理器在這片內存區鎖定期間,無法對這片內存區域進行相關操作。相對於鎖定總線,鎖定緩存的代價明顯比較小。

簡單而言lock 作用:

  • 如果要訪問的內存區域(area of memory)在lock前綴指令執行期間已經在處理器內部的緩存中被鎖定(即包含該內存區域的緩存行當前處於獨佔或以修改狀態),並且該內存區域被完全包含在單個緩存行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該緩存行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的內存區域,因此能保證指令執行的原子性

  • 禁止該指令與之前和之後的讀和寫指令重排序

  • 把寫緩衝區中的所有數據刷新到內存中

源碼分析

有了上面的背景知識,現在我們就可以從容不迫的閱讀CAS源碼了。以java.util.concurrent.atomic下子類AtomicInteger中的compareAndSet方法進行分析

說明:

mpxchg: 即“比較並交換”指令

dword: 全稱是 double word,在 x86/x64 體系中,一個word = 2 byte,dword = 4 byte = 32 bit

ptr: 全稱是 pointer,與前面的 dword 連起來使用,表明訪問的內存單元是一個雙字單元[edx]: [...] 表示一個內存單元,edx 是寄存器,dest 指針值存放在 edx 中。那麼 [edx] 表示內存地址爲 dest 的內存單元

AtomicInteger 

public class AtomicInteger extends Number implements java.io.Serializable {

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            // 計算變量 value 在類對象中的偏移
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
    
    public final boolean compareAndSet(int expect, int update) {
        /*
         * compareAndSet 實際上只是一個殼子,主要的邏輯封裝在 Unsafe 的 
         * compareAndSwapInt 方法中
         */
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
  //該方法功能是Interger類型加1
		public final int getAndIncrement() {
		//主要看這個getAndAddInt方法
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

		//var1 是this指針
		//var2 是地址偏移量
		//var4 是自增的數值,是自增1還是自增N
		public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
	        //獲取內存值,這是內存值已經是舊的,假設我們稱作期望值E
            var5 = this.getIntVolatile(var1, var2);
            //compareAndSwapInt方法是重點,
            //var5是期望值,var5 + var4是要更新的值
            //這個操作就是調用CAS的JNI,每個線程將自己內存裏的內存值M
            //與var5期望值E作比較,如果相同將內存值M更新爲var5 + var4,否則做自旋操作
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
    // ......
}

public final class Unsafe {
    // compareAndSwapInt 是 native 類型的方法,繼續往下看
    public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);
    // ......
}

unsafe.cpp 

// unsafe.cpp
/*
 * 這個看起來好像不像一個函數,不過不用擔心,不是重點。UNSAFE_ENTRY 和 UNSAFE_END 都是宏,
 * 在預編譯期間會被替換成真正的代碼。下面的 jboolean、jlong 和 jint 等是一些類型定義(typedef):
 * 
 * jni.h
 *     typedef unsigned char   jboolean;
 *     typedef unsigned short  jchar;
 *     typedef short           jshort;
 *     typedef float           jfloat;
 *     typedef double          jdouble;
 * 
 * jni_md.h
 *     typedef int jint;
 *     #ifdef _LP64 // 64-bit
 *     typedef long jlong;
 *     #else
 *     typedef long long jlong;
 *     #endif
 *     typedef signed char jbyte;
 */
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  // 根據偏移量,計算 value 的地址。這裏的 offset 就是 AtomaicInteger 中的 valueOffset
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  // 調用 Atomic 中的函數 cmpxchg,該函數聲明於 Atomic.hpp 中
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

// atomic.cpp
unsigned Atomic::cmpxchg(unsigned int exchange_value,
                         volatile unsigned int* dest, unsigned int compare_value) {
  assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
  /*
   * 根據操作系統類型調用不同平臺下的重載函數,這個在預編譯期間編譯器會決定調用哪個平臺下的重載
   * 函數。相關的預編譯邏輯如下:
   * 
   * atomic.inline.hpp:
   *    #include "runtime/atomic.hpp"
   *    
   *    // Linux
   *    #ifdef TARGET_OS_ARCH_linux_x86
   *    # include "atomic_linux_x86.inline.hpp"
   *    #endif
   *   
   *    // 省略部分代碼
   *    
   *    // Windows
   *    #ifdef TARGET_OS_ARCH_windows_x86
   *    # include "atomic_windows_x86.inline.hpp"
   *    #endif
   *    
   *    // BSD
   *    #ifdef TARGET_OS_ARCH_bsd_x86
   *    # include "atomic_bsd_x86.inline.hpp"
   *    #endif
   * 
   * 接下來分析 atomic_windows_x86.inline.hpp 中的 cmpxchg 函數實現
   */
  return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,
                                       (jint)compare_value);
}

上面的分析看起來比較多,不過主流程並不複雜。如果不糾結於細節代碼,還是比較容易動的。接下來我會分析win平臺下Atomic::cmpxchg函數。

// atomic_windows_x86.inline.hpp
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
              
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  // alternative for InterlockedCompareExchange
  // mp是“os::is_MP()”的返回結果,“os::is_MP()”是一個內聯函數,用來判斷當前系統是否爲多處理器
  //如果當前系統是多處理器,該函數返回1。否則,返回0。
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    // LOCK_IF_MP(mp)會根據mp的值來決定是否爲cmpxchg指令添加lock前綴。如果通過mp判斷當前系統是多處理器(即mp值爲1),則爲cmpxchg指令添加lock前綴。否則,不加lock前綴。
    // 這是一種優化手段,認爲單處理器的環境沒有必要添加lock前綴,只有在多核情況下才會添加lock前綴,因爲lock會導致性能下降。cmpxchg是彙編指令,作用是比較並交換操作數。
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

上面的代碼由 LOCK_IF_MP 預編譯標識符和 cmpxchg 函數組成。爲了看到更清楚一些,我們將 cmpxchg 函數中的 LOCK_IF_MP 替換爲實際內容。如下:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  // 判斷是否是多核 CPU
  int mp = os::is_MP();
  __asm {
    // 將參數值放入寄存器中
    mov edx, dest    // 注意: dest 是指針類型,這裏是把內存地址存入 edx 寄存器中
    mov ecx, exchange_value
    mov eax, compare_value
    
    // LOCK_IF_MP
    cmp mp, 0
    /*
     * 如果 mp = 0,表明是線程運行在單核 CPU 環境下。此時 je 會跳轉到 L0 標記處,
     * 也就是越過 _emit 0xF0 指令,直接執行 cmpxchg 指令。也就是不在下面的 cmpxchg 指令
     * 前加 lock 前綴。
     */
    je L0
    /*
     * 0xF0 是 lock 前綴的機器碼,這裏沒有使用 lock,而是直接使用了機器碼的形式。至於這樣做的
     * 原因可以參考知乎的一個回答:
     *     https://www.zhihu.com/question/50878124/answer/123099923
     */ 
    _emit 0xF0
L0:
    /*
     * 比較並交換。簡單解釋一下下面這條指令,熟悉彙編的朋友可以略過下面的解釋:
     *   cmpxchg: 即“比較並交換”指令
     *   dword: 全稱是 double word,在 x86/x64 體系中,一個 
     *          word = 2 byte,dword = 4 byte = 32 bit
     *   ptr: 全稱是 pointer,與前面的 dword 連起來使用,表明訪問的內存單元是一個雙字單元
     *   [edx]: [...] 表示一個內存單元,edx 是寄存器,dest 指針值存放在 edx 中。
     *          那麼 [edx] 表示內存地址爲 dest 的內存單元
     *          
     * 這一條指令的意思就是,將 eax 寄存器中的值(compare_value)與 [edx] 雙字內存單元中的值
     * 進行對比,如果相同,則將 ecx 寄存器中的值(exchange_value)存入 [edx] 內存單元中。
     */
    cmpxchg dword ptr [edx], ecx
  }
}

 

到這裏CAS的實現過程就講完了,CAS的實現離不開處理器的支持。上面這麼多代碼,其實核心代碼就是一條帶lock前綴的cmpxchg指令,即lock cmpchg dword ptr [edx] ,ecx

注意:CAS 只是保證了操作的原子性,並不保證變量的可見性,因此變量需要加上 volatile 關鍵字

 

解決什麼問題?

在JDK1.5之前,Java語言使用synchronized 關鍵字來保證同步,這會導致有鎖的存在,鎖機制存在一下問題:

  • 在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題

  • 一個線程持有鎖會導致其它所有需要此鎖的線程掛起

  • 如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導致優先級倒置,引起性能風險

volatile是一個不錯的機制,可以保證線程間數據可見性,但是volatile 不能保證原執行。因此對於同步最終還是要回到鎖機制上來。

獨佔鎖是一種悲觀鎖,synchronized就是一種獨佔鎖,會導致其他所有需求當前鎖的線程掛起,等待持有鎖的線程釋放鎖。而另一個更加有效的鎖就是樂觀鎖。所謂樂觀鎖,就是每次不加鎖而是假定沒有衝突而去完成某項操作,如果因爲衝突失敗就重試,直到成功爲止。

存在哪些缺陷?

1.ABA問題(鏈表會丟數據)

因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A

2.長時間自旋非常消耗CPU資源

自旋就是cas的一個操作週期,如果一個線程特別倒黴,每次獲取的值都被其他線程的修改了,那麼它就會一直進行自旋比較,直到成功爲止,在這個過程中cpu的開銷十分的大,所以要儘量避免。如果JVM能支持處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。 ??

3.只能保證一個共享變量的原子操作

當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行CAS操作.

應用場景

  • 自旋鎖 (spinlock)

  • 令牌桶限流器(Eureka 中 RateLimiter::refillToken),保證在多線程情況下,不阻塞線程的填充token 和消費token

public class RateLimiter {

    private final long rateToMsConversion;

    private final AtomicInteger consumedTokens = new AtomicInteger();
    private final AtomicLong lastRefillTime = new AtomicLong(0);

    @Deprecated
    public RateLimiter() {
        this(TimeUnit.SECONDS);
    }

    public RateLimiter(TimeUnit averageRateUnit) {
        switch (averageRateUnit) {
            case SECONDS:
                rateToMsConversion = 1000;
                break;
            case MINUTES:
                rateToMsConversion = 60 * 1000;
                break;
            default:
                throw new IllegalArgumentException("TimeUnit of " + averageRateUnit + " is not supported");
        }
    }

    //提供給外界獲取 token 的方法
    public boolean acquire(int burstSize, long averageRate) {
        return acquire(burstSize, averageRate, System.currentTimeMillis());
    }

    public boolean acquire(int burstSize, long averageRate, long currentTimeMillis) {
        if (burstSize <= 0 || averageRate <= 0) { // Instead of throwing exception, we just let all the traffic go
            return true;
        }

        //添加token
        refillToken(burstSize, averageRate, currentTimeMillis);

        //消費token
        return consumeToken(burstSize);
    }

    private void refillToken(int burstSize, long averageRate, long currentTimeMillis) {
        long refillTime = lastRefillTime.get();
        long timeDelta = currentTimeMillis - refillTime;

        //根據頻率計算需要增加多少 token
        long newTokens = timeDelta * averageRate / rateToMsConversion;
        if (newTokens > 0) {
            long newRefillTime = refillTime == 0
                    ? currentTimeMillis
                    : refillTime + newTokens * rateToMsConversion / averageRate;

            // CAS 保證有且僅有一個線程進入填充
            if (lastRefillTime.compareAndSet(refillTime, newRefillTime)) {
                while (true) {
                    int currentLevel = consumedTokens.get();
                    int adjustedLevel = Math.min(currentLevel, burstSize); // In case burstSize decreased
                    int newLevel = (int) Math.max(0, adjustedLevel - newTokens);
                    // while true 直到更新成功爲止
                    if (consumedTokens.compareAndSet(currentLevel, newLevel)) {
                        return;
                    }
                }
            }
        }
    }

    private boolean consumeToken(int burstSize) {
        while (true) {
            int currentLevel = consumedTokens.get();
            if (currentLevel >= burstSize) {
                return false;
            }

            // while true 直到沒有token 或者 獲取到爲止
            if (consumedTokens.compareAndSet(currentLevel, currentLevel + 1)) {
                return true;
            }
        }
    }

    public void reset() {
        consumedTokens.set(0);
        lastRefillTime.set(0);
    }
}

Java 8 incrementAndGet 優化

由於採用這種 CAS 機制是沒有對方法進行加鎖的,所以,所有的線程都可以進入 increment() 這個方法,假如進入這個方法的線程太多,就會出現一個問題:每次有線程要執行第三個步驟的時候,i 的值老是被修改了,所以線程又到回到第一步繼續重頭再來。

而這就會導致一個問題:由於線程太密集了,太多人想要修改 i 的值了,進而大部分人都會修改不成功,白白着在那裏循環消耗資源。

我們簡單的說下它做了下什麼優化,它內部維護了一個數組Cell[]和base,Cell裏面維護了value,在出現競爭的時候,JDK會根據算法,選擇一個Cell,對其中的value進行操作,如果還是出現競爭,會換一個Cell再次嘗試,最終把Cell[]裏面的value和base相加,得到最終的結果。

因爲其中的代碼比較複雜,我就選擇幾個比較重要的問題,帶着問題去看源碼:

  1. Cell[]是何時被初始化的。

  2. 如果沒有競爭,只會對base進行操作,這是從哪裏看出來的。

  3. 初始化Cell[]的規則是什麼。

  4. Cell[]擴容的時機是什麼。

  5. 初始化Cell[]和擴容Cell[]是如何保證線程安全性的。

public void add(long x) {
        Cell[] cs; long b, v; int m; Cell c;
        if ((cs = cells) != null || !casBase(b = base, b + x)) {//第一行
            boolean uncontended = true;
            if (cs == null || (m = cs.length - 1) < 0 ||//第二行
                (c = cs[getProbe() & m]) == null ||//第三行
                !(uncontended = c.cas(v = c.value, v + x)))//第四行
                longAccumulate(x, null, uncontended);//第五行
        }
    }

這個比較簡單,就是調用compareAndSet方法,判斷是否成功:

  • 如果當前沒有競爭,返回true。

  • 如果當前有競爭,有線程會返回false。

再回到第一行,整體解釋下這個判斷:如果cell[]已經被初始化了,或者有競爭,纔會進入到第二行代碼。如果沒有競爭,也沒有初始化,就不會進入到第二行代碼。

這就回答了第二個問題:如果沒有競爭,只會對base進行操作,是從這裏看出來的

第二行代碼: ||判斷,前者判斷cs是否【爲NULL】,後者判斷(cs的長度-1)是否【大於0】。這兩個判斷,應該都是判斷Cell[]是否初始化的。如果沒有初始化,會進入第五行代碼。

第三行代碼: 如果cell進行了初始化,通過【getProbe() & m】算法得到一個數字,判斷cs[數字]是否【爲NULL】,並且把cs[數字]賦值給了c,如果【爲NULL】,會進入第五行代碼。 我們需要簡單的看下getProbe() 中做了什麼:

static final int getProbe() {
        return (int) THREAD_PROBE.get(Thread.currentThread());
    }

    private static final VarHandle THREAD_PROBE;

第四行代碼: 對c進行了CAS操作,看是否成功,並且把返回值賦值給uncontended,如果當前沒有競爭,就會成功,如果當前有競爭,就會失敗,在外面有一個!(),所以CAS失敗了,會進入第五行代碼。需要注意的是,這裏已經是對Cell元素進行操作了。

第五行代碼: 這方法內部非常複雜,我們先看下方法的整體:

有三個if: 1.判斷cells是否被初始化了,如果被初始化了,進入這個if。

這裏面又包含了6個if,真可怕,但是在這裏,我們不用全部關注,因爲我們的目標是解決上面提出來的問題。

我們還是先整體看下:

第一個判斷:根據算法,拿出cs[]中的一個元素,並且賦值給c,然後判斷是否【爲NULL】,如果【爲NULL】,進入這個if。

if (cellsBusy == 0) {       // 如果cellsBusy==0,代表現在“不忙”,進入這個if
    Cell r = new Cell(x);   //創建一個Cell
    if (cellsBusy == 0 && casCellsBusy()) {//再次判斷cellsBusy ==0,加鎖,這樣只有一個線程可以進入這個if
        //把創建出來Cell元素加入到Cell[]
        try {       
            Cell[] rs; int m, j;
            if ((rs = cells) != null &&
                (m = rs.length) > 0 &&
                rs[j = (m - 1) & h] == null) {
                rs[j] = r;
                break done;
            }
        } finally {
            cellsBusy = 0;//代表現在“不忙”
        }
        continue;           // Slot is now non-empty
    }
}
collide = false;

這就對第一個問題進行了補充,初始化Cell[]的時候,其中一個元素是NULL,這裏對這個爲NULL的元素進行了初始化,也就是隻有用到了這個元素,纔去初始化。

第六個判斷:判斷cellsBusy是否爲0,並且加鎖,如果成功,進入這個if,對Cell[]進行擴容。

try {
     	if (cells == cs)        // Expand table unless stale
             cells = Arrays.copyOf(cs, n << 1);
         } finally {
                        cellsBusy = 0;
             }
         collide = false;
         continue; 

這就回答了第五個問題的一半:擴容Cell[]的時候,利用CAS加了鎖,所以保證線程的安全性。

那麼第四個問題呢?首先你要注意,最外面是一個for (;;)死循環,只有break了,才終止循環。

一開始collide爲false,在第三個if中,對cell進行CAS操作,如果成功,就break了,所以我們需要假設它是失敗的,進入第四個if,第四個if中會判斷Cell[]的長度是否大於CPU核心數, 如果小於核心數,會進入第五個判斷,這個時候collide爲false,會進入這個if,把collide改爲true,代表有衝突,然後跑到advanceProbe方法,生成一個新的THREAD_PROBE,再次循環。如果在第三個if中,CAS還是失敗,再次判斷Cell[]的長度是否大於核心數,如果小於核心數,會進入第五個判斷,這個時候collide爲true,所以不會進入第五個if中去了,這樣就進入了第六個判斷,進行擴容。是不是很複雜。

簡單的來說,Cell[]擴容的時機是:當Cell[]的長度小於CPU核心數,並且已經兩次Cell CAS失敗了。


2.前面兩個判斷很好理解,主要看第三個判斷:

 final boolean casCellsBusy() {
        return CELLSBUSY.compareAndSet(this, 0, 1);
    }

cas設置CELLSBUSY爲1,可以理解爲加了個鎖,因爲馬上就要進行初始化了。

 try {                           // Initialize table
                    if (cells == cs) {
                        Cell[] rs = new Cell[2];
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        break done;
                    }
                } finally {
                    cellsBusy = 0;
                }

初始化Cell[],可以看到長度爲2,根據算法,對其中的一個元素進行初始化,也就是此時Cell[]的長度爲2,但是裏面有一個元素還是NULL,現在只是對其中一個元素進行了初始化,最終把cellsBusy修改成了0,代表現在“不忙了”。

這就回答了 第一個問題:當出現競爭,且Cell[]還沒有被初始化的時候,會初始化Cell[]。 第四個問題:初始化的規則是創建長度爲2的數組,但是隻會初始化其中一個元素,另外一個元素爲NULL。 第五個問題的一半:在對Cell[]進行初始化的時候,是利用CAS加了鎖,所以可以保證線程安全。

3.如果上面的都失敗了,對base進行CAS操作。

如果大家跟着我一起在看源碼,會發現一個可能以前從來也沒有見過的註解:

這個註解是幹什麼的?Contended是用來解決僞共享的

好了,又引出來一個知識盲區,僞共享爲何物。

僞共享

我們知道CPU和內存之間的關係:當CPU需要一個數據,會先去緩存中找,如果緩存中沒有,會去內存找,找到了,就把數據複製到緩存中,下次直接去緩存中取出即可。

但是這種說法,並不完善,在緩存中的數據,是以緩存行的形式存儲的,什麼意思呢?就是一個緩存行可能不止一個數據。假如一個緩存行的大小是64字節,CPU去內存中取數據,會把臨近的64字節的數據都取出來,然後複製到緩存。

這對於單線程,是一種優化。試想一下,如果CPU需要A數據,把臨近的BCDE數據都從內存中取出來,並且放入緩存了,CPU如果再需要BCDE數據,就可以直接去緩存中取了。

但在多線程下就有劣勢了,因爲同一緩存行的數據,同時只能被一個線程讀取,這就叫僞共享了。

有沒有辦法可以解決這問題呢?聰明的開發者想到了一個辦法:如果緩存行的大小是64字節,我可以加上一些冗餘字段來填充到64字節。

比如我只需要一個long類型的字段,現在我再加上6個long類型的字段作爲填充,一個long佔8字節,現在是7個long類型的字段,也就是56字節,另外對象頭也佔8個字節,正好64字節,正好夠一個緩存行。

但是這種辦法不夠優雅,所以在Java8中推出了@jdk.internal.vm.annotation.Contended註解,來解決僞共享的問題。但是如果開發者想用這個註解, 需要添加 JVM 參數,具體參數我在這裏就不說了,因爲我沒有親測過。

 

參考文檔:

 

https://www.cnblogs.com/nullllun/p/9039049.html

https://blog.csdn.net/v123411739/article/details/79561458?depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-3&utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-3

https://juejin.im/post/5a73cbbff265da4e807783f5

https://juejin.im/post/5a75db20f265da4e826320a9

https://juejin.im/post/5cd4e7996fb9a0323e3ad6ff

https://juejin.im/post/5c7a86d2f265da2d8e7101a1

 

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