4個點說清楚Java中synchronized和volatile的區別

做者 : Hollis

回顧一下兩個關鍵字:synchronized和volatile

一、Java語言爲了解決併發編程中存在的原子性、可見性和有序性問題,提供了一系列和併發處理相關的關鍵字,好比synchronized、volatile、final、concurren包等。
二、synchronized經過加鎖的方式,使得其在須要原子性、可見性和有序性這三種特性的時候均可以做爲其中一種解決方案,看起來是「萬能」的。的確,大部分併發控制操做都能使用synchronized來完成。
三、volatile經過在volatile變量的操做先後插入內存屏障的方式,保證了變量在併發場景下的可見性和有序性。
四、volatile關鍵字是沒法保證原子性的,而synchronized經過monitorenter和monitorexit兩個指令,能夠保證被synchronized修飾的代碼在同一時間只能被一個線程訪問,便可保證不會出現CPU時間片在多個線程間切換,便可保證原子性。
那麼,咱們知道,synchronized和volatile兩個關鍵字是Java併發編程中常常用到的兩個關鍵字,並且,經過前面的回顧,咱們知道synchronized能夠保證併發編程中不會出現原子性、可見性和有序性問題,而volatile只能保證可見性和有序性,那麼,既生synchronized、何生volatile?
接下來,本文就來論述一下,爲何Java中已經有了synchronized關鍵字,還要提供volatile關鍵字。

Synchronized的問題

咱們都知道synchronized實際上是一種加鎖機制,那麼既然是鎖,自然就具有如下幾個缺點:

一、有性能損耗

雖然在JDK 1.6中對synchronized作了不少優化,如如適應性自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖等,可是他畢竟仍是一種鎖。
以上這幾種優化,都是儘可能想辦法避免對Monitor進行加鎖,可是,並非全部狀況均可以優化的,何況就算是通過優化,優化的過程也是有必定的耗時的。
因此,不管是使用同步方法仍是同步代碼塊,在同步操做以前仍是要進行加鎖,同步操做以後須要進行解鎖,這個加鎖、解鎖的過程是要有性能損耗的。
關於兩者的性能對比,因爲虛擬機對鎖實行的許多消除和優化,使得咱們很難量化這二者之間的性能差距,可是咱們能夠肯定的一個基本原則是:volatile變量的讀操做的性能小號普通變量幾乎無差異,可是寫操做因爲須要插入內存屏障因此會慢一些,即使如此,volatile在大多數場景下也比鎖的開銷要低。

二、產生阻塞

關於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();這行代碼到底作了什麼事情,大體過程以下:
  • 一、虛擬機遇到new指令,到常量池定位到這個類的符號引用。
  • 二、檢查符號引用表明的類是否被加載、解析、初始化過。
  • 三、虛擬機爲對象分配內存。
  • 四、虛擬機將分配到的內存空間都初始化爲零值。
  • 五、虛擬機對對象進行必要的設置。
  • 六、執行方法,成員變量進行初始化。
  • 七、將對象的引用指向這個內存區域。
咱們把這個過程簡化一下,簡化成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約束,保證他的初始化過程不會被指令重排。這樣就能夠保Thread2 要否則就是拿不到對象,要否則就是拿到一個完整的對象。

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藉助了內存屏障來幫助其解決可見性和有序性問題,而內存屏障的使用還爲其帶來了一個禁止指令重排的附件功能,因此在有些場景中是能夠避免發生指令重排的問題的。
因此,在往後須要作併發控制的時候,若是不涉及到原子性的問題,能夠優先考慮使用volatile關鍵字。

最後

歡迎你們關注個人公衆號【程序員追風】,文章都會在裏面更新,整理的資料也會放在裏面。
相關文章
相關標籤/搜索