引子
在java中,為了保證某種資源只被初始化一次,我們通常會將其放入同步代碼塊中,如:
public synchronized Resource getResource(){
if (resource == null){
resource = new Resource();
}
return resource;
}
上面的代碼可以保證正確性,但是執行效率上似乎還有優化的空間:無論resource是否已經被初始化,都會對getResource
方法加鎖。聰明如你一定很快就會想到使用"double check"的方式提高效率:
private Resource resource;
public Resource getResource() {
Resource tmp = this.resource;
if (tmp == null) {
synchronized(this){
tmp = this.resource
if (tmp == null) {
this.resource = tmp = new Resource();
}
}
}
return tmp;
}
ok,看起來很完美,然而,上面的代碼是有問題的。不過,在jdk 5 以后,只要給成員變量resource加上volatile
就可以解決這個問題了。
private volatile Resource resource;
public Resource getResource() {
Resource tmp = this.resource;
if (tmp == null) {
synchronized(this){
tmp = this.resource
if (tmp == null) {
this.resource = tmp = new Resource();
}
}
}
return tmp;
}
那么,volatile有什么作用,第一個版本的代碼問題在哪里,為何加上volatile后就可以正常使用?下面我們就從java的內存模型開始,分析這些問題。
volatile的作用
volatile有兩大作用:
保證內存可見性
防止指令重排
一、內存可見性
java的內存模型規定:在多線程情況下,線程操作主內存變量,需要通過線程獨有的工作內存拷貝主內存變量副本來進行。此處的所謂內存模型要區別于通常所說的虛擬機堆模型:
Java內存模型也規定了工作內存與主內存之間交互的協議,定義了8種原子操作:
(1) lock:將主內存中的變量鎖定,為一個線程所獨占
(2) unclock:將lock加的鎖定解除,此時其它的線程可以有機會訪問此變量
(3) read:將主內存中的變量值讀到工作內存當中
(4) load:將read讀取的值保存到工作內存中的變量副本中。
(5) use:將值傳遞給線程的代碼執行引擎
(6) assign:將執行引擎處理返回的值重新賦值給變量副本
(7) store:將變量副本的值存儲到主內存中。
(8) write:將store存儲的值寫入到主內存的共享變量當中。
從上面的描述可以看出,當一個線程修改了某個共享變量的值并同步到主線程時,在其它線程從主內存將變量同步回自己的工作內存之前,共享變量的改變對其是不可見的。所以其他線程的本地內存中的變量已經是過時的,并不是更新后的值。
為了解決上面的問題,可以使用volatile關鍵字:
線程中每次use變量時,都需要連續執行read->load->use幾項操作,即所謂的每次使用都要從主內存更新變量值,這樣其它線程的修改對該線程就是可見的。
線程每次assign變量時,都需要連續執行assign->store->write幾項操作,即所謂每次更新完后都會回寫到主內存,這樣使得其它線程讀到的都是最新數據。
二、指令重排
什么是指令重排?指令重排是指JVM為了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,盡可能地提高并行度。編譯器、處理器也遵循這樣一個目標。在單線程的情況下,指令重排不會對程序執行結果造成影響,然而在多線程的情況下,指令重排就會出現問題,看下面的代碼:
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread b = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
a.start();
b.start();
a.join();
b.join();
System.out.println(“(” + x + “,” + y + “)”);
}
上面這段代碼的結果是什么?考慮線程的執行順序,可能為 (0,1) 、(1,0)或者是(1,1)。但是如果運行上面的程序若干次,還有可能出現第四種情況(0,0),這就是由于指令重排改變了執行順序導致的。
看下面的語句:
double r = 2.3d;
double pi =3.1415926;
double area = r * r * pi;
這個語句有如下步驟:
- 將2.3賦值給r
- 將π賦值給pi
- 讀取r的值
- 讀取pi的值
- 計算area的值
jvm為了保證語義的正確性,操作1可能會和2、4交換次序,操作2可能會和1、3交換次序,但是1、3、5和2、4、5之間的執行順序肯定不會改變,這樣就保證了執行結果的一致性。但是,如上面的例子所示,在多線程情況下亂序執行可能會導致一些出乎意料之外的結果。
那么如果防止指令重排呢?可以使用內存屏障。內存屏障是一種CPU指令,用于控制特定條件下的重排序和內存可見性問題。Java編譯器也會根據內存屏障的規則禁止重排序。
內存屏障可以被分為以下幾種類型
- LoadLoad屏障:對于這樣的語句Load1; LoadLoad; Load2,在Load2及后續讀取操作要讀取的數據被訪問前,保證 Load1要讀取的數據被讀取完畢。
- StoreStore屏障:對于這樣的語句Store1; StoreStore; Store2,在Store2及后續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
- LoadStore屏障:對于這樣的語句Load1; LoadStore; Store2,在Store2及后續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
- StoreLoad屏障:對于這樣的語句Store1; StoreLoad; Load2,在Load2及后續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。
在使用了volatile后:
在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的后面插入一個StoreLoad屏障。
在每個volatile讀操作的后面插入一個LoadLoad屏障。
在每個volatile讀操作的后面插入一個LoadStore屏障。
這樣就可以防止指令重排導致的問題。
三、版本一 "double check" 的問題
有了上面的知識,我們就可以來分析第一個 "double check" 的問題了:
private Resource resource;
public Resource getResource() {
Resource tmp = this.resource;
if (tmp == null) {
synchronized(this){
tmp = this.resource
if (tmp == null) {
this.resource = tmp = new Resource();
}
}
}
return tmp;
}
new Resource()
可以分解為:
memory =allocate(); //1:分配對象的內存空間
ctorInstance(memory); //2:初始化對象
instance =memory; //3:設置instance指向剛分配的內存地址
如果被重排為
memory = allocate(); //1:分配對象的內存空間
instance = memory; //2:設置instance指向剛分配的內存地址
ctorInstance(memory); //3:初始化對象
就會出現線程A中執行這段賦值語句,在完成對象初始化之前就已經將其賦值給resource引用,恰好另一個線程進入方法判斷instance引用不為null,然后就將其返回使用,導致出錯。將resource設置為volatile
之后,可以保證對相關操作的順序。
另外,對于final
,也有其獨特的作用,例如在如下語句中,構建方法邊界前后的指令都不能重排序:
x.finalField = v;
... ;
構建方法邊界
sharedRef = x;
v.afield = 1;
x.finalField = v;
... ;
構建方法邊界
sharedRef = x;
總結
volatile
在jdk 5 以后有雙重語義:1. 保證內存可見性 2.防止語句重排。不過這樣也會使編譯器放棄對相關代碼的優化,因此,如無必要,不要使用volatile
修飾成員變量。