Java併發編程實戰 原子變量與非阻塞同步機制總結

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

硬件對併發的支持
獨佔鎖是一項悲觀技術——它假設最壞的情況(如果你不鎖門 那麼搗蛋鬼就會闖入並搞得一團糟) 並且只有在確保其他線程不會造成干擾(通過獲取正確的鎖)的情況下才能執行下去
對於細粒度的操作 還有另外一種更高效的方法 也是一種樂觀的方法 通過這種方法可以在不發生干擾的情況下完成更新操作 這種方法需要藉助衝突檢查機制來判斷在更新過程中是否存在來自其他線程的干擾 如果存在 這個操作將失敗 並且可以重試(也可以不重試)

比較並交換
在大多數處理器架構(包括IA32和Sparc)中採用的方法是實現一個比較並交換(CAS)指令 (在其他處理器中 例如PowerPC 採用一對指令來實現相同的功能:關聯加載與條件存儲) CAS包含了3個操作數——需要讀寫的內存位置V 進行比較的值A和擬寫入的新值B 當且僅當V的值等於A時 CAS纔會通過原子方式用新值B來更新V的值 否則不會執行任何操作 無論位置V的值是否等於A 都將返回V原有的值 (這種變化形式被稱爲比較並設置 無論操作是否成功都會返回) CAS的含義是 我認爲V的值應該爲A 如果是 那麼將V的值更新爲B 否則不修改並告訴V的值實際爲多少 CAS是一項樂觀的技術 它希望能成功地執行更新操作 並且如果有另一個線程在最近一次檢查後更新了該變量 那麼CAS能檢測到這個錯誤

模擬CAS操作

@ThreadSafe
public class SimulatedCAS {
    @GuardedBy("this") 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的典型使用模式是:首先從V中讀取值A 並根據A計算新值B 然後再通過CAS以原子方式將V中的值由A變成B(只要在這期間沒有任何線程將V的值修改爲其他值) 由於CAS能檢測到來自其他線程的干擾 因此即使不使用鎖也能夠實現原子的讀-改-寫操作序列

非阻塞的計數器

基於CAS實現的非阻塞計數器

@ThreadSafe
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;
    }
}

CasCounter不會阻塞 但如果其他線程同時更新計數器 那麼會多次執行重試操作(在實際情況中 如果僅需要一個計數器或序列生成器 那麼可以直接使用AtomicInteger或AtomicLong 它們能提供原子的遞增方法以及其他算術方法)
初看起來 基於CAS的計數器似乎比基於鎖的計數器在性能上更差一些 因爲它需要執行更多的操作和更復雜的控制流 並且還依賴看似複雜的CAS操作 但實際上 當競爭程度不高時 基於CAS的計數器在性能上遠遠超過了基於鎖的計數器 而在沒有競爭時甚至更高
CAS的主要缺點是 它將使調用者處理競爭問題(通過重試 回退 放棄) 而在鎖中能自動處理競爭問題(線程在獲得鎖之前將一直阻塞)

JVM對CAS的支持
在Java5.0之前 如果不編寫明確的代碼 那麼就無法執行CAS 在Java5.0中引入了底層的支持 在int long和對象的引用等類型上都公開了CAS操作 並且JVM把它們編譯爲底層硬件提供的最有效方法 在支持CAS的平臺上 運行時把它們編譯爲相應的(多條)機器指令 在最壞的情況下 如果不支持CAS指令 那麼JVM將使用自旋鎖 在原子變量類(例如java.util.concurrent.atomic中的AtomicXxx)中使用了這些底層的JVM支持爲數字類型和引用類型提供一種高效的CAS操作 而在java.util.concurrent中的大多數類在實現時則直接或間接地使用了這些原子變量類

原子變量類
原子變量比鎖的粒度更細 量級更輕 並且對於在多處理器系統上實現高性能的併發代碼來說是非常關鍵的 原子變量將發生競爭的範圍縮小到單個變量上 這是你獲得的粒度最細的情況(假設算法能夠基於這種細粒度來實現) 更新原子變量的快速(非競爭)路徑不會比獲取鎖的快速路徑慢 並且通常會更快 而它的慢速路徑肯定比鎖的慢速路徑快 因爲它不需要掛起或重新調度線程 在使用基於原子變量而非鎖的算法中 線程在執行時更不易出現延遲 並且如果遇到競爭 也更容易恢復過來

原子變量是一種 更好的volatile

通過CAS來維持包含多個變量的不變性條件

@ThreadSafe
        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;
        }
    }

    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();
            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;
        }
    }

    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;
        }
    }
}

性能比較:鎖與原子變量

基於ReentrantLock實現的隨機數生成器

@ThreadSafe
public class ReentrantLockPseudoRandom extends PseudoRandom {
    private final Lock lock = new ReentrantLock(false);
    private int seed;

    ReentrantLockPseudoRandom(int seed) {
        this.seed = seed;
    }

    public int nextInt(int n) {
        lock.lock();
        try {
            int s = seed;
            seed = calculateNext(s);
            int remainder = s % n;
            return remainder > 0 ? remainder : remainder + n;
        } finally {
            lock.unlock();
        }
    }
}

基於AtomicInteger實現的隨機數生成器

@ThreadSafe
public class AtomicPseudoRandom extends PseudoRandom {
    private AtomicInteger seed;

    AtomicPseudoRandom(int seed) {
        this.seed = new AtomicInteger(seed);
    }

    public int nextInt(int n) {
        while (true) {
            int s = seed.get();
            int nextSeed = calculateNext(s);
            if (seed.compareAndSet(s, nextSeed)) {
                int remainder = s % n;
                return remainder > 0 ? remainder : remainder + n;
            }
        }
    }
}

在高度競爭的情況下 鎖的性能將超過原子變量的性能 但在更真實的情況下 原子變量的性能將超過鎖的性能 這是因爲鎖在發生競爭時會掛起線程 從而降低了CPU的使用率和共享內存總線上的同步通信量(這類似於在生產者-消費者設計中的可阻塞生產者 它能降低消費者上的工作負載 使消費者的處理速度趕上生產者的處理速度) 另一方面 如果使用原子變量 那麼發成調用的類負責對競爭進行管理 與大多數基於CAS的算法一樣 AtomicPseudoRandom在遇到競爭時將立即重試 這通常是一種正確的方法 但在激烈競爭環境下卻導致了更多的競爭

非阻塞算法
如果在某種算法中 一個線程的失敗或掛起不會導致其他線程也失敗或掛起 那麼這種算法就被稱爲非阻塞算法 如果在算法的每個步驟中都存在某個線程能夠執行下去 那麼這種算法也被稱爲無鎖(Lock-Free)算法 如果在算法中僅將CAS用於協調線程之間的操作 並且能正確地實現 那麼它既是一種無阻塞算法 又是一種無鎖算法 無競爭的CAS通常都能執行成功 並且如果有多個線程競爭同一個CAS 那麼總會有一個線程在競爭中勝出並執行下去 在非阻塞算法中通常不會出現死鎖和優先級反轉問題(但可能會出現飢餓和活鎖問題 因此在算法中會反覆地重試)

非阻塞的棧
在實現相同功能的前提下 非阻塞算法通常比基於鎖的算法更爲複雜 創建非阻塞算法的關鍵在於 找出如何將原子修改的範圍縮小到單個變量上 同時還要維護數據的一致性 在鏈式容器類(例如隊列)中 有時候無需將狀態轉換操作表示爲對節點鏈接的修改 也無需使用AtomicReference來表示每個必須採用原子操作來更新的鏈接
棧是最簡單的鏈式數據結構:每個元素僅指向一個元素 並且每個元素也只被一個元素引用

使用Treiber算法(Treiber 1986)構造的非阻塞棧

@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;
        } 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;
        }
    }
}

非阻塞的鏈表
CAS的基本使用模式:在更新某個值時存在不確定性 以及在更新失敗時重新嘗試 構建非阻塞算法的技巧在於:將執行原子修改的範圍縮小到單個變量上 這在計數器中很容易實現 在棧中也很簡單 但對於一些更復雜的數據結構 例如隊列 散列表或樹 則要複雜得多
鏈接隊列比棧更爲複雜 因爲它必須支持對頭節點和尾節點的快速訪問 因此 它需要單獨維護的頭指針和尾指針 有兩個指針指向位於尾部的節點:當前最後一個元素的next指針 以及尾節點 當成功地插入一個新元素時 這兩個指針都需要採用原子操作來更新 初看起來 這個操作無法通過原子變量來實現 在更新這兩個指針時需要不同的CAS操作 並且如果第一個CAS成功 但第二個CAS失敗 那麼隊列將處於不一致的狀態 而且 即使這兩個CAS都成功了 那麼在執行這兩個CAS之間 仍可能有另一個線程會訪問這個隊列 因此 在爲鏈接隊列構建非阻塞算法時 需要考慮到這兩種情況

