Java記憶體區域與記憶體溢位異常(JVM學習系列1)

振宇要低調發表於2018-07-30

  相對於C、C++等語言來說,Java語言一個很美好的特性就是自動記憶體管理機制。C語言等在申請堆記憶體時,需要malloc記憶體,用完還有手動進行free操作,若程式設計師忘記回收記憶體,那這塊記憶體就只能在程式退出時,由作業系統來釋放了。而Java程式設計師(初級)則基本上不需要對記憶體分配、回收做過多的關注,完全由Java虛擬機器來管理。不過,一旦出現記憶體洩漏或者溢位,如果不理解JVM管理記憶體的機制,又如何排除錯誤、調優系統呢?

1.    執行時區域

1.1程式計數器

  Java程式最終編譯成位元組碼執行在JVM之上,程式計數器可以看做時當前執行緒執行的位元組碼的行號指示器。位元組碼直譯器在工作的時候就是通過這個計數器來選擇下一條要執行的位元組碼指令,分支、迴圈、異常處理等都需要依賴該計數器。

  另外,在多執行緒的場景下,一個CPU(或者一個核)在一個確定的時刻,只能執行一個執行緒的一條位元組碼指令,多執行緒的實現是由CUP在不同執行緒間切換來完成的。而CPU線上程間切換所依賴的也是程式計數器(CPU跳來跳去要確定調到某個執行緒的某一行上,從這一點可以看出,程式計數器是執行緒私有的,執行緒間互不影響)。

  注意,在JVM規範中,程式計數器不會發生OOM(就記個數,能用多少記憶體)。

1.2虛擬機器棧

  執行緒私有,與執行緒生命週期相同。

  棧描述的是Java執行方法的記憶體模型。執行緒是程式創造的(例如伺服器的每個請求可以看做是一個執行緒,舉例ThreadLocal),由多個方法間的呼叫組成,每個方法在執行時會建立一個棧幀,棧幀記憶體儲的是區域性變數表,運算元棧,動態連結,方法出口等資訊。每個方法從呼叫直到執行完成,就是一個棧幀入棧到出棧的過程。

  區域性變數表是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。型別:boolean、byte、char、short、int、float、reference(物件起始地址的指標或者控制程式碼)、returnAddress(指向了一條位元組碼指令的地址)八種。在編譯時,每個方法所需的區域性變數表大小就固定下來了。(疑問:若在迴圈體中定義變數,JVM如何取得的區域性變數表的大小? 在內層迴圈中定義變數到底會不會存在重複分配的問題,這涉及到編譯器的優化,不過主流編譯器(如vs和gcc)這一塊優化都比較好,不會反覆分配變數。棧中的空間在編譯這個程式碼的時候大小就確定下來了,執行這個方法時空間就已經分配好了,不要想當然的以為宣告一次就要分配一次空間,那是c語言,java可以重用這些超出作用域的空間。)

  虛擬機器棧這塊區域規定了兩種異常:StackOverflowError,執行緒請求的棧深度超過一定量(比如遞迴層級過多,大概幾千(與分配給jvm的記憶體有關)就報錯);OutOfMemoryError,無法申請到足夠的記憶體。

1.3本地方法棧

  本地方法棧與虛擬機器方法棧作用相似,區別為虛擬機器棧為虛擬機器執行Java方法服務,本地方法棧為虛擬機器使用的native方法服務。很多虛擬機器在實現時已經將二者合二為一。拋錯相同。

  Java native 方法:一個Native Method就是一個java呼叫非java程式碼的介面。大多數應用場景為java需要與一些底層系統如作業系統、某些硬體交換資訊時的情況。

1.4Java堆

  所有執行緒共享,用於存放所有執行緒產生的物件例項(還有陣列)。

  堆是垃圾收集器管理的主要區域。為了更好的回收或者分配記憶體,堆可能會被分為多個區域,例如分代收集演算法的垃圾回收器會將堆分為新生代和老年代(當然還可以繼續細分:Eden、From Survivor、To Survivor等)。但不管如何劃分,每個區間儲存的內容是不變的,都是物件例項。

  另外,堆在記憶體中並不是物理連續的,只要邏輯連續即可。當向堆申請記憶體(例項化物件),而堆中找不到這麼大的空間時)會丟擲OutOfMemoryError(最新虛擬機器都可動態擴充套件,但擴無可擴時也會拋錯)。

1.5方法區

  執行緒共享,方法區記憶體儲的是已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。一些虛擬機器實現上,將方法區作為堆上的“永久代”,意味著垃圾回收器可以向管理堆一樣來管理這塊記憶體(但本質上,方法區和永久代是不等價的,會產生一些問題,官方已經不推薦這麼使用。例如,String.intern()方法在不同的虛擬上會因為該機制而表現不同)。

  當然,方法區也確實有一些“永久”的意思,進入到該區域的資料,例如類資訊,基本上就不會被解除安裝了。但其實也會被解除安裝,只是解除安裝的條件相當的苛刻,導致很多垃圾回收器在這部分起到的作用並不大

  當方法區無法滿足記憶體分配要求時,將丟擲OutOfMemoryError異常。

