1. 程式人生 > >[深入理解JVM 四]---Jvm執行時記憶體分析

[深入理解JVM 四]---Jvm執行時記憶體分析

Jvm

jvm其實就是java的虛擬機器,它將編譯好的位元組碼檔案翻譯成機器能識別的機器語言,然後執行。主要包括類載入,執行(執行位元組碼指令),垃圾回收三個功能模組。下圖描述了各個功能模組作用的記憶體區域。
這裡寫圖片描述

直接記憶體

其中:直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致OutOfMemoryError異常出現,所以我們放到這裡一起講解。在JDK 1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java堆和Native堆中來回複製資料。

顯然,本機直接記憶體的分配不會受到Java堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體(包括RAM以及SWAP區或者分頁檔案)大小以及處理器定址空間的限制。伺服器管理員在配置虛擬機器引數時,會根據實際記憶體設定-Xmx等引數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統級的限制),從而導致動態擴充套件時出現OutOfMemoryError異常

執行時記憶體區域分配

這裡寫圖片描述

這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程序的啟動而存在,有些區域則依賴使用者執行緒的啟動和結束而建立和銷燬。還有就是會丟擲什麼異常

這裡寫圖片描述

程式計數器

程式計數器(Program Counter Register)是JVM中一塊較小的記憶體區域儲存著當前執行緒執行的虛擬機器位元組碼指令的記憶體地址。Java多執行緒的實現,其實是通過執行緒間的輪流切換並分配處理器執行時間的方式來實現的,在任何時刻,處理器都只會執行一個執行緒中的指令。在多執行緒場景下,為了保證執行緒切換回來後,還能恢復到原先狀態,找到原先執行的指令,所以每個執行緒都會設立一個程式計數器,並且各個執行緒之間不會互相影響,程式計數器為”執行緒私有”的記憶體區域。

如果當前執行緒正在執行Java方法,則程式計數器儲存的是虛擬機器位元組碼的記憶體地址,如果正在執行的是Native方法(非Java方法,JVM底層有許多非Java編寫的函式實現),計數器則為空。程式計數器是唯一一個在Java規範中沒有規定任何OutOfMemory場景的區域

總結

1,程式計數器執行緒私有,每一個執行緒都會設立一個,且彼此之間互不影響。
2,程式計數器是唯一一個在Java規範中沒有規定任何OutOfMemory場景的區域。
3,程式計數器只記錄當前執行緒執行虛擬位元組碼指令,也就是java方法,native方法(本地方法棧中)不記錄。

本地方法棧

本地方法棧(Native Method Stack)和虛擬機器棧的作用相似,它的生命週期與執行緒相同,也是執行緒隔離的。不過虛擬機器棧是為Java方法服務的,而本地方法棧是為Native方法服務的。

異常丟擲:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常,如果虛擬機器動態擴充套件時無法申請到足夠的內催,就會丟擲OutOfMemoryError異常

虛擬機器棧

虛擬機器棧(Java Virtual Machine Stacks)和執行緒是緊密聯絡的,每建立一個執行緒時就會對應建立一個Java棧它的生命週期與執行緒相同,所以Java棧也是”執行緒私有”的記憶體區域,這個棧中又會對應包含多個棧幀,每呼叫一個方法時就會往棧中建立並壓入一個棧幀,棧幀是用來儲存方法資料(區域性變數)和部分過程結果的資料結構,每一個方法從呼叫到最終返回結果的過程,就對應一個棧幀從入棧到出棧的過程

這裡寫圖片描述

存放於棧中的內容

每個執行緒包含一個棧區,棧中的每一個棧幀只儲存基礎資料型別的物件和自定義物件的引用(區域性變量表),運算元棧,動態連結,方法出口。

1 方法的形式引數,方法呼叫完後從棧空間回收
2 引用物件的地址,引用完後,棧空間地址立即被回收,堆空間等待GC

注意:每個棧中的資料(基礎資料型別和物件引用)都是私有的,其他棧不能訪問。

異常丟擲:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常,如果虛擬機器動態擴充套件時無法申請到足夠的內催,就會丟擲OutOfMemoryError異常

java堆是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。

注意以下幾點
1,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。
2,在實現時,既可以實現成固定大小的,也可以是可擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過-Xmx和-Xms控制)。

異常丟擲
如果在堆中沒有記憶體用來完成例項分配,並且堆也無法再擴充套件時,將丟擲OutOfMemoryError異常。

方法區

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,在虛擬機器啟動時建立它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的應該是與Java堆區分開來。

執行時常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。

執行時常量池相對於Class檔案常量池的另外一個重要特徵是具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。

異常丟擲:既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲OutOfMemoryError異常。

物件

物件的建立

1,檢查對應的類是否已載入完畢

虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,那必須先執行相應的類載入過程

2,在堆中為物件分配記憶體

在類載入檢查通過後,接下來虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來,主要有兩種劃分方式:
(1)指標碰撞(Bump the Pointer):假設Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為“指標碰撞”(Bump the Pointer)。
(2)空閒列表(Free List):如果Java堆中的記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單地進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄。

Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配演算法是指標碰撞,而使用CMS這種基於Mark-Sweep演算法的收集器時,通常採用空閒列表。
本地執行緒分配緩衝(TLAB)解決併發情況下執行緒不安全問題
除如何劃分可用空間之外,還有另外一個需要考慮的問題是物件建立在虛擬機器中是非常
頻繁的行為,即使是僅僅修改一個指標所指向的位置,在併發情況下也並不是執行緒安全的,可能出現正在給物件A分配記憶體,指標還沒來得及修改,物件B又同時使用了原來的指標來分配記憶體的情況。解決這個問題有兩種方案,一種是對分配記憶體空間的動作進行同步處理——實際上虛擬機器採用CAS配上失敗重試的方式保證更新操作的原子性;另一種是把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)。哪個執行緒要分配記憶體,就在哪個執行緒的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。虛擬機器是否使用TLAB,可以通過-XX:+/-UseTLAB引數來設定。

3,記憶體分配完成後,虛擬機器還要進行兩步操作

(1)虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),如果使用TLAB,這一工作過程也可以提前至TLAB分配時進行。這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。
(2)虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中

物件的記憶體佈局

物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、例項資料(Instance Data)和對齊填充(Padding)

物件頭

HotSpot虛擬機器的物件頭包括兩部分資訊:

第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。官方稱它為“Mark Word”
這裡寫圖片描述

第二部分是型別指標,即物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以通過普通Java物件的元資料資訊確定Java物件的大小,但是從陣列的元資料中卻無法確定陣列的大小。

例項資料

接下來的例項資料部分是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來

對齊填充

對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或者2倍),因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

物件的訪問定位

使用控制代碼訪問

如果使用控制代碼訪問的話,那麼Java堆中將會劃分出一塊記憶體來作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自的具體地資訊。
這裡寫圖片描述
使用控制代碼來訪問的最大好處就是reference中儲存的是穩定的控制代碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,reference本身不需要修改。

使用直接指標訪問

這裡寫圖片描述
使用直接指標訪問方式的最大好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本

記憶體溢位

除程式計數器外,虛擬機器記憶體的其他幾個執行時區域都有發生OutOfMemoryError(OOM)異常的可能

java堆溢位(heap space)

我們可以通過:將堆的最小值-Xms引數與最大值-Xmx引數設定堆記憶體大小

產生原因

建立的物件過多,且垃圾回收機制不能有效的清楚這些物件,就會產生溢位。

異常測試

為了測試,首先修改堆記憶體大小:限制Java堆的大小為20MB,不可擴充套件(將堆的最小值-Xms引數與最大值-Xmx引數設定為一樣即可避免堆自動擴充套件),通過引數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機器在出現記憶體溢位異常時Dump出當前的記憶體堆轉儲快照以便事後進行分析。在我的Myeclipse裡這樣設定。

這裡寫圖片描述

測試程式碼如下,可以執行死迴圈新增,一定會導致堆溢位

package test;

import java.util.ArrayList;
import java.util.List;

/**

 * 
 * @author 田茂林
 * @data 2017年8月9日 下午5:10:40
 *  VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    static class OOMObject {

    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true) {
            list.add(new OOMObject());

        }
    }
}

異常資訊:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid5756.hprof ...
Heap dump file created [27964652 bytes in 0.188 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3210)
    at java.util.Arrays.copyOf(Arrays.java:3181)
    at java.util.ArrayList.grow(ArrayList.java:261)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
    at java.util.ArrayList.add(ArrayList.java:458)
    at test.HeapOOM.main(HeapOOM.java:23)

分析與解決

遇到這種情況首先考慮到底是出現了記憶體洩漏還是記憶體溢位
1,記憶體洩露,如果是記憶體洩露,可進一步通過工具檢視洩露物件到GC Roots的引用鏈。於是就能找到洩露物件是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收它們的。掌握了洩露物件的型別資訊及GC Roots引用鏈的資訊,就可以比較準確地定位出洩露程式碼的位置。
2,如果不存在洩露,換句話說,就是記憶體中的物件確實都還必須存活著,那可能是記憶體溢位。那就應當檢查虛擬機器的堆引數(-Xmx與-Xms),與機器實體記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長的情況,嘗試減少程式執行期的記憶體消耗。

記憶體洩露介紹

概念

所謂記憶體洩露就是指一個不再被程式使用的物件或變數一直被佔據在記憶體中。

產生原因

長生命週期的物件持有短生命週期物件的引用就很可能發生記憶體洩露,儘管短生命週期物件已經不再需要,但是因為長生命週期物件持有它的引用而導致不能被回收。

通俗地說,就是程式設計師可能建立了一個物件,以後一直不再使用這個物件,這個物件卻一直被引用,即這個物件無用但是卻無法被垃圾回收器回收的,這就是java中可能出現記憶體洩露的情況

發生場景

1,快取系統,我們載入了一個物件放在快取中(例如放在一個全域性map物件中),然後一直不再使用它,這個物件一直被快取引用,但卻不再被使用。

2,如果一個外部類的例項物件的方法返回了一個內部類的例項物件,這個內部類物件被長期引用了,即使那個外部類例項物件不再被使用,但由於內部類持有外部類的例項物件,這個外部類物件將不會被垃圾回收,這也會造成記憶體洩露。

3,當一個物件被儲存進HashSet集合中以後,就不能修改這個物件中的那些參與計算雜湊值的欄位了,否則,物件修改後的雜湊值與最初儲存進HashSet集合中時的雜湊值就不同了,在這種情況下,即使在contains方法使用該物件的當前引用作為的引數去HashSet集合中檢索物件,也將返回找不到物件的結果,這也會導致無法從HashSet集合中單獨刪除當前物件,造成記憶體洩露。

注意:由於Java 使用有向圖的方式進行垃圾回收管理,可以消除引用迴圈的問題,例如有兩個物件,相互引用,只要它們和根程序不可達的,那麼GC也是可以回收它們的。例如物件A和物件B相互引用對方,GC照樣可以回收

部分分隔符=======================TML

虛擬機器棧和本地方法棧溢位

注意:我們可以通過-Xss引數設定虛擬機器棧容量大小

產生原因:

1,如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常。
2,如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲OutOfMemoryError異常。

異常測試

1,在單個執行緒下(只有一個虛擬機器棧),無論是由於棧幀太大(增加方法中本地變量表長度)還是虛擬機器棧容量太小(通過-Xss減小虛擬機器棧佔用記憶體),當記憶體無法分配的時候,虛擬機器丟擲的都是StackOverflowError異常。並且結果丟擲StackOverflowError異常時輸出的堆疊深度相應縮小。

2,如果測試時不限於單執行緒,通過不斷地建立執行緒的方式產生記憶體溢位異常

分析與解決

分析:虛擬機器提供了引數來控制Java堆和方法區的這兩部分記憶體的最大值。記憶體2GB(作業系統限制)減去Xmx(最大堆容量),減去MaxPermSize(最大方法區容量),程式計數器消耗記憶體很小,可以忽略掉。如果虛擬機器程序本身耗費的記憶體不計算在內,剩下的記憶體就由虛擬機器棧和本地方法棧“瓜分”了。每個執行緒分配到的棧容量越大,可以建立的執行緒數量自然就越少,建立執行緒時就越容易把剩下的記憶體耗盡。

解決:如果是建立過多執行緒導致的記憶體溢位,在不能減少執行緒數或者更換64位虛擬機器的情況下,就只能通過減少最大堆和減少棧容量來換取更多的執行緒

方法區溢位

我們可以通過-XX:PermSize和-XX:MaxPermSize限制方法區大小

產生原因

執行時產生大量的去填滿方法區,直到溢位。

異常測試(PermGen space)

1,大量JSP或動態產生JSP檔案的應用(JSP第一次執行時需要編譯為Java類)
2,基於OSGi的應用(即使是同一個類檔案,被不同的載入器載入也會視為不同的類)
3,當前的很多主流框架,如Spring、Hibernate,在對類進行增強時,都會使用到CGLib這類位元組碼技術,增強的類越多,就需要越大的方法區來保證動態生成的Class可以載入入記憶體
4,動態語言的使用和反射機制或者動態代理

分析與解決

就我個人看來,方法區一般不會發生溢位,一般我們做的專案或者寫的程式碼所能載入到的類其實遠遠小於方法區所能容納的量。

本機直接記憶體溢位

DirectMemory容量可通過-XX:MaxDirectMemorySize指定,如果不指定,則預設與Java堆最大值(-Xmx指定)一樣。直接記憶體溢位發生情況較少,一般使用NIO的時候可能發生。

一直對於jvm不甚瞭解,所以買了一本《深入理解java虛擬機器》,這篇博文意在對jvm進行記憶體分析和在記憶體溢位的時候瞭解問題所在,文中的配圖來自《深入理解java虛擬機器》。在瞭解了執行時記憶體大致如何分配後,我想應該瞭解一下類載入機制,這樣在類載入到jvm中,然後執行引擎,最後垃圾回收(垃圾回收也可能在過程中)。就有了一套完整 解釋。