1.為什麼要學習jvm?
- 一開始接觸jvm,是因為最近面試頻繁被問到,於是拜讀了《深入理解java虛擬機器:jvm高階特性與最佳實踐》,這是一本非常好的書,推薦。
- 看了裡面的幾章之後,對java執行一段程式碼的整個過程有了一定的理解;比如看了記憶體模型之後,發現了stackoverflow(棧溢位)和OutofMemory(記憶體不足)這幾種型別的錯誤主要引起的原因是什麼?不同型別下的解決方案是什麼?
- 寫這個部落格主要是想把自己最近看的jvm所有知識點能夠串起來,以便後面學習更好理解。
- 什麼是jvm:虛擬出來的計算機,是jre的一部分,使用jvm是為了支援與作業系統無關,實現跨平臺,jvm內部體系結構主要分為三個部分:類載入器子系統,執行時資料區和執行引擎。
- java中跨平臺與C++跨平臺的區別:java中跨平臺的意義在於jvm(執行區間)的跨平臺;而c++跨平臺在於jdk(編譯期間)跨平臺;
2.主要內容:
2.1 java程式碼執行
1.java原始檔-》編譯器-》位元組碼檔案;
2. 位元組碼檔案-》jvm-》機器碼(見上圖)
3.每一個平臺的直譯器是不同的,但是實現的虛擬機器是相同的,這是java能夠跨平臺的原因。
4.當一個程式從開始執行,虛擬機器就開始例項化,多個程式啟動的話就會存在多個虛擬機器例項。程式退出或者關閉,虛擬機器例項會消失,多個虛擬機器例項之間的資料不能共享。
2.2 java記憶體區域
1.執行時資料區
- 其中執行緒私有資料區域的生命週期和執行緒相同,每個執行緒與作業系統的本地執行緒直接對映,這部分的記憶體區域跟著本地執行緒建立銷燬。
- 執行緒共享區域隨虛擬機器的啟動而建立,關閉而銷燬。
- 除pc之外,別的區域都有可能造成OOM
2.直接記憶體
- 直接記憶體頻繁被使用,可能造成OOM
- NIO中引入了基於CHANEL與buffer的IO方式,可以使用native函式庫直接分配堆外記憶體。使用DirectByteBuffer物件作為這塊記憶體的引用進行操作。避免了java堆和native堆中來回複製資料,因此在一些場景中可以顯著提高效能。
3.執行時資料區主要內容
- pc:指向當前執行緒所執行的位元組碼的行號
- 虛擬機器棧:主要描述方法執行,每個方法在執行的時候建立一個棧幀,其中棧幀中儲存了區域性變數表,運算元棧,動態連結,方法出口等資訊。每一個方法從呼叫到執行結束,對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。
- 本地方法區:虛擬機器棧執行java方法;本地方法棧執行native方法。比如java中呼叫C或C++中的方法,在這裡執行。有的虛擬機器會將虛擬機器棧與本地方法棧合二為一。
- 堆:建立的物件與陣列都儲存在java堆記憶體中,因為是執行緒共享,所以與方法區兩個是垃圾收集的區域,java堆中從GC的角度細分為:新生區與老年區。
- 方法區:用於儲存被jvm載入的類資訊,常量,靜態變數,即時編譯器編譯後的程式碼等資料。**使用java堆的永久代實現方法區,GC收集的永久代區域。**永久代的記憶體回收主要目標是針對常量池的回收和型別的解除安裝。執行時常量池是方法區的一部分,class檔案除了有類的版本,欄位,方法,介面等描述資訊外,還有一項是常量池,主要存放編譯期間生成的各種字面量和符號引用,這部分內容在類載入後存放到方法區的執行時常量池中。
2.3 jvm是如何分配記憶體的?
1.堆上分配
存放物件例項與陣列
2.棧上分配
存放區域性變數
Java 棧記憶體空間用於執行執行緒, 棧記憶體始終遵循LIFO(Last-in-first-out) 順序, 每當一個方法被執行, 會在棧記憶體中建立一個新的棧幀 用於儲存在函式中定義的基本資料型別變數以及物件的引用變數;
當方法結束時, 這個棧幀 改變它的狀態為未使用並且可用於執行下一個方法
例項理解堆疊記憶體:
程式碼:
public class HeapStackTestMemory {
public static void main(String[] args) { //Line 1
int i = 1; //Line 2
Object obj = new Object(); //Line 3
HeapStackTestMemory mem = new HeapStackTestMemory(); //Line 4
mem.foo(obj); //Line 5
} //Line 9
private void foo(Object param) { //Line 6
String str = param.toString(); //Line 7
System.out.println(str);
} //Line 8
}
複製程式碼
程式執行步驟:
- 一旦我們開始執行程式, 它會把所有的執行時類載入到堆記憶體空間, 在 Line 1 行找到main() 方法, Java Runtime 建立由main() 方法執行緒使用的棧記憶體空間
- 在第二行 我們建立了原始資料型別的區域性變數, 所以它將被儲存在main() 方法的棧記憶體空間
- 在第3行我們建立了一個Object 型別的物件, 所以它被建立在Heap 堆記憶體空間中 並且 Stack 棧記憶體空間包含對它的引用, 當我們在第4行中建立Memory 物件時, 會發生類似的過程
- 現在我們在第5行呼叫foo() 方法, 此時會在stack 棧建立一個block 供foo() 方法使用
- Java 是通過值傳遞, 在第6行, 會在foo() 棧中建立一個對Object 物件的新的引用
- 在第7行 , 一個string 型別的物件被建立, 此時 會在foo() 棧記憶體中建立它的一個引用 str
- foo() 方法在第8行執行完畢, 此時, 程式會釋放stack 棧記憶體中為foo() 方法分配的棧記憶體空間
- 在第9行, main() 方法執行完畢, 為main()方法建立的堆疊記憶體被銷燬, 此時 這個java 程式結束執行, Java Runtime 會釋放所有的記憶體
基於上述的說明, 可以很容易的總結出堆疊記憶體的以下差異
1,生命週期: 堆記憶體屬於java 應用程式所使用, 生命週期與jvm一致;棧記憶體屬於執行緒所私有的, 它的生命週期與執行緒相同
2, 引用:不論何時建立一個物件, 它總是儲存在堆記憶體空間 並且棧記憶體空間包含對它的引用 . 棧記憶體空間只包含方法原始資料型別區域性變數以及堆空間中物件的引用變數
3, 在堆中的物件可以全域性訪問, 棧記憶體空間屬於執行緒所私有
4, jvm 棧記憶體結構管理較為簡單, 遵循LIFO 的原則, 堆空間記憶體管理較為複雜 , 細分為:新生代和老年代 etc..
5, 棧記憶體生命週期短暫, 而堆記憶體伴隨整個用用程式的生命週期
6, 二者丟擲異常的方式: 如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常, 堆記憶體丟擲OutOfMemoryError異常
3.TLAB分配
Thread Local Allocation Buffer
執行緒本地分配快取區
如果設定了虛擬機器引數 -XX:UseTLAB,線上程初始化時,同時也會申請一塊指定大小的記憶體,只給當前執行緒使用,這樣每個執行緒都單獨擁有一個空間,如果需要分配記憶體,就在自己的空間上分配,這樣就不存在競爭的情況,可以大大提升分配效率。
4.物件記憶體如何分配
虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在子常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,那必須先執行相應的類載入過程。在類載入檢查通過後,虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。
1.指標碰撞:
假設Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離。
2.空閒列表:
如果Java堆中記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,虛擬機器就必須維護一個列表,記錄上那些記憶體塊是可用的,在分配的時候從列表中找到一個足夠大的空間劃分給物件例項,並更新列表上的記錄。
選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配演算法是指標碰撞;而使用CMS這種基於Mark-Sweep演算法的收集器時,通常採用空閒列表。
參考:juejin.im/post/59df28… juejin.im/post/5a5eeb…
2.4 哪些記憶體需要回收?
不再使用的物件需要進行回收,不使用的類也有可能回收。
2.5 什麼情況下回收(兩種方法)?
如何判斷一個物件不再使用?
1.引用計數法
定義:給物件新增一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器就減一;任何時刻計數器為0的物件就是不會被使用的物件。
2.GC roots
通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑被稱為引用鏈,當一個物件到“GC Roots”沒有任何引用鏈相連的時候,就證明此物件是不可用的。
可以作為GC root物件包括:- 虛擬機器棧(棧幀中的本地變數表)中的引用物件。
- 方法區中的靜態屬性或常量(final)引用的物件。
- 本地方法棧中JNI(即一般說的Native方法)引用的物件。
2.6 jvm如何保證正確的回收?
分代收集:根據物件存活的不同年齡劃分不同的域。
新生代:複製演算法
老年代:標記複製演算法:
分代收集演算法將heap區域劃分為新生代和老年代,新生代的空間比老年代的空間要小。新生代又分為了Eden和兩個survivor空間,它們的比例為8:1:1。物件被建立時,記憶體的分配是在新生代的Eden區發生的,大物件直接在老年代分配記憶體,IBM的研究表明,Eden區98%的物件都是很快消亡的。
為了提高gc效率,分代收集演算法中新生代和老年代的gc是分開的,新生代發生的gc動作叫做minor gc 或 young gc,老年代發生的叫做major gc 或 full gc。
minor gc 的觸發條件:當建立新物件時Eden區剩餘空間小於物件的記憶體大小時發生minor gc;
major gc 觸發條件:
1、顯式呼叫System.gc()方法;
2、老年代空間不足;
3、方法區空間不足;
4、從新生代進入老年代的空間大於老年代空閒空間;
Eden區物件的特點是生命週期短,存活率低,因此Eden區使用了複製演算法來回收物件,上面也提到複製演算法的特點是在存活率較低的情況下效率會高很多,因為需要複製的物件少。與一般的複製演算法不同的是,一般的複製演算法每次只能使用一半的空間,另一半則浪費掉了,Eden區的回收演算法也叫做"停止-複製"演算法,當Eden區空間已滿時,觸發Minor GC,清理掉無用的物件,然後將存活的物件複製到survivor1區(此時survivor0有存活物件,survivor1為空的),清理完成後survivor0為空白空間,survivor1有存活物件,然後將survivor0和survivor1空間的角色物件,下次觸發Minor gc時重複上述過程。如果survivor1區剩餘空間小於複製物件所需空間時,將物件分配到老年代中。每發生一次Minor gc時,存活下來的物件的年齡則會加1,達到一定的年齡後(預設為15)該物件就會進入到老年代中。
老年代的物件基本是經過多次Minor gc後存活下來的,因此他們都是比較穩定的,存活率高,如果還是用複製演算法顯然是行不通的。所以老年代使用“標記-整理”演算法來回收物件的,從而提高老年代回收效率。
總的來說,分代收集演算法並不是一種具體的演算法,而是根據每個年齡代的特點,多種演算法結合使用來提高垃圾回收效率。
2.7 垃圾收集演算法
1.標記-清除演算法
標記清除演算法分為“標記”和“清除”兩個階段,首先先標記出那些物件需要被回收,在標記完成後會對這些被標記了的物件進行回收
缺點:碎片多;虛擬機器在給記憶體較大物件分配空間時,有可能找不到足夠大的連續空間存放,從而引發垃圾回收動作。實際上有大量空閒空間,只是不連續;2.複製演算法
複製演算法是將記憶體分為兩塊大小一樣的區域,每次是使用其中的一塊。當這塊記憶體塊用完了,就將這塊記憶體中還存活的物件複製到另一塊記憶體中,然後清空這塊記憶體。
現在商用的jvm中都採用了這種演算法來回收新生代,因為新生代的物件基本上都是朝生夕死的,存活下來的物件約佔10%左右,所以需要複製的物件比較少,採用這種演算法效率比較高。hotspot版本的虛擬機器將堆(heap)記憶體分為了新生代和老年代,其中新生代又分為記憶體較大的Eden區和兩個較小的survivor區。當進行記憶體回收時,將eden區和survivor區的還存活的物件一次性地複製到另一個survivor空間上,最後將eden區和剛才使用過的survivor空間清理掉。hotspot虛擬機器預設eden和survivor空間的大小比例為8:1,也就是每次新生代中可用記憶體空間為整個新生代空間的90%(80%+10%),只會浪費掉10%的空間。當然,98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當survivor空間不夠用時,需要依賴於其他記憶體(這裡指的是老年代)進行分配的擔保。
3.標記-整理演算法
複製演算法在物件存活率較高的情況下就要進行較多的物件複製操作,效率將會變低。更關鍵的是,如果你不需要浪費50%的空間,就需要有額外的空間進行分配擔保,用以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種辦法。
根據老年代的特點,有人提出了標記-整理的演算法,標記過程仍然與標記-清楚演算法一樣,但後續步驟不是直接將可回收物件清理掉,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體,演算法示意圖如下:
2.8 垃圾收集器
1.serial垃圾收集器
新生代
單執行緒
複製演算法
client模式下預設的新生代垃圾收集器
2.Parnew垃圾收集器
serial+多執行緒
server模式下預設的新生代垃圾收集器
3.parallel Scavenge收集器
多執行緒複製演算法
高效
重點關注程式達到一個可控制的吞吐量 : 吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間)
自適應調節策略,是PS收集器與ParNew收集器的一個重要區別
4.serial old收集器
單執行緒
標記整理演算法
執行在client預設的java虛擬機器老年代拉進收集器
在server模式下,兩個用途:
1.jdk15之前版本與新生代PS收集器搭配使用;
2.作為老年代中使用CMS收集器的後備垃圾收集方案。
- 新生代Serial與老年代Serial Old搭配:
- 新生代PS與老年代serial old搭配:
5.parallel old收集器
PS老年版本
多執行緒標記-整理演算法
jdk1.6之前,PS只能跟serial old搭配收集;
jdk1.6之後,PS與老年代的parallel Old版本搭配:
6.CMS收集器:多執行緒標記清除演算法
老年代的垃圾收集演算法,主要目標是獲取最短垃圾回收停頓時間,與其他老年代標記整理不同的是,這裡使用的是多執行緒標記-清除演算法,主要分為4個階段:
- 初始標記:標記GC roots能直接關聯的物件,速度快,仍然需要暫停所有工作執行緒。
- 併發標記:進行GC roots跟蹤的過程,跟使用者執行緒一起工作,不需要暫停工作執行緒。
- 重新標記:
- 併發清除
7.G1收集器
2.9 java類載入機制
類載入:object o =new object() 主要包括三個階段:通過不同的類載入器載入,當類被載入之後,進行驗證,只要包括:檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證;準備階段是為類的靜態變數分配記憶體;
2.10 執行引擎
位元組碼是如何被虛擬機器執行從而完成指定功能?
3.如何監控和優化GC
1.基於jdk命令列工具監控
jvm引數
jstat檢視虛擬機器統計資訊
jmap+MAT分析記憶體溢位
jstack分析死迴圈與死鎖
2.基於JVisualVM視覺化監控
啟動:
參考:www.mamicode.com/info-detail…3.GC日誌列印檢視
一般記憶體調整策略:
例子: public static void main(String[] args) {
//new thread01().start();
System.out.println(Runtime.getRuntime().maxMemory()/(double)1024/1024);//列印虛擬機器最大記憶體,兆
System.out.println(Runtime.getRuntime().totalMemory()/(double)1024/1024);//列印總記憶體
}
複製程式碼
以上面這段程式碼為例子:
可以看到結果分別為1797與123兆;
-Xmx2G
-Xms2G
-XX:+PrintGCDetails
-XX:+PrintHeapAtGC
4.學習jvm,給我帶來了什麼?
1.瞭解了java資料區域的整體記憶體模型,在宣告區域性變數以及new物件例項的記憶體分配。
2.方法執行時,java棧中棧幀的一系列變化。
3.堆疊在jvm中的區別
4.分代垃圾回收機制,什麼時候回收?回收什麼垃圾?有哪些垃圾回收演算法?不同版本下使用不同的垃圾收集器。
5.反編譯後(javap -c)的位元組碼指令大概能夠看一點,瞭解其整體在jvm中是如何一步一步執行的。
6.監控和GC引數日誌檢視。