【JVM學習】——本地方法棧、堆

1、本地方法棧

1.1 本地方法接口

(1)什麼是本地方法

簡單地講,一個Native Method就是一個Java調用非Java代碼的接囗。該方法的實現由非Java語言實現,好比C。這個特徵並不是Java所特有,不少其它的編程語言都有這一機制,好比在C++中,你能夠用extern "C" 告知C++編譯器去調用一個C的函數。java

"A native method is a Java method whose implementation is provided by non-java code."算法

在定義一個native method時,並不提供實現體(有些像定義一個Java interface),由於其實現體是由非java語言在外面實現的。編程

例如java.lang.Object中的public final native Class<?> getClass()方法;又如java.lang.Thread中的private native void start0()方法... ...數組

本地接口的做用是融合不一樣的編程語言爲Java所用,它的初衷是融合C/C++程序。緩存

Tips:標識符native能夠與其它java標識符連用,abstract除外。

(2)爲何使用本地方法

與Java環境的交互安全

有時Java應用須要與Java外面的環境交互,這是本地方法存在的主要緣由。你能夠想一想Java須要與一些底層系統,如操做系統或某些硬件交換信息時的狀況。本地方法正是這樣一種交流機制:它爲咱們提供了一個很是簡潔的接口,並且咱們無需去了解Java應用以外的繁瑣的細節。bash

與操做系統的交互數據結構

JVM支持着Java語言自己和運行時庫,它是Java程序賴以生存的平臺,它由一個解釋器(解釋字節碼)和一些鏈接到本地代碼的庫組成。然而無論怎樣,它畢竟不是一個完整的系統,它常常依賴於一底層系統的支持。這些底層系統經常是強大的操做系統。經過使用本地方法,咱們得以用Java實現了jre的與底層系統的交互,甚至JVM的一些部分就是用C寫的。還有,若是咱們要使用一些Java語言自己沒有提供封裝的操做系統的特性時,咱們也須要使用本地方法。多線程

Sun's Java併發

Sun的解釋器是用C實現的,這使得它能像一些普通的C同樣與外部交互。jre大部分是用Java實現的,它也經過一些本地方法與外界交互。例如:類java.lang.Thread的setpriority()方法是用Java實現的,可是它實現調用的是該類裏的本地方法setpriority()。這個本地方法是用C實現的,並被植入JVM內部,在Windows 95的平臺上,這個本地方法最終將調用Win32 setpriority() ApI。這是一個本地方法的具體實現由JVM直接提供,更多的狀況是本地方法由外部的動態連接庫(external dynamic link library)提供,而後被JVw調用。

現狀

目前這類方法使用的愈來愈少了,除非是與硬件有關的應用,好比經過Java程序驅動打印機或者Java系統管理生產設備,在企業級應用中已經比較少見。由於如今的異構領域間的通訊很發達,好比可使用Socket通訊,也可使用Web Service等等,很少作介紹。

1.2 本地方法棧

Java虛擬機棧於管理Java方法的調用,而本地方法棧(Native Method Stack)用於管理本地方法的調用

本地方法棧,也是線程私有的。

容許被實現成固定或者是可動態擴展的內存大小。(在內存溢出方面是相同的)

  • 若是線程請求分配的棧容量超過本地方法棧容許的最大容量,Java虛擬機將會拋出一個stackoverflowError 異常。
  • 若是本地方法棧能夠動態擴展,而且在嘗試擴展的時候沒法申請到足夠的內存,或者在建立新的線程時沒有足夠的內存去建立對應的本地方法棧,那麼Java虛擬機將會拋出一個outofMemoryError異常。

本地方法是使用C語言實現的。

它的具體作法是Native Method Stack中登記native方法,在Execution Engine 執行時加載本地方法庫。

當某個線程調用一個本地方法時,它就進入了一個全新的而且再也不受虛擬機限制的世界。它和虛擬機擁有一樣的權限。

  • 本地方法能夠經過本地方法接口來訪問虛擬機內部的運行時數據區
  • 它甚至能夠直接使用本地處理器中的寄存器
  • 直接從本地內存的堆中分配任意數量的內存。

