「JVM性能優化系列」Java內存區域



「JVM性能優化系列」Java內存區域

1. Java內存區域

1.1 運行時數據區

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分為若干個不同的數據區域。主要包括:程序計數器、虛擬機棧、本地方法棧、Java堆、方法區(運 行時常量池)、直接內存。

「JVM性能優化系列」Java內存區域

程序計數器

程序計數器(Program Counter Register)是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器。在虛擬機概念模型中,字節碼解釋器工作時就是通過改變計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

程序計數器是一塊“線程私有”的內存,各個線程相互獨立存儲,互不影響。

Java虛擬機棧

Java虛擬機棧(Java Virtual Machine Stacks)描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個 棧幀(Stack Frame) ,棧幀中存儲著局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,會對應一個棧幀在虛擬機棧中入棧到出棧的過程。與程序計數器一樣,Java虛擬機棧也是線程私有的。

本地方法棧

本地方法棧(Native Method Stack)與Java虛擬機棧作用很相似,它們的區別在於虛擬機棧為虛擬機執行Java方法(即字節碼)服務,而本地方法棧則為虛擬機使用到的Native方法服務。

在虛擬機規範中對本地方法棧中使用的語言、方式和數據結構並無強制規定,因此具體的虛擬機可實現它。甚至有的虛擬機(Sun HotSpot虛擬機)直接把本地方法棧和虛擬機棧合二為一。與虛擬機一樣,本地方法棧會拋出StackOverflowError和OutOfMemoryError異常。

Java堆

對於大多數應用而言,Java堆(Heap)是Java虛擬機所管理的內存中最大的一塊,它被所有線程共享的,在虛擬機啟動時創建。此內存區域唯一的目的是存放對象實例,幾乎所有的對象實例都在這裡分配內存,且每次分配的空間是不定長的。

在Heap 中分配一定的內存來保存對象實例,實際上只是保存對象實例的屬性值,屬性的類型和對象本身的類型標記等,並不保存對象的方法(方法是指令,保存在Stack中),在Heap 中分配一定的內存保存對象實例和對象的序列化比較類似。對象實例在Heap 中分配好以後,需要在Stack中保存一個4字節的Heap 內存地址,用來定位該對象實例在Heap 中的位置,便於找到該對象實例。

Java虛擬機規範中描述道:所有的對象實例以及數組都要在堆上分配,但是隨著JIT編譯器的發展和逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都在堆上分配的定論也並不“絕對”了。

方法區

方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域。Object Class Data(類定義數據)是存儲在方法區的,此外, 常量、靜態變量、JIT編譯後的代碼 也存儲在方法區。正因為方法區所存儲的數據與堆有一種類比關係,所以它還被稱為Non-Heap。

在Java 7及之前版本,我們也習慣稱方法區它為“永久代”(Permanent Generation),更確切來說,應該是“ HotSpot使用永久代實現了方法區 ”.

運行時常量池

運行時常量池(Runtime Constant Pool)是方法區的一部分,用於存放編譯期生成的各種字面量("zdy","123"等)和符號引用。

直接內存

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域。但這部分內存也被頻繁運用,而卻可能導致OutOfMemoryError異常出現。

本機直接內存的分配不會受到Java堆大小的限制,但是既然是內存,還是會受到本機總內存(包括RAM以及SWAP區或分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置-Xmx等參數信息,但經常忽略直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操作系統的限制),從而導致動態擴展時出現OutOfMemoryError異常。

以NIO(New Input/Output)類為例,NIO引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用Native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作為這塊內存的引用進行操作。這樣能避免在Java堆和Native堆中來回複製數據,在一些場景裡顯著提高性能。

1.2 JDK 6/7/8 內存區域變化

JDK 1.6 中的內存區域如下:此時運行時常量池(Runtime Constant Pool)是方法區的一部分。

「JVM性能優化系列」Java內存區域

JDK 1.7 中的內存區域如下:此時運行時常量池放到了堆中。

「JVM性能優化系列」Java內存區域

JDK 1.8 中的內存區域如下:此時運行時常量池仍在堆中,和JDK7最大的差別就是: 元數據區取代了永久代 ,就是JDK8沒有了PermSize相關的參數配置了。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於: 元數據空間並不在虛擬機中,而是使用本地內存。

「JVM性能優化系列」Java內存區域

方法區變化

JDK1.8中取消了永久代,那麼是不是也就沒有方法區了呢?當然不是, 方法區是一個規範,規範沒變,它就一直在,只不過取代永久代的是元空間(Metaspace)而已 。

在原來的永久代劃分中, 永久代用來存放類的元數據信息、靜態常量以及常量池等 。現在

