淺入理解jvm

目錄

自動內存管理機制

對象的內存佈局

垃圾回收機制

可達性分析

垃圾收集器

內存分配與回收策略 

虛擬機性能監控工具

類加載

new一個對象發生了什麼

棧幀

類加載器

常量池

變量和線程安全

java內存模型

volatile

線程安全的實現方法

鎖優化


  • 自動內存管理機制

程序計數器區是唯一沒有內存溢出的區域,hotspot通過使用直接指針訪問方法區的對象類型數據。

  • 對象的內存佈局

分爲三部分:對象頭、實例數據、對齊填充。

對象頭包括兩部分:第一部分存儲對象的運行時數據,如哈希碼、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等;第二部分是類型指針,即對象指向它的類元數據的指針,如果對象是java數組,對象頭信息中還有一塊記錄數組長度的數據。

  • 垃圾回收機制

新生代採用標記複製算法,老年代採用標記清除(cms)或標記整理算法。對象首先在eden區分配,當Eden區沒有足夠空間進行分配時,虛擬機會發起一次 Minor GC,把存活的對象複製到to space區域,如果to space區域不夠,則利用擔保機制進入老年代區域。

  • 可達性分析

通過可達性分析判斷對象是否存活,當一個對象到GC Roots沒有任何引用鏈相連證明對象是不可用的。GC Roots對象包括虛擬機棧中引用的對象(即正在運行的方法中引用的對象)、方法區中靜態屬性或常量引用的對象、本地方法棧中引用的對象。

  • 垃圾收集器

serial是默認的新生代收集器,但會stop the world帶來不良體驗。parnew僅僅是多線程,公司目前使用,多核下優於serial。新生代parallel scavenge不做分析,關注吞吐量。老年代有serial old,parallel old,cms。cms注重最短回收停頓時間,思想體現在回收線程與用戶線程並行。在注重吞吐量和cpu資源敏感的場合優先考慮parallel scavenge和parallel old組合。目前公司採用parnew和cms組合,因爲提供給客戶端使用,交互性強,更傾向於垃圾回收時間最短。g1最先進但尚未成熟,思想是將堆劃分爲多個塊,化整爲零,避免全堆掃描,但由於各塊之間對象互相引用,所以具體實現特別複雜。

"高吞吐量"和"低暫停時間"是一對相互競爭的目標(矛盾)。應用程序在GC期間必須停止(或者僅在GC的特定階段,這取決於所使用的算法),然而這會增加額外的線程調度開銷:直接開銷是上下文切換,間接開銷是因爲緩存的影響。 加上JVM內部安全措施的開銷,這意味着GC及隨之而來的不可忽略的開銷,將增加GC線程執行實際工作的時間。 因此我們可以通過儘可能少運行GC來最大化吞吐量,例如,只有在不可避免的時候進行GC,來節省所有與它相關的開銷。然而,僅僅偶爾運行GC意味着每當GC運行時將有許多工作要做,因爲在此期間積累在堆中的對象數量很高。 單個GC需要花更多時間來完成, 從而導致更高的平均和最大暫停時間。 因此,考慮到低暫停時間,最好頻繁地運行GC以便更快速地完成。 這反過來又增加了開銷並導致吞吐量下降,我們又回到了起點。綜上所述,在設計(或使用)GC算法時​​,我們必須確定我們的目標:一個GC算法​​只可能針對兩個目標之一(即只專注於最大吞吐量或最小暫停時間),或嘗試找到一個二者的折衷。

  • cms收集過程

  1. 初始標記(第一次暫停)(老年代gcroots、被年輕代引用的老年代對象)
  2. 併發標記(從初始標記階段標記的對象開始找出所有存活的對象)
  3. 重新標記(第二次暫停)(因爲是併發運行的,在運行期間會發生新生代的對象晉升到老年代、或者是直接在老年代分配對象、或者更新老年代對象的引用關係等等)
  4. 併發清理
  • 內存分配與回收策略 

幾種對象進入老年代的方法:大對象直接進入老年代,可以指定閾值。長期存活的對象進入老年代。空間分配擔保(eden區放不下,觸發young gc,eden區存活對象又大於survivor區,所以通過分配擔保提前轉移到老年代)。同齡對象總和大於survivor空間的一半也能進老年代。

  • 虛擬機性能監控工具

jps顯示虛擬機進程,jstat定位性能問題的首選工具,可以顯示類裝載 內存 垃圾回收等運行數據,jinfo查看虛擬機參數,jmap生成堆轉儲快照,jstack生成當前時刻的線程快照。兩種可視化工具jconsole和visualvm。

  • 類加載

