Java多線程之synchronized和volatile的比較
概述
在做多線程並發處理時,經常需要對資源進行可見度訪問和互斥同步操作。有時候,我們可能從前輩那裡得知我們需要對資源進行 volatile 或是 synchronized 關鍵字修飾處理。可是,我們卻不知道這兩者之間的區別,我們無法分辨在什麼時候應該使用哪一個關鍵字。本文就針對這個問題,展開討論。
記憶體語義分析happens-before 模型簡介
如果你單從字面上的意思來理解 happens-before 模型,你可能會覺得這是在說某一個操作在另一個操作之前執行。不過,學習完 happens-before 之後,你就不會還這樣理解了。以下是《Java 並發編程的藝術》書上對 happens-before 的定義:
在 JMM(Java Memory Model) 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關係。這裡提到的兩個操作既可以在一個線程之內,也可以是在不同的線程之間。
volatile 的記憶體語義
對於多線程編程來說,每個線程是可以擁有共用記憶體中變數的一個拷貝,這一點在後面還是會講到,這裡就不作過多說明。如果一個變數被 volatile 關鍵字修飾時,那麼對這的變數的寫是將本地記憶體中的拷貝重新整理到共用記憶體中;對這個變數的讀會有一些不同,讀的時候是無視他的本地記憶體的拷貝的,只是從共用變數中去讀取資料。
synchronized 的記憶體語義
我們說 synchronized 實際上是對變數進行加鎖處理。那麼不管是讀也好,寫也好都是基於對這個變數的加鎖操作。如果一個變數被 synchronized 關鍵字修飾,那麼對這的變數的寫是將本地記憶體中的拷貝重新整理到共用記憶體中;對這個變數的讀就是將共用記憶體中的值重新整理到本地記憶體,再從本地記憶體中讀取資料。因為全過程中變數是加鎖的,其他線程無法對這個變數進行讀寫操作。所以可以理解成對這個變數的任何操作具有原子性,即線程是安全的。
執行個體論證
上面的一些說明或是定義可能會有一些乏味枯燥,也不太好理解。這裡我們就列舉一些例子來說明,這樣比較具體和形象一些。
volatile 可見度測試
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 並不是原子操作。也就是在多線程的情況,仍然是不安全的。
可能,這個時候你會發問說,既然 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 = 300count = 300count = 300count = 400... ...count = 7618count = 7518count = 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 = 100count = 300count = 400count = 200... ...count = 9852count = 9752count = 9652... ...count = 8154count = 8054
不知道這個結果是不是會讓你感覺到意外。對於 count 的混亂的數字倒是好理解一些,應該多個線程同時修改時就發生這樣的事情。可是我們在結果為根本找不到邏輯上的最大值“10000”,這就有一些奇怪了。因為從邏輯上來說, volatile修改了 count 的可見度,對於線程 A 來說,它是可見線程 B 對 count 的修改的。只是從結果中並沒有體現這一點。
我們說,volatile並沒有保證安全執行緒。在上面子線程中的 addCount() 方法裡,執行的是 count++ 這樣一句代碼。而像 count++ 這樣一句代碼從學習Java變數自增的第一堂課上,老師就應該強調過它的執行過程。count++ 可以類比成以下的過程:
int tmp = count;tmp = tmp + 1;count = tmp;
可見,count++ 並非原子操作。任何兩個線程都有可能將上面的代碼分離進行,安全性便無從談起了。
所以,到這裡我們知道了 volatile 可以改變變數線上程之間的可見度,卻不能改變線程之間的同步。而同步操作則需要其他的操作來保證。
synchronized 同步測試
上面說到 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 = 100count = 200count = 300... ...count = 9800count = 9900count = 10000
通過 synchronized 我們可以很容易就獲得了理想的結果。而關於 synchronized 關鍵字的記憶體模型可以這樣來表示:
某一個線程在訪問一個被 synchronized 修飾的變數時,會對此變數的共用記憶體進行加鎖,那麼這個時候其他線程對其的訪問就會被互斥。 synchronized 的內部實現其實也是鎖的概念。
Ref《Java多線程編程核心技術》 《Java並發編程的藝術》