類的元信息存儲在元空間中靜態變量和常量池等併入堆中 ,相當於原來的永久代中的數據,被元空間和堆內存給瓜分了。

為什麼廢除永久代?Oracle為什麼要做這樣的改進呢?

  1. 容易內存溢出:在原來的永久代劃分中,每當一個類初次被加載的時候,它的元數據都會放到永久代中。但是永久代的內存空間也是有大小限制的,如果 加載的類太多,很有可能導致永久代內存溢出
  2. 大小無法確定: 永久代大小也不容易確定,因為這其中有很多影響因素,比如類的總數,常量池的大小和方法數量等,但是PermSize指定太小又很容易造成永久代內存溢出 ;
  3. GC回收效率低:HotSpot虛擬機的每種類型的垃圾回收器都需要特殊處理永久代中的元數據。永久代會為GC帶來不必要的複雜度,並且回收效率偏低。將元數據從永久代剝離出來,不僅實現了對元空間的無縫管理,還可以簡化Full GC以及對以後的併發隔離類元數據等方面進行優化。

1.3 Java線程的內存區域劃分

從Java線程的角度,按私有和共享劃分,可以劃分為如下圖所示:

「JVM性能優化系列」Java內存區域

1.4 堆和棧

區別

1. 功能不同

棧:以棧幀的方式存儲方法調用的過程,並存儲方法調用過程中基本數據類型的變量(int、short、long、byte、float、double、boolean、char等)以及對象的引用變量,其內存分配在棧上,變量出了作用域就會自動釋放;

堆:堆內存用來存儲Java中的對象。無論是成員變量,局部變量,還是類變量,它們指向的對象都存儲在堆內存中;

2. 線程的歸屬不一樣

棧內存歸屬於單個線程,每個線程都會有一個棧內存,其存儲的變量只能在其所屬線程中可見,即棧內存可以理解成線程的私有內存。

堆內存中的對象對所有線程可見。堆內存中的對象可以被所有線程訪問。

3. 空間大小不一樣

棧的內存要遠遠小於堆內存,棧的深度是有限制的,可能發生StackOverFlowError問題。

Java棧定義的默認大小是1M。

更多關於堆和棧的區別,如下圖所示:

「JVM性能優化系列」Java內存區域

方法的出入棧

當在執行函數時,會把方法打包成棧幀,一個棧幀至少要包含局部變量表、操作數棧和棧數據區。

「JVM性能優化系列」Java內存區域

棧上分配

虛擬機提供的一種優化技術,基本思想是,對於線程私有的對象,將它打散分配在棧上,而不分配在堆上。好處是對象跟著方法調用自行銷燬,不需要進行垃圾回收,可以提高性能。

棧上分配需要的技術基礎,逃逸分析。逃逸分析的目的是判斷對象的作用域是否會逃逸出方法體。 注意,任何可以在多個線程之間共享的對象,一定都屬於逃逸對象。

下面舉例對逃逸分析進行講解:

  • User類型的對象u就沒有逃逸出方法test
<code>public void test(int x,inty ){String x = “”;User u = ….….. }/<code>
  • User類型的對象u就逃逸出方法test
<code>public  User test(int x,inty ){String x = “”;User u = ….….. return u;}/<code>

JVM中如何啟用棧上分配:

對棧上分配發生影響的參數就是三個,-server、-XX:+DoEscapeAnalysis和-XX:+EliminateAllocations,任何一個發生變化都不會發生棧上分配,因為啟用逃逸分析和標量替換默認是打開的,所以,一般情況下,JVM的參數只用-server就可以有棧上替換的效果。

以下對三個參數進行詳細分析:

-server: JVM運行的模式之一, server模式才能進行逃逸分析, JVM運行的模式還有mix/client

-XX:+DoEscapeAnalysis:啟用逃逸分析(默認打開)

-XX:+EliminateAllocations:標量替換(默認打開),打開後JVM會嘗試在棧上分配未逃逸的對象。

棧上分配會大大加快實例對象的生成和銷燬速度。

1.5 虛擬機中的對象

對象的創建方法

Java類的創建方法大致有如下4種方法:

  • new關鍵字:這應該是我們最常見和最常用最簡單的創建對象的方式。
  • 使用newInstance()方法:這裡包括Class類的newInstance()方法和Constructor類的newInstance()方法(前者其實也是調用的後者)。
  • 使用clone()方法:要使用clone()方法我們必須實現實現Cloneable接口,用clone()方法創建對象並不會調用任何構造函數。即我們所說的淺拷貝。
  • 反序列化:要實現反序列化我們需要讓我們的類實現Serializable接口。當我們序列化和反序列化一個對象,JVM會給我們創建一個單獨的對象,在反序列化時,JVM創建對象並不會調用任何構造函數。即我們所說的深拷貝。

