第六十六條 同步訪問共享的可變資料
平時開發中,單執行緒的邏輯一般都好控制,但是併發情況下,多執行緒同時操作一個數據,可控性的難度就增加了,因為併發的情況不確定,並且除了問題也不好復現。因此,併發是一個重點,也是一個難點,併發中對資料的控制操作,就成為了併發的重點。同步關鍵字 synchronized ,可以保證同一時刻,只有一個執行緒可以執行某個方法。同步是為了執行緒安全,對此,需要滿足兩個特性,原子性和可見性。 同步的意思是,當一個物件被一個執行緒修改時,可以阻止另外一個執行緒觀察到物件內部不一致的情況,同時,如果有多個執行緒同時訪問它,它就會被鎖定,因此同步可以保證沒有任何方法可以獲取物件不一致的狀態。另外,java中的原子性,舉個例子,是指變數是 double 或 long,對於它們,即使是多執行緒操作,也能保證值不會出錯。
為了提高效能,在讀寫原子資料時,應避免同步。這個建議是很危險並且錯誤的。因為讀寫原子資料是原子操作,但不保證一個執行緒的寫入的值對另外一個執行緒一定是可見的,如果另外一個執行緒無法操作這個資料,那麼,就很容易出問題。執行緒與執行緒間的通訊,是建立在互斥和同步的基礎上。下面舉個例子,暫時跟著例子的思維走即可。(補充,這個例子在 jdk1.6 以下是正確的,但1.8版本及以上,還有在android手機上, 主執行緒是可以訪問子執行緒,子執行緒對主執行緒是可見的)。
如果需要停止一個執行緒,可以使用Thread.stop方法,但這個方法很久以前就不提倡使用了,因為不安全——使用它會使資料遭到破壞。因此,普遍做法是,讓一個執行緒輪詢一個boolean域,另一個執行緒設定這個boolean域即可:
private boolean stopRequested;
private void test() {
try {
Thread backgroundThread = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopRequested) {
i++;
}
}
});
backgroundThread.start();
TimeUnit.MILLISECONDS.sleep(1000);
stopRequested = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
設計的思路很好,我們開了一個子執行緒,在子執行緒中開啟一個迴圈,然後讓 int 型別值 i不停的增長,我們期待一秒後,把 stopRequested 值變為true,然後 while() 條件不成立,終止迴圈,i 不再增加。這個設計的願望很好,但現實很骨感。因為這是兩個不同的執行緒,由於執行緒限制,導致外面的執行緒改變了 stopRequested 的值,但 backgroundThread 執行緒訪問不到 stopRequested 改變後的值,這就相當於把
while (!stopRequested) {
i++;
}
替換成了
if(!stopRequested){
while (true) {
i++;
}
}
所以,問題就出現了,導致執行緒 backgroundThread 中的預想邏輯失效。那麼,我們知道問題的原因了,就可以對症下藥了。起因是執行緒間的互斥問題,就用到了開頭提到的同步鎖,synchronized 。之前是直接呼叫 stopRequested ,此時,我們把它的賦值和取值封裝成方法,同時使用 synchronized 修飾方法,這樣,各個執行緒就同步了。
private void test1() {
try {
Thread backgroundThread = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopRequested()) {
i++;
}
}
});
backgroundThread.start();
TimeUnit.MILLISECONDS.sleep(1000);
requestStop();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private synchronized void requestStop() {
stopRequested = true;
}
private synchronized boolean stopRequested() {
return stopRequested;
}
賦值和取值的方法都被同步了,我們的功能也就實現了,這是通過同步鎖實現的,我們也可以用另一種方式,就是 volatile ,例如下面的寫法,沒有對 stopRequested 的set和get方法進行同步鎖
private volatile boolean stopRequested;
private void test2() {
try {
Thread backgroundThread = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopRequested) {
i++;
}
}
});
backgroundThread.start();
TimeUnit.MILLISECONDS.sleep(1000);
stopRequested = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
效果和上面一樣。但我們要注意, volatile 修飾也是有限制的,例如
private volatile int nextSerialNumber = 0;
public int generateSerialNumber() {
return nextSerialNumber++;
}
nextSerialNumber++,是兩步操作,先是對自身加1,然後再把值賦給自己,所以如果是併發情況呼叫 generateSerialNumber() ,還可能一個執行緒正在執行加1操作,還沒有賦值,另外一個已經把它值給取到,所以會造成執行緒安全問題。解決方法是,加入synchronized並去掉volatile。或者直接使用系統的 AtomicInteger 、 AtomicLong類,自帶執行緒安全。
private final AtomicLong nextSerialNumber = new AtomicLong(0);
public Long generateSerialNumber() {
return nextSerialNumber.incrementAndGet();
}
注意看上面括號中的話,jdk 1.8 上, 主執行緒是可以訪問子執行緒的,我特意列印了一下,估計是jvm或者最新版做了修正。有了解的大神請留言。
private boolean stopRequested;
private void test3() {
Thread backgroundThread = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopRequested) {
i++;
System.out.println(" test i: " + i);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
backgroundThread.start();
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stopRequested = true;
}
因為每此睡眠10毫秒,所以100毫秒內應該執行10次左右,結果確實列印了1到10。