這里講的是關于Java并發(fā)機制的基礎模塊及如何設計合理的并發(fā)機制的抽象思維和設計模式。
有這么幾個知識點:
1 “先行發(fā)生”的次序(happens-before ordering)
2 “volatile”修飾符的使用
3 線程安全的延遲初始化
4 “Final”字段
5 關于Java并發(fā)機制的一些建議
happens-before ordering
當我們在Java里談起互斥鎖定(mutual-exclusion lock)時,通常都指當我首先進入了一個互斥鎖定,其他人試圖獲得這個同樣的互斥鎖定時,必須在我釋放了之后才可以。這是Java或C++里關于互斥鎖定的最重要的屬性。但事實上,這不是互斥鎖定唯一的屬性。還有一個屬性是可見性(visibility),它和次序屬性(ordering)緊密相關。
當一個線程使用一個鎖定時,它決定了其他線程何時可以看到該線程在鎖定后所做的更新。當一個線程對一個變量進行寫的操作時,這個寫的操作是否會被其他線程看到將取決于該線程使用的是何種鎖定。
下面是一個小測驗。有下面這段程序:
x=y=0;
//now start threads
//thread 1
x=1;
j=y;
//thread 2
y=1;
i=x;
問題是,在線程1和2執(zhí)行完畢后,有沒有可能i和j都等于0?
我們知道,如果i和j結果都為0的話,對y的讀(在j=y里用到)一定比對y的寫先發(fā)生,類似地,對x的讀一定比對x的寫先發(fā)生?那么,這可能嗎?
答案是肯定的。事實上,編譯器和處理器都可能對上述程序重新排序,尤其在使用多個處理器,賦值并沒有在主內存里同步時?,F(xiàn)代的java內存模型使上述現(xiàn)象成為可能。上面的程序顯然是錯誤的未經同步的代碼,因為它沒有使用鎖定。當不同的線程需要讀寫同一個數(shù)據(jù)時,必須使用鎖定的技術。
再看看下面一段非常關鍵的代碼??梢哉f,這段代碼是全篇演講的核心。
thread 1:
ref1.x = 1;
lock M;
glo = ref1;
unlock M;
thread 2:
lock M;
ref2 = glo;
unlock M;
j = ref2.x;
thread1里有幾個寫的操作,在對glo變量進行寫的操作之前,它首先對對象M進行了鎖定。在thread2里,當thread1釋放了對M的鎖定之后,它過得了對M的鎖定,并開始對glo的讀操作。問題是,在thread1里的寫操作,thread2進行讀操作時,可以看到嗎?
答案是肯定的,原因是thread1里對M對象的釋放和thread2里對同一個對象M的獲得,形成了一個配對??梢赃@樣想,當M在thread1里被釋放后,在thread1里所作的更新就被推出(到主內存),隨后的在thread2里對M的獲得,就會抓取所有在thread1里所作的更新。作為thread2能得到在thread1里的更新,這就是happens-before的次序。
一個釋放的操作和相匹配的之后發(fā)生的獲得操作就會建立起業(yè)已發(fā)生的次序。在同一個線程里的執(zhí)行次序也會建立起業(yè)已發(fā)生的次序(后有例子會涉及到在同一線程里的執(zhí)行次序問題)。 業(yè)已發(fā)生的次序是可以轉換的。
如果同時有兩筆對同一個內存地址的訪問,其中一筆是寫的操作,并且內存地址不是volatile的,那么這兩筆訪問在VM里的執(zhí)行次序就會按照“先行發(fā)生”的規(guī)則來排。
下面舉一些例子來說明問題。請看下面的程序:
int z = o.field1;
//block until obtain lock
synchronized(o){
//get main memory value of field1 and field2
int x = o.field1;
int y = o.field2;
o.field3 = x+y;
//commit value of field3 to main memory
}
//release lock
moreCode();
像你從這個程序的注釋里讀到的一樣,你會期望看到,在鎖定發(fā)生后,x和y會被從主要內存里讀到的field1和field2賦值,field3被賦值后在鎖定釋放后被推到主內存里,這樣,其他線程應該由此得到最近的更新。
想起來是蠻符合邏輯的。實際所發(fā)生的可能不一定如此,下面一些特殊情況會造成happens-before的次序失效。
1 如果o是本地線程的對象?因為鎖定的是本地線程里的對象,在其他線程里不可能獲得一個相匹配的鎖定,所以對本地線程對象的鎖定不起作用,
2 是否有現(xiàn)有對o的鎖定還未被釋放?如果此前已有一個對象的鎖定,在該鎖定被釋放之前,對同一個對象的再鎖定不起作用。
Volatile修飾符
當一個字段被多個線程同時訪問,至少其中一個訪問是進行寫操作,我們可以采用的手段有以下兩種:
1 采用鎖定來避免同時訪問
2 用volatile來定義該字段,這樣做有兩個作用,一是增強程序的可讀性,讓讀者知道這是一個將被多線程訪問操作的字段;另外一個作用是在JVM對該字段的處理上,可以得到特殊的保證。
volatile是java里除鎖定之外的重要同步手段。首先,volatile字段的讀和寫都直接進主內存,而不會緩存在寄存器中;其次,volatile字段的讀和寫的次序是不能更改的;最后,字段的讀和寫實質上變成了鎖定模型里的獲得和釋放。
對一個volatile字段的寫總是要happens-before對它的讀;對它的寫類似于對鎖定的釋放;對它的讀類似于進入一個鎖定。
就volatile修飾符對可見性的影響,讓我們看看下面的代碼:
class Animator implements Runnable {
private volatile boolean stop = false;
public void stop () { stop = true;}
public void run() {
while (!stop){
oneStep();
try { Thread.sleep(100);} …;
}
}
private void oneStep() { /*…*/ }
}
這段程序里主要有兩個線程,一個是stop,一個是run。注意,如果不用volatile來修飾stop變量,happens-before的次序就不會得到體現(xiàn),stop線程里對stop變量的寫操作不會影響其他線程,所以編譯器不會去主內存里讀取stop線程對stop變量的改變。這樣,在run線程里就會出現(xiàn)死循環(huán),因為在run線程里從始至終使用的只是stop變量初始化時的值。
由于編譯器優(yōu)化的考慮,如果沒有volatile來修飾stop變量,run線程永遠都不會讀到其他線程對stop變量的改變。
就volatile對執(zhí)行次序保證的作用,我們看看下面的代碼:
class Future {
private volatile boolean ready;
private Object data;
public Object get() {
if (!ready)
return null;
return data;
}
public synchronized void setOnce(Object o){
if (ready) throw…;
data = o;
ready = true;
}
}
首先一點還是由于volatile的使用使得happens-before的次序得以體現(xiàn),setOnce方法對ready變量的寫操作的結果一定會被get方法中的讀操作得到。
其次,更重要的,如果ready變量不被volatile來修飾,當線程A叫到setOnce方法時,可能按照data=o; ready=true;的次序來執(zhí)行程序,但是另一個線程B叫到setOnce方法時,可能會按照ready=true;data=o;的次序來執(zhí)行??赡馨l(fā)生的一個情況是當線程B執(zhí)行完ready=true時,線程A正在檢查ready變量,結果造成data未有寫操作的情況下就完成了方法。data可能是垃圾值,舊值,或空值。
有關volatile的另外一點是被volatile修飾的變量的非原子操作化。比如,執(zhí)行volatile value++;的命令時,如果在對value加1后要寫回value時,另外一個線程對value做寫的操作,之前加和的操作就會被影響到。
就JVM而言,對volatile變量的讀操作是沒有額外成本的,寫操作會有一些。
線程安全的延遲初始化
首先有下面一段代碼:
Helper helper;
Helper getHelper() {
if (helper == null)
synchronized(this){
if (helper ==null)
helper = new Helper();
}
return helper;
}
這段代碼是典型的延遲初始化的產物。它有兩個目的:一是讓初始化的結果能被多線程共用;一是一旦對象初始化完畢,為了提高程序的效率,就不再使用同步鎖定。如果不是由于第二點,實施對整個方法的同步其實是最保險的,而不是如本段代碼中的只是對段的同步。
這段代碼的問題是,對helper的寫操作鎖定是存在的,但是卻沒有相匹配的獲得鎖定來讀helper,因此,happens-before的關系沒有建立起來,進入同步段來初始化helper的唯一可能是helper==null。如果一個線程過來檢查是否helper == null,如果碰巧不是的話,它卻不能得到其他線程對helper的更新(因為沒有happens-before的關系),所以最后它返回的很可能是一個垃圾值。
在這里建立happens-before的關系的方法很簡單,就是對helper加上volatile的修飾符,volatile Helper helper;
線程安全的immutable對象
基本原則是盡可能的使用immutable對象,這樣做會有很多優(yōu)點,包括減少對同步機制的需要;
在類里,可以將所有變量定義為final,并且在構建完成前,不要讓其他線程看到正在構建的對象。
舉個例子,線程1新建了一個類的實例;線程1在沒有使用同步機制的情況下,將這個類的實例傳遞給線程2;線程2訪問這個實例對象。在這個過程中,線程2可能在線程1對實例構建完畢之前就得到對實例的訪問權,造成了在同步機制缺失的情況下的數(shù)據(jù)競爭。
關于Java并發(fā)機制的一些有益建議
盡可能的使用已經定義在java.util.concurrent里的類來解決問題,不要做得太底層。增強對java內存模型的理解,搞懂在特定環(huán)境下釋放和獲得鎖定的意義,在你需要自己去構想和實施并發(fā)機制時,這些都會用得上。
在一個單線程的環(huán)境下使用并發(fā)類可能會產生可觀的開銷,比如對Vector每一次訪問的同步,每一筆IO操作等等。在單線程環(huán)境下,可以用ArrayList來代替Vector。也可以用bulk I/O 或java.nio來加快IO操作。
看看下面一段代碼:
ConcurrentHashMap<String,ID> h;
ID getID(String name){
ID x = h.get(name);
if (x==null){
x=new ID();
h.put(name,x);
}
return x;
}
如果你只調用get(),或只調用put()時,ConcurrentHashMap確實是線程安全的。但是,在你調用完get后,調用put之前,如果有另外一個線程調用了h.put(name,x),你再執(zhí)行h.put(name,x),就很可能把前面的操作覆蓋掉了。所以,即使在線程安全的情況下,你還有有可能違法原子操作的規(guī)則。
減少同步機制的開銷:
1 避免在多線程間共用可變對象
2 避免使用舊的,線程不安全的數(shù)據(jù)結構,如Vector或Hashtable
3 使用bulk IO和java.nio里的類
在使用鎖定時,減少鎖定的范圍和持續(xù)時間。