學習Java並發編程,必須要學習Java內存模型,也是學習和理解後面更深入的課程打下基礎,做好准備。今天我們就來學習下Java內存模型。
以下是本文包含的知識點:
1.硬件的效率與一致性
2.Java內存模型
3.主內存和工作內存
4.原子性、可見性與有序性
5.先行發生原則(Happens-before)
一、硬件的效率與一致性
由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內存與處理器之前的緩沖:將運算需要用到的數據復制到緩存中,讓運算能快速運行,當運算結束再從緩存同步回內存之中,這樣處理器就無需等待緩慢的內存讀寫了。
基於高速緩存的存儲交互很好的解決了處理器與內存的速度矛盾,但是也引入了一個新的問題:緩存一致性。當多個處理器的運算任務都涉及到同一塊主內存區域時,將可能導致各自的緩存數據不一致,那同步回到主內存時以誰的緩存數據為准呢?為了解決一致性的問題,需要各處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來操作,這裡說的協議就是內存模型的抽象。Java虛擬機也有自己的內存模型。
二、Java內存模型
在JDK1.5發布後,Java虛擬機規范中定義的Java內存模型(Java Memory Model JMM)已經成熟和完善。它屏蔽掉各種硬件和操作系統內存的訪問差異,以實現讓Java程序在各種平台下都能達到一致的訪問效果。
三、主內存和工作內存
Java內存模型規定所有變量都存儲在主內存(Main Memory)中(可以理解為物理內存,不過是虛擬機內存的一部分),每條線程還有自己的工作內存(Woring Memory,可以理解為前面講的高速緩存),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀取,賦值)都必須在工作內存中完成。
如多線程執行變量i++操作的流程:
1).先將變量i讀取到工作內存中;
2).然後在工作內存中將i+1;
3).最後將變量i同步到主內存中。
四、原子性、可見性與有序性
Java內存模型的三大特征:原子性、可見性與有序性
1.
原子性(Atomicity):即一個操作要麼全部執行並且執行的過程中不被任何因素打斷,要不都不執行。
如多線程執行i++操作
- public class Test implements Runnable{
- int i = 0;
- public void run(){
- i++;
- }
- }
public class Test implements Runnable{
int i = 0;
public void run(){
i++;
}
}
假如i初始值為0,線程1和線程各自執行一次+1操作,結果是我們想要的2嗎?不一定
根據前面講的內存模型,假如線程1將i=0讀取的工作內存中,並對i+1,此時i=1,但只是在線程1的工作內存中,並未同步到主存中。
此時線程2從主存讀取i還是=0,並對i+1變為1,此時i=1,但只是在線程1的工作內存中,並未同步到主存中。
然後線程1同步到主存中,最後線程2同步到主存中,程序執行完畢,i的值為1。
這種情況就是原子性操作被打斷了。哪如何保證原子性不被打斷呢,Java提供了兩種方式Lock和synchronized,後續再講。
2.
可見性(Visibility)是指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。
Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值來實現可見性,無論是普通變量還是volatile變量都是如此,只不過volatile特殊規則保證了新值能夠立即同步到主內存,以及每次使用前都從主內存刷新。因此,可以說volatitle保證了變量的可見性,而普通變量不可以。
除了volatitle之外,java還有兩個關鍵字保證可見性,即synchronized和final。
同步的可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中”這條規則獲得。
而final關鍵字的可見性是指,被 final修飾的字段一旦在構造器中初始化完成,並且構造器沒有把“this”引用傳遞出去,那在其它線程中就能看見final字段的值。
- public static final int i;
- public final int j;
- static{
- i = 0;
- }
- {
- //也可以在構造器初始化
- j = 0;
- }
public static final int i;
public final int j;
static{
i = 0;
}
{
//也可以在構造器初始化
j = 0;
}
上面代碼變量i和j都具有可見性,它們無須同步就能被其它線程正確訪問到。
3.
有序性(Ordering)即程序執行代碼的先後順序。
看下面的代碼:
- int i=0;
- boolean flag = false;
- i = 1; //語句1
- flag = true; //語句2
int i=0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
JVM在真正執行這段代碼時會按照先語句1、後語句2的順序來執行嗎,不一定,因為這裡可能會發生指令重排序。
指令重排序:一般來說,處理器為了提高運行效率,會對運行的代碼優化排序,它不保證各個語句的執行順序與代碼的先後順序一致,但是它會保證程序的執行結果與順序執行的結果一致。
那它是怎麼來保證執行結果一致的呢,原因是它會考慮數據的依賴性。如果指令2必須要用到指令1的結果,那麼處理器會保證指令1比指令2先執行。
指令重排序在單線程中是沒有問題的,那在多線程中呢,就不一定了。看下面代碼:
- Map config;
- boolean flag = false;
- //假設線程1執行如下代碼
- config = initConfig();//初始化config
- flag = true;
- //假設線程2執行如下代碼
- while(!flag){//等待flag為true,代表線程1已經初始化完成
- sleep();
- }
- //使用線程1初始化好的配置
- doSomethingWithConfig();
Map config;
boolean flag = false;
//假設線程1執行如下代碼
config = initConfig();//初始化config
flag = true;
//假設線程2執行如下代碼
while(!flag){//等待flag為true,代表線程1已經初始化完成
sleep();
}
//使用線程1初始化好的配置
doSomethingWithConfig();
假如線程1在執行時,發生指令重排序,先執行了flag = true,後執行initConig(),因為兩句沒有依賴關系,是可以發生的。
然後線程2再執行時,就發生異常了,因為這時config根本沒有初始化完成。
所以指令重排序不會影響單個線程的執行,但是會影響多線程並發執行的正確性。
Java語言提供volatitle和synchronized來保證線程之間操作的有序性。
volatitle本身就包含禁止指令重排序的語義,詳細的後面再講。
synchronized則是由“一個變量在同一個時刻只允許一個線程對其進行Lock操作”這條規則獲得。
介紹完Java內存模型的三大特征,會不會覺得synchronized是萬能的,的確大部分的並發控制操作都能使用synchronized來完成,但就是因為它的萬能造就了程序員的濫用,越萬能的並發控制,通常會帶來越嚴重的性能影響。後面會講到虛擬機鎖的優化。
五、先行發生原則(Happens-before)
如果Java內存模型的所有有序性都靠volatitle和synchronized來完成,那麼有一些操作會變得很煩鎖,但是我們在編寫java並發代碼時並沒感覺到這一點,這是因為java語言有一個“先行發生”(happens-before) 原則。這個原則非常重要,這是判斷數據是否競爭,線程是否安全的重要依據。
下面的Java內存模型下一些天然的發生關系,這些發生關系無須任何同步器,就已經存在,可以在編碼中直接使用。如果有兩個操作關系不在此列,並且無法從下列關系推導出來,它們就沒有順序保障,虛擬機可以對它們隨意重排序。
1)程序次序規則:在一個線程內,按照程序代碼順序或控制流,書寫在前在的操作先行發生於寫在後面的操作。
2)管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調是同一個對象鎖,而後面,指的是時間上的先後順序。
3)volatitle變量規則:對一個volatitle變量的寫操作先行發生於後面對這個變量的讀操作。
4)線程啟動規則:Thread對象的start()方法先行發生於此線程的每一個動作。
5)線程終止規則:線程中的所有操作都先行發生於對此線程的終止檢測。我們可以通過join()方法結束、isAlive()返回值檢測線程的終止。
6)線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。可以通過interrupted()檢測是否有中斷發生。
7)對象終結規則:一個對象的初始化完成(構造方法執行結束)先行發生於它的finalize()方法的開始。
8)傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那麼可以得出操作A先行發生於操作C的結論。
我們來看個例子:
- private int value = 0;
- public void setValue(int value){
- this.value = vlaue;
- }
- public int getValue(){
- return value;
- }
private int value = 0;
public void setValue(int value){
this.value = vlaue;
}
public int getValue(){
return value;
}
假設存在線程A和線程B,線程A先(時間上的先後)調用setValue(1),然後線程B調用同一個對象的getValue(),會得到什麼結果呢?
我們依次分析下先行發生原則裡的各項規則,
由於兩個方法分別由線程A和線程B調用,不在一個線程中,所以程序次序規則在這裡不適用。
由於沒有同步塊,自然沒有lock與unlock,所以管程鎖定規則在這裡不適用。
由於變量value沒有用volatitle修飾,所以volatitle變量規則在這裡不適用。
後面的線程啟動、終止、中斷、對象終結都跟這沒關系。
因為沒有一個適用的先行發生規則,所以傳遞性也不適用。
所以我們可以判定盡管線程A在操作時間上先於線程B,但是無法確定線程B的返回結果,即這裡操作不是線程安全的。
那麼怎麼修復這個問題呢,很簡單,要麼給seter/geter 方法加上synchronized關鍵字,這樣就可以使用管程鎖定規則,要麼把變量value定義為volatitle類型,由於setter方法對value的修改不依賴value的原值,滿足volatitle關鍵字的使用場景,這樣就可以使用volatitle變量規則。
通過上面的例子可以得出一個結論:一個操作“時間上的先發生”不代表這個操作先行發生。
所以我們衡量並發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為准。