非阻塞算法在併發上下文下是指一個算法容許線程訪問共享狀態(亦或是協做和溝通)時不會阻塞到其餘相關線程.更通俗的講,一個非阻塞算法是指在該算法中一個線程的停頓並不會引發其餘相關線程的停頓.html
爲了更好的理解阻塞和非阻塞併發算法之間的區別,咱們會先講解阻塞算法再講解非阻塞算法.java
一個阻塞的併發算法須要包含如下兩個行爲:算法
許多算法和併發數據結構都是阻塞的.例如,全部java.util.concurrent.BlockingQueue
接口的實現類都是阻塞的數據結構.若是一個線程嘗試插入元素到一個阻塞隊列中而且發現隊列已經沒有剩餘空間了,那麼插入線程會被阻塞直到阻塞隊列中有剩餘空間能夠插入元素爲止.緩存
如下示例圖描述了一個阻塞算法保證共享數據結構安全訪問的行爲:安全
一個非阻塞併發算法須要包含如下兩個行爲:數據結構
Java中同時包含了一些非阻塞數據結構.像AtomicBoolean, AtomicInteger, AtomicLong 和 AtomicReference都是非阻塞數據結構活生生的例子.多線程
如下示例圖描述了一個非阻塞算法保證共享數據結構安全訪問的行爲:併發
非阻塞和阻塞算法之間的不一樣主要體如今上文說起算法須要包含兩個行爲中的第二個.換句話說,它們兩的不一樣之處主要體如今當請求操做不能被執行時作出的響應.post
阻塞算法會阻塞請求線程直到請求操做可以被執行爲止.而非阻塞算法則是通知請求線程它的請求操做不能被執行.性能
在阻塞算法中,一個線程可能會被阻塞到它的請求操做可以被安全執行爲止.一般其餘線程請求操做的阻塞成就了第一個線程請求操做的安全執行.出於某些緣由,若是應用中某些地方的其餘線程發生停頓或阻塞,可能致使第一個線程的請求操做沒法順利的執行,那麼第一個線程會陷入阻塞甚者是永久阻塞,直到有其餘線程執行了必要的操做喚醒它爲止.
例如,一個線程在嘗試插入元素到一個已經滿了的阻塞隊列中時,會被阻塞到其餘線程取走隊列中的元素爲止.若是出於某些緣由,在應用中的某些地方負責取走隊列元素的線程發生了停頓或阻塞,那麼嘗試插入元素到阻塞隊列中的線程將會發生阻塞甚至是永久阻塞,直到最終有線程取走阻塞隊列中的一個元素爲止.
在多線程系統中,線程一般須要經過一些不一樣類型的數據結構來進行通信.這些數據結構能夠是簡單的變量,也能夠是像隊列,map,棧等這樣複雜的數據結構.爲了確保正確性,多個線程併發訪問數據結構時,須要經過一些併發算法來保障.因爲這些併發算法的保障才讓數據結構成爲了併發數據結構.
若是一個算法是經過阻塞的方式來保障併發數據結構的,咱們稱爲阻塞算法.那麼這種數據結構咱們稱爲阻塞的併發數據結構.
若是一個算法是經過非阻塞的方式來保障併發數據結構的,咱們稱爲非阻塞算法.那麼這種數據結構咱們稱爲非阻塞的併發數據結構.
每種併發數據結構都是爲特定的通信場景設計的.至於須要使用哪一種併發數據結構取決於你的通信場景.咱們在接下來的章節中會講解幾種非阻塞的併發數據結構.而且說明哪些狀況下會用到這些數據結構.這些非阻塞併發數據結構工做原理的講解可以給你一些思路怎麼設計和實現一個非阻塞數據結構.
Java中的volatile變量可以讓變量始終是從主存中加載的.只要volatile變量被賦予新值就會當即被寫回到主存中去.這能夠保證volatile變量最新的修改始終能夠對運行在其餘CPU上的線程可見.其餘線程每次都會從主存中加載volatile變量而不是從它們運行CPU上的CPU緩存中.
volatile變量是非阻塞的.對volatile變量值的寫入是一個原子操做.它不會被打斷.然而,對一個volatile變量的讀取更新寫入一系列操做並非原子的.也就是說,下面這段代碼在多線程環境下仍然會出現競態條件.
volatile myVar = 0;
...
int temp = myVar;
temp++;
myVar = temp;
複製代碼
首先咱們從主存中加載myVar變量而後賦予temp變量.而後對temp變量累加1.而後將變量temp從新賦予myVar,這意味着myVar變量會被當即寫回到主存中去.
若是兩個線程同時執行這段代碼,同時加載變量myVar增長1並將變量值寫回到主存中.那麼存在必定的風險,原本對myVar變量的加法操做,如今只剩下一個了.(例如兩個線程都會讀取到變量值19,累加爲20,再把20寫回).
或許你以爲你不會寫出像上面這樣的代碼,但在實操中上面的代碼等同於:
myVar++;
複製代碼
當你執行這段代碼時,myVar變量值會被加載到CPU寄存器或CPU緩存中,進行一次加法操做,而後會將CPU寄存器或緩存中的值寫回主存.
某些場景下,你只有一個線程寫入共享變量而有多個線程來讀取變量.當只有一個線程更新變量時,不管有多少個線程同時讀取變量都不會有競態條件出現.因此只要只有一個寫線程的狀況下,你均可以使用volatile變量.
竟態條件只會在多個線程同時對一個共享變量作讀取更新和寫入一系列操做時纔會發生.當你只有一個線程執行讀取更新寫入系列操做而有多個線程執行讀取操做時,竟態條件不會發生.
這是一個只有一個寫線程場景下的Counter實例,即便沒有使用同步裝置也不會有併發問題:
public class SingleWriterCounter {
private volatile long count = 0;
/** * 只能讓一個相同的線程來調用該方法, * 不然將會有竟態條件出現 */
public void inc() {
this.count++;
}
/** * 這個方法能夠被多個讀取線程調用 * @return */
public long count() {
return this.count;
}
}
複製代碼
當只有一個線程調用inc()的狀況下,多個線程能夠安全的訪問相同的Counter實例.固然相同的線程能夠屢次調用inc()方法,而不是隻調用一次.多個線程能夠同時調用count()方法而不會產生竟態條件.
下圖描述了多個線程是如何訪問volatile修飾的count變量的:
咱們能夠聯合使用多個volatile變量來構建數據結構,每個volatile變量均可以被一個線程寫入和多個線程讀取.每個volatile變量能夠被不一樣的線程寫入(但只能是相同的線程).利用這種數據結構中的volatile變量可讓多個線程互相發送信息而不會發生阻塞.
這是一個可讓兩個寫線程操做的counter對象示例:
public class DoubleWriterCounter {
private volatile long countA = 0;
private volatile long countB = 0;
/** * 只能讓一個相同的寫線程來調用該方法, * 不然會發生竟態條件 */
public void incA() { this.countA++; }
/** * 只能讓一個相同的寫線程來調用該方法, * 不然會發生竟態條件 */
public void incB() { this.countB++; }
/** * 多個讀線程能夠調用該方法 */
public long countA() { return this.countA; }
/** * 多個讀線程能夠調用該方法 */
public long countB() { return this.countB; }
}
複製代碼
如你所見,DoubleWriterCounter有兩個volatile變量和兩對累加和讀取方法.只能有一個相同的線程調用incA()和一個相同的線程調用incB().但能夠由不一樣的線程分別調用incA()和inB()方法.多個線程能夠同時調用countA()和countB()方法,而不會出現竟態條件.
DoubleWriterCounter能夠用做兩個線程之間互相通信.兩個count計數器能夠用於執行生產任務和消費任務.下圖描述了兩個線程經過上述數據結構進行通信的場景:
聰明的讀者能夠發現可使用兩個SingleWriterCounter實例來達到DoubleWriterCounter同樣的效果.你甚至能夠增長更多的SingleWriterCounter實例來實現更多線程之間互相通信.
若是你確實須要知足多個線程同時寫入共享變量,那麼僅僅是使用volatile已經不夠用了.你須要特定類型的互斥訪問.下面利用了Java中的synchronized
同步塊來使用互斥訪問.
public class SynchronizedCounter {
long count = 0;
public void inc() {
synchronized(this) {
count++;
}
}
public long count() {
synchronized(this) {
return this.count;
}
}
}
複製代碼
咱們能夠注意到inc()和count()方法都被包裹在synchronized
同步塊中了.這就是咱們須要解決的問題,即不調用synchronized
同步塊和wait()/notify()方法等也能使上文說起代碼變成線程安全.
咱們可使用Java中的原子變量AtomicLong來替換兩個synchronized
同步塊.下面給出的是AtomicLong版本的Counter對象:
public class AtomicCounter {
private AtomicLong count = new AtomicLong(0);
public void inc() {
boolean updated = false;
while(!updated){
long prevCount = this.count.get();
updated = this.count.compareAndSet(prevCount, prevCount + 1);
}
}
public long count() {
return this.count.get();
}
}
複製代碼
這個版本與以前的synchronized
同步塊版本同樣也是線程安全的.這個版本有趣的地方是對inc()方法的更改.inc()方法中的代碼再也不包含在synchronized
同步塊中.而是更改成:
boolean updated = false;
while(!updated){
long prevCount = this.count.get();
updated = this.count.compareAndSet(prevCount, prevCount + 1);
}
複製代碼
這些代碼並不全是原子操做.這意味仍然有可能被兩個不一樣的線程調用,它們會同時執行long = prevCount = this.count.get();
語句,同時會取得更改前Counter中的count變量值.即便這樣這些代碼仍然不會出現竟態條件.有趣吧!(筆者此刻的感覺:當你對代碼的底層知根知底時,即便是while(!updated)這樣看似枯燥無味的代碼也會變得十分有趣.)
祕密就在於while循環中的第二行代碼.compareAndSet()調用是原子的.這段調用會比較AtomicLong中的值是否是預期值,若是符合預期則設置AtomicLong爲新的值.這裏的compareAndSet()方法直接使用CPU指令級的CAS.所以這裏不須要任何同步限制也不須要阻塞線程.省去了阻塞線程所須要的性能開銷.
想象一下AtomicLong此時內部值爲20.如今同時有兩個線程讀取該值,並嘗試調用compareAndSet(20, 20 + 1)
.因爲compareAndSet()是原子操做的,同一時間只能有一個線程執行這個方法.
第一個執行的線程會先比較AtomicLong的內部值是否爲20(執行更改前的值),當符合預期時,線程會將AtomicLong的內部值更改成21(20 + 1).若是更改變量成功,會將updated置換爲true並中止while循環.
如今第二個線程能夠調用compareAndSet(20, 20 + 1)
了.固然如今AtomicLong的內部值已經再也不是20了,這次調用將會失敗.AtomicLong的值不會被設置爲21.updated變量此時會被置換爲false,線程會在while循環上自旋一次,從新進入循環內部.這一次,若是沒有其餘線程在調用compareAndSet()的話,它會讀取到AtomicLong內部值爲21,並從新調用compareAndSet(21, 21 + 1)將AtomicLong更新爲22.
前文中提到的代碼實現咱們稱之爲樂觀鎖.樂觀鎖跟傳統的鎖不太同樣,咱們一般稱傳統的鎖爲悲觀鎖.傳統的方式是經過synchronized
同步塊和和各類類型的鎖來鎖住共享內存的訪問.一個synchronized
同步塊或是鎖會致使線程發生阻塞.
樂觀鎖容許全部線程建立共享內存的副本而不會發生阻塞.線程會對它們本身所持有的副本進行更改,並嘗試將更改寫回到共享內存.若是沒有其餘線程正在更改共享內存,那麼cas容許線程將它的更改寫回到共享內存中.若是已經有線程在更改共享內存,那麼會讀取一個新的拷貝,在新的拷貝上進行修改並從新嘗試將修改寫回到共享內存中.
咱們稱之爲樂觀鎖的緣由是線程會獲取一份數據拷貝,並基於這份拷貝進行修改,基於樂觀的假定,此時沒有任何線程在修改共享內存.若是假定成真,那麼線程只須要繼續更改共享內存便可而不須要鎖定任何東西.若是假定不成真,那麼這次修改會被做廢,但也不會鎖定任何東西.
樂觀鎖,在對共享內存競爭率較低的狀況下性能表現較好.若是對共享內存的競爭率比較高的話,線程會浪費大部分CPU運行時鐘來作無效的數據拷貝修改和失敗的共享內存寫入.可是若是你的共享資源比較龐大的話,你須要考慮將你的代碼從新設計爲對共享內存竟爭率較低的狀況.
上文示例的樂觀鎖是非阻塞的.若是一個線程出於未知的緣由對共享內存數據進行拷貝和修改的過程當中發生了阻塞將不會影響其餘線程繼續訪問共享內存.
一個傳統鎖lock/unlock的狀況.當一個線程取得鎖實例時會阻塞其餘線程直到它釋放鎖爲止.若是一個線程在取得鎖後執行臨界區代碼的過程當中發生阻塞,那麼會持有鎖一段時間甚至是永遠也不會釋放.這樣其餘等待持有該鎖的線程也會永遠等待下去.
一個簡單的cas樂觀鎖可以在一次cas操做後將共享數據結構整個替換爲新的.將整個數據結構替換爲一個已經修改過的拷貝並不老是可行的.
想象一下若是共享數據結構是一個隊列.每一個線程都會拷貝一份它本身的副本,並嘗試在副本上插入和取出元素以達到更改副本的效果.這裏能夠經過AtomicReference來達到目的.拷貝引用對象即拷貝和修改隊列,並嘗試將AtomicReference的引用指向新建立的隊列.
然而,一個較大的數據結構須要花費更多的CPU運行時間和內存來進行拷貝.這會讓你的應用消耗大量的內存和運行時間來進行拷貝.這可能會影響應用的執行,特別是對數據結構競爭比較激烈的狀況.更多的,若是線程花費拷貝和修改數據結構的時間越多,那麼就越有可能其餘線程會在此期間已經對共享內存中的數據結構進行修改.若是線程拷貝的共享數據結構已經被修改過了,全部的線程都須要從新進行它們的拷貝和修改操做.這會對程序的執行性能和內存消耗形成更大的負面影響.
下一節中,將會介紹一個實現可以被並行修改的非阻塞數據結構的方法.
該系列博文爲筆者複習基礎所著譯文或理解後的產物,複習原文來自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial