CAS 全稱是 compare and swap,是一種用於在多線程環境下實現同步功能的機制。CAS 操做包含三個操做數 -- 內存位置、預期數值和新值。CAS 的實現邏輯是將內存位置處的數值與預期數值想比較,若相等,則將內存位置處的值替換爲新值。若不相等,則不作任何操做。html
在 Java 中,Java 並無直接實現 CAS,CAS 相關的實現是經過 C++ 內聯彙編的形式實現的。Java 代碼需經過 JNI 才能調用。關於實現上的細節,我將會在第3章進行分析。java
前面說了 CAS 操做的流程,並非很難。但僅有上面的說明還不夠,接下來我將會再介紹一點其餘的背景知識。有這些背景知識,才能更好的理解後續的內容。linux
咱們都知道,CPU 是經過總線和內存進行數據傳輸的。在多核心時代下,多個核心經過同一條總線和內存以及其餘硬件進行通訊。以下圖:windows
圖片出處:《深刻理解計算機系統》數組
上圖是一個較爲簡單的計算機結構圖,雖然簡單,但足以說明問題。在上圖中,CPU 經過兩個藍色箭頭標註的總線與內存進行通訊。你們考慮一個問題,CPU 的多個核心同時對同一片內存進行操做,若不加以控制,會致使什麼樣的錯誤?這裏簡單說明一下,假設核心1經32位帶寬的總線向內存寫入64位的數據,核心1要進行兩次寫入才能完成整個操做。若在覈心1第一次寫入32位的數據後,核心2從核心1寫入的內存位置讀取了64位數據。因爲核心1還未徹底將64位的數據所有寫入內存中,核心2就開始從該內存位置讀取數據,那麼讀取出來的數據一定是混亂的。緩存
不過對於這個問題,實際上不用擔憂。經過 Intel 開發人員手冊,咱們能夠了解到自奔騰處理器開始,Intel 處理器會保證以原子的方式讀寫按64位邊界對齊的四字(quadword)。多線程
根據上面的說明,咱們可總結出,Intel 處理器能夠保證單次訪問內存對齊的指令以原子的方式執行。但若是是兩次訪存的指令呢?答案是沒法保證。好比遞增指令inc dword ptr [...]
,等價於DEST = DEST + 1
。該指令包含三個操做讀->改->寫
,涉及兩次訪存。考慮這樣一種狀況,在內存指定位置處,存放了一個爲1的數值。如今 CPU 兩個核心同時執行該條指令。兩個核心交替執行的流程以下:app
通過執行上述流程,內存中的最終值時2,而咱們期待的是3,這就出問題了。要處理這個問題,就要避免兩個或多個核心同時操做同一片內存區域。那麼怎樣避免呢?這就要引入本文的主角 - lock 前綴。關於該指令的詳細描述,能夠參考 Intel 開發人員手冊 Volume 2 Instruction Set Reference,Chapter 3 Instruction Set Reference A-L。我這裏引用其中的一段,以下:函數
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 能夠被添加在下面的指令前:oop
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# 信號不會鎖定總線,而是鎖定緩存行對應的內存區域。其餘處理器在這片內存區域鎖按期間,沒法對這片內存區域進行相關操做。相對於鎖定總線,鎖定緩存的代價明顯比較小。關於總線鎖和緩存鎖,更詳細的描述請參考 Intel 開發人員手冊 Volume 3 Software Developer’s Manual,Chapter 8 Multiple-Processor Management。
有了上面的背景知識,如今咱們就能夠從容不迫的閱讀 CAS 的源碼了。本章的內容將對 java.util.concurrent.atomic 包下的原子類 AtomicInteger 中的 compareAndSet 方法進行分析,相關分析以下:
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); } // ...... } public final class Unsafe { // compareAndSwapInt 是 native 類型的方法,繼續往下看 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x); // ...... }
// 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); }
上面的分析看起來比較多,不過主流程並不複雜。若是不糾結於代碼細節,仍是比較容易看懂的。接下來,我會分析 Windows 平臺下的 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 int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value 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 cmpxchg dword ptr [edx], ecx
。
談到 CAS,基本上都要談一下 CAS 的 ABA 問題。CAS 由三個步驟組成,分別是「讀取->比較->寫回」。考慮這樣一種狀況,線程1和線程2同時執行 CAS 邏輯,兩個線程的執行順序以下:
如上流程,線程1並不知道原值已經被修改過了,在它看來並沒什麼變化,因此它會繼續往下執行流程。對於 ABA 問題,一般的處理措施是對每一次 CAS 操做設置版本號。java.util.concurrent.atomic 包下提供了一個可處理 ABA 問題的原子類 AtomicStampedReference,具體的實現這裏就不分析了,有興趣的朋友能夠本身去看看。
寫到這裏,這篇文章總算接近尾聲了。雖然 CAS 自己的原理,包括實現都不是很難,可是寫起來真的不太好寫。這裏面涉及到了一些底層的知識,雖然能看懂,但想說明白,仍是有點難度的。因爲我底層的知識比較欠缺,上面的一些分析不免會出錯。因此若有錯誤,請輕噴,固然最好能說明怎麼錯的,感謝。
好了,本篇文章就到這裏。感謝閱讀,再見。
在前面源碼分析一節中用到的幾個文件,這裏把路徑貼出來。有助於你們進行索引,以下:
文件名 | 路徑 |
---|---|
Unsafe.java | openjdk/jdk/src/share/classes/sun/misc/Unsafe.java |
unsafe.cpp | openjdk/hotspot/src/share/vm/prims/unsafe.cpp |
atomic.cpp | openjdk/hotspot/src/share/vm/runtime/atomic.cpp |
atomic_windows_x86.inline.hpp | openjdk/hotspot/src/os_cpu/windows_x86/vm/atomic_windows_x86.inline.hpp |
本文在知識共享許可協議 4.0 下發布,轉載需在明顯位置處註明出處
做者:coolblog
本文同步發佈在個人我的博客: http://www.coolblog.xyz
本做品採用知識共享署名-非商業性使用-禁止演繹 4.0 國際許可協議進行許可。