無論你是Java還是C,或者其他編程語言編寫高併發程序時,都或多或少的會涉及內存模型。高併發程序下數據訪問的一致性和安全性受到挑戰,爲了保證程序正確執行,Java內存模型(以下簡稱JMM)由此而誕生。如果不理解JMM,就會對內存可見性,有序性等問題出現時無從下手。本文將從以下幾個方面進行JMM的說明:
1.內存模型的相關概念
2.可見性
3.有序性
4.原子性
1.內存模型的相關概念
1.1 Java虛擬機運行時數據區
在共享內存模型裏,線程間的是通過讀-寫內存中的公共狀態進行隱式通信的,那線程在Java虛擬機裏是什麼位置呢?Java虛擬機運行時數據區:
堆:解決的是對象數據存儲問題。
方法區:是先決條件,儲存已經被虛擬機加載過的類信息,常量,靜態變量等信息。它和堆描述的是Java共享數據(主)內存模型。
虛擬機棧:棧解決的是程序運行的問題,即程序如何處理數據。從圖中可以看棧是線程私有的,函數的調用要用棧實現,函數運算並改變棧中存放數據的引用。所以虛擬機棧描述的是Java執行的內存模型: 每個方法在執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變量表、 操作數棧、 動態鏈接、 方法出口等信息。
本地方法棧:和虛擬機棧類似,虛擬機棧爲Java方法服務,而本地方法棧爲虛擬機使用到的Native方法服務。程序計數器:cpu中的寄存器,它包含當前正在執行的指令的地址(位置)。(寄存器是cpu組成部分,暫存指令、數據和地址--百度百科)。
1.2 緩存一致性協議
CPU在執行指令過程中,勢必會牽扯到變量的讀和寫。共享變量存儲到內存中,CPU通過高速緩存(Cache)與內存進行通信,但是CPU執行指令比CPU從內存讀寫共享變量要快的多。而且在多核CPU時代,每條線程可能運行在不同的CPU裏。比如:
i=i+1;
兩個線程讀取i的值並在自己的CPU的高速緩存中+1操作,當第一個線程運算完了存到高速緩存還沒有刷新到內存中,線程二讀取到i還是0,也做了+1操作,然後將數據寫入高速緩存,最後將高速緩存中i最新的值刷新到主存當中。
最終結果i的值是1,而不是2。這就是緩存一致性問題。怎麼解決呢?當CPU寫數據時,發現其他CPU也在操作這個共享變量,會讓其他CPU的緩存無效且從內存中重新獲取。這個緩存一致性協議最出名的就是Intel 的MESI協議。
2.可見性
- 可見性是指當一個線程修改了某一個共享變量的值,其他線程是否能夠立即知道這個修改。
程序在執行過程中任何時候都可能產生可見性問題,這裏只討論JMM相關的可見性問題。Java線程之間的通信由JMM控制,JMM的抽象示意圖如下:
本地內存A和B有主內存中共享變量x的副本。假設初始時三個內存中的x值都爲0。線程A修改x=1後-->主內存修改爲x=1-->最後由B修改x=1。這些步驟實質上就是A在給B發消息,且要通過主內存,及JMM通過控制主內存在控制線程之間的內存可見性。
我們來看一個Java虛擬機層面產生的可見性問題:
public class Visibility implements Runnable{
private boolean isStoped = false;
public void run() {
int i = 0;
while(!isStoped) {
i++;
}
System.out.println("end and print,i=" + i);
}
public void stopFunction() {
isStoped = true;
}
public boolean getStopStatus(){
return isStoped;
}
public static void main(String[] args) throws Exception {
Visibility visibility = new Visibility();
new Thread(visibility,"visibility").start();
Thread.sleep(1000);
//當主線程直到主線程調用stop方法,改變了v線程中的stop變量的值使循環停止。
visibility.stopFunction();
Thread.sleep(1000);
System.out.println("finish main");
System.out.println(visibility.getStopStatus());
}
}
運行結果:
爲什麼循環沒停止,且沒有打印System.out.println("end and print,i=" + i);這句話?原因是主線程調用把isStoped值修改爲true對visibility線程並不可見,所以主線程走完了,而visibility線程一直在做i++操作。解決上述變量不可見性的方法:用volatile關鍵字修飾isStoped變量,它會強制性的從主內存中取值,從而避免本地內存變量不可見問題。
3.有序性
- 即程序執行的順序按照代碼的先後順序執行。
很奇怪?其實我們寫代碼時只是看上去有序,代碼在編譯成指令執行時會進行重排序保證程序運行的高效率。 因爲下面的語句1和語句2沒有什麼數據依賴性,可能會打亂順序執行:
a=b+c; //語句1
d=e-f; //語句2
g=a+3; //語句3
CPU執行一條指令的時候一般有:取指IF-->譯碼和讀取寄存器操作數ID-->執行或者有效地址計算EX(用到CPU中邏輯運算單元)-->存儲器訪問MEM-->寫回WB(用到寄存器)
比如兩條指令執行順序由上到下:
當我們運行語句1和語句2時有:
上圖產生氣泡X會嚴重影響效率,所以會把沒有相關性的操作加入到有氣泡操作裏,這就是處理器爲了提高程序運行效率,對輸入代碼進行優化:
一般認爲CPU的指令都是原子操作,雖然重排序不會影響單個線程內程序執行的結果,但是多線程會有影響:
public class VolatileSort {
int a =0;
boolean flag = false;
public void write(){
a=1;
flag =true;
}
public void read(){
if(flag){
int i=a+1;
//...
}
}
}
一個線程執行write方法,另一個線程檢查flag=true時,a=1還未執行,會導致程序運算錯誤。解決方法還是使用volatile關鍵字修飾變量。
4.原子性
- 原子性是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一旦開始,就不會被其它線程干擾,要麼直接就不執行。
volatile關鍵字最致命的缺點是不支持原子性。比如count++這樣的操作就不是原子性的:
public class VolatileTest {
private volatile int count= 0;
//解決方法之一
// Lock lock = new ReentrantLock();
// public void increase() {
// lock.lock();
// try {
// count++;
// } finally{
// lock.unlock();
// }
// }
//解決方法之二
// public synchronized void increase() {
public void increase() {
count++;
}
public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
//保證前面的線程都執行完
while(Thread.activeCount()>1){
Thread.yield();
}
//執行結果並不是10000
System.out.println(test.count);
}
}
count++其實有三個操作:讀取count-->做count+1操作-->然後把count+1後的值寫回到count裏。
假設有兩個線程,當第一個線程讀取count=1時,還沒進行+1操作,切換到第二個線程,此時第二個線程也讀取的是count=1。隨後兩個線程進行後續+1操作,再賦值回去以後,count不是3,而是2。顯然數據出現了不一致性,可以使用上面代碼的方法一和方法二加鎖或原子類操作:
public class VolatileTest2 {
private AtomicInteger count= new AtomicInteger();
public void increase() {
count.getAndIncrement();
}
public static void main(String[] args) {
final VolatileTest2 test = new VolatileTest2();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.println(test.count.get());
}
}
其實前面或多或少提到了volatile關鍵字,這裏做一些擴展,volatile是如何保證可見性的呢?
volatile User user =new User();
上述代碼在x86處理器通過工具獲取JIT編譯器生成的彙編指令如下:
0x01a3deld:movb 》$0×0,0×1104800(%esi);0x01a3de4>:lock add1 $0×0,(%esp);
lock指令在多核處理器會引發兩件事:
1.將當前緩存行的數據寫回到系統內存。
2.這個寫回內存操作會使其他CPU裏緩存了該內存地址的數據無效。
參考:
《深入理解Java虛擬機》
《Java併發編程的藝術》
《Java高併發程序設計》
系列: