多線程與高併發12-JMM和底層實現原理

JMM基礎-計算機原理

Java內存模型即Java Memory Model,簡稱JMM。JMM定義了Java 虛擬機(JVM)在計算機內存(RAM)中的工做方式。JVM是整個計算機虛擬模型,因此JMM是隸屬於JVM的。Java1.5版本對其進行了重構,如今的Java仍沿用了Java1.5的版本。Jmm遇到的問題與現代計算機中遇到的問題是差很少的java

物理機遇到的併發問題與虛擬機中的狀況有很多類似之處,物理機對併發的處理方案對於虛擬機的實現也有至關大的參考意義緩存

根據《Jeff Dean在Google全體工程大會的報告》咱們能夠看到
image.png多線程

計算機在作一些咱們平時的基本操做時,須要的響應時間是不同的併發

舉個例子(如下案例僅作說明,並不表明真實狀況)

若是從內存中讀取1M的int型數據由CPU進行累加,耗時要多久?app

作個簡單的計算,1M的數據,Java裏int型爲32位,4個字節,共有1024*1024/4 = 262144個整數 ,則CPU 計算耗時:262144 *0.6 = 157 286 納秒,而咱們知道從內存讀取1M數據須要250000納秒,二者雖然有差距(固然這個差距並不小,十萬納秒的時間足夠CPU執行將近二十萬條指令了),可是還在一個數量級上。可是,沒有任何緩存機制的狀況下,意味着每一個數都須要從內存中讀取,這樣加上CPU讀取一次內存須要100納秒,262144個整數從內存讀取到CPU加上計算時間一共須要262144*100+250000 = 26 464 400 納秒,這就存在着數量級上的差別了。jvm

並且現實狀況中絕大多數的運算任務都不可能只靠處理器「計算」就能完成,處理器至少要與內存交互,如讀取運算數據、存儲運算結果等,這個I/O操做是基本上是沒法消除的(沒法僅靠寄存器來完成全部運算任務)。早期計算機中cpu和內存的速度是差很少的,但在現代計算機中,cpu的指令速度遠超內存的存取速度,因爲計算機的存儲設備與處理器的運算速度有幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的 高速緩存(Cache)來做爲內存與處理器之間的緩衝:將運算須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了

image.png
image.png

在計算機系統中,寄存器劃是L0級緩存,接着依次是L1,L2,L3(接下來是內存,本地磁盤,遠程存儲)。越往上的緩存存儲空間越小,速度越快,成本也更高;越往下的存儲空間越大,速度更慢,成本也更低。從上至下,每一層均可以看作是更下一層的緩存,即:L0寄存器是L1一級緩存的緩存,L1是L2的緩存,依次類推;每一層的數據都是來至它的下一層,因此每一層的數據是下一層的數據的子集性能

image.png

在現代CPU上,通常來講L0, L1,L2,L3都集成在CPU內部,而L1還分爲一級數據緩存(Data Cache,D-Cache,L1d)和一級指令緩存(Instruction Cache,I-Cache,L1i),分別用於存放數據和執行數據的指令解碼。每一個核心擁有獨立的運算處理單元、控制器、寄存器、L一、L2緩存,而後一個CPU的多個核心共享最後一層CPU緩存L3優化

物理內存模型帶來的問題