不管使用哪種方法,對象的創建過程都分為以下5個步驟:

「JVM性能優化系列」Java內存區域

1. 類加載檢查

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

2. 分配內存

在類加載檢查通過後,虛擬機就將為新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,為對象分配空間的任務具體便等同於從Java堆中劃出一塊大小確定的內存空間,可以分如下兩種情況討論:

  • Java堆中內存絕對規整

所有用過的內存都被放在一邊,空閒的內存被放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內存就僅僅是把那個指針向空閒空間那邊挪動一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”(Bump The Pointer)。

  • Java堆中的內存不規整

已被使用的內存和空閒的內存相互交錯,那就沒有辦法簡單的進行指針碰撞了,虛擬機就必須維護一個列表,記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱為“空閒列表”(Free List)。

選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配算法是指針碰撞,而使用CMS這種基於Mark-Sweep算法的收集器時(CMS收集器可以通過UseCMSCompactAtFullCollection或CMSFullGCsBeforeCompaction來整理內存),就通常採用空閒列表.

除如何劃分可用空間之外,由於對象創建在虛擬機中是非常頻繁的行為,即使是僅僅修改一個指針所指向的位置,在併發情況下也並非線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存。解決這個問題有如下兩個方案:

  • 對分配內存空間的動作進行同步

實際上虛擬機是採用CAS配上失敗重試的方式保證更新操作的原子性。

  • 把內存分配的動作按照線程劃分在不同的空間之中進行

即每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩衝(TLAB ,Thread Local Allocation Buffer),哪個線程要分配內存,就在哪個線程的TLAB上分配,只有TLAB用完,分配新的TLAB時才需要同步鎖定。虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。

3. 初始化

內存分配完成之後,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),如果使用TLAB的話,這一個工作也可以提前至TLAB分配時進行。這步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用。

4. 設置對象頭

虛擬機要設置對象的信息(如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息)並存放在對象的對象頭(Object Header)中。根據虛擬機當前的運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。

5. 執行 方法

在上面工作都完成之後,在虛擬機的視角來看,一個新的對象已經產生了。但是在Java程序的視角看來,對象創建才剛剛開始—— 方法還沒有執行,所有的字段都還為零值。所以一般來說(由字節碼中是否跟隨有invokespecial指令所決定),new指令之後會接著執行 方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象才算完全產生出來。

對象的內存佈局

HotSpot虛擬機中,對象在內存中存儲的佈局可以分為三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

1. 對象頭

HotSpot虛擬機的對象頭包括兩部分信息:

  • 對象自身的運行時數據 “Mark Word” : 如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等.

這部分數據的長度在32位和64位的虛擬機(暫不考慮開啟壓縮指針的場景)中分別為32個和64個Bits,官方稱它為“Mark Word”。考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。例如在32位的HotSpot虛擬機中對象未被鎖定的狀態下,Mark Word的32個Bits空間中的25Bits用於存儲對象哈希碼(HashCode),4Bits用於存儲對象分代年齡,2Bits用於存儲鎖標誌位,1Bit固定為0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容如下圖所示:

「JVM性能優化系列」Java內存區域

  • 類型指針 : 類型指針即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說查找對象的元數據信息並不一定要經過對象本身,這點我們在下一節討論。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因為虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中無法確定數組的大小。

2. 實例數據

實例數據是對象真正存儲的有效信息,也既是我們在程序代碼裡面所定義的各種類型的字段內容,無論是從父類繼承下來的,還是在子類中定義的都需要記錄起來。

這部分的存儲順序會受到虛擬機分配策略參數(FieldsAllocationStyle)和字段在Java源碼中定義順序的影響。HotSpot虛擬機默認的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的字段總是被分配到一起。在滿足這個前提條件的情況下,在父類中定義的變量會出現在子類之前。如果CompactFields參數值為true(默認為true),那子類之中較窄的變量也可能會插入到父類變量的空隙之中。

3. 對齊填充

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

對象的訪問定位

Java程序需要通過棧上的對象引用(reference)數據(存儲在棧上的局部變量表中)來操作堆上的具體對象。由於reference類型在Java虛擬機規範裡面也只規定了是一個指向對象的引用,並沒有定義這個引用的具體實現,對象訪問方式也是取決於虛擬機實現而定的。主流的訪問方式有使用句柄和直接指針兩種。

「JVM性能優化系列」Java內存區域

