1. 程式人生 > >java多執行緒11.非阻塞同步機制

java多執行緒11.非阻塞同步機制

關於非阻塞演算法CAS。 比較並交換CAS:CAS包含了3個運算元---需要讀寫的記憶體位置V,進行比較的值A和擬寫入的新值B。當且僅當V的值等於A時,CAS才會通過原子的方式用新值B來更新V的值,否則不會執行任何操作。無論位置V的值是否等於A,都將返回V原有的值。然後執行緒可以基於新返回的V值來做對應的操作,可以反覆嘗試。通常,反覆重試是一種合理的策略,但在一些競爭很激烈的情況下,更好的方式是在重試之前首先等待一段時間或者回退,從而避免造成活鎖問題。CAS的主要缺點就是,它將使呼叫者處理競爭問題,而在鎖中能自動處理競爭問題。雖然java語言的鎖定語句比較簡潔,但JVM和操作在管理鎖時需要完成的工作卻並不簡單。在實現鎖定時需要遍歷JVM中一條非常複雜的程式碼路徑,並可能導致作業系統級的鎖定、執行緒掛起以及上下文卻換等動作。在最好的情況下,在鎖定時至少需要一次CAS,因此雖然在使用鎖時沒有用到CAS,但實際上也無法節約任何執行開銷。另外,在程式內部執行CAS不需要執行JVM程式碼、系統呼叫或執行緒排程操作。在應用級上看起來越長的程式碼路徑,如果加上JVM和作業系統中的程式碼呼叫,那麼事實上卻變得更短。

在非阻塞演算法中不存在死鎖和其他活躍性問題。

而在基於鎖的演算法中,如果一個執行緒在休眠或自旋的同時持有一個鎖,那麼其他執行緒都無法執行下去,而非阻塞演算法不會受到單個執行緒失敗的影響。

鎖的劣勢

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

另外,當一個執行緒正在等待鎖時,它不能做任何其他事情。如果一個執行緒在持有鎖的情況下被延遲執行,那麼所有需要這個鎖的執行緒都無法執行下去。如果被阻塞執行緒的優先順序高,而持有鎖的執行緒優先順序低,那麼將是一個嚴重的問題。

比較並交換CAS

CAS包含了3個運算元---需要讀寫的記憶體位置V,進行比較的值A和擬寫入的新值B。當且僅當V的值等於A時,CAS才會通過原子的方式用新值B來更新V的值,否則不會執行任何操作。無論位置V的值是否等於A,都將返回V原有的值。

CAS的含義:我認為V的值應該為A,如果是,那麼將V的值更新為B,否則不修改並告訴V的值實際為多少。CAS是一種樂觀的態度,它希望能成功地執行更新操作,並且如果有另一個執行緒在最近一次檢查後更新了該變數,那麼CAS能檢測到這個錯誤。

/**
 * 當多個執行緒嘗試使用CAS同時更新同一個變數時,只有其中一個執行緒能更新變數的值,而其他執行緒都將失敗。
 * 然而,失敗的執行緒並不會被掛起,而是被告知在這次競爭中失敗,並可以再次嘗試。
 * 由於一個執行緒在競爭CAS時不會阻塞,因此它可以決定是否重新嘗試,或者執行一些恢復操作,也或者不執行任何操作。
 */
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的典型使用模式是:首先從V中讀取值A,並根據A計算新值B,然後再通過CAS以原子方式將V中的值由A變成B。由於CAS能檢測到來自其他執行緒的干擾,因此即使不使用鎖也能夠實現原子的讀--改--寫操作

非阻塞的計算器

/**
 * 通常,反覆重試是一種合理的策略,但在一些競爭很激烈的情況下,更好的方式是在重試之前首先等待一段時間或者回退,從而避免造成活鎖問題。
 *  
 * 雖然java語言的鎖定語句比較簡潔,但JVM和操作在管理鎖時需要完成的工作卻並不簡單。
 * 在實現鎖定時需要遍歷JVM中一條非常複雜的程式碼路徑,並可能導致作業系統級的鎖定、執行緒掛起以及上下文卻換等動作。
 * 在最好的情況下,在鎖定時至少需要一次CAS,因此雖然在使用鎖時沒有用到CAS,但實際上也無法節約任何執行開銷。
 * 另外,在程式內部執行CAS不需要執行JVM程式碼、系統呼叫或執行緒排程操作。
 * 在應用級上看起來越長的程式碼路徑,如果加上JVM和作業系統中的程式碼呼叫,那麼事實上卻變得更短。
 * CAS的主要缺點是,它要求呼叫者處理競爭問題,而在鎖中能自動處理競爭問題
 */
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;
    }
}

JVM對CAS的支援

Java5.0中引入了底層的支援,在int,long和物件引用等型別上都公開了CAS操作,並且JVM把它們編譯為底層硬體提供的最有效方法。在原子變數類中,使用了這些底層的JVM支援為數字型別和引用型別提供一種高效的CAS操作,而在java.util.concurrent中的大多數類在實現時都直接或間接地使用了這些原子變數類。

  • 示例:非阻塞的棧
/**
 * 棧是由Node元素構成的一個連結串列,根節點為棧頂yop,每個元素中都包含了一個值以及指向下一個元素的連結。
 * push方法建立一個新的節點,該節點的next域指向當前的棧頂,然後使用CAS把這個新節點放入棧頂。
 * 
 * @param <E>
 */
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> newHead;
        Node<E> oldHead;
        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;
        }
    }
}
  • 示例:非阻塞連結串列

連結串列佇列比棧複雜,它必須支援對頭節點和尾節點的快速訪問。它需要單獨維護頭指標和尾指標。

對於尾部的插入,有兩個點需要更新:將當前尾節點的next指向要插入的節點,和將尾節點更新為新插入的節點。這兩個更新操作需要不同的CAS操作,不好通過原子變數來實現

需要使用一些策略:

策略一是,即使在一個包含多個步驟的更新操作中,也要確保資料結構總是處於抑制的狀態。這樣,執行緒B到達時,如果發現A正在執行更新,那麼執行緒B就可以知道有一個操作已部分完成,並且不能立即執行自己的更新操作。然後B可以等待並直到A完成更新。雖然能使不同的執行緒輪流訪問資料結構,並且不會造成破壞,但如果有一個執行緒在更新操作中失敗了,那麼其他的執行緒都無法再方位佇列。

策略二是,如果B到達時發現A正在修改資料結構,那麼在資料結構中應該有足夠多的資訊,使得B能完成A的更新操作。如果B幫助A完成了更新操作,那麼B可以執行自己的操作,而不用等待A的操作完成。當A恢復後再試圖完成其操作時,會發現B已經替它完成了。