並非全部的JVM都支持本地方法。由於Java虛擬機規範並無明確要求本地方法棧的使用語言、具體實現方式、數據結構等。若是JVM產品不打算支持native方法,也能夠無需實現本地方法棧。

在Hotspot JVM中,直接將本地方法棧和虛擬機棧合二爲一。

2、堆核心概述

2.1 堆內存細分

一個進程只有一個JVM,一個JVM實例只存在一個堆內存。可是進程可包含多個線程,他們是共享同一堆空間的。

Java堆區(Heap)在JVM啓動的時候即被建立時就肯定了空間大小,是JVM管理的最大一塊內存空間。《Java虛擬機規範》規定,堆能夠處於物理上不連續的內存空間中,但在邏輯上被視爲連續的。全部的對象實例以及數組都應當在運行時分配在堆上。更準確說法是——「幾乎」全部的對象實例都在這裏分配內存。

  • 由於還有一些對象是在棧上分配的。數組和對象可能永遠不會存儲在棧上,由於棧幀中保存引用,這個引用指向對象或者數組在堆中的位置。

Java 7及以前堆內存邏輯上分爲三部分:新生區+養老區+永久區

新生區 養老區 永久區
Young Generation Space Tenure generation space Permanent Space
Young/New(又被劃分爲Eden區和Survivor區) Old/Tenure Perm

Java 8及以後堆內存邏輯上分爲三部分:新生區+養老區+元空間

新生區 養老區 元空間
Young Generation Space Tenure generation space Meta Space
Young/New(又被劃分爲Eden區和Survivor區) Old/Tenure Meta

其中,新生區=新生代=年輕代;養老區=老年區=老年代;永久區=永久代。

2.2 設置堆內存大小

Java堆區用於存儲Java對象實例,那麼堆的大小在JVM啓動時就已經設定好了,你們能夠經過選項"-Xmx"和"-Xms"來進行設置。例如:

-Xms10m:最小堆內存 -Xmx10m:最大堆內存
  • -Xms"用於表示堆區的起始內存,等價於-XX:InitialHeapSize
  • 「-Xmx"則用於表示堆區的最大內存,等價於-XX:MaxHeapSize

一旦堆區中的內存大小超過「-Xmx"所指定的最大內存時,將會拋出OutofMemoryError異常(俗稱OOM異常)。

一般會將-Xms和-Xmx兩個參數配置相同的值,其目的是爲了可以在Java垃圾回收機制清理完堆區後不須要從新分隔計算堆區的大小,從而提升性能

默認狀況:

  • 初始內存大小:物理電腦內存大小 / 64
  • 最大內存大小:物理電腦內存大小 / 4
/**
 * -Xms 用來設置堆空間(年輕代+老年代)的初始內存大小
 *  -X:是jvm運行參數
 *  ms:memory start
 * -Xmx:用來設置堆空間(年輕代+老年代)的最大內存大小
 */
public class HeapSpaceInitial {
    public static void main(String[] args) {
        // 返回Java虛擬機中的堆內存總量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        // 返回Java虛擬機試圖使用的最大堆內存
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
        System.out.println("-Xms:" + initialMemory + "M");
        System.out.println("-Xmx:" + maxMemory + "M");
    }
}

輸出結果:

-Xms:243M
-Xmx:3591M
系統內存大小:15.1875G
系統內存大小:14.02734375G

查看堆內存的內存分配

方法一:CMD敲入命令jps——>jstat -gc 進程id

方法二:配置VM option時加上-XX:+PrintGCDetails

2.4 年輕代與老年代

從生命週期角度可將存儲在JVM中的Java對象能夠被劃分爲兩類:

  • 一類是生命週期較短的瞬時對象,這類對象的建立和消亡都很是迅速(生命週期短的,及時回收便可)
  • 另一類對象的生命週期卻很是長,在某些極端的狀況下還可以與JVM的生命週期保持一致

