1. 鎖的劣勢
前文中曾經對比同步方法的內置鎖相比和顯式鎖,來說明它們各自的優勢,但是無論是內置說還是顯式鎖,其本質都是通過加鎖來維護多線程安全。
由于加鎖機制,線程在申請鎖和等待鎖的過程中,必然會造成線程的掛起和恢復,這樣的線程上線文間切換會帶來很大的資源開銷,尤其是在鎖資源競爭激烈的情況下。
同時,線程在等待鎖的過程中,因為阻塞而什么也做,無限條件的等待不僅性能效率不佳,同時也容易造成死鎖。
2. 悲觀鎖和樂觀鎖
無論是內置鎖還是顯式鎖,都是一種獨占鎖,也是悲觀鎖。所謂悲觀鎖,就是以悲觀的角度出發,認為如果不上鎖,一定會有其他線程修改數據,破壞一致性,影響多線程安全,所以必須通過加鎖讓線程獨占資源。
與悲觀鎖相對,還有更高效的方法——樂觀鎖,這種鎖需要借助沖突檢查機制來判斷在更新的過程中是否存在來氣其他線程的干擾,如果沒有干擾,則操作成功,如果存在干擾則操作失敗,并且可以重試或采取其他策略。換而言之,樂觀鎖需要原子性“讀-改-寫”指令的支持,來讀取數據是否被其他線程修改,改寫數據內容并將最新的數據寫回到原有地址。現在大部分處理器以及可以支持這樣的操作。
3. 比較并交換操作CAS
大部分處理器框架是通過實現比較并交換(Compare and Swap,CAS)指令來實現樂觀鎖。CAS指令包含三個操作數:需要讀寫的內存位置V,進行比較的值A和擬寫入新值B。當且僅當V處的值等于A時,才說明V處的值沒有被修改過,指令才會使用原子方式更新其為B值,否者將不會執行任何操作。無論操作是否執行, CAS都會返回V處原有的值。下面的代碼模仿了CAS的語義。如果你在學習Java的過程中或者在工作中遇到什么問題都可以來群里提問,阿里Java高級大牛直播講解知識點,分享知識,多年工作經驗的梳理和總結,帶著大家全面、科學地建立自己的技術體系和技術認知!可以加群找我要課堂鏈接 注意:是免費的 沒有開發經驗誤入哦! 非喜勿入! 學習交流QQ群:478052716
public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
// CAS = compare and swap
public synchronized int compareAndSwap(int expectedValue,
int newValue) {
int oldValue = value;
if (oldValue == expectedValue)
value = newValue;
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue,
int newValue) {
return (expectedValue
== compareAndSwap(expectedValue, newValue));
}
}
當多個線程嘗試更新同一個值時,只會有一個線程成功,其他線程都會失敗,但是在CAS中,失敗的線程不會被擁塞,可以自主定義失敗后該如何處理,是重試還是取消操作,更具有靈活性。
通常CAS的使用方法為:先從V中讀取A值,并根據A值計算B值,然后再通過CAS以原子的方法各部分更新V中的值。以計數器為例
public class CasCounter {
private SimulatedCAS value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
// 獲得當前的值
v = value.get();
} while (v != value.compareAndSwap(v, v + 1));
// 如果返回值不同,則說明更新成功了
return v + 1;
}
}
以不加鎖的方式實現了原子的“讀-改-寫”操作。
CAS的方法在性能上有很大優勢:在競爭程度不是很大的情況下,基于CAS的操作,在性能上遠遠超過基于鎖的計數器;在沒有競爭的情況下,CAS的性能更高。
但是CAS的缺點是:將競爭的問題交給調用者來處理,但是悲觀鎖自身就能處理競爭。
4. 原子變量
隨著硬件上對于原子操作指令的支持,Java中也引入CAS。對于int、long和對象的引用,Java都支持CAS操作,也就是原子變量類,JVM會把對于原子變量類的操作編譯為底層硬件提供的最有效的方法:如果硬件支持CAS,則編譯為CAS指令,如果不支持,則編譯為上鎖的操作。
原子變量比鎖的粒度更細, 更為輕量級,將競爭控制在單個變量之上。因為其不需要上鎖,所以不會引發線程的掛起和恢復,因此避免了線程間上下文的切換,性能更好,不易出現延遲和死鎖的現象。
常見的原子變量有AtomicInteger、AtomicLong、AtomicBoolean和AtomicReference,這些類都支持原子操作,使用get和set方法來獲取和更新對象。原子變量數組只支持AtomicInteger、AtomicLong和AtomicReference類型,保證數組中每個元素都是可以以volatile語義被訪問。
需要注意的是原子變量沒有定義hashCode和equals方法,所以每個實例都是不同的,不適合作為散列容器的key。
原子變量可以被視為一種更好volatile變量,通過compareAndSet方法嘗試以CAS方式更新數據,下面以實現數字區間為示例代碼展示如何使用AtomicReference。
public class CasNumberRange {
@Immutable
private static class IntPair {
// INVARIANT: lower <= upper
final int lower;
final int upper;
public IntPair(int lower, int upper) {
this.lower = lower;
this.upper = upper;
}
}
//源自引用 IntPair 初始化為[0,0]
private final AtomicReference values =
new AtomicReference(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();
// 如果下限設置比當前上限還要大
if (i > oldv.upper)
//拋出異常
throw new IllegalArgumentException("Can't set lower to " + i + " > upper");
IntPair newv = new IntPair(i, oldv.upper);
//原子性更新
if (values.compareAndSet(oldv, newv))
//如果更新成功則直接返回,否者重新嘗試
return;
}
}
//設置上限 過程和setLower類似
public void setUpper(int i) {
while (true) {
IntPair oldv = values.get();
if (i < oldv.lower)
throw new IllegalArgumentException("Can't set upper to " + i + " < lower");
IntPair newv = new IntPair(oldv.lower, i);
if (values.compareAndSet(oldv, newv))
return;
}
}
}
性能對比:
前文已經提過,原子變量因其使用CAS的方法,在性能上有很大優勢:在競爭程度不是很大的情況下,基于CAS的操作,在性能上遠遠超過基于鎖的計數器;在沒有競爭的情況下,CAS的性能更高;但是在高競爭的情況下,加鎖的性能將會超過原子變量性能(類似于,交通略擁堵時,環島疏通效果好,但是當交通十分擁堵時,信號燈能夠實現更高的吞吐量)。
不過需要說明的是,在真實的使用環境下,資源競爭的強度絕大多數情況下不會大到可以讓鎖的性能超過原子變量。所以還是應該優先考慮使用原子變量。
鎖和原子變量在不同競爭程度上性能差異很好地說明了各自的優勢:在中低程度的競爭之下,原子變量能提供更高的可伸縮性,而在高強度的競爭下,鎖能夠有效地避免競爭。
當然,如果能避免在多線程間使用共享狀態,轉而使用線程封閉(如ThreadLocal),代碼的性能將會更進一步地提高。
5. 非阻塞算法
如果某種算法中,一個線程的失敗或者掛起不會導致其他線程也失敗和掛起,這該種算法是非阻塞的算法。如果在算法的每一步中都存在某個線程能夠執行下去,那么該算法是無鎖(Lock-free)的算法。
如果在算法中僅僅使用CAS用于協調線程間的操作,并且能夠正確的實現,那么該算法既是一種無阻塞算法,也是一種無鎖算法。在非擁塞算法中,不會出現死鎖的優先級反轉的問題(但是不排除活鎖和資源饑餓的問題,因為算法中會反復嘗試)。
上文中的CasNumberRange就是一種非阻塞算法,其很好的說明了非擁塞算法設計的基本模式:在更新某個值時存在不確定性,如果失敗就重新嘗試。其中關鍵點在于將執行CAS的范圍縮小在單一變量上。
5.1 非阻塞的棧
我們以非阻塞的棧為例說明非擁塞算法的設計思路。創建非阻塞算法的關鍵在于將原子修改的范圍縮小到單個變量上,同時保證數據一致性。
棧是最簡單的鏈式數據結構:每個元素僅僅指向一個元素,每個元素也僅被一個元素引用,關鍵的操作入棧(push)和出棧(pop)都是針對于棧頂元素(top)的。因此每次操作只需要保證棧頂元素的一致性,將原子操作的范圍控制在指向棧頂元素的引用即可。實例代碼如下:
//非阻塞的并發棧
public class ConcurrentStack {
//原子對象 棧頂元素
AtomicReference> top = new AtomicReference>();
public void push(E item) {
Node newHead = new Node(item);
Node oldHead;
do { //循環嘗試
oldHead = top.get();//獲得舊值
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead)); //比較舊值是否被修改,如果沒有則操作成功,否者繼續嘗試;
}
public E pop() {
Node oldHead;
Node newHead;
do {
oldHead = top.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node {
public final E item;
public Node next;
public Node(E item) {
this.item = item;
}
}
}
以上代碼充分體現了非阻塞算法的特點:某項操作的完成具有不確定性,如不成功必須重新執行。這個棧通過compareAndSet來修改棧頂元素,該方法為原子操作,如果發現被其他線程干擾,則修改操作失敗,方法將重新嘗試。
算法中的多線程安全性依賴于compareAndSet,其提供和加鎖機制一樣的安全性。既保證原子性,有保證了可見性。除此之外,AtomicReference對象上使用get方法,也保證了內存可見性, 和使用volatile變量一樣。
5.2 非阻塞的鏈表
鏈表的結構比棧更為復雜,其必須支持頭指針和尾指針,且同時有兩個指針指向尾部,分別是尾指針和最后一個元素next指針。如何保證兩個指針的數據一致性是一個難題,這不能通過一個CAS操作來完成。
這個難題可以應用這樣一個技巧來解決:當線程B發現線程A正在修改數據結構時,數據結構中應該有足夠多的信息使得線程B能幫助線程A完成操作,保證數據結構維持一致性。
我們以插入操作為例分析。在插入過程中有兩個步驟:
插入新節點,將原有尾節點的next域指向該節點;
將尾指針移動到新的尾節點處。
所以我們可以根據尾節點的next域判斷鏈表是否在穩定狀態:如尾節點的next域為null,則說明該鏈表是穩定狀態,沒有其他線程在執行插入操作;反之,節點的next域不為null,則說明有其他線程在插入數據。
如果鏈表不處于穩定狀態該怎么辦呢?可以讓后到的線程幫助正在插入的線程將尾部指針向后推移到新插入的節點處。示例代碼如下:
public class LinkedQueue {
private static class Node {
final E item;
//下一個節點
final AtomicReference> next;
public Node(E item, Node next) {
this.item = item;
this.next = new AtomicReference>(next);
}
}
//啞結點 也是頭結點
private final Node dummy = new Node(null, null);
private final AtomicReference> head
= new AtomicReference>(dummy);
//尾部節點
private final AtomicReference> tail
= new AtomicReference>(dummy);
public boolean put(E item) {
Node newNode = new Node(item, null);
while (true) {
Node curTail = tail.get();
Node tailNext = curTail.next.get();
//得到尾部節點
if (curTail == tail.get()) {
// 1. 尾部節點的后續節點不為空,則隊列處于不一致的狀態
if (tailNext != null) {
// 2. 將為尾部節點向后退進;
tail.compareAndSet(curTail, tailNext);
} else {
// 3. 尾部節點的后續節點為空,則隊列處于一致的狀態,嘗試更新
if (curTail.next.compareAndSet(null, newNode)) {
// 4. 更新成功,將為尾部節點向后退進;
tail.compareAndSet(curTail, newNode);
return true;
}
}
}
}
}
}
假如步驟一處發現鏈表處在非穩定狀態,則會以原子的方法嘗試將尾指針移動到新插入的節點,無論是否成功這時鏈表都會回到穩定狀態,tail.next=null,此時再去重新新嘗試。如果步驟二出已經將鏈表的尾指針移動,則步驟四處的原子操作就會失敗,不過這沒有關系,因為別的線程已經幫助其完成了該操作,鏈表保持穩定狀態。
5.3 原子域更新器
上面提到的非擁塞鏈表,在ConcurrentLinkedQueue就有所應用,但是ConcurrentLinkedQueue并不是使用原子變量,而是使用普通的volatile變量,通過基于反射的原子域更新器(AtomicReferenceFieldUpdater)來進行更新。
原子域更新器是現有volatile域的一種基于反射的“視圖”,能夠在volatile域上使用CAS指令。原子域更新器沒有構造器,要構建對象需要使用工廠方法newUpdater,函數然注釋如下
/**
* @param tclass 持有待更新域的類
* @param vclass 待更新域的類型
* @param fieldName 待更新域的名字
*/
public static AtomicReferenceFieldUpdater newUpdater(Class tclass,
Class vclass,
String fieldName);
使用更新器的好處在于避免構建原子變量的開銷,但是這只適用于那些頻繁分配且生命周期很短對象,比如列表的節點,其他情況下使用原子變量即可。
5.4 帶有版本號原子變量
CAS操作是通過比較值來判斷原值是否被修改,但是還有可能出現這樣的情況:原值為A被修改為B,然后又被修改為A,也就是A-B-A的修改情況。這時再通過比較原值就不能判斷是否被修改了。這個問題也被稱為ABA問題。
ABA問題的解決方案是為變量的值加上版本號,只要版本號變化,就說明原值被修改了,這就是帶有時間戳的原子變量AtomicStampedReference
//原值和時間戳
public AtomicStampedReference(V initialRef, int initialStamp);
總結
非擁塞算法通過底層CAS指令來維護多線程的安全性,CAS指令被封裝成原子變量的形式對外公開,是一種更好的volatile變量,可以提供更好伸縮性,防止死鎖,但是設計和實現較為復雜,對開發人員要求很高。