Java併發——共享物件
本篇博文是Java併發程式設計實戰的筆記。
併發程式設計面臨兩個大的問題,一個就是關於共享資料的讀寫訪問該如何同步,還有一個就是如何安全的將一個物件共享出去(給多個執行緒使用)。
可見性
可見性是你在共享一個物件時要縝密考慮的問題,它是說一個執行緒對於一個物件狀態的修改是否能夠及時的被另一個執行緒察覺,如果不能就會發生一些莫名其妙的問題。
可見性問題源於CPU為獲得更高的執行效率而做的一些優化帶來的副作用,這些優化包括快取和指令重排。
快取
老生常談的問題,一旦有兩個元件之間需要通訊並且還要保證高效能的時候就會用到快取。
CPU和記憶體之間存在一些互動,CPU執行程式指令,記憶體儲存程式和程式資料,所以它們之間時常要通訊,而讀取記憶體需要消耗掉CPU的很多個指令週期,在這一段時間,CPU本來能幹很多事。所以快取就來了,CPU會把記憶體中的常用資料搬到離CPU更近,速度更快的快取記憶體或暫存器中,日後再次讀取就不用讀取記憶體了。
所以快取就是:把經常需要用到的東西搬得離我近點兒,下次我用到就不用跑那麼遠了
Java中對上面的CPU和記憶體之間的快取模型的抽象是工作記憶體和主記憶體,每一個執行緒有一個工作記憶體,這個工作記憶體通常就會是暫存器或者快取記憶體,而主記憶體就是我們的實體記憶體。一個執行緒會把主記憶體中的資料搬到它的工作記憶體中使用,以獲得更快的執行速度。
通過上面的模型,我們也看出工作記憶體是執行緒私有的,主記憶體是大家公有的,在這樣的模型裡多執行緒之間如果想要共同讀寫一份共享資料,如果不進行同步,各個執行緒的工作記憶體裡就可能會有不一樣的資料副本,這樣可見性問題就產生了。
比如主記憶體中有個變數x
,執行緒A寫入它之後並未將它及時的寫回到主記憶體,此時執行緒B讀取x
x
的舊值。
不過也不用對Java灰心,這是併發程式設計必須要面臨的問題,Java也提供了很多規範和手段來解決這些問題。
如下的程式碼展示了快取可能引發的可見性問題,在極端情況下,ready
變數設定為true
後,WorkThread
可能永遠不會發現,然後一直這麼執行下去。
class A { private static boolean ready; static class WorkThread extends Thread { @Override public void run() { while(!ready) Thread.yield(); // do something } } public static void main(String[] args) { new WorkThread().start(); ready = true; } }
一旦我們談到併發程式設計,很多東西都是理論上可能發生的,也許它們在某些平臺的JDK上可能根本不會發生。但儘管可能程式執行一百年也不會出現一次這個問題,我們也要了解這些可能性。
指令重排
指令重排是指CPU可能會對程式的位元組碼指令的先後順序進行顛倒執行,而不是順序執行,但指令重排必須保證在重排之後在單執行緒內部看起來程式還是和沒重排過一樣。
舉個例子:
int a = 10;
int b = 20;
print(a);
print(b);
可能被重排成:
int a = 10;
print(a);
int b = 20;
print(b);
但是如下程式碼:
int a = 10;
int b = 3;
b = a * 10;
a = b + 2;
這裡最後b=100
、a=102
,如果按如下順序重排:
int a = 10;
int b = 3;
a = b + 2;
b = a * 10;
最後的結果是a=5
、b=50
,這樣單執行緒的順序性就得不到保證了,所以CPU不能進行上面這種重排序。
重排序出現的一個前提條件是要重排的一條指令的執行效果不依賴另一條指令的執行效果,上面對a
和b
的第二次賦值操作,它們明顯是互相依賴的,所以它們不能被重排序,重排序它們將得到錯誤的執行結果。對於多執行緒程式,指令重排並不保證結果的正確性,所以我們需要自己採用一些同步手段來保證正確。
CPU實際執行的都是它所支援的指令集中的機器指令,而非Java命令,我這樣寫只是為了便於理解。實際上,一條Java指令編譯成Java位元組碼都可能需要好幾條位元組碼指令,更別說最終由虛擬機器實際執行或者由即時編譯器編譯出來的CPU機器指令了。
下面是重排序可能帶來的一種併發錯誤:
class A {
private static boolean ready;
private static int number;
static class WorkThread extends Thread {
@Override
public void run() {
while(!ready) Thread.yield();
// do something
println(number);
}
}
public static void main(String[] args) {
new WorkThread().start();
number = 12;
ready = true;
}
}
在上面的例子中,WorkThread可能打印出number
為0,因為main方法可能碰巧被重排成這樣:
public static void main(String[] args) {
new WorkThread().start();
ready = true;
number = 12;
}
執行完ready=true
後,碰巧WorkThread
中發現了ready
狀態更改,結束迴圈並讀取number
,此時number
是0,因為主方法中的賦值語句尚未執行。
失效資料
失效資料即資料已經被一個執行緒更新,但另一個執行緒讀取到的仍然是舊資料,比如上面的ready
。
非原子的64位操作
Java提供併發共享資料的最低安全性,即保證執行緒可能得到失效資料,但是這個資料一定是之前某個執行緒所設定的
但是對於非volatile的long
和double
,JVM允許它們不是原子的,它們也不受最低安全性保護。
大部分64位商用虛擬機器(如Hotspot)都提供這兩種型別的最低安全性保護
加鎖與可見性
使用Java的內建鎖(同步程式碼塊)可以保證下一個進入同步程式碼塊的執行緒可以看到上一個在同步程式碼塊中的執行緒所做的全部操作。
volatile變數
volatile
不是鎖,它被宣告在一個變數上,被宣告的變數不會參與指令重排,不會被快取在暫存器或者任何對其它處理器不可見的地方。
一些虛擬機器可能的實現是在
volatile
變數操作的後面加上一個記憶體屏障來保證後面的程式碼不會重排到volatile
操作前,並且立即將該變數的快取寫入主記憶體。
簡單來說,volatile
只保證可見性,一個執行緒對它更新後,另一個執行緒能立刻得到更新後的值,但它不提供任何其它的同步機制,比如下面的程式碼還是有問題:
volatile int counter;
public void incr() {
counter++;
}
因為counter++
不是一個原子操作,volatile
的典型應用是標誌,比如上面的ready
標誌。
釋出與逸出
釋出即把一個物件共享出去,使它能夠在當前作用域之外的程式碼中使用。程式的元件間不可能沒有互動,所以釋出物件在所難免,程式設計師要做的就是通過封裝和各種約束來保證釋出出去的物件是安全的,不希望被改變的物件屬性不會被輕易的改變。
需要注意的是,當你釋出出去一個物件,所有該物件釋出出去的物件也都會被髮布,這是一個連帶關係,同時可能會產生一些副作用,造成本不應該被髮布的物件被髮布了,或者是單純的由於你的疏忽導致物件被錯誤的釋出,這種情況就叫逸出。
先說風險,一旦一個物件逸出,那麼該物件可能隨時被誤用。有多大的風險取決於被逸出出去的物件。
下面是可能的幾種物件被髮布的手段:
-
直接通過公有域釋出
class C { public User user; public C() { user = new User(...); } }
-
通過方法返回
class C { private User user; public User getUser() { return user; } }
-
通過將物件傳遞給外部方法
外部方法就是行為不由本類決定的方法,包括其它類中的方法和本類中可以被改寫的非private非final方法。class C { private User user; public void doSomething() { UserUtils.changeUser(user); } }
-
釋出內部類例項
這個比較繞,但是也不難理解class C { public C(EventSource source) { source.registerListener( new EventListener() { public void onEvent(Event e) { // `onEvent`方法裡可以呼叫任何C類的方法和屬性,但它卻不受C類本身控制 doSomething(); } }; ) } private void doSomething() { // ... } }
-
連帶釋出
當你釋出一個物件,這個物件釋出的所有物件都被連帶的釋出:public Set<User> users; // users中的每一個user都被髮布 public Session session; // session中釋出的每一個物件都被髮布
不安全的物件構造
上面的第4種釋出手段是很危險的,並且是一定要避免的,即在構造方法中使this引用逸出。
構造方法是對一個物件進行例項化的方法,該方法被執行完,物件才算真正的構建出來,如果你在構造方法中把this
引用逸出了,那麼別人拿到的就是不完整的物件,這樣可能會出現很嚴重的問題。
執行緒封閉
最簡單的解決併發問題的辦法——不共享資料。
上面介紹了可見性可能引發的一些問題還有物件釋出的一些手段和注意事項,好像我們馬上就要開始著手學習如何安全的在併發環境下共享資料了,然後這書的作者告訴你,我先來教你如何“不共享資料”。。。
我不明白該書為啥把它安排在名字叫“共享物件”的一章中,但是...無傷大雅哈哈哈哈。
Ad-hoc執行緒封閉
不重要,略。
棧封閉
棧封閉就是執行緒別訪問任何公有變數,只訪問方法作用域中的區域性變數。眾所周知,棧空間是執行緒私有的,自然存在這裡沒有問題。
當然,你要花一些心思確保方法呼叫中不會出現某些引用逸出的情況,比如:
public boolean loadSomeData() {
Set<Entry> datas = new HashSet();
// ... dosomething ...
datas = Utils.checkAndFilterData(datas);
// ... dosomething ...
return datas;
}
你並不知道Utils.checkAndFilterData
中會不會新開一個執行緒對這個datas
做些什麼導致loadSomeData
和另一個執行緒對data的併發讀寫。
ThreadLocal類
get
方法返回呼叫者執行緒最後通過set
方法向其中設定的資料。
不變性
哈,終於說到了和共享物件有關的內容了。
任何併發問題都來源於被不同執行緒共享的資料狀態的改變,不管是由於原子性引發的問題還是可見性引發的問題。如果資料只有一種狀態,那麼自然永遠不會發生問題。
不可變物件(Immutable Object)就是一種狀態不可變的物件,一般情況下它在建構函式中初始化它的狀態,初始化完成後它的狀態就再也不改變。
你可能覺得上面說的都是廢話,並沒有解決實際問題,但其實,併發程式設計中的大部分共享物件都可以被設計為不可變的。不可變物件應該遵守如下規則:
- 物件建立以後其狀態就不能被修改(包括該物件中引用的其他物件的狀態也不能被修改)
- 物件的所有域都是final型別
- 物件是正確建立的(在物件的建立期間this引用沒有逸出)
Final域
final除了不可變域之外還有一個語義:
當建構函式結束時,final型別的值是被保證其他執行緒訪問該物件時,它們的值是可見的
這一點只需先記住,稍後會有一個小例子,到時候再回來查詢這句話或許就豁然開朗了。
同時,final關鍵字也可以提醒程式設計師該域不希望被更改。
示例:使用volatile型別來發布不可變物件
不可變物件不意味著該物件不能被更新,通常使用替換物件的方式來更新一個不可變物件,而且不可變物件通常非常小。
@Immutable
public class Point {
public final int x;
public final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
public class Human {
private volatile Point position = new Point(0, 0);
public void move(Point newPosition) {
position = newPosition;
}
public Point getPosition() {
return position;
}
}
現在多個執行緒可以共同操作這個Human
物件,不可變物件Point
保證了沒有執行緒會讀到不完整的或者是錯誤的資料,volatile
保證了沒有執行緒會讀到過時的資料,即一有執行緒更新position
,那麼另一個執行緒立即會讀取到新的位置。
安全釋出
下面我們將討論如何安全的釋出一個物件。
正確的物件被破壞
public class Holder {
private int n;
private Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n) {
throw new AssertionError("This statement is false.");
}
}
}
public class HolderTest {
public Holder holder;
public void initHolder() {
holder = new Holder(new Random().nextInt());
}
}
如上Holder
物件如果被HolderTest
釋出,那麼這次釋出就是不安全的。
- 某執行緒可能由於可見性原因導致訪問
holder
時訪問到null
- 某執行緒可能由於可見性原因導致訪問
holder
時訪問到之前舊的holder
- 某執行緒看到的
holder
引用值是新的,但是其狀態值是舊的(由於n
不是final
欄位,JVM沒有提供該屬性再在構造方法結束後一定初始化的保證,所以可能讀到n=0
) - 某執行緒第一次讀到失效狀態(0),第二次讀到新狀態(Random().nextInt())
如果把上面的holder
改成volatile
,問題能得到解決嗎?
第一個和第二個能解決,因為第一個第二個本質上是holder
引用更新的不及時,一個執行緒對它更新另一個執行緒沒有及時看到,volatile
能解決這個問題。
第三個第四個不能解決,所以,即使某個物件的引用改變對其它執行緒是可見的,不代表該物件中的狀態改變對其它執行緒也是可見的。
問題3、4的根本原因是Holder
不是不可變物件,且沒有任何同步機制來保護n
的初始化,所以通過這個例子也容易理解為什麼之前說要求不可變物件的所有域都是final
了,因為:
當建構函式結束時,final型別的值是被保證其他執行緒訪問該物件時,它們的值是可見的
所以:
符合不可變物件規則的物件總是執行緒安全的,即使它們沒被安全的釋出
安全釋出的常用模式
從上面的Holder的例子可以看出,一個可變物件如果想要安全的釋出,它的引用以及狀態必須同時對其它執行緒可見,否則就會發生上面那四種不一致問題。下面是常用的安全釋出物件的模式:
- 在靜態初始化函式中初始化對一個物件的引用(即static塊或static變數直接賦值,能進入
<clinit>
方法的位置) - 將物件的引用儲存到volatile型別的域或者
AutomicReference
物件中 - 將物件的引用儲存到某個正確構造物件的final型別域中
- 將物件的引用儲存到某個由鎖保護的域中(比如放到某些執行緒安全的容器中)
事實不可變物件
如果物件從技術上來看是可變的(不滿足上面不可變物件的定義),但其狀態在釋出後不會再改變,我們稱它為事實不可變物件。
在沒有額外同步的情況下,任何執行緒都可以安全的使用被安全釋出的事實不可變物件。
比如Date
是可變的,如果你把它當不可變物件來用,並將它釋出到synchronizedMap
中,那麼所有執行緒都可以安全的使用這個Date
物件。
可變物件
如果物件在構造以後可以修改,那麼安全釋出只能保證釋出當時狀態的可見性。所以你不僅要保證它安全釋出,在每次訪問物件時同樣需要使用同步機制來確保後續修改操作的可見性和正確性。