根據存儲對象的不一樣,Java堆區便劃分爲年輕代(YoungGen)和老年代(OldGen)。其中年輕代又能夠劃分爲Eden空間、Survivor0空間和Survivor1空間(有時也叫作from區、to區)。

配置新生代與老年代堆結構的佔比:

  • 默認-XX:NewRatio=2,表示新生代佔佔整個堆的1/3,老年代佔2/3。
  • 能夠修改-XX:NewRatio=4,表示新生代佔整個堆的1/5,老年代佔4/5。
Tips:生命週期長的對象偏多,就能夠經過調整 老年代的大小,來進行調優。

在新生代中,Eden空間和另外兩個survivor空間所佔的比例默認是8:1:1。

-XX:-UseAdaptiveSizePolicy:關閉自適應的內存分配策略。能夠經過選項-XX:SurvivorRatio調整這個空間比例。(實際比例不是8:1:1,如要肯定是8:1:1須要指定-XX:SurvivorRatio=8

幾乎全部的Java對象都是在Eden區被new出來的。絕大部分的Java對象的銷燬都在新生代進行了。(有些大的對象在Eden區沒法存儲時候,將直接進入老年代)

可使用選項"-Xmn"設置新生代最大內存大小。

2.5 堆對象分配過程

(1)概念

爲新對象分配內存是一件很是嚴謹和複雜的任務,JVM的設計者們不只須要考慮內存如何分配、在哪裏分配等問題,而且因爲內存分配算法與內存回收算法密切相關,因此還須要考慮GC執行完內存回收後是否會在內存空間中產生內存碎片。

  • new的對象先放Eden區。
  • 當Eden區的空間填滿時,程序還需建立對象,JVM的垃圾回收器將對Eden區進行垃圾回收(MinorGC,又稱YGC),將Eden區中的再也不被其餘對象所引用的對象進行銷燬,再加載新的對象放到Eden區。
  • 而後將Eden區中的倖存的對象移動到From區(Survivor From區)。
  • 若是再次觸發垃圾回收,此時Eden區和From區倖存下來的對象就會放到To區(Survivor To區)。

    • 此過程後From區對象都放到To區,故From區變To區,原To區變From區。
  • 若是再次經歷垃圾回收,此時Eden區對象會從新放回From區,接着再去To區。
  • 啥時候能去養老區呢?當Survivor中的對象的年齡達到15的時候,將會觸發一次 Promotion晉升的操做,對象晉升至養老區。能夠設置次數:-Xx:MaxTenuringThreshold= N默認是15次
  • 當養老區內存不足時,再次觸發垃圾回收(Major GC),進行養老區的內存清理。
  • 若養老區執行了Major GC以後,發現依然沒法進行對象的保存,就會產生OOM異常。

特別注意,在Eden區滿了的時候,纔會觸發MinorGC;而倖存者區滿了後,不會觸發MinorGC操做。若是Survivor區滿了後,將會觸發一些特殊的規則,也就是可能直接晉升老年代。

(2)對象分配的特殊狀況

代碼演示對象分配過程

public class HeapInstanceTest {
    byte[] buffer = new byte[new Random().nextInt(1024 * 200)];

    public static void main(String[] args) throws InterruptedException {
        ArrayList<HeapInstanceTest> list = new ArrayList<>();
        while (true) {
            list.add(new HeapInstanceTest());
            Thread.sleep(10);
        }
    }
}

而後設置JVM參數

-Xms600m -Xmx600m

執行上面代碼,經過VisualGC進行動態化查看。最終,老年代和新生代都滿了,出現OOM錯誤:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.kai.jvm.HeapInstanceTest.<init>(HeapInstanceTest.java:12)
    at com.kai.jvm.HeapInstanceTest.main(HeapInstanceTest.java:17)

(3)經常使用的調優工具

  • JDK命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • Visual VM(實時監控 推薦)
  • Jprofiler(推薦)
  • Java Flight Recorder(實時監控)
  • GCViewer
  • GCEasy

總結

  • 針對倖存者S0,S1區:複製以後有交換,誰空誰是To區
  • 關於垃圾回收:頻繁在新生區收集,不多在老年代收集,幾乎再也不永久代和元空間進行收集
  • 新生代採用複製算法的目的:是爲了減小內碎片。

2.6 Minor GC,Major GC、Full GC

  • Minor GC:新生代的GC
  • Major GC:老年代的GC
  • Full GC:整堆收集,收集整個Java堆和方法區的垃圾收集
Major GC 和 Full GC出現STW的時間,是Minor GC的10倍以上

JVM在進行GC時,並不是每次都對上面三個內存區域(新生代,老生代;方法區)一塊兒回收的,大部分時候回收的都是指新生代。針對Hotspot VM的實現,它裏面的GC按照回收區域又分爲兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(Full GC)

部分收集:不是完整收集整個Java堆的垃圾收集。其中又分爲:

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC):只是老年代的圾收集。

    • 目前,只有CMS GC會有單獨收集老年代的行爲。
    • 注意,不少時候Major GC會和Full GC混淆使用,須要具體分辨是老年代回收仍是整堆回收
  • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。

    • 目前,只有G1 GC會有這種行爲

