33.Android架構-線程(四)-JMM

JMM基礎-計算機原理

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

物理計算機中的併發問題

物理機遇到的併發問題與虛擬機中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機的實現也有相當大的參考意義。
根據《Jeff Dean在Google全體工程大會的報告》我們可以看到


計算機在做一些我們平時的基本操作時,需要的響應時間是不一樣的。
(以下案例僅做說明,並不代表真實情況。)
如果從內存中讀取1M的int型數據由CPU進行累加,耗時要多久?
做個簡單的計算,1M的數據,Java裏int型爲32位,4個字節,共有
1024*1024/4 = 262144個整數 ,則CPU 計算耗時:262144 0.6 = 157 286 納秒,而我們知道從內存讀取1M數據需要250000納秒,兩者雖然有差距(當然這個差距並不小,十萬納秒的時間足夠CPU執行將近二十萬條指令了),但是還在一個數量級上。但是,沒有任何緩存機制的情況下,意味着每個數都需要從內存中讀取,這樣加上CPU讀取一次內存需要100納秒,262144個整數從內存讀取到CPU加上計算時間一共需要262144100+250000 = 26 464 400 納秒,這就存在着數量級上的差異了。
而且現實情況中絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與內存交互,如讀取運算數據、存儲運算結果等,這個I/O操作是基本上是無法消除的(無法僅靠寄存器來完成所有運算任務)。早期計算機中cpu和內存的速度是差不多的,但在現代計算機中,cpu的指令速度遠超內存的存取速度,由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作爲內存與處理器之間的緩衝:將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。

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

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

Java內存模型(JMM)

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


可見性

可見性指的是當多個線程操作同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即讀取到修改後的值。
由於線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存的變量,那麼對於共享變量A,他們首先是在自己的工作內存中被修改,然後纔會同步到主內存,但是並不會及時的刷新到主內存,而是會有一定的時間差,如果在刷新到主內存之前有其他線程讀取了這個值,那麼就會導致讀取到的不是最新的值,這就是可見性的問題
要解決共享變量可見性的問題,1 volatile 2 加鎖

原子性

原子性就是一個操作或者多個操作,一旦開始執行,在執行完成之前不會被其他操作打斷,那麼這個操作或者這多個操作就是具有原子性的

導致原子性問題的根源在於cpu時間片的切換,cpu時間的分配都是以線程爲單位的,並且是分時調用,操作系統允許某個線程執行一小段時間,例如50毫秒,過了50毫秒操作系統就會重新選擇一個線程來執行,這個50毫秒就是“時間片”

那麼時間片的切換會帶來什麼問題?我們知道cpu的時間片切換是以cpu指令爲單位進行的,而cpu指令不等於我們寫的一行代碼,因爲我們的一行代碼可能是一條cpu執行,也可能需要多條cpu指令才能執行完,比如a++,我們說自增運算不具有原子性,所以他是由多條指令組成的,所以這一行代碼在執行過程中就有可能被時間片切換打斷,導致意外的問題。

volatile

可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。

當一個變量被volatile修飾的時候,一旦某一個線程對這個變量做了修改,那麼修改後的值會被立即同步到主存中,並且會使其他線程的工作內存中的記錄的這個變量的副本失效,此時當其他線程再次讀取這個值時,會強制從主存中讀取

原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

volatile雖然能保證執行完及時把變量刷到主內存中,但對於count++這種非原子性、多指令的情況,由於線程切換,線程A剛把count=0加載到工作內存,線程B就可以開始工作了,這樣就會導致線程A和B執行完的結果都是1,都寫到主內存中,主內存的值還是1不是2

volatile的實現原理

volatile關鍵字修飾的變量會存在一個“lock:”前綴,lock不是一種內存屏障,但是它能完成類似內存屏障的功能,lock會對cpu總線和高速緩存加鎖,可以理解爲cpu指令級的一種鎖,同時該指令會將當前處理器緩存行的數據直接寫入到系統內存中,且這個寫回內存的操作會使在其他cpu裏緩存了該地址的數據失效

synchronized的實現原理

Synchronized在JVM裏的實現都是基於進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實現。
對同步塊,MonitorEnter指令插入在同步代碼塊的開始位置,當代碼執行到該指令時,將會嘗試獲取該對象Monitor的所有權,即嘗試獲得該對象的鎖,而monitorExit指令則插入在方法結束處和異常處,JVM保證每個MonitorEnter必須有對應的MonitorExit。
對同步方法,從同步方法反編譯的結果來看,方法的同步並沒有通過指令monitorenter和monitorexit來實現,相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。
JVM就是根據該標示符來實現方法的同步的:當方法被調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。
synchronized使用的鎖是存放在Java對象頭裏面,


