上文說到了 synchronized,那麼就不得不說下 volatile關鍵字了,它們兩者經常協同處理多執行緒的安全問題。
volatile保證可見性
那麼volatile的作用是什麼呢?
在jvm執行時刻記憶體的分配中有一個記憶體區域是jvm虛擬機器棧,每一個執行緒執行時都有一個執行緒棧,
執行緒棧儲存了執行緒執行時候變數值資訊。當執行緒訪問某一個物件時候值的時候,首先通過物件的引用找到對應在堆記憶體的變數的值,
然後把堆記憶體變數的具體值load到執行緒本地記憶體中,建立一個變數副本,之後執行緒就不再和物件在堆記憶體變數值有任何關係,而是直接修改副本變數的值,
在修改完之後的某一個時刻(執行緒退出之前),自動把執行緒變數副本的值回寫到物件在堆中變數。這樣在堆中的物件的值就產生變化了。
注意這些操作並不是原子性,也就是 在read load之後,如果主記憶體count變數發生修改之後,執行緒工作記憶體中的值由於已經載入,
不會產生對應的變化,所以計算出來的結果會和預期不一樣,對於volatile修飾的變數,jvm虛擬機器只是保證從主記憶體載入到執行緒工作記憶體的值是最新的。
如何解決快取不一致的問題?
在早期的CPU當中,是通過在匯流排上加LOCK#鎖的形式來解決快取不一致的問題。
因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),
從而使得只能有一個CPU能使用這個變數的記憶體。比如一個執行緒在執行 i = i +1,如果在執行這段程式碼的過程中,在匯流排上發出了LCOK#鎖的訊號,
那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從變數i所在的記憶體讀取變數,然後進行相應的操作。
這樣就解決了快取不一致的問題。但是上面的方式會有一個問題,由於在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。
所以就出現了快取一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。
它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,
因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。
看一個例子:
public class MyThread15 extends Thread { private boolean isRunning = true; public boolean isRunning() { return isRunning; } public void setRunning(boolean isRunning) { this.isRunning = isRunning; } public void run() { System.out.println("進入run了"); while (isRunning == true){} System.out.println("執行緒被停止了"); } }
@Test public void test15() throws InterruptedException { try{ MyThread15 a = new MyThread15(); a.start(); Thread.sleep(1000); a.setRunning(false); System.out.println("已賦值為false"); } catch (InterruptedException e){ e.printStackTrace(); } a.join(); }
執行結果:
進入run了 已賦值為false
可以看待,明明已經將 isRunning設定為false了,但是“執行緒被停止了”還是沒有被執行。
當我們給isRunning加上關鍵字volatile後,執行結果:
進入run了 已賦值為false 執行緒被停止了
可見volatile關鍵字可以保證共享變數在多執行緒之間的可見性。
volatile不保證原子性
下面看個demo,定義一個執行緒類,將count值++ 10000次。
public class MyDomain16 { private volatile int count = 0; public MyDomain16() { } public void count() { for (int i=0; i<10000; i++) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } count++; } System.out.println(Thread.currentThread().getName() + ": count=" + count); } }
public class Mythread16 extends Thread { private MyDomain16 myDomain16; public Mythread16(MyDomain16 myDomain16) { this.myDomain16 = myDomain16; } public void run() { myDomain16.count(); } } @Test public void test16() throws InterruptedException { MyDomain16 myDomain16 = new MyDomain16(); Mythread16 a = new Mythread16(myDomain16); Mythread16 b = new Mythread16(myDomain16); a.start(); b.start(); a.join(); b.join(); }
執行結果:
Thread-1: count=17913 Thread-0: count=17919
可以看出兩個執行緒總共應該增加2萬次,但是count值並沒有等於20000,說明自增操作不是原子性操作,而且volatile也無法保證對變數的任何操作都是原子性的。
當我們給count方法加上synchronized之後
public class MyDomain16 { private volatile int count = 0; public MyDomain16() { } public synchronized void count() { for (int i=0; i<10000; i++) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } count++; } System.out.println(Thread.currentThread().getName() + ": count=" + count); } }
執行結果:
Thread-0: count=10000 Thread-1: count=20000
總結:
關鍵字synchronized和volatile進行一下比較:
1)關鍵字volatile是執行緒同步的輕量級實現,所以volatile效能肯定比synchronized要好,並且volatile只能修飾於變數,而synchronized可以修飾方法,以及程式碼塊。
隨著JDK新版本的釋出,synchronized關鍵字在執行效率上得到很大提升,在開發中使用synchronized關鍵字的比率還是比較大的。
2)多執行緒訪問volatile不會發生阻塞,而synchronized會出現阻塞。
3)volatile能保證資料的可見性,但不能保證原子性;而synchronized可以保證原子性,也可以間接保證可見性,因為它會將私有記憶體和公共記憶體中的資料做同步。
4)關鍵字volatile解決的是變數在多個執行緒之間的可見性;而synchronized關鍵字解決的是多個執行緒之間訪問資源的同步性。
執行緒安全包含原子性和可見性兩個方面,Java的同步機制都是圍繞這兩個方面來確保執行緒安全的。
參考文獻
1:《Java併發程式設計的藝術》
2:《Java多執行緒程式設計核心技術》