本篇文章給大家?guī)砹岁P(guān)于java的相關(guān)知識,其中主要整理了并發(fā)編程的相關(guān)問題,包括了Java 內(nèi)存模型、volatile 詳解以及synchronized 的實現(xiàn)原理等等內(nèi)容,下面一起來看一下,希望對大家有幫助。
推薦學習:《java視頻教程》
一、JMM 基礎-計算機原理
Java 內(nèi)存模型即 Java Memory Model,簡稱JMM。JMM 定義了Java 虛擬機 (JVM)在計算機內(nèi)存(RAM)中的工作方式。JVM 是整個計算機虛擬模型,所以 JMM 是隸屬于 JVM 的。Java1.5 版本對其進行了重構(gòu),現(xiàn)在的 Java 仍沿用了 Java1.5 的版本。Jmm 遇到的問題與現(xiàn)代計算機中遇到的問題是差不多的。
物理計算機中的并發(fā)問題,物理機遇到的并發(fā)問題與虛擬機中的情況有不少 相似之處,物理機對并發(fā)的處理方案對于虛擬機的實現(xiàn)也有相當大的參考意義。
根據(jù)《Jeff Dean 在 Google 全體工程大會的報告》我們可以看到
計算機在做一些我們平時的基本操作時,需要的響應時間是不一樣的。
以下案例僅做說明,并不代表真實情況。
如果從內(nèi)存中讀取 1M 的 int 型數(shù)據(jù)由 CPU 進行累加,耗時要多久?
做個簡單的計算,1M 的數(shù)據(jù),Java 里 int 型為 32 位,4 個字節(jié),共有 1024*1024/4 = 262144 個整數(shù) ,則 CPU 計算耗時:262144 0.6 = 157286 納秒, 而我們知道從內(nèi)存讀取 1M 數(shù)據(jù)需要 250000 納秒,兩者雖然有差距(當然這個差距并不小,十萬納秒的時間足夠 CPU 執(zhí)行將近二十萬條指令了),但是還在 一個數(shù)量級上。但是,沒有任何緩存機制的情況下,意味著每個數(shù)都需要從內(nèi)存 中讀取,這樣加上 CPU 讀取一次內(nèi)存需要 100 納秒,262144 個整數(shù)從內(nèi)存讀取 到 CPU 加上計算時間一共需要 262144100+250000 = 26 464 400 納秒,這就存在 著數(shù)量級上的差異了。
而且現(xiàn)實情況中絕大多數(shù)的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與內(nèi)存交互,如讀取運算數(shù)據(jù)、存儲運算結(jié)果等,這個 I/O 操作是基本上是無法消除的(無法僅靠寄存器來完成所有運算任務)。早期計算機中 cpu 和內(nèi)存的速度是差不多的,但在現(xiàn)代計算機中,cpu 的指令速度遠超內(nèi)存的存取速度,由于計算機的存儲設備與處理器的運算速度有幾個數(shù)量級的差距,所 以現(xiàn)代計算機系統(tǒng)都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內(nèi)存與處理器之間的緩沖:將運算需要使用到的數(shù)據(jù)復制到緩存中,讓運算能快速進行,當運算結(jié)束后再從緩存同步回內(nèi)存之中,這樣 處理器就無須等待緩慢的內(nèi)存讀寫了。
在計算機系統(tǒng)中,寄存器是 L0 級緩存,接著依次是 L1,L2,L3(接下來是內(nèi)存,本地磁盤,遠程存儲)。越往上的緩存存儲空間越小,速度越快,成本也更高;越往下的存儲空間越大,速度更慢,成本也更低。從上至下,每一層都可以看做是更下一層的緩存,即:L0 寄存器是 L1 一級緩存的緩存,L1 是 L2 的緩存,依次類推;每一層的數(shù)據(jù)都是來至它的下一層,所以每一層的數(shù)據(jù)是下一 層的數(shù)據(jù)的子集。
在現(xiàn)代 CPU 上,一般來說 L0, L1,L2,L3 都集成在 CPU 內(nèi)部,而 L1 還分 為一級數(shù)據(jù)緩存(Data Cache,D-Cache,L1d)和一級指令緩存(Instruction Cache, I-Cache,L1i),分別用于存放數(shù)據(jù)和執(zhí)行數(shù)據(jù)的指令解碼。每個核心擁有獨立 的運算處理單元、控制器、寄存器、L1、L2 緩存,然后一個 CPU 的多個核心共 享最后一層 CPU 緩存 L3。
二、Java 內(nèi)存模型(JMM)
從抽象的角度來看,JMM 定義了線程和主內(nèi)存之間的抽象關(guān)系:線程之間的共享變量存儲在主內(nèi)存(Main Memory)中,每個線程都有一個私有的本地內(nèi)存(Local Memory),本地內(nèi)存中存儲了該線程以讀/寫共享變量的副本。本地內(nèi)存是 JMM 的一個抽象概念,并不真實存在。它涵蓋了緩存、寫緩沖區(qū)、寄存器以及其他的硬件和編譯器優(yōu)化。
2.1、可見性
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值, 其他線程能夠立即看得到修改的值。
由于線程對變量的所有操作都必須在工作內(nèi)存中進行,而不能直接讀寫主內(nèi)存中的變量,那么對于共享變量 V,它們首先是在自己的工作內(nèi)存,之后再同步到主內(nèi)存??墒遣⒉粫皶r的刷到主存中,而是會有一定時間差。很明顯,這個時候線程 A 對變量 V 的操作對于線程 B 而言就不具備可見性了 。
要解決共享對象可見性這個問題,我們可以使用 volatile 關(guān)鍵字或者是加鎖。
2.2、原子性
原子性:即一個操作或者多個操作,要么全部執(zhí)行并且執(zhí)行的過程不會被任何因素打斷,要么就都不執(zhí)行。
我們都知道 CPU 資源的分配都是以線程為單位的,并且是分時調(diào)用,操作系統(tǒng)允許某個進程執(zhí)行一小段時間,例如 50 毫秒,過了 50 毫秒操作系統(tǒng)就會重新選擇一個進程來執(zhí)行(我們稱為“任務切換”),這個 50 毫秒稱為“時間片”。 而任務的切換大多數(shù)是在時間片段結(jié)束以后,。
那么線程切換為什么會帶來 bug 呢?
因為操作系統(tǒng)做任務切換,可以發(fā)生在任何一條 CPU 指令執(zhí)行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高級語言里的一條語句。比如 count++,在 java 里就是一句話,但高級語言里一條語句往往需要多條 CPU 指令完成。其實 count++至少包含了三個 CPU 指令!
三、volatile 詳解
3.1、volatile 特性
可以把對 volatile 變量的單個讀/寫
,看成是使用同一個鎖對這些單個讀/寫
操作做了同步
public class Volati { // 使用volatile 聲明一個64位的long型變量 volatile long i = 0L;// 單個volatile 變量的讀 public long getI() { return i; }// 單個volatile 變量的寫 public void setI(long i) { this.i = i; }// 復合(多個)volatile 變量的 讀/寫 public void iCount(){ i ++; }}
可以看成是下面的代碼:
public class VolaLikeSyn { // 使用 long 型變量 long i = 0L; public synchronized long getI() { return i; }// 對單個的普通變量的讀用同一個鎖同步 public synchronized void setI(long i) { this.i = i; }// 普通方法調(diào)用 public void iCount(){ long temp = getI(); // 調(diào)用已同步的讀方法 temp = temp + 1L; // 普通寫操作 setI(temp); // 調(diào)用已同步的寫方法 }}
所以 volatile 變量自身具有下列特性:
- 可見性:對一個 volatile 變量的讀,總是能看到(任意線程)對這個 volatile 變量最后的寫入。
- 原子性:對任意單個 volatile 變量的讀/寫具有原子性,但類似于 volatile++ 這種復合操作不具有原子性。
volatile 雖然能保證執(zhí)行完及時把變量刷到主內(nèi)存中,但對于 count++這種非原子性、多指令的情況,由于線程切換,線程 A 剛把 count=0 加載到工作內(nèi)存, 線程 B 就可以開始工作了,這樣就會導致線程 A 和 B 執(zhí)行完的結(jié)果都是 1,都寫到主內(nèi)存中,主內(nèi)存的值還是 1 不是 2
3.2、volatile 的實現(xiàn)原理
- volatile 關(guān)鍵字修飾的變量會存在一個“l(fā)ock:”的前綴。
- Lock 前綴,Lock 不是一種內(nèi)存屏障,但是它能完成類似內(nèi)存屏障的功能。Lock 會對 CPU 總線和高速緩存加鎖,可以理解為 CPU 指令級的一種鎖。
- 同時該指令會將當前處理器緩存行的數(shù)據(jù)直接寫會到系統(tǒng)內(nèi)存中,且這個寫 回內(nèi)存的操作會使在其他 CPU 里緩存了該地址的數(shù)據(jù)無效。
四、synchronized 的實現(xiàn)原理
Synchronized 在 JVM 里的實現(xiàn)都是基于進入和退出 Monitor 對象來實現(xiàn)方法同步和代碼塊同步,雖然具體實現(xiàn)細節(jié)不一樣,但是都可以通過成對的 MonitorEnter 和 MonitorExit 指令來實現(xiàn)。
對同步塊,MonitorEnter 指令插入在同步代碼塊的開始位置,而 monitorExit 指令則插入在方法結(jié)束處和異常處,JVM 保證每個 MonitorEnter 必須有對應的 MonitorExit??偟膩碚f,當代碼執(zhí)行到該指令時,將會嘗試獲取該對象 Monitor 的所有權(quán),即嘗試獲得該對象的鎖:
- 如果 monitor 的進入數(shù)為 0,則該線程進入 monitor,然后將進入數(shù)設置為 1,該線程即為 monitor 的所有者。
- 如果線程已經(jīng)占有該 monitor,只是重新進入,則進入 monitor 的進入數(shù)加 1。
- 如果其他線程已經(jīng)占用了 monitor,則該線程進入阻塞狀態(tài),直到 monitor 的進入數(shù)為 0,再重新嘗試獲取 monitor 的所有權(quán)。 對同步方法,從同步方法反編譯的結(jié)果來看,方法的同步并沒有通過指令 monitorenter 和 monitorexit 來實現(xiàn),相對于普通方法,其常量池中多了 ACC_SYNCHRONIZED 標示符。
JVM 就是根據(jù)該標示符來實現(xiàn)方法的同步的:當方法被調(diào)用時,調(diào)用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標志是否被設置,如果設置了,執(zhí)行線程將先獲取 monitor,獲取成功之后才能執(zhí)行方法體,方法執(zhí)行完后再釋放 monitor。在方法執(zhí)行期間,其他任何線程都無法再獲得同一個 monitor 對象。
synchronized 使用的鎖是存放在 Java 對象頭里面,Java 對象的對象頭由 mark word 和 klass pointer 兩部分組成:
- mark word 存儲了同步狀態(tài)、標識、hashcode、GC 狀態(tài)等等。
- klass pointer 存儲對象的類型指針,該指針指向它的類元數(shù)據(jù) 另外對于數(shù)組而言還會有一份記錄數(shù)組長度的數(shù)據(jù)。
鎖信息則是存在于對象的 mark word 中,MarkWord 里默認數(shù)據(jù)是存儲對象的 HashCode 等信息。
但是會隨著對象的運行改變而發(fā)生變化,不同的鎖狀態(tài)對應著不同的記錄存儲方式
4.1、鎖的狀態(tài)
對照上面的圖中,我們發(fā)現(xiàn)鎖一共有四種狀態(tài),無鎖狀態(tài),偏向鎖狀態(tài),輕量級鎖狀態(tài)和重量級鎖狀態(tài), 它會隨著競爭情況逐漸升級。鎖可以升級但不能降級,目的是為了提高獲得鎖和 釋放鎖的效率。
4.2、偏向鎖
引入背景:大多數(shù)情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖,減少不必要的 CAS 操作。
偏向鎖,顧名思義,它會偏向于第一個訪問鎖的線程,如果在運行過程中, 同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發(fā)同步的,減少加鎖/解鎖的一些 CAS 操作(比如等待隊列的一些 CAS 操作),這種情況下,就會給線程加一個偏向鎖。 如果在運行過程中,遇到了其他線程搶占鎖,則持有偏向鎖的線程會被掛起,JVM 會消除它身上的偏向鎖,將鎖恢復到標 準的輕量級鎖。它通過消除資源無競爭情況下的同步原語,進一步提高了程序的 運行性能。
看下面圖,了解偏向鎖獲取過程:
步驟 1、 訪問 Mark Word 中偏向鎖的標識是否設置成 1,鎖標志位是否為 01,確認為可偏向狀態(tài)。
步驟 2、 如果為可偏向狀態(tài),則測試線程 ID 是否指向當前線程,如果是, 進入步驟 5,否則進入步驟 3。
步驟 3、 如果線程 ID 并未指向當前線程,則通過 CAS 操作競爭鎖。如果競 爭成功,則將 Mark Word 中線程 ID 設置為當前線程 ID,然后執(zhí)行 5;如果競爭 失敗,執(zhí)行 4。
步驟 4、 如果 CAS 獲取偏向鎖失敗,則表示有競爭。當?shù)竭_全局安全點 (safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞在安全點的線程繼續(xù)往下執(zhí)行同步代碼。(撤銷偏向鎖的時候會導致 stop the word)
步驟 5、 執(zhí)行同步代碼。
偏向鎖的釋放:
偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放偏向鎖,線程不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節(jié)碼正在執(zhí)行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處于被鎖定狀態(tài),撤銷偏向鎖后恢復到未鎖定(標志位為“01”)或輕量級鎖(標志位為“00”)的狀態(tài)。
偏向鎖的適用場景:
始終只有一個線程在執(zhí)行同步塊,在它沒有執(zhí)行完釋放鎖之前,沒有其它線程去執(zhí)行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級為輕量級鎖,升級為輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致 stop the word 操作;
在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向鎖的時候會導致進入安全點,安全點會導致 stw,導致性能下降,這種情況下應當禁用。
jvm 開啟/關(guān)閉偏向鎖
開啟偏向鎖:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 關(guān)閉偏向鎖:-XX:-UseBiasedLocking
4.3、 輕量級鎖
輕量級鎖是由偏向鎖升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當?shù)诙€線程加入鎖爭用的時候,偏向鎖就會升級為輕量級鎖;
輕量級鎖的加鎖過程:
- 在代碼進入同步塊的時候,如果同步對象鎖狀態(tài)為無鎖狀態(tài)且不允許進行偏向(鎖標志位為“01”狀態(tài),是否為偏向鎖為“0”),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的 Mark Word 的拷貝,官方稱之為 Displaced Mark Word。
- 拷貝對象頭中的 Mark Word 復制到鎖記錄中。
- 拷貝成功后,虛擬機將使用 CAS 操作嘗試將對象的 Mark Word 更新為指向 Lock Record 的指針,并將 Lock record 里的 owner 指針指向 object mark word。如果更新成功,則執(zhí)行步驟 4,否則執(zhí)行步驟 5。
- 如果這個更新動作成功了,那么這個線程就擁有了該對象的鎖,并且對象 Mark Word 的鎖標志位設置為“00”,即表示此對象處于輕量級鎖定狀態(tài)
- 如果這個更新操作失敗了,虛擬機首先會檢查對象的 Mark Word 是否指向當前線程的棧幀,如果是就說明當前線程已經(jīng)擁有了這個對象的鎖,那就可以直接進入同步塊繼續(xù)執(zhí)行。否則說明多個線程競爭鎖,那么它就會自旋等待鎖,一定次數(shù)后仍未獲得鎖對象。重量級線程指針指向競爭線程,競爭線程也會阻塞,等待輕量級線程釋放鎖后喚醒他。鎖標志的狀態(tài)值變?yōu)椤?0”,Mark Word 中存儲 的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也要進入阻塞狀態(tài)。
4.3.1、自旋鎖原理
自旋鎖原理非常簡單,如果持有鎖的線程能在很短時間內(nèi)釋放鎖資源,那么那些等待競爭鎖的線程就不需要做內(nèi)核態(tài)和用戶態(tài)之間的切換進入阻塞掛起狀態(tài),它們只需要等一等(自旋),等持有鎖的線程釋放鎖后即可立即獲取鎖,這樣就避免用戶線程和內(nèi)核的切換的消耗。
但是線程自旋是需要消耗 CPU 的,說白了就是讓 CPU 在做無用功,線程不能一直占用 CPU 自旋做無用功,所以需要設定一個自旋等待的最大時間。
如果持有鎖的線程執(zhí)行的時間超過自旋等待的最大時間扔沒有釋放鎖,就會導致其它爭用鎖的線程在最大等待時間內(nèi)還是獲取不到鎖,這時爭用線程會停止自旋進入阻塞狀態(tài)。
4.3.2、自旋鎖的優(yōu)缺點
自旋鎖盡可能的減少線程的阻塞,這對于鎖的競爭不激烈,且占用鎖時間非常短的代碼塊來說性能能大幅度的提升,因為自旋的消耗會小于線程阻塞掛起操作的消耗。
但是如果鎖的競爭激烈,或者持有鎖的線程需要長時間占用鎖執(zhí)行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是占用 cpu 做無用 功,占著 茅坑 不 那啥,線程自旋的消耗大于線程阻塞掛起操作的消耗,其它需要 cup 的線程又不能獲取到 cpu,造成 cpu 的浪費。
4.3.3、自旋鎖時間閾值
自旋鎖的目的是為了占著 CPU 的資源不釋放,等到獲取到鎖立即進行處理。 但是如何去選擇自旋的執(zhí)行時間呢?如果自旋執(zhí)行時間太長,會有大量的線程處于自旋狀態(tài)占用 CPU 資源,進而會影響整體系統(tǒng)的性能。因此自旋次數(shù)很重要。
JVM 對于自旋次數(shù)的選擇,jdk1.5 默認為 10 次,在 1.6 引入了適應性自旋鎖, 適應性自旋鎖意味著自旋的時間不在是固定的了,而是由前一次在同一個鎖上的 自旋時間以及鎖的擁有者的狀態(tài)來決定,基本認為一個線程上下文切換的時間是 最佳的一個時間。
JDK1.6 中-XX:+UseSpinning 開啟自旋鎖; JDK1.7 后,去掉此參數(shù),由 jvm 控 制;
4.3.4、不同鎖的比較
推薦學習:《java視頻教程》