共享變量
要使多個線程在一個程序中有用,它們必須有某種方法可以互相通信或共享它們的結果。
讓線程共享其結果的最簡單方法是使用共享變量。它們還應該使用同步來確保值從一個線程正確傳播到另一個線程,以及防止當一個線程正在更新一些相關數據項時,另一個線程看到不一致的中間結果。
線程基礎中計算素數的示例使用了一個共享布爾變量,用於表示指定的時間段已經過去了。這說明了在線程間共享數據最簡單的形式是:輪詢共享變量以查看另一個線程是否已經完成執行某項任務。
存在於同一個內存空間中的所有線程
正如前面討論過的,線程與進程有許多共同點,不同的是線程與同一進程中的其它線程共享相同的進程上下文,包括內存。這非常便利,但也有重大責任。只要訪問共享變量(靜態或實例字段),線程就可以方便地互相交換數據,但線程還必須確保它們以受控的方式訪問共享變量,以免它們互相干擾對方的更改。
任何線程可以訪問所有其作用域內的變量,就象主線程可以訪問該變量一樣。素數示例使用了一個公用實例字段,叫做 finished,用於表示已經過了指定的時間。當計時器過期時,一個線程會寫這個字段;另一個線程會定期讀取這個字段,以檢查它是否應該停止。注:這個字段被聲明成 volatile,這對於這個程序的正確運行非常重要。在本章的後面,我們將看到原因。
受控訪問的同步
為了確保可以在線程之間以受控方式共享數據,Java 語言提供了兩個關鍵字:synchronized 和 volatile。
Synchronized 有兩個重要含義:它確保了一次只有一個線程可以執行代碼的受保護部分(互斥,mutual exclusion 或者說 mutex),而且它確保了一個線程更改的數據對於其它線程是可見的(更改的可見性)。
如果沒有同步,數據很容易就處於不一致狀態。例如,如果一個線程正在更新兩個相關值(比如,粒子的位置和速率),而另一個線程正在讀取這兩個值,有可能在第一個線程只寫了一個值,還沒有寫另一個值的時候,調度第二個線程運行,這樣它就會看到一個舊值和一個新值。同步讓我們可以定義必須原子地運行的代碼塊,這樣對於其他線程而言,它們要麼都執行,要麼都不執行。
同步的原子執行或互斥方面類似於其它操作環境中的臨界段的概念。
確保共享數據更改的可見性
同步可以讓我們確保線程看到一致的內存視圖。
處理器可以使用高速緩存加速對內存的訪問(或者編譯器可以將值存儲到寄存器中以便進行更快的訪問)。在一些多處理器體系結構上,如果在一個處理器的高速緩存中修改了內存位置,沒有必要讓其它處理器看到這一修改,直到刷新了寫入器的高速緩存並且使讀取器的高速緩存無效。
這表示在這樣的系統上,對於同一變量,在兩個不同處理器上執行的兩個線程可能會看到兩個不同的值!這聽起來很嚇人,但它卻很常見。它只是表示在訪問其它線程使用或修改的數據時,必須遵循某些規則。
Volatile 比同步更簡單,只適合於控制對基本變量(整數、布爾變量等)的單個實例的訪問。當一個變量被聲明成 volatile,任何對該變量的寫操作都會繞過高速緩存,直接寫入主內存,而任何對該變量的讀取也都繞過高速緩存,直接取自主內存。這表示所有線程在任何時候看到的 volatile 變量值都相同。
如果沒有正確的同步,線程可能會看到舊的變量值,或者引起其它形式的數據損壞。
用鎖保護的原子代碼塊
Volatile 對於確保每個線程看到最新的變量值非常有用,但有時我們需要保護比較大的代碼片段,如涉及更新多個變量的片段。
同步使用監控器(monitor)或鎖的概念,以協調對特定代碼塊的訪問。
每個 Java 對象都有一個相關的鎖。同一時間只能有一個線程持有 Java 鎖。當線程進入 synchronized 代碼塊時,線程會阻塞並等待,直到鎖可用,當它可用時,就會獲得這個鎖,然後執行代碼塊。當控制退出受保護的代碼塊時,即到達了代碼塊末尾或者拋出了沒有在 synchronized 塊中捕獲的異常時,它就會釋放該鎖。
這樣,每次只有一個線程可以執行受給定監控器保護的代碼塊。從其它線程的角度看,該代碼塊可以看作是原子的,它要麼全部執行,要麼根本不執行。
簡單的同步示例
使用 synchronized 塊可以讓您將一組相關更新作為一個集合來執行,而不必擔心其它線程中斷或看到計算的中間結果。以下示例代碼將打印“1 0”或“0 1”。如果沒有同步,它還會打印“1 1”(或“0 0”,隨便您信不信)。
public class SyncExample {
private static lockObject = new Object();
private static class Thread1 extends Thread {
public void run() {
synchronized (lockObject) {
x = y = 0;
System.out.println(x);
}
}
}
private static class Thread2 extends Thread {
public void run() {
synchronized (lockObject) {
x = y = 1;
System.out.println(y);
}
}
}
public static void main(String[] args) {
new Thread1().run();
new Thread2().run();
}
}
在這兩個線程中都必須使用同步,以便使這個程序正確工作。
Java 鎖定
Java 鎖定合並了一種互斥形式。每次只有一個線程可以持有鎖。鎖用於保護代碼塊或整個方法,必須記住是鎖的身份保護了代碼塊,而不是代碼塊本身,這一點很重要。一個鎖可以保護許多代碼塊或方法。
反之,僅僅因為代碼塊由鎖保護並不表示兩個線程不能同時執行該代碼塊。它只表示如果兩個線程正在等待相同的鎖,則它們不能同時執行該代碼。
在以下示例中,兩個線程可以同時不受限制地執行 setLastAccess() 中的 synchronized 塊,因為每個線程有一個不同的 thingie 值。因此,synchronized 代碼塊受到兩個正在執行的線程中不同鎖的保護。
public class SyncExample {
public static class Thingie {
private Date lastAccess;
public synchronized void setLastAccess(Date date) {
this.lastAccess = date;
}
}
public static class MyThread extends Thread {
private Thingie thingie;
public MyThread(Thingie thingie) {
this.thingie = thingie;
}
public void run() {
thingie.setLastAccess(new Date());
}
}
public static void main() {
Thingie thingie1 = new Thingie(),
thingie2 = new Thingie();
new MyThread(thingie1).start();
new MyThread(thingie2).start();
}
}
同步的方法
創建 synchronized 塊的最簡單方法是將方法聲明成 synchronized。這表示在進入方法主體之前,調用者必須獲得鎖:
public class Point {
public synchronized void setXY(int x, int y) {
this.x = x;
this.y = y;
}
}
對於普通的 synchronized方法,這個鎖是一個對象,將針對它調用方法。對於靜態 synchronized 方法,這個鎖是與 Class 對象相關的監控器,在該對象中聲明了方法。僅僅因為 setXY() 被聲明成 synchronized 並不表示兩個不同的線程不能同時執行 setXY(),只要它們調用不同的 Point 實例的 setXY() 就可同時執行。對於一個 Point 實例,一次只能有一個線程執行 setXY(),或 Point 的任何其它 synchronized 方法。
同步的塊
synchronized 塊的語法比 synchronized 方法稍微復雜一點,因為還需要顯式地指定鎖要保護哪個塊。Point 的以下版本等價於前一頁中顯示的版本:
public class Point {
public void setXY(int x, int y) {
synchronized (this) {
this.x = x;
this.y = y;
}
}
}
使用 this 引用作為鎖很常見,但這並不是必需的。這表示該代碼塊將與這個類中的 synchronized 方法使用同一個鎖。
由於同步防止了多個線程同時執行一個代碼塊,因此性能上就有問題,即使是在單處理器系統上。最好在盡可能最小的需要保護的代碼塊上使用同步。
訪問局部(基於堆棧的)變量從來不需要受到保護,因為它們只能被自己所屬的線程訪問。
大多數類並沒有同步
因為同步會帶來小小的性能損失,大多數通用類,如 java.util 中的 Collection 類,不在內部使用同步。這表示在沒有附加同步的情況下,不能在多個線程中使用諸如 HashMap 這樣的類。
通過每次訪問共享集合中的方法時使用同步,可以在多線程應用程序中使用 Collection 類。對於任何給定的集合,每次必須用同一個鎖進行同步。通常可以選擇集合對象本身作為鎖。
下一頁中的示例類 SimpleCache 顯示了如何使用 HashMap 以線程安全的方式提供高速緩存。但是,通常適當的同步並不只是意味著同步每個方法。
Collections 類提供了一組便利的用於 List、Map 和 Set 接口的封裝器。您可以用 Collections.synchronizedMap 封裝 Map,它將確保所有對該映射的訪問都被正確同步。
如果類的文檔沒有說明它是線程安全的,那麼您必須假設它不是。
示例:簡單的線程安全的高速緩存
如以下代碼樣本所示,SimpleCache.java 使用 HashMap 為對象裝入器提供了一個簡單的高速緩存。load() 方法知道怎樣按對象的鍵裝入對象。在一次裝入對象之後,該對象就被存儲到高速緩存中,這樣以後的訪問就會從高速緩存中檢索它,而不是每次都全部地裝入它。對共享高速緩存的每個訪問都受到 synchronized 塊保護。由於它被正確同步,所以多個線程可以同時調用 getObject 和 clearCache 方法,而沒有數據損壞的風險。
public class SimpleCache {
private final Map cache = new HashMap();
public Object load(String objectName) {
// load the object somehow
}
public void clearCache() {
synchronized (cache) {
cache.clear();
}
}
public Object getObject(String objectName) {
synchronized (cache) {
Object o = cache.get(objectName);
if (o == null) {
o = load(objectName);
cache.put(objectName, o);
}
}
return o;
}
}
小結
由於線程執行的計時是不確定的,我們需要小心,以控制線程對共享數據的訪問。否則,多個並發線程會互相干擾對方的更改,從而損壞數據,或者其它線程也許不能及時看到對共享數據的更改。
通過使用同步來保護對共享變量的訪問,我們可以確保線程以可預料的方式與程序變量進行交互。
每個 Java 對象都可以充當鎖,synchronized 塊可以確保一次只有一個線程執行由給定鎖保護的 synchronized 代碼。