三.JVM與線程的原子性,可見性,有序性,易變性

1.硬件內存模型

硬件內存模型: 處理器->高速緩存->緩存一致性協議->主存
在這裏插入圖片描述

一:高速緩存的價值

  • CPU的運行效率要遠遠高於ARM也就是我們的主存,那麼在這樣的一個大前提下,當CPU所需要的數據需要從ARM中讀取時,相對來說CPU有很大一部分性能被閒置,並未發揮到CPU最大效能
  • 所以基於前一點,加入高速緩存後,高速緩存的運行效率與CPU接近,鎖需要的數據從高速緩存獲取,這樣一來,儘可能的避免了CPU被閒置的情況,從而從整體上提高了程序的執行效率

二:高速緩存運作原理

  • 高速緩存會將在CPU執行所需要的數據從ARM中拷貝寫入到高速緩存中,那麼在對高速緩存進程操作的時候會有兩種情況:
    • 高速緩存被命中 : 在在CPU執行時,對於所需要的數據首先會去與之性能最接近的高速緩衝中尋找,當在高速緩存中找到後就直接執行,那麼在這樣的情況下就凸顯出了高速緩存的價值
    • 高速緩存未被命中 : 在在CPU執行時,對於所需要的數據首先會去與之性能最接近的高速緩衝中尋找,當未找到,CPU被迫等待,後續由高速緩存從ARM中拷貝再從高速緩存讀取

三:高速緩存的回收機制

假如高速緩內存空間已滿,那麼所需要做的就是清理置空一部分高速緩存的內存空間,爲後續使用提供空間,最常見的一種策略就是LRU原則(最近最少使用原則),該原則在java的LinkedHashMap就有使用,其底層實現的原理就是LRU原則

四:高速緩存與命中率

CPU在從高速緩存中讀取數據之前,那麼就先必須找到所需要的數據它在高速緩存上的位置,而查找又需要時間,由此一來就延伸出了一個問題: 在高速緩存上的尋找效率和命中率

  • 直接映射: 高速緩存只提供一塊緩存給ARM中提供的數據進行存儲,那麼CPU只需要直接查找這個位置所需要的數據是否存在就可,但這也存在着一個缺陷就是一個緩存快的容量非常有限,很大程度上限制了ARM所提供的數據量,這樣一來,儘管提高了尋找效率但很大程度上降低了命中率

  • 關聯映射: 與直接映射完全相反, 高速緩存中的任意緩存塊都可提供給ARM的數據進行存儲,而這樣一來,這極大的提高了命中率,但也增加了CPU尋找的時間

  • 組相聯映射 : 這是上面二者之間比較折中的方案,提供數量有限的緩存塊用來存儲ARM中提供的數據,例如:2路相聯映射系統將ARM中提供的數據存儲到兩個中的任一一個緩存塊中,8路相聯映射系統就是將ARM中提供的數據存儲到8箇中的任一一個緩存塊中進行存儲

五.緩存的寫策略

  • "write-through"策略:該策略下,任何寫入高速緩存的數據都會直接寫入RAM
  • “write-back策略”: 任何寫進高速緩存的數據都會被標記爲"dirty",當該數據從高速緩存中刪除時,纔會寫入RAM刷新RAM中原來的數據,在該策略下會導致多線程中對數據的不可見性

六:緩存一致性協議

該協議的主要作用就是在多核處理器上,每個核心對於共享數據保證數據一致性的處理解決方案,由於篇幅過長,推薦大家可看此處: 緩存一致性協議

七:多線程與多核

  • 多核心處理器是指:在一個處理器上集成多個運算處理核心,從而提高計算能力,也就
    有多個真正具有並行計算的核心,每一個處理核心,對應一個內核
    線程.

  • 內核線程:就是直接由操作系統內核支持的線程,內核線程是由內核來完成
    切換的,而內核通過操作調度器對線程完成調度,內核並且負責
    將線程的任務映射到各個處理器上.

