AtomicInteger深入理解

高併發的情況下,i++無法保證原子性,往往會出現問題,所以引入AtomicInteger類。

  • 代碼測試

public class TestAtomicInteger {
    private static final int THREADS_COUNT = 2;

    public static int count = 0;
    public static volatile int countVolatile = 0;
    public static AtomicInteger atomicInteger = new AtomicInteger(0);
    public static CountDownLatch countDownLatch = new CountDownLatch(2);

    public static void increase() {
        count++;
        countVolatile++;
        atomicInteger.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i< threads.length; i++) {
            threads[i] = new Thread(() -> {
                for(int i1 = 0; i1 < 1000; i1++) {
                    increase();
                }
                countDownLatch.countDown();
            });
            threads[i].start();
        }

        countDownLatch.await();

        System.out.println(count);
        System.out.println(countVolatile);
        System.out.println(atomicInteger.get());
    }
}
  • 測試結果如下:
1974
1990
2000
  • 通過多次測試,我們可以看到只有AtomicInteger能夠真正保證最終結果永遠是2000。關於volatile的文章,這裏可以推薦一個: 面試官最愛的volatile關鍵字
  • 代碼閱讀
    • 考慮到代碼比較長,這裏僅僅截取部分代碼進行講解。
  • 定義的變量
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

// 通過Unsafe計算出value變量在對象中的偏移,該偏移值下邊會用到
static {
   try {
       valueOffset = unsafe.objectFieldOffset
          (AtomicInteger.class.getDeclaredField("value"));
   } catch (Exception ex) { throw new Error(ex); }
}

// value保存當前的值
private volatile int value;
  • unsafe: 一般來說,Java不像c或者c++那樣,可以直接操作內存,Unsafe可以說是一個後門,可以直接操作內存,或者進行線程調度。(以後會專門寫一篇關於Unsafe類的文章),能夠使用
  • valueOffset: 在類初始化的時候,計算出value變量在對象中的偏移
  • value: 保存當前的值
  • layzSet方法:
/**
 * Eventually sets to the given value.
 */
public final void lazySet(int newValue) {
    unsafe.putOrderedInt(this, valueOffset, newValue);
}
  • 該方法調用了本地方法Unsafe.putOrderedLong,具體實現見源碼
  {CC"putOrderedObject",   CC"("OBJ"J"OBJ")V",         FN_PTR(Unsafe_SetOrderedObject)},
  {CC"putOrderedInt",      CC"("OBJ"JI)V",             FN_PTR(Unsafe_SetOrderedInt)},
  {CC"putOrderedLong",     CC"("OBJ"JJ)V",             FN_PTR(Unsafe_SetOrderedLong)},
  • 可以看到該方法會調用Unsafe_SetOrderedInt方法。
UNSAFE_ENTRY(void, Unsafe_SetOrderedInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint x))
  UnsafeWrapper("Unsafe_SetOrderedInt");
  SET_FIELD_VOLATILE(obj, offset, jint, x);
UNSAFE_END
  • 調用了SET_FIELD_VOLATILE
#define SET_FIELD_VOLATILE(obj, offset, type_name, x) \
  oop p = JNIHandles::resolve(obj); \
  OrderAccess::release_store_fence((volatile type_name*)index_oop_from_field_offset_long(p, offset), x);
  • 繼續看: OrderAccess::release_store_fence,該方法可見源碼
inline void     OrderAccess::release_store_fence(volatile jint*   p, jint   v) {
  __asm__ volatile (  "xchgl (%2),%0"
                    : "=r" (v)
                    : "0" (v), "r" (p)
                    : "memory");
}
  • 最終採用xchgl指令來實現變量的賦值。
  • 所以: lazySet方法是由依賴於硬件的系統指令(如x86的xchg)實現的。使用lazySet的話,其他線程在之後的一小段時間裏還是可以讀到舊的值。個人猜測:lazySet方法相比於set方法可能性能好一點。
  • 網站上找到的資料:
首先set()是對volatile變量的一個寫操作, 我們知道volatile的write爲了保證對其他線程的可見性會追加以下兩個Fence(內存屏障)
StoreStore // 在intel cpu中, 不存在[寫寫]重排序, 這個可以直接省略了
StoreLoad // 這個是所有內存屏障裏最耗性能的
注: 內存屏障相關參考Doug Lea大大的cookbook (http://g.oswego.edu/dl/jmm/cookbook.html)

Doug Lea大大又說了, lazySet()省去了StoreLoad屏障, 只留下StoreStore

設想如下場景: 設置一個 volatile 變量爲 null,讓這個對象被 GC 掉,volatile write 是消耗比較大(store-load 屏障)的,但是 putOrderedInt 只會加 store-store 屏障,損耗會小一些。

  • set方法

  • 由於使用了volatile關鍵字,因此set方法可以保證併發情況下不會出現問題。
  • incrementAndGet

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}   
  • 調用unsafe中的getAndInt方法。
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        // 直接從該對象對應位置取出變量的值
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}
  • var1: Object
  • var2: valueOffset
  • var5: 當前該變量在內存中的值
  • var5 + var4: 需要寫進去的值
  • 採用CAS機制,不斷使用compareAndSwapInt嘗試修改該值,如果失敗,重新獲取。如果併發量小,問題不大。
  • 併發量大的情況下,由於真正更新成功的線程佔少數,容易導致循環次數過多,浪費時間。
  • 由於需要保證變量真正的共享,緩存行失效,緩存一致性開銷變大。
  • 底層開銷可能較大,這個我就不追究了。
  • 該函數做的事較多,不僅增加value,同時還給出返回值,返回值換成void就好了。
  • getAndUpdate
public final int getAndUpdate(IntUnaryOperator updateFunction) {
    int prev, next;
    do {
        prev = get();
        next = updateFunction.applyAsInt(prev);
    } while (!compareAndSet(prev, next));
    return prev;
}
  • 該方法需要實現IntUnaryOperator接口,然後會調用applyAsInt方法對當前值進行處理,將當前值替換爲applyAsInt方法的返回值。
  • 參考代碼:
public class TestAtomicInteger {
    private static final int THREADS_COUNT = 2;

    public static AtomicInteger atomicInteger = new AtomicInteger(0);
    public static CountDownLatch countDownLatch = new CountDownLatch(2);

    public static void doubleValue() {
        atomicInteger.getAndUpdate(operand -> {
            if(operand % 2 == 0) {
                operand = operand + 11;
            } else {
                operand = operand + 19;
            }
            return operand;
        });
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[THREADS_COUNT];
        for(int i = 0; i< threads.length; i++) {
            threads[i] = new Thread(() -> {
                for(int i1 = 0; i1 < 1000; i1++) {
                    doubleValue();
                }
                countDownLatch.countDown();
            });
            threads[i].start();
        }

        countDownLatch.await();

        System.out.println(atomicInteger.get());
    }
}

  • 結果是:30000。
  • 操作系統級別CAS實現
  • 摘自CAS操作在ARM和x86下的不同實現
  • cmpxchg是X86比較交換指令,這個指令在各大底層系統實現的原子操作和各種同步原語中都有廣泛的使用,比如linux內核,JVM,GCC編譯器等,cmpxchg就是比較交換指令。
  • intel P6以及最新系列處理器保證了以下操作是原子的:1.讀寫一個字節。2.讀寫16位對齊的字。3.讀寫32位對齊的雙字。4.讀寫64位對齊的四字。5.讀寫16位,32位,64位在cache line內的未對齊的字。所以普通的load store指令都是原子的。cache一致性協議保證了不可能有兩個cpu同時寫一個內存。對於cmpxchg這種比較交換指令肯定不是原子的,intel是CISC複雜指令集架構,在內部流水線執行的時候,肯定會將cmpxchg指令翻譯成幾條微碼執行(對比ARM精簡指令集)。所以英特爾對於一些指令提供了LOCK前綴來保證這個指令的原子性。Intel 64和IA-32處理器提供LOCK#信號,該信號在某些關鍵存儲器操作期間自動置位,以鎖定系統總線或等效鏈路。當該輸出信號被斷言時,來自其他處理器或總線代理的用於控制總線的請求被阻止。
  • 爲了更清楚理解cmxchg,需要同時看ARM和x86兩種架構下的實現一個RISC,一個CISC,linux內核提供了兩種架構下的實現。linux內核的原子變量定義如下:

//原子變量
typedef struct {
    volatile int counter; //volatile禁止編譯器把變量緩衝到寄存器
} atomic_t;
  • 先看ARM架構下,ARM架構是精簡指令集,沒有提供cmpxchg這種複雜指令,和其它所有RISC架構一樣提供了LL/SC(鏈接加載,條件存儲)操作,這個操作是很多原子操作的基礎。ARMv8指令是LDXR\STXR,屬於獨佔訪問,需要有local monitor和global monitor配合使用。這兩條指令一般需要成對出現。ldrex是從內存取出數據放到寄存器,然後監視器將此地址標記爲獨佔,strex會先測試是否是當前cpu的獨佔,如果是則存儲成功返回0,如果不是則存儲失敗返回1。例如cpu0將地址m標記爲獨佔,在strex執行前,線程被調出了,cpu1調用ldrex會清除cpu0的獨佔,而將自己標記爲獨佔,然後執行strxr,然後cpu0的線程重新被調度,此時執行strex會失敗,因爲自己的獨佔位被清除了。這樣也會導致後進入ldrex的線程可能比先進入的先執行。標記爲獨佔的地址調用strex後都會清除獨佔標誌。
/**
 *  比較ptr->counter和old的值如果相等,則ptr->counter = new,並且返回old,否則ptr->counter不變
 * 返回ptr->counter
 */
static inline int atomic_cmpxchg(atomic_t *ptr, int old, int new)
{
    unsigned long oldval, res;
 
    smp_mb(); //內存屏障,保證cmpxchg不會在屏障前執行
 
    do {
        __asm__ __volatile__("@ atomic_cmpxchg\n"
        "ldrex  %1, [%2]\n" //獨佔訪問,監視器會將此地址標誌獨佔並且將ptr->counter給oldvalue
        "mov    %0, #0\n"   //res = 0
        "teq    %1, %3\n"   //測試oldvalue是否和old相等也就是ptr->counter和old
 
        //獨佔訪問成功並且如果相等則把new賦值給ptr->counter,否則不執行這條指令
        "strexeq %0, %4, [%2]\n" 
            : "=&r" (res), "=&r" (oldval)
            : "r" (&ptr->counter), "Ir" (old), "r" (new)
            : "cc");
    } while (res);  //while res是因爲strexeq指令是獨佔訪存指令從,此時可能未標記訪存,而res爲1
 
    smp_mb();//內存屏障,保證cmpxchg不會在屏障後執行
 
    return oldval;
}
  • X86架構類似:

/*
 *  根據size大小比較交換字節,字或者雙字,如果返回old則交換成功,否則交換失敗
 */
static inline unsigned long __cmpxchg(volatile void *ptr, unsigned long old,
                      unsigned long new, int size)
{
    unsigned long prev;
    switch (size) {
    case 1:
        __asm__ __volatile__(LOCK_PREFIX "cmpxchgb %b1,%2"
                     : "=a"(prev)
                     : "q"(new), "m"(*__xg(ptr)), "0"(old)
                     : "memory");
        return prev;
    case 2:
        __asm__ __volatile__(LOCK_PREFIX "cmpxchgw %w1,%2"
                     : "=a"(prev)
                     : "r"(new), "m"(*__xg(ptr)), "0"(old)
                     : "memory");
        return prev;
//eax = old,比較%2 = ptr->counter和eax是否相等,如果相等則ZF置位,並把%1 = new賦值給ptr->counter,返回old值,否則ZF清除,並且將ptr->counter賦值給eax
    case 4:
        __asm__ __volatile__(LOCK_PREFIX "cmpxchgl %1,%2"
                     : "=a"(prev)
                     : "r"(new), "m"(*__xg(ptr)), "0"(old)  //0表示eax = old
                     : "memory");
        return prev;
    }
    return old;
}
  • 代碼解釋:
  • 以最爲常用的4字節交換爲例,主要的操作就是彙編指令cmpxchgl %1,%2,注意一下其中的%2,也就是後面的"m"(*__xg(ptr))__xg是在這個文件中定義的宏:
struct __xchg_dummy { unsigned long a[100]; };

#define __xg(x) ((struct __xchg_dummy *)(x))
  • 那麼%2經過預處理,展開就是"m"(*((struct __xchg_dummy *)(ptr))),這種做法,就可以達到在cmpxchg中的%2是一個地址,就是ptr指向的地址。如果%2是"m"(ptr),那麼指針本身的值就出現在cmpxchg指令中。
  • 簡單點就是:
cmpxchg %ecx, %ebx;如果EAX與EBX相等,則ECX送EBX且ZF置1;否則EBX送ECX,且ZF清0
  • 在cmpxchg指令前加了lock前綴,保證在進行操作的時候,不會讓其它cpu操作同一個內存。使得整個操作保持原子性。對比來看雖然X86只用了一條指令,但是處理器內部肯定將這條指令轉成了類RISC的微碼。
  • 性能

基於AtomicLong可能性能有點差,所以出現了LongAdder。接下來通過基準測試,來看看兩者的性能差別。

  • 基準測試
  • 內存:4G,CPU:Intel(R) Core(TM) i5-4250U CPU @ 1.30GHz,硬盤: 128G SSD
  • Mode設爲Throughput,測試吞吐量。
  • Mode設爲AverageTime,測試平均耗時。
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@BenchmarkMode(Mode.Throughput)
public class Main {
    private static AtomicLong count = new AtomicLong();

    private static LongAdder longAdder = new LongAdder();

    public static void main(String[] args) throws RunnerException {
        Options options = (Options) new OptionsBuilder().include(Main.class.getName());
        new Runner(options).run();
    }

    @Benchmark
    @Threads(10)
    public void run0() {
        count.getAndIncrement();
    }

    @Benchmark
    @Threads(10)
    public void run1() {
        longAdder.increment();
    }
}
  • 吞吐量:

  • 線程爲1,結果:
Benchmark   Mode  Cnt    Score   Error   Units
Main.run0  thrpt   25  125.268 ± 2.471  ops/us
Main.run1  thrpt   25   74.587 ± 1.484  ops/us
  • 線程爲10,結果:
Benchmark   Mode  Cnt    Score   Error   Units
Main.run0  thrpt   25   24.897 ± 2.888  ops/us
Main.run1  thrpt   25  192.474 ± 2.824  ops/us
  • 線程爲30,結果:
Benchmark   Mode  Cnt    Score   Error   Units
Main.run0  thrpt   25   21.096 ± 1.897  ops/us
Main.run1  thrpt   25  189.732 ± 4.089  ops/us
  • 平均耗時:
  • 線程爲1,結果:
Main.run0  avgt   25  0.008 ±  0.001  us/op
Main.run1  avgt   25  0.013 ±  0.001  us/op
  • 線程爲10,結果:
Benchmark  Mode  Cnt  Score   Error  Units
Main.run0  avgt   25  0.485 ± 0.048  us/op
Main.run1  avgt   25  0.050 ± 0.001  us/op
  • 線程爲30,結果:
Benchmark  Mode  Cnt  Score   Error  Units
Main.run0  avgt   25  1.364 ± 0.033  us/op
Main.run1  avgt   25  0.158 ± 0.003  us/op
  • 可以看到除了線程爲1的情況下,其他情況下,LogAdder明顯比AtomicLong要好的多。
  • ABA問題
  • 設想如下場景:
    • 線程1準備用CAS將變量的值由A替換爲B。
    • 在此之前,線程2將變量的值由A替換爲C,又由C替換爲A,
    • 然後線程1執行CAS時發現變量的值仍然爲A,所以CAS成功。
  • 但實際上這時的現場已經和最初不同了,儘管CAS成功,但可能存在潛藏的問題。
  • 比如:
    • 有一個用單向鏈表實現的棧,棧頂爲A。
    • 線程T1獲取A.next爲B,然後希望用CAS將棧頂替換爲B,head.compareAndSet(A,B)。
    • 在T1執行上面這條指令之前,線程T2介入,將A、B出棧,再pushD、C、A。此時B.next爲null。
    • 此時輪到線程T1執行CAS操作,檢測發現棧頂仍爲A,所以CAS成功,棧頂變爲B。
    • 但實際上B.next爲null,其中堆棧中只有B一個元素,C和D組成的鏈表不再存在於堆棧中,這樣就造成C、D被拋棄的現象。
  • 解決方法
  • 各種樂觀鎖的實現中通常都會用版本戳version來對記錄或對象標記,避免併發操作帶來的問題,在Java中,AtomicStampedReference<E>也實現了這個作用,它通過包裝[E,Integer]的元組來對對象標記版本戳stamp,從而避免ABA問題。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章