這裏僅僅記錄了一些筆者認爲須要重點掌握的 JVM 知識點,若是你想更加全面地瞭解 JVM 底層原理,能夠閱讀周志明老師《深刻理解Java虛擬機——JVM高級特性與最佳實踐(第2版)》全書。前端
Java 虛擬機的內存空間分爲 5 個部分:java
JDK 1.8 同 JDK 1.7 比,最大的差異就是:元數據區取代了永久代。元空間的本質和永久代相似,都是對 JVM 規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元數據空間並不在虛擬機中,而是使用本地內存。git
程序計數器是一塊較小的內存空間,是當前線程正在執行的那條字節碼指令的地址。若當前線程正在執行的是一個本地方法,那麼此時程序計數器爲Undefined
。github
OutOfMemoryError
的內存區域。Java 虛擬機棧是描述 Java 方法運行過程的內存模型。算法
Java 虛擬機棧會爲每個即將運行的 Java 方法建立一塊叫作「棧幀」的區域,用於存放該方法運行過程當中的一些信息,如:數據庫
當方法運行過程當中須要建立局部變量時,就將局部變量的值存入棧幀中的局部變量表中。數組
Java 虛擬機棧的棧頂的棧幀是當前正在執行的活動棧,也就是當前正在執行的方法,PC 寄存器也會指向這個地址。只有這個活動的棧幀的本地變量能夠被操做數棧使用,當在這個棧幀中調用另外一個方法,與之對應的棧幀又會被建立,新建立的棧幀壓入棧頂,變爲當前的活動棧幀。緩存
方法結束後,當前棧幀被移出,棧幀的返回值變成新的活動棧幀中操做數棧的一個操做數。若是沒有返回值,那麼新的活動棧幀中操做數棧的操做數沒有變化。安全
因爲Java 虛擬機棧是與線程對應的,數據不是線程共享的,所以不用關心數據一致性問題,也不會存在同步鎖的問題。服務器
出現 StackOverFlowError 時,內存空間可能還有不少。
本地方法棧是爲 JVM 運行 Native 方法準備的空間,因爲不少 Native 方法都是用 C 語言實現的,因此它一般又叫 C 棧。它與 Java 虛擬機棧實現的功能相似,只不過本地方法棧是描述本地方法運行過程的內存模型。
本地方法被執行時,在本地方法棧也會建立一塊棧幀,用於存放該方法的局部變量表、操做數棧、動態連接、方法出口信息等。
方法執行結束後,相應的棧幀也會出棧,並釋放內存空間。也會拋出 StackOverFlowError 和 OutOfMemoryError 異常。
若是 Java 虛擬機自己不支持 Native 方法,或是自己不依賴於傳統棧,那麼能夠不提供本地方法棧。若是支持本地方法棧,那麼這個棧通常會在線程建立的時候按線程分配。
堆是用來存放對象的內存空間,幾乎全部的對象都存儲在堆中。
不一樣的區域存放不一樣生命週期的對象,這樣能夠根據不一樣的區域使用不一樣的垃圾回收算法,更具備針對性。
堆的大小既能夠固定也能夠擴展,但對於主流的虛擬機,堆的大小是可擴展的,所以當線程請求分配內存,但堆已滿,且內存已沒法再擴展時,就拋出 OutOfMemoryError 異常。
Java 堆所使用的內存不須要保證是連續的。而因爲堆是被全部線程共享的,因此對它的訪問須要注意同步問題,方法和對應的屬性都須要保證一致性。
Java 虛擬機規範中定義方法區是堆的一個邏輯部分。方法區存放如下信息:
方法區中存放:類信息、常量、靜態變量、即時編譯器編譯後的代碼。常量就存放在運行時常量池中。
當類被 Java 虛擬機加載後, .class 文件中的常量就存放在方法區的運行時常量池中。並且在運行期間,能夠向常量池中添加新的常量。如 String 類的 intern() 方法就能在運行期間向常量池中添加字符串常量。
直接內存是除 Java 虛擬機以外的內存,但也可能被 Java 使用。
在 NIO 中引入了一種基於通道和緩衝的 IO 方式。它能夠經過調用本地方法直接分配 Java 虛擬機以外的內存,而後經過一個存儲在堆中的DirectByteBuffer
對象直接操做該內存,而無須先將外部內存中的數據複製到堆中再進行操做,從而提升了數據操做的效率。
直接內存的大小不受 Java 虛擬機控制,但既然是內存,當內存不足時就會拋出 OutOfMemoryError 異常。
服務器管理員在配置虛擬機參數時,會根據實際內存設置
-Xmx
等參數信息,但常常忽略直接內存,使得各個內存區域總和大於物理內存限制,從而致使動態擴展時出現OutOfMemoryError
異常。
在 HotSpot 虛擬機中,對象的內存佈局分爲如下 3 塊區域:
對象頭記錄了對象在運行過程當中所須要使用的一些數據:
對象頭可能包含類型指針,經過該指針能肯定對象屬於哪一個類。若是對象是一個數組,那麼對象頭還會包括數組長度。
實例數據部分就是成員變量的值,其中包括父類成員變量和本類成員變量。
用於確保對象的總長度爲 8 字節的整數倍。
HotSpot VM 的自動內存管理系統要求對象的大小必須是 8 字節的整數倍。而對象頭部分正好是 8 字節的倍數(1 倍或 2 倍),所以,當對象實例數據部分沒有對齊時,就須要經過對齊填充來補全。
對齊填充並非必然存在,也沒有特別的含義,它僅僅起着佔位符的做用。
虛擬機在解析.class
文件時,若遇到一條 new 指令,首先它會去檢查常量池中是否有這個類的符號引用,而且檢查這個符號引用所表明的類是否已被加載、解析和初始化過。若是沒有,那麼必須先執行相應的類加載過程。
對象所需內存的大小在類加載完成後即可徹底肯定,接下來從堆中劃分一塊對應大小的內存空間給新的對象。分配堆中內存有兩種方式:
指針碰撞 若是 Java 堆中內存絕對規整(說明採用的是「複製算法」或「標記整理法」),空閒內存和已使用內存中間放着一個指針做爲分界點指示器,那麼分配內存時只須要把指針向空閒內存挪動一段與對象大小同樣的距離,這種分配方式稱爲「指針碰撞」。
空閒列表 若是 Java 堆中內存並不規整,已使用的內存和空閒內存交錯(說明採用的是標記-清除法,有碎片),此時無法簡單進行指針碰撞, VM 必須維護一個列表,記錄其中哪些內存塊空閒可用。分配之時從空閒列表中找到一塊足夠大的內存空間劃分給對象實例。這種方式稱爲「空閒列表」。
分配完內存後,爲對象中的成員變量賦上初始值,設置對象頭信息,調用對象的構造函數方法進行初始化。
至此,整個對象的建立過程就完成了。
全部對象的存儲空間都是在堆中分配的,可是這個對象的引用倒是在堆棧中分配的。也就是說在創建一個對象時兩個地方都分配內存,在堆中分配的內存實際創建這個對象,而在堆棧中分配的內存只是一個指向這個堆對象的指針(引用)而已。 那麼根據引用存放的地址類型的不一樣,對象有不一樣的訪問方式。
堆中須要有一塊叫作「句柄池」的內存空間,句柄中包含了對象實例數據與類型數據各自的具體地址信息。
引用類型的變量存放的是該對象的句柄地址(reference)。訪問對象時,首先須要經過引用類型的變量找到該對象的句柄,而後根據句柄中對象的地址找到對象。
引用類型的變量直接存放對象的地址,從而不須要句柄池,經過引用可以直接訪問對象。但對象所在的內存空間須要額外的策略存儲對象所屬的類信息的地址。
須要說明的是,HotSpot 採用第二種方式,即直接指針方式來訪問對象,只須要一次尋址操做,因此在性能上比句柄訪問方式快一倍。但像上面所說,它須要額外的策略來存儲對象在方法區中類信息的地址。
程序計數器、虛擬機棧、本地方法棧隨線程而生,也隨線程而滅;棧幀隨着方法的開始而入棧,隨着方法的結束而出棧。這幾個區域的內存分配和回收都具備肯定性,在這幾個區域內不須要過多考慮回收的問題,由於方法結束或者線程結束時,內存天然就跟隨着回收了。
而對於 Java 堆和方法區,咱們只有在程序運行期間才能知道會建立哪些對象,這部份內存的分配和回收都是動態的,垃圾收集器所關注的正是這部份內存。
若一個對象不被任何對象或變量引用,那麼它就是無效對象,須要被回收。
在對象頭維護着一個 counter 計數器,對象被引用一次則計數器 +1;若引用失效則計數器 -1。當計數器爲 0 時,就認爲該對象無效了。
引用計數算法的實現簡單,斷定效率也很高,在大部分狀況下它都是一個不錯的算法。可是主流的 Java 虛擬機裏沒有選用引用計數算法來管理內存,主要是由於它很難解決對象之間循環引用的問題。
舉個栗子👉對象 objA 和 objB 都有字段 instance,令 objA.instance = objB 而且 objB.instance = objA,因爲它們互相引用着對方,致使它們的引用計數都不爲 0,因而引用計數算法沒法通知 GC 收集器回收它們。
全部和 GC Roots 直接或間接關聯的對象都是有效對象,和 GC Roots 沒有關聯的對象就是無效對象。
GC Roots 是指:
GC Roots 並不包括堆中對象所引用的對象,這樣就不會有循環引用的問題。
斷定對象是否存活與「引用」有關。在 JDK 1.2 之前,Java 中的引用定義很傳統,一個對象只有被引用或者沒有被引用兩種狀態,咱們但願能描述這一類對象:當內存空間還足夠時,則保留在內存中;若是內存空間在進行垃圾手收集後仍是很是緊張,則能夠拋棄這些對象。不少系統的緩存功能都符合這樣的應用場景。
在 JDK 1.2 以後,Java 對引用的概念進行了擴充,將引用分爲了如下四種。不一樣的引用類型,主要體現的是對象不一樣的可達性狀態reachable
和垃圾收集的影響。
相似 "Object obj = new Object()" 這類的引用,就是強引用,只要強引用存在,垃圾收集器永遠不會回收被引用的對象。可是,若是咱們錯誤地保持了強引用,好比:賦值給了 static 變量,那麼對象在很長一段時間內不會被回收,會產生內存泄漏。
軟引用是一種相對強引用弱化一些的引用,可讓對象豁免一些垃圾收集,只有當 JVM 認爲內存不足時,纔會去試圖回收軟引用指向的對象。JVM 會確保在拋出 OutOfMemoryError 以前,清理軟引用指向的對象。軟引用一般用來實現內存敏感的緩存,若是還有空閒內存,就能夠暫時保留緩存,當內存不足時清理掉,這樣就保證了使用緩存的同時,不會耗盡內存。
弱引用的強度比軟引用更弱一些。當 JVM 進行垃圾回收時,不管內存是否充足,都會回收只被弱引用關聯的對象。
虛引用也稱幽靈引用或者幻影引用,它是最弱的一種引用關係。一個對象是否有虛引用的存在,徹底不會對其生存時間構成影響。它僅僅是提供了一種確保對象被 finalize 之後,作某些事情的機制,好比,一般用來作所謂的 Post-Mortem 清理機制。
對於可達性分析中不可達的對象,也並非沒有存活的可能。
JVM 會判斷此對象是否有必要執行 finalize() 方法,若是對象沒有覆蓋 finalize() 方法,或者 finalize() 方法已經被虛擬機調用過,那麼視爲「沒有必要執行」。那麼對象基本上就真的被回收了。
若是對象被斷定爲有必要執行 finalize() 方法,那麼對象會被放入一個 F-Queue 隊列中,虛擬機會以較低的優先級執行這些 finalize()方法,但不會確保全部的 finalize() 方法都會執行結束。若是 finalize() 方法出現耗時操做,虛擬機就直接中止指向該方法,將對象清除。
若是在執行 finalize() 方法時,將 this 賦給了某一個引用,那麼該對象就重生了。若是沒有,那麼就會被垃圾收集器清除。
任何一個對象的 finalize() 方法只會被系統自動調用一次,若是對象面臨下一次回收,它的 finalize() 方法不會被再次執行,想繼續在 finalize() 中自救就失效了。
方法區中存放生命週期較長的類信息、常量、靜態變量,每次垃圾收集只有少許的垃圾被清除。方法區中主要清除兩種垃圾:
只要常量池中的常量不被任何變量或對象引用,那麼這些常量就會被清除掉。好比,一個字符串 "bingo" 進入了常量池,可是當前系統沒有任何一個 String 對象引用常量池中的 "bingo" 常量,也沒有其它地方引用這個字面量,必要的話,"bingo"常量會被清理出常量池。
斷定一個類是不是「無用的類」,條件較爲苛刻。
一個類被虛擬機加載進方法區,那麼在堆中就會有一個表明該類的對象:java.lang.Class。這個對象在類被加載進方法區時建立,在方法區該類被刪除時清除。
學會了如何斷定無效對象、無用類、廢棄常量以後,剩餘工做就是回收這些垃圾。常見的垃圾收集算法有如下幾個:
標記的過程是:遍歷全部的 GC Roots
,而後將全部 GC Roots
可達的對象標記爲存活的對象。
清除的過程將遍歷堆中全部的對象,將沒有標記的對象所有清除掉。與此同時,清除那些被標記過的對象的標記,以便下次的垃圾回收。
這種方法有兩個不足:
爲了解決效率問題,「複製」收集算法出現了。它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊內存用完,須要進行垃圾收集時,就將存活者的對象複製到另外一塊上面,而後將第一塊內存所有清除。這種算法有優有劣:
爲了解決空間利用率問題,能夠將內存分爲三塊: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一塊 Survivor。回收時,將 Eden 和 Survivor 中還存活的對象一次性複製到另一塊 Survivor 空間上,最後清理掉 Eden 和剛纔使用的 Survivor 空間。這樣只有 10% 的內存被浪費。
可是咱們沒法保證每次回收都只有很少於 10% 的對象存活,當 Survivor 空間不夠,須要依賴其餘內存(指老年代)進行分配擔保。
分配擔保
爲對象分配內存空間時,若是 Eden+Survivor 中空閒區域沒法裝下該對象,會觸發 MinorGC 進行垃圾收集。但若是 Minor GC 事後依然有超過 10% 的對象存活,這樣存活的對象直接經過分配擔保機制進入老年代,而後再將新對象存入 Eden 區。
標記:它的第一個階段與標記/清除算法是如出一轍的,均是遍歷 GC Roots
,而後將存活的對象標記。
整理:移動全部存活的對象,且按照內存地址次序依次排列,而後將末端內存地址之後的內存所有回收。所以,第二階段才稱爲整理階段。
這是一種老年代的垃圾收集算法。老年代的對象通常壽命比較長,所以每次垃圾回收會有大量對象存活,若是採用複製算法,每次須要複製大量存活的對象,效率很低。
根據對象存活週期的不一樣,將內存劃分爲幾塊。通常是把 Java 堆分爲新生代和老年代,針對各個年代的特色採用最適當的收集算法。
HotSpot 虛擬機提供了多種垃圾收集器,每種收集器都有各自的特色,雖然咱們要對各個收集器進行比較,但並不是爲了挑選出一個最好的收集器。咱們選擇的只是對具體應用最合適的收集器。
只開啓一條 GC 線程進行垃圾回收,而且在垃圾收集過程當中中止一切用戶線程(Stop The World)。
通常客戶端應用所需內存較小,不會建立太多對象,並且堆內存不大,所以垃圾收集器回收時間短,即便在這段時間中止一切用戶線程,也不會感受明顯卡頓。所以 Serial 垃圾收集器適合客戶端使用。
因爲 Serial 收集器只使用一條 GC 線程,避免了線程切換的開銷,從而簡單高效。
ParNew 是 Serial 的多線程版本。由多條 GC 線程並行地進行垃圾清理。但清理過程依然須要 Stop The World。
ParNew 追求「低停頓時間」,與 Serial 惟一區別就是使用了多線程進行垃圾收集,在多 CPU 環境下性能比 Serial 會有必定程度的提高;但線程切換須要額外的開銷,所以在單 CPU 環境中表現不如 Serial。
Parallel Scavenge 和 ParNew 同樣,都是多線程、新生代垃圾收集器。可是二者有巨大的不一樣點:
吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)
追求高吞吐量,能夠經過減小 GC 執行實際工做的時間,然而,僅僅偶爾運行 GC 意味着每當 GC 運行時將有許多工做要作,由於在此期間積累在堆中的對象數量很高。單個 GC 須要花更多的時間來完成,從而致使更高的暫停時間。而考慮到低暫停時間,最好頻繁運行 GC 以便更快速完成,反過來又致使吞吐量降低。
Serial Old 收集器是 Serial 的老年代版本,都是單線程收集器,只啓用一條 GC 線程,都適合客戶端應用。它們惟一的區別就是:Serial Old 工做在老年代,使用「標記-整理」算法;Serial 工做在新生代,使用「複製」算法。
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
CMS(Concurrent Mark Sweep,併發標記清除)收集器是以獲取最短回收停頓時間爲目標的收集器(追求低停頓),它在垃圾收集時使得用戶線程和 GC 線程併發執行,所以在垃圾收集過程當中用戶也不會感到明顯的卡頓。
併發標記與併發清除過程耗時最長,且能夠與用戶線程一塊兒工做,所以,整體上說,CMS 收集器的內存回收過程是與用戶線程一塊兒併發執行的。
CMS 的缺點:
對於產生碎片空間的問題,能夠經過開啓 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成後都會進行一次內存壓縮整理,將零散在各處的對象整理到一塊。設置參數 -XX:CMSFullGCsBeforeCompaction告訴 CMS,通過了 N 次 Full GC 以後再進行一次內存整理。
G1 是一款面向服務端應用的垃圾收集器,它沒有新生代和老年代的概念,而是將堆劃分爲一塊塊獨立的 Region。當要進行垃圾收集時,首先估計每一個 Region 中垃圾的數量,每次都從垃圾回收價值最大的 Region 開始回收,所以能夠得到最大的回收效率。
從總體上看, G1 是基於「標記-整理」算法實現的收集器,從局部(兩個 Region 之間)上看是基於「複製」算法實現的,這意味着運行期間不會產生內存空間碎片。
這裏拋個問題👇:
一個對象和它內部所引用的對象可能不在同一個 Region 中,那麼當垃圾回收時,是否須要掃描整個堆內存才能完整地進行一次可達性分析?
並不!每一個 Region 都有一個 Remembered Set,用於記錄本區域中全部對象引用的對象所在的區域,進行可達性分析時,只要在 GC Roots 中再加上 Remembered Set 便可防止對整個堆內存進行遍歷。
若是不計算維護 Remembered Set 的操做,G1 收集器的工做過程分爲如下幾個步驟:
對象的內存分配,就是在堆上分配(也可能通過 JIT 編譯後被拆散爲標量類型並間接在棧上分配),對象主要分配在新生代的 Eden 區上,少數狀況下可能直接分配在老年代,分配規則不固定,取決於當前使用的垃圾收集器組合以及相關的參數配置。
如下列舉幾條最廣泛的內存分配規則,供你們學習。
大多數狀況下,對象在新生代 Eden 區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。
👇Minor GC vs Major GC/Full GC:
在 JVM 規範中,Major GC 和 Full GC 都沒有一個正式的定義,因此有人也簡單地認爲 Major GC 清理老年代,而 Full GC 清理整個內存堆。
大對象是指須要大量連續內存空間的 Java 對象,如很長的字符串或數據。
一個大對象可以存入 Eden 區的機率比較小,發生分配擔保的機率比較大,而分配擔保須要涉及大量的複製,就會形成效率低下。
虛擬機提供了一個 -XX:PretenureSizeThreshold 參數,令大於這個設置值的對象直接在老年代分配,這樣作的目的是避免在 Eden 區及兩個 Survivor 區之間發生大量的內存複製。(還記得嗎,新生代採用複製算法回收垃圾)
JVM 給每一個對象定義了一個對象年齡計數器。當新生代發生一次 Minor GC 後,存活下來的對象年齡 +1,當年齡超過必定值時,就將超過該值的全部對象轉移到老年代中去。
使用 -XXMaxTenuringThreshold 設置新生代的最大年齡,只要超過該參數的新生代對象都會被轉移到老年代中去。
若是當前新生代的 Survivor 中,相同年齡全部對象大小的總和大於 Survivor 空間的一半,年齡 >= 該年齡的對象就能夠直接進入老年代,無須等到 MaxTenuringThreshold 中要求的年齡。
JDK 6 Update 24 以前的規則是這樣的:
在發生 Minor GC 以前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代全部對象總空間, 若是這個條件成立,Minor GC 能夠確保是安全的; 若是不成立,則虛擬機會查看 HandlePromotionFailure 值是否設置爲容許擔保失敗, 若是是,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小, 若是大於,將嘗試進行一次 Minor GC,儘管此次 Minor GC 是有風險的; 若是小於,或者 HandlePromotionFailure 設置不容許冒險,那此時也要改成進行一次 Full GC。
JDK 6 Update 24 以後的規則變爲:
只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小,就會進行 Minor GC,不然將進行 Full GC。
經過清除老年代中廢棄數據來擴大老年代空閒空間,以便給新生代做擔保。
這個過程就是分配擔保。
👇總結一下有哪些狀況可能會觸發 JVM 進行 Full GC:
System.gc() 方法的調用 此方法的調用是建議 JVM 進行 Full GC,注意這只是建議而非必定,但在不少狀況下它會觸發 Full GC,從而增長 Full GC 的頻率。一般狀況下咱們只須要讓虛擬機本身去管理內存便可,咱們能夠經過 -XX:+ DisableExplicitGC 來禁止調用 System.gc()。
老年代空間不足 老年代空間不足會觸發 Full GC操做,若進行該操做後空間依然不足,則會拋出錯誤: java.lang.OutOfMemoryError: Java heap space
永久代空間不足 JVM 規範中運行時數據區域中的方法區,在 HotSpot 虛擬機中也稱爲永久代(Permanet Generation),存放一些類信息、常量、靜態變量等數據,當系統要加載的類、反射的類和調用的方法較多時,永久代可能會被佔滿,會觸發 Full GC。若是通過 Full GC 仍然回收不了,那麼 JVM 會拋出錯誤信息:java.lang.OutOfMemoryError: PermGen space
CMS GC 時出現 promotion failed 和 concurrent mode failure promotion failed,就是上文所說的擔保失敗,而 concurrent mode failure 是在執行 CMS GC 的過程當中同時有對象要放入老年代,而此時老年代空間不足形成的。
統計獲得的 Minor GC 晉升到舊生代的平均大小大於老年代的剩餘空間
在高性能硬件上部署程序,目前主要有兩種方式:
堆內存變大後,雖然垃圾收集的頻率減小了,但每次垃圾回收的時間變長。 若是堆內存爲14 G,那麼每次 Full GC 將長達數十秒。若是 Full GC 頻繁發生,那麼對於一個網站來講是沒法忍受的。
對於用戶交互性強、對停頓時間敏感的系統,能夠給 Java 虛擬機分配超大堆的前提是有把握把應用程序的 Full GC 頻率控制得足夠低,至少要低到不會影響用戶使用。
可能面臨的問題:
在一臺物理機器上啓動多個應用服務器進程,每一個服務器進程分配不一樣端口, 而後在前端搭建一個負載均衡器,以反向代理的方式來分配訪問請求。
考慮到在一臺物理機器上創建邏輯集羣的目的僅僅是爲了儘量利用硬件資源,並不須要關心狀態保留、熱轉移之類的高可用性能需求, 也不須要保證每一個虛擬機進程有絕對的均衡負載,所以使用無 Session 複製的親合式集羣是一個不錯的選擇。 咱們僅僅須要保障集羣具有親合性,也就是均衡器按必定的規則算法(通常根據 SessionID 分配) 將一個固定的用戶請求永遠分配到固定的一個集羣節點進行處理便可。
可能遇到的問題:
一個小型系統,使用 32 位 JDK,4G 內存,測試期間發現服務端不定時拋出內存溢出異常。 加入 -XX:+HeapDumpOnOutOfMemoryError(添加這個參數後,堆內存溢出時就會輸出異常日誌), 但再次發生內存溢出時,沒有生成相關異常日誌。
在 32 位 JDK 上,1.6G 分配給堆,還有一部分分配給 JVM 的其餘內存,直接內存最大也只能在剩餘的 0.4G 空間中分出一部分, 若是使用了 NIO,JVM 會在 JVM 內存以外分配內存空間,那麼就要當心「直接內存」不足時發生內存溢出異常了。
直接內存雖然不是 JVM 內存空間,但它的垃圾回收也由 JVM 負責。
垃圾收集進行時,虛擬機雖然會對直接內存進行回收, 可是直接內存卻不能像新生代、老年代那樣,發現空間不足了就通知收集器進行垃圾回收, 它只能等老年代滿了後 Full GC,而後「順便」幫它清理掉內存的廢棄對象。 不然只能一直等到拋出內存溢出異常時,先 catch 掉,再在 catch 塊裏大喊 「System.gc()」。 要是虛擬機仍是不聽,那就只能眼睜睜看着堆中還有許多空閒內存,本身卻不得不拋出內存溢出異常了。
談論 JVM 的無關性,主要有如下兩個:
Java 源代碼首先須要使用 Javac 編譯器編譯成 .class 文件,而後由 JVM 執行 .class 文件,從而程序開始運行。
JVM 只認識 .class 文件,它不關心是何種語言生成了 .class 文件,只要 .class 文件符合 JVM 的規範就能運行。 目前已經有 JRuby、Jython、Scala 等語言可以在 JVM 上運行。它們有各自的語法規則,不過它們的編譯器 都能將各自的源碼編譯成符合 JVM 規範的 .class 文件,從而可以藉助 JVM 運行它們。
Java 語言中的各類變量、關鍵字和運算符號的語義最終都是由多條字節碼命令組合而成的, 所以字節碼命令所能提供的語義描述能力確定會比 Java 語言自己更增強大。 所以,有一些 Java 語言自己沒法有效支持的語言特性,不表明字節碼自己沒法有效支持。
Class 文件是二進制文件,它的內容具備嚴格的規範,文件中沒有任何空格,全都是連續的 0/1。Class 文件 中的全部內容被分爲兩種類型:無符號數、表。
Class 文件具體由如下幾個構成:
Class 文件的頭 4 個字節稱爲魔數,用來表示這個 Class 文件的類型。
Class 文件的魔數是用 16 進製表示的「CAFE BABE」,是否是很具備浪漫色彩?
魔數至關於文件後綴名,只不事後綴名容易被修改,不安全,所以在 Class 文件中標識文件類型比較合適。
緊接着魔數的 4 個字節是版本信息,5-6 字節表示次版本號,7-8 字節表示主版本號,它們表示當前 Class 文件中使用的是哪一個版本的 JDK。
高版本的 JDK 能向下兼容之前版本的 Class 文件,但不能運行之後版本的 Class 文件,即便文件格式並未發生任何變化,虛擬機也必需拒絕執行超過其版本號的 Class 文件。
版本信息以後就是常量池,常量池中存放兩種類型的常量:
字面值常量
字面值常量就是咱們在程序中定義的字符串、被 final 修飾的值。
符號引用 符號引用就是咱們定義的各類名字:類和接口的全限定名、字段的名字和描述符、方法的名字和描述符。
類型 | tag | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8編碼的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮點型字面量 |
CONSTANT_Long_info | 5 | 長整型字面量 |
CONSTANT_Double_info | 6 | 雙精度浮點型字面量 |
CONSTANT_Class_info | 7 | 類或接口的符號引用 |
CONSTANT_String_info | 8 | 字符串類型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符號引用 |
CONSTANT_Methodref_info | 10 | 類中方法的符號引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符號引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符號引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 標識方法類型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一個動態方法調用點 |
對於 CONSTANT_Class_info(此類型的常量表明一個類或者接口的符號引用),它的二維表結構以下:
類型 | 名稱 | 數量 |
---|---|---|
u1 | tag | 1 |
u2 | name_index | 1 |
tag 是標誌位,用於區分常量類型;name_index 是一個索引值,它指向常量池中一個 CONSTANT_Utf8_info 類型常量,此常量表明這個類(或接口)的全限定名,這裏 name_index 值若爲 0x0002,也便是指向了常量池中的第二項常量。
CONSTANT_Utf8_info 型常量的結構以下:
類型 | 名稱 | 數量 |
---|---|---|
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
tag 是當前常量的類型;length 表示這個字符串的長度;bytes 是這個字符串的內容(採用縮略的 UTF8 編碼)
在常量池結束以後,緊接着的兩個字節表明訪問標誌,這個標誌用於識別一些類或者接口層次的訪問信息,包括:這個 Class 是類仍是接口;是否認義爲 public 類型;是否被 abstract/final 修飾。
類索引和父類索引都是一個 u2 類型的數據,而接口索引集合是一組 u2 類型的數據的集合,Class 文件中由這三項數據來肯定類的繼承關係。類索引用於肯定這個類的全限定名,父類索引用於肯定這個類的父類的全限定名。
因爲 Java 不容許多重繼承,因此父類索引只有一個,除了 java.lang.Object 以外,全部的 Java 類都有父類,所以除了 java.lang.Object 外,全部 Java 類的父類索引都不爲 0。一個類可能實現了多個接口,所以用接口索引集合來描述。這個集合第一項爲 u2 類型的數據,表示索引表的容量,接下來就是接口的名字索引。
類索引和父類索引用兩個 u2 類型的索引值表示,它們各自指向一個類型爲 CONSTANT_Class_info 的類描述符常量,經過該常量總的索引值能夠找到定義在 CONSTANT_Utf8_info 類型的常量中的全限定名字符串。
字段表集合存儲本類涉及到的成員變量,包括實例變量和類變量,但不包括方法中的局部變量。
每個字段表只表示一個成員變量,本類中的全部成員變量構成了字段表集合。字段表結構以下:
類型 | 名稱 | 數量 | 說明 |
---|---|---|---|
u2 | access_flags | 1 | 字段的訪問標誌,與類稍有不一樣 |
u2 | name_index | 1 | 字段名字的索引 |
u2 | descriptor_index | 1 | 描述符,用於描述字段的數據類型。 基本數據類型用大寫字母表示; 對象類型用「L 對象類型的全限定名」表示。 |
u2 | attributes_count | 1 | 屬性表集合的長度 |
u2 | attributes | attributes_count | 屬性表集合,用於存放屬性的額外信息,如屬性的值。 |
字段表集合中不會出現從父類(或接口)中繼承而來的字段,但有可能出現本來 Java 代碼中不存在的字段,譬如在內部類中爲了保持對外部類的訪問性,會自動添加指向外部類實例的字段。
方法表結構與屬性表相似。
volatile 關鍵字 和 transient 關鍵字不能修飾方法,因此方法表的訪問標誌中沒有 ACC_VOLATILE 和 ACC_TRANSIENT 標誌。
方法表的屬性表集合中有一張 Code 屬性表,用於存儲當前方法經編譯器編譯後的字節碼指令。
每一個屬性對應一張屬性表,屬性表的結構以下:
類型 | 名稱 | 數量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
類從被加載到虛擬機內存開始,到卸載出內存爲止,它的整個生命週期包括如下 7 個階段:
驗證、準備、解析 3 個階段統稱爲鏈接。
加載、驗證、準備、初始化和卸載這 5 個階段的順序是肯定的,類的加載過程必須按照這種順序循序漸進地開始(注意是「開始」,而不是「進行」或「完成」),而解析階段則不必定:它在某些狀況下能夠在初始化後再開始,這是爲了支持 Java 語言的運行時綁定。
Java 虛擬機規範沒有強制約束類加載過程的第一階段(即:加載)何時開始,但對於「初始化」階段,有着嚴格的規定。有且僅有 5 種狀況必須當即對類進行「初始化」:
這 5 種場景中的行爲稱爲對一個類進行主動引用,除此以外,其它全部引用類的方式都不會觸發初始化,稱爲被動引用。
/** * 被動引用 Demo1: * 經過子類引用父類的靜態字段,不會致使子類初始化。 * * @author ylb * */
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
// SuperClass init!
}
}
複製代碼
對於靜態字段,只有直接定義這個字段的類纔會被初始化,所以經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。
/** * 被動引用 Demo2: * 經過數組定義來引用類,不會觸發此類的初始化。 * * @author ylb * */
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
}
}
複製代碼
這段代碼不會觸發父類的初始化,但會觸發「[L 全類名」這個類的初始化,它由虛擬機自動生成,直接繼承自 java.lang.Object,建立動做由字節碼指令 newarray 觸發。
/** * 被動引用 Demo3: * 常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化。 * * @author ylb * */
class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLO_BINGO = "Hello Bingo";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_BINGO);
}
}
複製代碼
編譯經過以後,常量存儲到 NotInitialization 類的常量池中,NotInitialization 的 Class 文件中並無 ConstClass 類的符號引用入口,這兩個類在編譯成 Class 以後就沒有任何聯繫了。
接口加載過程與類加載過程稍有不一樣。
當一個類在初始化時,要求其父類所有都已經初始化過了,可是一個接口在初始化時,並不要求其父接口所有都完成了初始化,當真正用到父接口的時候纔會初始化。
類加載過程包括 5 個階段:加載、驗證、準備、解析和初始化。
「加載」是「類加載」過程的一個階段,不能混淆這兩個名詞。在加載階段,虛擬機須要完成 3 件事:
對於 Class 文件,虛擬機沒有指明要從哪裏獲取、怎樣獲取。除了直接從編譯好的 .class 文件中讀取,還有如下幾種方式:
驗證階段確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。
準備階段是正式爲類變量(或稱「靜態成員變量」)分配內存並設置初始值的階段。這些變量(不包括實例變量)所使用的內存都在方法區中進行分配。
初始值「一般狀況下」是數據類型的零值(0, null...),假設一個類變量的定義爲:
public static int value = 123;
複製代碼
那麼變量 value 在準備階段事後的初始值爲 0 而不是 123,由於這時候還沒有開始執行任何 Java 方法。
存在「特殊狀況」:若是類字段的字段屬性表中存在 ConstantValue 屬性,那麼在準備階段 value 就會被初始化爲 ConstantValue 屬性所指定的值,假設上面類變量 value 的定義變爲:
public static final int value = 123;
複製代碼
那麼在準備階段虛擬機會根據 ConstantValue 的設置將 value 賦值爲 123。
解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。
類初始化階段是類加載過程的最後一步,是執行類構造器 <clinit>() 方法的過程。
<clinit>() 方法是由編譯器自動收集類中的全部類變量的賦值動做和靜態語句塊(static {} 塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。
靜態語句塊中只能訪問定義在靜態語句塊以前的變量,定義在它以後的變量,在前面的靜態語句塊中能夠賦值,但不能訪問。以下方代碼所示:
public class Test {
static {
i = 0; // 給變量賦值能夠正常編譯經過
System.out.println(i); // 這句編譯器會提示「非法向前引用」
}
static int i = 1;
}
複製代碼
<clinit>() 方法不須要顯式調用父類構造器,虛擬機會保證在子類的 <clinit>() 方法執行以前,父類的 <clinit>() 方法已經執行完畢。
因爲父類的 <clinit>() 方法先執行,意味着父類中定義的靜態語句塊要優先於子類的變量賦值操做。以下方代碼所示:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 輸出 2
}
複製代碼
<clinit>() 方法不是必需的,若是一個類沒有靜態語句塊,也沒有對類變量的賦值操做,那麼編譯器能夠不爲這個類生成 <clinit>() 方法。
接口中不能使用靜態代碼塊,但接口也須要經過 <clinit>() 方法爲接口中定義的靜態成員變量顯式初始化。但接口與類不一樣,接口的 <clinit>() 方法不須要先執行父類的 <clinit>() 方法,只有當父接口中定義的變量使用時,父接口才會初始化。
虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確加鎖、同步。若是多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的 <clinit>() 方法。
任意一個類,都由加載它的類加載器和這個類自己一同確立其在 Java 虛擬機中的惟一性,每個類加載器,都有一個獨立的類名稱空間。
所以,比較兩個類是否「相等」,只有在這兩個類是由同一個類加載器加載的前提下才有意義,不然,即便這兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不一樣,那麼這兩個類就一定不相等。
這裏的「相等」,包括表明類的 Class 對象的 equals() 方法、isInstance() 方法的返回結果,也包括使用 instanceof 關鍵字作對象所屬關係斷定等狀況。
系統提供了 3 種類加載器:
<JAVA_HOME>\lib
目錄中的,而且能被虛擬機識別的(僅按照文件名識別,如 rt.jar,名字不符合的類庫即便放在 lib 目錄中也不會被加載)類庫加載到虛擬機內存中。<JAVA_HOME>\lib\ext
目錄中的全部類庫,開發者能夠直接使用擴展類加載器。固然,若是有必要,還能夠加入本身定義的類加載器。
雙親委派模型是描述類加載器之間的層次關係。它要求除了頂層的啓動類加載器外,其他的類加載器都應當有本身的父類加載器。(父子關係通常不會以繼承的關係實現,而是以組合關係來複用父加載器的代碼)
若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋本身沒法完成這個加載請求(找不到所需的類)時,子加載器纔會嘗試本身去加載。
在 java.lang.ClassLoader 中的 loadClass() 方法中實現該過程。
像 java.lang.Object 這些存放在 rt.jar 中的類,不管使用哪一個類加載器加載,最終都會委派給最頂端的啓動類加載器加載,從而使得不一樣加載器加載的 Object 類都是同一個。
相反,若是沒有使用雙親委派模型,由各個類加載器自行去加載的話,若是用戶本身編寫了一個稱爲 java.lang.Object 的類,並放在 classpath 下,那麼系統將會出現多個不一樣的 Object 類,Java 類型體系中最基礎的行爲也就沒法保證。