Java虛擬機(一):Java內存區域與內存溢出異常

1、Java內存區域

      由圖可知,java運行時內存區域主要劃分爲6個區域,分別爲程序計數器、Java虛擬機盞、本地方法盞、Java堆、方法區(內含運行時常量池)、和直接內存區(分配空間時直接在本地堆中分配)。
(1)、程序計數器   
        線程隔離的數據區:每個線程一個獨立的計數器,線程之間互不影響。
        可以看成當前線程執行字節碼的行號指示器。如果線程正在執行java代碼,其值是字節碼指令地址,如果是本地Native方法,計數器爲空。
(2)、虛擬機棧
        線程隔離的數據區:它的生命週期和線程相同

        描述的是java方法執行的內存模型:每個方法在執行的同時多會創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈表、方法出口等信息。

每一個方法從調用直至完成的過程,就對應着一個棧幀在虛擬機中入棧到出棧的過程。

       局部變量表存放了編譯期可知的各種基本數據類型和對象引用,所需內存空間在編譯期確定。

       操作數棧是爲運行過程中、字節碼指令執行服務。

       參數設置:-Xoss參數設置本地方法棧大小(對於HotSpot無效),-Xss參數設置棧容量 例: -Xss128k

(3)、本地方法棧

       線程隔離的數據區:桶虛擬機棧作用類似。

       主要用於虛擬機執行Java本地(Native)方法。Sun HotSpot虛擬機把本地方法棧和虛擬機棧合二爲一

(4)、Java堆
       線程共享的數據區:存放運行時對象,由所有線程共享訪問。
      Java堆是垃圾回收的主要區域,是Java虛擬機所管理的內存中最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啓動的時候創建,此內存區域的唯一目的是存放對象實例,幾乎所有的對象實例都在這裏分配內存。所有的對象實例和數組都在堆上分配。Java堆細分爲新生代和老年代不管怎樣,劃分的目的都是爲了更好的回收內存,或者更快地分配內存。Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可。

       參數設置:-Xms參數設置堆最小值,-Xmx參數設置堆最大值 ,若-Xms=-Xmx,則可避免堆自動擴展。
(5)、方法區
      線程共享的數據區:

      方法區它用於儲存已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據,除了和Java堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。這個區域的內存回收目標主要是針對常量池的回收和對類型的卸載。

      運行時常量池(Runtime Constant Pool)是方法區的一部分Class文件中除了有類的版本、字段、方法、接口等信息外,還有一項是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。
      參數設置:-XX:MaxPermSize設置上限 ,-XX:PermSize設置最小值。
(6)、直接內存

         直接內存並不是虛擬機運行時數據區的一部分。在NIO中,引入了一種基於通道和緩衝區的I/O方式,它可以使用native函數直接分配堆外內存,然後通過一個存儲在java堆中的DirectByteBuffer對象作爲這塊內存的引用進行操作。

      參數設置:-XX:MaxDirectMemorySize設置最大值,默認與java堆最大值一樣。

2、對象創建與內存佈局

   (1)、對象的創建       
       1).檢查 

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

        2) .分配內存 

        接下來將爲新生對象分配內存,爲對象分配內存空間的任務等同於把一塊確定的大小的內存從Java堆中劃分出來。假設Java堆中內存是絕對規整的,所有用過的內存放在一遍,空閒的內存放在另一邊,中間放着一個指針作爲分界點的指示器,那所分配內存就僅僅是把那個指針指向空閒空間那邊挪動一段與對象大小相等的距離,這個分配方式叫做“指針碰撞”。如果Java堆中的內存並不是規整的,已使用的內存和空閒的內存相互交錯,那就沒辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式成爲“空閒列表”。選擇那種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

        3).設置

       內存分配完後,虛擬機將內存設置爲零,然後設置對象頭,具體包括設置對象類型元數據信息、對象哈希嗎,對象分帶年齡,以及鎖相關的信息。

        4). Init

     執行new指令之後會接着執行Init方法,進行構造函數初始化,按照程序員要求完成對象狀態設置,這樣一個對象纔算產生出來。

 (2)內存佈局
       1).  在HotSpot虛擬機中,對象在內存中儲存的佈局可以分爲3塊區域:對象頭、實例數據和對齊填充。

       對象頭包括兩部分:

          a) 儲存對象自身的運行時數據,如哈希碼、GC分帶年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳。

          b) 另一部分是指類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是那個類的實例。

      2).  對象的訪問定位

  •  使用句柄訪問

      Java堆中將會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址。

      優勢 :reference中存儲的是穩點的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不需要修改

  • 使用直接指針訪問

      Java堆對象的佈局就必須考慮如何訪問類型數據的相關信息,而refreence中存儲的直接就是對象的地址。

       優勢:速度更快,節省了一次指針定位的時間開銷,由於對象的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常可觀的執行成本

3、內存溢出異常
 (1)java堆溢出
       Java堆用於存儲對象實例,只要不斷的創建對象,並且保證GCRoots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那麼在數量到達最大堆的容量限制後就會產生內存溢出異常。
       如果是內存泄漏,可進一步通過工具查看泄漏對象到GC Roots的引用鏈。於是就能找到泄露對象是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收它們的。掌握了泄漏對象的類型信息及GC Roots引用鏈的信息,就可以比較準確地定位出泄漏代碼的位置。如果不存在泄露,換句話說,就是內存中的對象確實都還必須存活着,那就應當檢查虛擬機的堆參數(-Xmx與-Xms),與機器物理內存對比看是否還可以調大,從代碼上檢查是否存在某些對象生命週期過長、持有狀態時間過長的情況,嘗試減少程序運行期的內存消耗。

實例: 虛擬機參數:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

代碼:
package com.xl.jvm;
import java.util.ArrayList;
import java.util.List;
/**
 * 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_pid7348.hprof ...
Heap dump file created [27860758 bytes in 0.164 secs]

(2)、虛擬機盞和本地棧溢出
       對於HotSpot來說,雖然-Xoss參數(設置本地方法棧大小)存在,但實際上是無效的,棧容量只由-Xss參數設定。關於虛擬機棧和本地方法棧,在Java虛擬機規範中描述了兩種異常:
          如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError。

         如果虛擬機在擴展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常。

         在單線程下,無論由於棧幀太大還是虛擬機棧容量太小,當內存無法分配的時候,虛擬機拋出的都是StackOverflowError異常。

         如果是多線程導致的內存溢出,與棧空間是否足夠大並不存在任何聯繫,這個時候每個線程的棧分配的內存越大,反而越容易產生內存溢出異常。解決的時候是在不能減少線程數或更換64爲的虛擬機的情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。

實例:虛擬機參數:-Xss128k
代碼:
  
結果:
Exception in thread "main" java.lang.StackOverflowError
 at com.xl.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:7)
 at com.xl.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:8)
stack length:991
 package com.xl.jvm;

public class JavaVMStackSOF {
 private int stackLength = 1;

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

 public static void main(String[] args) throws Throwable {
  JavaVMStackSOF oom = new JavaVMStackSOF();
  try {
   oom.stackLeak();
  } catch (Throwable e) {
   System.out.println("stack length:" + oom.stackLength);
   throw e;
  }
 }
}

(3)方法區和運行時常量池溢出

       String.intern()是一個Native方法,它的作用是:如果字符串常量池中已經包含一個等於此String對象的字符串,則返回代表池中這個字符串的String對象;否則,將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。由於常量池分配在永久代中,可以通過-XX:PermSize和-XX:MaxPermSize限制方法區大小,從而間接限制其中常量池的容量。

       Intern():JDK1.6 intern方法會把首次遇到的字符串實例複製到永久代,返回的也是永久代中這個字符串實例的引用,而由StringBuilder創建的字符串實例在Java堆上,所以必然不是一個引用。JDK1.7 intern()方法的實現不會再複製實例,只是在常量池中記錄首次出現的實例引用,因此intern()返回的引用和由StringBuilder創建的那個字符串實例是同一個。

實例:虛擬機參數:-XX:PermSize=10M -XX:MaxPermSize=10M
代碼:
package com.xl.jvm;

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

public class RuntimeConstantPoolOOM {
 public static void main(String[] args) {
  // 使用List保持着常量池引用,避免Full GC回收常量池行爲
  List<String> list = new ArrayList<String>();
  // 10MB的PermSize在integer範圍內足夠產生OOM了
  int i = 0;
  while (true) {
   list.add(String.valueOf(i++).intern());
  }
 }
}


參考資料:《深入理解java虛擬機》




發佈了44 篇原創文章 · 獲贊 5 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章