整堆收集:收集整個java堆和方法區的垃圾收集。

(1)Minor GC

當年輕代空間不足時,就會觸發Minor GC,這裏的年輕代指的是Eden滿,Survivor滿不會引起GC。(每次Minor GC會清理年輕代的內存。)

由於Java對象大多都具有 朝生夕滅 的特性,因此Minor GC很是頻繁,通常回收速度也比較快。這必定義既清晰又易於理解。

Minor GC會引起STW,暫停其它用戶的線程,等垃圾回收結束,用戶線程才恢復運行

STW:stop the word

(2)Major GC

指發生在老年代的GC,對象從老年代消失時,咱們說 「Major GC」 或 「Full GC」 發生了。

出現了MajorGc,常常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裏就有直接進行Major GC的策略選擇過程)

  • 也就是在老年代空間不足時,會先嚐試觸發Minor GC。若是以後空間還不足,則觸發Major GC。

Major GC的速度通常會比Minor GC慢10倍以上,STW的時間更長,若是Major GC後,內存還不足,就報OOM了。

(3)Full GC

觸發Fu11 GC執行的狀況有以下五種:

  • 調用System.gc()時,系統建議執行Fu11 GC,可是沒必要然執行。
  • 老年代空間不足
  • 方法區空間不足
  • 經過Minor GC後進入老年代的平均大小大於老年代的可用內存
  • 由Eden區、survivor space0(From Space)區向survivor space1(To Space)區複製時,對象大小大於To Space可用內存,則把該對象轉存到老年代,且老年代的可用內存小於該對象大小。

說明:Full GC 是開發或調優中儘可能要避免的。這樣暫時時間會短一些。

GC 舉例

不斷的建立字符串是存放在堆區元空間中:

public class GCTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "Hello World!";
            while(true) {
                list.add(a);
                a = a + a;
                i++;
            }
        }catch (Exception e) {
            e.getStackTrace();
        }
    }
}

設置JVM啓動參數:

-Xms10m -Xmx10m -XX:+PrintGCDetails

打印出日誌:

[GC (Allocation Failure) [PSYoungGen: 1996K->480K(2560K)] 1996K->872K(9728K), 0.0010677 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2460K->472K(2560K)] 2852K->2304K(9728K), 0.0007179 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2061K->440K(2560K)] 3893K->3040K(9728K), 0.0007400 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1322K->472K(2560K)] 6994K->6152K(9728K), 0.0014277 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 472K->0K(2560K)] [ParOldGen: 5680K->3698K(7168K)] 6152K->3698K(9728K), [Metaspace: 3209K->3209K(1056768K)], 0.0039549 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1609K->0K(2560K)] [ParOldGen: 6770K->6750K(7168K)] 8380K->6750K(9728K), [Metaspace: 3262K->3262K(1056768K)], 0.0048638 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 6750K->6750K(9728K), 0.0003074 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 6750K->6732K(7168K)] 6750K->6732K(9728K), [Metaspace: 3262K->3262K(1056768K)], 0.0050359 secs] [Times: user=0.11 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 2560K, used 114K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 5% used [0x00000000ffd00000,0x00000000ffd1cac8,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 7168K, used 6732K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 93% used [0x00000000ff600000,0x00000000ffc93090,0x00000000ffd00000)
 Metaspace       used 3321K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOfRange(Arrays.java:3664)
    at java.lang.String.<init>(String.java:207)
    at java.lang.StringBuilder.toString(StringBuilder.java:407)
    at com.kai.jvm.GCTest.main(GCTest.java:19)

觸發OOM的時候,必定是進行了一次Full GC,由於只有在老年代空間不足時候,纔會爆出OOM異常。

3、堆空間分代

3.1 堆空間分代思想

爲何要把Java堆分代?不分代就不能正常工做了嗎?經研究,不一樣對象的生命週期不一樣。70%-99%的對象是臨時對象。

新生代:有Eden、兩塊大小相同的survivor(又稱爲from/to,s0/s1)構成,to總爲空。
老年代:存放新生代中經歷屢次GC仍然存活的對象。

其實不分代徹底能夠,分代的惟一理由就是優化GC性能。若是沒有分代,那全部的對象都在一塊,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些對象沒用,這樣就會對堆的全部區域進行掃描。而不少對象都是朝生夕死的,若是分代的話,把新建立的對象放到某一地方,當GC的時候先把這塊存儲「朝生夕死」對象的區域進行回收,這樣就會騰出很大的空間出來。

3.2 內存分配策略

若是對象在Eden出生並通過第一次Minor GC後仍然存活,而且能被Survivor容納的話,將被移動到survivor空間中,並將對象年齡設爲1。對象在survivor區中每熬過一次MinorGC,年齡就增長1歲,當它的年齡增長到必定程度(默認爲15歲,其實每一個JVM、每一個GC都有所不一樣)時,就會被晉升到老年代

對象晉升老年代的年齡閥值,能夠經過選項-XX:MaxTenuringThreshold來設置

針對不一樣年齡段的對象分配原則以下所示:

  • 優先分配到Eden

    • 開發中比較長的字符串或者數組,會直接存在老年代,可是由於新建立的對象 都是 朝生夕死的,因此這個大對象可能也很快被回收,可是由於老年代觸發Major GC的次數比 Minor GC要更少,所以可能回收起來就會比較慢
  • 大對象直接分配到老年代

    • 儘可能避免程序中出現過多的大對象
  • 長期存活的對象分配到老年代
  • 動態對象年齡判斷

    • 若是survivor區中相同年齡的全部對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象能夠直接進入老年代,無須等到MaxTenuringThreshold 中要求的年齡。

空間分配擔保:-XX:HandlePromotionFailure

  • 也就是通過Minor GC後,全部的對象都存活,由於Survivor比較小,因此就須要將Survivor沒法容納的對象,存放到老年代中。

3.3 對象分配內存:TLAB

問題:堆空間都是共享的麼?

不必定,由於還有TLAB這個概念,在堆中劃分出一塊區域,爲每一個線程所獨佔。

爲何有TLAB?

TLAB:Thread Local Allocation Buffer,也就是爲每一個線程單獨分配了一個緩衝區。

堆區是線程共享區域,任何線程均可以訪問到堆區中的共享數據。因爲對象實例的建立在JVM中很是頻繁,所以在併發環境下從堆區中劃份內存空間是線程不安全的。爲避免多個線程操做同一地址,須要使用加鎖等機制,進而影響分配速度。

什麼是TLAB

從內存模型而不是垃圾收集的角度,對Eden區域繼續進行劃分,JVM爲每一個線程分配了一個私有緩存區域,它包含在Eden空間內。

多線程同時分配內存時,使用TLAB能夠避免一系列的非線程安全問題,同時還可以提高內存分配的吞吐量,所以咱們能夠將這種內存分配方式稱爲快速分配策略。

全部OpenJDK衍生出來的JVM都提供了TLAB的設計。

儘管不是全部的對象實例都可以在TLAB中成功分配內存,但JVM確實是將TLAB做爲內存分配的首選

在程序中,開發人員能夠經過選項-XX:UseTLAB設置是否開啓TLAB空間。默認狀況下,TLAB空間的內存很是小,僅佔有整個Eden空間的1%,固然咱們能夠經過選項-XX:TLABWasteTargetPercent設置TLAB空間所佔用Eden空間的百分比大小。

一旦對象在TLAB空間分配內存失敗時,JVM就會嘗試着經過使用加鎖機制確保數據操做的原子性,從而直接在Eden空間中分配內存。

TLAB分配過程

對象首先是經過TLAB開闢空間,若是不能放入,那麼須要經過Eden來進行分配。

3.4 堆空間的參數設置

  • -XX:+PrintFlagsInitial:查看全部的參數的默認初始值
  • -XX:+PrintFlagsFinal:查看全部的參數的最終值(可能會存在修改,再也不是初始值)
  • -Xms:初始堆空間內存(默認爲物理內存的1/64)
  • -Xmx:最大堆空間內存(默認爲物理內存的1/4)
  • -Xmn:設置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代與老年代在堆結構的佔比
  • -XX:SurvivorRatio:設置新生代中Eden和S0/S1空間的比例
  • -XX:MaxTenuringThreshold:設置新生代垃圾的最大年齡
  • -XX:+PrintGCDetails:輸出詳細的GC處理日誌

    • 打印GC簡要信息:①-XX:+PrintGC - verbose:gc
  • -XX:HandlePromotionFalilure:是否設置空間分配擔保

在發生Minor GC以前,虛擬機會檢查老年代最大可用的連續空間是否大於新生代全部對象的總空間

  • 若是大於,則這次Minor GC是安全的。
  • 若是小於,則虛擬機會查看-XX:HandlePromotionFailure設置值是否允擔保失敗。

    • 若是HandlePromotionFailure=true,那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代的對象的平均大小
    • 若是大於,則嘗試進行一次Minor GC,但此次Minor GC依然是有風險的;
    • 若是小於,則改成進行一次Full GC。
    • 若是HandlePromotionFailure=false,則改成進行一次Full GC。

在JDK6 Update24以後,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,觀察openJDK中的源碼變化,雖然源碼中還定義了HandlePromotionFailure參數,可是在代碼中已經不會再使用它。JDK6 Update 24以後的規則變爲只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,不然將進行FullGC。

3.5 逃逸分析

(1)概述

在《深刻理解Java虛擬機》中關於Java堆內存有這樣一段描述:

隨着JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會致使一些微妙的變化,全部的對象都分配到堆上也漸漸變得不那麼「絕對」了。

在Java虛擬機中,對象是在Java堆中分配內存的,這是一個廣泛的常識。可是,有一種特殊狀況,那就是若是通過逃逸分析(Escape Analysis)後發現,一個對象並無逃逸出方法的話,那麼就可能被優化成棧上分配。這樣就無需在堆上分配內存,也無須進行垃圾回收了。這也是最多見的堆外存儲技術。

此外,前面提到的基於openJDk深度定製的TaoBaovm,其中創新的GCIH(GC invisible heap)技術實現off-heap,將生命週期較長的Java對象從heap中移至heap外,而且GC不能管理GCIH內部的Java對象,以此達到下降GC的回收頻率和提高GC的回收效率的目的。

如何將堆上的對象分配到棧,須要使用逃逸分析手段。

這是一種能夠有效減小Java程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。經過逃逸分析,Java Hotspot編譯器可以分析出一個新的對象的引用的使用範圍從而決定是否要將這個對象分配到堆上。逃逸分析的基本行爲就是分析對象動態做用域:

  • 當一個對象在方法中被定義後,對象只在方法內部使用,則認爲沒有發生逃逸。
  • 當一個對象在方法中被定義後,它被外部方法所引用,則認爲發生逃逸。例如做爲調用參數傳遞到其餘地方中。

逃逸分析舉例

沒有發生逃逸的對象,則能夠分配到棧上,隨着方法執行的結束,棧空間就被移除。

public void my_method() {
    V v = new V();
    // use v
    // ....
    v = null;
}

針對下面的代碼

public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

若是想要StringBuffer sb不發生逃逸,能夠這樣寫

public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

完整的逃逸分析代碼舉例

public class EscapeAnalysis {

    public EscapeAnalysis obj;

    /**
     * 方法返回EscapeAnalysis對象,發生逃逸
     * @return
     */
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis():obj;
    }

    /**
     * 爲成員屬性賦值,發生逃逸
     */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }

    /**
     * 對象的做用於僅在當前方法中有效,沒有發生逃逸
     */
    public void useEscapeAnalysis() {
        EscapeAnalysis e = new EscapeAnalysis();
    }

    /**
     * 引用成員變量的值,發生逃逸
     */
    public void useEscapeAnalysis2() {
        EscapeAnalysis e = getInstance();
        // getInstance().XXX  發生逃逸
    }
}

