當多個線程訪問同一個對象時,若是不用考慮這些線程在運行時環境下的調度和交替運行,也不須要進行額外的同步,或者在調用方進行任何其餘的協調操做,調用這個對象的行爲均可以獲取正確的結果,那這個對象是線程安全的。 關於定義的理解這是一個仁者見仁智者見智的事情。出現線程安全的問題通常是由於主內存和工做內存數據不一致性和重排序致使的,而解決線程安全的問題最重要的就是理解這兩種問題是怎麼來的,那麼,理解它們的核心在於理解 java 內存模型(JMM)。java
Java 內存模型,即 Java Memory Model,簡稱 JMM
,它是一種抽象的概念,或者是一種協議,用來解決在併發編程過程當中內存訪問的問題,同時又能夠兼容不一樣的硬件和操做系統,JMM 的原理與硬件一致性的原理相似。在硬件一致性的實現中,每一個 CPU 會存在一個高速緩存,而且各個 CPU 經過與本身的高速緩存交互來向共享內存中讀寫數據。編程
以下圖所示,在 Java 內存模型中,全部的變量都存儲在主內存。每一個 Java 線程都存在着本身的工做內存,工做內存中保存了該線程用獲得的變量的副本,線程對變量的讀寫都在工做內存中完成,沒法直接操做主內存,也沒法直接訪問其餘線程的工做內存。當一個線程之間的變量的值的傳遞必須通過主內存。緩存
當兩個線程 A 和線程 B 之間要完成通訊的話,要經歷以下兩步:安全
volatile 關鍵字使得每次 volatile 變量都可以強制刷新到主存,從而對每一個線程都是可見的。多線程
須要注意的是,JMM 與 Java 內存區域的劃分是不一樣的概念層次,更恰當說 JMM 描述的是一組規則,經過這組規則控制程序中各個變量在共享數據區域和私有數據區域的訪問方式。在 JMM 中主內存屬於共享數據區域,從某個程度上講應該包括了堆和方法區,而工做內存數據線程私有數據區域,從某個程度上講則應該包括程序計數器、虛擬機棧以及本地方法棧。併發
上面介紹了 JMM 中主內存和工做內存交互以及線程之間通訊的原理,可是具體到各個內存之間如何進行變量的傳遞,JMM 定義了 8 種操做,用來實現主內存與工做內存之間的具體交互協議:app
lock
(鎖定):做用於主內存的變量,把一個變量標識爲一條線程獨佔狀態;unlock
(解鎖):做用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定;read
(讀取):做用於主內存變量,把一個變量值從主內存傳輸到線程的工做內存中,以便隨後的 load 動做使用;load
(載入):做用於工做內存的變量,它把 read 操做從主內存中獲得的變量值放入工做內存的變量副本中;use
(使用):做用於工做內存的變量,把工做內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個須要使用變量的值的字節碼指令時將會執行這個操做;assign
(賦值):做用於工做內存的變量,它把一個從執行引擎接收到的值賦值給工做內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操做;store
(存儲):做用於工做內存的變量,把工做內存中的一個變量的值傳送到主內存中,以便隨後的 write 的操做;write
(寫入):做用於主內存的變量,它把 store 操做從工做內存中一個變量的值傳送到主內存的變量中。若是要把一個變量從主內存中複製到工做內存,就須要按順尋地執行 read
和 load
操做,若是把變量從工做內存中同步回主內存中,就要按順序地執行 store
和 write
操做。Java 內存模型只要求上述兩個操做必須按順序執行,而沒有保證必須是連續執行。也就是 read
和 load
之間,store
和 write
之間是能夠插入其餘指令的,如對主內存中的變量 a
、b
進行訪問時,可能的順序是 read a
,read b
,load b
, load a
。函數式編程
Java內存模型還規定了在執行上述八種基本操做時,必須知足以下規則:函數
read
和 load
、store
和 write
操做之一單獨出現;assign
的操做,即變量在工做內存中改變了以後必須同步到主內存中;assign
操做)把數據從工做內存同步回主內存中;use
和 store
操做以前,必須先執行過了 assign
和 load
操做;lock
操做,lock
和 unlock
必須成對出現;lock
操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量前須要從新執行 load
或 assign
操做初始化變量的值;lock
操做鎖定,則不容許對它執行 unlock
操做,也不容許去unlock一個被其餘線程鎖定的變量;unlock
操做以前,必須先把此變量同步到主內存中(執行 store
和 write
操做)。此外,虛擬機還對 voliate 關鍵字和 long 及 double 作了一些特殊的規定。post
咱們如下面的程序爲例來講明 voliate 是如何防止指令重排序:
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) { // 1
sychronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton(); // 2
}
}
}
return singleton;
}
}
複製代碼
實際上當程序執行到 2 處的時候,若是咱們沒有使用 voliate 關鍵字修飾變量 singleton,就可能會形成錯誤。這是由於使用 new
關鍵字初始化一個對象的過程並非一個原子的操做,它分紅下面三個步驟進行:
若是虛擬機存在指令重排序優化,則步驟 2 和 3 的順序是沒法肯定的。若是 A 線程率先進入同步代碼塊並先執行了 3 而沒有執行 2,此時由於 singleton 已經非 null。這時候線程 B 到了 1 處,判斷 singleton 非 null 並將其返回使用,由於此時 Singleton 實際上還未初始化,天然就會出錯。
可是特別注意在 jdk 1.5 之前的版本使用了 volatile 的雙檢鎖仍是有問題的。其緣由是Java 5之前的JMM(Java 內存模型)是存在缺陷的,即時將變量聲明成 volatile 也不能徹底避免重排序,主要是 volatile 變量先後的代碼仍然存在重排序問題。這個 volatile 屏蔽重排序的問題在 jdk 1.5 (JSR-133) 中才得以修復,這時候 jdk 對 volatile 加強了語義,對 volatile 對象都會加入讀寫的內存屏障,以此來保證可見性,這時候 2-3 就變成了代碼序而不會被 CPU 重排,因此在這以後才能夠放心使用 volatile.
虛擬機除了對 voliate 關鍵字作了特殊規定,還對 long 及 double 作了一些特殊的規定:容許沒有被 volatile 修飾的 long 和 double 類型的變量讀寫操做分紅兩個 32 位操做。也就是說,對 long 和 double 的讀寫是非原子的,它是分紅兩個步驟來進行的。可是,你能夠經過將它們聲明爲 voliate 的來保證對它們的讀寫的原子性。
Java 內存模型是經過各類操做定義的,JMM 爲程序中全部的操做定義了一個偏序關係,就是先行發生原則 (Happens-before)。它是判斷數據是否存在競爭、線程是否安全的主要依據。想要保證執行操做B的線程看到操做 A 的結果,那麼在 A 和 B 之間必須知足 Happens-before 關係,不然 JVM 就能夠對它們任意地排序。
先行發生原則主要包括下面幾項,當兩個變量之間知足如下關係中的任意一個的時候,咱們就能夠判斷它們之間的是存在前後順序的,串行執行的。
程序次序規則 (Program Order Rule)
:在同一個線程中,按照程序代碼順序,書寫在前面的操做先行發生於書寫在後面的操縱。準確的說是程序的控制流順序,考慮分支和循環等;管理鎖定規則 (Monitor Lock Rule)
:一個 unlock 操做先行發生於後面(時間上的順序)對同一個鎖的 lock 操做;volatile 變量規則 (Volatile Variable Rule)
:對一個 volatile 變量的寫操做先行發生於後面(時間上的順序)對該變量的讀操做;線程啓動規則 (Thread Start Rule)
:Thread 對象的 start()
方法先行發生於此線程的每個動做;線程終止規則 (Thread Termination Rule)
:線程的全部操做都先行發生於對此線程的終止檢測,能夠經過 Thread.join()
方法結束、Thread.isAlive()
的返回值等手段檢測到線程已經終止執行;線程中斷規則 (Thread Interruption Rule)
:對線程 interrupt()
方法的調用先行發生於被中斷線程的代碼檢測到中斷時事件的發生。Thread.interrupted()
能夠檢測是否有中斷髮生;對象終結規則 (Finilizer Rule)
:一個對象的初始化完成(構造函數執行結束)先行發生於它的 finalize()
的開始;傳遞性 (Transitivity)
:若是操做 A 先行發生於操做 B,操做 B 先行發生於操做 C,那麼能夠得出 A 先行發生於操做 C。不一樣操做時間前後順序與先行發生原則之間沒有關係,兩者不能相互推斷,衡量併發安全問題不能受到時間順序的干擾,一切都要以先行發生原則爲準。
若是兩個操做訪問同一個變量,且這兩個操做有一個爲寫操做,此時這兩個操做就存在數據依賴性這裏就存在三種狀況:1).讀後寫;2).寫後寫;3). 寫後讀,三種操做都是存在數據依賴性的,若是重排序會對最終執行結果會存在影響。編譯器和處理器在重排序時,會遵照數據依賴性,編譯器和處理器不會改變存在數據依賴性關係的兩個操做的執行順序。
還有就是as-if-serial語義:無論怎麼重排序(編譯器和處理器爲了提供並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵照 as-if-serial 語義。as-if-serial 語義保證單線程內程序的執行結果不被改變,happens-before 關係保證正確同步的多線程程序的執行結果不被改變。
先行發生原則 (happens-before) 和 as-if-serial 語義是虛擬機爲了保證執行結果不變的狀況下提供程序的並行度優化所遵循的原則,前者適用於多線程的情形,後者適用於單線程的環境。
在 Window 系統和 Linux 系統上,Java 線程的實現是基於一對一的線程模型,所謂的一對一模型,實際上就是經過語言級別層面程序去間接調用系統內核的線程模型,即咱們在使用 Java 線程時,Java 虛擬機內部是轉而調用當前操做系統的內核線程來完成當前任務。這裏須要瞭解一個術語,內核線程 (Kernel-Level Thread,KLT),它是由操做系統內核 (Kernel) 支持的線程,這種線程是由操做系統內核來完成線程切換,內核經過操做調度器進而對線程執行調度,並將線程的任務映射到各個處理器上。每一個內核線程能夠視爲內核的一個分身,這也就是操做系統能夠同時處理多任務的緣由。因爲咱們編寫的多線程程序屬於語言層面的,程序通常不會直接去調用內核線程,取而代之的是一種輕量級的進程 (Light Weight Process),也是一般意義上的線程,因爲每一個輕量級進程都會映射到一個內核線程,所以咱們能夠經過輕量級進程調用內核線程,進而由操做系統內核將任務映射到各個處理器,這種輕量級進程與內核線程間 1 對 1 的關係就稱爲一對一的線程模型。
如圖所示,每一個線程最終都會映射到 CPU 中進行處理,若是 CPU 存在多核,那麼一個 CPU 將能夠並行執行多個線程任務。
Java中可使用三種方式來保障程序的線程安全:1).互斥同步;2).非阻塞同步;3).無同步。
在Java中最基本的使用同步方式是使用 sychronized
關鍵字,該關鍵字在被編譯以後會在同步代碼塊先後造成 monitorenter
和 monitorexit
字節碼指令。這兩個字節碼都須要一個 reference 類型的參數來指明要鎖定和解鎖的對象。若是在 Java 程序中明確指定了對象參數,就會使用該對象,不然就會根據 sychronized 修飾的是實例方法仍是類方法,去去對象實例或者 Class 對象做爲加鎖對象。
synchronized 先天具備重入性:根據虛擬機的要求,在執行 sychronized 指令時,首先要嘗試獲取對象的鎖。若是這個對象沒有被鎖定,或者當前線程已經擁有了該對象的鎖,就把鎖的計數器加 1,相應地執行 monitorexit
指令時會將鎖的計數器減 1,當計數器爲 0 時就釋放鎖。若獲取對象鎖失敗,那當前線程就要阻塞等待,直到對象鎖被另一個線程釋放爲止。
除了使用 sychronized,咱們還可使用 JUC 中的 ReentrantLock 來實現同步,它與 sychronized 相似,區別主要表如今如下 3 個方面:
在 JDK1.5 以前,sychronized 在多線程環境下比 ReentrantLock 要差一些,可是在 JDK1.6 以上,虛擬機對 sychronized 的性能進行了優化,性能再也不是使用 ReentrantLock 替代 sychronized 的主要因素。
所謂非阻塞同步就是在實現同步的過程當中無需將線程掛起,它是相對於互斥同步而言的。互斥同步本質上是一種悲觀的併發策略,而非阻塞同步是一種樂觀的併發策略。在 JUC 中的許多併發組建都是基於 CAS 原理實現的,所謂 CAS就是 Compare-And-Swape,相似於樂觀加鎖。但與咱們熟知的樂觀鎖不一樣的是,它在判斷的時候會涉及到 3 個值:「新值」、「舊值」 和 「內存中的值」,在實現的時候會使用一個無限循環,每次拿 「舊值」 與 「內存中的值」 進行比較,若是兩個值同樣就說明 「內存中的值」 沒有被其餘線程修改過;不然就被修改過,須要從新讀取內存中的值爲 「舊值」,再拿 「舊值」 與 「內存中的值」 進行判斷。直到 「舊值」 與 「內存中的值」 同樣,就把 「新值」 更新到內存當中。
這裏要注意上面的 CAS 操做是分 3 個步驟的,可是這 3 個步驟必須一次性完成,由於否則的話,當判斷 「內存中的值」 與 「舊值」 相等以後,向內存寫入 「新值」 之間被其餘線程修改就可能會獲得錯誤的結果。JDK 中的sun.misc.Unsafe
中的 compareAndSwapInt
等一系列方法Native就是用來完成這種操做的。另外還要注意,上面的CAS操做存在一些問題:
AtomicReference
。所謂無同步方案就是不須要同步。
自旋鎖用來解決互斥同步過程當中線程切換的問題,由於線程切換自己是存在必定的開銷的。若是物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,咱們就可讓後面請求鎖的那個線程「稍等一會」,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,咱們只須讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。
自旋鎖在 JDK 1.4.2 中就已經引入,只不過默認是關閉的,可使用 -XX:+UseSpinning
參數來開啓,在 JDK 1.6 中就已經改成默認開啓了。自旋等待自己雖然避免了線程切換的開銷,但它是要佔用處理器時間的, 因此若是鎖被佔用的時間很短,自旋等待的效果就會很是好,反之若是鎖被佔用的時間很長,那麼自旋的線程只會白白消耗處理器資源,而不會作任何有用的工做, 反而會帶來性能的浪費。
咱們能夠經過參數 -XX:PreBlockSpin
來指定自旋的次數,默認值是 10 次。在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味着自旋的時間再也不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。若是在同一個鎖對象上,自旋等待剛剛成功得到過鎖,而且持有鎖的線程正在運行中,那麼虛擬機就會認爲此次自旋也頗有可能再次成功,進而它將容許自旋等待持續相對更長的時間, 好比 100 個循環。另外一方面,若是對於某個鎖,自旋不多成功得到過,那在之後要獲取這個鎖時將可能省略掉自旋過程,以免浪費處理器資源。
下面是自旋鎖的一種實現的例子:
public class SpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
while(!sign.compareAndSet(null, current)) ;
}
public void unlock() {
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
}
}
複製代碼
從上面的例子咱們能夠看出,自旋鎖是經過 CAS 操做,經過比較期值是否符合預期來加鎖和釋放鎖的。在 lock 方法中若是 sign 中的值是 null,也就代標鎖被釋放了,不然鎖被其餘線程佔用,須要經過循環來等待。在 unlock 方法中,經過將 sign 中的值設置爲 null 來通知正在等待的線程鎖已經被釋放。
鎖粗化的概念應該比較好理解,就是將屢次鏈接在一塊兒的加鎖、解鎖操做合併爲一次,將多個連續的鎖擴展成一個範圍更大的鎖。
public class StringBufferTest {
StringBuffer sb = new StringBuffer();
public void append(){
sb.append("a");
sb.append("b");
sb.append("c");
}
}
複製代碼
這裏每次調用 sb.append()
方法都須要加鎖和解鎖,若是虛擬機檢測到有一系列連串的對同一個對象加鎖和解鎖操做,就會將其合併成一次範圍更大的加鎖和解鎖操做,即在第一次 append()
方法時進行加鎖,最後一次 append()
方法結束後進行解鎖。
輕量級鎖是用來解決重量級鎖在互斥過程當中的性能消耗問題的,所謂的重量級鎖就是 sychronized
關鍵字實現的鎖。synchronized
是經過對象內部的一個叫作監視器鎖(monitor)來實現的。可是監視器鎖本質又依賴於底層的操做系統的 Mutex Lock
來實現的。而操做系統實現線程之間的切換就須要從用戶態轉換到核心態,這個成本很是高,狀態之間的轉換須要相對比較長的時間。
首先,對象的對象頭中存在一個部分叫作 Mark word
,其中存儲了對象的運行時數據,如哈希碼、GC 年齡等,其中有 2bit 用於存儲鎖標誌位。
在代碼進入同步塊的時候,若是對象鎖狀態爲無鎖狀態(鎖標誌位爲 「01」 狀態),虛擬機首先將在當前線程的棧幀中創建一個名爲 鎖記錄
(Lock Record
) 的空間,用於存儲鎖對象目前的 Mark Word
的拷貝。拷貝成功後,虛擬機將使用 CAS 操做嘗試將對象的 Mark Word
更新爲指向 Lock Record
的指針,並將 Lock Record
裏的 owner
指針指向對的 Mark word
。而且將對象的 Mark Word
的鎖標誌位變爲 "00",表示該對象處於鎖定狀態。更新操做失敗了,虛擬機首先會檢查對象的 Mark Word
是否指向當前線程的棧幀,若是是就說明當前線程已經擁有了這個對象的鎖,那就能夠直接進入同步塊繼續執行。不然說明多個線程競爭鎖,輕量級鎖就要膨脹爲重量級鎖,鎖標誌的變爲 「10」,Mark Word
中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。 而當前線程便嘗試使用自旋來獲取鎖,自旋就是爲了避免讓線程阻塞,而採用循環去獲取鎖的過程。
從上面咱們能夠看出,實際上當一個線程獲取了一個對象的輕量級鎖以後,對象的 Mark Word
會指向線程的棧幀中的 Lock Record
,而棧幀中的 Lock Record
也會指向對象的 Mark Word
。棧幀中的 Lock Record
用於判斷當前線程已經持有了哪些對象的鎖,而對象的 Mark Word
用來判斷哪一個線程持有了當前對象的鎖。當一個線程嘗試去獲取一個對象的鎖的時候,會先經過鎖標誌位判斷當前對象是否被加鎖,而後經過CAS操做來判斷當前獲取該對象鎖的線程是不是當前線程。
輕量級鎖不是設計用來取代重量級鎖的,由於它除了加鎖以外還增長了額外的CAS操做,所以在競爭激烈的狀況下,輕量級鎖會比傳統的重量級鎖更慢。
一個對象剛開始實例化的時候,沒有任何線程來訪問它的時候。它是可偏向的,意味着,它如今認爲只可能有一個線程來訪問它,因此當第一個線程來訪問它的時候,它會偏向這個線程。此時,對象持有偏向鎖,偏向第一個線程。這個線程在修改對象頭成爲偏向鎖的時候使用 CAS 操做,並將對象頭中的 ThreadID 改爲本身的 ID,以後再次訪問這個對象時,只須要對比 ID,不須要再使用 CAS 在進行操做。
一旦有第二個線程訪問這個對象,由於偏向鎖不會主動釋放,因此第二個線程能夠看到對象時偏向狀態,這時代表在這個對象上已經存在競爭了,檢查原來持有該對象鎖的線程是否依然存活,若是掛了,則能夠將對象變爲無鎖狀態,而後從新偏向新的線程,若是原來的線程依然存活,則立刻執行那個線程的操做棧,檢查該對象的使用狀況,若是仍然須要持有偏向鎖,則偏向鎖升級爲輕量級鎖,(偏向鎖就是這個時候升級爲輕量級鎖的)。若是不存在使用了,則能夠將對象回覆成無鎖狀態,而後從新偏向。
輕量級鎖認爲競爭存在,可是競爭的程度很輕,通常兩個線程對於同一個鎖的操做都會錯開,或者說稍微等待一下(自旋),另外一個線程就會釋放鎖。 可是當自旋超過必定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹爲重量級鎖,重量級鎖使除了擁有鎖的線程之外的線程都阻塞,防止 CPU 空轉。
若是大多數狀況下鎖老是被多個不一樣的線程訪問,那麼偏向模式就是多餘的,能夠經過 -XX:-UserBiaseLocking
禁止偏向鎖優化。
輕量級鎖和偏向鎖的提出是基於一個事實,就是大部分狀況下獲取一個對象鎖的線程都是同一個線程,它在這種情形下的效率會比重量級鎖高,當鎖老是被多個不一樣的線程訪問它們的效率就不必定比重量級鎖高。所以,它們的提出不是用來取代重量級鎖的,但在一些場景中會比重量級鎖效率高,所以咱們能夠根據本身應用的場景經過虛擬機參數來設置是否啓用它們。
JMM 是 Java 實現併發的理論基礎,JMM 種規定了 8 種操做與8種規則,並對 voliate、long 和 double 類型作了特別的規定。
JVM 會對咱們的代碼進行重排序以優化性能,對於重排序,JMM 又提出了先行發生原則 (happens-before) 和 as-if-serial 語義,以保證程序的最終結果不會由於重排序而改變。
Java 的線程是經過一種輕量級進行映射到內核線程實現的。咱們可使用互斥同步、非阻塞同步和無同步三種方式來保證多線程狀況下的線程安全。此外,Java 還提供了多種鎖優化的策咯來提高多線程狀況下的代碼性能。
這裏主要介紹 JMM 的內容,因此介紹的併發相關內容也僅介紹了與 JMM 相關的那一部分。但真正去研究併發和併發包的內容,還有許多的源代碼須要咱們去閱讀,僅僅一篇文章的篇幅顯然沒法所有覆蓋。