類加載時機:new等指令、反射、父類未初始化等場景。類加載需要完成三件事:根據全限定名加載二進制流,靜態存儲結構轉化爲方法區運行時數據結構,生成class對象作爲方法區數據訪問入口。加載字節流可以從jar、zip等格式、網絡、動態生成等方式。類編譯爲class文件時,方法會被編譯成字節碼指令,存放在一個名爲code的屬性裏面。類加載後,會驗證文件格式、元數據(如繼承關係)、字節碼(語義)、符號引用(權限等)。加載、驗證後進入準備階段,爲類變量分配內存並設置初始值(不是初始化),常量直接初始化。

  • new一個對象發生了什麼

如果是第一次使用此類,則使用雙親委派機制加載,分爲加載、驗證、準備、解析、初始化,此時類變量已經分配內存並初始化。創建對象首先在堆上分配內存、對實例變量賦默認值、初始化、在棧區定義引用變量並賦值給他堆上的地址。需要注意的是,無論是加載還是實例化,父類總比子類先執行,所以父類和子類的執行順序是:父類靜態變量和靜態代碼塊,子類靜態變量和靜態代碼塊,父類變量和代碼塊,父類構造函數,子類變量和代碼塊,子類構造函數。

  • 棧幀

存儲了方法的局部變量表、操作數棧、動態連接、方法返回地址等信息。靜態方法和私有方法符合編譯期可知,運行期不可變。靜態分派的典型應用是方法重載,動態分派-重寫。

  • 類加載器

兩個類相等的前提條件是由同一個類加載器加載。啓動類加載器加載lib目錄下的類庫,擴展類加載器加載lib\ext目錄下的類庫,應用程序類加載器加載用戶類路徑上的類庫。雙親委派模型:當類加載器接收到加載類的請求時,自己先不加載,而是委託給父類加載器去完成,最終委託到啓動類加載器,當父類搜不到需要加載的類時再由子類加載器去完成。線程上下文類加載器、熱部署等破壞雙親委派模型。

  • 常量池

java中的常量池分爲靜態常量池和運行時常量池。靜態常量池即class文件中的Constant pool,jvm加載類時,將class中的靜態常量池保存到方法區形成運行時常量池。

  • 變量和線程安全

  1. 靜態變量內存中只有一份所有對象共享,修改後會對其他對象有影響,線程不安全;
  2. 實例變量在單例模式下線程不安全,雖然是佔用堆內存,但只有一份,spring的bean默認是單例模式,所以應小心添加實例變量,實例變量在非單例模式下是線程安全的,每個對象都在堆上有自己的內存;
  3. 局部變量線程安全,每個線程執行時把局部變量放在自己的棧幀的工作內存中,線程間不共享。
  • java內存模型

