1. 程式人生 > >既然synchronized是"萬能"的,為什麼還需要volatile呢?

既然synchronized是"萬能"的,為什麼還需要volatile呢?

在我的部落格和公眾號中,發表過很多篇關於併發程式設計的文章,之前的文章中我們介紹過了兩個在Java併發程式設計中比較重要的兩個關鍵字:synchronized和volatile

我們簡單回顧一下相關內容:

1、Java語言為了解決併發程式設計中存在的原子性、可見性和有序性問題,提供了一系列和併發處理相關的關鍵字,比如synchronized、volatile、final、concurren包等。(再有人問你Java記憶體模型是什麼,就把這篇文章發給他)

2、synchronized通過加鎖的方式,使得其在需要原子性、可見性和有序性這三種特性的時候都可以作為其中一種解決方案,看起來是“萬能”的。的確,大部分併發控制操作都能使用synchronized來完成。再有人問你synchronized是什麼,就把這篇文章發給他。

3、volatile通過在volatile變數的操作前後插入記憶體屏障的方式,保證了變數在併發場景下的可見性和有序性。再有人問你volatile是什麼,把這篇文章也發給他

4、volatile關鍵字是無法保證原子性的,而synchronized通過monitorenter和monitorexit兩個指令,可以保證被synchronized修飾的程式碼在同一時間只能被一個執行緒訪問,即可保證不會出現CPU時間片在多個執行緒間切換,即可保證原子性。Java的併發程式設計中的多執行緒問題到底是怎麼回事兒?

那麼,我們知道,synchronized和volatile兩個關鍵字是Java併發程式設計中經常用到的兩個關鍵字,而且,通過前面的回顧,我們知道synchronized可以保證併發程式設計中不會出現原子性、可見性和有序性問題,而volatile只能保證可見性和有序性,那麼,既生synchronized、何生volatile?

synchronized的問題

我們都知道synchronized其實是一種加鎖機制,那麼既然是鎖,天然就具備以下幾個缺點:

1、有效能損耗

雖然在JDK 1.6中對synchronized做了很多優化,如如適應性自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖等(深入理解多執行緒(五)—— Java虛擬機器的鎖優化技術),但是他畢竟還是一種鎖。

以上這幾種優化,都是儘量想辦法避免對Monitor(深入理解多執行緒(四)—— Moniter的實現原理)進行加鎖,但是,並不是所有情況都可以優化的,況且就算是經過優化,優化的過程也是有一定的耗時的。

所以,無論是使用同步方法還是同步程式碼塊,在同步操作之前還是要進行加鎖,同步操作之後需要進行解鎖,這個加鎖、解鎖的過程是要有效能損耗的。

關於二者的效能對比,由於虛擬機器對鎖實行的許多消除和優化,使得我們很難量化這兩者之間的效能差距,但是我們可以確定的一個基本原則是:volatile變數的讀操作的效能小號普通變數幾乎無差別,但是寫操作由於需要插入記憶體屏障所以會慢一些,即便如此,volatile在大多數場景下也比鎖的開銷要低。

2、產生阻塞

我們在深入理解多執行緒(一)——Synchronized的實現原理中介紹過關於synchronize的實現原理,無論是同步方法還是同步程式碼塊,無論是ACC_SYNCHRONIZED還是monitorenter、monitorexit都是基於Monitor實現的。

基於Monitor物件,當多個執行緒同時訪問一段同步程式碼時,首先會進入Entry Set,當有一個執行緒獲取到物件的鎖之後,才能進行The Owner區域,其他執行緒還會繼續在Entry Set等待。並且當某個執行緒呼叫了wait方法後,會釋放鎖並進入Wait Set等待。

所以,synchronize實現的鎖本質上是一種阻塞鎖,也就是說多個執行緒要排隊訪問同一個共享物件。

而volatile是Java虛擬機器提供的一種輕量級同步機制,他是基於記憶體屏障實現的。說到底,他並不是鎖,所以他不會有synchronized帶來的阻塞和效能損耗的問題。

volatile的附加功能

除了前面我們提到的volatile比synchronized效能好以外,volatile其實還有一個很好的附加功能,那就是禁止指令重排。

我們先來舉一個例子,看一下如果只使用synchronized而不使用volatile會發生什麼問題,就拿我們比較熟悉的單例模式來看。

我們通過雙重校驗鎖的方式實現一個單例,這裡不使用volatile關鍵字:

 1   public class Singleton {  
 2      private static Singleton singleton;  
 3       private Singleton (){}  
 4       public static Singleton getSingleton() {  
 5       if (singleton == null) {  
 6           synchronized (Singleton.class) {  
 7               if (singleton == null) {  
 8                   singleton = new Singleton();  
 9               }  
 10           }  
 11       }  
 12       return singleton;  
 13       }  
 14   }  

以上程式碼,我們通過使用synchronized對Singleton.class進行加鎖,可以保證同一時間只有一個執行緒可以執行到同步程式碼塊中的內容,也就是說singleton = new Singleton()這個操作只會執行一次,這就是實現了一個單例。

但是,當我們在程式碼中使用上述單例物件的時候有可能發生空指標異常。這是一個比較詭異的情況。

我們假設Thread1 和 Thread2兩個執行緒同時請求Singleton.getSingleton方法的時候:

Step1 ,Thread1執行到第8行,開始進行物件的初始化。 Step2 ,Thread2執行到第5行,判斷singleton == null。 Step3 ,Thread2經過判斷髮現singleton != null,所以執行第12行,返回singleton。 Step4 ,Thread2拿到singleton物件之後,開始執行後續的操作,比如呼叫singleton.call()。

以上過程,看上去並沒有什麼問題,但是,其實,在Step4,Thread2在呼叫singleton.call()的時候,是有可能丟擲空指標異常的。

之所有會有NPE丟擲,是因為在Step3,Thread2拿到的singleton物件並不是一個完整的物件。

我們這裡來分析一下,singleton = new Singleton();這行程式碼到底做了什麼事情,大致過程如下:

1、虛擬機器遇到new指令,到常量池定位到這個類的符號引用。 2、檢查符號引用代表的類是否被載入、解析、初始化過。 3、虛擬機器為物件分配記憶體。 4、虛擬機器將分配到的記憶體空間都初始化為零值。 5、虛擬機器對物件進行必要的設定。 6、執行方法,成員變數進行初始化。 7、將物件的引用指向這個記憶體區域。

我們把這個過程簡化一下,簡化成3個步驟:

a、JVM為物件分配一塊記憶體M b、在記憶體M上為物件進行初始化 c、將記憶體M的地址複製給singleton變數

因為將記憶體的地址賦值給singleton變數是最後一步,所以Thread1在這一步驟執行之前,Thread2在對singleton==null進行判斷一直都是true的,那麼他會一直阻塞,直到Thread1將這一步驟執行完。

但是,以上過程並不是一個原子操作,並且編譯器可能會進行重排序,如果以上步驟被重排成:

a、JVM為物件分配一塊記憶體M c、將記憶體的地址複製給singleton變數 b、在記憶體M上為物件進行初始化

這樣的話,Thread1會先執行記憶體分配,在執行變數賦值,最後執行物件的初始化,那麼,也就是說,在Thread1還沒有為物件進行初始化的時候,Thread2進來判斷singleton==null就可能提前得到一個false,則會返回一個不完整的sigleton物件,因為他還未完成初始化操作。

這種情況一旦發生,我們拿到了一個不完整的singleton物件,當嘗試使用這個物件的時候就極有可能發生NPE異常。

那麼,怎麼解決這個問題呢?因為指令重排導致了這個問題,那就避免指令重排就行了。

所以,volatile就派上用場了,因為volatile可以避免指令重排。只要將程式碼改成以下程式碼,就可以解決這個問題:

 1   public class Singleton {  
 2      private volatile static Singleton singleton;  
 3       private Singleton (){}  
 4       public static Singleton getSingleton() {  
 5       if (singleton == null) {  
 6           synchronized (Singleton.class) {  
 7               if (singleton == null) {  
 8                   singleton = new Singleton();  
 9               }  
 10           }  
 11       }  
 12       return singleton;  
 13       }  
 14   }  

對singleton使用volatile約束,保證他的初始化過程不會被指令重排。

synchronized的有序性保證呢?

看到這裡可能有朋友會問了,說到底上面問題還是個有序性的問題,不是說synchronized是可以保證有序性的麼,這裡為什麼就不行了呢?

首先,可以明確的一點是:synchronized是無法禁止指令重排和處理器優化的。那麼他是如何保證的有序性呢?

這就要再把有序性的概念擴充套件一下了。Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有操作都是天然有序的。如果在一個執行緒中觀察另一個執行緒,所有操作都是無序的。

以上這句話也是《深入理解Java虛擬機器》中的原句,但是怎麼理解呢?周志明並沒有詳細的解釋。這裡我簡單擴充套件一下,這其實和as-if-serial語義有關。

as-if-serial語義的意思指:不管怎麼重排序,單執行緒程式的執行結果都不能被改變。編譯器和處理器無論如何優化,都必須遵守as-if-serial語義。

這裡不對as-if-serial語義詳細展開了,簡單說就是,as-if-serial語義保證了單執行緒中,不管指令怎麼重排,最終的執行結果是不能被改變的。

那麼,我們回到剛剛那個雙重校驗鎖的例子,站在單執行緒的角度,也就是隻看Thread1的話,因為編譯器會遵守as-if-serial語義,所以這種優化不會有任何問題,對於這個執行緒的執行結果也不會有任何影響。

但是,Thread1內部的指令重排卻對Thread2產生了影響。

那麼,我們可以說,synchronized保證的有序性是多個執行緒之間的有序性,即被加鎖的內容要按照順序被多個執行緒執行。但是其內部的同步程式碼還是會發生重排序,只不過由於編譯器和處理器都遵循as-if-serial語義,所以我們可以認為這些重排序在單執行緒內部可忽略。

總結

本文從兩方面論述了volatile的重要性以及不可替代性:

一方面是因為synchronized是一種鎖機制,存在阻塞問題和效能問題,而volatile並不是鎖,所以不存在阻塞和效能問題。

另外一方面,因為volatile藉助了記憶體屏障來幫助其解決可見性和有序性問題,而記憶體屏障的使用還為其帶來了一個禁止指令重排的附件功能,所以在有些場景中是可以避免發生指令重排的問題的