一般來說,一個內核對應一條內核線程,但現在一般的CPU,都是
一個處理核心對應兩條內核線程,其所採用的是超級線程技術,
將一個物理處理核心虛擬成兩個邏輯處理核心,來實現讓單個處理核心有也具有線程並行運算能力.

程序一般不會直接去使用內核線程,而是去使用內核線程的高級
接口—輕量級進程,也就是我們常說的 用戶線程,每一個用戶
線程都會有一個內核線程,所以需要先對內核線程支持,纔會有


2.Java內存模型

規範了線程如何和何時可以看到其他線程修改過的共享變量的值,以及在必須時如何同步的訪問共享變量

在這裏插入圖片描述
在這裏插入圖片描述
注:圖2引用於百度圖庫

一:工作內存

工作內存是java模型中的一個抽象概念,用來指定代表CPU的寄存器和高速緩存等

二:本地內存及其私有化性質

  • 同工作內存一樣是一個抽象描述
  • 從圖中我們可以看到,在線程執行時,會對數據所需要的數據進行私有拷貝作爲副本
  • 所需要注意:當兩個線程中的兩個方法同時指向堆內存中的同一個成員變量的時候,這時兩個線程都能夠對該成員變量進行 私有拷貝作爲副本,各線程在執行的都可以對該成員變量的副本進行操作,而這過程中,各線程中該副本的值可能發生變化,這是導致多線程安全問題的本質
  • 由上圖2可以到,調用棧和本地變量都是存放線程棧中,執行過程中所調用的的方法也放在線程棧中

三:堆

堆: 運行時的數據區,堆是由垃圾回收器來負責的,堆的,創建的對象和靜態變量,成員變量就放在堆中

  • 優勢:動態的分配內存大小,由Java垃圾回收器動態回收
  • 缺點: 需要存儲時,因動態的分配內存大小,所以存儲速度會慢一些

四:棧

棧: 棧中主要存放一些基本類型的變量,例如 int short,long…等;

  • 優勢: 存儲速度比堆要快,棧中數據可以共享
  • 缺點: 存在棧中的數據大小與生命週期是固定的,如java類中編寫的局部變量等

五:java內存間的交互操作

在這裏插入圖片描述
注:圖引用於百度圖庫

  1. lock(鎖定):作用於主內存的變量,把一個變量標記爲一條線程獨佔狀態
  2. unlock(解鎖):作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定
  3. read(讀取):作用於主內存的變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
  4. load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中
  5. use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎
  6. assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量
  7. store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作
  8. write(寫入):作用於主內存的變量,它把store操作從工作內存中的一個變量的值傳送到主內存的變量中

六:內存間的交互操作需要滿足的八條規則

  1. 不允許read和load,store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況出現.
  2. 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存.
  3. 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存.
  4. 一個新的變量只能在主內存中"誕生",不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說,就是對一個變量實施use,store操作之前,必須先執行過了assign和load操作.
  5. 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖.
  6. 如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值.這就使得線程加鎖時使用共享變量時需要從主內存中重新讀取最新的值
  7. 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量.
  8. 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store,write操作).

注:該規則引用於此處


3.原子性

一:什麼是原子性

  • 一個代碼塊在一次執行過程中,該代碼塊要就全部執行完成,要就不執行.在執行該代碼塊的時候不存在多線程中執行到一半切換線程去執行其他邏輯

二:synchronize,Lock保障原子性

  • synchronize: 依賴JVM
  • Lock: 依賴特殊的CPU指令

4.可見性

  • 可以理解爲:當前線程在對主內存的數據操作後更新,能夠保證在主內存中,該數據在被其他線程訪問之前刷新到主內存

一:不可見的原因

  1. 多線程中CPU切換執行每條線程中的代碼
  2. 多線程中CPU切換執行每條線程中的代碼且代碼在編譯過程中受編譯器重排序(重排序後面會講到)
  3. 由於高速緩存寫入主內存的策略不同,例如在"write-back"策略下,變量的更新並不會在第一時間更新到主內存,而是當它從高速緩存中刪除時,纔會寫入主內存中,這樣就導致了數據更新後對其他線程的不可見性

二:synchronize 可見性

  1. 由上面內存間的交互操作需要滿足的八條規則第六條,可以知道,線程加鎖時,將清空工作內存中共享變量的值,從而使共享變量的值需要使用時從主內存中獲取最新的值(注意:加鎖與解鎖是同一把鎖)
  2. 由上面內存間的交互操作需要滿足的八條規則第八條,可以知道,線程解鎖前,必須把共享變量最新值更新到主內存.

三:volatile 關鍵字

  1. 被volatile關鍵字修飾,在編譯時期,能夠防止編譯器對其只能重排序
  2. 被volatile關鍵字修飾的變量每次被訪問,都會強制從主內存中讀取,當變量發生變化的時候都會強制將最新的值寫入主內存中.保證了volatile變量的可見性
  3. 在多線程的環境下,被volatile關鍵字修飾的變量不能保證其執行的原子性

5.有序性

一:先行發生原則 happens-before

  • 規範限定了代碼執行的先後順數,是代碼執行順序的主要依據

先行發生原則(8種)

  1. 程序次序規則:同一個線程內,按照代碼出現的順序,前面的代碼先行於後面的代碼,準確的說是控制流順序,因爲要考慮到分支和循環結構.

  2. 管程鎖定規則:一個unlock操作先行發生於後面(時間上)對同一個鎖的lock操作.

  3. volatile變量規則:對一個volatile變量的寫操作先行發生於後面(時間上)對這個變量的讀操作.

  4. 線程啓動規則:Thread的start()方法先行發生於這個線程的每一個操作.

  5. 線程終止規則:線程的所有操作都先行於此線程的終止檢測.可以通過Thread.join()方法結束,Thread.isAlive()的返回值等手段檢測線程的終止.

  6. 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupt()方法檢測線程是否中斷

  7. 對象終結規則:一個對象的初始化完成先行於發生它的finalize()方法的開始.

  8. 傳遞性:如果操作A先行於操作B,操作B先行於操作C,那麼操作A先行於操作C.

注:該原則引用於此處

二:該原則注意事項

注意:

1. 不安全線程導致的情況和先行發生原則就有關係,因爲兩個線程調用同一個變量,兩個操作不在羅列出來的八種規則裏面,那麼就可以對他們進行任意的重排序,從而導致了線程安全問題. 該問題也是下面要講到的指令重排序問題

  • 時間先後順序與先行發生的順序之間基本沒有太大的關係.

6.易變性(指令重排序)

一:什麼是指令重排序?

重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段.

二:數據依賴性

  • 在單線程下,訪問兩變量,其中至少一個變量有寫操作,此時就存在數據依賴性

存在數據依賴性,例如:

  • 寫後讀 a=0 b=a;
  • 讀後寫 a=b b=1;
  • 寫後寫 a=1 a=2

注意: 上面三個例子的有效性僅僅侷限於單線程中,在多線程下交叉執行可能出現問題;

例:

int a=1,b=1;
        new Thread(() -> {
        a=0; b=a;
        System.out.println("b:"+ b);
        });
        new Thread(() -> {
        a=0; b=a;
        System.out.println("b:"+ b);
        });

正確b=0 錯誤b=1

三:指令重排序舉例

  • 編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會對存在數據依賴關係的兩個變量進行重排序.而數據依賴性基於單線程考慮,在多線程不受數據依賴性限制,則可能導致指令重排序舉例;

例:

static int a = 0, b= 0 ,x = 0, y = 0;
...

        new Thread(() -> {
            a = 1;//1
            x = b;//2
        });
        new Thread(() -> {
            b = 1;//3
            y = a;//4
        });

        System.out.println("x:"+ x+";  y:"+ y);
        //當未指令重排序正常執行結果爲: x = 0; y = 1;
        //當指令重排序按上面標註,執行順序爲2,3,4,1結果爲: x = 0; y = 0;

由此可以總結到:多線程下,代碼書寫的前後順序並不能夠完全保證代碼的先後執行順序

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章