java內存模型用來屏蔽java程序在不同硬件或操作系統對內存訪問的差異,規定了所有變量(主要是實例變量和靜態變量這種能被共享的變量,不包括局部變量和方法參數這種線程私有的變量)都存儲在主線程中,每條線程有自己的工作內存,保存該線程使用到的主內存副本拷貝,線程對變量的所有操作都必須在工作內存中進行(通過lock、unlock、read、load、use、assign、store、write操作),而不能直接讀寫主內存的變量。

  • volatile

  1. 保證變量對所有線程的可見性,即當一個線程修改了變量值 ,新值對其他線程來說是立即得知的。線程修改變量並非把所有線程的變量都修改了,而是指線程使用加了volatile的變量時必須要先刷新。(保證不了原子性)
  2. 禁止指令重排(單例模式雙重校驗方式爲什麼需要使用volatile
  • 線程安全的實現方法

1、互斥同步(synchronized)

synchronized關鍵字在經過編譯之後,會在同步塊的前後形成monitor enter和monitor exit兩個字節碼指令,這兩個字節碼都需要一個reference類型的參數來指明要鎖定和解鎖的對象。任何一個對象都有一個Monitor與之關聯,當且一個Monitor被持有後,它將處於鎖定狀態。Synchronized在JVM裏的實現都是 基於進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實現。那什麼是Monitor?可以把它理解爲一個同步工具,也可以描述爲一種同步機制,它通常被描述爲一個對象。與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都有成爲Monitor的潛質,因爲在Java的設計中 ,每一個Java對象自打孃胎裏出來就帶了一把看不見的鎖,它叫做內部鎖或者Monitor鎖。也就是通常說Synchronized的對象鎖,MarkWord鎖標識位爲10,其中指針指向的是Monitor對象的起始地址。

由於java的線程是映射到操作系統的原生線程上的,如果要阻塞或喚醒一個線程,都需要從用戶態轉換到內核態,耗費很多的時間,因此synchronized是一個重量級的操作。互斥同步屬於悲觀策略。

ReentrantLock與synchronized很相似,都是可重入鎖,ReentrantLock通過lock()和unlock()配合try/catch完成,主要有三個不同:等待可中斷(線程等待鎖的時候可以放棄等待)、公平鎖(多個線程按照先後順序獲得鎖)、可以綁定多個條件(ReentrantLock可以精確喚醒線程,不像synchronized只能隨機喚醒或者全部喚醒)、輪詢鎖(通過多次tryLock()獲得多個鎖,如果不能同時獲得,就全部釋放,按固定時間+隨機時間輪詢再次請求)。ReentrantLock的幾個重要方法:lock()、lockInterruptibly()、tryLock()、tryLock(long time, TimeUnit unit)、unlock()。

2、非阻塞同步

基於衝突檢測的樂觀策略,先進行操作,如果沒有其他線程爭用共享數據就操作成功,否則再採取補償措施(最常見的補償措施就是不斷重試),這種方式不需要把線程掛起。

CAS(compare and swap)比較並交換。CAS指令需要3個操作數:內存位置、舊預期值、新值。缺點是存在ABA問題

3、無同步方案

如果一個變量要被多線程訪問,可以聲明爲volatile;如果一個變量要被線程獨享就使用ThreadLocal存儲。把共享數據的可見範圍限制在同一個線程之內。最經典的應用實例——“一個請求對應一個服務器線程”。

  • 鎖優化

鎖優化是針對互斥同步來進行。由於互斥同步阻塞和喚醒都需要轉到內核態,給操作系統併發性能帶來了很大壓力。

1、自旋鎖、自適應自旋

讓後面請求鎖的線程不阻塞,而是“稍等一下”。jdk1.6之前自旋次數默認10次,並且可以通過參數修改。1.6之後引入了自適應自旋鎖,自旋的時間或次數由前一次在同一個鎖上的自旋時間及鎖的擁有者狀態決定。如果在同一個鎖對象上,自旋等待剛剛獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機會認爲這次自旋也很有可能成功,進而允許自旋更長時間。如果對於某個鎖對象很少自旋獲得成功過,以後則可能會省略自旋過程。

2、鎖消除

虛擬機即時編譯器在運行時,檢測到某些不存在共享競爭的數據加了鎖則進行消除。

比如StringBuffer的append()方法中都有一個同步塊,若jvm發現StringBuffer的對象的所有引用都不會逃逸(逃逸分析的基本行爲就是分析對象動態作用域:當一個對象在方法中被定義後,可能會通過參數傳遞被外部方法所引用,稱爲方法逃逸;賦值給類變量或可以在其他線程訪問到的實例變量稱爲線程逃逸)到方法體之外,則會消除同步塊。

3、鎖粗化

我們在寫代碼時總是推薦同步塊的作用範圍儘量小,但如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖是出現在循環體中的,則不如直接將鎖粗化到整個操作序列的外部。比如StringBuffer連續的append操作連續的加鎖解鎖,不如把鎖粗化到第一個append之前和最後一個append之後。

4、輕量級鎖

輕量級鎖並不是用來替代重量級鎖的,它能提升程序同步性能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”。輕量級鎖的執行過程是:在代碼進入同步塊時,如果同步對象沒有被鎖定(鎖標誌位爲“01”),虛擬機將鎖對象的Mark Word(對象頭存儲的哈希碼、GC年齡等)拷貝到當前線程的棧幀,並使用CAS操作嘗試將對象頭的Mark Word更新爲指向副本的指針。若更新成功,則這個線程擁有了該對象的鎖,並且對象的鎖標誌位轉變爲“00”;若更新失敗,首先檢查對象的Mark Word是否指向當前線程的棧幀,若指向則說明當前線程已經擁有了對象鎖,直接進入同步塊執行,否則說明鎖對象已經被其他線程搶佔了,那輕量級鎖不再有效,要膨脹爲重量級鎖,鎖標誌位轉爲“10”。解鎖過程也是通過CAS操作進行。

5、偏向鎖

如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步互斥,那偏向鎖就是在無競爭的情況下把整個同步都消除,連CAS操作都不做了。它的原理是:當線程請求的鎖對象後,將Mark Word中鎖對象的狀態標誌位改爲“01”,即偏向模式。然後使用CAS操作將線程的ID記錄在Mark Word中,以後該線程可以直接進入同步塊,連CAS都不用做。但是一旦有第二條線程競爭鎖,則偏向模式立即結束,膨脹爲輕量級鎖。

6、鎖的膨脹過程

當線程A訪問同步代碼塊時,此時同步對象是無鎖狀態(MarkWord中鎖標誌位爲01、線程ID爲空),所以將對象頭設置爲偏向鎖狀態,使用CAS將線程ID設置爲當前線程ID。當線程B訪問同步代碼塊時,發現同步對象是偏向鎖狀態,檢查持有鎖的線程A是否存活。若A已經掛了則將同步對象設置爲無鎖狀態,然後偏向線程B;否則A持有的鎖膨脹爲輕量級鎖(鎖標誌位轉爲00、MarkWord),B自旋一段時間。當B自旋結束或者又來一個線程C,A還沒釋放輕量級鎖就膨脹爲重量級鎖(鎖標誌位轉爲10,指針指向monitor對象)。

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