什麼是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的電腦上模擬模擬各種電腦功能來實現的。由一套位元組碼指令集、一組寄存器、一個棧、一個垃圾回收堆和一個存儲方法域等組成。JVM屏蔽了與操作系統平臺相關的信息,使得Java程式只需要生成在Java虛擬機 ...
什麼是JVM?
JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的電腦上模擬模擬各種電腦功能來實現的。由一套位元組碼指令集、一組寄存器、一個棧、一個垃圾回收堆和一個存儲方法域等組成。JVM屏蔽了與操作系統平臺相關的信息,使得Java程式只需要生成在Java虛擬機上運行的目標代碼(位元組碼),就可在多種平臺上不加修改的運行,這也是Java能夠“一次編譯,到處運行的”原因。
講一下JVM記憶體結構?
JVM記憶體結構分為5大區域,程式計數器、虛擬機棧、本地方法棧、堆、方法區。最全面的Java面試網站
程式計數器
線程私有的,作為當前線程的行號指示器,用於記錄當前虛擬機正在執行的線程指令地址。程式計數器主要有兩個作用:
- 當前線程所執行的位元組碼的行號指示器,通過它實現代碼的流程式控制制,如:順序執行、選擇、迴圈、異常處理。
- 在多線程的情況下,程式計數器用於記錄當前線程執行的位置,當線程被切換回來的時候能夠知道它上次執行的位置。
程式計數器是唯一一個不會出現 OutOfMemoryError
的記憶體區域,它的生命周期隨著線程的創建而創建,隨著線程的結束而死亡。
本文已經收錄到Github倉庫,該倉庫包含電腦基礎、Java基礎、多線程、JVM、資料庫、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分散式、微服務、設計模式、架構、校招社招分享等核心知識點,歡迎star~
如果訪問不了Github,可以訪問gitee地址。
虛擬機棧
Java 虛擬機棧是由一個個棧幀組成,而每個棧幀中都擁有:局部變數表、操作數棧、動態鏈接、方法出口信息。每一次函數調用都會有一個對應的棧幀被壓入虛擬機棧,每一個函數調用結束後,都會有一個棧幀被彈出。
局部變數表是用於存放方法參數和方法內的局部變數。
每個棧幀都包含一個指向運行時常量池中該棧所屬方法的符號引用,在方法調用過程中,會進行動態鏈接,將這個符號引用轉化為直接引用。
- 部分符號引用在類載入階段的時候就轉化為直接引用,這種轉化就是靜態鏈接
- 部分符號引用在運行期間轉化為直接引用,這種轉化就是動態鏈接
Java 虛擬機棧也是線程私有的,每個線程都有各自的 Java 虛擬機棧,而且隨著線程的創建而創建,隨著線程的死亡而死亡。Java 虛擬機棧會出現兩種錯誤:StackOverFlowError
和 OutOfMemoryError
。
可以通過-Xss
參數來指定每個線程的虛擬機棧記憶體大小:
java -Xss2M
本地方法棧
虛擬機棧為虛擬機執行 Java
方法服務,而本地方法棧則為虛擬機使用到的 Native
方法服務。Native
方法一般是用其它語言(C、C++等)編寫的。
本地方法被執行的時候,在本地方法棧也會創建一個棧幀,用於存放該本地方法的局部變數表、操作數棧、動態鏈接、出口信息。
堆
堆用於存放對象實例,是垃圾收集器管理的主要區域,因此也被稱作GC
堆。堆可以細分為:新生代(Eden
空間、From Survivor
、To Survivor
空間)和老年代。
通過 -Xms
設定程式啟動時占用記憶體大小,通過-Xmx
設定程式運行期間最大可占用的記憶體大小。如果程式運行需要占用更多的記憶體,超出了這個設置值,就會拋出OutOfMemory
異常。
java -Xms1M -Xmx2M
1.方法區
方法區與 Java 堆一樣,是各個線程共用的記憶體區域,它用於存儲已被虛擬機載入的類信息、常量、靜態變數、即時編譯器編譯後的代碼等數據。
對方法區進行垃圾回收的主要目標是對常量池的回收和對類的卸載。
2.永久代
方法區是 JVM 的規範,而永久代PermGen
是方法區的一種實現方式,並且只有 HotSpot
有永久代。對於其他類型的虛擬機,如JRockit
沒有永久代。由於方法區主要存儲類的相關信息,所以對於動態生成類的場景比較容易出現永久代的記憶體溢出。
3.元空間
JDK 1.8 的時候,HotSpot
的永久代被徹底移除了,使用元空間替代。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。兩者最大的區別在於:元空間並不在虛擬機中,而是使用直接記憶體。
為什麼要將永久代替換為元空間呢?
永久代記憶體受限於 JVM 可用記憶體,而元空間使用的是直接記憶體,受本機可用記憶體的限制,雖然元空間仍舊可能溢出,但是相比永久代記憶體溢出的概率更小。
運行時常量池
運行時常量池是方法區的一部分,在類載入之後,會將編譯器生成的各種字面量和符號引號放到運行時常量池。在運行期間動態生成的常量,如 String 類的 intern()方法,也會被放入運行時常量池。
直接記憶體
直接記憶體並不是虛擬機運行時數據區的一部分,也不是虛擬機規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導致 OutOfMemoryError
錯誤出現。
NIO的Buffer提供了DirectBuffer
,可以直接訪問系統物理記憶體,避免堆內記憶體到堆外記憶體的數據拷貝操作,提高效率。DirectBuffer
直接分配在物理記憶體中,並不占用堆空間,其可申請的最大記憶體受操作系統限制,不受最大堆記憶體的限制。
直接記憶體的讀寫操作比堆記憶體快,可以提升程式I/O操作的性能。通常在I/O通信過程中,會存在堆內記憶體到堆外記憶體的數據拷貝操作,對於需要頻繁進行記憶體間數據拷貝且生命周期較短的暫存數據,都建議存儲到直接記憶體。
好東西應該要分享出來!我把自己學習電腦多年以來的書籍分享出來了,彙總到一個電腦經典編程書籍倉庫了,一共300多本,包括C語言、C++、Java、Python、前端、資料庫、操作系統、電腦網路、數據結構和演算法、機器學習、編程人生等,可以star一下,下次找書直接在上面搜索,倉庫持續更新中~
Java對象的定位方式
Java 程式通過棧上的 reference 數據來操作堆上的具體對象。對象的訪問方式由虛擬機實現而定,目前主流的訪問方式有使用句柄和直接指針兩種:
- 如果使用句柄的話,那麼 Java 堆中將會劃分出一塊記憶體來作為句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。使用句柄來訪問的最大好處是 reference 中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。
- 直接指針。reference 中存儲的直接就是對象的地址。對象包含到對象類型數據的指針,通過這個指針可以訪問對象類型數據。使用直接指針訪問方式最大的好處就是訪問對象速度快,它節省了一次指針定位的時間開銷,虛擬機hotspot主要是使用直接指針來訪問對象。
說一下堆棧的區別?
-
堆的物理地址分配是不連續的,性能較慢;棧的物理地址分配是連續的,性能相對較快。
-
堆存放的是對象的實例和數組;棧存放的是局部變數,操作數棧,返回結果等。
-
堆是線程共用的;棧是線程私有的。
什麼情況下會發生棧溢出?
- 當線程請求的棧深度超過了虛擬機允許的最大深度時,會拋出
StackOverFlowError
異常。這種情況通常是因為方法遞歸沒終止條件。 - 新建線程的時候沒有足夠的記憶體去創建對應的虛擬機棧,虛擬機會拋出
OutOfMemoryError
異常。比如線程啟動過多就會出現這種情況。
類文件結構
Class 文件結構如下:
ClassFile {
u4 magic; //類文件的標誌
u2 minor_version;//小版本號
u2 major_version;//大版本號
u2 constant_pool_count;//常量池的數量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//類的訪問標記
u2 this_class;//當前類的索引
u2 super_class;//父類
u2 interfaces_count;//介面
u2 interfaces[interfaces_count];//一個類可以實現多個介面
u2 fields_count;//欄位屬性
field_info fields[fields_count];//一個類會可以有個欄位
u2 methods_count;//方法數量
method_info methods[methods_count];//一個類可以有個多個方法
u2 attributes_count;//此類的屬性表中的屬性數
attribute_info attributes[attributes_count];//屬性表集合
}
主要參數如下:
魔數:class
文件標誌。
文件版本:高版本的 Java 虛擬機可以執行低版本編譯器生成的類文件,但是低版本的 Java 虛擬機不能執行高版本編譯器生成的類文件。
常量池:存放字面量和符號引用。字面量類似於 Java 的常量,如字元串,聲明為final
的常量值等。符號引用包含三類:類和介面的全限定名,方法的名稱和描述符,欄位的名稱和描述符。
訪問標誌:識別類或者介面的訪問信息,比如這個Class
是類還是介面,是否為 public
或者 abstract
類型等等。
當前類的索引:類索引用於確定這個類的全限定名。
什麼是類載入?類載入的過程?
類的載入指的是將類的class
文件中的二進位數據讀入到記憶體中,將其放在運行時數據區的方法區內,然後在堆區創建一個此類的對象,通過這個對象可以訪問到方法區對應的類信息。
載入
- 通過類的全限定名獲取定義此類的二進位位元組流
- 將位元組流所代表的靜態存儲結構轉換為方法區的運行時數據結構
- 在記憶體中生成一個代表該類的
Class
對象,作為方法區類信息的訪問入口
驗證
確保Class文件的位元組流中包含的信息符合虛擬機規範,保證在運行後不會危害虛擬機自身的安全。主要包括四種驗證:文件格式驗證,元數據驗證,位元組碼驗證,符號引用驗證。
準備
為類變數分配記憶體並設置類變數初始值的階段。
解析
虛擬機將常量池內的符號引用替換為直接引用的過程。符號引用用於描述目標,直接引用直接指向目標的地址。
初始化
開始執行類中定義的Java
代碼,初始化階段是調用類構造器的過程。
什麼是雙親委派模型?
一個類載入器收到一個類的載入請求時,它首先不會自己嘗試去載入它,而是把這個請求委派給父類載入器去完成,這樣層層委派,因此所有的載入請求最終都會傳送到頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入。
雙親委派模型的具體實現代碼在 java.lang.ClassLoader
中,此類的 loadClass()
方法運行過程如下:先檢查類是否已經載入過,如果沒有則讓父類載入器去載入。當父類載入器載入失敗時拋出 ClassNotFoundException
,此時嘗試自己去載入。源碼如下:
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}
為什麼需要雙親委派模型?
雙親委派模型的好處:可以防止記憶體中出現多份同樣的位元組碼。如果沒有雙親委派模型而是由各個類載入器自行載入的話,如果用戶編寫了一個java.lang.Object
的同名類並放在ClassPath
中,多個類載入器都去載入這個類到記憶體中,系統中將會出現多個不同的Object
類,那麼類之間的比較結果及類的唯一性將無法保證。
什麼是類載入器,類載入器有哪些?
-
實現通過類的全限定名獲取該類的二進位位元組流的代碼塊叫做類載入器。
主要有一下四種類載入器:
- 啟動類載入器:用來載入 Java 核心類庫,無法被 Java 程式直接引用。
- 擴展類載入器:它用來載入 Java 的擴展庫。Java 虛擬機的實現會提供一個擴展庫目錄。該類載入器在此目錄裡面查找並載入 Java 類。
- 系統類載入器:它根據應用的類路徑來載入 Java 類。可通過
ClassLoader.getSystemClassLoader()
獲取它。 - 自定義類載入器:通過繼承
java.lang.ClassLoader
類的方式實現。
類的實例化順序?
- 父類中的
static
代碼塊,當前類的static
代碼塊 - 父類的普通代碼塊
- 父類的構造函數
- 當前類普通代碼塊
- 當前類的構造函數
如何判斷一個對象是否存活?
對堆垃圾回收前的第一步就是要判斷那些對象已經死亡(即不再被任何途徑引用的對象)。判斷對象是否存活有兩種方法:引用計數法和可達性分析。
引用計數法
給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加 1;當引用失效,計數器就減 1;任何時候計數器為 0 的對象就是不可能再被使用的。
這種方法很難解決對象之間相互迴圈引用的問題。比如下麵的代碼,obj1
和 obj2
互相引用,這種情況下,引用計數器的值都是1,不會被垃圾回收。
public class ReferenceCount {
Object instance = null;
public static void main(String[] args) {
ReferenceCount obj1 = new ReferenceCount();
ReferenceCount obj2 = new ReferenceCount();
obj1.instance = obj2;
obj2.instance = obj1;
obj1 = null;
obj2 = null;
}
}
可達性分析
通過GC Root
對象為起點,從這些節點向下搜索,搜索所走過的路徑叫引用鏈,當一個對象到GC Root
沒有任何的引用鏈相連時,說明這個對象是不可用的。
可作為GC Roots的對象有哪些?
- 虛擬機棧中引用的對象
- 本地方法棧中Native方法引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
什麼情況下類會被卸載?
需要同時滿足以下 3 個條件類才可能會被卸載 :
- 該類所有的實例都已經被回收。
- 載入該類的類載入器已經被回收。
- 該類對應的
java.lang.Class
對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
虛擬機可以對滿足上述 3 個條件的類進行回收,但不一定會進行回收。
強引用、軟引用、弱引用、虛引用是什麼,有什麼區別?
強引用:在程式中普遍存在的引用賦值,類似Object obj = new Object()
這種引用關係。只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象。
軟引用:如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些對象的記憶體。
//軟引用
SoftReference<String> softRef = new SoftReference<String>(str);
弱引用:在進行垃圾回收時,不管當前記憶體空間足夠與否,都會回收只具有弱引用的對象。
//弱引用
WeakReference<String> weakRef = new WeakReference<String>(str);
虛引用:虛引用並不會決定對象的生命周期。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。虛引用主要是為了能在對象被收集器回收時收到一個系統通知。
GC是什麼?為什麼要GC?
GC(Garbage Collection
),垃圾回收,是Java與C++的主要區別之一。作為Java開發者,一般不需要專門編寫記憶體回收和垃圾清理代碼。這是因為在Java虛擬機中,存在自動記憶體管理和垃圾清理機制。對JVM中的記憶體進行標記,並確定哪些記憶體需要回收,根據一定的回收策略,自動的回收記憶體,保證JVM中的記憶體空間,防止出現記憶體泄露和溢出問題。
Minor GC 和 Full GC的區別?
-
Minor GC:回收新生代,因為新生代對象存活時間很短,因此
Minor GC
會頻繁執行,執行的速度一般也會比較快。 -
Full GC:回收老年代和新生代,老年代的對象存活時間長,因此
Full GC
很少執行,執行速度會比Minor GC
慢很多。
記憶體的分配策略?
對象優先在 Eden 分配
大多數情況下,對象在新生代 Eden
上分配,當 Eden
空間不夠時,觸發 Minor GC
。
大對象直接進入老年代
大對象是指需要連續記憶體空間的對象,最典型的大對象有長字元串和大數組。可以設置JVM參數 -XX:PretenureSizeThreshold
,大於此值的對象直接在老年代分配。
長期存活的對象進入老年代
通過參數 -XX:MaxTenuringThreshold
可以設置對象進入老年代的年齡閾值。對象在Survivor
區每經過一次 Minor GC
,年齡就增加 1 歲,當它的年齡增加到一定程度,就會被晉升到老年代中。
動態對象年齡判定
並非對象的年齡必須達到 MaxTenuringThreshold
才能晉升老年代,如果在 Survivor
中相同年齡所有對象大小的總和大於 Survivor
空間的一半,則年齡大於或等於該年齡的對象可以直接進入老年代,無需達到 MaxTenuringThreshold
年齡閾值。
空間分配擔保
在發生 Minor GC
之前,虛擬機先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果條件成立的話,那麼 Minor GC
是安全的。如果不成立的話虛擬機會查看 HandlePromotionFailure
的值是否允許擔保失敗。如果允許,那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試著進行一次 Minor GC
;如果小於,或者 HandlePromotionFailure
的值為不允許擔保失敗,那麼就要進行一次 Full GC
。
Full GC 的觸發條件?
對於 Minor GC,其觸發條件比較簡單,當 Eden 空間滿時,就將觸發一次 Minor GC。而 Full GC 觸發條件相對複雜,有以下情況會發生 full GC:
調用 System.gc()
只是建議虛擬機執行 Full GC,但是虛擬機不一定真正去執行。不建議使用這種方式,而是讓虛擬機管理記憶體。
老年代空間不足
老年代空間不足的常見場景為前文所講的大對象直接進入老年代、長期存活的對象進入老年代等。為了避免以上原因引起的 Full GC,應當儘量不要創建過大的對象以及數組、註意編碼規範避免記憶體泄露。除此之外,可以通過 -Xmn
參數調大新生代的大小,讓對象儘量在新生代被回收掉,不進入老年代。還可以通過 -XX:MaxTenuringThreshold
調大對象進入老年代的年齡,讓對象在新生代多存活一段時間。
空間分配擔保失敗
使用複製演算法的 Minor GC 需要老年代的記憶體空間作擔保,如果擔保失敗會執行一次 Full GC。
JDK 1.7 及以前的永久代空間不足
在 JDK 1.7 及以前,HotSpot 虛擬機中的方法區是用永久代實現的,永久代中存放的為一些 Class 的信息、常量、靜態變數等數據。當系統中要載入的類、反射的類和調用的方法較多時,永久代可能會被占滿,在未配置為採用 CMS GC 的情況下也會執行 Full GC。如果經過 Full GC 仍然回收不了,那麼虛擬機會拋出 java.lang.OutOfMemoryError
。
垃圾回收演算法有哪些?
垃圾回收演算法有四種,分別是標記清除法、標記整理法、複製演算法、分代收集演算法。
標記清除演算法
首先利用可達性去遍歷記憶體,把存活對象和垃圾對象進行標記。標記結束後統一將所有標記的對象回收掉。這種垃圾回收演算法效率較低,並且會產生大量不連續的空間碎片。
複製清除演算法
半區複製,用於新生代垃圾回收。將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的對象複製到另一塊去,然後再把使用的空間一次清理掉。
特點:實現簡單,運行高效,但可用記憶體縮小為了原來的一半,浪費空間。
標記整理演算法
根據老年代的特點提出的一種標記演算法,標記過程仍然與標記-清除
演算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉邊界以外的記憶體。
分類收集演算法
根據各個年代的特點採用最適當的收集演算法。
一般將堆分為新生代和老年代。
- 新生代使用複製演算法
- 老年代使用標記清除演算法或者標記整理演算法
在新生代中,每次垃圾收集時都有大批對象死去,只有少量存活,使用複製演算法比較合適,只需要付出少量存活對象的複製成本就可以完成收集。老年代對象存活率高,適合使用標記-清理或者標記-整理演算法進行垃圾回收。
有哪些垃圾回收器?
垃圾回收器主要分為以下幾種:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1
。
這7種垃圾收集器的特點:
收集器 | 串列、並行or併發 | 新生代/老年代 | 演算法 | 目標 | 適用場景 |
---|---|---|---|---|---|
Serial | 串列 | 新生代 | 複製演算法 | 響應速度優先 | 單CPU環境下的Client模式 |
ParNew | 並行 | 新生代 | 複製演算法 | 響應速度優先 | 多CPU環境時在Server模式下與CMS配合 |
Parallel Scavenge | 並行 | 新生代 | 複製演算法 | 吞吐量優先 | 在後臺運算而不需要太多交互的任務 |
Serial Old | 串列 | 老年代 | 標記-整理 | 響應速度優先 | 單CPU環境下的Client模式、CMS的後備預案 |
Parallel Old | 並行 | 老年代 | 標記-整理 | 吞吐量優先 | 在後臺運算而不需要太多交互的任務 |
CMS | 併發 | 老年代 | 標記-清除 | 響應速度優先 | 集中在互聯網站或B/S系統服務端上的Java應用 |
G1 | 併發 | both | 標記-整理+複製演算法 | 響應速度優先 | 面向服務端應用,將來替換CMS |
Serial 收集器
單線程收集器,使用一個垃圾收集線程去進行垃圾回收,在進行垃圾回收的時候必須暫停其他所有的工作線程( Stop The World
),直到它收集結束。
特點:簡單高效;記憶體消耗小;沒有線程交互的開銷,單線程收集效率高;需暫停所有的工作線程,用戶體驗不好。
ParNew 收集器
Serial
收集器的多線程版本,除了使用多線程進行垃圾收集外,其他行為、參數與 Serial
收集器基本一致。
Parallel Scavenge 收集器
新生代收集器,基於複製清除演算法實現的收集器。特點是吞吐量優先,能夠並行收集的多線程收集器,允許多個垃圾回收線程同時運行,降低垃圾收集時間,提高吞吐量。所謂吞吐量就是 CPU 中用於運行用戶代碼的時間與 CPU 總消耗時間的比值(吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 + 垃圾收集時間)
)。Parallel Scavenge
收集器關註點是吞吐量,高效率的利用 CPU 資源。CMS
垃圾收集器關註點更多的是用戶線程的停頓時間。
Parallel Scavenge
收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis
參數以及直接設置吞吐量大小的-XX:GCTimeRatio
參數。
-
-XX:MaxGCPauseMillis
參數的值是一個大於0的毫秒數,收集器將儘量保證記憶體回收花費的時間不超過用戶設定值。 -
-XX:GCTimeRatio
參數的值大於0小於100,即垃圾收集時間占總時間的比率,相當於吞吐量的倒數。
Serial Old 收集器
Serial
收集器的老年代版本,單線程收集器,使用標記整理演算法。
Parallel Old 收集器
Parallel Scavenge
收集器的老年代版本。多線程垃圾收集,使用標記整理演算法。
CMS 收集器
Concurrent Mark Sweep
,併發標記清除,追求獲取最短停頓時間,實現了讓垃圾收集線程與用戶線程基本上同時工作。
CMS
垃圾回收基於標記清除演算法實現,整個過程分為四個步驟:
- 初始標記: 暫停所有用戶線程(
Stop The World
),記錄直接與GC Roots
直接相連的對象 。 - 併發標記:從
GC Roots
開始對堆中對象進行可達性分析,找出存活對象,耗時較長,但是不需要停頓用戶線程。 - 重新標記: 在併發標記期間對象的引用關係可能會變化,需要重新進行標記。此階段也會暫停所有用戶線程。
- 併發清除:清除標記對象,這個階段也是可以與用戶線程同時併發的。
在整個過程中,耗時最長的是併發標記和併發清除階段,這兩個階段垃圾收集線程都可以與用戶線程一起工作,所以從總體上來說,CMS
收集器的記憶體回收過程是與用戶線程一起併發執行的。
優點:併發收集,停頓時間短。
缺點:
- 標記清除演算法導致收集結束有大量空間碎片。
- 產生浮動垃圾,在併發清理階段用戶線程還在運行,會不斷有新的垃圾產生,這一部分垃圾出現在標記過程之後,
CMS
無法在當次收集中回收它們,只好等到下一次垃圾回收再處理;
G1收集器
G1垃圾收集器的目標是在不同應用場景中追求高吞吐量和低停頓之間的最佳平衡。
G1將整個堆分成相同大小的分區(Region
),有四種不同類型的分區:Eden、Survivor、Old和Humongous
。分區的大小取值範圍為 1M 到 32M,都是2的冪次方。分區大小可以通過-XX:G1HeapRegionSize
參數指定。Humongous
區域用於存儲大對象。G1規定只要大小超過了一個分區容量一半的對象就認為是大對象。
G1 收集器對各個分區回收所獲得的空間大小和回收所需時間的經驗值進行排序,得到一個優先順序列表,每次根據用戶設置的最大回收停頓時間,優先回收價值最大的分區。
特點:可以由用戶指定期望的垃圾收集停頓時間。
G1 收集器的回收過程分為以下幾個步驟:
- 初始標記。暫停所有其他線程,記錄直接與
GC Roots
直接相連的對象,耗時較短 。 - 併發標記。從
GC Roots
開始對堆中對象進行可達性分析,找出要回收的對象,耗時較長,不過可以和用戶程式併發執行。 - 最終標記。需對其他線程做短暫的暫停,用於處理併發標記階段對象引用出現變動的區域。
- 篩選回收。對各個分區的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,然後把決定回收的分區的存活對象複製到空的分區中,再清理掉整個舊的分區的全部空間。這裡的操作涉及存活對象的移動,會暫停用戶線程,由多條收集器線程並行完成。
常用的 JVM 調優的命令都有哪些?
jps:列出本機所有 Java 進程的進程號。
常用參數如下:
-m
輸出main
方法的參數-l
輸出完全的包名和應用主類名-v
輸出JVM
參數
jps -lvm
//output
//4124 com.zzx.Application -javaagent:E:\IDEA2019\lib\idea_rt.jar=10291:E:\IDEA2019\bin -Dfile.encoding=UTF-8
jstack:查看某個 Java 進程內的線程堆棧信息。使用參數-l
可以列印額外的鎖信息,發生死鎖時可以使用jstack -l pid
觀察鎖持有情況。
jstack -l 4124 | more
輸出結果如下:
"http-nio-8001-exec-10" #40 daemon prio=5 os_prio=0 tid=0x000000002542f000 nid=0x4028 waiting on condition [0x000000002cc9e000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x000000077420d7e8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:103)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:31)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:748)
Locked ownable synchronizers:
- None
WAITING (parking)
指線程處於掛起中,在等待某個條件發生,來把自己喚醒。
jstat:用於查看虛擬機各種運行狀態信息(類裝載、記憶體、垃圾收集等運行數據)。使用參數-gcuitl
可以查看垃圾回收的統計信息。
jstat -gcutil 4124
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 67.21 19.20 96.36 94.96 10 0.084 3 0.191 0.275
參數說明:
- S0:
Survivor0
區當前使用比例 - S1:
Survivor1
區當前使用比例 - E:
Eden
區使用比例 - O:老年代使用比例
- M:元數據區使用比例
- CCS:壓縮使用比例
- YGC:年輕代垃圾回收次數
- FGC:老年代垃圾回收次數
- FGCT:老年代垃圾回收消耗時間
- GCT:垃圾回收消耗總時間
jmap:查看堆記憶體快照。通過jmap
命令可以獲得運行中的堆記憶體的快照,從而可以對堆記憶體進行離線分析。
查詢進程4124的堆記憶體快照,輸出結果如下:
>jmap -heap 4124
Attaching to process ID 4124, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.221-b11
using thread-local object allocation.
Parallel GC with 6 thread(s)
Heap Configuration:
MinHeapFreeRatio = 0
MaxHeapFreeRatio = 100
MaxHeapSize = 4238344192 (4042.0MB)
NewSize = 88604672 (84.5MB)
MaxNewSize = 1412431872 (1347.0MB)
OldSize = 177733632 (169.5MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)
Heap Usage:
PS Young Generation
Eden Space:
capacity = 327155712 (312.0MB)
used = 223702392 (213.33922576904297MB)
free = 103453320 (98.66077423095703MB)
68.37795697725736% used
From Space:
capacity = 21495808 (20.5MB)
used = 0 (0.0MB)
free = 21495808 (20.5MB)
0.0% used
To Space:
capacity = 23068672 (22.0MB)
used = 0 (0.0MB)
free = 23068672 (22.0MB)
0.0% used
PS Old Generation
capacity = 217579520 (207.5MB)
used = 41781472 (39.845916748046875MB)
free = 175798048 (167.65408325195312MB)
19.20285144484187% used
27776 interned Strings occupying 3262336 bytes.
jinfo:jinfo -flags 1
。查看當前的應用JVM參數配置。
Attaching to process ID 1, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.111-b14
Non-default VM flags: -XX:CICompilerCount=2 -XX:InitialHeapSize=31457280 -XX:MaxHeapSize=480247808 -XX:MaxNewSize=160038912 -XX:MinHeapDeltaBytes=196608 -XX:NewSize=10485760 -XX:OldSize=20971520 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops
Command line:
查看所有參數:java -XX:+PrintFlagsFinal -version
。用於查看最終值,初始值可能被修改掉(查看初始值可以使用java -XX:+PrintFlagsInitial)。
[Global flags]
uintx AdaptiveSizeDecrementScaleFactor = 4 {product}
uintx AdaptiveSizeMajorGCDecayTimeScale = 10 {product}
uintx AdaptiveSizePausePolicy = 0 {product}
uintx AdaptiveSizePolicyCollectionCostMargin = 50 {product}
uintx AdaptiveSizePolicyInitializingSteps = 20 {product}
uintx AdaptiveSizePolicyOutputInterval = 0 {product}
uintx AdaptiveSizePolicyWeight = 10 {product}
uintx AdaptiveSizeThroughPutPolicy = 0 {product}
uintx AdaptiveTimeWeight = 25 {product}
bool AdjustConcurrency = false {product}
bool AggressiveOpts = false {product}
....
對象頭瞭解嗎?
Java 記憶體中的對象由以下三部分組成:對象頭、實例數據和對齊填充位元組。
而對象頭由以下三部分組成:mark word、指向類信息的指針和數組長度(數組才有)。
mark word
包含:對象的哈希碼、分代年齡和鎖標誌位。
對象的實例數據就是 Java 對象的屬性和值。
對齊填充位元組:因為JVM要求對象占的記憶體大小是 8bit 的倍數,因此後面有幾個位元組用於把對象的大小補齊至 8bit 的倍數。
記憶體對齊的主要作用是:
- 平臺原因:不是所有的硬體平臺都能訪問任意地址上的任意數據的;某些硬體平臺只能在某些地址處取某些特定類型的數據,否則拋出硬體異常。
- 性能原因:經過記憶體對齊後,CPU的記憶體訪問速度大大提升。
Object o = new Object()占用多少個位元組?
答案是16個位元組。
首先先分析對象的記憶體佈局。
在 JVM 中,Java對象保存在堆中時,由以下三部分組成:
對象頭(Object Header):包括關於堆對象的佈局、類型、GC狀態、同步狀態和標識哈希碼的基本信息。由兩個詞mark word
和classpointer
組成,如果是數組對象的話,還會有一個length field
。
- mark word:通常是一組位域,用於存儲對象自身的運行時數據,如hashCode、GC分代年齡、鎖同步信息等等。占用64個比特(64位系統),8個位元組。
- classpointer:類指針,是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。占用64個比特(64位系統),8個位元組。開啟壓縮類指針後,占用32個比特,4個位元組。
實例數據(Instance Data):存儲了代碼中定義的各種欄位的內容,包括從父類繼承下來的欄位和子類中定義的欄位。如果對象無屬性欄位,則這裡就不會有數據。根據欄位類型的不同占不同的位元組,例如boolean類型占1個位元組,int類型占4個位元組等等。為了提高存儲空間的利用率,這部分數據的存儲順序會受到虛擬機分配策略參數和欄位在Java源碼中定義順序的影響。
對齊填充(Padding):對象可以有對齊數據也可以沒有。預設情況下,Java虛擬機堆中對象的起始地址需要對齊至8的整數倍。如果一個對象的對象頭和實例數據占用的總大小不到8位元組的整數倍,則以此來填充對象大小至8位元組的整數倍。
為什麼要對齊填充?欄位記憶體對齊的其中一個原因,是讓欄位只出現在同一CPU的緩存行中。如果欄位不是對齊的,那麼就有可能出現跨緩存行的欄位。也就是說,該欄位的讀取可能需要替換兩個緩存行,而該欄位的存儲也會同時污染兩個緩存行。這兩種情況對程式的執行效率而言都是不利的。其實對其填充的最終目的是為了電腦高效定址。
經過上面的分析之後,就可以知道Object o = new Object()具體占用多少記憶體了(以64位系統為例)。
- 在開啟指針壓縮的情況下,markword占用8位元組,classpointer占用4位元組,Instance data無數據,總共是12位元組,由於對象需要為8的整數倍,Padding會補充4個位元組,總共占用16位元組。
- 在沒有開啟指針壓縮的情況下,markword占用8位元組,classpointer占用8位元組,Instance data無數據,也是占用16位元組。
main方法執行過程
以下是示例代碼:
public class Application {
public static void main(String[] args) {
Person p = new Person("大彬");
p.getName();
}
}
class Person {
public String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
執行main
方法的過程如下:
- 編譯
Application.java
後得到Application.class
後,執行這個class
文件,系統會啟動一個JVM
進程,從類路徑中找到一個名為Application.class
的二進位文件,將Application
類信息載入到運行時數據區的方法區內,這個過程叫做類的載入。 - JVM 找到
Application
的主程式入口,執行main
方法。 main
方法的第一條語句為Person p = new Person("大彬")
,就是讓 JVM 創建一個Person
對象,但是這個時候方法區中是沒有Person
類的信息的,所以 JVM 馬上載入Person
類,把Person
類的信息放到方法區中。- 載入完
Person
類後,JVM 在堆中分配記憶體給Person
對象,然後調用構造函數初始化Person
對象,這個Person
對象持有指向方法區中的 Person 類的類型信息的引用。 - 執行
p.getName()
時,JVM 根據 p 的引用找到 p 所指向的對象,然後根據此對象持有的引用定位到方法區中Person
類的類型信息的方法表,獲得getName()
的位元組碼地址。 - 執行
getName()
方法。
對象創建過程
- 類載入檢查:當虛擬機遇到一條
new
指令時,首先檢查是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已被載入過、解析和初始化過。如果沒有,那先執行類載入。 - 分配記憶體:在類載入檢查通過後,接下來虛擬機將為對象實例分配記憶體。
- 初始化。分配到的記憶體空間都初始化為零值,通過這個操作保證了對象的欄位可以不賦初始值就直接使用,程式能訪問到這些欄位的數據類型所對應的零值。
- 設置對象頭。
Hotspot
虛擬機的對象頭包括:存儲對象自身的運行時數據(哈希碼、分代年齡、鎖標誌等等)、類型指針和數據長度(數組對象才有),類型指針就是對象指向它的類信息的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。 - 按照
Java
代碼進行初始化。
如何排查 OOM 的問題?
線上JVM必須配置
-XX:+HeapDumpOnOutOfMemoryError
和-XX:HeapDumpPath=/tmp/heapdump.hprof
,當OOM發生時自動 dump 堆記憶體信息到指定目錄
排查 OOM 的方法如下:
- 查看伺服器運行日誌日誌,捕捉到記憶體溢出異常
- jstat 查看監控JVM的記憶體和GC情況,評估問題大概出在什麼區域
- 使用MAT工具載入dump文件,分析大對象的占用情況
什麼是記憶體溢出和記憶體泄露?
記憶體溢出指的是程式申請記憶體時,沒有足夠的記憶體供申請者使用,比如給了你一塊存儲int類型數據的存儲空間,但是你卻存儲long類型的數據,那麼結果就是記憶體不夠用,此時就會報錯OOM,即記憶體溢出。
記憶體泄露是指程式中間動態分配了記憶體,但在程式結束時沒有釋放這部分記憶體,從而造成那部分記憶體不可用的情況。這種情況重啟電腦可以解決,但也有可能再次發生記憶體泄露。記憶體泄露和硬體沒有關係,它是由軟體設計缺陷引起的。
像IO操作或者網路連接等,在使用完成之後沒有調用close()方法將其連接關閉,那麼它們占用的記憶體是不會自動被GC回收的,此時就會產生記憶體泄露。
比如操作資料庫時,通過SessionFactory獲取一個session:
Session session=sessionFactory.openSession();
完成後我們必須調用session.close()方法關閉,否則就會產生記憶體泄露,因為sessionFactory這個長生命周期對象一直持有session這個短生命周期對象的引用。
那兩者有什麼不同呢?
記憶體泄露可以通過完善代碼來避免,記憶體溢出可以通過調整配置來減少發生頻率,但無法徹底避免。
如何避免記憶體泄露和溢出呢?
- 儘早釋放無用對象的引用。比如使用臨時變數的時候,讓引用變數在退出活動域後自動設置為null,暗示垃圾收集器來收集該對象,防止發生記憶體泄露。
- 儘量少用靜態變數。因為靜態變數是全局的,GC不會回收。
- 避免集中創建對象尤其是大對象,如果可以的話儘量使用流操作。
- 儘量運用池化技術(資料庫連接池等)以提高系統性能。
- 避免在迴圈中創建過多對象。
參考資料
- 周志明. 深入理解 Java 虛擬機 [M]. 機械工業出版社