深刻理解jvm運行時區域

前言

最近一直在看周志明老師的《深刻理解虛擬機》,老是看了忘,忘了又看,陷入這樣無休止的循環當中。抱着紙上得來終覺淺的想法,準備陸續的寫幾篇學習筆記,梳理知識的脈絡並強化一下對知識的掌握。(本文遠遠談不上深刻,但爲了博瀏覽量,請原諒我這個標題黨)。java

概述

"Write Once,Run Anywhere"是sun公司用來展現java語言跨平臺特性的口號。這標示着java語言能夠在任何機器上開發,並編譯成標準的字節碼,在任何具備jvm虛擬機上的設備運行,這也是java語言早期興起的關鍵。java另外一大特性是其虛擬機的內存自動管理機制,這使得java程序員在建立任何一個對象時都不須要去寫與之配對的delete/free代碼(釋放內存),不容易出現由於粗枝大葉而致使的內存泄漏和內存溢出的問題。但是由於將內存管理的權利交給虛擬機,一旦出現內存泄漏和內存溢出的問題,若是咱們不瞭解虛擬機相關的知識,排查問題將是一件極爲艱難的事情。程序員

java內存區域

java虛擬機在運行java程序時,會將其管理的內存區域劃分紅若干個不一樣的數據區域。接下來的知識若是沒有指明jdk版本號,統一以jdk1.6爲標準,內存區域以下圖所示:
圖片描述算法

  • 程序計數器
    程序計數器是一塊較小的內存區域,能夠把它當作是當前線程執行字節碼的行號指示器。因爲java虛擬機的多線程是經過線程輪流切換並分配處理器執行時間的方式實現的。在任意一個肯定的時刻,一個cpu核心只會執行一個線程,所以爲了cpu在切換線程後能夠找到上次運行的位置,每條線程都應該有一個獨立的程序計數器。各個線程間的程序計數器應互不影響並獨立存儲。若是此時運行的是java方法,這個記錄器記錄的是正在執行虛擬機字節碼指令的地址,若是執行的是native方法,則這個計數器爲空。此內存區域也是惟一一個java虛擬機規範裏沒有規定任何OutOfMemoryError狀況的區域。
  • java虛擬機棧
    虛擬機棧也是線程私有的,它的生命週期和線程是相同的,它描述的就是java方法執行的內存區域。每一個方法在執行的同時都會建立一個棧幀用於存儲局部變量表、操做數棧、動態連接、方法出口等信息。每個方法從調用到執行完成就對應着一個棧幀在虛擬機中從入棧到出棧的過程。若是線程請求的深刻大於棧所容許的深度,就會拋出StackOverflowError異常,大部分虛擬機支持動態擴展,若是擴展時沒法申請到足夠的內存,則會拋出OutOfMemoryError異常.
    局部變量表:存放了編譯器可知的各類基本數據類型(8種基礎類型)、對象的引用(reference類型)和returnAddress類型(指向了一條字節碼指令的地址),局部變量表在編譯期是就可肯定其大小。
    操做數棧:也是棧的一種,虛擬機把操做數棧做爲它的工做區,大多數指令都要從這裏彈出數據,執行運算,而後把結果壓回操做數棧。
    動態連接: Class 文件中存放了大量的符號引用,字節碼中的方法調用指令就是以常量池中指向方法的符號引用做爲參數。這些符號引用一部分會在類加載階段或第一次使用時轉化爲直接引用,這種轉化稱爲靜態解析。另外一部分將在每一次運行期間轉化爲直接引用,這部分稱爲動態鏈接.能夠簡單的理解爲爲了支持在方法中使用靜態變量和常量...
    方法出口:通常來講只有兩種方法出口。一種是正常執行完畢,能夠講程序計數器做爲返回地址返回,另一種就是拋出異常,此時返回地址爲空,須要異常處理器來肯定返回地址。
    圖片描述
  • 本地方法棧
    本地方法棧和虛擬機棧的做用是很是類似的。他們之間的區別不過就是一個爲java方法服務,另一個爲native方法使用。本地方法棧的實現由java虛擬機規範所定義,各大虛擬機廠商在虛擬機規範的基礎上自由實現.
  • java堆
    java堆是全部線程共享的內存區域,也是大多數應用中虛擬機管理內存區域最大的一塊,在虛擬機啓動時建立。其做用就是爲了存放對象實例。從內存回收的角度看,如今的收集器基本都採用分代收集算法。因此java堆還能夠分爲新生代和老年代。其中新生代又可分爲Eden空間、From Survivor空間、To Survivor空間。堆內存區域的大小是經過-Xmx和-Xms來控制。若是在堆中沒有完成內存實例分配,而且堆也沒法擴展,將會拋出OutOfMemoryError異常。
  • 方法區
    方法區也是全部線程共享的內存區域,它用於存儲已經被虛擬機加載的類信息、常量、靜態變量等數據。它有一個別名叫作Non-heap(非堆),目的就是爲了和堆作區分。對於常常在hotspot虛擬機上的開發者來講,更願意將方法區成爲永久代,本質上二者並不等價。只不過jvm設計團隊選擇把gc分代收集擴展至方法區,或者說使用永久代來實現方法區。但就目前發展來看,這樣並非一個好作法。jdk1.7中已經將本來放在永久代的字符串常量池移出,jdk1.8已經徹底廢除永久代這個概念,改用metaspace(元空間)。這塊區域的回收主要針對常量池的回收和對類型的卸載,條件至關的苛刻,通常回收成績也很難讓人滿意,但對其回收是很是有必要的。Sun公司的bug列表中,曾經出現多個嚴重的bug就是由於低版本的虛擬機未對方法區進行回收。當方法區沒法知足內存分配需求時,將拋出OutOfMemoryError異常。
  • 運行時常量池
    運行時常量池是方法區的一部分。Class文件除了有類的版本、字段、方法、接口等描述信息等,還有一項信息是常量池,常量池在通過類加載後進入方法區的運行時常量池中存放。運行時常量池的一個重要特徵就是具有動態性,java語言容許在運行期加新的常量放入池中。運行時常量池是方法去的一部分,天然會受到方法區內存的限制,當沒法申請到內存時將會拋出OutOfMemory異常。
  • 直接內存
    直接內存並非jvm內存管理區域的一部分,但也被頻繁的使用,並可能致使OOM一場出現。在jdk1.4以後新加入了nio(new Input/Output)類,引入了一種基於通道(channel)和緩衝區(Buffer)的I/O方式,它使用native函數分配堆外內存。它不會說到java堆大小的限制,可是會受到本機總內存的限制。在配置虛擬機參數時,常常會忽略直接內存,從而致使動態擴展時出現OOM異常。