1.6執行時常量池

  是上邊1.5裡講的方法區中的一部分。Class檔案在編譯期會生成各種字面量和符號引用,這部分內容將在類載入後,放入到方法區的常量池中存放。另外,並非只有預置入Class檔案中的常量池的部分才能進入方法區的執行時常量池,執行期間也可能將新的常量放入池中,例如String類的intern()方法。

  執行時常量池屬於方法區的一部分,所以當申請不到記憶體的時候,會丟擲OutOfMemoryError異常。

1.7直接記憶體

  一些native函式庫可以直接分配堆外記憶體,例如NIO,它可以通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作。由於直接記憶體是受機器總記憶體限制的,當申請不到記憶體的時候,同樣會丟擲OutOfMemoryError異常。

2.    物件的建立

2.1物件的建立

  建立物件的幾種方式:1)使用new 關鍵字;2)使用反射的newInstance()方法,newInstance方法通過呼叫無參的建構函式建立物件;3)clone,呼叫clone時,jvm會建立一個新的物件,將前面物件的內容全部拷貝進去。用clone方法建立物件並不會呼叫任何建構函式;4)反序列化,jvm會給我們建立一個單獨的物件。在反序列化時,jvm建立物件並不會呼叫任何建構函式。 我們在著重談一談new時都發生了什麼。

  當jvm遇到new指令時,第一步要做的是去常量池中找一找,看是否能找到對應類的符號引用,並且檢查該符號引用代表的類是否被載入、解析、初始化過(檢查類是否被載入)。

  類載入檢查通過之後,接下來就是分配記憶體,物件所需記憶體大小在類載入完成之後就完全確定了,所以分配物件的工作其實就是把一塊確定的記憶體從Java堆中劃出來。

  堆記憶體是規整的時候——用過的在一邊、沒用過的在另一邊,中間用一個指標標記,記憶體分配就是指標向沒用過的方向挪動一下,這種方式叫做指標碰撞。這個時候若多個執行緒一起申請記憶體,就會衝突。對應的解決方法:1)加同步,採用CAS加失敗重試策略;2)為每個執行緒預分配一小塊記憶體(Thread Local Allocation Buffer,TLAB),哪個執行緒需要記憶體,就在自己的TLAB上進行分配,而只在建立執行緒為執行緒分配TLAB是用同步鎖定。

  堆記憶體不是規整的時候——用過和沒用過的亂糟糟的放在一起,記憶體分配就需要記住哪些地方被分配了,哪些地方還是空閒的,這種分配方式叫做分配列表。在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表。

  對記憶體是否規整,是由使用的垃圾回收機制是否帶有壓縮整理功能決定的。

  記憶體分配完成之後,虛擬機器需要設定一下物件的資料:非物件頭部分,會被初始化為零值,這個操作保證了物件的例項欄位在Java程式碼中可以不賦初始值就直接使用;物件頭部分,進行必要的設定,例如:物件類的後設資料資訊、雜湊碼、GC分代資訊、鎖資訊等。

  一個新的物件產生了,後續就在java語言層面,按照程式設計師的想法,執行init函式了。

2.2物件的記憶體佈局

  一個物件在記憶體中由三部分組成:物件頭,例項資料,對齊填充。

  物件頭由兩部分組成:一部分儲存執行時資料:雜湊碼、GC分代、鎖狀態等等;另一部分是指向類後設資料的指標,說明該物件是由哪個類例項化來的。

  例項資料存放的是物件真正儲存的有效資訊,也就是程式設計師自己定義的各種型別欄位內容。需要注意的時,為了節省記憶體,相同型別的欄位總是被放在一起存放的,而且子類較窄的變數有可能會插入到父類變數的空隙中。

  由於物件大小必須是8位元組的整數倍,所以對齊填充,就是湊整用的,可有可無。

2.3物件的訪問定位

  兩種定位方式:控制程式碼、直接指標。貼兩個圖,分別說一下他們的優缺點。

 

  控制程式碼訪問,堆內劃分出一塊記憶體來作為控制程式碼池,物件引用儲存的是控制程式碼地址,控制程式碼中包含了物件的真實地址資訊。有點:物件被移動時,無需通知引用這個它的物件,只需要更改控制程式碼池就行了;缺點:增加了一層定址,會慢一些。

 

  直接指標訪問:物件引用的就是真實的地址資訊。優點:快,節省一次指標定位時間;缺點:物件被移動時,引用它的物件也要跟著修改。

3.    關於記憶體溢位