具體位置是對象頭裏面的MarkWord,MarkWord裏默認數據是存儲對象的HashCode等信息,


但是會隨着對象的運行改變而發生變化,不同的鎖狀態對應着不同的記錄存儲方式


同步方法中有synchronized和無synchronized的對比(javap -v TestSync.class反編譯class文件)
    public synchronized void test() {
        count++;
    }
    public void test() {
        count++;
    }

帶synchronized

public synchronized void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field count:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field count:I
         8: return

不帶synchronized

public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field count:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field count:I
         8: return

可以看到,二者的區別就是flags中前者多了一個ACC_SYNCHRONIZED標記

同步代碼塊有synchronized和無synchronized的對比(javap -v TestSync.class反編譯class文件)
    public void test0() {
        synchronized (this){
            count++;
        }
    }
    public void test0() {
            count++;
    }

帶synchronized

public void test0();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field count:I
         7: iconst_1
         8: iadd
         9: putstatic     #2                  // Field count:I
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return

不帶synchronized

 public void test0();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field count:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field count:I
         8: return

可以看到二者的區別在於在被synchronized鎖住的代碼中,有3: monitorenter13: monitorexit兩個指令

JVM對synchronized進行的優化

早期Java版本中(JDK1.6前),synchronized屬於重量級鎖,效率低,monitor依賴於低層的操作系統的Mutex Lock來實現。而操作系統實現線程中的切換時,涉及到用戶態到內核態的切換,這是一個非常重的操作,時間成本較高。這是早期 synchronized 效率低下的原因。JDK1.6後,JVM官方對鎖做了較大優化

1.偏向鎖
2.輕量級鎖
3.鎖粗化
4.鎖消除
5.適應性自旋
鎖的狀態

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

Monitor
上邊也提到了,Synchronized在JVM裏的實現都是基於進入和退出
Monitor對象來實現方法同步和代碼塊同步,那麼Monitor的實現是什
麼原理呢?

簡單來說 monito存儲了所有在等待獲取該monitor的線程對象。(線
程生命週期中存在兩種狀態,運行態和阻塞態),當A線程獲取到
monitor對象,B線程在嘗試獲取的時候會失敗並進入阻塞態,直到A
線程執行完成釋放monitor對象,並喚醒B線程,B被喚醒後,獲取到
了A釋放了的monitor對象,繼續運行,直到完成。(`A怎麼知道有B
在等待?看第一句話,monitor對象中存儲了所有在等待獲取該
monitor的線程對象`)
偏向鎖(爲了減少不必要的CAS)

大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖,減少不必要的CAS操作。
偏向鎖,顧名思義,它會偏向於第一個訪問鎖的線程,如果在運行過程中,同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發同步的,減少加鎖/解鎖的一些CAS操作(比如等待隊列的一些CAS操作),這種情況下,就會給線程加一個偏向鎖。 如果在運行過程中,遇到了其他線程搶佔鎖,則持有偏向鎖的線程會被掛起,JVM會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。它通過消除資源無競爭情況下的同步原語,進一步提高了程序的運行性能。

偏向鎖獲取過程:
步驟1、 訪問Mark Word中偏向鎖的標識是否設置成1,鎖標誌位是否爲01,確認爲可偏向狀態。
步驟2、 如果爲可偏向狀態,則測試線程ID是否指向當前線程,如果是,進入步驟5,否則進入步驟3。
步驟3、 如果線程ID並未指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程ID設置爲當前線程ID,然後執行5;如果競爭失敗,執行4。
步驟4、 如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼。(撤銷偏向鎖的時候會導致stop the word)
步驟5、 執行同步代碼。
偏向鎖的釋放:
偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他線程嘗
試競爭偏向鎖時,持有偏向鎖的線程纔會釋放偏向鎖,線程不會主動
去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點
上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖
對象是否處於被鎖定狀態,撤銷偏向鎖後恢復到未鎖定(標誌位爲
“01”)或輕量級鎖(標誌位爲“00”)的狀態。

偏向鎖的適用場景
始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前,沒有
其它線程去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就
升級爲輕量級鎖,升級爲輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向
鎖的時候會導致stop the word操作; 

在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向所的
時候會導致進入安全點,安全點會導致stw,導致性能下降,這種情況
下應當禁用。
jvm開啓/關閉偏向鎖
開啓偏向鎖:-XX:+UseBiasedLocking 
-XX:BiasedLockingStartupDelay=0
關閉偏向鎖:-XX:-UseBiasedLocking
輕量級鎖(通過CAS操作,在競爭並不激烈的情況下,降低線程掛起和恢復的時間消耗)

