為什麼Java volatile++不是原子性的詳解

問題

在討論原子性操作時,我們經常會聽到一個說法:任意單個volatile變量的讀寫具有原子性,但是volatile++這種操作除外。

所以問題就是:為什麼volatile++不是原子性的?

答案

因為它實際上是三個操作組成的一個符合操作。

  1. 首先獲取volatile變量的值
  2. 將該變量的值加1
  3. 將該volatile變量的值寫會到對應的主存地址

一個很簡單的例子:

如果兩個線程在volatile讀階段都拿到的是a=1,那麼後續在線程對應的CPU核心上進行自增當然都得到的是a=2,最後兩個寫操作不管怎麼保證原子性,結果最終都是a=2。每個操作本身都沒啥問題,但是合在一起,從整體上看就是一個線程不安全的操作:發生瞭兩次自增操作,然而最終結果卻不是3。

分析

結合內存屏障這個概念對volatile的讀寫操作深入理解的話:

第一步:讀

在第一步操作的指令後,會增加兩個內存屏障:

  1. 在Volatile讀操作後插入LoadLoad屏障,防止前面的Volatile讀與後面的普通讀重排序
  2. 在Volatile讀操作後插入LoadStore屏障,防止前面的Volatile讀與後面的普通寫重排序

因此第一個指令和它後續的普通讀寫操作會被保證沒有重排序來搗亂。通常是去內存中去讀。

那麼問題又來瞭,為什麼通常去內存中讀?

其實這個問題要說細的話可以很細,大概就兩個關鍵點吧:

  1. volatile的寫操作的緩存失效機制
  2. 最後一個對volatile變量執行寫操作的CPU,由於在它對應的緩存中保有最新的值,因此可以不用再去主存裡面獲取

具體看下面第三步的分析。

第二步:自增

這個步驟沒什麼特別的,就是在CPU自身的高速緩存(寄存器,L1-L3 Cache)中完成。不涉及到緩存和內存的交互。

第三步:寫

volatile寫算是一個重點。

根據JMM對於volatile變量類型的語義規范:volatile在編譯之後,會在變量寫操作時添加LOCK前綴指令。這個LOCK前綴指令在多核處理器的環境中,有這樣的作用:

  1. 通知CPU將當前處理器緩存行的數據寫回到系統主存中
  2. 該寫回操作將使其他CPU緩存瞭該內存地址的數據無效

另外,內存屏障在volatile的寫操作中起到瞭很大的作用,來保證上面兩點能夠實現:

  1. 在Volatile寫操作前插入StoreStore屏障,防止前面其他寫與本次Volatile寫重排序
  2. 在Volatile寫操作後插入StoreLoad屏障,防止本次的Volatile寫與後面的讀操作重排序

延伸

那麼為瞭解決volatile++這類復合操作的原子性,有什麼方案呢?其實方案也比較多的,這裡提供兩種典型的:

  1. 使用synchronized關鍵字
  2. 使用AtomicInteger/AtomicLong原子類型

synchronized關鍵字

synchronized是比較原始的同步手段。它本質上是一個獨占的,可重入的鎖。當一個線程嘗試獲取它的時候,可能會被阻塞住,所以高並發的場景下性能存在一些問題。

在某些場景下,使用synchronized關鍵字和volatile是等價的:

  1. 寫入變量值時候不依賴變量的當前值,或者能夠保證隻有一個線程修改變量值。
  2. 寫入的變量值不依賴其他變量的參與。
  3. 讀取變量值時候不能因為其他原因進行加鎖。

加鎖可以同時保證可見性和原子性,而volatile隻保證變量值的可見性。

AtomicInteger/AtomicLong

這類原子類型比鎖更加輕巧,比如AtomicInteger/AtomicLong分別就代表瞭整型變量和長整型變量。

在它們的實現中,實際上分別使用的volatile int/volatile long保存瞭真正的值。因此,也是通過volatile來保證對於單個變量的讀寫原子性的。

在此基礎之上,它們提供瞭原子性的自增自減操作。比如incrementAndGet方法,這類方法相對於synchronized的好處是:它們不會導致線程的掛起和重新調度,因為在其內部使用的是CAS非阻塞算法。

CAS是什麼

所謂的CAS全程為CompareAndSet。直譯過來就是比較並設置。這個操作需要接受三個參數:

  1. 內存位置
  2. 舊的預期值
  3. 新值

這個操作的做法就是看指定內存位置的值符不符合舊的預期值,如果符合的話就將它替換成新值。它對應的是處理器提供的一個原子性指令 – CMPXCHG。

比如AtomicLong的自增操作:

public final long incrementAndGet() {
 for (;;) {
  long current = get(); // Step 1
  long next = current + 1; // Step 2
  if (compareAndSet(current, next)) // Step 3
   return next;
 }
}

public final boolean compareAndSet(long expect, long update) {
 return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}

我們考慮兩個線程T1和T2,同時執行到瞭上述Step 1處,都拿到瞭current值為1。然後通過Step 2之後,current在兩個線程中都被設置為2。

緊接著,來到Step 3。假設線程T1先執行,此時符合CompareAndSet的設置規則,因此內存位置對應的值被設置成2,線程T1設置成功。當線程T2執行的時候,由於它預期current為1,但是實際上已經變成瞭2,所以CompareAndSet執行不成功,進入到下一輪的for循環中,此時拿到最新的current值為2,如果沒有其它線程感染的話,再次執行CompareAndSet的時候就能夠通過,current值被更新為3。

所以不難發現,CAS的工作主要依賴於兩點:

  1. 無限循環,需要消耗部分CPU性能
  2. CPU原子指令CompareAndSet

雖然它需要耗費一定的CPU Cycle,但是相比鎖而言還是有其優勢,比如它能夠避免線程阻塞引起的上下文切換和調度。這兩類操作的量級明顯是不一樣的,CAS更輕量一些。

總結

我們說對於volatile變量的讀/寫操作是原子性的。因為從內存屏障的角度來看,對volatile變量的單純讀寫操作確實沒有任何疑問。

由於其中摻雜瞭一個自增的CPU內部操作,就造成這個復合操作不再保有原子性。

然後,討論瞭如何保證volatile++這類操作的原子性,比如使用synchronized或者AtomicInteger/AtomicLong原子類。

到此這篇關於為什麼Java volatile++不是原子性的文章就介紹到這瞭,更多相關Java volatile++不是原子性內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!