原文鏈接:http://tutorials.jenkov.com/java-concurrency/race-conditions-and-critical-sections.html
摘要:這是翻譯自一個大概30個小節的關於Java併發編程的入門級教程,原作者Jakob Jenkov,譯者Zhenning Lang,轉載請註明出處,thanks and have a good time here~~~(希望自己不要留坑)
“競爭”是可能發生在“臨界區”內的一種特使情況。臨界區的含義是:如果多個線程訪問一個代碼段,同時這些線程的執行順序將影響總得執行效果,那麼這個代碼端就叫做臨界區。
反言之,如果臨界區的執行結果受到多線程執行順序的影響,那麼就說存在競爭。競爭比喻了不同的線程互相爭搶臨界區的代碼,並且爭搶的結果也將影響臨界區的運行結果。
如果上面過於抽象難懂,下面的例子將幫助理解競爭與臨界區的含義。
1 臨界區
當多個線程執行其內部代碼的時候,其本身不會引起任何問題;引起問題的原因是對共享資源的使用。例如共用的內存(變量、數組和對象等)、系統資源(數據庫、網絡服務等)或者是文件。
跟進一步,事實上只有寫共享資源操作纔會引發問題。只要內容不變,讓多個線程讀取相同的資源是安全的。
下面是一個臨界區的 Java 代碼示例,例子中如果多個線程同時運行這段代碼就會發生失敗:
public class Counter {
protected long count = 0;
public void add(long value){
this.count = this.count + value;
}
}
現在假設兩個線程 A 和 B 在對相同的 Counter 類的實例進行 add 操作,沒有辦法知道操作系統何時在兩個線程間切換。Java 虛擬機無法像對待單一“原子”一樣處理 add() 函數中的代碼(就是沒辦法一下子執行完代碼中的全部內容,而是按步驟執行的),事實上 add() 中的代碼好像被一系列更小的指令執行:
- 將 this.count 從內存中讀到寄存器中
- 將 value 與寄存器中的數據相加並存入寄存器
- 將寄存器數據寫入內存
那麼現在考慮如下的線程 A 和 B 的執行情況:
this.count = 0; |
B: 將 this.count 讀入一個寄存器 (0) ( |
A: 將 this.count 讀入一個寄存器 (0) |
B: 將寄存器中的數據 +2 (2) |
B: 將寄存器中的數據 (2) 寫回內存,此時 this.count 等於 2 |
A: 將寄存器中的數據 +3 (3) |
A: 將寄存器中的數據 (3) 寫回內存,此時 this.count 等於 3 |
這兩個線程的本來目的是將 2 和 3 加到 counter 上,所以期待的結果應該是 5。然而實際運行中線程發生了交錯,導致結果與預期不同。上例中,兩個線程都將 0 從內存中讀出並且加上了 2 和 3,然後將其寫回內存。所以最後的結果取決於誰最後將結果寫回內存(2 和 3 都有可能)。
2 臨界區中的競爭
前面例子的 add() 方法中存在着臨界區,所以當多線程執行臨界區代碼的時候,競爭發生了。
對於臨界區和競爭更正規的定義是:如果兩個線程爭奪共享資源,並且資源被獲取的時機(順序)將對結果產生影響,這種情況叫做競爭;引發競爭的代碼段被稱作鄰接區。
3 阻止競爭的發生
爲了阻止競爭的發生,臨界區的代碼必須以“原子”的模式被執行 —— 即一旦一個線程開始執行臨界區的代碼,直到其執行完臨界區,其他的線程就無法執行臨界區。
通過對臨界區代碼設置合理的線程同步(thread synchronization)機制,競爭就可以被阻止。而 Java 中的線程同步的一種實現方式是同步代碼塊(a synchronized block of Java code)。其他的實現途徑還有鎖(locks)、原子變量(atomic variables)(例如 java.util.concurrent.atomic.AtomicInteger)。
4 臨界區的吞吐量
相比於定義分散的小的臨界區,將所有代碼定義成一個大的臨界區也可以讓代碼正常運作。但將大的臨界區拆封成小的臨界區將帶來更多的好處,因爲這樣的話不同的線程可以同時執行這些小的臨界區代碼,從而減少了其相互之間對資源的爭奪,增加了整個臨界區吞吐量。
爲了解釋這一點,下面是一個非常簡單的示例:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
public void add(int val1, int val2){
synchronized(this){
this.sum1 += val1;
this.sum2 += val2;
}
}
}
上例中 add() 函數嘗試着將 val1 和 val2 加到 sum1 和 sum2 上。同時爲了防止競爭的發生,這段代碼被放在了 Java 同步代碼塊中,這樣同一時間只能有一個線程執行 add() 中的具體加操作。
然而,由於例子中的 sum1 和 sum2 變量是相互獨立,完全可以分開在兩個同步塊中執行二者的加操作,如下:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
private Integer sum1Lock = new Integer(1);
private Integer sum2Lock = new Integer(2);
public void add(int val1, int val2){
synchronized(this.sum1Lock){
this.sum1 += val1;
}
synchronized(this.sum2Lock){
this.sum2 += val2;
}
}
}
現在兩個線程可以同時執行 add() 方法中的代碼。一個線程執行 sum1 的加操作,而另一個線程執行 sum2 的加操作。由於兩個同步塊被兩個不同的對象同步(this.sum1Lock 和 this.sum2Lock),這樣一來執行 add() 方法的多個線程相互等待的時間就被減少了。
當然上例是非常簡單的,而實際中對於臨界區的拆分可能更加複雜,並且需要仔細考量線程執行順序以及其結果的可能。