一、概念
volatile 是 Java 中的關(guān)鍵字,是一個(gè)變量修飾符,被用來修飾會被不同線程訪問和修改的變量。
二、volatile 作用
1. 可見性
可見性是指多個(gè)線程訪問同一個(gè)變量時(shí),其中一個(gè)線程修改了該變量的值,其它線程能夠立即看到修改的值。
在 Java 內(nèi)存模型中,所有的變量都存儲在主存中,同時(shí)每個(gè)線程都擁有自己的工作線程,用于提高訪問速度。線程會從主存中拷貝變量值到自己的工作內(nèi)存中,然后在自己的工作線程中操作變量,而不是直接操作主存中的變量,由于每個(gè)線程在自己的內(nèi)存中都有一個(gè)變量的拷貝,就會造成變量值不一致的問題。
如下面的代碼所示:
測試類:
class VolatileTestObj {
private String value = null;
private boolean hasNewValue = false;
public void put(String value) {
while (hasNewValue) {
// 等待,防止重復(fù)賦值
}
this.value = value;
hasNewValue = true;
}
public String get() {
while (!hasNewValue) {
// 等待,防止獲取到舊值
}
String value = this.value;
hasNewValue = false;
return value;
}
}
測試代碼:
public class VolatileTest {
public static void main(String... args) {
VolatileTestObj obj = new VolatileTestObj();
new Thread(() -> {
while (true) {
obj.put("time:" + System.currentTimeMillis());
}
}).start();
new Thread(() -> {
while (true) {
System.out.println(obj.get());
}
}).start();
}
}
以上測試代碼中,一個(gè)線程進(jìn)行賦值操作,另一個(gè)線程取值,運(yùn)行該測試代碼可以發(fā)現(xiàn),很容易阻塞在循環(huán)等待中。
這是因?yàn)閷懢€程寫入一個(gè)新值,同時(shí)將 hasNewValue 置為 true,但是只更新了寫線程自己工作線程的緩存值,沒有更新主存中的值。而讀線程在獲取新值是,其工作線程中的 hasNewValue 為 false,會陷入到循環(huán)等待中,即使寫線程寫了新值,讀線程也無法獲取。因?yàn)樽x線程沒有獲取都新值,寫線程的 hasNewValue 沒有被置回 false,所以寫線程也會陷入到循環(huán)等待中。因此產(chǎn)生了死鎖。
使用 volatile 關(guān)鍵字可以解決這個(gè)問題,使用 volatile 修飾的變量確保了線程不會將該變量拷貝到自己的工作線程中,所有線程對該變量的操作都是在主存中進(jìn)行的,所以 volatile 修飾的變量對所有線程可見。
使用 volatile 修飾 hasNewValue,這樣在寫線程和讀線程中都是在主存中操作 hasNewValue 的值,就不會產(chǎn)生死鎖。
2. 原子性
volatile 只保證單次讀/寫操作的原子性,對于多步操作,volatile 不能保證原子性,如下代碼所示:
測試類:
class VolatileCounter {
private volatile int count = 0;
public void inc() {
count++;
}
public void dec() {
count--;
}
public int get() {
return count;
}
}
測試代碼:
public class VolatileTest {
public static void main(String... args) {
while (true) {
VolatileCounter counter = new VolatileCounter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50; i++) {
counter.inc();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 50; i++) {
counter.dec();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("counter = " + counter.get());
}
}
}
運(yùn)行結(jié)果:
...
counter = 0
counter = 0
counter = 0
counter = 0
counter = -21
counter = 0
counter = 0
counter = 0
counter = 0
...
從運(yùn)行結(jié)果可以看出,絕大部分情況下輸出結(jié)果為 counter = 0,但也有部分其它結(jié)果。由此可知,對于 count++; 和 count--; 這兩個(gè)操作并不具有原子性。
這是因?yàn)?count++ 是一個(gè)復(fù)合操作,包括三個(gè)部分:
讀取 count 的值;
對 count 加 1;
將 count 的值寫回內(nèi)存;
volatile 對于這三步操作是無法保證原子性的,所以會出現(xiàn)上述運(yùn)行結(jié)果。
所以,vloatile 并不能解決所有同步的問題
3. 有序性
在 Java 內(nèi)存模型中,允許編譯器和處理器對指令進(jìn)行重排序,重排序過程不會影響到單線程程序的執(zhí)行,但是會影響到多線程并發(fā)執(zhí)行的正確性。
volatile 關(guān)鍵字可以禁止指令重新排序,可以保證一定的有序性。
volatile 修飾的變量的有序性有兩層含義:
所有在 volatile 修飾的變量寫操作之前的寫操作,將會對隨后該 volatile 修飾的變量讀操作之后的語句可見。
禁止 JVM 重排序:volatile 修飾的變量的讀寫指令不能和其前后的任何指令重排序,其前后的指令可能會被重排序。
3.1 happen-before
happen-before 關(guān)系是用來判斷是否存在數(shù)據(jù)競爭、線程是否安全的主要依據(jù),也是指令重排序的依據(jù),保證了多線程下的可見性。
volatile 修飾的變量在讀寫時(shí)會建立 happen-before 關(guān)系。
如下面的測試類:
class VolatileOrder {
int i = 0;
volatile boolean flag = false;
public void write() {
i = 1; // 步驟 1
flag = true; // 步驟 2
}
public String get() {
if (flag) { // 步驟 3
System.out.println("i = " + i); // 步驟 4
}
}
}
上面的代碼依據(jù) happen-before 原則(關(guān)于 happen-before 原則可自行搜索)會建立如下的關(guān)系:
根據(jù) happen-before 單線程順序原則會有:步驟 1 happen-before 步驟 2、步驟 3 happen-before 步驟 4;
根據(jù) happen-before 的 volatile 原則會有:步驟 2 happen-before 步驟 3;
根據(jù) happen-before 的傳遞性原則會有:步驟 1 happen-before 步驟 4;
所以 步驟 1 對于 步驟 4 是可見的,即變量 i 在多個(gè)線程中具有可見性。
這也解釋了 volatile 有序性的第一層含義:所有在 volatile 修飾的變量寫操作之前的寫操作,將會對隨后該 volatile 修飾的變量讀操作之后的語句可見。
利用這個(gè)特性可以優(yōu)化變量在線程間的可見性,不需要對每個(gè)變量都用 volatile 修飾,只需要用 volatile 修飾一部分變量即可保證其它變量在多線程間也具有可見性。
3.2 禁止 JVM 重排序
對于上述代碼,如果變量 flag 沒有使用 volatile 修飾,那么步驟 1 和步驟 2 就有可能被 JVM 重排序,就無法得到上述的 happen-before 關(guān)系,所以 volatile 修飾的變量禁止 JVM 重排序。
如下代碼所示:
class VolatileOrder {
int a, b, c;
volatile int d;
void write() {
a = 1;
b = 2;
c = 3;
d = 4;
}
void read() {
int D = d;
int A = a;
int B = b;
int C = c;
}
}
在 write() 方法中:
a = 1;
b = 2;
c = 3;
JVM 可能會重排序這三個(gè)指令,但是這三個(gè)指令一定是排在 d = 4; 這個(gè)指令之前。
同樣的,在 read() 方法中:
int A = a;
int B = b;
int C = c;
JVM 可能會重排序這三個(gè)指令,但是這三個(gè)指令一定是排在 int D = d; 這個(gè)指令之后。