第十五章——原子變量與非阻塞同步機制

java.util.concurrent 包的許多類中,例如 SemaphoreConcurrentLinkedQueue,都提供了比 synchronized 機制更高的性能和可伸縮性。本章將介紹這種性能提升的主要來源:原子變量和非阻塞的同步機制。

15.1 鎖的劣勢

現代的許多 JVM 都對非競爭鎖獲取和釋放等操作進行了極大的優化,但如果有多個線程同時請求鎖,那么 JVM 就需要借助操作系統的功能。如果出現了這種情況,那么一些線程將被掛起并且在稍后恢復運行。當線程恢復執行時,必須等待其他線程執行完它們的時間片以后,才能被調度執行。在掛起和恢復線程等過程中存在著很大的開銷,并且通常存在著較長時間的中斷。如果在基于鎖的類中包含有細粒度的操作(在被鎖保護的代碼塊中只包含少量操作),那么當在鎖上存在著激烈的競爭時,調度開銷與工作開銷的比值會非常高。

與鎖相比,volatile 變量是一種更輕量級的同步機制,因為在使用這些變量時不會發生上下文切換或線程調度等操作。然而,volatile 變量同樣存在一些局限,比如不能支持 讀-改-寫 的原子操作。

鎖還存在其他一些缺點。當一個線程正在等待鎖時,它不能做任何其他事情。如果一個線程在持有鎖的情況下被延遲執行(例如發生了缺頁錯誤、調度延遲、或者其他類似情況),那么所有需要這個鎖的線程都無法執行下去。如果被阻塞的線程優先級比較高,而持有鎖的線程優先級比較低,那么這將是一個嚴重的問題——也被稱為 優先級反轉(Priority Inversion)

15.2 硬件對并發的支持

獨占鎖是一項悲觀鎖技術——它假設最壞的情況,并且只有在確保其他線程不會造成干擾的情況下才能執行下去。

對于細粒度的操作,還有另一個種更高效的方法,也是一種樂觀的方法,通過這種方法可以在不發生干擾的情況下完成更新操作。這種方法需要借助沖突檢查機制來判斷在更新過程中是否存在來自其他線程的干擾,如果存在,這個操作將失敗,并且可以重試(也可以不重試)。

15.2.1 比較并交換

在大多數處理器架構中采用的方法是實現一個比較并交換(CAS)指令。CAS 包含了 3 個操作數——需要讀寫的內存位置 V、進行比較的值 A 和 擬寫入的新值 B。當且僅當 V 的值等于 A 時,CAS 才會通過原子方式用新值 B 來更新 V 的值,否則不會執行任何操作。無論位置 V 的值是否等于 A,都將返回 V 原有的值。CAS 是一項樂觀的技術,它希望能成功地執行更新操作,并且如果有另一個線程在最近一次檢查后更新了該變量,那么 CAS 能檢測到這個錯誤。程序清單 15-1 說明了 CAS 語義。

// 程序清單 15-1
public class SimulatedCAS {
    private int value;
    
    public synchronized int get() {
        return value;
    } 
    
    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 同時更新同一個變量時,只有其中一個線程能更新變量的值,而其他線程都將失敗。然而,失敗的線程并不會被掛起(這與獲取鎖的情況不同:當獲取鎖失敗時,線程將被掛起),而是被告知在這次競爭中失敗,并可以再次嘗試。

15.2.2 非阻塞的計數器

程序清單 15-2 中的 CasCounter 使用 CAS 實現了一個線程安全的計數器:

// 程序清單 15-2
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;
    }
}

遞增操作采用了標準形式——讀取舊值,根據它計算出新值(加 1),并使用 CAS 來設置這個新值。如果 CAS 失敗,那么該操作將立即重試。CasCounter 不會阻塞,但如果其他線程同時更新計數器,那么會多次執行重試操作。

初看起來,基于 CAS 的計數器似乎比基于鎖的計數器在性能上更差一些,因為它需要執行更多的操作和更復雜的控制流,并且還依賴看似復雜的 CAS 操作。但實際上,當競爭程度不高時,基于 CAS 的計數器在性能上遠遠超過了基于鎖的計數器,而在沒有競爭時甚至更高。如果要快速獲取無競爭的鎖,那么至少需要一次 CAS 操作再加上與其他所相關的操作,因此基于鎖的計數器即使在最好的情況下也會比基于 CAS 的計數器在一般情況下能執行更多的操作。

一個很管用的經驗法則時:在大多數處理器上,在無競爭的鎖獲取和釋放的 “快速代碼路徑” 上的開銷,大約是 CAS 開銷的兩倍。

15.2.3 JVM 對 CAS 的支持

Java 5.0 之前,如果不便攜明確地代碼,那么就無法執行 CAS。在 Java 5.0 中引入了底層的支持,在 intlong 和對象的引用等類型上都公開了 CAS 操作。在原子變量類(例如 java.util.concurrent.atomic 中的 AtomicXxx)中使用了這些底層的 JVM 支持為數字類型和引用類型提供了一種高效的 CAS 操作,而在 java.util.concurrent 中的大多數類在實現時則直接或間接地使用了這些原子變量類。

15.3 原子變量類

共有 12 個原子變量類,可分為 4 組:標量類(Scalar)、更新器類、數組類以及復合變量類。最常用的原子變量類就是標量類:AtomicIntegerAtomicLongAtomicBoolean 以及 AtomicReference

15.3.1 原子變量是一種 “更好的 volatile

15.4 非阻塞算法

