談談JUC----------CAS機制及AtomicInteger原始碼分析

擁抱心中的夢想發表於2020-01-17

一、CAS簡介

CAS即Compare And Swap對比交換,區別於悲觀鎖,藉助CAS可以實現區別於synchronized獨佔鎖的一種樂觀鎖,被廣泛應用在各大程式語言之中。Java JUC底層大量使用了CAS,可以說java.util.concurrent完全是建立在CAS之上的。但是CAS也有相應的缺點,諸如ABAcpu使用率高等問題無法避免。

CAS總共有3個運算元,當前記憶體值V,舊的預期值A,要修改的新值N。當且僅當A和V相同時,將V修改為N,否則什麼都不做。

二、CAS原始碼分析

我們都知道,java提供了一些列併發安全的原子操作類,如AtomicIntegerAtomicLong.下面我們拿AtomicInteger為例分析其原始碼實現。

// 1、獲取UnSafe例項物件,用於對記憶體進行相關操作
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 2、記憶體偏移量
private static final long valueOffset;

static {
    try {
        // 3、初始化地址偏移量
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

// 4、具體值,使用volatile保證可見性
private volatile int value;
複製程式碼

從上面程式碼中我們可以看出,AtomicInteger中依賴於一個叫Unsafe的例項物件,我們都知道,java語言遮蔽了像C++那樣直接操作記憶體的操作,程式設計師不需手動管理記憶體,但話說回來,java還是開放了一個叫Unsafe的類直接對記憶體進行操作,由其名字可以看出,使用Unsafe中的操作是不安全的,要小心謹慎。

valueOffset是物件的記憶體偏移地址,通過Unsafe物件進行初始化,有一點需要注意的是,對於給定的某個欄位都會有相同的偏移量,同一類中的兩個不同欄位永遠不會有相同的偏移量。也就是說,只要物件不死,這個偏移量就永遠不會變,可以想象,CAS所依賴的第一個引數(記憶體地址值)正是通過這個地址偏移量進行獲取的。

value屬於共享資源,藉助volatile保證記憶體可見性,關於volatile的簡單分析,可以參考

Java 反彙編、反編譯、volitale解讀

// 1、獲取並增加delta
public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}
// 2、加一
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
複製程式碼

上面兩個方法依賴下面Unsafe類中的getAndAddInt操作,藉助openjdk提供的Unsafe原始碼,我們看下其實現:

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    // 1、不斷的迴圈比較,直到CAS操作成功返回
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}
複製程式碼

從上面可以看出,本質上CAS使用了自旋鎖進行自旋,直到CAS操作成功,如果很長一段時間都沒有操作成功,那麼將一直自旋下去。

三、CAS的缺點

從第一、二節可以看出,CAS在java中的實現本質上是使用Unsafe類提供的方法獲取物件的記憶體地址偏移量,進而通過自旋實現的。CAS的優點很明顯,那就是區別於悲觀策略,樂觀策略在高併發下效能表現更好,當然CAS也是有缺點的,主要有類似ABA自旋時間過長只能保證一個共享變數原子操作三大問題,下面我們一一分析。

1、ABA

什麼是ABA呢?簡單的說,就是有兩個執行緒,執行緒A和執行緒B,對於同一個變數X=0,A準備將X置為10,按照CAS的步驟,首先會從記憶體讀取值舊的預期值0,然後比較,最後置為10,但就在A讀取完X=0後,還沒來得及比較和賦值,此時執行緒B完成了X=0 -> X=10 -> X=0這3個操作,隨後A繼續執行比較,發現此時記憶體的值依舊是0,最後CAS執行成功。雖然過程和結果沒有問題,但是A比較時的0已經不是最初那個0了,有種被偷樑換柱的感覺。

下面程式碼舉例演示ABA問題,執行緒1模擬將變數從100->110->100,執行緒2執行100->120,最後看下輸出:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * 
 * @Author jiawei huang
 * @Since 2020年1月17日
 * @Version 1.0
 */