輕量級鎖是由偏向鎖升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當第二個線程加入鎖爭用的時候,偏向鎖就會升級爲輕量級鎖

獲取輕量鎖:

1.判斷當前對象是否處於無鎖狀態(偏向鎖標記=0,無鎖狀態=01)
如果是,則JVM會首先將當前線程的棧幀中建立一個名爲鎖記錄
(Lock Record)的空間,用於存儲當前對象的Mark Word拷貝。(官
方稱爲Displaced Mark Word)。接下來執行第2步。如果對象處於有
鎖狀態,則執行第3步
2.JVM利用CAS操作,嘗試將對象的Mark Word更新爲指向Lock 
Record的指針。如果成功,則表示競爭到鎖。將鎖標誌位變爲00(表
示此對象處於輕量級鎖的狀態),執行同步代碼塊。如果CAS操作失
敗,則執行第3步。
3.判斷當前對象的Mark Word 是否指向當前線程的棧幀,如果是,則
表示當前線程已經持有當前對象的鎖,直接執行同步代碼塊。否則,
說明該鎖對象已經被其他對象搶佔,此後爲了不讓線程阻塞,還會進
入一個自旋鎖的狀態,如在一定的自旋週期內嘗試重新獲取鎖,如果
自旋失敗,則輕量鎖需要膨脹爲重量鎖(重點),鎖標誌位變爲10,
後面等待的線程將會進入阻塞狀態。
釋放輕量鎖:
輕量級鎖的釋放操作,也是通過CAS操作來執行的,步驟如下:
1.取出在獲取輕量級鎖時,存儲在棧幀中的 Displaced Mard Word 數
據。
2.用CAS操作,將取出的數據替換到對象的Mark Word中,如果成
功,則說明釋放鎖成功,如果失敗,則執行第3步。
3.如果CAS操作失敗,說明有其他線程在嘗試獲取該鎖,則要在釋放
鎖的同時喚醒被掛起的線程。

重量級鎖

重量級鎖通過對象內部的監視器(Monitor)來實現,而其中monitor本質上是依賴於低層操作系統的 Mutex Lock實現。
操作系統實現線程切換,需要從用戶態切換到內核態,切換成本非常高。

適應性自旋

在輕量級鎖獲取失敗時,爲了避免線程真實的在系統層面被掛起,還會進行一項稱爲自旋鎖的優化手段。

這是基於以下假設:
大多數情況下,線程持有鎖的時間不會太長,將線程掛起在系統層面耗費的成本較高。
而“適應性”則表示,該自學的週期更加聰明。自旋的週期是不固定的,它是由上一次在同一個鎖上的自旋時間 以及 鎖擁有者的狀態 共同決定。

具體方式是:如果自旋成功了,那麼下次的自旋最大次數會更多,因爲JVM認爲既然上次成功了,那麼這一次也有很大概率會成功,那麼允許等待的最大自旋時間也相應增加。反之,如果對於某一個鎖,很少有自旋成功的,那麼就會相應的減少下次自旋時間,或者乾脆放棄自旋,直接升級爲重量鎖,以免浪費系統資源。

有了適應性自旋,隨着程序的運行信息不斷完善,JVM會對鎖的狀態預測更加精準,虛擬機會變得越來越聰明。

鎖粗化

我們知道,在使用鎖的時候,需要讓同步的作用範圍儘可能的小——僅在共享數據的操作中才進行。這樣做的目的,是爲了讓同步操作的數量儘可能小,如果村子鎖競爭,那麼也能儘快的拿到鎖。
在大多數的情況下,上面的原則是正確的。
但是如果存在一系列連續的 lock unlock 操作,也會導致性能的不必要消耗.
粗化鎖就是將連續的同步操作連在一起,粗化爲一個範圍更大的鎖。
例如,對Vector的循環add操作,每次add都需要加鎖,那麼JVM會檢測到這一系列操作,然後將鎖移到循環外。

鎖消除

鎖消除是JVM進行的另外一項鎖優化,該優化更徹底。
JVM在進行JIT編譯時,通過對上下文的掃描,JVM檢測到不可能存在共享數據的競爭,如果這些資源有鎖,那麼會消除這些資源的鎖。這樣可以節省毫無意義的鎖請求時間。
雖然大部分程序員可以判斷哪些操作是單線程的不必要加鎖,但我們在使用Java的內置 API時,部分操作會隱性的包含鎖操作。例如StringBuffer的操作,HashTable的操作。
鎖消除的依據,是逃逸分析的數據支持。

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