資深消防猿爲你解讀Java多線程與併發模型之共享對象

互聯網上充斥着對Java多線程編程的介紹,每篇文章都從不一樣的角度介紹並總結了該領域的內容。但大部分文章都沒有說明多線程的實現本質,沒能讓開發者真正「過癮」。數據庫

如下內容如無特殊說明均指代Java環境。編程

共享對象緩存

使用Java編寫線程安全的程序關鍵在於正確的使用共享對象,以及安全的對其進行訪問管理。在第一章咱們談到Java的內置鎖能夠保障線程安全,對於其餘的應用來講併發的安全性是在內置鎖這個「黑盒子」內保障了線程變量使用的邊界。談到線程的邊界問題,隨之而來的是Java內存模型另外的一個重要的含義,可見性。Java對可見性提供的原生支持是volatile關鍵字。安全

volatile關鍵字bash

volatile 變量具有兩種特性,其一是保證該變量對全部線程可見,這裏的可見性指的是當一個線程修改了變量的值,那麼新的值對於其餘線程是能夠當即獲取的。其二 volatile 禁止了指令重排。多線程

雖然 volatile 變量具備可見性和禁止指令重排序,可是並不能說 volatile 變量能確保併發安全。併發

public class VolatileTest {public static volatile int a = 0;public static final int THREAD_COUNT = 20;public static void increase() {a++;}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[THREAD_COUNT];for (int i = 0; i < THREAD_COUNT; i++) {threads[i] = new Thread(new Runnable() {public void run() {for (int i = 0; i < 1000; i++) {increase();}}});threads[i].start();}while (Thread.activeCount() > 2) {Thread.yield();}System.out.println(a);}}複製代碼

按照咱們的預期,它應該返回 20000 ,可是很惋惜,該程序的返回結果幾乎每次都不同。app

問題主要出在 a++ 上,複合操做並不具有原子性, 雖然這裏利用 volatile 定義了 a ,可是在作 a++ 時, 先獲取到最新的 a 值,好比這時候最新的多是 50,而後再讓 a 增長,可是在增長的過程當中,其餘線程極可能已經將 a 的值改變了,或許已經成爲 5二、53 ,可是該線程做自增時,仍是使用的舊值,因此會出現結果每每小於預期的 2000。若是要解決這個問題,能夠對 increase() 方法加鎖。分佈式

volatile 適用場景函數

volatile 適用於程序運算結果不依賴於變量的當前值,也至關於說,上述程序的 a 不要自增,或者說僅僅是賦值運算,例如 boolean flag = true 這樣的操做。

volatile boolean shutDown = false;public void shutDown() {shutDown = true;}public void doWork() {while (!shutDown) {System.out.println("Do work " + Thread.currentThread().getId());}}複製代碼

代碼2.1:變量的可見性問題

在代碼2.1中,能夠看到按照正常的邏輯應該打印10以後線程中止,可是實際的狀況多是打印出0或者程序永遠不會被終止掉。其緣由是沒有使用恰當的同步機制以保障線程的寫入操做對全部線程都是可見的。

咱們通常將volatile理解爲synchronized的輕量級實現,在多核處理器中能夠保障共享變量的「可見性」,可是不能保障原子性。關於原子性問題在該章節的程序變量規則會加以說明,下面咱們先看下Java的內存模型實現以瞭解JVM和計算機硬件是如何協調共享變量的以及volatile變量的可見性。

Java內存模型

咱們都知道現代計算機都是馮諾依曼結構的,全部的代碼都是順序執行的。若是計算機須要在CPU中運算某個指令,勢必就會涉及對數據的讀取和寫入操做。因爲程序數據的大部份內容都是存儲在主內存(RAM)中的,在這當中就存在着一個讀取速度的問題,CPU很快而主內存相對來講(相對CPU)就會慢上不少,爲了解決這個速度階梯問題,各個CPU廠商都在CPU裏面引入了高速緩存來優化主內存和CPU的數據交互。針對上面的技術我特地整理了一下,有不少技術不是靠幾句話能講清楚,因此乾脆找朋友錄製了一些視頻,不少問題其實答案很簡單,可是背後的思考和邏輯不簡單,要作到知其然還要知其因此然。若是想學習Java工程化、高性能及分佈式、深刻淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友能夠加個人Java進階羣:591240817,羣裏有大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享

此時當CPU須要從主內存獲取數據時,會拷貝一份到高速緩存中,CPU計算時就能夠直接在高速緩存中進行數據的讀取和寫入,提升吞吐量。當數據運行完成後,再將高速緩存的內容刷新到主內存中,此時其餘CPU看到的纔是執行以後的結果,但在這之間存在着時間差。

看這個例子:

int counter = 0; counter = counter + 1;複製代碼

代碼2.2:自增不一致問題

代碼2.2在運行時,CPU會從主內存中讀取counter的值,複製一份到當前CPU核心的高速緩存中,在CPU執行完成加1的指令以後,將結果1寫入高速緩存中,最後將高速緩存刷新到主內存中。這個例子代碼在單線程的程序中將正確的運行下去。

但咱們試想這樣一種狀況,如今有兩個線程共同運行該段代碼,初始化時兩個線程分別從主內存中讀取了counter的值0到各自的高速緩存中,線程1在CPU1中運算完成後寫入高速緩存Cache1,線程2在CPU2中運算完成後寫入高速緩存Cache2,此時counter的值在兩個CPU的高速緩存中的值都是1。

此時CPU1將值刷新到主內存中,counter的值爲1,以後CPU2將counter的值也刷新到主內存,counter的值覆蓋爲1,最終的結果計算counter爲1(正確的兩次計算結果相加應爲2)。這就是緩存不一致性問題。這會在多線程訪問共享變量時出現。

解決緩存不一致問題的方案:

  1. 經過總線鎖LOCK#方式。

  2. 經過緩存一致性協議。


圖2.1 :緩存不一致問題

圖2.1中提到的兩種內存一致性協議都是從計算機硬件層面上提供的保障。CPU通常是經過在總線上增長LOCK#鎖的方式,鎖住對內存的訪問來達到目的,也就是阻塞其餘CPU對內存的訪問,從而使只有一個CPU能訪問該主內存。所以須要用總線進行內存鎖定,能夠分析獲得此種作法對CPU的吞吐率形成的損害很嚴重,效率低下。

隨着技術升級帶來了緩存一致性協議,市場佔有率較大的Intel的CPU使用的是MESI協議,該協議能夠保障各個高速緩存使用的共享變量的副本是一致的。其實現的核心思想是:當在多核心CPU中訪問的變量是共享變量時,某個線程在CPU中修改共享變量數據時,會通知其餘也存儲了該變量副本的CPU將緩存置爲無效狀態,所以其餘CPU讀取該高速緩存中的變量時,發現該共享變量副本爲無效狀態,會從主內存中從新加載。但當緩存一致性協議沒法發揮做用時,CPU仍是會降級使用總線鎖的方式進行鎖定處理。

一個小插曲:爲何volatile沒法保障的原子性

咱們看下圖2.2,CPU在主內存中讀取一個變量以後,拷貝副本到高速緩存,CPU在執行期間雖然識別了變量的「易變性」,可是隻能保障最後一步store操做的原子性,在load,use期間並未實現其原子性操做。


圖2.2:數據加載和內存屏障

JVM爲了使咱們的代碼獲得最優的執行體驗,在進行自我優化時,並不保障代碼的前後執行順序(知足Happen-Before規則的除外),這就是「指令重排」,而上面提到的store操做保障了原子性,JVM是如何實現的呢?其緣由是這裏存在一個「內存屏障」的指令(之後咱們會談到整個內容),這個是CPU支持的一個指令,該指令只能保障store時的原子性,可是不能保障整個操做的原子性。

從整個小插曲中,咱們看到了volatile雖然有可見性的語義,可是並不能真正的保證線程安全。若是要保證併發線程的安全訪問,須要符合併發程序變量的訪問規則。

併發程序變量的訪問規則

1. 原子性

程序的原子性和數據庫事務的原子性有着一樣的意義,能夠保障一次操做要麼所有執行成功,要不所有都不執行。

2. 可見性

可見性是微妙的,由於最終的結果老是和咱們的直覺截然不同,當多個線程共同修改一個共享變量的值時,因爲存在高速緩存中的變量副本操做,不能及時將數據刷新到主內存,致使當前線程在CP中的操做結果對其餘CPU是不可見狀態。

3. 有序性

有序性通俗的理解就是程序在JVM中是按照順序執行的,可是前面已經提到了JVM爲了優化代碼的執行速度,會進行「指令重排」。在單線程中「指令重排」並不會帶來安全問題,但在併發程序中,因爲程序的順序不能保障,運行過程當中可能會出現不安全的線程訪問問題。

綜上,要想在併發編程環境中安全的運行程序,就必須知足原子性、可見性和有序性。只要以上任何一點沒有保障,那程序運行就可能出現不可預知的錯誤。最後咱們介紹一下Java併發的「殺手鐗」,Happens-Before法則,符合該法則的狀況下能夠保障併發環境下變量的訪問規則。

happens-before語義

Java內存模型使用了各類操做來定義的,包括對變量的讀寫,監視器的獲取釋放等,JMM中使用了

happens-before

語義闡述了操做之間的內存可見性。若是想要保證執行操做B的線程看到操做A的結構(不管AB是否在同一線程),那麼A,B必須知足

happens-before

關係。若是兩個操做之間缺少

happens-before


Happens-Before法則:

  1. 程序次序法則:線程中的每一個動做A都Happens-Before於該線程中的每個動做B,在程序中,全部的動做B都出如今動做A以後。

  2. Lock法則:對於一個Lock的解鎖操做老是Happens-Before於每個後續對該Lock的加鎖操做。

  3. volatile變量法則:對於volatile變量的寫入操做Happens-Before於後續對同一個變量的讀操做。

  4. 線程啓動法則:在一個線程裏,對Thread.start()函數的調用會Happens-Before於每個啓動線程中的動做。

  5. 線程終結法則:線程中的任何動做都Happens-Before於其餘線程檢測到這個線程已經終結或者從Thread.join()函數調用中成功返回或者Thread.isAlive()函數返回false。

  6. 中斷法則:一個線程調用另外一個線程的interrupt老是Happens-Before於被中斷的線程發現中斷。

  7. 終結法則:一個對象的構造函數的結束老是Happens-Before於這個對象的finalizer(Java沒有直接的相似C的析構函數)的開始。

  8. 傳遞性法則:若是A事件Happens-Before於B事件,而且B事件Happens-Before於C事件,那麼A事件Happens-Before於C事件。

當一個變量在多線程競爭中被讀取和存儲,若是並未按照Happens-Before的法則,那麼他就會存在數據競爭關係。

總結

給你們關於Java的共享變量的內容就介紹到這裏,如今你已經明白Java的volatile關鍵字的含義了,瞭解了爲何volatile不能保障原子性的緣由了,瞭解了Happens-Before規則能讓咱們的Java程序運行的更加安全。經過這節內容但願能夠幫助你更深刻的瞭解Java的併發概念中的內置鎖和共享變量。Java的併發內容還有不少,例如在某些場景下比synchronized效率要更高的Lock,阻塞隊列,同步器等。

相關文章
相關標籤/搜索