在並發編程中,我們通常會遇到以下三個問題:原子性問題,可見性問題,有序性問題。
1、原子性
即一個操作或者多個操作,要么全部執行並且執行的過程不會被任何因素打斷,要么就都不執行。
一個很經典的例子就是銀行賬戶轉賬問題:
比如從賬戶A向賬戶B轉1000元,那么必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。試想一下,如果這2個操作不具備原子性,會造成什么樣的后果。假如從賬戶A減去1000元之后,操作突然中止。然后又從B取出了500元,取出500元之后,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。
所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。
同樣地反映到並發編程中會出現什么結果呢?舉一個簡單的例子:
i = 0; //1 j = i ; //2 i++; //3 i = j + 1; //4
上面四個操作,有哪個幾個是原子操作,那幾個不是?如果不是很理解,可能會認為都是原子性操作,其實只有1才是原子操作,其余均不是。
1在Java中,對基本數據類型的變量和賦值操作都是原子性操作; 2中包含了兩個操作:讀取i,將i值賦值給j 3中包含了三個操作:讀取i值、i + 1 、將+1結果賦值給i; 4中同三一樣
在單線程環境下我們可以認為整個步驟都是原子性操作,但是在多線程環境下則不同,Java只保證了基本數據類型的變量和賦值操作才是原子性的(注:在32位的JDK環境下,對64位數據的讀取不是原子性操作,如long、double)。
要想在多線程環境下保證原子性,則可以通過鎖、synchronized來確保。volatile是無法保證復合操作的原子性。
2、可見性
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
舉個簡單的例子,看下面這段代碼:
//線程1執行的代碼 int i = 0; i = 10; //線程2執行的代碼 j = i;
假若執行線程1的是CPU1,執行線程2的是CPU2。由上面的分析可知,當線程1執行 i = 10這句時,會先把i的初始值加載到CPU1的高速緩存中,然后賦值為10,那么在CPU1的高速緩存當中i的值變為10了,卻沒有立即寫入到主存當中。此時線程2執行 j = i,它會先去主存讀取i的值並加載到CPU2的緩存當中,注意此時內存當中i的值還是0,那么就會使得j的值為0,而不是10。這就是可見性問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。
在上面已經分析了,在多線程環境下,一個線程對共享變量的操作對其他線程是不可見的。
對於可見性,Java提供了volatile關鍵字來保證可見性。當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。而普通的共享變量不能保證可見性,因為普通共享變量被修改之后,什么時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。
另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然后執行同步代碼,並且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。
3、有序性
即程序執行的順序按照代碼的先后順序執行。
舉個簡單的例子,看下面這段代碼:
int i = 0; boolean flag = false; i = 1; //語句1 flag = true; //語句2
上面代碼定義了一個int型變量,定義了一個boolean類型變量,然后分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那么JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什么呢?這里可能會發生指令重排序(Instruction Reorder)。
下面解釋一下什么是指令重排序,一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。
比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那么就有可能在執行過程中,語句2先執行而語句1后執行。但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那么它靠什么保證的呢?再看下面一個例子:
int a = 10; //語句1 int r = 2; //語句2 a = a + 3; //語句3 r = a*a; //語句4
這段代碼有4個語句,那么可能的一個執行順序是:
語句2 -> 語句1 -> 語句3 -> 語句4
那么可不可能是這個執行順序:
語句2 -> 語句1 -> 語句4 -> 語句3。
不可能,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那么處理器會保證Instruction 1會在Instruction 2之前執行。雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:
//線程1: context = loadContext(); //語句1 inited = true; //語句2 //線程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以為初始化工作已經完成,那么就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。
從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程並發執行的正確性。也就是說,要想並發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。
在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程並發執行的正確性。
在Java里面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼,自然就保證了有序性。另外,Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。