引言
為了更加形象的描述並發的基礎知識,因此本文LZ采用了園子里一度大火的標題形式——“沒聽說過XXXX,就不要說你XXXX了”。希望能夠給猿友們一個醒目的警醒,借此來普及並發的基礎知識,也討論一下這些內容。
對於大多數人而言,並發亦近矣,亦遠矣。
如果你問一個程序猿,“你知道並發嗎?”。
估計不少人會說,“恩,知道個大概吧!”。
如果此時你再繼續追問下去,可能得到的仍然會是一些千篇一律的答案。比如,“並發應該就是多個線程一起運行”、“並發的時候應該加鎖,加synchronized關鍵字”,“並發的時候采用時間片輪詢的方式”等等諸如此類的答案。
其實大多數人都是知道並發的,但卻大部分是一知半解,這也是為什么LZ說,並發亦近亦遠,近是因為幾乎所有程序猿都聽說過,遠是因為大部分人還都只停留在初級階段,包括現在剛入門的LZ本人。如果寫一個簡單的並發程序,大部分猿友們估計都能勝任,不過若是稍微復雜一點的,可能就會出現很多問題,或者自以為沒有問題。
本文的主要目的,一個是普及一點並發的基礎知識,一個是鞏固一下LZ自己對並發的理解。如果哪位猿友對此也有興趣的話,不妨試着看下去,看能否有所收獲。
線程安全
線程安全這個詞匯實在是折磨人,它給人一種錯覺,讓你仿佛很輕松的理解了它,但實則是一個典型的笑面虎,背后冷不丁就給你一刀,讓你血濺職場。
我們先來看下這個詞語組成的詞匯都有哪些,首先后面可以加一“性”字,此為線程安全性。另外,如果后面加“類”或者“程序”,就組成了線程安全類或者是線程安全程序。很顯然,線程安全性是類和程序的屬性,就像一個類或者程序的其它屬性一樣,例如擴展性、維護性等等。
到現在重點就出來了,到底什么是線程安全性?從字面上看,線程安全性就是一個類或者程序在多線程的環境中運行是安全的。可是這顯然是廢話,重點還是落在了安全性上面。怎么才能稱作是安全的?
LZ這里先貼出一個比較官方的解釋,接下來再和各位猿友侃侃大山。安全性是指,某個類的行為與其規范完全一致。那么我們現在就可以將整句話連起來了,也就是說,線程安全性就是指,一個類或者程序在多線程的環境下,其行為與規范完全一致的特性。
有的猿友可能會說,“我們開發從來都沒有規范的,OK?既然如此,何來與規范一致一說?”。是的,只是如果哪位猿友心里冒出這么一句話的話,說明你對這里的“規范”兩字理解錯誤了,這里的規范可不是指的編碼規范。LZ舉個簡單的例子來說明,這個規范的意思是什么。
public class Region { private int left; private int right; public Region() { super(); } public Region(int left, int right) { super(); if (left <= right) { this.left = left; this.right = right; }else { this.left = left; this.right = right; } } public void setLeft(int left) { if (left > right) { this.left = right; }else { this.left = left; } } public void setRight(int right) { if (right < left) { this.right = left; }else { this.right = right; } } public boolean in(int value){ return value >= left && value <= right; } public String toString(){ return "[" + left + "," + right + "]"; } }
看一下上面這個類,它表示一個整數區間,對於一個區間來講,我們自然而然的有一些規則,比如區間左邊的值必須小於或者等於右邊的值。在上面的類當中,我們也在很多地方限制着客戶端的輸入,試圖保持這種規則(但是在多線程環境下,我們這種約束將顯得非常薄弱)。
我們說這種規則就是上面提到的規范,也就是說對於Region類來說,始終保持它是一個有效的區間,就是它的規范。因此對於Region類來說,它的線程安全性就是指它可以在多線程的環境下保持它是一個有效的區間(left小於等於right)。對於Region是一個有效的區間這件事來說,其實就相當於在說in方法不能永久返回false。如果我們更加抽象點來說,就是說方法的行為應該與預期的一致。
由此我們可以看出,一個類或程序的規范,就是指它能夠始終保持一定的約束條件。比如一個應用類的stop方法,在客戶端調用后,必須能夠保證應用被正確關閉等等,這些方法的使用說明其實就是一種規范。
線程安全類
通過上面的描述,我們知道了線程安全性的定義,或者說,我們已經知道要滿足線程安全性需要達到什么要求。那么對於一個類來說,它的線程安全性如果被滿足,它就是一個線程安全的類。
對於線程安全的類,我們有一些可描述的規律,接下來LZ就和各位分享一下這些規律,很多時候,它對我們非常有用。
1、無狀態的對象一定是線程安全的。
這一條規律實在是太有用了,很多時候,我們的代碼處於多線程的環境下,而我們往往苦惱於這些代碼的安全性。此時,如果你的類是無狀態的,那么你就可以高枕無憂的在多線程環境下使用它。
為什么說無狀態的對象一定是線程安全的?
一個對象如果沒有狀態,則意味着對象不存在運行時狀態的改變,因此無論是單線程還是多線程的情況下,都不會使對象處於不正確的狀態。大多數時候,無狀態的對象就是一堆代碼的持有者而已,它每一個方法的變量都封閉在獨立的線程當中,線程相互之間無法共享變量,因此它們也無法互相影響各自的行為。因此,在多線程的環境下,我們首先推薦的就是無狀態對象。
下例就是一個無狀態對象,它沒有任何域,自然也就沒有狀態。
public class NonStatusObject{ public void handle(String param){ System.out.println(param); } }
2、不可變對象一定是線程安全的。
提到不可變對象,總讓人不知不覺的想到基本類型的包裝類,比如Java當中的String就是典型的不可變對象。不可變對象的不可變性與無狀態的對象非常相似,只是無狀態對象通過不添加任何狀態保持對象在運行時狀態的不可變性,而不可變對象則通常通過final域來強制達到這一特性,不過要注意的是,如果final域指向的是可變對象,則該對象依然可能是可變的。
比如一個List的包裝類,如果提供了對List的操作,那么既然內部的List是final類型的,該對象依然是可變的,我們看下面的例子。
import java.util.ArrayList; import java.util.List; public class ListWrapper<E> { private final List<E> list; public ListWrapper(){ list = new ArrayList<E>(); } public boolean contains(E e){ return list.contains(e); } public void add(E e){ list.add(e); } public void remove(E e){ list.remove(e); } }
這個類其實有時候是有用的,盡管它很簡單,但是它可以彌補JDK1.5加入泛型的弊病,比如remove方法的參數是Object。但是很可惜,它唯一的域是final類型的,但卻不是不可變的。因為我們提供了add和remove方法,這些方法依然可以改變這個類的狀態,因為list的狀態就是它的狀態。倘若我們在構造函數中加入一些初始化的元素,並且去掉add和remove方法,那么盡管該類引用了可變的非線程安全的類,但它依然是不可變的,也就是說依然是線程安全的。
3、除了以上兩種對象,我們通常都需要使用加鎖機制來保證對象的線程安全性。
這一條基本上道出了大部分的情況,很多時候,我們無法將一個可能處於多線程環境的對象設計成以上兩種,這時就需要我們進行合適的加鎖機制來保證它的線程安全性。通常情況下,我們希望一個對象是無狀態的或者不可變的,這可以大大降低程序的復雜性,請盡量這么做。
加鎖機制(何時加鎖)
既然有時候我們必須使用鎖機制來保證類的線程安全性,那么我們最關心的就是兩件事,第一件是何時加鎖,第二件是如何加鎖。
關於何時加鎖這個問題,我們主要關注以下幾點來決定,這些內容都是並發的精髓。
1、原子性
原子性,我們通俗的理解就是,一個操作要么就做完,要么就沒開始,不存在做了一部分的情況,那么這個操作就具有原子性。這個簡單的理解其實有一個重大漏洞,那就是這個操作是針對什么層次來說的,這將直接影響我們的判斷。比如下面這個被用的爛透了的例子,萬年的自增。
// i++;
博客園的大神們不讓LZ直接輸入i++,因此這里加了個注釋符號(這算不算一個bug,0.0)。i++這個操作,從編程語言的層次來講,它是一個原子操作,因為它只有一句代碼,如果你去調試這行代碼,它一定無法執行一半或一部分。但是如果從匯編語言的層次來講,它就不是一個原子操作,因為它有好幾條指令(看過計算機原理系列的猿友應該非常清楚),既然有好幾條指令,那么就意味着i++這個操作在匯編層次,可以存在做了一部分的情況。
對於原子性的層次定義,一般應該以CPU提供的指令集為准,至少我們認為,一個指令是無法拆分的操作。從這個角度來看,我們Java當中大部分看似原子性的操作,其實都不是原子操作,比如剛才提到的自增、賦值操作等等。如果在並發環境中,一個操作無法保證其原子性,可能就需要進行加鎖操作。
1.1、競態條件
上面已經簡單的提了一下原子性的概念,接下來,我們再來看一個和原子性密切相關的概念——競態條件。競態條件的含義是,操作的正確性要取決於多線程之間指令執行的順序。
看了上面的定義,大部分猿友估計會唏噓不已,因為多線程之間指令執行的順序完全是不定的。如果我們考慮一個多線程程序可能的指令執行順序,或許會得到10種、100種甚至更多種可能,而我們的程序可能在其中幾種情況下執行是正確的,也就是說,我們的程序正確的概率可能為1/10、1/100甚至1/1000000。
驚呆了,這是中彩票的概率吧?
我們可以這么去想,當你中了500萬的彩票時,你的程序或許就能正確執行了。程序的正確性完全取決於“運氣”,這就是典型的競態條件。比如下面這個更典型的單例模式當中經常出現的方式。
public static SingletonObject getInstance(){ if (instance == null) { instance = new SingletonObject(); } return instance; }
這里就出現了競態條件,因為instance是否為單例,取決於指令執行的順序。舉一個極端的例子,假設10個線程同時運行這個方法,如果這10個線程每一個都判斷完instance是否為null之后掛起,那這10個線程在再次被喚醒時都將會去執行new的操作,我們假設每個線程的new和return操作都會一起執行完,然后才把CPU讓給其它線程。最終的結果會是,這10個線程得到了10個不一樣的實例。各位猿友可以執行一下下面這個簡單的測試程序,它將開啟100個線程同時執行getInstance方法。
import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SingletonObject { private static SingletonObject instance; private SingletonObject(){} public static SingletonObject getInstance(){ if (instance == null) { instance = new SingletonObject(); } return instance; } public static void main(String[] args) throws InterruptedException { int threadCounts = 100; int testCounts = 10000; for (int i = 0; i < testCounts; i++) { test(threadCounts); } } public static void test(int threadCounts) throws InterruptedException{ ExecutorService executorService = Executors.newCachedThreadPool(); final CountDownLatch startFlag = new CountDownLatch(1); final CountDownLatch counter = new CountDownLatch(threadCounts); final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>()); for (int i = 0; i < threadCounts; i++) { executorService.execute(new Runnable() { public void run() { try { startFlag.await(); } catch (InterruptedException e) {} instanceSet.add(SingletonObject.getInstance().toString()); counter.countDown(); } }); } startFlag.countDown(); counter.await();
SingletonObject.instance = null; if (instanceSet.size() > 1) { System.out.print("{"); for (String instance : instanceSet) { System.out.print("[" + instance + "]"); } System.out.println("}"); } executorService.shutdown(); } }
以上的測試共執行1萬次,這是為了加大出錯幾率。基本上,你總能看到以下這樣的輸出。
{[SingletonObject@16930e2][SingletonObject@7259da]}
這說明在一次測試中,生成了兩個SingletonObject對象(可能會有更多,LZ運行了一小會就見到一次14個的)。可以看出,並不是這10000次測試都會出錯,相對來說,出錯的概率還是非常小的。這正是競態條件的發生形式,在一定的指令執行序列下,程序就會出錯,比如單例模式實際上變成了非單例的情況。
1.2、復合操作
顧名思義,復合操作就是非原子性的操作,兩者具有互斥性,也就是說,一個操作要么屬於原子操作,要么屬於復合操作。上面的if塊就是一個典型的復合操作,根據某一個變量的值,決定下一步的行為。通常情況下,使用同步關鍵字(synchronized)可以使得復合操作變成原子操作,但我們往往更推薦使用現有的類庫去實現原子性。
比如一個並發的計數器,就可以寫成如下形式。
import java.util.concurrent.atomic.AtomicInteger; public class ConcurrentCounter { private final AtomicInteger count = new AtomicInteger(0); public int getCount(){ return count.get(); } public int increment(){ return count.incrementAndGet(); } public int decrement(){ return count.decrementAndGet(); } }
這里我們使用現有的線程安全類來實現一個並發計數器,這省去了我們很多工作,比如自增並返回、遞減並返回這些復合操作(實際上AtomicInteger提供了很多常用的復合操作,並保證原子性)。這樣做的好處是,不容易出錯,性能可能更高(比如ConcurrentHashMap),分析起來更簡單。實際上,我們包裝了一個線程安全的類,使之成為了另外一個線程安全的類。
2、可見性
可見性這玩意實在是太奇葩了,以至於亮瞎了LZ的一雙氪金人眼。為了把可見性寫的更神秘一點,LZ先給出一個簡單的例子。
public class Integer { private int value; public int getValue() { return value; } public void setValue(int value) { this.value = value; } }
這個類是Java類庫中Integer類的偽劣產品,各位猿友想象一下,它是一個線程安全的類嗎?(JDK中的Integer是不可變對象,因此是線程安全的)
乍一看好像是的,因為這個類太簡單了,而且沒有競態條件(當前的行為不受之前狀態的影響)。但是很抱歉,這個類依然不是線程安全的。原因就是因為它的可見性不能保證,因此在多線程環境下,如果一個線程設置了value的值為100,那么另外一個線程或許會看不到100這個值。
為何會這樣呢?
我們依然回想一下計算機原理當中的內容,在計算機原理當中我們曾經無數次的接觸過寄存器與存儲器,在匯編級別的代碼當中,我們會發現,很多變量的賦值是不會反應到存儲器當中的,它們有時候一直存在於寄存器當中。這樣一來,可見性就好解釋了,有時候一個線程A去讀取一個變量,這時候它會瞄准存儲器的某一個位置進行讀取操作,它或許會期待另外一個線程B去改變存儲器的值,但事實往往是,另外一個線程B只是把值隱藏在了寄存器,而導致線程A永遠看不到這個更新后的值。
還有另外一種情況是,編譯器會將現有的程序進行亂序重組,或許表面看起來,我們是先給一個變量賦值,然后又在另外一個線程去讀取它,但事實可能是我們先去讀取了這個變量,然后才進行的賦值。
不管是哪種情況,一旦牽扯到可見性,就說明程序的行為是不可預見的。換句話說,我們的程序如果想要正確的運行,和中彩票是一個概念,需要一定的概率才能發生,這當然是我們不能容忍的。
因此,我們必須保證一個對象的可見性,否則在共享一個對象時,就會非常的危險。對於上面這個簡單的整數類,我們只要給get/set方法加上synchronized關鍵字,就可以保證它的可見性。這是由於synchronized關鍵字不僅保證了同步機制,更重要的是禁用了亂序重組以及保證了值對存儲器的寫入,這樣就可以保證可見性。
加鎖機制(如何加鎖)
上面主要回答了各位我們應該在何時加鎖,看似很復雜,但其實更難的還是在如何加鎖的問題上。因為如果不考慮簡單性或者性能等一些問題,給一個類的全部方法加上synchronized關鍵字就可以確保這個類的線程安全性。但是很顯然,這種做法很多時候是不可取的,除非你想收到上級的“誇獎”。
如果一個多線程環境下的類無法做成無狀態或者是不可變對象,那么我們就只能嘗試去做一些同步機制,來保證它的線程安全性,或者說保證它可以正常工作。這個問題很難一概而論,不過在絕大多數情況下,我們秉持這樣一個原則去進行同步,那就是總是用同一個鎖去保護需要協變的狀態。
這一句話顯然無法概括所有加鎖的情況,但是卻是LZ個人感覺能解決大部分問題的方法。接下來LZ就舉一個簡單的例子,比如上面的區間類,它當中就有一些明顯的協變狀態(協變狀態是LZ個人起的名字,意思是想指那些需要相互協助變化的狀態)。我們接下來就嘗試將上面的區間類變成線程安全的類。
public class Region { private int left; private int right; public Region() { super(); } public Region(int left, int right) { super(); if (left <= right) { this.left = left; this.right = right; }else { this.left = left; this.right = right; } } public synchronized void setLeft(int left) { if (left > right) { this.left = right; }else { this.left = left; } } public synchronized void setRight(int right) { if (right < left) { this.right = left; }else { this.right = right; } } public synchronized boolean in(int value){ return value >= left && value <= right; } public String toString(){ return "[" + left + "," + right + "]"; } }
方法非常簡單,我們只是簡單的給三個方法加上了synchronized關鍵字,但不可否認的是,它現在已經是一個線程安全的類(我們對toString的顯示要求不高,因此不進行同步)。這個類當中很顯然left和right變量是一組協變狀態,它們兩個之間需要相互協助的變化,而不可以單獨進行改變。
其實在現實當中,這樣的協變狀態有很多。比如我們常用的ArrayList,它當中就有一個Object數組和一個size標識,這兩個狀態很明顯是需要協變的,一旦object數組有所變化,size就要跟隨着變化,這樣的話在多線程當中使用時,就需要將二者使用同一個鎖進行同步(一般情況下,我們會使用當前對象充當這個鎖,即this關鍵字)。
如果一個方法當中,並不全是協變狀態,我們就可以進行局部同步(使用synchronized同步塊),這樣就可以減少性能的損失,但也要保證一定的簡單性,否則的話,這段程序維護起來會非常頭疼。
接下來,我們看一個簡單的例子,我們給區間類加一些輸出語句,來顯示同步塊的使用。
public class Region { private int left; private int right; public Region() { super(); } public Region(int left, int right) { super(); if (left <= right) { this.left = left; this.right = right; }else { this.left = left; this.right = right; } } public void setLeft(int left) { System.out.println("before setLeft:" + toString()); synchronized (this) { if (left > right) { this.left = right; }else { this.left = left; } } System.out.println("after setLeft:" + toString()); } public void setRight(int right) { System.out.println("before setRight:" + toString()); synchronized (this) { if (right < left) { this.right = left; }else { this.right = right; } } System.out.println("after setRight:" + toString()); } public synchronized boolean in(int value){ return value >= left && value <= right; } public String toString(){ return "[" + left + "," + right + "]"; } }
這里我們為了盡可能的保證程序的性能,所以使用了同步塊,在進行輸出語句的調用時,並不會將當前對象鎖定。眾所周知,JAVA在I/O方面的處理是比較慢的,因此在同步的語句當中,我們應當盡量的將I/O語句移出同步塊(當然還包括其它的一些處理較慢的語句)。
這里LZ再舉一個非常常見的例子,就是對於循環一個列表的處理,以下這段代碼節選自JDK1.6當中Observable類(觀察者模式當中的被觀察者父類)。
public void notifyObservers(Object arg) { /* * a temporary array buffer, used as a snapshot of the state of * current Observers. */ Object[] arrLocal; synchronized (this) { /* We don't want the Observer doing callbacks into * arbitrary code while holding its own Monitor. * The code where we extract each Observable from * the Vector and store the state of the Observer * needs synchronization, but notifying observers * does not (should not). The worst result of any * potential race-condition here is that: * 1) a newly-added Observer will miss a * notification in progress * 2) a recently unregistered Observer will be * wrongly notified when it doesn't care */
if (!changed) return; arrLocal = obs.toArray(); clearChanged(); } for (int i = arrLocal.length-1; i>=0; i--) ((Observer)arrLocal[i]).update(this, arg); }
可以看到,這個方法的任務是通知所有的觀察者,也就是說,需要循環obs這個list列表,並挨個調用update方法。但是這里並沒有直接循環obs這個列表,而是使用了一個臨時變量arrLocal,並獲取到obs的一個快照(snapshot)進行循環。這就是為了保證同步的情況下,盡量的提高性能,因為update方法當中可能會有一些很占用時間的操作,這樣的話,如果我們直接對obs循環期間進行同步,那么就可能會導致被觀察者被鎖定相當長的一段時間。
總結
並發算是編程當中的一個高級課題,所以難度可能會較高。但話說回來,只要你在做Java Web,就一定離不開並發。所以看似高級課題的並發,其實一直都與你日夜相伴。從某種意義上來講,真正要入門web的前提,就是搞清楚並發的相關內容,因為在運維的過程中,往往代碼中出現的bug都是非常簡單的,而難的地方,就是一些並發所帶來的偶然性問題,這就需要你對並發有一定深入的了解才能發現問題的所在。
好了,本章內容就到此為止了,盡管LZ也是剛剛入門,但還是希望本文能給各位帶來一些幫助。