3.1棧溢位

  不斷遞迴,超過棧允許的最大深度時,就可以觸發StackOverflowError。看一個棧深度超限引發StackOverflowError的示例,程式碼及錯誤資訊如下:

 1 public class Stack_StackOverflowError {
 2     private Integer stackLength = 1;
 3 
 4     public void stackLoop() {
 5         stackLength++;
 6         stackLoop();
 7     }
 8 
 9     public static void main(String[] args) {
10         Stack_StackOverflowError a = new Stack_StackOverflowError();
11         try {
12             a.stackLoop();
13         } catch (Throwable e) {
14             System.out.println("stack length: " + a.stackLength);
15             throw e;
16         }
17     }
18 }
Exception in thread "main" stack length: 9651(本人機器64位,12G記憶體,未對jvm系統做任何引數修改)
java.lang.StackOverflowError
    at java.lang.Number.<init>(Number.java:55)
    at java.lang.Integer.<init>(Integer.java:849)
    at java.lang.Integer.valueOf(Integer.java:832)
    at com.star.ott.scriptsTranslation.api.business.test.Stack_StackOverflowError.stackLoop(Stack_StackOverflowError.java:10)
    at com.star.ott.scriptsTranslation.api.business.test.Stack_StackOverflowError.stackLoop(Stack_StackOverflowError.java:11)
    at com.star.ott.scriptsTranslation.api.business.test.Stack_StackOverflowError.stackLoop(Stack_StackOverflowError.java:11)

 3.2堆溢位

  堆是用來存放物件示例的,只要不斷建立物件,並且保證垃圾回收器無法回收這些物件,就能產生堆的OutOfMemoryError異常。看一個不斷建立物件引發OutOfMemoryError的示例,程式碼及錯誤資訊如下:首先將idea中的堆大小限制為20M。

 

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

/**
 * Created by laizy on 2018/7/30.
 */
public class Heap_OutOfMemoryError {
    static class OOMTestObject {
    }

    public static void main(String[] args) {
        //保證建立出來的物件不被回收
        List<Heap_OutOfMemoryError.OOMTestObject> list = new ArrayList<Heap_OutOfMemoryError.OOMTestObject>();
        //不斷建立物件
        while (true) {
            list.add(new Heap_OutOfMemoryError.OOMTestObject());
            System.out.println(list.size());
        }
    }
}
540213
540214
540215
540216
540217(向佇列中插入這麼多物件之後,崩了)
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 com.star.ott.aaa.Heap_OutOfMemoryError.main(Heap_OutOfMemoryError.java:20)

Process finished with exit code 1

 

  另外,在jdk1.8中,String常量池已經從方法區中的執行時常量池分離到堆中了(劃重點),也就是說不斷的建立String常量,也能夠將堆撐爆,程式碼及錯誤資訊如下:


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

/**
* Created by laizy on 2018/7/31.
*/
// -Xms20m -Xmx20m
public class Heap_StringConstantOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("0");
int i = 1;
try {
while (true) {
list.add(list.get(i - 1) + String.valueOf(i++).intern());
if (list.size() % 100 == 0) {
System.out.println(list.size());
}
}
} catch (Throwable e) {
System.out.print(list.size());
throw e;
}
}
}

1900
2000
2100
2200
2201

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  at java.util.Arrays.copyOf(Arrays.java:3332)
  at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
  at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
  at java.lang.StringBuilder.append(StringBuilder.java:136)
  at com.star.ott.aaa.Heap_StringConstantOOM.main(Heap_StringConstantOOM.java:15)

3.3方法區溢位

  執行時常量池屬於方法區的一部分,首先我們通過將常量池撐爆的方式,製造方法區溢位。首先還是限制jvm的引數,設定方法區大小為5m,不限制的話,程式得跑到地老天荒。參照3.2中設定jvm的方式設定方法區大小。在jdk8之前,方法區放到了永久代中,對應引數為:-XX: PermSize=5m -XX:MaxPermSize=5m;在jdk8以後,方法區放到的後設資料裡,對應引數為:-XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m。程式碼及錯誤資訊如下:

好吧,讓你失望了,我的環境是jdk8,在jdk8中我做不到(捂臉),希望大家指點一下,如何在jdk8中實現常量池的溢位。
另外,在之前的jdk中,要實現常量池的溢位是通過不斷建立String來實現的,對,就是上邊3.2中的用String.intern()撐爆堆的那種做法。

 

  接下來我們通過CGLib技術,不斷建立動態類,將方法區撐爆。程式碼及異常如下:

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

/**
 * Created by laizy on 2018/7/31.
 */
// -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
public class RunTime_ObjectOOM {

    public static void main(String[] args) {
        int i = 0;
        while (true) {
            i++;
            System.out.println(i);
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method,
                                        Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {
    }
}
328
329
330
331(331次迴圈之後,方法區崩了)
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:348)
    at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:386)
    at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:219)
    at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
    at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
    at com.star.ott.aaa.RunTime_ObjectOOM.main(RunTime_ObjectOOM.java:28)

 

  最後,棧中的OOM、直接記憶體OOM並未做驗證。

相關文章