在JDK 1.7 版本以後,HotSpot中默認就已經開啓了逃逸分析

若是使用的是較早的版本,則能夠經過:

  • 選項-XX:+DoEscapeAnalysis顯式開啓逃逸分析
  • 經過選項-xx:+PrintEscapeAnalysis查看逃逸分析的篩選結果

結論:開發中能使用局部變量的,就不要使用在方法外定義

使用逃逸分析,編譯器能夠對代碼作以下優化:

  • 棧上分配:將堆分配轉化爲棧分配。若是一個對象在子程序中被分配,要使指向該對象的指針永遠不會發生逃逸,對象多是棧上分配的候選,而不是堆上分配
  • 同步省略:若是一個對象被發現只有一個線程被訪問到,那麼對於這個對象的操做能夠不考慮同步。
  • 分離對象或標量替換:有的對象可能不須要做爲一個連續的內存結構存在也能夠被訪問到,那麼對象的部分(或所有)能夠不存儲在內存,而是存儲在CPU寄存器中。

(2)棧上分配

JIT編譯器在編譯期間根據逃逸分析的結果,發現若是一個對象並無逃逸出方法的話,就可能被優化成棧上分配。分配完成後,繼續在調用棧內執行,最後線程結束,棧空間被回收,局部變量對象也被回收。這樣就無須進行垃圾回收了。

常見的棧上分配的場景:給成員變量賦值、方法返回值、實例引用傳遞

舉例

咱們經過舉例來講明 開啓逃逸分析 和 未開啓逃逸分析時候的狀況

public class StackAllocation {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花費的時間爲:" + (end - start) + " ms");

        // 爲了方便查看堆內存中對象個數,線程sleep
        Thread.sleep(10000000);
    }

    private static void alloc() {
        User user = new User();
    }
}
class User {
    private String name;
    private String age;
    private String gender;
    private String phone;
}

設置JVM參數,未開啓逃逸分析:

-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

運行結果,同時還觸發了GC操做:

花費的時間爲:366 ms

而後查看內存的狀況,發現有大量的User存儲在堆中。

咱們再開啓逃逸分析:

-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

而後查看運行時間,咱們可以發現花費的時間快速減小,同時不會發生GC操做。

花費的時間爲:5 ms

而後再看內存狀況,咱們發現只有不多的User對象,說明User發生了逃逸,由於他們存儲在棧中,隨着棧的銷燬而消失。

(3)同步省略

線程同步的代價是至關高的,同步的後果是下降併發性和性能。

在動態編譯同步塊的時候,JIT編譯器能夠藉助逃逸分析來判斷同步塊所使用的鎖對象是否只可以被一個線程訪問而沒有被髮布到其餘線程。若是沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。這樣就能大大提升併發性和性能。這個取消同步的過程就叫同步省略,也叫鎖消除

例以下面的代碼:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

代碼中對hollis這個對象加鎖,可是hollis對象的生命週期只在f()方法中,並不會被其餘線程所訪問到,因此在JIT編譯階段就會被優化優化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

