在做多線程並發處理時,經常需要對資源進行可見性訪問和互斥同步操作。有時候,我們可能從前輩那裡得知我們需要對資源進行 volatile 或是 synchronized 關鍵字修飾處理。可是,我們卻不知道這兩者之間的區別,我們無法分辨在什麼時候應該使用哪一個關鍵字。本文就針對這個問題,展開討論。
如果你單從字面上的意思來理解 happens-before 模型,你可能會覺得這是在說某一個操作在另一個操作之前執行。不過,學習完 happens-before 之後,你就不會還這樣理解了。以下是《Java 並發編程的藝術》書上對 happens-before 的定義:
在 JMM(Java Memory Model) 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關系。這裡提到的兩個操作既可以在一個線程之內,也可以是在不同的線程之間。
對於多線程編程來說,每個線程是可以擁有共享內存中變量的一個拷貝,這一點在後面還是會講到,這裡就不作過多說明。如果一個變量被 volatile 關鍵字修飾時,那麼對這的變量的寫是將本地內存中的拷貝刷新到共享內存中;對這個變量的讀會有一些不同,讀的時候是無視他的本地內存的拷貝的,只是從共享變量中去讀取數據。
我們說 synchronized 實際上是對變量進行加鎖處理。那麼不管是讀也好,寫也好都是基於對這個變量的加鎖操作。如果一個變量被 synchronized 關鍵字修飾,那麼對這的變量的寫是將本地內存中的拷貝刷新到共享內存中;對這個變量的讀就是將共享內存中的值刷新到本地內存,再從本地內存中讀取數據。因為全過程中變量是加鎖的,其他線程無法對這個變量進行讀寫操作。所以可以理解成對這個變量的任何操作具有原子性,即線程是安全的。
上面的一些說明或是定義可能會有一些乏味枯燥,也不太好理解。這裡我們就列舉一些例子來說明,這樣比較具體和形象一些。
RunThread.java
public class RunThread extends Thread {
private boolean isRunning = true;
public boolean isRunning() {
return isRunning;
}
public void setRunFlag(boolean flag) {
isRunning = flag;
}
@Override
public void run() {
System.out.println("I'm come in...");
boolean first = true;
while(isRunning) {
if (first) {
System.out.println("I'm in while...");
first = false;
}
}
System.out.println("I'll go out.");
}
}
MyRun.java
public class MyRun {
public static void main(String[] args) throws InterruptedException {
RunThread thread = new RunThread();
thread.start();
Thread.sleep(100);
thread.setRunFlag(false);
System.out.println("flag is reseted: " + thread.isRunning());
}
}
對於上面的例子只是一個很普通的多線程操作,這裡我們很容易就得到了 RunThread 線程在 while 中進入了死循環。
我們可以在 main() 方法裡看到一句 Thread.sleep(100) ,結合前面說到的 happens-before 內存模型,可知下面的 thread.setRunFlag(false) 並不會 happens-before 子線程中的 while 。這樣一來,雖然主線程中對 isRunning 進行了修改,然而對子線程中的 while 來說,並沒有改變,所以這就會引發在 while 中的死循環。
在這種情況下,線程工作時的內存模型像下面這樣
在這裡,可能你會奇怪,為什麼會有兩個“內存塊”?這是出於多線程的性能考慮的。雖然對象以及成員變量分配的內存是在共享內存中的,不過對於每個線程而言,還是可以擁有這個對象的拷貝,這樣做的目的是為了加快程序的執行,這也是現代多核處理器的一個顯著特征。從上面的內存模型可以看出,Java的線程是直接與它自身的工作內存(本地內存)交互,工作內存再與共享內存交互。這樣就形成了一個非原子的操作,在Java裡多線程的環境下非原子的操作是很危險的。這個我們都已經知道了,因為這可能會被異步的讀寫操作所破壞。
這裡工作內存被 while 占用,無法去更新主線程對共享內存 isRunning 變量的修改。所以,如果我們想要打破這種限制,可以通過 volatile 關鍵字來處理。通過 volatile 關鍵字修飾 while 的條件變量,即 isRunning。就像下面這樣修改 RunThread.java 代碼:<喎?http://www.Bkjia.com/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxwcmUgY2xhc3M9"brush:java;">
private volatile boolean isRunning = true;
這樣一來, volatile 修改了 isRunning 的可見性,使得主線程的 thread.setRunFlag(false) 將會 happens-before 子線程中的 while 。最終,使得子線程從 while 的循環中跳出,問題解決。
下面我們來看看 volatile 是如何修改了 isRunning 的可見性的吧。
這裡,因為 isRunning 被 volatile 修飾,那麼當子線程想要訪問工作內存中的 inRunning 時,被強制地直接從共享內存中獲取。而共享內存中的 isRunning 被主線程修改過了,已經被修改成了 false ,while 被打破,這樣子線程就從 while 的循環中跳出來了。
volatile 確實有很多優點,可是它卻有一個致命的缺點,那就是 volatile 並不是原子操作。也就是在多線程的情況,仍然是不安全的。
可能,這個時候你會發問說,既然 volatile 保證了它在線程間的可見性,那麼在什麼時候修改它,怎麼修改它,對於其他線程是可見的,某一個線程讀到的都會是修改過的值,為什麼還要說它還是不安全的呢?
我們通過一個例子來說明吧,這樣更形象一些。大家看下面這樣一段代碼:
public class DemoNoProtected {
static class MyThread extends Thread {
static int count = 0;
private static void addCount() {
for (int i = 0; i < 100; i++) {
count++;
}
System.out.println("count = " + count);
}
@Override
public void run() {
addCount();
}
}
public static void main(String[] args) {
MyThread[] threads = new MyThread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new MyThread();
}
for (int i = 0; i < 100; i++) {
threads[i].start();
}
}
}
count = 300
count = 300
count = 300
count = 400
... ...
count = 7618
count = 7518
count = 9918
這是一個未經任何處理的,很直白的過程。可是它的結果,也很直白。其實這個結果並不讓人意外,從我們學習Java的時候,就知道Java的多線程並不安全。是不是從上面的學習中,你感覺這個可以通過 volatile 關鍵字解決?既然你這麼說,那麼我們就來試一試,給 count 變量添加 volatile 關鍵字,如下:
public class DemoVolatile {
static class MyThread extends Thread {
static volatile int count = 0;
... ...
}
public static void main(String[] args) {
... ...
}
}
count = 100
count = 300
count = 400
count = 200
... ...
count = 9852
count = 9752
count = 9652
... ...
count = 8154
count = 8054
不知道這個結果是不是會讓你感覺到意外。對於 count 的混亂的數字倒是好理解一些,應該多個線程同時修改時就發生這樣的事情。可是我們在結果為根本找不到邏輯上的最大值“10000”,這就有一些奇怪了。因為從邏輯上來說, volatile修改了 count 的可見性,對於線程 A 來說,它是可見線程 B 對 count 的修改的。只是從結果中並沒有體現這一點。
我們說,volatile並沒有保證線程安全。在上面子線程中的 addCount() 方法裡,執行的是 count++ 這樣一句代碼。而像 count++ 這樣一句代碼從學習Java變量自增的第一堂課上,老師就應該強調過它的執行過程。count++ 可以類比成以下的過程:
int tmp = count;
tmp = tmp + 1;
count = tmp;
可見,count++ 並非原子操作。任何兩個線程都有可能將上面的代碼分離進行,安全性便無從談起了。
所以,到這裡我們知道了 volatile 可以改變變量在線程之間的可見性,卻不能改變線程之間的同步。而同步操作則需要其他的操作來保證。
上面說到 volatile 不能解決線程的安全性問題,這是因為 volatile 不能構建原子操作。而在多線程編程中有一個很方便的同步處理,就是 synchronized 關鍵字。下面來看看 synchronized 是如何處理多線程同步的吧,代碼如下:
public class DemoSynchronized {
static class MyThread extends Thread {
static int count = 0;
private synchronized static void addCount() {
for (int i = 0; i < 100; i++) {
count++;
}
System.out.println("count = " + count);
}
@Override
public void run() {
addCount();
}
}
public static void main(String[] args) {
MyThread[] threads = new MyThread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new MyThread();
}
for (int i = 0; i < 100; i++) {
threads[i].start();
}
}
}
count = 100
count = 200
count = 300
... ...
count = 9800
count = 9900
count = 10000
通過 synchronized 我們可以很容易就獲得了理想的結果。而關於 synchronized 關鍵字的內存模型可以這樣來表示:
某一個線程在訪問一個被 synchronized 修飾的變量時,會對此變量的共享內存進行加鎖,那麼這個時候其他線程對其的訪問就會被互斥。 synchronized 的內部實現其實也是鎖的概念。