《Java併發編程實踐》五(3):原子變量和非阻塞同步

java併發庫(java.util.concurrent)提供了很多(相比鎖)性能更優越的同步設施,比如ConcurrentLinkedQueue。本章的主題,是研究此類併發裝置的性能祕密:原子變量和非阻塞同步。

鎖的性能劣勢

對現代JVM來說,在鎖未發生競爭的情況下,JVM執行“加鎖、釋放鎖”操作是非常快的;但是一旦發生競爭,就需要執行系統調用來掛起競爭失敗的線程,等將來鎖釋放時再喚醒它們。

掛起和喚醒線程的性能損耗是不能忽視的,對那些需要加鎖的高頻操作(比如集合讀寫)來說,鎖競爭導致的線程調度消耗的CPU,和業務邏輯操作消耗的CPU之間的比例,有可能會非常之高。換句話說,鎖競爭導致CPU一直在執行線程的調入&調出,花在執行業務代碼上的時間反而較少。

Volatile變量是一種輕量級的同步機制,不會阻塞線程;但Volatile不能保證變量操作的原子性,所以使用場景非常有限。

樂觀鎖

排他鎖是一種“悲觀“鎖,它總是假定“壞”的事情(競爭)會發生,在未獲得鎖之前,不能執行任何操作。對於細粒度的操作,還有一種偏“樂觀”的方式,它假定不會發生競爭,先嚐試執行操作再說;但是一旦發生衝突,需要能檢測到衝突。

爲了實現這種“樂觀”的同步機制,現代CPU都提供一種原子性的“read-modify-write”指令,最典型的是CAS(Compare and Swap)指令。JVM很早就使用了CAS指令,但一直到Java 5, java代碼才能使用它。

CAS指令

CAS指令有三個參數:內存地址、期望的原值、新值,它僅當內存地址存儲的值等於“期望的原值”,纔將它修改爲“新值”,無論成功或失敗,該指令返回“指令執行之前,內存地址存儲的值”。

CAS的語義,可以用下面的代碼來模擬:

@ThreadSafe
public class SimulatedCAS {

	@GuardedBy("this") private int value;
	
	public synchronized int get() { return value; }
	
	//compareAndSwap模擬CAS指令
	public synchronized int compareAndSwap(int expectedValue,int newValue) {
		int oldValue = value;
		if (oldValue == expectedValue)
			value = newValue;
		return oldValue;
	}
	
	//線程用cas執行同步操作,在失敗時能得到通知,不會阻塞
	public synchronized boolean compareAndSet(int expectedValue,int newValue) {
		return (expectedValue == compareAndSwap(expectedValue, newValue));
	}
}

原子變量

原子變量是細粒度、輕量級的同步裝置,是CAS指令最直接的應用。可以認爲原子變量是更好的“Volatile”變量,它保留了Volatile變量的內存可見性保證,同時支持原子的“read-modify-write”操作。

Java的原子變量類型可分爲四組:

  • 標量:AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference,LongAdder,LongAccumulator;
  • field upaters:原子性地更新一個對象字段的工具,比如AtomicIntegerFieldUpdater;
  • 數組:支持對數組元素執行原子操作,比如AtomicIntegerArray;
  • 組合變量:AtomicStampedReference和AtomicMarkableReference,特殊用途。

Atomic變量最常用的操作就是compareAndSet,它內部調用了平臺的CAS操作。Atomic類型都是對java基本類型的包裹,因爲它只能爲非常細粒度的操作提供原子性;它無法爲多個狀態字段的操作提供原子性保證。AtomicStampedReference這種所謂的組合原子變量,是通過內嵌的不可變對象來組合多個字段,本質上就是個AtomicReference。

示例:CasNumberRange

現在用Atomic變量來實現一個線程安全的range結構:

public class CasNumberRange {
	@Immutable
	private static class IntPair {
		final int lower; // Invariant: lower <= upper
		final int upper;
		...
	}
	private final AtomicReference<IntPair> values = new AtomicReference<IntPair>(new IntPair(0, 0));
	
	public int getLower() { return values.get().lower; }
	
	public int getUpper() { return values.get().upper; }
	
	public void setLower(int i) {
		while (true) {
			IntPair oldv = values.get();
			IntPair newv = new IntPair(i, oldv.upper);
			if (values.compareAndSet(oldv, newv))
				return;
		}
	}
}

如果把lower和upper字段替換成Atomic變量是無法湊效的,因爲它們並不互相獨立。所以將它們封裝成一個不可變對象IntPair,修改時替換成新的IntPair實例。

AtomicStampedReference和AtomicMarkableReference的實現也是這個套路。

Atomic Field Updater

Atomic field updater可以用原子操作來修改對象的某個字段,比如ConcurrentLinkedQueue的內部Node結構類似如下:

private class Node<E> {
	private final E item;
	private volatile Node<E> next;
	public Node(E item) {
		this.item = item;
	}
}

