深入理解JVM備忘錄

初識

Java SE + 擴充 = Java EE
擴充一般以 javax. 作爲包名,java. 均爲Java SE API的核心包,由於歷史原因,核心包中也包含不少 javax.*。

JDK 1.4,引入NIO類。

2004.9.30 發佈 JDK 1.5,引入java.util.concurrent 包。

JDK 1.7,引入java.util.concurrency.forkjoin。

Apach Hadoop Map/Reduce: 分佈式並行運算框架。

Scale, Erlang, Clojure: 天生具備並行運算能力。

JDK 1.6 Update 14 後,提供了普通對象指針壓縮功能以減緩64位虛擬機的內存消耗與性能問題(指針膨脹和數據類型對白補齊引起)。

自動內存管理機制

Java 內存區域與溢出

JVM將管理的的內存劃分爲:

  1. 程序計數器: 當前執行字節碼的行號指示器。執行native方法時,值爲空。
  2. 虛擬機棧: Java方法執行的內存模型,當執行時創建棧幀,用於存儲局部變量表,操作棧,動態鏈接,方法出口等。調用方法則入棧,結束出棧。局部變量所需內存在編譯期完成分配。大小由-Xss設置。無限遞歸,定義大量本地變量可發生StackOverflow(由OOM引起)。定義大量線程可發生OOM。
  3. 本地方法棧: Native方法。Sum HotSpot 將其與虛擬機棧合二爲一,故-Xoss參數(設置本地方法棧大小)存在但無效。
  4. Java堆(線程間共享): 存儲對象實例及數組。物理上不連續,邏輯上連續,一般設計成可拓展(通過-Xmx和-Xms實現)。GC主要區域。不斷生成對象並加入List可發生OOM。
  5. 方法區(線程間共享): 存儲已被加載的類信息,常量,靜態變量,即時編譯後產生的代碼等。GC較少,JVM規範限制非常寬鬆。-XX:PermSize和-XX:MaxPermSize。使用反射,動態代理,CGLib可實現OOM。
  • 運行時常量池: 存放編譯期生成的字面量和符號引用。具有動態性。使用Native方法String.intern()(動態生成字符串並加入運行時常量池)生成大量常量並加入List可發生OOM。
直接內存: 不屬於JVM數據區。NIO使用Native函數直接分配外內存,通過存儲在Java堆中的DirectByBuffer對象作爲此塊內存的引用。這樣避免Java堆與Native堆間數據複製。其不受Java堆大小限制(-Xmx參數)。其大小由-XX:MaxDirectMemorySize控制,當不指定此值時,其默認值與-Xmx一樣)。根據反射獲得Unsafe實例並進行內存分配可發生OOM。

Socket緩衝區

Object object = new Object()

  1. object 以reference類型存儲於Java棧的本地變量表中。
  2. Object類型所有的實例數據值以一個結構化內存形式存儲於Java堆。同時包含此對象的類型數據(類,父類,實現的接口,方法等)的地址。
  3. 類型數據存儲於方法區中。

reference 定位對象的方法

  1. 直接指向Java堆的地址,其中還存放指向類型數據的地址信息。Sun HotSpot採用此方法。優點: 尋址快。
  2. 指向句柄池,Java堆劃分一塊內存作爲句柄池,其中包含對象地址(Java堆)及對象類型地址(方法區)。優點: GC代價小,對象被移動時無需更改reference.

JDK1.2 後,Java對引用進行了擴充

  1. Strong Reference: 正常的引用。
  2. Soft Reference: 系統將要發生OOM時觸發第二次GC回收並將Soft Reference列入回收範圍,依舊內存不足則觸發OOM。
  3. Week Reference: 僅能存活到下次GC。
  4. Phantom Reference: 無法通過虛引用取得實例對象。其唯一目的是在對象被回收時收到一個系統通知。

垃圾收集器與內存回收策略

GC重點關注Java堆和方法區。

識別無用對象

引用計數

當對象相互引用時,就是二者均未再被外界引用,計數器均爲1,不會被回收。Java未用此方法。

根搜索

以一系列GC root爲起點搜索引用鏈,未與任何引用鏈相關聯的對象爲可回收。Java及其他主流商用語言(如C#)採用此方法
GC root對象包括以下幾種:

  1. 虛擬機棧(棧幀中的本地變量表)中引用的對象
  2. 方法區中類靜態屬性引用的對象
  3. 方法區中常量引用的對象
  4. 本地方法棧的JNI(即Native方法)引用的對象

GC過程

發現不可達到到真正GC,至少要經歷兩次標記過程。
  1. 發現未與GC Root相連,被第一次標記並進行篩選。對象未覆蓋finalize方法或finalize已被虛擬機調用過,則無必要執行finalize。
  2. 若有必要執行finalize,將放入F-Queue。稍後由一個虛擬機建立的,低優先級的Finalize線程執行。(不承諾等待其執行完畢)
  3. 稍後GC對F-Queue進行二次標記,如果對象在finalize函數中重新與引用鏈建立聯繫,則就將在此次標記時移出“即將回收”集合。

方法區(或者HotSpot虛擬機的永久代)的垃圾回收

主要分爲
*. 判斷棄用常量:與回收Java堆的對象類似。
*. 判斷無用類:

  1. 該類所有實例均被回收,即Java堆中不存在該類的實例。
  2. 加載該類的ClassLoader已被回收。
  3. 該類對應的java.lang.Class 未被引用。無法在任何地方通過反射訪問到該類。

與對象不同,被判斷爲可回收後,並不一定被回收,HotSpot中可由-Xnoclassgc控制。-verbose:class, -XX:+TraceClassLoading, -XX:+TraceClassUnloading(具體可否使用有虛擬機版本限制)可查看類的加載和卸載信息。在某些場景下,類卸載是保證永久代不會溢出的關鍵。

垃圾收集算法

  1. 標記 - 清除(Mark-Sweep):缺點:標記和清除過程效率都不高。空間碎片太多,分配大對象時會提前觸發另一次GC。多應用於老年代。
  2. 複製算法:將內存劃分爲大小相同的兩塊,每次只使用一塊,只移動堆頂指針,順序分配。當內存不足時,將存活的對象複製到另一塊內存。優點:無需考慮碎片問題,實現簡單,運行高效。缺點:內存縮小一半,對象存活率較高時,複製操作變多,效率變低。基於多數情況下,98%的新生代對象均爲朝生夕死,商用虛擬機使用此方法回收新生代。實際使用中,將內存劃分爲一塊較大的Eden和兩塊較小的Survivor,當回收時,將Eden和使用的Survivor中的存活對象拷貝到另一塊Survivor並清空二者。HotSpot中,二者大小比爲8:1,即僅10%會被浪費。當可回收的對象大於10%時(無法完全被拷貝到一個Survivor),需要依賴老年代進行分配擔保。
  3. 標記 - 整理:標記後將存活的對象向一端移動,然後清理掉邊界以外的內存。多應用於老年代。
  4. 分代收集:一般將Java堆劃分爲新生代和老年代。

垃圾收集器

  1. Serial:使用複製算法,利用一個線程且暫停其他所有工作線程。JDK 1.3.1之前是虛擬機新生代的唯一選擇。對於單線程環境,其沒有線程交互開銷,專心GC使其簡單而高效。時至今日,它依然是Client模式下運行的虛擬機的新生生代收集器。在用戶的桌面環境,分配給虛擬機的內存一般不大,停頓時間可以接受。
  2. ParNew:使用多線程進行回收,其他與Serial相同。-XX:ParallelGCThreads可限制回收線程數目,默認與CPU數量相同。它是許多Server模式下的虛擬機首選的新生代收集器(因爲只有它和Serial能與CMS配合),也是使用-XX:+UseConcMarkSweepGC後的默認新生代收集器,也可使用-XX:UsePerNewGC強制指定。
  3. Parallel Scavenge:同2一樣,爲新生代收集,複製算法、並行多線程。其以提高吞吐量而非停頓時間爲目標(吞吐量優先收集器),適合在後臺運算而不需要太多交互。-XX:MaxGCPauseMillis 控制垃圾回收最大停頓時間,-XX:GCTomeRatio 控制設置吞吐量大小(用戶運行時間/GC時間)。-XX:+UseAdaptiveSizePolicy 開啓後無需手動指定 -Xmm 新生代大小, -XX:SurvivorRatio Eden與Survivor比例,-XX:PetenureSizeThreshold 晉升老年代年齡等。
  4. Serial Old:Serial的老年代版本,使用 標記 - 整理 算法,依然主要用於Client模式。另Server模式下,在JDK 1.5 及之前與Parallel Scavenge 配合使用(Parallel Scavenge架構中有PS MarkSweep進行老年代收集,其以Serial Old 爲模版且非常相近,所以許多常直接用Serial Old進行講解)。同時作爲CMS的後備預案,在併發收集發生Concurrent Mode Failure時使用。
  5. Parallel Old:JDK 1.6 中釋出,Parallel Scavenge的老年代版本,使用多線程 標記 - 整理算法。
  6. CMS(Concurrent Mark Sweep / Concurrent Low Pause Collector):HotSpot於JDK 1.5時期推出的老年代收集器,以最短回收時間爲目標,採用 標記 - 清理 算法。其運作過程爲:1. 初始標記,標記GC Roots可達的對象,速度很快,需要暫停其他工作線程。2. 併發標記,進行GC Roots Tracing的過程,相對耗時較長。3. 重新標記,修正併發標記階段因程序繼續程序運作而導致標記產生的變動,比初始標記稍長,需要暫停其他工作線程。4. 併發清除。耗時較長的2和4均採用與用戶工作線程併發運作的方式。缺點:對CPU敏感,其啓用(CPU MUBER + 3) / 4個回收線程,當CPU不足4時,有一半被佔用,嚴重影響吞吐量。虛擬機提供 增量式併發收集器(Incremental Concurrency Mark Sweep / i-CMS / Deprecated 不推薦使用)使用單CPU年代的搶佔式模擬多任務機制,使垃圾收集週期增長,從而減小多用戶的影響。無法處理浮動垃圾。可通過 -XX:CMSInitialingOccupancyFraction設置觸發回收的內存餘量閥值(默認68%)。-XX:+UseCMSCompactAtFullCollection開關用於在GC(標記 - 清除算法)後整理產生的碎片,此灰過程無法併發,使停頓時間變長。-XX:CMSFullGCsBeforeCompaction可設置多次GC對應一次整理。
  7. G1:於6,使用 標記 - 整理 機制。可精確控制停頓,也即在一定時間中GC消耗的時間。其將Java堆(老年代和新生代)劃分爲大小固定的獨立區域,並跟蹤區域的垃圾堆積程度,維護一張優先列表,在GC優先回收高優先級區域。它與Parallel Scanvege未使用傳統GC代碼框架,故無法與其他收集器配合工作。
Minor GC指新生代GC,通常小較快。Full GC / Major GC 指老年代GC,通常伴隨Minor GC(Parallel Scavenge就有直接Full GC的策略選擇過程),速度慢10倍以上。

其他配置:

  1. UseSerialGC: Client模式下的默認值,使用Serial + Serial Old。
  2. UseParNewGC:ParNew + Serial Old。
  3. UseConcMarkSweepGC: 使用ParNew + CMS + Serial Old(作爲預備方案)。
  4. UseParallelGC:Service模式下的默認值,使用Parellel Scavenge + Serial Old(PS MarkWeep)。
  5. UseParallelOldGC:Parallel Scavenge + Parallel Old。
  6. PretenureSizeThreshold:直接晉升老年代的對象大小閥值。避免複製算法中Survivor和Eden間發生大量複製。僅Serial 和 ParNew 有效。
  7. MaxTenuringThreshold:晉升老年代的年齡。默認15。
  8. HandlePromotionFailure:是否允許擔保失敗。發生Minor GC時,虛擬機會檢測之前每次晉升的平均對象大小是否大於老年代剩餘空間,若大於則進行Full GC,若小於且此值爲允許則不進行,小於且允許則進行。大多數情況打開此開關避免頻繁的Full GC。
  9. PrintGCDetails:輸出GC過程。
  10. -Xms:堆最小值。
  11. -Xmx:堆最大值。
  12. -Xmn:分配給新生代的大小。
  13. HeapDumpOnOutOfMemorryError:出現內存溢出時Dump出當前內存堆轉儲快照。
  14. -XX:+DisableExplicitGC:禁用手動觸發GC(System.gc())。

內存分配與回收策略

  1. 對象優先在Eden分配。
  2. 大對象直接進入老年代:避免新生代內存間出現大量的大文件拷貝操作。
  3. 長期存活對象進入老年代。
  4. 動態對象年齡判斷:如果Suvivor中相同年齡的對象佔用大於一半以上空間,則此年齡及以上的對象直接進入老年代。

虛擬機性能監控與故障處理工具

主要數據來源:運行日誌,異常堆棧,GC日誌,線程快照(threaddump / javacore文件),堆轉儲快照(heapdump / hprof文件)等。

Sun JDK監控和故障處理工具:

  1. jps:JVM Process Status Tool,顯示指定系統內的所有HotSpot虛擬機進程。
  2. jstat:JVM Statistics Monitoring Tool,收集HotSpot各方面的運行數據。
  3. jinfo:Configuration Info for Java,顯示虛擬機配置信息。
  4. jmap:Memory Map for Java,生成虛擬機的內存轉儲快照(heapdump文件)。
  5. jhat:JVM Heap Dump Brower,用於分析heapdump文件並建立服務器讓用戶可以在瀏覽器上查看分析結果。
  6. jstack:Stack Trace for Java,顯示虛擬機線程快照。

可視化工具:

  1. JConsole:JDK 1.5時期提供。
  2. VisualVM:JDK 1.6首發。

調優案例分析及實戰

虛擬機執行子系統

類文件結構

平臺與語言無關性的基石:字節碼存儲格式(Class文件)。

虛擬機類加載機制

將類的描述數據從Class文件加載到內存並進行一系列操作形成可被JVM直接使用的Java類型的過程。
與編譯時連接的語言不同,Java類型的 加載 和 連接 過程是在程序運行期間完成的,使其擁有動態拓展的特性。例如,編寫一個使用接口的應用程序,可待運行時再確定具體實現。

類加載的時機

過程:加載 - 驗證 - 準備 - 解析 & 初始化 - 使用 - 卸載。

解析和初始化順序不定是爲了支持運行時綁定(動態綁定,晚期綁定)。
驗證 - 準備 - 解析 合稱爲連接。各過程可同時進行但開始順序一定。

各過程的時機:
加載:虛擬機規範未強制規定。
初始化:

  1. new, get static, put static, invoke static四條字節碼指令(static指代類的除final修飾、已在編譯期把結果放入常量池的靜態字段和靜態方法)。
  2. 使用java.lang.reflect包的方法對類進行反射調用。
  3. 子類被初始化之前(與類不同,對於接口,只有真正被子類使用時纔會初始化,如使用接口定義的常量)。
  4. 程序執行的主類。

所有引用類的方式,不會觸發初始化,即被動引用。

  1. 通過子類引用父類的靜態字段。(Sun HotSpot中,通過-XX:+TraceClassLoading 可發現子類被加載)
  2. 定義類的數組。(此時虛擬機中會使用newarray字節碼指令初始化自動生成的繼承於Object的名爲 “[$類名” 的類。 )
  3. 訪問類中的常量(因其在編譯期已進入調用類的常量池,未直接引用類)。

類加載過程

  • 加載:
  1. 通過類的 全限定名 獲取 定義此類的二進制字節流。
流來源有很多,如Class文件,ZIP包中獲取(JAR, EAR, WAR文件),網絡中獲取(Applet),運行時計算(動態代理),其他文件生成(JSP)等。
  1. 將此二進制流所代表的 靜態存儲結構 轉換爲 方法區的運行時結構。
  2. 在 Java堆 中生成一個代表此類的 java.lang.Class對象 作爲方法區訪問此類的入口。
  • 驗證:

虛擬機的一項自我保護工作,工作量佔比較大。對於可信的代碼集,可使用-Xverify:none關閉大部分的類驗證措施。大致分爲分爲:

  1. 文件格式驗證:經此驗證後字節流纔會進入內存的方法區進行存儲。
  2. 元數據校驗:
  3. 字節碼校驗:最複雜的階段。
  4. 符號引用驗證:
  • 準備:正式爲類變量(被static修飾的變量,不包含實例變量)分配內存並設置類變量初始值(類型的初始值,並不進行賦值,如int初始化爲0,賦值將在初始化階段進行。對於被static final修飾的變量,在編譯時已爲其值生成ConstantValue屬性,從時則會直接使用該值進行準確的賦值)。
  • 解析:將常量池中的符號引用(引用目標不一定已被加載到內存)引用轉換爲直接引用(直接指向目標的指針、相對偏移量、能間接定位到目標的句柄)。

對同一個符號引用進行解析時虛擬機可能會對結果進行緩存,在運行時常量池中記錄直接引用,並把常量標記爲已解析。

當子類和父類聲明同名Static字段,編譯器將拒絕編譯。接口均爲public故針對接口的解析一般不會拋出java.lang.IIlegleAccessError。
  • 初始化: 執行類構造器<client>()方法。其在編譯期收集所有類變量的賦值語句和靜態代碼塊(static{})中的語句。

類加載器

類加載過程被放置在虛擬機外部,以便應用自行決定如何去獲取類。實現此動作的代碼塊稱爲 類加載器。

  • 啓動類加載器(Bootstrap ClassLoader): 加載java_home/lib目錄、被-Xbootclasspath指定的目錄、並且被虛擬機識別的類庫到虛擬機內存。與下面所述的加載器不同,其無法被Java直接引用,對於HotSpot,其使用C++實現。
  • 擴展類記載器(Extension ClassLoader): 由sun.misc.Launcher$ExtClassLoader實現,負責加載java_home/lib/ext目錄、被java.ext.dirs系統變量指定的目錄中的類庫。
  • 應用程序加載器(Application ClassLoader): 由sun.misc.Launcher$AppClassLoader實現,是ClassLoader.getSystemClassLoader()方法的返回值。

雙親委派模型

JDK1.2中被引入。

按上所述順序形成父子類加載器(不以繼承(Inheritance)關係而以組合(Composition)方式實現來複用毒加載器的代碼),當子類收到類加載請求,則先委派給父加載器去完成(如果類還未被加載的話),當父加載器拋出ClassNotFoundException後,再調用自己的findClass()方法進行加載。其主要代碼集中在java.lang.ClassLoader的loadClass()方法中。此模型使得不管哪個加載器要加載特定的類,最終均會使用同一加載器完成,即爲同一個類,實現統一性(不同加載器加載的同一Class屬於不同類)。

被破壞的雙親委派模型:

  1. 例如JNDI、JDBC、JCE、JAXB、JBI等(代碼由啓動類加載器加載)涉及SPI(Service Provider Interface, 接力提供者)的加載動作, 需要調用獨立廠商實現並部署在ClassPath下的接口提供者代碼(啓動類加載器並不認識)。爲此新增線程上下文類加載器(通過java.lang.Thread.setContextClassLoader()設置,若創建線程時爲設置,則從父加載器繼承一個,若全局範圍均未設置,也默認爲應用類加載器),使父加載器可以請求子加載器完成加載操作。
  2. OSGi環境下,爲實現模塊化熱部署,類加載進一步發展爲網狀結構。

虛擬機字節碼執行引擎

每個方法從調用開始到完成均對應着一個棧幀從入棧到出棧的過程。

編譯程序代碼時,棧幀中需要多大的局部變量和多深的操作數棧都已經確定。一個棧幀分配多少內存不受運行期變量數據影響。

運行時棧幀結構:

  • 局部變量表: 一組變量值存儲空間,以變量槽(Slot)爲最小範圍,單位大小可簡單理解爲32位長度的內存空間。對於64位數據類型,以高位在前爲其分配兩個Slot。虛擬機使用Slot的索引值(0值開頭)使用局部變量表。如果是實例方法(Not Static Method),則局部變量表中的第0位索引的Slot默認是用於傳遞方法所述對象實例的引用,也即this關鍵字所訪問的的參數。Slot可被重用,若字節碼PC計數器已超過某變量運用域,則變量對應的Slot可交由其他變量使用。不同於類變量,局部變量無“準備階段”,即不會存在默認值(Boolean類型默認爲false是不存在的)。
  • 操作數棧(操作棧): 其中內容可爲任意Java數據類型。方法開始執行時,棧爲空,當做諸如算法等操作時,字節碼指令會向棧中提取和寫入內容。原則上兩個棧幀相互獨立,但作爲優化,會有部分重合,使方法調用無須額外的參數複製。解釋執行引擎即基於棧的執行引擎,棧即指操作數棧
以下三項可統稱爲棧幀信息
  • 動態連接: 各棧幀均包含指向運行時常量池中該幀所屬方法的引用以支持動態鏈接。
  • 方法返回地址: 調用者的PC計數器可以作爲返回地址。當有返回值時,會將其壓入調用者的操作數棧中。
  • 附加信息: 虛擬機允許添加規範中爲涉及的信息,如調試信息。

方法調用

虛擬機編譯不包含連接過程,一切方法調用在Class文件中都是符號引用,非實際內存入口,使Java擁有強大的擴展能力,但方法調用更爲複雜。

  • 解析: 每個目標調用方法在Class文件裏都是一個常量池中的符號引用。對於靜態方法私有方法,實例構造器,父類方法,final修飾的方法五類(invokestatic,invokespecial, invokevirtual, invokeinterface字節碼指令中的前兩個所調用的方法及final修飾的方法),在類加載的時候就會把符號引用解析爲直接引用,稱爲解析調用(對應於分派調用,這些方法稱爲非虛方法)。
  • 分派:

靜態分派:

static abstract class Human{}
static class Women extends Human{}
static class Man extends Human{}

Human爲靜態類型(外觀類型),Women和Man爲實際類型。虛擬機(編譯器)在重載時通過靜態類型判斷,其在編譯期是可知的。當以以上三個類爲參數不同點進行重載時,編譯器會編譯期生成invokevirtual指令,調用Human參數的重載方法。使用靜態類型定位調用方法版本的分派動作稱爲靜態分派。當類線性實現接口時,針對接口變量的重載將成回溯形形式定位調用方法版本,當某處同時實現兩個接口且兩個接口均存在重載方法時會拒絕編譯。此時調用重載方法時需要顯式類型轉換。

動態分派: 當調用Women重寫於Human的方法時,執行的invokevirtual指令會先找到操作數棧第一個元素指向的對象實例類型(也即實際類型)。如果其中找到所需方法則進行權限檢驗(未通過則拋java.lang.IIlegalAccessError),否則按繼承關係向上搜索與驗證。若始終找不到,拋java.lang.AbstractMethodError。invokevirtual指令把常量池中的類方法符號引用解析到不同的直接引用上,此過程即爲方法重寫的本質。在運行期根據實際類型確定方法執行版本的分派過程稱爲動態分派

  • 單分派和多分派: 靜態多分派,動態單分派。
father.say(_360)
song.say(qq)

編譯階段,靜態分派使用靜態類型進行定位,最終生成Father.say(_360)和Father.say(qq)兩條invokevirtual指令。其根據兩個宗量(目標方法所有者和方法參數稱爲宗量)進行選擇,故稱爲多分派
運行階段,確定兩條指令的確切目標是Father還是Son,參數將不再成爲選擇因素,故一個宗量進行選擇稱爲單分派

  • 動態分派的優化

動態分派非常頻繁且需要運行時在類的方法元數據中搜索合適的目標方法,出於性能考慮,會有一些優化手段。比如使用虛方法表(virtual method table,接口方法表與此類似)存儲各個方法的實際入口,父子類未重寫的方法入口地址將相同。

基於棧的字節碼解釋執行引擎

基於棧的指令集和基於寄存器的指令集

基於棧的解釋器的執行過程

類加載及執行子系統的案例與實戰

程序編譯與代碼優化

  1. 前段編譯器: .java轉換成.class的過程,Sun的Javac,Eclipse JDT中的增量式編譯器(ECJ)。javac使用語法糖來改善程序員的編碼風格和效率。
  2. 運行期編譯器(JIT編譯器): 字節碼轉換成機器碼,HotSpot VM的C1, C2編譯器。優化的主要階段,使非javac編譯的Class文件也可以享受編譯器優化帶來的好處。
  3. 靜態提前編譯器(AOT編譯器): .java直接轉換成機器碼,GNU Complier for the Java(GCJ),Excelsior JET。

Javac 編譯器

  • 解析與充填符號表: 詞法分析,將源碼中字符流轉換成標記(Token)集合,標記指一個關鍵詞如int。語法分析將根據Token序列構造抽象語法樹(AST),
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章