在Java中,虛擬機將運行時區域分成6種,如圖:
在上面的6種類型中,前三種是線程私有的,也就是說裡面存放的值其他線程是看不到的,而後面三種(真正意義上講只有堆一種)是線程之間共享的,這裡面的變量對於各個線程都是可見的。如下圖所示,前三種存放在線程內存中,大家都是相互獨立的,而主內存可以理解為堆內存(實際上只是堆內存中的對象實例數據部分,其他例如對象頭和對象的填充數據並不算入在內),為線程之間共享:
這裡的變量指的是可以放在堆中的變量,其他例如局部變量、方法參數這些並不算入在內。線程內存跟主內存變量之間的交互是非常重要的,Java虛擬機把這些交互規範為以下8種操作,每一種都是原子性的(非volatile修飾的Double和Long除外)操作。
可能有人會不理解read和load、store和write的區別,覺得這兩對的操作類似,可以把其當做一個是申請操作,另一個是審核通過(允許賦值)。例如:線程內存A向主內存提交了變更變量的申請(store操作),主內存通過之後修改變量的值(write操作)。如下圖:
參照《深入理解Java虛擬機》
對於普通的變量來說(非volatile修飾的變量),虛擬機要求read、load有相對順序即可,例如從主內存讀取i、j兩個變量,可能的操作是read i=>read j=>load j=> load i,並不一定是連續的。此外虛擬機還為這8種操作定製了操作的規則:
對於關鍵字volatile,大家都知道其一般作為並發的輕量級關鍵字,並且具有兩個重要的語義:
這兩個語義都是因為JMM對於volatile關鍵字修飾的變量會有特殊的規則:
在對變量執行use操作之前,其前一步操作必須為對該變量的load操作;在對變量執行load操作之前,其後一步操作必須為該變量的use操作。也就是說,使用volatile修飾的變量其read、load、use都是連續出現的,所以每次使用變量的時候都要從主內存讀取最新的變量值,替換私有內存的變量副本值(如果不同的話)。在對變量執行assign操作之前,其後一步操作必須為store;在對變量執行store之前,其前一步必須為對相同變量的assign操作。也就是說,其對同一變量的assign、store、write操作都是連續出現的,所以每次對變量的改變都會立馬同步到主內存中。在主內存中有變量a、b,動作A為當前線程對變量a的use或者assign操作,動作B為與動作A對應load或store操作,動作C為與動作B對應的read或write操作;動作D為當前線程對變量b的use或assign操作,動作E為與D對應的load或store操作,動作F為與動作E對應的read或write操作;如果動作A先於動作D,那麼動作C要先於動作F。也就是說,如果當前線程對變量a執行的use或assign操作在對變量buse或assign之前執行的話,那麼當前線程對變量a的read或write操作肯定要在對變量b的read或write操作之前執行。
從上面volatile的特殊規則中,我們可以知道1、2條其實就是volatile內存可見性的語義,第三條就是禁止指令重排序的語義。另外還有其他的一些特殊規則,例如對於非volatile修飾的double或者long這兩個64位的數據類型中,虛擬機允許對其當做兩次32位的操作來進行,也就是說可以分解成非原子性的兩個操作,但是這種可能性出現的情況也相當的小。因為Java內存模型雖然允許這樣子做,但卻「強烈建議」虛擬機選擇實現這兩種類型操作的原子性,所以平時不會出現讀到「半個變量」的情況。
volatile不具備原子性
雖然volatile修飾的變量可以強制刷新內存,但是其並不具備原子性,稍加思考就可以理解,雖然其要求對變量的(read、load、use)、(assign、store、write)必須是連續出現,即以組的形式出現,但是這兩組操作還是分開的。比如說,兩個線程同時完成了第一組操作(read、load、use),但是還沒進行第二組操作(assign、store、write),此時是沒錯的,然後兩個線程開始第二組操作,這樣最終其中一個線程的操作會被覆蓋掉,導致數據的不準確。如下面代碼:
結果圖:
解釋一下:因為i++操作其實為i = i + 1,假設在主內存i = 99的時候同時有兩個線程完成了第一組操作(read、load、use),也就是完成了等號後面變量i的讀取操作,這時候是沒問題的,然後進行運算,都得出i+1=100的結果,接著對變量i進行賦值操作,這就開始第二組操作(assign、store、write),是不是同時賦值的無所謂,這樣一來,兩個線程都會以i = 100把值寫到主內存中,也就是說,其中一個線程的操作結果會被覆蓋,相當於無效操作,這就導致上面程序最終結果的不準確。
如果要保證原子性的話可以使用synchronize關鍵字,其可以保證原子性和內存可見性(但是不具備有禁止指令重排序的語義,這也是為什麼double-check的單例模式中,實例要用volatile修飾的原因);當然你也可以使用JUC包的原子類AtomicInteger之類的。
如果單靠volatile和synchronized來維持程序的有序性的話,那麼難免會變得有些繁瑣。然而大部分時候我們並不需要這樣做,因為Java中有一個「先行發生原則」:*如果操作A先行發生於操作B,那麼進行B操作之前A操作的變化都能被B操作觀察到,也就是說B能看到A對變量進行的修改。 *這裡的先後指的是執行順序的先後,與時間無關。例如在下面偽代碼中:
// 在線程A執行,定為A操作
i = 0;
// 線程B執行,定義為B操作
j = i;
// 線程C執行,定義為C操作
i = 1;
假設A操作先於B操作發生,暫時忽略C操作,那麼最終得到的結果必定是i = j = 1;但是如果此時加入C操作,並且跟A、B操作沒有確定先行發生關係,那麼最終的結果就變成了不確定,因為C可能在B之前執行也可能在B之後執行,所以此時就會出現數據不準確的情況。如果一開始沒有A操作先行於B操作這個前提的話,那麼就算沒有C操作,結果也是不確定的。
當然,符合先行發生原則的並不一定按照這個規則來執行,只有在操作之間會有依賴的時候(即下一個操作用到上個操作的變量),此時的先行發生原則才一定適用。例如在下面的偽代碼中,雖然符合先行發生原則,但是也不保證能有序執行。
// 同一線程執行以下操作
// A操作
int i = 0;
// B操作
int j = 1;
這裡完全符合程序次序規則(先行發生原則的一種),但是兩個操作之間並沒有依賴,所以虛擬機完全可以對其進行重排序,使得B操作在A操作之前執行,當然這對程序的正確性並沒有影響。
那麼該如何判斷是否符合先行發生原則呢?就連前面的例子都是通過假設來得出先行發生的。莫慌,Java內存模型為我們提供一些規則,只要符合這些規則之一,那就符合先行發生原則。可以類比為先行發生原則為接口,下面的規則則為實現此接口的實現類。
public static int i = 0;
public static void main(String[] args) {
for (int k = 0; k < 10000; k++) testThread();
}
public static void testThread() {
Thread threadB = new Thread(() -> {
System.err.println("線程B中i的值為:" + i);
System.err.println("線程B執行結束");
});
new Thread(() -> {
i = 1;
// 在修改了共享變量i的值後,啟動線程B
threadB.start();
System.err.println("線程A中執行完之後i的值為:" + i);
}).start();
}
結果圖:
public static int i = 0;
public static void main(String[] args) throws InterruptedException {
// 執行1000次
for (int k = 0; k < 1000; k++) {
i = 0;
testThread();
}
}
public static void testThread() throws InterruptedException {
Thread threadA = new Thread(() -> {
int k = 0;
while (k++ < 100 * 100 * 100) {
i++;
}
while (--k > 1) {
i--;
}
System.err.println("線程A中執行完之後i的值為:" + i);
});
threadA.start();
// 加上下面這段代碼的話,join之前讀到的i可能為0也可能大於0(不一定是1),原因是變量i主內存的read和write操作沒有固定順序
// TimeUnit.NANOSECONDS.sleep(1);
System.out.println("主線程中開啟線程A後i的值為:" + i);
// 線程A終止
threadA.join();
// join之後的結果一定為1
System.err.println("Join之後i的值為:" + i);
}
結果圖:
這8種就是Java提供的不需要任何同步器的自然規則了,只要符合在8條之一,那麼就符合先行發生原則;反之,則不然。可以通過下面的例子理解:
// 對象中有一個變量i
private int i = 0;
public int getI() {
return i;
}
public void setI(int i) {
this.i = i;
}
// 在線程A執行set操作A
setI(1);
// 在線程B執行相同對象的get操作B
int j = getI();
我們假設在時間上A操作先執行,然後再接著執行B操作,那麼B得到的i是多少呢?
我們將上面的規則一個個的往裡套,不同線程,程序次序規則OUT;沒有加鎖和volatile關鍵字,管程鎖定和volatile變量規則OUT;關於線程的三個規則和對象終止規則也不符合,OUT;最後一個更不用提,OUT;綜上,這個操作並不符合先行發生原則,所以這個操作是沒法保證的,也就是說B得到的變量i為1為0都有可能,即是線程不安全的。所以判斷線程是否安全的依據是先行發生原則,跟時間順序並沒有太大的關係。
像上面這種情況要修正的話,使其符合其中一條規則即可,例如加上volatile關鍵字或者加鎖(同一把鎖)都可以解決這個問題。