1. 程式人生 > >深入理解JVM虛擬機器1:JVM記憶體的結構與永久代的消失

深入理解JVM虛擬機器1:JVM記憶體的結構與永久代的消失

所有的Java開發人員可能會遇到這樣的困惑?我該為堆記憶體設定多大空間呢?OutOfMemoryError的異常到底涉及到執行時資料的哪塊區域?該怎麼解決呢?其實如果你經常解決伺服器效能問題,那麼這些問題就會變的非常常見,瞭解JVM記憶體也是為了伺服器出現效能問題的時候可以快速的瞭解那塊的記憶體區域出現問題,以便於快速的解決生產故障。


先看一張圖,這張圖能很清晰的說明JVM記憶體結構佈局。

JVM記憶體結構主要有三大塊:堆記憶體、方法區。堆記憶體是JVM中最大的一塊由年輕代和老年代組成,而年輕代記憶體又被分成三部分,Eden空間、From Survivor空間、To Survivor空間

,預設情況下年輕代按照8:1:1的比例來分配;

 方法區儲存類資訊、常量、靜態變數等資料,是執行緒共享的區域,為與Java堆區分,方法區還有一個別名Non-Heap(非堆);棧又分為java虛擬機器棧和本地方法棧主要用於方法的執行。

在通過一張圖來了解如何通過引數來控制各區域的記憶體大小。

控制引數

       -Xms設定堆的最小空間大小。

  • -Xmx設定堆的最大空間大小。
  • -XX:NewSize設定新生代最小空間大小。
  • -XX:MaxNewSize設定新生代最大空間大小。
  • -XX:PermSize設定永久代最小空間大小。
  • -XX:MaxPermSize設定永久代最大空間大小。
  • -Xss設定每個執行緒的堆疊大小。

沒有直接設定老年代的引數,但是可以設定堆空間大小和新生代空間大小兩個引數來間接控制。 

老年代空間大小=堆空間大小-年輕代大空間大小。

從更高的一個維度再次來看JVM和系統呼叫之間的關係

 

方法區和對是所有執行緒共享的記憶體區域;而java棧、本地方法棧和程式設計師計數器是執行是執行緒私有的記憶體區域。

下面我們詳細介紹每個區域的作用。

Java堆(Heap) 

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

Java堆是垃圾收集器管理的主要區域,因此很多時候也被稱做“GC堆”。如果從記憶體回收的角度看,由於現在收集器基本都是採用的分代收集演算法,所以Java堆中還可以細分為:新生代和老年代再細緻一點的有Eden空間From Survivor空間To Survivor空間等。

根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,既可以實現成固定大小的,也可以是可擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過-Xmx和-Xms控制)。如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError異常。

方法區(Method Area)

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊常量靜態變數即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的應該是與Java堆區分開來。對於習慣在HotSpot虛擬機器上開發和部署程式的開發者來說,很多人願意把方法區稱為“永久代”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot虛擬機器的設計團隊選擇把GC分代收集擴充套件至方法區,或者說使用永久代來實現方法區而已。Java虛擬機器規範對這個區域的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收“成績”比較難以令人滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收確實是有必要的。根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。方法區有時被稱為持久代(PermGen)。


 

所有的物件在例項化後的整個執行週期內,都被存放在堆記憶體中。堆記憶體又被劃分成不同的部分:伊甸區(Eden),倖存者區域(Survivor Sapce),老年代(Old Generation Space)。

方法的執行都是伴隨著執行緒的。原始型別的本地變數以及引用都存放線上程棧中。而引用關聯的物件比如String,都存在在堆中。為了更好的理解上面這段話,我們可以看一個例子:


import java.text.SimpleDateFormat;

import java.util.Date;

import org.apache.log4j.Logger;

 

public class HelloWorld {

    private static Logger LOGGER = Logger.getLogger(HelloWorld.class.getName());

    public void sayHello(String message) {

        SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.YYYY");

        String today = formatter.format(new Date());

        LOGGER.info(today + ": " + message);

    }

}

這段程式的資料在記憶體中的存放如下:

 

通過JConsole工具可以檢視執行中的Java程式(比如Eclipse)的一些資訊:堆記憶體的分配,執行緒的數量以及載入的類的個數; 

 

程式計數器(Program Counter Register)

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它的作用可以看做是當前執行緒所執行的位元組碼的行號指示器。在虛擬機器的概念模型裡(僅是概念模型,各種虛擬機器可能會通過一些更高效的方式去實現),位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間的計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是Natvie方法,這個計數器值則為空(Undefined)。

此記憶體區域是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域。


JVM棧(JVM Stacks)

與程式計數器一樣,Java虛擬機器棧(Java Virtual Machine Stacks)也是執行緒私有的,它的生命週期與執行緒相同虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法被執行的時候都會同時建立一個棧幀(Stack Frame)用於儲存區域性變量表、操作棧、動態連結、方法出口等資訊。每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

區域性變量表存放了編譯期可知的各種基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference型別,它不等同於物件本身,根據不同的虛擬機器實現,它可能是一個指向物件起始地址的引用指標,也可能指向一個代表物件的控制代碼或者其他與此物件相關的位置)和returnAddress型別(指向了一條位元組碼指令的地址)。

其中64位長度的long和double型別的資料會佔用2個區域性變數空間(Slot),其餘的資料型別只佔用1個。區域性變量表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小。

在Java虛擬機器規範中,對這個區域規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果虛擬機器棧可以動態擴充套件(當前大部分的Java虛擬機器都可動態擴充套件,只不過Java虛擬機器規範中也允許固定長度的虛擬機器棧),當擴充套件時無法申請到足夠的記憶體時會丟擲OutOfMemoryError異常。

本地方法棧(Native Method Stacks)

本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其區別不過是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的Native方法服務。虛擬機器規範中對本地方法棧中的方法使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。甚至有的虛擬機器(譬如Sun HotSpot虛擬機器)直接就把本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError和OutOfMemoryError異常。
 

哪兒的OutOfMemoryError

對記憶體結構清晰的認識同樣可以幫助理解不同OutOfMemoryErrors:

Exception in thread “main”: java.lang.OutOfMemoryError: Java heap space
原因:物件不能被分配到堆記憶體中

Exception in thread “main”: java.lang.OutOfMemoryError: PermGen space

原因:類或者方法不能被載入到持久代。它可能出現在一個程式載入很多類的時候,比如引用了很多第三方的庫;

Exception in thread “main”: java.lang.OutOfMemoryError: Requested array size exceeds VM limit

原因:建立的陣列大於堆記憶體的空間。

Exception in thread “main”: java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?

原因:分配本地分配失敗。JNI、本地庫或者Java虛擬機器都會從本地堆中分配記憶體空間。

Exception in thread “main”: java.lang.OutOfMemoryError: <reason> <stack trace>(Native method)

原因:同樣是本地方法記憶體分配失敗,只不過是JNI或者本地方法或者Java虛擬機發現。

1.背景

2.為什麼廢棄永久代(PermGen)

3.深入理解元空間(Metaspace)

4.總結

一、背景

1.1 永久代(PermGen)在哪裡?

根據,hotspot jvm結構如下(虛擬機器棧和本地方法棧合一起了):

上圖引自網路,但有個問題:方法區和heap堆都是執行緒共享的記憶體區域。

關於方法區和永久代:

在HotSpot JVM中,這次討論的永久代,就是上圖的方法區(JVM規範中稱為方法區)。《Java虛擬機器規範》只是規定了有方法區這麼個概念和它的作用,並沒有規定如何去實現它。在其他JVM上不存在永久代。

1.2 JDK8永久代的廢棄。

JDK8 永久代變化如下圖:

1.新生代:Eden+From Survivor+To Survivor。

2.老年代:OldGen。

3.永久代(方法區的實現) : PermGen----->替換為Metaspace(本地記憶體中)

二、為什麼廢棄永久代(PermGen)

2.1 官方說明

參照JEP122:http://openjdk.java.net/jeps/122,原文擷取:

Motivation

This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.


即:移除永久代是為融合HotSpot JVM與 JRockit VM而做出的努力,因為JRockit沒有永久代,不需要配置永久代。

2.2 現實使用中易出問題

由於永久代記憶體經常不夠用或發生記憶體洩露,爆出異常java.lang.OutOfMemoryError: PermGen

其實在JDK7時就已經逐步把永久代的內容移動到其他區域了,比如移動到native區,移動到堆區等,而JDK8則是則是廢除了永久代,改用元資料。

三、深入理解元空間(Metaspace)

3.1元空間的記憶體大小

元空間是方法區的在HotSpot jvm 中的實現,方法區主要用於儲存類的資訊、常量池、方法資料、方法程式碼等。方法區邏輯上屬於堆的一部分,但是為了與堆進行區分,通常又叫“非堆”。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。,理論上取決於32位/64位系統可虛擬的記憶體大小。可見也不是無限制的,需要配置引數。


3.2常用配置引數

1.MetaspaceSize

初始化的Metaspace大小,控制元空間發生GC的閾值。GC後,動態增加或降低MetaspaceSize。在預設情況下,這個值大小根據不同的平臺在12M到20M浮動。使用Java -XX:+PrintFlagsInitial命令檢視本機的初始化引數

2.MaxMetaspaceSize

限制Metaspace增長的上限,防止因為某些情況導致Metaspace無限的使用本地記憶體,影響到其他程式。在本機上該引數的預設值為4294967295B(大約4096MB)。

3.MinMetaspaceFreeRatio

當進行過Metaspace GC之後,會計算當前Metaspace的空閒空間比,如果空閒比小於這個引數(即實際非空閒佔比過大,記憶體不夠用),那麼虛擬機器將增長Metaspace的大小。預設值為40,也就是40%。設定該引數可以控制Metaspace的增長的速度,太小的值會導致Metaspace增長的緩慢,Metaspace的使用逐漸趨於飽和,可能會影響之後類的載入。而太大的值會導致Metaspace增長的過快,浪費記憶體。


4.MaxMetasaceFreeRatio

當進行過Metaspace GC之後, 會計算當前Metaspace的空閒空間比,如果空閒比大於這個引數,那麼虛擬機器會釋放Metaspace的部分空間。預設值為70,也就是70%。

5.MaxMetaspaceExpansion

Metaspace增長時的最大幅度。在本機上該引數的預設值為5452592B(大約為5MB)。

6.MinMetaspaceExpansion

Metaspace增長時的最小幅度。在本機上該引數的預設值為340784B(大約330KB為)。

3.3測試並追蹤元空間大小

3.3.1.測試字串常量

public class StringOomMock {
    static String  base = "string";
    
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        for (int i=0;i< Integer.MAX_VALUE;i++){
            String str = base + base;
            base = str;
            list.add(str.intern());
        }
    }
}

在eclipse中選中類--》run configuration-->java application--》new 引數如下:

 

由於設定了最大記憶體20M,很快就溢位,如下圖:

 

可見在jdk8中:

1.字串常量由永久代轉移到堆中。

2.持久代已不存在,PermSize MaxPermSize引數已移除。(看圖中最後兩行)

3.3.2.測試元空間溢位

根據定義,我們以載入類來測試元空間溢位,程式碼如下:

package jdk8;

import java.io.File;
import java.lang.management.ClassLoadingMXBean;
import java.lang.management.ManagementFactory;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;

/**
 * 
 * @ClassName:OOMTest
 * @Description:模擬類載入溢位(元空間oom)
 * @author diandian.zhang
 * @date 2017年4月27日上午9:45:40
 */
public class OOMTest {  
    public static void main(String[] args) {  
        try {  
            //準備url  
            URL url = new File("D:/58workplace/11study/src/main/java/jdk8").toURI().toURL();  
            URL[] urls = {url};  
            //獲取有關型別載入的JMX介面  
            ClassLoadingMXBean loadingBean = ManagementFactory.getClassLoadingMXBean();  
            //用於快取類載入器  
            List<ClassLoader> classLoaders = new ArrayList<ClassLoader>();  
            while (true) {  
                //載入型別並快取類載入器例項  
                ClassLoader classLoader = new URLClassLoader(urls);  
                classLoaders.add(classLoader);  
                classLoader.loadClass("ClassA");  
                //顯示數量資訊(共載入過的型別數目,當前還有效的型別數目,已經被解除安裝的型別數目)  
                System.out.println("total: " + loadingBean.getTotalLoadedClassCount());  
                System.out.println("active: " + loadingBean.getLoadedClassCount());  
                System.out.println("unloaded: " + loadingBean.getUnloadedClassCount());  
            }  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

為了快速溢位,設定引數:-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=80m,執行結果如下:

 

上圖證實了,我們的JDK8中類載入(方法區的功能)已經不在永久代PerGem中了,而是Metaspace中。可以配合JVisualVM來看,更直觀一些。

四、總結

本文講解了元空間(Metaspace)的由來和本質,常用配置,以及監控測試。元空間的大小是動態變更的,但不是無限大的,最好也時常關注一下大小,以免影響伺服器記憶體。