1、概要
本文是無鎖同步系列文章的第二篇,主要探討JAVA中的原子操作,以及如何進行無鎖同步。
關於JAVA中的原子操作,我們很容易想到的是Volatile變量、java.util.concurrent.atomic包和JVM提供的CAS操作。
2、Volatile
1)Volatile變量不具有原子性
Volatile變量具有一種可見性,該特性能保證不同線程甚至處理器核心在對這種類型的變量在讀取的時候能讀到最新的值。但Volatile變量不提供原子操作的保證。
下面我們給出一個例子:
1 public class test { 2
3 volatile static int someValue; 4
5 public static void main(String[] args) { 6 someValue = 1; 7 int b = someValue; 8 } 9
10 }
在這個例子中,我們對一個int類型的volatile變量進行寫和讀,在這種場景下volatile變量的讀和寫操作是原子的(注意:這里指是讀操作和寫操作分別是原子的),並且jvm會為我們保證happens-before語義(即會保證寫操作在讀操作之前發生,其實jvm給我們提供的不單止是happens-before,具體詳情我們在本系列的下一篇博文再具體介紹)。我們可以利用這個特性完成一小部分線程同步的需求。但是我們需要注意下面這種情況。
1 public class test { 2
3 volatile static int someValue; 4
5 public static void main(String[] args) { 6 someValue++; 7 } 8
9 }
在這里,我們把讀和寫操作改成一個自增操作,那么這個自增操作是不是原子的呢?
答案是否定的。
自增操作其本質是
1 int tmp = someValue; 2 tmp += 1; 3 someValue = tmp;
這里包含讀、加、寫3個操作。對於int類型來說,java保證這里面的讀和寫操作中是原子的,但不保證它們加在一起仍然是原子的。
也正是由於這個特性,單獨使用volatile變量還不足以實現計數器等包含計算的需求。但是如果使用恰當,這種變量將為線程間的同步帶來無可比擬的性能提升。
2)Volatile變量如何保證可見性
我們知道現代的CPU為了優化性能,計算時一般不與內存直接交互。一般先把數據從內存讀取到CPU內部緩存再進行操作。而不同線程可能由不同的CPU內核執行,很可能會導致某變量在不同的處理器中保存着2個不同副本的情況,導致數據不一致,產生意料之外的結果。那么java是怎么保證volatile變量在所有線程中的數據都是一致的呢?
若對一個Volatile變量進行賦值,編譯后除了生成賦值字節碼外,還會生成一個lock指令。該指令是CPU提供的,能實現下面2個功能:
- 將CPU當前緩存行內的新數據寫入內存
- 將其它CPU核心里包含本變量的緩存行無效化,以強制下次讀取時到內存中讀取
上述過程基於CPU內部的一套緩存協議。具體可以查閱相關文檔。
2、java.util.concurrent.atomic包和CAS
對比volatile變量,atomic包給我們提供了AtomicInteger、AtomicLong、AtomicBooleanAtomicReference、 AtomicIntegerArray、 AtomicLongArray、 AtomicReferenceArray等一系列類,提供了相應類型一系列的原子操作。它們的接口語義非常明顯,下面我們選AtomicInteger加以說明,讀者可以舉一反三學會其他原子類的用法。
AtomicInteger
Get()/Set()
下面我們進入AtomicInteger類探秘,看看它是如何實現原子讀寫的。(下文使用的源碼均來自JDK7)
1 private volatile int value; 2
3 /** 4 * Gets the current value. 5 * 6 * @return the current value 7 */
8 public final int get() { 9 return value; 10 } 11
12 /** 13 * Sets to the given value. 14 * 15 * @param newValue the new value 16 */
17 public final void set(int newValue) { 18 value = newValue; 19 }
沒有錯,就是利用我們上面提到的volatile實現的。
compareAndSet(int expect, int update)和weakCompareAndSet(int expect, int update)
這就是著名的CAS(compare and set)接口。
對比變量的值和expect是否相等,如果相等則將變量的值更新為update。參考第一篇,我們可以根據這個特性實現一些無鎖數據結構。事實上,JDK8中的java.util.concurrent包有不少數據結構被使用CAS優化,其中最著名的就是ConcurrentHashMap。
而要說到weak版本的CAS接口有什么特別之處,它的注釋說明它會"fail spuriously",但是其源碼卻是一模一樣的。
1 /** 2 * Atomically sets the value to the given updated value 3 * if the current value {@code ==} the expected value. 4 * 5 * @param expect the expected value 6 * @param update the new value 7 * @return true if successful. False return indicates that 8 * the actual value was not equal to the expected value. 9 */
10 public final boolean compareAndSet(int expect, int update) { 11 return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 12 } 13
14 /** 15 * Atomically sets the value to the given updated value 16 * if the current value {@code ==} the expected value. 17 * 18 * <p>May <a href="package-summary.html#Spurious">fail spuriously</a> 19 * and does not provide ordering guarantees, so is only rarely an 20 * appropriate alternative to {@code compareAndSet}. 21 * 22 * @param expect the expected value 23 * @param update the new value 24 * @return true if successful. 25 */
26 public final boolean weakCompareAndSet(int expect, int update) { 27 return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 28 }
證明SUN JDK 7沒有按照標准實現weak版本的接口,但是我們無法保證以后的JDK是如何實現的。因此,無論何時,我們都不應假定weak版本的CAS操作和非weak版本具有完全一致的行為。
其他常用接口
int addAndGet(int delta)
以原子方式將給定值與當前值相加。 功能等價於i=i+delta。
int getAndAdd(int delta)
以原子方式將給定值與當前值相加。 功能等價於{int tmp=i;i+=delta;return tmp;}。
int getAndIncrement()
以原子方式將當前值加 1。 功能等價於i++。
int decrementAndGet()
以原子方式將當前值減 1。 功能等價於--i。
int getAndDecrement()
以原子方式將當前值減 1。 功能等價於i--。
int getAndSet(int newValue)
以原子方式設置為給定值,並返回舊值。 功能等價於{int tmp=i;i=newValue;return tmp;}。
int incrementAndGet()
以原子方式將當前值加 1。 功能等價於++i。
3、CAS的ABA問題
描述
ABA問題的描述如下:
- 進程P1在共享變量中讀到值為A
- P1被搶占,進程P2獲得CPU時間片並執行
- P2把共享變量里的值從A改成了B,再改回到A
- P2被搶占,進程P1獲得CPU時間片並執行
- P1回來看到共享變量里的值沒有被改變,繼續按共享變量沒有被改變的邏輯執行
顯然,這很可能導致不可預料的錯誤。
JAVA中的解決方案
在java.util.concurrent.atomic包中,有一個AtomicStampedReference類,它提供了一個帶有Stamp字段的CAS接口。
1 /** 2 * Atomically sets the value of both the reference and stamp 3 * to the given update values if the 4 * current reference is {@code ==} to the expected reference 5 * and the current stamp is equal to the expected stamp. 6 * 7 * @param expectedReference the expected value of the reference 8 * @param newReference the new value for the reference 9 * @param expectedStamp the expected value of the stamp 10 * @param newStamp the new value for the stamp 11 * @return true if successful 12 */
13 public boolean compareAndSet(V expectedReference, 14 V newReference, 15 int expectedStamp, 16 int newStamp) { 17 Pair<V> current = pair; 18 return
19 expectedReference == current.reference &&
20 expectedStamp == current.stamp &&
21 ((newReference == current.reference &&
22 newStamp == current.stamp) ||
23 casPair(current, Pair.of(newReference, newStamp))); 24 }
大家可能已經發現,這個Stamp參數就相當於一個版本號,當版本號和變量的值均一致的時候才允許更新變量。
我們試着用這個方法解決ABA問題:
- 進程P1在共享變量中讀到值為A,Stamp為0。下面我們用二元組(A, 0)表示共享變量的值
- P1被搶占,進程P2獲得CPU時間片並執行
- P2把共享變量里的值從(A, 0)改成了(B, 1),再嘗試把值修改為A,同時更新Stamp。即改為(A, 2)
- P2被搶占,進程P1獲得CPU時間片並執行
- P1回來嘗試更新共享變量的值,A在expectedStamp參數傳入原數值0,卻發現現在Stamp已經不是0了,CAS操作失敗
- P1知道共享變量已經被改變,避免了BUG出現
到這里,ABA問題被解決。
4、總結
線程同步的方法很多,在適當的場景下靈活運用原子操作,避免使用鎖可以提高我們的程序性能。