Node.next的更新需要原子性,但Node.next並不是Atomic變量,而是一個普通的volatile變量,ConcurrentLinkedQueue通過AtomicReferenceFieldUpdater來更新它:

private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater = AtomicReferenceFieldUpdater.newUpdater(Node.class, Node.class, "next");

Atomic Field Updater提供了對象字段的一個atomic操作工具,但是程序是有可能繞過updater來修改字段的,所以updater的原子性保證並不完整;因而,updater只能用來更新被完全封裝的內部對象字段。

使用updater的動機有兩個:

  • 避免Atomic破壞對象的序列化;
  • 減少一層對象,獲得微弱的性能優勢(如果next字段的類型是AtomicReference,相當於多了一層對象訪問)。

所以一般的情況下,我們是沒有必要使用updater的。

ABA問題

Atomic通過對比內存地址的期望值實際值來判斷是否存在衝突。存在一種極限情況:線程在獲取舊值和置換新值兩個操作之間,有另外一個線程將舊值修改了兩次(先改成另外一個,又改回來),這就是所謂的ABA問題。在絕大多數情況下,ABA問題對業務沒有任何影響,我們可以忽略它。CAS操作本身無法避開ABA問題,Atomic提供了兩種類型來提供解決方案:AtomicStampedReference和AtomicMarkableReference,

AtomicStampedReference給reference加上一個int標記,每次修改,這個標記加1;AtomicMarkableReference則維護一個bool標記。

Atomic VS Lock

CAS指令消耗的CPU週期在10~150之間;而且在市場競爭的驅動下,處理器的CAS指令會越來越快。可以定性認爲,在都沒有發生競爭的情況下,Atomic的操作的速度大概是Lock加鎖解鎖的兩倍;而發生競爭的情況下,Lock會導致線程掛起,Atomic不會阻塞線程,二者不具備可比性,除非Atomic變量操作陷入了“活鎖“,否則性能遠優於Lock。

非阻塞同步算法&數據結構

基於Atomic變量或CAS操作,可以設計出非阻塞且線程安全的算法或數據結構,由於沒有阻塞,此類算法不會有死鎖風險。另一方面,由於它不會鎖住數據狀態然後再操作,而是任由多個線程併發地操作狀態,因此算法複雜度要大很多。

示例1:ConcurrentStack

現在我們使用Atomic來實現一個併發安全的Stack數據結構。

@ThreadSafe
public class ConcurrentStack <E> {

	AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
	
	public void push(E item) {
		Node<E> newHead = new Node<E>(item);
		Node<E> oldHead;
		do {
			oldHead = top.get();
			newHead.next = oldHead;
			//如果有其他線程併發第push或pop,top會發生變化,compareAndSet操作會失敗
		} while (!top.compareAndSet(oldHead, newHead));
	}
	
	public E pop() {
		Node<E> oldHead;
		Node<E> newHead;
		do {
			oldHead = top.get();
			if (oldHead == null)
				return null;
			newHead = oldHead.next;
			//如果有其他線程併發第push或pop,top會發生變化,compareAndSet操作會失敗
		} while (!top.compareAndSet(oldHead, newHead));
		
		return oldHead.item;
	}
	
	private static class Node <E> {
		public final E item;
		public Node<E> next;
		public Node(E item) {
			this.item = item;
		}
	}
}

ConcurrentStack將元素放入Node,將Node組織爲一個單鏈表,只需要持有棧頂節點——top,就可以了。

ConcurrentStack.push操作步驟:

  1. 創建出新的棧頂節點newHead;
  2. 獲取當前棧頂的元素(oldHead),讓newHead.next=oldHead,維護了單向鏈表;
  3. 通過cas操作將top設置爲newHead
    • 如果沒有併發競爭,那麼cas操作成功
    • 如果發生競爭,那麼oldHead會過期,那麼cas操作會失敗,返回步驟2重試

ConcurrentStack.push的正確性源自:Atomic提供了和volatile一樣的內存可見性,且compareAndSet能檢測到併發衝突。ConcurrentStack.pop的操作步驟是類似的,不再贅述。

ConcurrentStack是一個極簡的併發安全數據結構,但是足以展示出此類算法的精髓,ConcurrentLinkedQueue、ConcurrentHashMap的實現原理是類似的,只不過更復雜罷了。

總結

基於硬件處理器的CAS指令,Atomic變量提供了細粒度的非阻塞的原子操作,基於Atomic,我們又可以構建很多非阻塞的併發安全數據結構(見java.util.concurrent)。相比鎖,Atomic變量和併發數據結構,具備優越的併發性能。但是我們也要意識到,非阻塞同步機制不是在任何情況下可以取代鎖,非阻塞同步只能保證細粒度的原子操作,它無法爲涉及多個狀態字段的操作提供原子性保證。一個併發數據結構是無法被鎖定的,線程永遠無法準確定判定一個併發集合是否empty,它的size是多少。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章