hotspot虛擬機對象探祕

  • 對象的建立
    在java語言層面,對象的建立經過new關鍵字來就能夠實現。在jvm層面,對象(僅限於普通對象,不包括數組和class對象)的建立又是什麼樣子的呢?
    當虛擬機接收到一條new指令時,會先跟據new指令的參數去常量池查詢這個類的符號引用,並檢查這個類是否已經被虛擬機加載、解析、初始化。若是沒有,則要先執行相應的類加載過程。接下來要爲對象分配內存,假設堆內存是絕對規整的,只須要一個指針做爲臨界點來標記內存已使用和內存未使用的區域,每次分配對象只須要移動與對象大小相等的距離便可,這種內存分配方式叫作"指針碰撞"。若是堆內存不是絕對規整的,咱們沒法經過簡單的指針碰撞去分配內存,這時就須要虛擬機去維護一個列表,記錄哪些內存區域是未使用的和其內存區域的大小,給對象分配內存只須要去空閒列表裏找到一個塊足夠大的內存劃分給對象實例便可,這種方式叫作「空閒列表」。
    在一個應用程序中,建立對象是很是頻繁的行爲,僅僅是一個指針的分配在併發狀況下都不是絕對安全的。頗有可能正在給A對象分配內存,指針還沒來得及修改位置,又發生着使用原來的指針給B對象分配內存。jvm提供了兩種解決方案,1.jvm使用cas配上失敗重試來保證指針更新操做的原子性。2.將內存分配的動做按照線程分區域進行,也就是預先給每一個線程申請一部分區域,這種方式稱爲本地緩衝(Thread Local Allocate Buffer,TLAB).哪一個線程要分配對象就在哪一個線程的tlab上分配。只有當tlab用完並分配新的tlab才須要同步鎖定,虛擬機是否開啓tlab能夠經過參數-XX:+/UseTLAB來決定。
    內存分配好後,jvm須要分配的內存都初始化爲零值(不包括對象頭),以便java代碼中變量不賦值,也能夠訪問到其數據類型對應的零值。接下來須要對對象頭部分來作一個設置,對象頭中主要包括類的元信息,對象的哈希碼,對象的gc分代年齡以及鎖記錄等,在上面這些工做都完成時,從虛擬機的角度來講一個對象就已經建立好了。但從java語言的角度來看,還須要其執行構造方法讓其按照程序員的意願去構造這個對象,這樣一個真正可用的對象纔算徹底產生出來。
  • 對象的內存佈局
    對象的主要分爲三部分對象頭(Object Header),實例數據(Instance Data)和對齊填充(Padding).
    對象頭主要分爲兩部分,一部分是類型指針,經過類型指針指向類的元數據(肯定對象是哪一個類的實例)。另一部分官方稱爲"Mark Word",用於存儲自身運行時的數據,好比哈希值、gc年齡、鎖狀態標誌、偏向線程id等。「Mark word」的存儲內容以下圖所示:
    圖片描述
    實例數據存儲的是真正有效的數據,也是咱們業務所關心的數據。
    對齊填充並非必須存在的,只是由於hotspot要求對象的大小必須是8bit的整數倍,而"Mark Word"又必定是8的整數倍,實例數據大小不肯定,因此用對齊填充來補充其空餘的地方。
  • 對象的訪問定位
    建立對象是爲了訪問對象。咱們在須要經過java虛擬機棧的reference引用去獲取堆上的具體對象。可是並無規定如何經過一個引用具體的定位訪問到一個對象,因此對想得訪問方式也是由虛擬機的實現定義的。主流的實現方式有使用句柄和直接指針兩種。以下圖所示:
    圖片描述
    使用句柄池其最大的好處就是保證reference引用中句柄的穩定,reference引用存放的是句柄池的地址,句柄中保存了指向對象實例數據和對象類型數據的指針,在虛擬機gc的時候,對象會發生很是頻繁的移動,這個時候只要修改句柄指向對象數據的指針便可,不須要修改reference.
    使用直接指針的好處就是塊,能夠減小一次指針定位。因爲訪問對象在一個程序中將是很是頻繁的操做,聚沙成塔,因此這也是一個很是可觀的優化。

OOM異常--例子分析

通過一長串的的理論分析,咱們已經大體清楚java的內存區域,如今咱們使用具體的例子來驗證。會將jvm的參數放在代碼註釋中。數組

  • java堆溢出
/**
 * -XX:+PrintGCDetails -Xmx20m -Xms20m
 */
public class HeapOOM {

    static class OOMObjectt {
    }

    public static void main(String[] args) {

        List<OOMObjectt> list = new ArrayList<OOMObjectt>();
        try {
            while (true){
                list.add(new OOMObjectt());
            }
        } catch (Exception e) {

        }
    }
}

其運行結果以下:安全

[GC [PSYoungGen: 5898K->480K(6656K)] 5898K->3769K(20480K), 0.0043241 secs] [Times: user=0.09 sys=0.00, real=0.00 secs] 
[GC [PSYoungGen: 6315K->488K(6656K)] 9604K->8320K(20480K), 0.0064706 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC [PSYoungGen: 6632K->0K(6656K)] [ParOldGen: 10997K->13393K(13824K)] 17629K->13393K(20480K) [PSPermGen: 3164K->3163K(21504K)], 0.1786099 secs] [Times: user=0.58 sys=0.00, real=0.18 secs] 
[Full GC [PSYoungGen: 3031K->3001K(6656K)] [ParOldGen: 13393K->13393K(13824K)] 16425K->16394K(20480K) [PSPermGen: 3163K->3163K(21504K)], 0.1063835 secs] [Times: user=0.64 sys=0.02, real=0.11 secs] 
[Full GC [PSYoungGen: 3001K->3001K(6656K)] [ParOldGen: 13393K->13377K(13824K)] 16394K->16378K(20480K) [PSPermGen: 3163K->3163K(21504K)], 0.0873232 secs] [Times: user=0.28 sys=0.02, real=0.09 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:2245)
    at java.util.Arrays.copyOf(Arrays.java:2219)
    at java.util.ArrayList.grow(ArrayList.java:242)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
    at java.util.ArrayList.add(ArrayList.java:440)
    at HeapOOM.main(HeapOOM.java:17)
Heap
 PSYoungGen      total 6656K, used 3144K [0x00000000ff900000, 0x0000000100000000, 0x0000000100000000)
  eden space 6144K, 51% used [0x00000000ff900000,0x00000000ffc12240,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 13824K, used 13377K [0x00000000feb80000, 0x00000000ff900000, 0x00000000ff900000)
  object space 13824K, 96% used [0x00000000feb80000,0x00000000ff890578,0x00000000ff900000)
 PSPermGen       total 21504K, used 3194K [0x00000000f9980000, 0x00000000fae80000, 0x00000000feb80000)
  object space 21504K, 14% used [0x00000000f9980000,0x00000000f9c9ebc8,0x00000000fae80000)

  • 虛擬機棧和本地方法棧溢出
public class JavaVMStackSOF {

    private int stackLength=1;

    public void stackLeak(){
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Exception e) {
            System.out.println("e.length:"+oom.stackLength);
            e.printStackTrace();
        }
    }
}
Exception in thread "main" java.lang.StackOverflowError
    at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:6)
    at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7)
    at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7)
    at JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7)
  • 方法區和運行時常量池溢出

    前文提到hotspot虛擬機棧中方法區是由永久代來實現的,能夠用參數-XX:PermSize -XX:MaxPermSize來限制其空間,當沒法申請到足夠的內存時,會出現「permgen space」異常。但在jdk1.7中已經將永久代的字符串常量池移除,將其移入到Class對象末尾(也就是gc heap)。在jdk1.8將廢除永久代,引用元空間概念,使用native memory來實現,能夠經過參數:-XX:MetaspaceSize -XX:MaxMetaspaceSize來指定元空間大小。多線程

/**
 * vm args:-XX:PermSize=4m -XX:MaxPermSize=4m -Xmx6m
 * Created by zhizhanxue on 18-3-26.
 */
public class MethodAreaOOM {

    public static void main(String[] args) {
        long i=0;
        List<String> list = new ArrayList<>();
        while (true){
            list.add(String.valueOf(i++).intern());
        }
    }
}

jdk1.6的運行結果:
圖片描述併發

jdk1.7的運行結果:
圖片描述jvm

jdk1.8的運行結果:
圖片描述ide

下面咱們來驗證下元空間的例子:函數

/**
*-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m
*/
public class SpringTest {

    static class OOM implements MethodInterceptor{

        public Object getInstance(){
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOM.class);
            enhancer.setCallback(this);
            enhancer.setUseCache(false);
            return enhancer.create();
        }

        @Override
        public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
            return methodProxy.invoke(o,objects);
        }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        List<Object> list = new ArrayList<>();
        OOM oom = new OOM();
        while (true){
            list.add(oom.getInstance());
        }
    }
   }

運行結果:
圖片描述

  • 本機直接內存溢出

DirectMemory容量能夠經過參數-XX:MaxDirectMemorySize來指定,若是不指定則默認與java堆最大值(-Xmx指定同樣),代碼經過unsafe.allocateMemory()去申請堆外內存模擬本地內存溢出異常。

/**
 * -Xmx220m -XX:MaxDirectMemorySize=10m
 */
public class LocalOOM {

    public static void main(String[] args) throws IllegalAccessException {
        Field field = Unsafe.class.getDeclaredFields()[0];
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);
        while (true){
            unsafe.allocateMemory(1024*1024);
        }
    }

}

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError
    at sun.misc.Unsafe.allocateMemory(Native Method)
    at LocalOOM.main(LocalOOM.java:12)

由堆外內存致使的內存溢出,通常都是gc日誌不多,且堆dump文件不會看到明顯的異常,若是狀況和上述相似,你的項目中又使用了NIO,能夠着重檢查下是否是這方面的緣由。

下節預告

1.對象已死?(如何判斷對象是否存活)2.垃圾收集的四種基礎算法3.垃圾收集器的介紹

相關文章
相關標籤/搜索