Java併發程式設計實戰
簡介
執行緒的優勢:
- 發揮多處理器強大的能力
- 建模的簡單性(為模型中的每種型別的任務都分配一個專門的執行緒)
- 非同步事件的簡化處理
- 響應更靈敏的使用者介面
執行緒帶來的風險
安全性問題
執行緒安全性可能是非常複雜的,在沒有充分同步的情況下,多個執行緒中的操作執行順序是不可預測的,甚至會產生奇怪的結果。
活躍性問題
安全性的定義是”永遠不發生糟糕的事情”,而活躍性關注於另一個目標“某件正確的事情最終會發生“。當某個操作無法繼續執行下去時,就會發生活躍性問題。活躍性問題的形式之一就是無意中造成的無限迴圈。
效能問題
在設計良好的併發應用程式中,執行緒能提高程式的效能。但無論如何,執行緒會帶來一定的執行時開銷。在多執行緒程式中,當執行緒排程器掛起一個活躍執行緒並轉而執行另一個執行緒時,就會頻繁出現上下文切換操作,這會帶來極大的開銷。
/* * 1-1 非執行緒安全的數值序列生成器 * */ public class UnsafeSequence { private int value; /* * 返回一個獨一無二的值 * */ public int getNext(){ return value++; } public static void main(String[] args) { UnsafeSequence sequence = new UnsafeSequence(); for(int i = 0; i < 10; i++){ Thread t = new Thread(() -> { System.out.print(sequence.getNext() + "\t"); }); t.start(); } } }
在沒有充分同步的情況下,生成的序列號可能相同(也可能全部不相同,但是多執行幾次一定可以看到相同的序列號)。
一次執行結果:
0 3 2 1 0 5 4 6 7 8
我們將 getNext 修改為一個同步方法(新增 synchronized),就可修復上面的錯誤,每次都可以得到唯一的序列號。
/* * 1-2 執行緒安全的數值序列生成器 * */ public class Sequence { private int nextValue; public synchronized int getNext() { return nextValue++; } public static void main(String[] args) { Sequence sequence = new Sequence(); for(int i = 0; i < 10; i++){ Thread t = new Thread(() -> { System.out.print(sequence.getNext() + "\t"); }); t.start(); } } }
執行緒安全性
從非正式意義上來說,物件的狀態是指儲存在狀態變數(例如例項和靜態域)中的資料。共享
意味著變數可以由多個執行緒同時訪問,而可變
意味著變數的值可以在生命週期內變化。
當多個執行緒訪問某個狀態變數並且其中有一個執行緒執行寫入操作,必須採用同步
機制來協同這些執行緒對變數的訪問。Java 中的主要同步機制是關鍵字 synchronized
,它提供了一種獨佔的加鎖方式,但“同步”這個術語還包括 volatile 型別的變數,顯式鎖(Explicit Lock) 以及原子變數。
如果當多個執行緒訪問同一個可變的狀態變數時沒有使用合適的同步,那麼程式就會出現錯誤,有三種方式可以修復這個問題:
- 不線上程中共享該狀態變數
- 將該狀態變數設定為不可變的變數
- 在訪問狀態變數時使用同步
在編寫併發程式時,一種正常的程式設計方法就是:首先使程式碼正確執行,然後提高程式碼的速度。
什麼是執行緒安全性
線上程安全性的定義中最核心的概念就是正確性,正確性的含義是,某個類的行為與其規範完全一致。在良好的規範中通常會定義各種不變性條件(Invariant)來約束物件的狀態,以及定義各種後驗條件(Post condition)來描述物件操作的結果。當多執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼這個類就是執行緒安全的。
線上程安全的類中封裝了必要的同步機制,因此客戶端無需進一步採取同步措施。
class Request{//Response 和Request 類定義一樣
int value;
//構造、setter、getter 省略
}
/*
* 2-1 一個無狀態的 Servlet
* 執行緒安全
* */
public class AdderServlet{
public void service(Request request, Response response){
int value = request.getValue();
System.out.println("Init Value: " + value);
value += 6;
System.out.println("Modified Value: " + value);
request.setValue(value);
}
}
與大多數 Servlet 相同,AdderServlet 是無狀態的:它既不包含任何域,也不包含對任何其他類中域的引用。計算過程中的臨時狀態僅存在於執行緒棧上的區域性變數中,並且只能由正在執行的執行緒訪問。由於執行緒訪問無狀態物件的行為並不會影響其他執行緒中操作的正確性,因此無狀態物件是執行緒安全的。
無狀態物件一定是執行緒安全的
原子性
當我們在無狀態物件中增加一個狀態時,會出現什麼情況?我們在 Servlet 中增加一個 long 型別的域,用它來統計請求的次數。
/*
* 2-2 在沒有同步的情況下統計已請求數量的 Servlet
* 非執行緒安全
* */
public class UnsafeAdderServlet {
private long count;
public void service(Request request, Response response){
int value = request.getValue();
System.out.println("Init Value: " + value);
value += 6;
System.out.println("Modified Value: " + value);
response.setValue(value);
++count;
}
public long getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
Request req = new Request(11);
Response resp = new Response();
UnsafeAdderServlet servlet = new UnsafeAdderServlet();
for(int i = 0; i < 200000; i++){
new Thread(() -> {
servlet.service(req,resp);
System.out.println(servlet.getCount());
}).start();
}
}
}
我們呼叫 service 方法 200,000 次,最後的 count 也應該是 200,000 ,一次的執行結果卻是 199,999。在併發量高的時候,count 值出現了偏差,這是因為自增操作包含三個獨立的操作:讀取 - 修改 - 寫入,結果狀態依賴於前面的狀態。如果兩個執行緒在沒有同步的情況下對 count 變數進行自增操作,可能會帶來偏差。以 count 初值為 9 為例:
Thread 1: read(9) --> modify(9 + 1 = 10) --> wirteback(10)
Thread 2: read(9) --> modify(9 + 1 = 10) --> wirteback(10)
最終 count 的值為 10,而正確的值為 11 ,這產生了偏差。在併發程式設計中,這種由不正確的時序而出現的不正確的結果是一種非常重要的情況,它有一個正式的名字:競態條件。
當某個計算的正確性取決於多個執行緒的交替執行時序時,那麼就會發生競態條件。最常見的競態條件型別就是“先檢查後執行(Check-Then-Act)”操作,即通過一個可能失效的觀測結果來決定下一步的動作。
使用“先檢查後執行”的一種常見情況就是延遲初始化。延遲初始化的目的是將物件的初始化操作推遲到實際被使用時才進行,同時要確保只被初始化一次。
/*
* 2-3 延遲初始化中的競態條件
* 非執行緒安全
* */
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() throws InterruptedException {
if(instance == null){
instance = new ExpensiveObject();
}
return instance;
}
public static void main(String[] args) {
LazyInitRace lazyInitRace = new LazyInitRace();
for(int i = 0; i < 3; i++){
new Thread(() ->{
try {
System.out.println(lazyInitRace.getInstance());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
class ExpensiveObject{
public ExpensiveObject() throws InterruptedException {
//假設建立物件時間為 200 ms,增加錯誤機率
Thread.sleep(200);
}
}
執行結果:
concurrencyinpractice.chap2.ExpensiveObject@674827d5
concurrencyinpractice.chap2.ExpensiveObject@674827d5
concurrencyinpractice.chap2.ExpensiveObject@22673956
字元@後面的十六進位制數就是物件的雜湊碼值,在對同一個物件多次呼叫 hashcode 方法時,雜湊碼值應該不會改變,結果中出現了兩個不同的雜湊碼值說明我們呼叫三次 getInstance 方法時,instance 被初始化了兩次,這不是我們想要的結果。
為了保證執行緒安全性,“先檢查後操作” 和 “讀取 - 修改 - 寫入” 操作必須是原子的,我們稱這類操作為複合操作。可以使用鎖來保證複合操作以原子方式執行,這裡我們使用原子變數來修復 UnsafeAdderServlet 的錯誤。
import java.util.concurrent.atomic.AtomicLong;
/*
* 2-4 使用 AtomicLong 型別的變數來統計已處理請求的數量
* 執行緒安全
* */
public class SafeAdderServlet {
private AtomicLong count = new AtomicLong(0);
public void service(Request request, Response response){
int value = request.getValue();
System.out.println("Init Value: " + value);
value += 6;
System.out.println("Modified Value: " + value);
response.setValue(value);
count.incrementAndGet();
}
public long getCount(){
return count.get();
}
public static void main(String[] args){
Request req = new Request(11);
Response resp = new Response();
SafeAdderServlet servlet = new SafeAdderServlet();
for(int i = 0; i < 500000; i++){
new Thread(() -> {
servlet.service(req,resp);
System.out.println(servlet.getCount());
}).start();
}
}
}
執行程式,我們看到最後的count 值為 500,000 與請求的次數相同。通過使用 AtomicLong 來代替 long 型別的計數器,能夠確保所有對計數器狀態的訪問都是原子的。
當在無狀態的類中新增一個狀態時,如果該狀態完全由執行緒安全的物件來管理,那麼這個類仍是執行緒安全的。
在實際情況中,應儘可能地使用現有的執行緒安全物件(例如 AtomicLong)來管理類的狀態。
我們希望提升 Servlet 的效能,將最近計算的結果快取起來,當兩個相同的請求數值到來時,可以直接使用上一次的計算結果,而無須重新計算。
我們通過 AtomicReference 來管理最近執行的數值和結果,它能保證執行緒安全性嗎?
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicReference;
/*
* 2-5 在沒有足夠原子性保證的情況下對最近計算結果進行快取
* 非執行緒安全
* */
public class UnsafeCachingAdderServlet {
private final AtomicReference<Integer> lastNumber = new AtomicReference<>();
private final AtomicReference<Integer> lastResult = new AtomicReference<>();
public void service(Request request, Response response) throws InterruptedException {
print();
if(request.equals(lastNumber.get())){
response.setValue(lastResult.get());
}else{
response.setValue(request.getValue() + 6);
lastNumber.set(request.getValue());
Thread.sleep(3);
lastResult.set(response.getValue());
}
}
public void print(){
System.out.println("lastNumber: " + lastNumber + "\t lastResult: " + lastResult);
}
public static void main(String[] args) {
UnsafeCachingAdderServlet servlet = new UnsafeCachingAdderServlet();
Response response = new Response();
for(int i = 0; i < 50; i++){
new Thread(() -> {
try {
servlet.service(new Request((int)(Math.random() * 100)), response);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
部分執行結果:
lastNumber: 18 lastResult: null
lastNumber: 33 lastResult: 27
lastNumber: 42 lastResult: 83
這部分結果中,只有第二行是我們期待的結果,其他兩行的 result != number + 6。程式碼中的兩組操作(見註釋)每一個操作由兩個原子操作組成,但這兩個原子操作直接序列,不加以同步,這組操作仍是非執行緒安全的,因為在這兩個原子操作中可能有其他執行緒修改了 AtomicReference 指向的值,這破壞了不變性條件。
要保持狀態的一致性,就要在單個原子操作中更新所有相關的狀態變數。
加鎖機制
Java 提供了一種內建的鎖機制來支援原子性:同步程式碼塊(Synchronized Block)。同步程式碼塊包括兩部分:一個作為鎖的物件引用,一個作為由這個鎖保護的程式碼塊。以 synchronized 來修飾的方法就是一種橫跨整個方法體的同步程式碼塊,該同步程式碼塊的鎖就是方法呼叫所在的物件。靜態的 synchronized 方法以 Class 物件作為鎖。
每個 Java 物件都可以用做一個實現同步的鎖,這些鎖被稱為內建鎖(Intrinsic Lock)或監視器鎖(Monitor Lock)。執行緒在進入同步程式碼塊之前會自動獲得鎖,並且在退出同步程式碼塊時自動釋放鎖。
在程式 2-6 中使用 synchronized 修飾 service 方法,在同一時刻只能有一個執行緒可以使用 service 方法,服務的響應性非常低。
/*
* 2-6 能正確地快取最新的計算結果,但併發性非常糟糕(不要這麼做)
* 執行緒安全
* */
public class SynchronizedAdderServlet {
private Integer lastNumber;
private Integer lastResult;
public synchronized void service(Request request, Response response){
print();
if(request.equals(lastNumber)){
response.setValue(lastNumber);
}else{
lastNumber = request.getValue();
response.setValue(request.getValue() + 6);
lastResult = response.getValue();
}
}
public void print(){
System.out.println("lastNumber: " + lastNumber + "\t lastResult: " + lastResult);
}
public static void main(String[] args) {
SynchronizedAdderServlet servlet = new SynchronizedAdderServlet();
Response response = new Response();
for(int i = 0; i < 50; i++){
new Thread(() -> {
servlet.service(new Request((int)(Math.random() * 100)), response);
}).start();
}
}
}
當某個執行緒請求一個由其他執行緒持有的鎖時,發出請求的執行緒就會阻塞。然而,由於內建鎖是可重入的,因此如果某個執行緒試圖獲得一個已經由它自己持有的鎖,這個請求就會成功。“重入”意味著獲取鎖的操作的粒度是“執行緒”而不是“呼叫”。重入的一種實現方法是,為每個鎖關聯一個獲取計數值和一個所有者執行緒,當計數值為 0 時,這個鎖未被任何執行緒持有。當執行緒請求一個未被持有的鎖時,JVM 將記下鎖的持有者,並將計數值置為 1 ,如果同一個執行緒再次獲取這個數,計數值將會遞增。
程式 2-7 中,子類改寫了父類的 synchronized 方法,然後呼叫父類中的方法如果沒有可重入的鎖,那麼這段程式碼將產生死鎖。由於 Widget 和 LoggingWidget 中的 doSomething 方法都是 synchronized 方法,因此每個 doSomething 方法在執行前都會獲取 Widget 上的鎖。如果內建鎖是不可重入的,那麼呼叫 super.doSomething 時無法獲得 Widget 上的鎖,這個鎖已經被持有,執行緒將永遠停頓下去。
/*
* 2-7 如果內建鎖是不可重入的,這段程式碼會發生死鎖
* 執行緒安全
* */
class Widget{
public synchronized void doSomething(){
System.out.println("Widget::doSomething()");
}
}
public class LoggingWidget extends Widget{
@Override
public synchronized void doSomething() {
System.out.println("LoggingWidget::doSomething()");
super.doSomething();
}
public static void main(String[] args) {
LoggingWidget widget = new LoggingWidget();
widget.doSomething();
}
}
用鎖來保護狀態
由於鎖能使其保護的程式碼路徑以穿行形式來訪問,因此可以通過鎖來構造一些協議以實現對共享狀態的獨佔訪問。只要始終遵循這些協議,就能保證狀態的一致性。
訪問共享狀態的複合操作都必須是原子操作以避免產生競態條件。如果複合操作在執行過程中持有一個鎖,那麼會使複合操作成為原子操作。然而,僅僅將複合操作封裝到同步程式碼塊中是不夠的。如果使用鎖來協調對某個變數的訪問時,在訪問變數的所有位置上都要使用同一個鎖。一種常見的錯誤認為:只有在寫入共享變數時才需要同步,然而並非如此。(見3.1節)
對於可能被多個執行緒同時訪問的可變狀態變數,在訪問它時都需要持有同一個鎖,在這種情況下,我們稱狀態變數是由這個鎖保護的。
一種常見的加鎖約定是,將所有的可變狀態都封裝在物件內部,並通過物件的內建鎖對所有訪問可變狀態的程式碼路徑進行同步,使得在該物件上不會發生併發訪問。在許多執行緒安全類中都使用了這種模式,例如 Vector 和其他同步集合類。
每個共享和可變的變數都應該只由一個鎖來保護,從而使維護人員知道是哪一個鎖在保護變數。
只有被多個執行緒同時訪問的可變資料才需要通過鎖來保護。
對於每個包含多個變數的不變性條件,其中涉及的所有變數都需要同一個鎖來保護。
活躍性與效能
在 UnsafeCachingAdderServlet 中,我們引入了快取來提升效能,在快取中需要使用共享狀態,因此需要通過同步來維護狀態的完整性。然而,如果使用 SynchronizedAdderServlet 中的同步方式,那麼程式碼的執行效能將會十分糟糕。它通過 Servlet 物件的內建鎖來保護每一個狀態變數,這種簡單且粗粒度的方法能保證執行緒安全性,但讀出的代價很高。由於 service 是一個 synchronized 方法,因此每次只有一個執行緒可以執行,這背離了Servlet 框架的初衷,即 Servlet 需要能同時處理多個請求,這在負載過高的情況下將給使用者帶來糟糕的體驗。
程式2-8中將 Servlet 的程式碼修改為兩個獨立的程式碼塊,第一個程式碼塊執行“先檢查後執行”序列,另一個程式碼塊負責對快取更新。
/*
* 2-8 快取最近計算的數值積計算結果的Servlet
* 執行緒安全
* */
public class CachedAdderServlet {
private Integer lastNumber;
private Integer lastResult;
private long hits;
private long cacheHits;
public synchronized long getHits(){
return hits;
}
public synchronized double getCacheHitRatio() {
return (double)cacheHits / (double)hits;
}
public void service(Request request, Response response){
//不要把從 request 提取數值等耗時操作放在同步程式碼塊中
int num = request.getValue();
int result = Integer.MIN_VALUE;
//判斷是否命中快取
synchronized (this){
++hits;
if(lastNumber != null && num == lastNumber){
++cacheHits;
result = lastResult;
}
}
//沒有命中快取就更新快取的值
if(result == Integer.MIN_VALUE){
//計算結果
result = request.getValue() + 6;
synchronized (this){//更新快取
lastNumber = request.getValue();
lastResult = result;
}
}
response.setValue(result);
}
public static void main(String[] args) {
CachedAdderServlet servlet = new CachedAdderServlet();
Response response = new Response();
for(int i = 0; i < 10; i++){
new Thread(() -> {
servlet.service(new Request((int)(Math.random() * 4)), response);
System.out.println("Cache Hit Ratio: " + servlet.getCacheHitRatio());
}).start();
}
}
}
通常,在簡單性與效能之間存在相互制約的因素。當實現某個同步策略時,一定不要盲目地為了效能而犧牲簡單性(這可能破壞安全性)。
當使用鎖時,你應該清楚程式碼塊中實現的功能,以及在執行該程式碼塊時是否需要很長的時間。無論執行計算密集的操作,還是執行某個可能阻塞的操作,如果持有鎖的時間過長,那麼都會帶來活躍性問題。
當執行時間較長的計算或者可能無法快速完成的操作時(例如,網路 I/O 或控制檯 I/O ),一定不要持有鎖。
物件的共享
要編寫正確的併發程式,關鍵問題在於:在訪問共享的可變狀態時需要進行正確的管理。我們已經知道同步程式碼塊和同步方法可以確保以原子的方式執行操作,但一種常見的誤解是,認為關鍵字 synchronized 只能用於實現原子性。同步還有一個重要的方面:記憶體可見性(Memory Visibility)。我們不僅希望防止某個執行緒正在使用物件狀態而另一個執行緒在同時修改該狀態,而且希望確保當一個執行緒修改了物件狀態後,其他執行緒能夠看到發生的狀態變化。
可見性
可見性是一種複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺。有多個讀執行緒和寫執行緒同時對一個變數進行操作,讀執行緒可能讀到的是過期的資料,我們無法確保讀執行緒能適時地看到其他執行緒寫入的值,有時甚至是不可能的事情。為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步。
程式 3-1 中說明了當多個執行緒在沒有同步的情況下共享資料時出現的錯誤。在程式碼中,主執行緒和讀執行緒都將訪問共享變數 ready 和 number。主執行緒啟動讀執行緒,然後將 numer 設為 42,並將 ready 設為 true。讀執行緒一直迴圈知道發現 ready 的值變為 true 然後輸出number。雖然看起來可能會輸出 42(運行了好多次,都是 42。。),但事實上很可能輸出 0 ,或者根本無法終止。這是因為在程式碼中沒有使用足夠的同步機制,因此無法保證主執行緒寫入的 ready值和 number 值對於讀執行緒來說是可見的。
/*
* 3-1 在沒有同步的情況下共享變數
* 非執行緒安全
* */
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread{
@Override
public void run() {
while(!ready){
//暫停當前正在執行的執行緒物件(及放棄當前擁有的cpu資源),並執行其他執行緒
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args){
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility 可能會輸出 0 ,因為讀執行緒看到了 ready 的值,但沒有看到 number 的值,這種現象稱為重排序(Reordering)。
在沒有同步的情況下,編譯器、處理器以及執行時都可能對操作的執行順序進行意向不到的調整。只要有資料在多個執行緒之間共享,就使用正確的同步。
NoVisibility 展示了在缺乏同步的程式中可能產生錯誤結果的一種情況:失效資料。當讀執行緒檢視 ready 變數時,可能會得到一個已經失效的值。更糟糕的是,可能獲得一個變數的最新值而獲得另一個變數的失效值。失效資料還可能導致一些令人困惑的故障,例如意料之外的異常、被破壞的資料結構、不精確的計算以及無限迴圈等。
程式 3-2 中的 MutableInteger 不是執行緒安全的,get 和 set 都是在沒有同步的情況下訪問 value的。
/*
* 3-2 非執行緒安全的可變整數類
* */
public class MutableInteger {
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
程式 3-3 SynchronizedInteger 通過對 get 和 set 方法進行同步,可以使之成為一個執行緒安全的類。僅對 set 方法進行同步是不夠的,呼叫 get 的執行緒仍然會看見失效值。
/*
* 3-3 執行緒安全的可變整數類
* */
public class SynchronizedInteger {
private int value;
public synchronized int getValue() {
return value;
}
public synchronized void setValue(int value) {
this.value = value;
}
}
當執行緒在沒有同步的情況下讀取變數時,可能會得到一個失效值,但至少這個值是由之前某個執行緒設定的值,而不是一個隨機值。這種安全性保證也被稱為最低安全性。
最低安全性適用於絕大多數變數,但有一個例外:非 volatile 型別的 64 位數值變數(double 和 long)。Java 記憶體模型要求,變數的讀取和寫入操作必須是原子操作,但對於非 volatile 型別的 long 和 double 變數,JVM 允許將64 位的讀寫操作分為兩個 32 位的操作。當讀取到一個新值的高 32 位 和 舊值的低 32 位組合的 64 位數時,就出現了錯誤。
在多執行緒程式中使用共享且可變的 long 和 double 等型別的變數也是不安全的,除非用 volatile 來宣告它們,或者用鎖保護起來。
內建鎖可以用於確保某個執行緒以一種可預測的方式來檢視另一個執行緒的執行結果。當後一個執行緒執行由鎖保護的同步程式碼塊時,可以看到前一個執行緒之前在同一個同步程式碼塊中的所有操作結果。
加鎖的含義不僅僅侷限於互斥行為,還包括記憶體可見性。為了確保所有執行緒都能看到共享變數的最新值,所有執行讀操作或者寫操作的執行緒都必須在同一個鎖上同步。
Java 語言提供了一種稍弱的同步機制,即 volatile 變數,用來確保將變數的更新操作通知到其他執行緒。當把變數宣告為 volatile 型別後,編譯器與執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile 變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取 volatile 型別的變數時總會返回最新寫入的值。
在訪問 volatile 變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此 volatile 變數是一種比 synchronized 關鍵字更輕量級的同步機制。從記憶體可見性的角度來看,寫入 volatile 變數相當於退出同步程式碼塊,而讀取 volatile 變數相當於進入同步程式碼塊。
僅當 volatile 變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用它們。如果在驗證正確性時需要對可見性進行復雜的判斷,那麼就不要使用 volatile 變數。
程式 3-4 給出了 volatile 變數的一種典型用法:檢查某個狀態標記以判斷是否退出迴圈。
/*
* 3-4 數綿羊
* */
volatile boolean asleep;
...
while(!asleep)
countSomeSheep();
volatile 的語義不足以保證遞增操作的原子性(count),除非你能確保只有一個執行緒對變數執行寫操作。(如果存在兩個執行緒對變數進行寫操作,需要一種機制來保持這兩個執行緒之間的互斥關係,但 volatile 只能保證可見性,不能保證原子性)
加鎖機制既可以確保可見性又可以保證原子性,volatile 變數只能保證可見性。
當且僅當滿足以下所有條件時,才應該使用 volatile 變數:
- 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值
- 該變數不會與其他狀態變數一起納入不變性條件中
- 在訪問變數時不需要加鎖
釋出與逸出
“釋出(Publish)”一個物件指,使物件能夠在當前作用域之外的程式碼中使用。“逸出(Escape)”指,當某個不應該釋出的物件被髮布。
釋出物件最簡單的方法是將物件的引用儲存到一個公有的靜態變數中,以便任何類和執行緒都能看見該物件。如 3-5 所示。
/*
* 3-5 釋出一個物件
*/
public static Set<Secret> knownSecrets;
public void initialize(){
knownSecrets = new HashSet<Secret>();
}
當釋出某個物件時,可能會間接地釋出其他物件。如果將一個 Secret 物件新增到集合 knownSecrets 中,那麼同樣會釋出這個物件,因為任何程式碼都能遍歷這個集合,獲得Secret 物件的引用。
/*
* 3-6 使內部的可變狀態逸出(不要這麼做)
* */
class UnsafeStates{
private String[] states = new String[]{
"AK", "AL" ...
};
public String[] getStates(){return states; }
}
上述程式碼中,任何呼叫者都能修改 states 陣列的內容,陣列states已經逸出了它所在的作用域。
當釋出一個物件時,在該物件的非私有域中引用的所有物件同樣會被髮布。
最後一種釋出物件或其內部狀態的機制就是釋出一個內部類的例項。3-7 中,當 ThisEscape 釋出 EventListener 時,也隱含地釋出了 ThisEscape 例項本身,因為在這個內部類例項中包含了對 ThisEscape 例項地隱含引用。
/*
* 3-7 隱式地使 this 引用逸出(不要這麼做)
* */
public class ThisEscape{
public ThisEscape(EventSource source){
source.registerListener(new EventListener(){
public void onEvent(Event e){
doSomething(e);
}
});
}
}
在 ThisEscape 中給出了逸出地一個特殊示例,即 this 引用在建構函式中逸出。當從物件的建構函式中釋出物件時,只是釋出了一個尚未構造完成的物件。在構造過程中使 this 引用逸出的一個常見錯誤是:在建構函式中啟動一個執行緒。如果想在建構函式中註冊一個時間監聽器或啟動執行緒,可以使用一個私有的建構函式和一個公共的工廠方法,從而避免不正確的構造過程。
/*
* 3-8 使用工廠方法來防止 this 引用在構造過程中逸出
* */
public class SafeListener{
private final EventListener listener;
private SafeListener(){
listener = new EventListener(){
public void onEvent(Event e){
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source){
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
}
執行緒封閉
當訪問共享的可變資料時,通常需要使用同步。一種避免使用同步的方式就是不共享資料。如果盡在單執行緒內訪問資料,就不需要同步。這種技術被稱為執行緒封閉(Thread Confinement)。
棧封閉是執行緒封閉的一種特例,在棧封閉中,只能通過區域性變數才能訪問物件。區域性變數的固有屬性之一就是封閉在執行執行緒中。它們位於執行執行緒的棧中,其他執行緒無法訪問。
/*
* 3-9 基本型別的區域性變數與引用變數的執行緒封閉性
*/
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals 被封閉在方法中,不要使它們逸出
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
如果釋出了對 animals 的引用,那麼執行緒封閉性將被破壞,並導致物件 animals 的逸出。
維持執行緒封閉性的一種更規範方法是使用 ThreadLocal,這個類能使執行緒中的某個值與儲存值的物件關聯起來。ThreadLocal 物件通常用於防止對可變的單例項變數(Singleton)或全域性變數進行共享。
例如,在單執行緒程式中可能維持一個全域性的資料庫連線,並在程式啟動時初始化這個連線,由於 JDBC 連線物件不一定是執行緒安全的。通過將 JDBC 的連線儲存到 ThreadLocal 物件中,每個執行緒都會擁有屬於自己的連線。
/*
* 3-10 使用 ThreadLocal 來維持執行緒封閉性
* */
class Connection{
}
public class ThreadLocalDemo {
private static ThreadLocal<Connection> connectionHandler= new ThreadLocal<Connection>(){
@Override
protected Connection initialValue() {
return new Connection();
}
};
public static Connection getConnection(){
//當第一次呼叫 get 方法時,initialValue 將會被呼叫
return connectionHandler.get();
}
}
當某個頻繁執行的操作需要一個臨時物件,例如一個緩衝區,而同時又希望避免在每次執行時都重新分配該臨時物件,就可以使用這項技術。
ThreadLocal 變數類似於全域性變數,它能降低程式碼的可重用性,並在類之間引入隱含的耦合性,因此在使用時要格外小心。
不變性
滿足同步需求的另一種方法是使用不可變物件(Immutable Object)。如果某個物件在被建立後其狀態就不能被修改,那麼這個物件就稱為不可變物件。執行緒安全性是不可變物件的固有屬性,它們的不變性條件是由建構函式建立的,只要它們的狀態不改變,那麼這些不變性條件就能得以維持。
不可變物件一定是執行緒安全的。
當滿足以下條件時,物件才是不可變的:
- 物件建立以後其狀態不能改變
- 物件的所有域都是 final 型別
- 物件是正確建立的(在建立物件期間 this 引用沒有逸出)
在不可變物件的內部仍可以使用可變物件來管理它們的狀態,如程式 3-11 所示,但是其中的 Set 物件在構造完成之後無法對其進行修改。
/*
* 3-11 在可變物件基礎上構建的不可變類
*/
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
}
關鍵字 final 可以視為 C++ 中 const 機制的一種受限版本,用於構造不可變物件。final 型別的域是不能修改的(但如果 final 域所引用的物件是可變的,那麼這些被引用的物件是可以修改的)。final 域能確保初始化過程的安全性,從而可以不受限制地訪問不可變物件,並在共享這些物件時無須同步。
正如“除非需要更高的可見性,否則應將所有的域都宣告為私有域”是一個良好的程式設計習慣,“除非需要某個域是可變的,否則應將其宣告為 final 域”也是一個良好的程式設計習慣。
我們看一個因式分解 Servlet,它包含兩個原子操作:更新快取的結果,以及判斷快取中的數值是否等於請求的數值。每當需要對一組相關資料以原子方式執行某個操作時,就可以考慮建立一個不可變的類來包含這些資料。例如 3-12 的 OneValueCache。
/*
* 3-12 對數值及其因數分解結果進行快取的不可變容器類
*/
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i,
BigInteger[] factors) {
lastNumber = i;
//如果沒有呼叫 copyOf 函式,那麼 OneValue 就是不可變的
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
對於在訪問和更新多個相關變數時出現的競爭條件問題,可以通過將這些變數全部儲存在一個不可變物件中來消除。
程式 3-13 中的 VolatileCachedFactorizer 使用了 OneValueCache 來儲存快取的數值及其因數。當一個執行緒將 volatile 型別的 cache 設定為引用一個新的 OneValueCache 時,其他執行緒就會立即看到最新快取的資料。
/*
* 3-13 使用指向不可變容器物件的 volatile 型別引用以快取最新的結果
*/
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
//更新快取
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
// Doesn't really factor
return new BigInteger[]{i};
}
}
安全釋出
在某些情況下我們希望在多個執行緒間共享物件,此時必須確保安全地進行共享。如果像程式 3-14 那樣將物件引用儲存到公有域中,那麼還不足以安全地釋出這個物件。
/*
* 3-14 在沒有足夠同步的情況下發布物件(不要這麼做)
*/
public class StuffIntoPublic {
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
}
由於存在可見性問題,其他執行緒看到的 Holder 物件將處於不一致的狀態,即使該物件的建構函式中已經正確地構造了不變性條件。這種不正確地釋出將導致其他執行緒看到尚未建立完成的物件。
/*
* 3-15 由於未被正確釋出,因此這個類可能出現故障
*/
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
由於沒有使用同步來確保 Holder 物件對其他執行緒可見,因此將 Holder 稱為“未被正確釋出”。除了釋出物件的執行緒外,其他執行緒可以看到的 Holder 域是一個失效值,因此將看到一個空引用或之前的舊值。
任何執行緒都可以在不需要額外同步的情況下安全地訪問不可變物件,即使在釋出這些物件時沒有使用同步。
在沒有額外同步地情況下,也可以安全地訪問 final 型別的域。然而,如果 final 型別的域所指向的是可變物件,那麼在訪問這些域所指向的物件的狀態是仍然需要同步。
要安全地釋出一個物件,物件的引用以及物件的狀態必須同時對其他執行緒可見。一個正確構造的物件可以通過以下方式來安全地釋出:
- 在靜態初始化函式中初始化一個物件引用。
- 將物件的引用儲存到 volatile 型別的域或者 AtomicReference 物件中。
- 將物件的引用儲存到某個正確構造物件的 final 型別域中。
- 將物件的引用儲存到一個由鎖保護的域中。
如果物件從技術上來看是可變的,但其狀態在釋出後不會再改變,那麼把這種物件叫做“事實不可變物件(Effectively Immutable Object)”。
在沒有額外同步的情況下,任何執行緒都可以安全得使用被安全釋出得事實不可變物件。
如果物件在構造後可以修改,那麼安全釋出只能確保“釋出當時”狀態得可見性。對於可變物件,不僅在釋出物件時需要使用同步,而且在每次物件訪問時同樣需要使用同步來確保繼續修改操作的可見性。
物件的釋出需求取決於它的可變性:
- 不可變物件可以通過任意機制來發布
- 事實不可變物件必須通過安全方式釋出
- 可變物件必須通過安全方式釋出,並且必須是執行緒安全的或者由某個鎖保護起來
在併發程式中使用和共享物件時,可以使用一些實用的策略,包括:
- 執行緒封閉。執行緒封閉的物件只能由一個而執行緒擁有,物件被封閉在該執行緒中,並且只能由這個執行緒修改。
- 只讀共享。在沒有額外同步的情況下,共享的只讀物件可以由多個執行緒併發訪問,但任何執行緒都不能修改它。共享的只讀物件包括不可變物件和事實不可變物件。
- 執行緒安全共享。執行緒安全的物件在其內部實現同步,因此多個執行緒可以通過物件的公有介面來進行訪問而不需要進一步同步。
- 保護物件。被保護的物件只能通過持有特定的鎖來訪問。保護物件包括封裝線上程安全物件中的物件,以及已釋出的並且由某個特定鎖保護的物件。