1、前言java
長久以來,一直想剖析一下Java線程安全的本質,可是苦於有些微觀的點想不明白,便擱置了下來,前段時間慢慢想明白了,便把全部的點串聯起來,趁着思路清晰,整理成這樣一篇文章。緩存
2、導讀安全
一、爲何有多線程?多線程
二、線程安全描述的本質問題是什麼?併發
三、Java內存模型(JMM)數據可見性問題、指令重排序、內存屏障性能
3、揭曉答案spa
一、爲何有多線程.net
談到多線程,咱們很容易與高性能畫上等號,可是並不是如此,舉個簡單的例子,從1加到100,用四個線程計算不必定比一個線程來得快。由於線程的建立和上下文切換,是一筆巨大的開銷。線程
那麼設計多線程的初衷是什麼呢?來看一個這樣的實際例子,計算機一般須要與人來交互,假設計算機只有一個線程,而且這個線程在等待用戶的輸入,那麼在等待的過程當中,CPU什麼事情也作不了,只能等待,形成CPU的利用率很低。若是設計成多線程,在CPU在等待資源的過程當中,能夠切到其餘的線程上去,提升CPU利用率。設計
現代處理器大多含有多個CPU核心,那麼對於運算量大任務,能夠用多線程的方式拆解成多個小任務併發的執行,提升計算的效率。
總結起來無非兩點,提升CPU的利用率、提升計算效率。
二、線程安全的本質
咱們先來看一個例子:
public class Add { private int count = 0; public static void main(String[] args) { CountDownLatch countDownLatch = new CountDownLatch(4); Add add = new Add(); add.doAdd(countDownLatch); try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(add.getCount()); } public void doAdd(CountDownLatch countDownLatch) { for (int i = 0; i < 4; i++) { new Thread(new Runnable() { public void run() { for (int j = 0; j < 25; j++) { count++; } countDownLatch.countDown(); } }).start(); } } public int getCount() { return count; } }
上面是一個把變量自增100次的例子,只不過用了4個線程,每一個線程自增25次,用CountDownLatch等4個線程執行完,打印出最終結果。實際上,咱們但願程序的結果是100,可是打印出來的結果並不是老是100。
這就引出了線程安全所描述的問題,咱們先用通俗的話來描述一下線程安全:
線程安全就是要讓程序運行出咱們想要的結果,或者話句話說,讓程序像咱們看到的那樣執行。
解釋一下我總結的這句話,咱們先new出了一個add對象,調用了對象的doAdd方法,原本咱們但願每一個線程有序的自增25次,最終獲得正確的結果。若是程序增的像咱們預先設定的那樣運行,那麼這個對象就是線程安全的。
下面咱們來看看Brian Goetz對線程安全的描述:當多線程訪問一個對象時,若是不用考慮這些線程在運行時環境下的調度和交替,也不須要進行額外的同步,或者在調用方進行任何其餘的協調操做,調用這個對象的行爲均可以得到正確的結果,那麼這個對象就是線程安全的。
下面咱們就來分析這段代碼爲何不能確保老是獲得正確的結果。
三、Java內存模型(JMM)數據可見性問題、指令重排序、內存屏障
先從計算機的硬件效率提及,CPU的計算速度比內存快幾個數量級,爲了平衡CPU和內存之間的矛盾,引入的高速緩存,每一個CPU都有高速緩存,甚至是多級緩存L一、L2和L3,那麼緩存與內存的交互須要緩存一致性協議,這裏就不深刻講解。那麼最終處理器、高速緩存、主內存的交互關係以下:
那麼Java的內存模型(Java Memory Model,簡稱JMM)也定義了線程、工做內存、主內存之間的關係,很是相似於硬件方面的定義。
這裏順帶提一下,Java虛擬機運行時內存的區域劃分
方法區:存儲類信息、常量、靜態變量等,各線程共享
虛擬機棧:每一個方法的執行都會建立棧幀,用於存儲局部變量、操做數棧、動態連接等,虛擬機棧主要存儲這些信息,線程私有
本地方法棧:虛擬機使用到的Native方法服務,例如c程序等,線程私有
程序計數器:記錄程序運行到哪一行了,至關於當前線程字節碼的行號計數器,線程私有
堆:new出的實例對象都存儲在這個區域,是GC的主戰場,線程共享。
因此對於JMM定義的主內存,大部分時候能夠對應堆內存、方法區等線程共享的區域,這裏只是概念上對應,其實程序計數器、虛擬機棧等也有部分是放在主內存的,具體看虛擬機的設計。
好了,瞭解了JMM內存模型,咱們來分析一下,上面的程序爲何沒獲得正確的結果。請看下圖,線程A、B同時去讀取主內存的count初始值存放在各自的工做內存裏,同時執行了自增操做,寫回主內存,最終獲得了錯誤的結果。
咱們再來深刻分析一下,形成這個錯誤的本質緣由:
(1)、可見性,工做內存的最新值不知道何時會寫回主內存
(2)、有序性,線程之間必須是有序的訪問共享變量,咱們用「視界」這個概念來描述一下這個過程,以B線程的視角看,當他看到A線程運算好以後,把值寫回以內存以後,立刻去讀取最新的值來作運算。A線程也應該是看到B運算完以後,立刻去讀取,在作運算,這樣就獲得了正確的結果。
接下來,咱們來具體分析一下,爲何要從可見性和有序性兩個方面來限定。
給count加上volatile關鍵字,就保證了可見性。
private volatile int count = 0;
volatile關鍵字,會在最終編譯出來的指令上加上lock前綴,lock前綴的指令作三件事情
(1)、防止指令重排序(這裏對本問題的分析不重要,後面會詳細來說)
(2)、鎖住總線或者使用鎖定緩存來保證執行的原子性,早期的處理可能用鎖定總線的方式,這樣其餘處理器沒辦法經過總線訪問內存,開銷比較大,如今的處理器都是用鎖定緩存的方式,在配合緩存一致性來解決。
(3)、把緩衝區的全部數據都寫回主內存,並保證其餘處理器緩存的該變量失效
既然保證了可見性,加上了volatile關鍵詞,爲何仍是沒法獲得正確的結果,緣由是count++,並不是原子操做,count++等效於以下步驟:
(1)、 從主內存中讀取count賦值給線程副本變量:
temp=count
(2)、線程副本變量加1
temp=temp+1
(3)、線程副本變量寫回主內存
count=temp
就算是真的嚴苛的給總線加鎖,致使同一時刻,只能有一個處理器訪問到count變量,可是在執行第(2)步操做時,其餘cpu已經能夠訪問count變量,此時最新運算結果還沒刷回主內存,形成了錯誤的結果,因此必須保證順序性。
那麼保證順序性的本質,就是保證同一時刻只有一個CPU能夠執行臨界區代碼。這時候作法一般是加鎖,鎖本質是分兩種:悲觀鎖和樂觀鎖。如典型的悲觀鎖synchronized、JUC包下面典型的樂觀鎖ReentrantLock。
總結一下:要保證線程安全,必須保證兩點:共享變量的可見性、臨界區代碼訪問的順序性。
下一篇博客將從指令重排序、內存屏障等微觀的角度,站在線程的視角來看一個亂序的Java世界,請關注下一篇博客《一篇文章看懂Java併發和線程安全(二)》
from: https://my.oschina.net/u/1778239/blog/1610185