java中final與volatile-執行緒安全問題
線上程安全問題中final主要體現在安全釋出問題上,在這裡先講一下什麼事安全釋出,在《java併發程式設計實踐》一書中有講,不過看起來挺難懂的….
public class Holder {
private int n;
public Holder(int n) { this.n = n; }
public void assertSanity() {
if (n != n)
throw new AssertionError("error");
}
}
假設這裡有一個執行緒A執行了下面一段程式碼
Holder holder = new Holder(10);
同時有另一個執行緒也在執行下面這段程式碼
if (holder != null) holder.assertSanity();
那麼在某些情況下就會丟擲上面的異常,原因就是:
Holder holder = new Holder(10);其實是由三步組成的
- 給holder分配記憶體
- 呼叫建構函式
- 將holder指向剛分配的記憶體
理想中是這個執行順序,然而事實上這三步並不一定按照這個順序執行,是為了優化效率而存在的指令重排在作怪,假如一個執行順序為1 3 2,那麼在剛執行完1和3的時候執行緒切換到B,這時候holder由於指向了記憶體所以不為空並呼叫assertSanity函式,該函式中的if語句並不是一步完成:
- 取左表示式n的值
- 取右表示式n的值
- 進行!=表示式運算
那麼假設剛執行完第一步的時候B執行緒掛起並重新回到A執行緒,A執行緒繼續執行建構函式並將n賦值為10,然後再次跳回B執行緒,這時候執行第2步,那麼就會造成前後取到的n不一樣,從而丟擲異常。
那麼加了final修飾之後會如何呢,JVM做了如下保證
一旦物件引用對其他執行緒可見,則其final成員也必須正確的賦值了。
就是說一旦你得到了引用,final域的值(即n)都是完成了初始化的,因此不會再丟擲上面的異常。另外高併發環境下還是多用final吧!
再來看一下volatile關鍵字,這個關鍵字有兩層意義,1.保證可見性 2.阻止程式碼重排。
先看第一條,這個問題我到現在還是有疑問,這一條的意思是說一個執行緒修改了一個被volatile修飾的變數的值,這新值對其他執行緒來說是立即可見的。可是在網上找了好久也沒有說到底是如何實現立即可見的,先來看java記憶體模型都定義了哪些操作:
- lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。
- unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
- read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
- load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
- use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
- assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
- store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。
其中當要修改主記憶體中的值時,要先複製到工作記憶體(快取記憶體)中,然後修改工作記憶體,然後複製回工作記憶體,由於需要store和write兩步才能將值寫回主記憶體,所以對於普通變數來說有可能剛執行完store就被切換執行緒了,也就是說操作完了但是主記憶體卻沒變,因此可能出現問題,也就是不可見性,而volatile避免了這種不確定性(注意volatile還有個作用是讓所有該變數的快取無效,即在讀這個變數時一定要去主存讀),我的理解就是強制將這兩步繫結到了一起,也就是store完之後必須馬上write,不許幹別的
在happens-before原則中有一條是關於volatile的:
volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作 也大體讓我堅信了這一點,不知道這個理解是否正確。
然後很經典的一段程式碼:
//執行緒1
boolean stop = false;
while(!stop) {
doSomething();
}
//執行緒2
stop = true;
網上很多說出現死迴圈是因為
執行緒2更改了stop變數的值之後,沒來得及寫入主存當中,執行緒2被切換了,執行緒1由於不知道執行緒2對stop變數的更改,因此死迴圈。
我是死活沒明白為什麼會死迴圈,就算是沒來得及寫入主存,那總會有重新切回執行緒2的那時候,然後繼續把stop寫回主存,也根本不會出現死迴圈吧。。
我認為正確的是JVM在某種情況下會將執行緒1的程式碼優化成如下程式碼:
boolean stop = false;
if (!stop) {
while(!true) {
doSomething();
}
}
那麼這種情況下確實很容易出現死迴圈,而且這種優化在JVM開啟了server模式才會出現,而加了volatile之後就不會出現的原因應該就是阻止了程式碼重排,也就是阻止了這種優化。下面來說一下volatile阻止程式碼重排。
在進行指令優化時,不能將在對volatile變數訪問的語句放在其後面執行,也不能把volatile變數後面的語句放到其前面執行。
主要體現在:
i = 0; // 1
j = 1; // 2
flag = true; // volatile修飾
x = 10; // 3
y = "111"; //4
那麼這段程式碼flag上下的程式碼不能互相越界,但是1和2,3和4仍然可以交換順序。
還有個很經典的問題,看程式碼:
public volatile int i = 0;
for (int i = 0; i < 10; ++i) {
new Thread(() -> {
for (int j = 0; j < 100000; ++j) i++;
}).start();
}
這段程式碼i最終會小於1e6,原因是i++沒有原子性,因為i++由三步組成:
- 讀取i的值
- 計算i+1
- 重新賦值給i
然後一中順序就是
1.執行緒A讀取i的值(假設為1)
2.執行緒切換到B,執行緒B取i的值,計算i+1=2,賦值給i=2
3.執行緒切換回A,A計算i+1=2,賦值給i=2
也就是隻加了1,那麼這裡就會有人有疑問了,不是說violate關鍵字會讓i對其他執行緒可見並且讓i的快取無效嗎,那為何第3步的時候執行緒A計算i+1還是等於2,這裡的原因就是,實際上i++的真正操作是這樣子的:
- 取i的值到棧頂
- 將棧頂元素+1
- 賦值回i
因此這裡的第二步並沒有訪問i,因此也就看不到i的更新了。
/*******************************/
更新一波,面試的時候面試官對指令重排這一部分產生了質疑,今天做了個實驗,發現並沒有出現所謂的指令重排,我現在是徹底懵逼了。。。。。上程式碼:
private static int a = 0;
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000000; i++) {
ThreadA threadA = new ThreadA();
ThreadB threadB = new ThreadB();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
a = 0;
flag = false;
}
}
static class ThreadA extends Thread {
public void run() {
a = 1;
flag = true;
}
}
static class ThreadB extends Thread {
public void run() {
if (flag) {
if (a == 0) System.out.println("a == 0!!");
}
}
}
**
如果文章寫得有問題,請一定要指正!!
**