基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,可是也爲計算機系統帶來更高的複雜度,由於它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每一個處理器都有本身的高速緩存,而它們又共享同一主內存(MainMemory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致spa

image.png
image.png

處理器A和處理器B按程序的順序並行執行內存訪問,最終可能獲得x=y=0的結果線程

處理器A和處理器B能夠同時把共享變量寫入本身的寫緩衝區(步驟A1,B1),而後從內存中讀取另外一個共享變量(步驟A2,B2),最後才把本身寫緩存區中保存的髒數據刷新到內存中(步驟A3,B3)。當以這種時序執行時,程序就能夠獲得x=y=0的結果

從內存操做實際發生的順序來看,直處處理器A執行A3來刷新本身的寫緩存區,寫操做A1纔算真正執行了。雖然處理器A執行內存操做的順序爲:A1→A2,但內存操做實際發生的順序倒是A2→A1

緩存一致性協議:若是真的發生這種狀況,那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,須要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操做,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等,緩存一致行協議可能是採用總線鎖,鎖住總線相應緩存行,使得其餘線程沒法操做,所以對性能是有必定影響的

僞共享

image.png

  • 緩存行:前面咱們已經知道,CPU中有好幾級高速緩存。可是CPU緩存系統中是以緩存行(cache line)爲單位存儲的。目前主流的CPU Cache的Cache Line大小都是64Bytes。Cache Line能夠簡單的理解爲CPU Cache中的最小緩存單位,今天的CPU再也不是按字節訪問內存,而是以64字節爲單位的塊(chunk)拿取,稱爲一個緩存行(cache line)。當你讀一個特定的內存地址,整個緩存行將從主存換入緩存

一個緩存行能夠存儲多個變量(存滿當前緩存行的字節數);而CPU對緩存的修改又是以緩存行爲最小單位的,在多線程狀況下,若是須要修改「共享同一個緩存行的變量」,就會無心中影響彼此的性能,這就是僞共享(False Sharing)

爲了不僞共享,咱們可使用數據填充的方式來避免,即單個數據填充滿一個CacheLine。這本質是一種空間換時間的作法
示例代碼:

public class T02_CacheLinePadding {
    private static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7;
    }

    private static class T extends Padding {
        public volatile long x = 0L;
    }

    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(()->{
            for (long i = 0; i < 1000_0000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(()->{
            for (long i = 0; i < 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start)/100_0000);
    }
}

可是這種方式在Java7之後可能失效,Java8中已經提供了官方的解決方案,Java8中新增了一個註解@sun.misc.Contended

加上這個註解的類會自動補齊緩存行,須要注意的是此註解默認是無效的,須要在jvm啓動時設置-XX:-RestrictContended纔會生效

Java內存模型(JMM)

從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(Main Memory)中,每一個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器優化。
image.png

Java內存模型帶來的問題

可見性問題

image.png

左邊CPU中運行的線程從主存中拷貝共享對象obj到它的CPU緩存,把對象obj的count變量改成2。但這個變動對運行在右邊CPU中的線程不可見,由於這個更改尚未flush到主存中

在多線程的環境下,若是某個線程首次讀取共享變量,則首先到主內存中獲取該變量,而後存入工做內存中,之後只須要在工做內存中讀取該變量便可。一樣若是對該變量執行了修改的操做,則先將新值寫入工做內存中,而後再刷新至主內存中。可是何時最新的值會被刷新至主內存中是不太肯定,通常來講會很快,但具體時間不知

要解決共享對象可見性這個問題,咱們可使用volatile關鍵字或者是加鎖

競爭問題

線程A和線程B共享一個對象obj。假設線程A從主存讀取Obj.count變量到本身的CPU緩存,同時,線程B也讀取了Obj.count變量到它的CPU緩存,而且這兩個線程都對Obj.count作了加1操做。此時,Obj.count加1操做被執行了兩次,不過都在不一樣的CPU緩存中
image.png

若是這兩個加1操做是串行執行的,那麼Obj.count變量便會在原始值上加2,最終主存中的Obj.count的值會是3。然而圖中兩個加1操做是並行的,無論是線程A仍是線程B先flush計算結果到主存,最終主存中的Obj.count只會增長1次變成2,儘管一共有兩次加1操做。 要解決上面的問題咱們可使用java synchronized代碼塊

重排序

重排序類型

除了共享內存和工做內存帶來的問題,還存在重排序的問題:在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序。重排序分3種類型

1)編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。

2)指令級並行的重排序:現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。

3)內存系統的重排序:因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。

as-if-serial