咱們將其轉換成字節碼:

(4)分離對象和標量替換

標量(scalar)是指一個沒法再分解成更小的數據的數據。Java中的原始數據類型就是標量。

相對的,那些還能夠分解的數據叫作聚合量(Aggregate),Java中的對象就是聚合量,由於他能夠分解成其餘聚合量和標量。

在JIT階段,若是通過逃逸分析,發現一個對象不會被外界訪問的話,那麼通過JIT優化,就會把這個對象拆解成若干個其中包含的若干個成員變量來代替。這個過程就是標量替換

參數-XX:+EliminateAllocations開啓標量替換(默認打開),容許對象打散分配在棧上。

public static void main(String args[]) {
    alloc();
}
class Point {
    private int x;
    private int y;
}
private static void alloc() {
    Point point = new Point(1,2);
    System.out.println("point.x" + point.x + ";point.y" + point.y);
}

以上代碼,通過標量替換後,就會變成

private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}

能夠看到,Point這個聚合量通過逃逸分析後,發現他並無逃逸,就被替換成兩個聚合量了。那麼標量替換有什麼好處呢?就是能夠大大減小堆內存的佔用。由於一旦不須要建立對象了,那麼就再也不須要分配堆內存了。
標量替換爲棧上分配提供了很好的基礎。

代碼優化之標量替換

public class ScalarReplaceTest {
    public static class User {
        private int age;
        private String name;
    }

    private static void alloc() {
        User user = new User(); //未發生逃逸
        user.age = 20;
        user.name = "張三";
    }

    public static void main(String args[]) {
        long start = System.currentTimeMillis();

        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花費時間:" + (end - start) + "ms");
    }
}

上述代碼在主函數中進行了1億次alloc。調用進行對象建立,因爲User對象實例須要佔據約16字節的空間,所以累計分配空間達到將近1.5GB。若是堆空間小於這個值,就必然會發生GC。使用以下參數運行上述代碼:

-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations

這裏設置參數以下:

  • 參數-server:啓動Server模式,由於在server模式下,才能夠啓用逃逸分析。
  • 參數-XX:+DoEscapeAnalysis:啓用逃逸分析
  • 參數-Xmx10m:指定了堆空間最大爲10MB
  • 參數-XX:+PrintGC:將打印GC日誌。
  • 參數-xx:+EliminateAllocations:開啓了標量替換(默認打開),容許將對象打散分配在棧上,好比對象擁有id和name兩個字段,那麼這兩個字段將會被視爲兩個獨立的局部變量進行分配

逃逸分析的不足

關於逃逸分析的論文在1999年就已經發表了,但直到JDK1.6纔有實現,並且這項技術到現在也並非十分紅熟的。

其根本緣由就是沒法保證逃逸分析的性能消耗必定能高於他的消耗。雖然通過逃逸分析能夠作標量替換、棧上分配、和鎖消除。可是逃逸分析自身也是須要進行一系列複雜的分析的,這其實也是一個相對耗時的過程
一個極端的例子,就是通過逃逸分析以後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。

雖然這項技術並不十分紅熟,可是它也是即時編譯器優化技術中一個十分重要的手段。注意到有一些觀點,認爲經過逃逸分析,JVM會在棧上分配那些不會逃逸的對象,這在理論上是可行的,可是取決於JVM設計者的選擇。Oracle Hotspot JVM中並未這麼作,這一點在逃逸分析相關的文檔裏已經說明,因此能夠明確全部的對象實例都是建立在堆上。

目前不少書籍仍是基於JDK7之前的版本,JDK已經發生了很大變化,intern字符串的緩存和靜態變量曾經都被分配在永久代上,而永久代已經被元數據區取代。可是,intern字符串緩存和靜態變量並非被轉移到元數據區,而是直接在堆上分配,因此這一點一樣符合前面的結論:對象實例都是分配在堆上

參考

深刻理解Java虛擬機:JVM高級特性與最佳實踐(第3版)

相關文章
相關標籤/搜索