public class ABATest {
    // 初始值為100
    private static AtomicInteger atomicInteger = new AtomicInteger(100);
    public static void main(String[] args) throws InterruptedException {
    
    	// AtomicInteger實現 100->110->100
    	Thread t1 = new Thread(new Runnable() {
    		@Override
    		public void run() {
    			atomicInteger.compareAndSet(100, 110);
    			atomicInteger.compareAndSet(110, 100);
    		}
    	});
    
    	// 實現 100->120
    	Thread t2 = new Thread(new Runnable() {
    		@Override
    		public void run() {
    			try {
    				// 這裡模擬執行緒1執行完畢,偷樑換柱成功
    				TimeUnit.SECONDS.sleep(2);
    			} catch (InterruptedException e) {
    				e.printStackTrace();
    			}
    			// 下面依舊返回true
    			System.out.println("AtomicInteger:" + atomicInteger.compareAndSet(100, 120));
    		}
    	});
    
    	t1.start();
    	t2.start();
	}
}
複製程式碼

輸出結果為:

AtomicInteger:true
複製程式碼

可見執行緒2中的CAS也執行成功了,那麼如何解決這個問題呢?解決方案是通過版本號,Java提供了AtomicStampedReference來解決。AtomicStampedReference通過包裝[E,Integer]的元組來對物件標記版本戳stamp,從而避免ABA問題。

/*
 * Copyright (C) 2011-2019 DL 
 * 
 * All right reserved.
 * 
 * This software is the confidential and proprietary information of DL of China.
 * ("Confidential Information"). You shall not disclose such Confidential 
 * Information and shall use it only in accordance with the argeements 
 * reached into with DL himself.
 *
 */
package com.algorithm.leetcode.linkedlist;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * 
 * @Author jiawei huang
 * @Since 2020年1月17日
 * @Version 1.0
 */
public class ABATest {
    // 初始值100,版本號1
    private static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<Integer>(100, 1);
    
    public static void main(String[] args) throws InterruptedException {
    // AtomicStampedReference實現
    Thread tsf1 = new Thread(new Runnable() {
    	@Override
    	public void run() {
    		try {
    			// 讓 tsf2先獲取stamp,導致預期時間戳不一致
    			TimeUnit.SECONDS.sleep(2);
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		// 預期引用:100,更新後的引用:110,預期標識getStamp() 更新後的標識getStamp() + 1
    		atomicStampedReference.compareAndSet(100, 110, atomicStampedReference.getStamp(),
    				atomicStampedReference.getStamp() + 1);
    		atomicStampedReference.compareAndSet(110, 100, atomicStampedReference.getStamp(),
    				atomicStampedReference.getStamp() + 1);
    	}
    });
    
    Thread tsf2 = new Thread(new Runnable() {
    	@Override
    	public void run() {
        	int stamp = atomicStampedReference.getStamp();
        
        	try {
        		TimeUnit.SECONDS.sleep(2); // 執行緒tsf1執行完
        	} catch (InterruptedException e) {
        		e.printStackTrace();
        	}
        	System.out.println(
        			"AtomicStampedReference:" + atomicStampedReference.compareAndSet(100, 120, stamp, stamp + 1));
    	}
    });
    
    tsf1.start();
    tsf2.start();
	}
}
複製程式碼

輸出結果:

AtomicStampedReference:false
複製程式碼

可以看出執行緒1執行失敗了。

2、自旋時間過長

通過第二節分析可以得知,CAS本質上是通過自旋來判斷是否更新的,那麼問題來了,如果多次舊預期值不等於記憶體值的情況,那麼這個自旋將會自旋下去,而自旋過久將會導致CPU利用率變高。

3、只能保證一個共享變數原子操作

從第二節可以看出,只是單純對單個共享物件進行CAS操作,保證了其更新獲取的原子性,無法對多個共享變數同時進行原子操作。這是CAS的侷限所在,但JDK提供同時了AtomicReference類來保證引用物件之間的原子性,可以把多個變數放在一個物件裡來進行CAS操作。

相關文章