共享變量比較典型的就是指類的成員變量,在類中定義了很多方法對成員變量的使用,如果是單實例,當有多個線程同時來調用這些方法,方法又沒加控制,那么這些方法對成員變量的操作就會使得該成員變量的值變得不準確了。
大象用一個最典型的i++例子來說明:
public class Test {
private int i = 0;
private final CountDownLatch mainLatch = new CountDownLatch(1);
public void add(){
i++;
}
private class Work extends Thread{
private CountDownLatch threadLatch;
public Work(CountDownLatch latch){
threadLatch = latch;
}
@Override
public void run() {
try {
mainLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 1000; j++) {
add();
}
threadLatch.countDown();
}
}
public static void main(String[] args) throws InterruptedException {
for(int k = 0; k < 10; k++){
Test test = new Test();
CountDownLatch threadLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
test.new Work(threadLatch).start();
}
test.mainLatch.countDown();
threadLatch.await();
System.out.println(test.i);
}
}
}
java.util.concurrent.CountDownLatch是JDK5.0提供的關于并發的一個新API,它的作用就像一個門閂或是閘門那樣。上面這段代碼一共執行10次,每次啟動10個線程同時執行。mainLatch.await()相當于門閂擋著線程,讓準備好的線程處于等待狀態,當所有的線程都準備好時再調用mainLatch.countDown()方法,打開門閂讓線程同時執行。我在這里用這個類的原因,是想讓我創建的10個線程都準備好后再一起并發執行,這樣才能很明顯的看出add方法里面的i++效果。如果不引入CountDownLatch,只執行test.new Work(threadLatch).start(),則獲得的結果可能看不出來線程競爭共享變量產生的錯誤情況。threadLatch這個CountDownLatch的作用是讓10個線程都執行完run方法的for循環后通知主線程的threadLatch.await()停止等待打印出當前i的值。
這段代碼我加了-server參數運行了多次,每次結果都不一樣,我取了幾個比較明顯的結果。當然,你也可以多運行幾次看看效果。



共享變量i沒做任何同步操作,當有多個線程都要讀取并修改它時,問題就產生了。正確的結果應該是10000,但是我們看到了,不是每次結果都是10000。我這段代碼最初的版本不是這樣的,因為現在的CPU哪怕是家用級PC的CPU核心頻率都非常高,所以完全看不出效果,在和一個朋友的討論中,他給出了修改的建議,最后改為上面的代碼,在這里謝謝Sunny君。run方法中的循環次數越大,i的并發問題就越明顯,大家可以動手試下。對于上圖的運行結果,和硬件平臺有關,也和-server參數有關。
有同學會有疑問了,既然共享變量沒加同步處理,那為什么還是會出現10000的結果呢?關于這點我想這可能是JVM優化的結果,對于JVM(HotSpot)大象還沒有很深入的研究,不敢隨便下結論,請知道的朋友幫忙解答一下。
在Java中,線程是怎么操作共享變量的呢?我們都知道,Java代碼在編譯后會變成字節碼,然后在JVM里面運行,而像實例域(i)這樣的變量是存儲在堆內存(Heap Memory)中的,堆內存是內存中的一塊區域。線程的執行其實說到底就是CPU的執行,當今的CPU(Intel)基本上都是多核的,因此多線程都是由多核CPU來處理,并且都有L1、L2或L3等CPU緩存,CPU為了提高處理速度,在執行的時候,會從內存中把數據讀到緩存后再操作,而每個線程執行add方法操作i++的過程是這樣的:
1、線程從堆內存中讀取i的值,將它復制到緩存中
2、在緩存中執行i++操作,并將結果賦給變量i
3、再用緩存中的值刷新堆內存中的變量i的值
我上面寫的這三步并不是嚴格按照JVM及CPU指令的步驟來的,但過程就是這么一回事,方便大家理解。通過上面這個過程我們可以看出問題了,如果有多個線程同時要修改i,那么都需要先讀取堆內存中的變量i值,然后把它復制到緩存后執行i++操作,再將結果寫回到堆內存的變量i中。這個執行的時間非常短,可能只有零點幾納秒(主要還是跟硬件平臺有關),但還是出現了錯誤。產生這種錯誤的原因是共享變量的可見性,線程1在讀取變量i的值的時候,線程2正在更新變量i的值,而線程1這時看不到線程2修改的值。這種現象就是常說的共享變量可見性。
下圖是線程執行的抽象圖,也可以說是Java內存模型的抽象示意圖,可能不嚴謹,但大意是這樣的。

現在選用開發框架一般都會選擇Spring,或是類似Spring這樣的東西,而代碼中經常用到的依賴注入的Bean如果沒做處理一般都會是單例模式。試想一下,按下面這個方式引用Service或其它類似的Bean,在UserService中又不小心用到了共享變量,同時沒有處理它的共享可見性,即同步,那將會產生意想不到的結果。不光Service是單例的,Spring MVC中的Controller也是單例的,所以編寫代碼的時候一定要注意共享變量的問題。
@Autowired
private UserService userService;
所以我們要盡可能的不使用共享變量,避開它,因為處理好共享變量可見性不是一個很簡單的問題。如果有非用不可的理由,請使用java.util.concurrent.atomic包下面的原子類來代替常用變量類型。比如用AtomicInteger代替int,AtomicLong代替long等等,具體可以參考API文檔。如果需求比這更復雜,那還得想其它解決辦法。
以上是大象關于共享變量的一些淺薄見解,有什么不對的,還請各位指出來。
本文為菠蘿大象原創,如要轉載請注明出處。http://www.aygfsteel.com/bolo