1. 使用句柄訪問

如果使用句柄訪問的話,Java堆中將會劃分出一塊內存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據的各自的具體地址信息。

2. 使用直接指針訪問

如果使用直接指針訪問的話,Java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址。

兩種方式的對比:

  • 句柄

使用句柄訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要被修改。

  • 直接指針

使用直接指針來訪問最大的好處就是速度更快,它節省了一次指針定位的時間開銷,由於對象訪問的在Java中非常頻繁,因此這類開銷積小成多也是一項 非常可觀的執行成本。從上一部分講解的對象內存佈局可以看出,HotSpot是使用直接指針進行對象訪問的。

1.6 堆參數設置和內存溢出實戰

Java堆溢出

可以使用 -Xms(堆的最小值) 和 -Xmx(堆的最大值) 參數進行堆大小的配置:

下面的例子中,通過參數 -Xms5m -Xmx5m -XX:+PrintGC 設置堆的大小為5M,程序中不斷的往list中添加Object,最後造成堆溢出。

<code>public class OOM {        public static void main(String[] args) {                List<object> list = new LinkedList<>();        int i=0;        while(true) {            i++;            if(i%10000==0) System.out.println("i="+i);            list.add(new Object());        }    }}/<object>/<code>

注意到堆溢出時,可能發生兩種error,

  1. 出現java.lang.OutOfMemoryError: GC overhead limit exceeded 一般是(某個循環裡可能性最大)在不停的分配對象,但是分配的太多,把堆撐爆了。
  2. 出現java.lang.OutOfMemoryError: Java heap space一般是分配了巨型對象

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

前面介紹到,jdk7及以前,通過永久代實現了方法區。jdk8及以後,移除了永久代,採用元數據區,所以兩者的參數配置不一樣。

jdk1.7及以前: -XX:PermSize ; -XX:MaxPermSize
jdk1.8以後: -XX:MetaspaceSize ; -XX:MaxMetaspaceSize

此時採用和上述相同的例子,在jdk11中,通過參數 -XX:MaxMetaspaceSize=3M 設置元數據區的大小最大為3M。

<code>public class OOM {        public static void main(String[] args) {                List<object> list = new LinkedList<>();        int i=0;        while(true) {            i++;            if(i%10000==0) System.out.println("i="+i);            list.add(new Object());        }    }}/<object>/<code>

程序啟動後初始化失敗,提示元數據區過小。

<code>Error occurred during initialization of VMMaxMetaspaceSize is too small./<code>

虛擬機棧和本地方法棧溢出

Java棧的默認大小為1M,可以通過–Xss調整大小。

下面的例子中,通過 -Xss256k 設置棧的大小為256k,程序啟動後不久發生棧溢出java.lang.StackOverflowError。

<code>public class StackOOM {        private int stackLength = 1;    private void diGui(int x,String y) {        stackLength++;        diGui(x,y);    }        public static void main(String[] args) {        StackOOM oom = new StackOOM();        try {            oom.diGui(12,"Way2backend.tech");        } catch (Throwable e) {            System.out.println("stackLength = "+oom.stackLength);            e.printStackTrace();        }    }}/<code>

java.lang.StackOverflowError 一般的方法調用是很難出現的,如果出現了要考慮是否有無限遞歸。

虛擬機棧帶給我們的啟示:方法的執行因為要打包成棧楨,所以天生要比實現同樣功能的循環慢,所以樹的遍歷算法中:遞歸和非遞歸(循環來實現)都有存在的意義。遞歸代碼簡潔,非遞歸代碼複雜但是速度較快。

本地直接內存溢出

直接內存不是虛擬機運行時數據區的一部分,也不是java虛擬機規範中定義的內存區域;如果使用了NIO,這塊區域會被頻繁使用,在java堆內可以directByteBuffer對象直接引用並操作;

這塊內存不受java堆大小限制,但受本機總內存的限制,可以通過 -XX:MaxDirectMemorySize來設置(默認與堆內存最大值一樣),所以也會出現OOM異常。

下面的例子中,通過參數 -XX:MaxDirectMemorySize=10M 設置直接內存的大小為10M,但是程序中卻嘗試分配14M的直接內存,導致程序啟動後拋出直接內存OutOfMemoryError的錯誤。

<code>public class DirectMem {    public static void main(String[] args) {        ByteBuffer b = ByteBuffer.allocateDirect(1024*1024*14);    }}/<code>

最後

覺得此文不錯的大佬們可以多多關注或者幫忙轉發分享一下哦,感謝!!!!

「JVM性能優化系列」Java內存區域


分享到:


相關文章: