寫在前面:
本來是想簡短點的,可是越寫越多
這裏主要介紹了 JVM,至於 JMM 還會再整理
面試題參考
文章目錄
JVM概述
什麼是 JVM?
Java Virtual Machine ,java 程序的運行環境(java 二進制字節碼的運行換環境)
- jvm 是運行在操作系統之上的,與硬件沒有任何關係
- 編譯之後的字節碼文件和平臺無關,需要在不同的操作系統上安裝一個對應版本的虛擬機(JVM)
功能
- 一次編寫,到處運行(可以在不同的操作系統上運行)
- 自動內存管理,垃圾回收功能
- 數組下標越界檢查,(防止覆蓋其它代碼內存)
- 多態
常見的 JVM
JVM 是一種規範,公司可以實現自己的 jvm,介紹三大商業虛擬機
Hotspot
:Sun 的,官網上下的基本都是這個,免費的。它是Sun JDK和OpenJDK中所帶的虛擬機,也是目前使用範圍最廣的 Java 虛擬機。J9t
:IBM的,商用的需要和IBM的其他軟件綁定,比如webSphere。JRockitt
:專注於服務端應用(JRockit內部不包含解析器實現,全部代碼都靠即時編譯器編譯後執行),是世界上最快的 jvm,08年被 oracle 收購
JVM 結構圖
運行時內存結構
內存結構主要分爲五部分:
- 程序計數器
- 虛擬機棧
- 本地方法棧
- 堆
- 方法區
線程共享的:堆、方法區
線程私有的:虛擬機棧、本地方法棧、程序計數器
程序計數器
Program Counter Register 程序計數器(寄存器)
- 作用:是記住下一條 jvm 指令的執行地址
- 特點:
- 是線程私有的(每個線程都有自己的程序計數器)
- 程序計數器是唯一一個不會出現 OutOfMemoryError 的內存區域,它的生命週期隨着線程的創建而創建,隨着線程的結束而死亡。
- 如果當前正在執行的方法是本地方法,那麼此刻程序計數器的值爲 undefined
虛擬機棧
Java Virtual Machine Stacks Java 虛擬機棧
- 每個線程運行時所需要的內存,稱爲虛擬機棧。
- 棧元素是棧幀。方法調用,棧幀入棧,反之出棧。
- 每個線程只能有一個活動棧幀(棧頂部),對應着當前正在執行的那個方法
存放:
局部變量表
(方法參數、方法內的局部變量)- 8大基本類型
- 對象引用(句柄引用、直接引用)
- returnAddress類型(返回地址,並跳出函數)
操作棧數
- 局部變量表中的變量是不可直接使用的
- 通過字節碼指令將其加載至操作數棧中作爲操作數使用
動態鏈接
(方法引用)- 每個棧幀都包含一個指向運行時常量池中該棧幀所屬性方法的引用
- 一部分會在類加載階段或第一次使用的時候轉化爲直接引用(編譯期)靜態鏈接
- 在運行期期間轉化爲直接引用爲動態鏈接
出口
(方法的返回地址)- 正常完成出口,執行引擎遇到任意一個方法返回的字節碼指令
- 異常完成出口,方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理
特點:
- 線程私有,它的生命週期與線程相同
- 不涉及垃圾回收(方法調用佔用棧內存,但每次方法調用結束,自動彈出棧)
- 線程安全問題(考慮是否共享)
- 如果方法內局部變量 沒有逃離方法的作用訪問,它是線程安全的
- 如果是局部變量引用了對象,並逃離方法的作用範圍,需要考慮線程安全
異常:
- 當線程請求的棧深度超過最大值,會拋出
StackOverflowError
異常; - 棧進行動態擴展時如果無法申請到足夠內存,會拋出
OutOfMemoryError
異常。
本地方法棧
Native Method Stack,本地方法棧。
特點:
-
本地方法棧的功能和特點類似於虛擬機棧,均具有線程隔離的特點以及都能拋出
StackOverflowError
和OutOfMemoryError
異常。 -
本地方法棧服務的對象是 JVM 執行的 native 方法,而虛擬機棧服務的是 JVM 執行的 java 方法。
-
Native 方法指的是那些用c、c++編寫的方法,因 java 代碼的侷限性,需要間接的調用 native 方法(java中標有native 關鍵字)來與操作系統的底層打交道。這些方法佔用的內存就是本地方法棧。
實現思路
- native 關鍵字,表示 java 作用範圍達不到,會去調用底層 c 的庫
- 會進入本地方法棧(登記 native 方法),最終通過調用 JNI ,來加載本地方法庫的方法
- JNI( java Native Interface),拓展 java 的使用,融合不同的編程語言爲 java 所用
堆
Heap 堆,所有對象、數組都在這裏分配內存,是垃圾收集的主要區域(“GC 堆”)。
細分:(永久區/元空間單獨劃分到 方法區了)
1(新生代):2 (老年代)分配
- 新生代(Young Generation)(假設爲10m)
- 伊甸園 Eden Space(8m)
- 倖存區S0 From(1m)
- 倖存區S1 To(1m)
- 老年代(Old Generation)(20m)
存放:
- 所有引用類型的真實對象(實例),包括 類、方法、常量、變量等
特點:
- 通過 new 關鍵字,創建對象都會使用堆內存。
- 一般對象都出生在伊甸區(大對象在老年代)
- 它是線程共享的,堆中對象都需要考慮線程安全的問題
- 有垃圾回收機制,且是垃圾回收的主要區域(99%)
- 一個 JVM 只有一個一個堆內存,其大小可以通過
-Xmx
和Xms
來控制 - 一個線程內oom不會導致所有的進程結束
方法區
Method Area ,方法區
方法區是一種規範,元空間與永久代(1.8前)都是其的實現。
所有定義的方法的信息都保存在該區域,此區域屬於共享區間
存放:存儲每個類的結構
靜態變量
staic常量
final類信息
(構造方法、接口定義)Class 模板- class文件信息包括:魔數,版本號,常量池,類,父類和接口數組,字段,方法等信息,其實類裏面又包括字段和方法的信息。
運行時的常量池
在 JDK 1.8 之後,原來永久代的數據被分到了堆和元空間中。
元空間存儲類的元信息;靜態變量和常量池等放入堆中。
特點:
-
方法區是被所有線程共享
-
在邏輯上是堆的一部分,也稱非堆(Non-Heap)。
-
提供對方法區域初始大小的控制,和堆一樣不需要連續的內存,並且可以動態擴展
-
對這塊區域進行垃圾回收的主要目標是對常量池的回收和對類的卸載,但是一般比較難實現。
-
GC 主要在 伊甸園與老年代
-
Dump:
-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
常量池
在Java的內存分配中,總共3種常量池:
(1)字符串常量池(String Constant Pool)
存放:
- 都是字符串常量
- 1.7 以後可以存放放於堆內的字符串對象的引用(
intern()
方法)
特點:
- jdk1.8,將String常量池放到了堆中。(原來存在於方法區)
- 爲 HashTable 結構,長度固定,不能擴容。
- 常量池中的字符串僅是符號,第一次用到時才變爲對象,加入串池(類似懶加載)
- 利用串池的機制,來避免重複創建字符串對象
- 會被垃圾回收
字符串拼接:
- 變量拼接的原理是 StringBuilder (1.8)(運行期才能確定)
- new StringBuilder,最後 toString 方法會根據拼接好的字符 new String(就在堆裏面啦)
- StringBuilder 經歷 init =>append=>toString
- 常量拼接的原理是編譯期優化
性能調優:
- 調整
-XX:StringTableSize=桶個數
,適當提高桶個數,更好的哈希分佈,減少哈希衝突 intern
方法,入池
(2)class常量池(Class Constant Pool)
- 存放編譯器生成的各種字面量(Literal)和符號引用(Symbolic References)
- 每個 class文件都有一個 class常量池
(3)運行時常量池(Runtime Constant Pool)
- 運行時常量池存在於內存中,也就是class常量池被加載到內存之後的版本
- 運行時常量池也是每個類都有一個
- 當類加載到內存中後,jvm就會將class常量池中的內容存放到運行時常量池中,
- 在解析階段,會把符號引用替換爲直接引用,解析的過程會去查詢字符串常量池,也就是我們上面所說的StringTable,以保證運行時常量池所引用的字符串與字符串常量池中是一致的。
直接內存
Direct Memory,直接內存,屬於操作系統內存
- 常見於 NIO 操作時,用於數據緩衝區
- 分配回收成本較高,但讀寫性能高
- 不受 JVM 內存回收管理
- java 與 系統 都能訪問
分配和回收原理
- 使用了 Unsafe 對象完成直接內存的分配回收,
- 使用
allocateMemory
、setMemory
方法分配內存 - 並且回收需要主動調用
freeMemory
方法
- 使用
- ByteBuffer 的實現類內部,使用了 Cleaner (虛引用 對象)來監測 ByteBuffer 對象,一旦 ByteBuffer 對象被垃圾回收,那麼就會由 ReferenceHandler 線程(守護線程)通過 Cleaner 的 clean 方法調用 freeMemory 來釋放直接內存
禁用顯示內回收對直接內存有影響
-xx:+DisableExplicitGC
禁用顯示回收System.gc()
會無效,該方法是 Full GC,很影響性能- 此時,可手動調用 unsafe 對象的 freeMemory 方法釋放內存
內存溢出與內存泄漏
介紹概念
-
OutOfMemoryError
:內存溢出,指程序運行過程中無法申請到足夠的內存而導致的一種錯誤。內存溢出通常發生於 Old 段或 Perm 段垃圾回收後,仍然無內存空間容納新的 Java 對象的情況。參考 -
memory leak
:內存泄露,指程序中動態分配內存給一些臨時對象,但是對象不會被GC所回收,它始終佔用內存。即被分配的對象可達但已無用。比如一個盤子用盡各種方法只能裝4個果子,你裝了5個,結果掉倒地上不能吃了。這就是溢出!
從定義上可以看出內存泄露是內存溢出的一種誘因,不是唯一因素。
內存溢出的幾種情況
(1)堆內存溢出(OutOfMemoryError:java heap space)
- 對於堆內存溢出,主要注意大量的字符串拼接操作和循環中重複創建對象的問題,
- 在一段代碼內申請上百M甚至上G的內存也是一個原因
- jvm參數:
-Xms5m -Xmx5m -Xmn2m -XX:NewSize=1m
(2)方法區內存溢出(OutOfMemoryError:permgem space)
- 如果程序加載的類過多,或者使用反射、gclib等這種動態代理生成類的技術,就可能導致該區發生內存溢出,
- jvm參數:
-XX:PermSize=2m -XX:MaxPermSize=2m
(3)棧內存溢出(java.lang.StackOveFflowError)
- 棧幀過多導致棧內存溢出(如方法的遞歸調用)
- 棧幀過大導致棧內存溢出(比較難出現吧)
內存泄露的幾種場景:
-
長生命週期的對象持有短生命週期對象的引用
- 這是內存泄露最常見的場景,也是代碼設計中經常出現的問題。
- 例如:在全局靜態map中緩存局部變量,且沒有清空操作,隨着時間的推移,這個map會越來越大,造成內存泄露。
-
修改 hashset 中對象的參數值,且參數是計算哈希值的字段
當一個對象被存儲進HashSet集合中以後,就不能修改這個對象中的那些參與計算哈希值的字段,否則對象修改後的哈希值與最初存儲進HashSet集合中時的哈希值就不同了,在這種情況下,即使在contains方法使用該對象的當前引用作爲參數去HashSet集合中檢索對象,也將返回找不到對象的結果,這也會導致無法從HashSet集合中刪除當前對象,造成內存泄露。
-
機器的連接數和關閉時間設置
- 長時間開啓非常耗費資源的連接,也會造成內存泄露。
爲了避免內存泄露,在編寫代碼的過程中可以參考下面的建議:
- 儘早釋放無用對象的引用
- 使用字符串處理,避免使用String,應大量使用StringBuffer,每一個String對象都得獨立佔用內存一塊區域
- 儘量少用靜態變量,因爲靜態變量存放在永久代(方法區),永久代基本不參與垃圾回收
- 避免在循環中創建對象
- 開啓大型文件或從數據庫一次拿了太多的數據很容易造成內存溢出,所以在這些地方要大概計算一下數據量的最大值是多少,並且設定所需最小及最大的內存空間值。
逃逸分析
逃逸分析的基本行爲就是分析對象動態作用域:當一個對象在方法中被定義後,它可能被外部方法所引用,例如作爲調用參數傳遞到其他地方中,稱爲方法逃逸
。
在之前的虛擬機棧 線程安全分析,也提到過
- 如果方法內局部變量 沒有逃離方法的作用訪問,它是線程安全的
- 如果是局部變量引用了對象,並逃離方法的作用範圍,需要考慮線程安全
第一段代碼中的sb
就逃逸了,而第二段代碼中的sb
就沒有逃逸。參考
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
一個類的分析
經過了上述知識的儲備,我們可以通過下圖,來回顧一下,一個類中方法與變量的內存情況
- 加載類信息到方法區(圖中的成員方法、類變量等)
- main 方法入棧(方法的參數、局部變量也存放在棧中),
- (圖中)實例化對象,在堆中 new Phone,初始化值(初始化後值在堆中),賦值,
- (有的話)調用下一個成員方法(堆中存放的是成員方法地址,此時調用它,還需再找到方法區的)
- 該方法入棧,類似上述操作,調用完出棧(所以不涉及垃圾回收)
- 最後 main 方法出棧,結束
垃圾回收
如何判斷對象可以回收?
- 引用計數法:爲對象添加一個引用計數器
- 可達性分析算法:以 GC Roots 爲起始點進行搜索,可達的對象都是存活的
- 四種引用(嚴格上五種)
強引用
:- 被強引用關聯的對象不會被回收。
- 使用 new 一個新對象的方式來創建強引用。
軟引用
(SoftReference)- 被軟引用關聯的對象只有在內存不夠的情況下纔會被回收。
- 可以配合引用隊列來釋放軟引用自身
- 使用 SoftReference 類來創建軟引用。
弱引用
(WeakReference)- 被弱引用關聯的對象一定會被回收,也就是說它只能存活到下一次垃圾回收發生之前。
- 可以配合引用隊列來釋放弱引用自身
- 使用 WeakReference 類來創建弱引用。
虛引用
(PhantomReference)- 又稱爲幽靈引用或者幻影引用,一個對象是否有虛引用的存在,不會對其生存時間造成影響,也無法通過虛引用得到一個對象。
- 必須配合引用隊列使用,主要配合 ByteBuffer 使用,被引用對象回收時,會將虛引用入隊,由 Reference Handler 線程調用虛引用相關方法釋放直接內存
- 使用 PhantomReference 來創建虛引用。
終結器引用
(FinalReference)- 無需手動編碼,但其內部配合引用隊列使用,在垃圾回收時,終結器引用入隊(被引用對象暫時沒有被回收),再由 Finalizer 線程通過終結器引用找到被引用對象並調用它的 finalize 方法,第二次 GC 時才能回收被引用對象
GC Roots 一般包含以下內容:
- 虛擬機棧中局部變量表中引用的對象
- 本地方法棧中 JNI 中引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中的常量引用的對象
方法區回收
因爲方法區主要存放永久代對象,而永久代對象的回收率比新生代低很多,所以在方法區上進行回收性價比不高。
- 主要是對常量池的回收和對類的卸載。
爲了避免內存溢出,在大量使用反射和動態代理的場景都需要虛擬機具備類卸載功能。
類的卸載條件很多,需要滿足以下三個條件,並且滿足了條件也不一定會被卸載:
- 該類所有的實例都已經被回收,此時堆中不存在該類的任何實例。
- 加載該類的 ClassLoader 已經被回收。
- 該類對應的 Class 對象沒有在任何地方被引用,也就無法在任何地方通過反射訪問該類方法。
垃圾回收算法
- 標記清除(Mark Sweep):速度較快,會產生內存碎片
- 標記整理(Mark Compact) :速度慢,沒有內存碎片,涉及對象的移動,效率低
- 複製(Copy):不會有內存碎片,需要佔用雙倍內存空間
比較
內存效率(時間複雜度): 複製 > 標記清除 >標記整理
內存整齊度:複製=標記整理 > 標記清除
內存利用率:標記整理=標記清除 > 複製
沒有最好的算法,但是有最合適的。堆內分代收集,應運而生:
- 新生代 (存活率低)複製
- 老年代 (存活率高)標記清除+標記整理 混合實現
垃圾收集器
以上是 HotSpot 虛擬機中的 7 個垃圾收集器,連線表示垃圾收集器可以配合使用。
除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式執行。
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:CMS、Serial Old、Parallel Old
整堆收集器: G1
幾個概念
並行收集:指多條垃圾收集線程並行工作,但此時用戶線程仍處於等待狀態。
併發收集:指用戶線程與垃圾收集線程同時工作(不一定是並行的可能會交替執行)。用戶程序在繼續運行,而垃圾收集程序運行在另一個CPU上。
吞吐量:即CPU用於運行用戶代碼的時間與CPU總消耗時間的比值(吞吐量 = 運行用戶代碼時間 / ( 運行用戶代碼時間 + 垃圾收集時間 ))。例如:虛擬機共運行100分鐘,垃圾收集器花掉1分鐘,那麼吞吐量就是99%
- Serial 收集器(串行)
- 單線程、簡單高效
- 新生代 複製算法,老年代 標記整理算法,兩個各階段都有 STW
- 適用於Client模式下的虛擬機。
- ParNew 收集器(Serial 收集器的多線程版本)
- 多線程、ParNew收集器默認開啓的收集線程數與CPU的數量相同
- 新生代 複製算法,老年代 標記整理算法,兩個各階段都有 STW
- 它是 Server 場景下默認的新生代收集器,除了性能原因外,主要是因爲除了 Serial 收集器,只有它能與 CMS 收集器配合使用。
- Parallel Scavenge 收集器(吞吐量優先收集器)
- 與ParNew收集器類似,並行的多線程收集器。
- 新生代 複製算法,老年代 標記整理算法,兩個各階段都有 STW
- GC 自適應調節策略
- 注重高吞吐量以及CPU資源敏感的場合
以上爲新生代收集器
- Serial Old 收集器(Serial收集器的老年代版本)
- 同樣是單線程收集器
- 老年代 標記整理算法,有 STW
- 主要也是使用在Client模式下的虛擬機中。也可在Server模式下使用。
- Parallel Old 收集器(Parallel Scavenge收集器的老年代版本)
- 多線程
- 老年代 標記整理算法,有 STW
- 注重高吞吐量以及CPU資源敏感的場合,都可以優先考慮
- CMS (Concurrent Mark Sweep)收集器(響應時間優先)
- 多線程,堆內存較大,多核 cpu
- 老年代 標記清除算法,內存回收過程是與所有用戶線程併發進行
- 分爲以下四個流程
- 初始標記(需要停頓)
- 併發標記
- 重新標記(需要停頓)(修正併發標記期間標記產生變動的那一部分對象)
- 併發清除
- 適用於注重服務的響應速度,希望系統停頓時間最短,給用戶帶來更好的體驗等場景下。如web程序、b/s服務。
- G1 (Garbage-First)收集器 官方文檔
- 取代了之前的 CMS 垃圾回收器(同樣有併發標記)、分代收集、空間整合
- G1 可以直接對新生代和老年代一起回收。
- 超大堆內存,會將堆劃分爲多個大小相等的 Region;整體上是標記+整理算法,兩個區域之間是複製算法
- 同時注重吞吐量(Throughput)和低延遲(Low latency),可預測的停頓(默認的暫停目標是 200 ms)
整理
- SerialGC
- 新生代內存不足發生的垃圾收集 - minor gc
- 老年代內存不足發生的垃圾收集 - full gc
- ParallelGC
- 新生代內存不足發生的垃圾收集 - minor gc
- 老年代內存不足發生的垃圾收集 - full gc
- CMS
- 新生代內存不足發生的垃圾收集 - minor gc
- 老年代內存不足,垃圾回收速度小於垃圾產生速度,併發收集失敗,退化爲 Serial GC,最後調用 FullGC
- G1
- 新生代內存不足發生的垃圾收集 - minor gc
- 老年代內存不足,垃圾回收速度小於垃圾產生速度,併發收集失敗,退化爲 Serial GC,最後調用 FullGC
晉升與卡表
晉升機制
Java 虛擬機會記錄 Survivor 區中的對象一共被來回複製了幾次。
-
如果一個對象被複制的次數爲 15(對應虛擬機參數 -XX:+MaxTenuringThreshold),那麼該對象將被晉升(promote)至老年代。
-
另外,如果單個 Survivor 區已經被佔用了 50%(對應虛擬機參數 -XX:TargetSurvivorRatio),那麼較高複製次數的對象也會被晉升至老年代。
卡表
由於新生代的垃圾收集通常很頻繁,如果老年代對象引用了新生代的對象,那麼,需要跟蹤從老年代到新生代的所有引用,從而避免每次YGC時掃描整個老年代,減少開銷。
- HotSpot 給出的解決方案是一項叫做卡表(Card Table)的技術。該技術將整個堆劃分爲一個個大小爲 512 字節的卡,並且維護一個卡表,用來存儲每張卡的一個標識位。這個標識位代表對應的卡是否可能存有指向新生代對象的引用。如果可能存在,那麼我們就認爲這張卡是髒的。
- 在進行 Minor GC 的時候,我們便可以不用掃描整個老年代,而是在卡表中尋找髒卡,並將髒卡中的對象加入到 Minor GC 的 GC Roots 裏。當完成所有髒卡的掃描之後,Java 虛擬機便會將所有髒卡的標識位清零。
內存分配與回收策略
- Minor GC:回收新生代,因爲新生代對象存活時間很短,因此 Minor GC 會頻繁執行,執行的速度一般也會比較快。
- Full GC:回收老年代和新生代,老年代對象其存活時間長,因此 Full GC 很少執行,執行速度會比 Minor GC 慢很多。
內存分配策略
- 對象優先分配在Eden區,如果Eden區沒有足夠的空間時,虛擬機執行一次Minor GC。
- 大對象直接進入老年代(大對象是指需要大量連續內存空間的對象)。這樣做的目的是避免在Eden區和兩個Survivor區之間發生大量的內存拷貝(新生代採用複製算法收集內存)。
- 長期存活的對象進入老年代。虛擬機爲每個對象定義了一個年齡計數器,如果對象經過了1次Minor GC那麼對象會進入Survivor區,之後每經過一次Minor GC那麼對象的年齡加1,知道達到閥值對象進入老年區。(
-XX:MaxTenuringThreshold
用來定義年齡的閾值) - 動態判斷對象的年齡。如果Survivor區中相同年齡的所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象可以直接進入老年代。
- 空間分配擔保。每次進行Minor GC時,JVM 會計算 Survivor區移至老年區的對象的平均大小,如果這個值大於老年區的剩餘值大小則進行一次Full GC,如果小於檢查HandlePromotionFailure設置,如果true則只進行Monitor GC,如果false則進行Full GC。
Full GC 的觸發條件
對於 Minor GC,其觸發條件非常簡單,當 Eden 空間滿時,就將觸發一次 Minor GC。而 Full GC 則相對複雜,有以下條件:
- 調用
System.gc()
;- 只是建議虛擬機執行 Full GC,但是虛擬機不一定真正去執行。
- 老年代空間不足
- 通過
-Xmn
虛擬機參數調大新生代的大小,讓對象儘量在新生代被回收掉,不進入老年代。 - 還可以通過
-XX:MaxTenuringThreshold
調大對象進入老年代的年齡,讓對象在新生代多存活一段時間。
- 通過
- 空間分配擔保失敗
- JDK 1.7 及以前的永久代空間不足
- Concurrent Mode Failure
- 執行 CMS GC 的過程中同時有對象要放入老年代,而此時老年代空間不足(可能是 GC 過程中浮動垃圾過多導致暫時性的空間不足),便會報 Concurrent Mode Failure 錯誤,並觸發 Full GC。
類加載
類是在運行期間第一次使用時動態加載的,而不是一次性加載所有類。因爲如果一次性加載,那麼會佔用很多的內存。
類加載過程:加載->連接->初始化。連接過程又可分爲三步:驗證->準備->解析。參考
加載
將類的字節碼載入方法區
中,內部採用 C++ 的 instanceKlass 描述 java 類。
加載是類加載的一個階段,注意不要混淆。
加載過程完成以下三件事:
- 通過類的完全限定名稱獲取定義該類的二進制字節流。
- 將該字節流表示的靜態存儲結構轉換爲方法區的運行時存儲結構。
- 在內存中生成一個代表該類的 Class 對象,作爲方法區中該類各種數據的訪問入口。
注意:
如果這個類還有父類沒有加載,先加載父類
加載和鏈接可能是交替運行的
連接
又可分爲三步:驗證->準備->解析
驗證:驗證類是否符合 JVM規範,安全性檢查
準備:爲 static 變量分配空間,設置默認值
- static 變量在 JDK 7 之前存儲於 instanceKlass 末尾,從 JDK 7 開始,存儲於 _java_mirror 末尾
- static 變量分配空間和賦值是兩個步驟,分配空間在準備階段完成,賦值在初始化階段完成
- 如果 static 變量是 final 的基本類型,以及字符串常量,那麼編譯階段值就確定了,賦值在準備階段完成
- 如果 static 變量是 final 的,但屬於引用類型,那麼賦值也會在初始化階段完成
解析:將常量池中的符號引用解析爲直接引用
初始化
初始化即調用 <cinit>()V
,虛擬機會保證這個類的『構造方法』的線程安全
發生的時機:概括得說,類初始化是【懶惰的】
- main 方法所在的類,總會被首先初始化
- 首次訪問這個類的靜態變量或靜態方法時
- 子類初始化,如果父類還沒初始化,會引發
- 子類訪問父類的靜態變量,只會觸發父類的初始化
Class.forName
- new 會導致初始化
不會導致類初始化的情況:
- 訪問類的 static final 靜態常量(基本類型和字符串)不會觸發初始化
- 類對象
.class
不會觸發初始化 - 創建該類的數組不會觸發初始化
- 類加載器的 loadClass 方法
Class.forName
的參數 2 爲 false 時
小結一下類加載過程
- JVM 會先去方法區中找有沒有相應類的
.class
存在。如果有,就直接使用;如果沒有,則把相關類的.clss
加載到方法區。 - 在
.class
加載到方法區時,先加載父類再加載子類;先加載靜態內容,再加載非靜態內容 - 加載靜態內容:
- 把
.class
中的所有靜態內容加載到方法區下的靜態區域內 - 靜態內容加載完成之後,對所有的靜態變量進行默認初始化
- 所有的靜態變量默認初始化完成之後,再進行顯式初始化
- 當靜態區域下的所有靜態變量顯式初始化完後,執行靜態代碼塊
- 把
- 加載非靜態內容:把
.class
中的所有非靜態變量及非靜態代碼塊加載到方法區下的非靜態區域內。 - 執行完之後,整個類的加載就完成了。
對於靜態方法和非靜態方法都是被動調用,即系統不會自動調用執行,所以用戶沒有調用時都不執行,主要區別在於靜態方法可以直接用類名直接調用(實例化對象也可以),而非靜態方法只能先實例化對象後才能調用。
這時,我們便可以得到一張,類的初始化過程圖
類加載器
JVM 中內置了三個重要的 ClassLoader,除了 BootstrapClassLoader
其他類加載器均由 Java 實現且全部繼承自java.lang.ClassLoader
:
- BootstrapClassLoader(啓動類加載器) :最頂層的加載類,由C++實現,負責加載
%JAVA_HOME%/lib
目錄下的 jar 包和類或者或被-Xbootclasspath
參數指定的路徑中的所有類。 - ExtensionClassLoader(擴展類加載器) :主要負責加載目錄
%JRE_HOME%/lib/ext
目錄下的 jar 包和類,或被java.ext.dirs
系統變量所指定的路徑下的 jar 包。 - AppClassLoader(應用程序類加載器) :面向我們用戶的加載器,負責加載當前應用classpath下的所有 jar 包和類。
雙親委派
每一個類都有一個對應它的類加載器。系統中的 ClassLoder 在協同工作的時候會默認使用 雙親委派模型 。
- 即在類加載的時候,系統會首先判斷當前類是否被加載過。
- 已經被加載的類會直接返回,否則纔會嘗試加載。
- 加載的時候,首先會把該請求委派該父類加載器的
loadClass()
處理,因此所有的請求最終都應該傳送到頂層的啓動類加載器BootstrapClassLoader
中。 - 當父類加載器無法處理時,才由自己來處理。
- 當父類加載器爲 null 時,會使用啓動類加載器
BootstrapClassLoader
作爲父類加載器。
好處:
- 雙親委派模型保證了 Java 程序的穩定運行,可以避免類的重複加載( JVM 區分不同類的方式不僅僅根據類名,相同的類文件被不同的類加載器加載產生的是兩個不同的類),也保證了 Java 的核心 API 不被篡改。
- 如果沒有使用雙親委派模型,而是每個類加載器加載自己的話就會出現一些問題,比如我們編寫一個稱爲
java.lang.Object
類的話,那麼程序運行的時候,系統就會出現多個不同的Object
類。
自定義類加載器
除了 BootstrapClassLoader
其他類加載器均由 Java 實現且全部繼承自java.lang.ClassLoader
。如果我們要自定義自己的類加載器,需要繼承 ClassLoader
。
loadClass()
實現了雙親委派模型的邏輯(取消雙親委派,就重寫他)- 自定義類加載器一般不去重寫它,但是需要重寫
findClass()
方法。
步驟:
- 繼承 ClassLoader 父類
- 要遵從雙親委派機制,重寫
findClass
方法 - 讀取類文件的字節碼
- 調用父類的
defineClass
方法來加載類 - 使用者調用該類加載器的
loadClass
方法
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
public class Load7 {
public static void main(String[] args) throws Exception {
MyClassLoader classLoader = new MyClassLoader();
Class<?> c1 = classLoader.loadClass("MapImpl1");
Class<?> c2 = classLoader.loadClass("MapImpl1");
System.out.println(c1 == c2);//true,加載爲同一個
MyClassLoader classLoader2 = new MyClassLoader();
Class<?> c3 = classLoader2.loadClass("MapImpl1");
System.out.println(c1 == c3);//false,類加載器不同,類對象不同
c1.newInstance();
}
}
class MyClassLoader extends ClassLoader {
@Override // name 就是類名稱
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = "e:\\myclasspath\\" + name + ".class";
try {
ByteArrayOutputStream os = new ByteArrayOutputStream();
Files.copy(Paths.get(path), os);
// 得到字節數組
byte[] bytes = os.toByteArray();
// byte[] -> *.class
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException("類文件未找到", e);
}
}
}
實例化對象
實例化對象的方式:
- 用 new 語句創建對象,這是最常見的創建對象的方法。會調用構造方法
- 通過工廠方法返回對象,如:
String str = String.valueOf(23);
- 運用反射手段,調用
java.lang.Class
或者java.lang.reflect.Constructor
類的newInstance()
實例方法問。會調用構造方法- 如:
Object obj = Class.forName("java.lang.Object").newInstance();
- 如:
- 調用對象的
clone()
方法。Object對象中存在clone方法,它的作用是創建一個對象的副本。 - 通過 I/O 流(包括反序列答化),如運用反序列化手段,調用
java.io.ObjectInputStream
對象的readObject()
方法。- 從文件中還原對象
- 使用 Unsafe 類創建對象
- 反射才能拿到 Unsafe 對象
- 拿到這個對象後,調用其中的native方法allocateInstance 創建一個對象實例
Object event = unsafe.allocateInstance(Test.class);
獲取 class 對象:
Class.forName("全類名")
:將字節碼文件加載進內存,返回Class對象- 多用於配置文件,將類名定義在配置文件中。讀取文件,加載類
類名.class
:通過類名的屬性class
獲取- 多用於參數的傳遞,最爲安全可靠,程序性能最高
對象.getClass()
:getClass()
方法在Object
類中定義着。- 多用於對象的獲取字節碼的方式
- 類加載器:獲取類加載器,再調用
loadClass
Class cl = this.getClass().getClassLoader().loadClass(“類的全類名”);
對象的形成過程
對象內存分配規則
回顧一下,內存分配原則
- 對象優先分配在Eden區,如果Eden區沒有足夠的空間時,虛擬機執行一次Minor GC。
- 大對象直接進入老年代(大對象是指需要大量連續內存空間的對象)。這樣做的目的是避免在Eden區和兩個Survivor區之間發生大量的內存拷貝(新生代採用複製算法收集內存)。
- 長期存活的對象進入老年代。虛擬機爲每個對象定義了一個年齡計數器,如果對象經過了1次Minor GC那麼對象會進入Survivor區,之後每經過一次Minor GC那麼對象的年齡加1,直到達到閥值對象進入老年區。
- 動態判斷對象的年齡。如果Survivor區中相同年齡的所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象可以直接進入老年代。(無需達到閾值)
- 空間分配擔保。每次進行Minor GC時,JVM會計算Survivor區移至老年區的對象的平均大小,如果這個值大於老年區的剩餘值大小則進行一次Full GC,如果小於檢查HandlePromotionFailure設置,如果true則只進行Monitor GC,如果false則進行Full GC。
對象的訪問
由於 reference 類型在 Java 虛擬機規範裏面只規定了一個指向對象的引用。並沒有定義這個引用通過哪種方式去定位,以及訪問到 Java 堆中的對象具體位置,因此不同虛擬機有不同的實現,主流有兩種:
- 使用句柄
- 直接訪問
(1)如果使用句柄,Java堆中會劃分出一塊內存稱爲句柄池,reference存放的就是對象的句柄地址,而句柄中包含了對象實例數據和類型數據各自的具體地址信息:
(2) 如果使用直接指針訪問方式,Java堆對象的佈局就必須考慮如何放置訪問類型數據的相關信息,reference存儲的就是對象的地址:
分析
這兩種對象訪問的方式各有優點:
- 使用句柄訪問方式最大好處就是 reference 存放的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。
- 使用直接指針訪問方式的最大好處是速度更快,它節省了一次時間定位的開銷,由於對象訪問在 Java 中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本。Sun HotSpot 是使用直接指針訪問方式。
對象的創建過程
Java 對象由三個部分組成:
- 對象頭
- 一部分存儲對象自身的運行時數據(哈希碼、GC分代年齡、鎖標識狀態、線程持有的鎖、偏向線程ID(一般佔32/64 bit))
- 第二部分是指針類型,指向對象的類元數據類型(即對象代表哪個類)如果是數組對象,則對象頭中還有一部分用來記錄數組長度。
- 實例數據:用來存儲對象真正的有效信息(包括父類繼承下來的和自己定義的)
- 對齊填充:JVM 要求對象起始地址必須是 8 字節的整數倍(8字節對齊)
好了,我們來總結一下對象創建過程
-
JVM 遇到一條新建對象的指令時(new 類)首先去檢查這個指令的參數是否能在常量池中定義到一個類的符號引用。然後加載這個類
-
爲對象分配內存。一種辦法“指針碰撞”、一種辦法“空閒列表”,最終常用的辦法“本地線程緩衝分配(TLAB)”
- 指針碰撞:依靠連續的內存空間,靠指針的移動來分配內存
- 空間列表:由固定的列表記錄內存分配的信息,每一線程指定一塊空間。
- 選擇以上兩種方式中的哪一種,取決於 Java 堆內存是否規整。以下介紹分配併發解決方式
- 爲每一個線程預先在 Eden 區分配一塊兒內存,JVM 在給線程中的對象分配內存時,首先在 TLAB 分配,
- 當對象大於 TLAB 中的剩餘內存或 TLAB 的內存已用盡時,再採用 CAS+失敗重試 進行內存分配(原子性)
-
將除對象頭外的對象內存空間初始化爲 0
-
對 對象頭進行必要設置。例如這個對象是那個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息。
-
調用對象的 init 方法