Java CAS操作的实现原理深度解析与应用案例
首先介紹了CAS的基本概念,然后深入至HotSpot源碼級別的角度解析了CAS的底層實現,最后介紹了CAS操作存在的問題以及應用。
文章目錄
- 1 CAS的概述
- 2 Java的CAS實現
- 3 CAS的底層實現原理
- 4 CAS的三大問題
- 4.1 ABA問題
- 4.2 循環時間長開銷大
- 4.3 只能保證一個共享變量的原子操作
- 5 總結
對于并發控制而言, 鎖是一種悲觀的策略。它總是假設每一次的臨界區操作會產生沖突,因此,必須對每次操作都小心翼翼。如果有多個線程同時需要訪問臨界區資源,則寧可犧牲性能讓線程進行等待,所以說鎖會阻塞線程執行。
而無鎖(lock-free)是一種樂觀的策略,它會假設對資源的訪問是沒有沖突的。既然沒有沖突,自然不需要等待,所以所有的線程都可以在不停頓的狀態下待續執行。那遇到沖突怎么辦呢?無鎖的策略使用一種叫作比較交換(CAS, Compare And Swap) 的技術來鑒別線程沖突,一旦檢測到沖突產生,就重試當前操作直到沒有沖突為止。
1 CAS的概述
CAS(Compare and Swap),翻譯過來就是“比較并交換”。CAS 操作包含三個操作數 —— 要更新的字段內存位置V(它的值是我們想要去更新的)、預期原值A(前面從內存中讀取的值)和新值B(將要寫入的新值)。
CAS操作過程:首先讀取預期原值A,然后在要更新數據的時候再次讀取內存位置V的值,如果該值與預期原值A相匹配,那么處理器會自動將該位置V值更新為新值B;否則一直重試該過程,直到成功。
CAS 操作是抱著樂觀的態度進行的,它總是認為自己可以成功完成操作。當多個線程同時使用CAS操作一個變量時,只有一個會勝出,并成功更新,其余均會失敗。失敗的線程不會被掛起,僅是被告知失敗,并且允許再次嘗試,直到成功。當然也允許失敗的線程放棄操作。
2 Java的CAS實現
在硬件層面,大部分的現代處理器都已經支持原子化的CAS 指令。在JDK5 以后, 虛擬機便可以使用這個指令來實現并發操作和并發數據結構, 并且這種操作在虛擬機中可以說是無處不在的。
從Java 1.5開始,JDK的并發包里提供了一些類來支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當前值自增1和自減1。它們的內部就是使用CAS操作來實現的。
如下案例:
public class Practise {private AtomicInteger atomicI = new AtomicInteger(0);//使用到計數器實現線程等待,設置初始計數器值為100private static CountDownLatch countDownLatch = new CountDownLatch(100);public static void main(String[] args) throws InterruptedException {final Practise cas = new Practise();ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(50, 100, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(50), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());for (int j = 0; j < 100; j++) {threadPoolExecutor.submit(() -> {for (int i = 0; i < 10000; i++) {cas.safeCount();//cas.unsafeCount();}//上面的任務執行完畢,計數器值減去一countDownLatch.countDown();});}threadPoolExecutor.shutdown();//main線程將會等待所有任務都執行完,即計數器值變為0時,才繼續執行countDownLatch.await();System.out.println(cas.atomicI.get());}/*** 不安全的更新方式,unsafeCount方法中的代碼不是原子性的,有可能造成多個線程重復寫同一個變量*/private void unsafeCount() {int i = atomicI.get();atomicI.set(++i);}/*** 使用CAS實現安全的更新方法*/private void safeCount() {//for循環嘗試使得共享變量atomicI的值增加1for (; ; ) {int i = atomicI.get();//compareAndSet方法是Java幫我們實現的一個CAS方法,CAS成功之后會返回true,CAS失敗則返回false//compareAndSet方法的參數含義是:預估原值為i,讓后將原值嘗試CAS更新為++i;其他所需的參數則是在compareAndSet方法內部幫我們自動獲取了。//如果是true,說明變量atomicI的值增加成功,跳出循環,如果返回false,說明變量atomicI的值增加失敗,重新循環直到成功為止boolean suc = atomicI.compareAndSet(i, ++i);if (suc) {break;}}}/*** compareAndSet方法內部,變量和字段內存偏移量幫我們獲取了* @param expect* @param update* @return*/ // public final boolean compareAndSet(int expect, int update) { // //this:變量 valueOffset:value值的偏移量,通過此可以定位該字段在JVM內存中的位置 expect:預估原值 update:更新為指定值 // return unsafe.compareAndSwapInt(this, valueOffset, expect, update); // } }3 CAS的底層實現原理
CAS的底層實現,是通過lock cmpxchg匯編指令來實現的。cmpxchg用來實現比較交換操作,lock前綴指令用來保證多cpu環境下指令所在緩存行的獨占同步,保證了原子性,有序性和可見性。
原子類的很多方法都是使用CAS操作,以上例中的compareAndSet方法為例子:
public final boolean compareAndSet(int expect, int update) {return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }可以看到內部調用了Unsafe類的compareAndSwapInt方法,Unsafe類中的方法能夠通過直接操作字段偏移量來修改字段的值,相當于通過“指針”來操作字段,這個字段的偏移量-“指針”的位置需要自己計算,因此如果計算錯誤那么會帶來一系列錯誤,比如會導致整個JVM實例崩潰等嚴重問題。因此Java并不鼓勵使用該類,甚至命名為“Unsafe”。Unsafe類是Java實現CAS的基石,但它的作用不僅僅是實現CAS,還有操作直接內存等作用,實際上Unsafe類也是juc包的實現的基石。關于Unsafe的詳細理解,可以看這篇文章:JUC中的Unsafe類詳解與使用案例。
Unsafe的compareAndSwapInt方法是一個native的方法,native方法又稱本地方法,可以看作Java代碼調用非java代碼的接口,即該方法的實現由非java語言實現。由于Java語言無法訪問操作系統底層信息,這時候如果想要直接操作底層系統,那就需要借助C++來完成了,因此native方法一般是Java通過借助C、C++來實現直接操作底層系統、直接內存的方法。
Unsafe的具體實現是和虛擬機實現相關的,不同的虛擬機具有不同的實現。在openjdk8的hotspot源碼(unsafe.cpp)中能看到unsafe的源碼,由于hotspot使用C++編寫,那么unsafe的對應方法的C++源碼如下:
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))UnsafeWrapper("Unsafe_CompareAndSwapInt"); //獲取obj這個對象在jvm里面的對應的內存對象實例oop p = JNIHandles::resolve(obj); //通過對象實例和偏移量獲取字段在對象中的偏移地址jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); // 通過判斷調用Atomic.cmpxchg方法的結果返回值是不是原來的e值,如果是表示更新成功,否則更新失敗。return (jint)(Atomic::cmpxchg(x, addr, e)) == e; UNSAFE_END我們可以看到在該方法的最后調用了Atomic::cmpxchg的方法,但是你如果直接去atomic.cpp中是找不到的,并且上面的方法只是C++的源碼并沒有具體的匯編指令,但是我們在atomic.cpp中能夠找到很多預處理指令,即#include “”,該指令會在實現定義的位置查找文件,并將其包含。我們找到:
#include "runtime/atomic.inline.hpp"進入atomic.inline.hpp,這里面又有很多預處理指令,這些包含的文件均具有Atomic::cmpxchg的不同實現,有windows的也有linux的,一般生產應用運行在linux環境中,因此我們找到其中一個-我們分析Linux的x86的環境:
# include "atomic_linux_x86.inline.hpp"在atomic_linux_x86.hpp的源碼中,終于能找到該方法,匯編指令(CPU指令)可以自由的嵌入C/C++,下面的方法就是最好的證明,接下來進入匯編指令的世界:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value, cmpxchg_memory_order order) {int mp = os::is_MP();__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)": "=a" (exchange_value): "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp): "cc", "memory");return exchange_value; }其中__asm__表示匯編指令開始,volatile禁止優化重排序,MP表示multiprocessor,LOCK_IF_MP開始時表示它會判斷當前系統是否是多核處理器,如果是,那么就在cmpxchg指令前面加上lock指令前綴,否則就使用cmpxchg一條指令。
Cmpxchg后面加了一個后綴l,表示操作數字長32位。另外cmpxchgl %1,(%3)是一種匯編代碼風格: 操作指令 源操作數 首操作數。
CMPXCHG r,r/m 將累加器AL/AX/EAX/RAX中的值與第二個操作數(首操作數)比較,如果相等,第1操作數(源操作數)的值裝載到首操作數,zf置1。如果不等,首操作數的值裝載到AL/AX/EAX/RAX并將zf清0。
針對上面的匯編代碼,exchange_value表示交換的值,compare_value表示被比較的值(預期值),dest表示內存偏移量,其大概過程就是將被比較的值compare_value與dest地址的值比較,如果相等則將exchange_value寫入該地址值,并將compare_value賦值給exchange_value,如果不等則將目前dest地址的值賦給exchange_value,將最終會返回exchange_value。
可以看到Java的CAS操作的最終實現,是通過lock cmpxchg匯編指令實現的。關于這兩條指令,在Intel文檔中有詳細介紹,這里將它簡單翻譯成中文如下:
cmpxchg
作用:匯編指令,比較并交換操作數
該指令只能用于486及其后繼機型。第2操作數(源操作數)只能用8位、16位或32位寄存器。第1操作數(目地操作數)則可用寄存器或任一種存儲器尋址方式。
注意:雖然cmpxchg看起來只有一條指令,但在多核cpu下僅比較交換的指令仍然不具有原子性,因為cmpxchg作為復雜指令,同時帶有讀寫操作,在執行時會被分解為一條條的更小的微碼(微指令),一般來說只有單一的load、stroe等指令是真正原子性的。
但是該指令可以與lock同步一起使用,以允許以原子方式執行該指令(來自Intel手冊)。
========================================
lock
前綴指令,通常可以與某些指令連用(ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令),它具有如下特性(來自Intel手冊):
注意這里的lock和Java中的Lock是完全不一樣的,Java中的Lock被實現為一個高級鎖,而這里的lock僅僅是一條低級CPU指令。
Lock鎖可以保護Java代碼片段的原子性,由于不確定代碼片段的執行時間(可能很長),沒有獲取Lock鎖的線程將會等待,直到Lock鎖可用,這會帶來線程狀態切換的開銷。這里的lock指令僅保護單個指令,針對單個指令實現同步,單個指令的執行時間一般很短的,因此不需要線程變成等待狀態。
從底層匯編指令看起來,CAS實現的“無鎖算法”,并不是真正的消除同步,而是將同步限定在單個指令上面,或者說鎖定單個指令,不過這相比于常見的synchronized和Lock鎖這些重量級鎖來講,確實可以算作“無鎖了”。
4 CAS的三大問題
4.1 ABA問題
CAS需要再操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新。但是一個值,如果原來為A,變成了B,又變成了A,那么使用CAS進行compare and set的時候,會發現它的值根本沒變化過,但實際上是變化過的。
ABA問題的解決思路就是使用版本號,1A->2B->3A,在Atomic包中(JDK5),提供了一個現成的AtomicStampedReference類來解決ABA問題,使用的就是添加版本號的方法。
4.2 循環時間長開銷大
由于線程并不會阻塞,如果CAS自旋長時間不成功,這會給CPU帶來非常大的執行開銷。
如果JVM能支持處理器提供的pause指令,那么效率會有一定的提升。pause指令有兩個作用:第一,它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決于具體實現的版本,在一些處理器上延遲時間是零;第二,它可以避免在退出循環的時候因內存順序沖突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush),從而提高CPU的執行效率。
4.3 只能保證一個共享變量的原子操作
當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,由于CAS底層只能鎖定單個指令,均是針對單個變量的操作,對多個共享變量操作時意味著多個指令,此時CAS就無法保證所有操作的原子性,這個時候就可以用鎖。
還有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如,有兩個共享變量i=2,j=a,合并一下ij=2a,然后用CAS來操作ij。從Java 1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象里來進行CAS操作。
5 總結
synchronized被稱為重量鎖,因為synchronized會進行比較復雜的加鎖、解鎖和喚醒操作,這其中涉及到線程狀態的轉換,比較耗費時間。CAS作為非阻塞的輕量級的樂觀鎖,通過CPU指令實現,沒有線程狀態的切換,CAS在資源競爭不激烈的情況下性能高,而如果資源競爭激烈的話CAS可能導致大量線程長時間空轉(自旋),這樣同樣會消耗大量CPU資源。
但是上面的一切在JDK6之后變得不那么一定了。JDK6之后Java對synchronized進行了一系列“鎖升級”的優化(Java中的synchronized的底層實現原理以及鎖升級優化詳解),其中synchronized的很多底層實現就是采用了CAS操作,對于線程沖突較少時使用的偏向鎖和輕量級鎖也沒有了線程狀態切換,可以獲得和CAS類似的性能,而線程沖突嚴重的情況下,synchronized的性能仍然遠高于CAS。這使得synchronized沒有那么“重”了,實際上JDK6之后的synchronized的性能已經非常好了,所以目前volatile的應用范圍已經不大了。
還有一點,我們看到了lock指令那么多的作用,我相信你們已經猜到了,除了CAS操作之外,實際上Java中的final、synchronized、volatile關鍵字的底層實現都和lock指令有關。具體是有什么關系,后面的博客會有講解。
參考資料:
如果有什么不懂或者需要交流,可以留言。另外希望點贊、收藏、關注,我將不間斷更新各種Java學習博客!
總結
以上是生活随笔為你收集整理的Java CAS操作的实现原理深度解析与应用案例的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: .tsv文件批量导入mysql
- 下一篇: 今天,小灰35岁了!