如果在某種算法中,一個線程的失敗或掛起不會導致其他線程也失敗或掛起,那么這種算法就被稱為非阻塞算法。如果在算法的每個步驟中都存在某個線程能夠執行下去,那么這種算法也被稱為無鎖(Lock-Free)算法。如果在算法中僅將 CAS 用于協調線程之間的操作,并且能正確地實現,那么它既是一種物阻塞算法,又是一種無鎖算法。到目前為止,我們已經看到了一個非阻塞算法:CasCounter。在許多常見的數據結構中都可以使用非阻塞算法,包括棧、隊列、優先隊列以及散列表等。

15.4.1 非阻塞的棧
// 程序清單 15-6
// 使用 Treiber 算法構造的非阻塞棧
public class ConcurrentStack<E> {
    AtomicReference<Node<E>> top = new AtomicReference<>();
    
    public void push(E item) {
        Node<E> newHead = new Node<>(item);
        Node<E> oldHead;
        do {
            oldHead = top.get();
            newHead.next = oldHead;
        } 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;
        } 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;
        }
    }
}

CasCounterConcurrentStack 中說明了非阻塞算法的所有特性:某項工作的完成具有不確定性,必須重新執行。

15.4.2 非阻塞的鏈表

到目前為止,我們已經看到了兩個非阻塞算法,計數器和棧,它們很好地說明了 CAS 的基本使用模式:在更新某個值時存在不確定性,以及在更新失敗時重新嘗試。

鏈接隊列比棧更為復雜,因為它必須支持對頭節點和尾節點的快速訪問。因此,它需要單獨維護頭指針和尾指針。有兩個指針指向位于尾部的節點:當前最后一個元素的 next 指針,以及尾節點。當成功地插入一個新元素時,這兩個指針都需要采用原子操作來更新。初看起來,這個操作無法通過原子變量來實現。在更新這兩個指針時需要不同的 CAS 操作,并且如果第一個 CAS 成功,但第二個 CAS 失敗,那么隊列將處于不一致的狀態。而且,即使這兩個 CAS 都成功了,那么在執行這兩個 CAS 之間,仍可能由另一個線程會訪問這個隊列。因此,在為鏈接隊列構建非阻塞算法時,需要考慮到這兩種情況。

我們需要使用一些技巧。第一個技巧是,即使在一個包含多個步驟的更新操作中,也要確保數據結構總是處于一致的狀態。這樣,當線程 B 到達時,如果發現線程 A 正在執行更新,那么線程 B 就可以知道有一個操作已部分完成,并且不能立即開始執行自己的更新操作。然后 B 可以等待(通過反復檢查隊列的狀態)并直到 A 完成更新,從而使兩個線程不會相互干擾。

// 程序清單 15-7
// Michael-Scott 非阻塞算法中的插入算法
public class LinkedQueue<E> {
    private static class Node <E> {
        final E item;
        final AtomicReference<Node<E>> next;
        
        public Node(E item, Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<>(next);
        }
    }
    
    private final Node<E> dummy = new Node<>(null, null);
    private final AtomicReference<Node<E>> head = new AtomicReference<>(dummy);
    private final AtomicReference<Node<E>> tail = new AtomicReference<>(dummy);
    
    public boolean put(E item) {
        Node<E> newNode = new Node<>(item, null);
        while (true) {
            Node<E> curTail = tail.get();
            Node<E> tailNext = curTail.next.get();
            if (curTail == tail.get()) {
                if (tailNext != null) {
                    // 隊列處于中間狀態,推進尾節點
                    tail.compareAndSet(curTail, tailNext);
                } else {
                    // 處于穩定狀態,嘗試插入新節點
                    if (curTail.next.compareAndSet(null, newNode)) {
                        // 插入操作成功,嘗試推進尾節點
                        tail.compareAndSet(curTail, newNode);
                        return true;
                    }
                }
            }
        }
    }
}

ConcurrentLinkedQueue 中使用的正是上面的算法。在許多隊列算法中,空隊列通常都包含一個 “哨兵(Sentinel)節點” 或者 “啞(Dummy)結點”,并且頭節點和尾節點在初始化時都指向該哨兵節點。

當插入一個新的元素時,需要更新兩個指針。首先更新當前最后一個元素的 next 指針,將新節點鏈接到列表隊尾(處于中間狀態),然后更新尾節點,將其指向這個新元素(最終的穩定狀態)。

實現這兩個技巧時的關鍵點在于:當隊列處于穩定狀態時,尾節點的 next 域將為空,如果隊列處于中間狀態,那么 tail.next 將為非空。因此,任何線程都能夠通過檢查 tail.next 來獲取隊列當前的狀態。而且,當隊列處于中間狀態時,可以通過將尾節點向前移動一個節點,從而結束其他線程正在執行的插入元素操作,并使得隊列恢復為穩定狀態。

15.4.3 原子的域更新器

15.4.4 ABA 問題

CAS 操作中將判斷 “V 的值是否仍然為 A?”,并且如果是的話就繼續執行更新操作。在大多數情況下,包括本章給出的示例,這種判斷是完全足夠的。然而,有時候還需要直到 “自從上次看到 V 的值為 A 以來,這個是是否發生了變化?”在某些算法中,如果 V 的值首先由 A 變成 B,再由 B 變成 A,那么仍然被認為是發生了變化,并需要重新執行算法中的某些步驟。

有一個相對簡單的解決方案:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號。即使這個值由 A 變為 B,然后又變為 A,版本號也將是不同的。AtomicStampedReference(以及 AtomicMarkableReference) 支持在兩個變量上執行原子的條件更新。AtomicStampedReference 將更新一個 “對象 - 引用” 二元組,通過在引用上加上 “版本號”,從而避免 ABA 問題。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容