as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度,(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義

內存屏障

Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按咱們預想的流程去執行。

一、保證特定操做的執行順序

二、影響某些數據(或則是某條指令的執行結果)的內存可見性

編譯器和CPU可以重排序指令,保證最終相同的結果,嘗試優化性能。插入一條Memory Barrier會告訴編譯器和CPU:無論什麼指令都不能和這條Memory Barrier指令重排序

Memory Barrier所作的另一件事是強制刷出各類CPU cache,如一個Write-Barrier(寫入屏障)將刷出全部在Barrier以前寫入 cache 的數據,所以,任何CPU上的線程都能讀取到這些數據的最新版本。

內存屏障底層分爲讀屏障和寫屏障,JMM又對讀寫屏障作了各類組合,分爲了Loadload、Storestore、LoadStore、StoreLoad

happens-before

happens-before是JVM規定重排序必須遵照的規則

Happens-Before規則

JMM爲咱們提供瞭如下的Happens-Before規則:

1)程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做

2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖

3)volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀

4)傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C

5)start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操做happens-before於線程B中的任意操做

6)join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回

7 )線程中斷規則:對線程interrupt方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生

volatile詳解

volatile的內存語義

  • 經過緩存一致性協議保證變量的線程間可見性
  • 經過內存中的讀寫屏障避免指令重排序
volatile的內存屏障

在Java中對於volatile修飾的變量,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序問題。

volatile寫

image.png

  • storestore屏障:對於這樣的語句store1; storestore; store2,在store2及後續寫入操做執行前,保證store1的寫入操做對其它處理器可見(也就是說若是出現storestore屏障,那麼store1指令必定會在store2以前執行,CPU不會store1與store2進行重排序)
  • storeload屏障:對於這樣的語句store1; storeload; load2,在load2及後續全部讀取操做執行前,保證store1的寫入對全部處理器可見(也就是說若是出現storeload屏障,那麼store1指令必定會在load2以前執行,CPU不會對store1與load2進行重排序)
volatile讀

image.png

在每一個volatile讀操做的後面插入一個LoadLoad屏障。在每一個volatile讀操做的後面插入一個loadstore屏障

  • loadload屏障:對於這樣的語句load1; loadload; load2,在load2及後續讀取操做要讀取的數據被訪問前,保證load1要讀取的數據被讀取完畢(也就是說,若是出現loadload屏障,那麼load1指令必定會在load2以前執行,CPU不會對load1與load2進行重排序)
  • loadstore屏障:對於這樣的語句load1; loadstore; store2,在store2及後續寫入操做被刷出前,保證load1要讀取的數據被讀取完畢(也就是說,若是出現loadstore屏障,那麼load1指令必定會在store2以前執行,CPU不會對load1與store2進行重排序

volatile的實現原理

經過unsafe.cpp的lock:前綴,調用系統的總線鎖,鎖住總線和緩存

鎖的內存語義

synchronized的實現原理

Synchronized在JVM裏的實現都是基於進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不同,可是均可以經過成對的MonitorEnterMonitorExit指令來實現

synchronized使用的鎖是存放在Java對象頭裏面
image.png

synchronized鎖的狀態

一共有四種狀態,無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態,它會隨着競爭狀況逐漸升級。鎖能夠升級但不能降級,目的是爲了提升得到鎖和釋放鎖的效率

各類鎖的比較

image.png

JDK對鎖的更多優化措施

逃逸分析

若是證實一個對象不會逃逸方法外或者線程外,則可針對此變量進行優化:

同步消除synchronization Elimination,若是一個對象不會逃逸出線程,則對此變量的同步措施可消除

鎖消除和粗化

  • 鎖消除:虛擬機的運行時編譯器在運行時若是檢測到一些要求同步的代碼上不可能發生共享數據競爭,則會去掉這些鎖
  • 鎖粗化:將臨近的代碼塊用同一個鎖合併起來

消除無心義的鎖獲取和釋放,能夠提升程序運行性能

相關文章
相關標籤/搜索