Michael-Scott(Michael and Scott 1996)非阻塞算法中的插入算法

@ThreadSafe
public class LinkedQueue <E> {

    private static class Node <E> {
        final E item;
        final AtomicReference<LinkedQueue.Node<E>> next;

        public Node(E item, LinkedQueue.Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<LinkedQueue.Node<E>>(next);
        }
    }

    private final LinkedQueue.Node<E> dummy = new LinkedQueue.Node<E>(null, null);
    private final AtomicReference<LinkedQueue.Node<E>> head
            = new AtomicReference<LinkedQueue.Node<E>>(dummy);
    private final AtomicReference<LinkedQueue.Node<E>> tail
            = new AtomicReference<LinkedQueue.Node<E>>(dummy);

    public boolean put(E item) {
        LinkedQueue.Node<E> newNode = new LinkedQueue.Node<E>(item, null);
        while (true) {
            LinkedQueue.Node<E> curTail = tail.get();
            LinkedQueue.Node<E> tailNext = curTail.next.get();
            if (curTail == tail.get()) {
                if (tailNext != null) {
                    // Queue in intermediate state, advance tail
                    tail.compareAndSet(curTail, tailNext);
                } else {
                    // In quiescent state, try inserting new node
                    if (curTail.next.compareAndSet(null, newNode)) {
                        // Insertion succeeded, try advancing tail
                        tail.compareAndSet(curTail, newNode);
                        return true;
                    }
                }
            }
        }
    }
}

原子的域更新器

在ConcurrentLinkedQueue中使用原子的域更新器

private class Node<E> {
	private final E item;
	private volatile Node<E> next;

	public Node(E item) {
		this.item = item;
}
}

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

原子的域更新器類表示現有volatile域的一種基於反射的 視圖 從而能夠在已有的volatile域上使用CAS 在更新器類中沒有構造函數 要創建一個更新器對象 可以調用newUpdate工廠方法 並制定類和域的名字 域更新器類沒有與某個特定的實例關聯在一起 因而可以更新目標類的任意實例中的域 更新器類提供的原子性保證比普通原子類更弱一些 因爲無法保證底層的域不被直接修改——compareAndSet以及其他算術方法只能確保其他使用原子域更新器方法的線程的原子性

ABA問題
ABA問題是一種異常現象:如果在算法中的節點可以被循環使用 那麼在使用 比較並交換 指令時就可能出現這種問題(主要在沒有垃圾回收機制的環境中) 在CAS操作中將判斷 V的值是否仍然爲A 並且如果是的話就繼續執行更新操作 在大多數情況下 這種判斷是完全足夠的 然而 有時候還需要知道 自從上次看到V的值爲A以來 這個值是否發生了變化 在某些算法中 如果V的值首先由A變成B 再由B變成A 那麼仍然被認爲是發生了變化 並需要重新執行算法中的某些步驟
如果在算法中採用自己的方式來管理節點對象的內存 那麼可能出現ABA問題 在這種情況下 即使鏈表的頭節點仍然指向之前觀察到的節點 那麼也不足以說明鏈表的內容沒有發生改變 如果通過垃圾回收器來管理鏈表節點仍然無法避免ABA問題 那麼還有一個相對簡單的解決方案:不是更新某個引用的值 而是更新兩個值 包括一個引用和一個版本號 即使這個值由A變爲B 然後又變爲A 版本號也將是不同的 AtomicStampedReference(以及AtomicMarkableReference)支持在兩個變量上執行原子的條件更新 AtomicStampedReference將更新一個 對象-引用 二元組 通過在引用上加上 版本號 從而避免ABA問題 類似地 AtomicMarkableReference將更新一個 對象引用-布爾值 二元組 在某些算法中將通過這種二元組使節點保存在鏈表中同時又將其標記爲 已刪除的節點

小結
非阻塞算法通過底層的併發原語(例如比較並交換而不是鎖)來維護線程的安全性 這些底層的原語通過原子變量類向外公開 這些類也用做一種 更好的volatile變量 從而爲整數和對象引用提供原子的更新操作
非阻塞算法在設計和實現時非常困難 但通常能夠提供更高的可伸縮性 並能更好地防止活躍性故障的發生 在JVM從一個版本升級到下一個版本的過程中 併發性能的主要提升都來自於(在JVM內部以及平臺類庫中)對非阻塞算法的使用

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