JVM內存模型及JIT運行優化

JVM內存模型定義

  • JVM不只承擔了Java字節碼的分析(JIT)和執行(Runtime),同時也內置了自動內存分配管理機制
  • 內存模型圖解
  • 堆是jvm內存中最大的一塊內存空間,該空間被全部線程共享,幾乎全部的對象和數組都被分配到了堆內存中: 堆被劃分爲新生代和老年代,新生代劃分爲Eden和Survivor區,Suvivor是由From Survivor和To Survivor組成
    • java6中,永久代在非堆內存去
    • java7中,永久代的靜態變量和運行時常量池被合併到 了堆中
    • java8中,永久代被元空間取代了,元空間存儲靜態變量和運行時常量池跟java7永久代同樣兒,都移到了堆中中
程序計數器
  • 是一塊很小的內存空間,主要用來記錄各個線程執行的字節碼地址 例如:分支,循環,跳轉,異常,線程恢復都能依賴於計數器
  • 注意: 每一個線程有一個單獨的程序計數器來記錄下一條運行的指令
方法區
  • 在HotSpot虛擬機使用永久代來實現方法區,在其餘虛擬機中不是這樣的,只是在HotSpot虛擬機中,設計人員使用了永久代實現了JVM規範的方法區
  • 方法區主要用來存放已被虛擬機加載的類相關信息 : 類信息,運行時常量池,字符串常量池(class、運行時常量池、字段、方法、代碼、JIT代碼等)
    • 類信息包括了類的版本,字段,方法,接口和父類等信息
    • JVM執行類加載步驟:加載,鏈接(驗證,準備,解析三個階段),初始化,在加載類的時候,JVM會先加載class文件,在class文件中除了有類的版本,字段,方法和接口等描述信息外,還有一項信息是常量池,用於存放編譯期間生成的各類字面量和符合引用
      • 字面量包括字符串(String a = "hello"),基本類型的常量(final修飾的變量)
      • 符號引用包括類和方法的全限定名(如String爲Java/lang/String),字段的名稱和描述符以及方法的名稱和描述符
    • 當類加載到內存中後,JVM就會將class文件常量池中的內容存放到運行時常量池中,在解析階段,JVM會把符號引用替換爲直接引用(對象的索引值)
      • 好比:類中的一個字符串常量在class文件中時,存放在class文件常量池中的
    • 在JVM加載完類後,JVM會將這個字符串常量放到運行時常量池中,並在解析階段,指定改字符串對象的索引值
    • 運行時常量池是全局共享的,多個類中共用一個運行時常量池,class文件中常量池多個相同的字符串在運行時常量池中只會存在一份
    • 方法區與堆空間相似,也是一個共享內存區,因此方法區是線程共享的,若是有兩個線程試圖都訪問方法區中的一個類信息,而這個類尚未裝入JVM中,那麼此時就只容許一個線程去加載它,另外一個線程必須等待
    • 永久代:包括靜態變量和運行時常量池,永久代的類等數據
      • Java7中將永久代的靜態變量和運行時常量池轉移到了堆中,其他部分則存儲在JVM的非堆內存中(當依然在JVM內存中)
      • Java8中將方法區中實現的永久代去掉,使用元空間替代,而且元空間的存儲位置爲本地內存(不在JVM內存中,而是直接存在內存中的),以前永久代的類的元數據存儲在了元空間,而永久代的靜態變量以及運行時常量池跟Java7同樣轉移到了堆中
      • 元空間:存儲的是類的元數據信息:關於數據的數據或者叫作用來描述數據的數據:就是最小的數據單元,元數據能夠爲數聽說明其元素或屬性(名稱,大小,數據類型等),其結構(長度,字段,數據列),或其相關數據(位於何處,如何聯繫,擁有者等)
      • 爲什麼使用元數據區替代永久代
        1. 字符串存在永久代中,容易出現性能問題和內存溢出
        2. 類及方法的信息等都比較難肯定其大小,所以對於永久代的大小指定比較困難(默認8M),大小容易出現永久代溢出,太大則容易致使老年代溢出
        3. 永久代會爲GC帶來沒必要要的複雜度,而且回收效率偏低
        4. 最重要的是Oracle想將HotSpot與JRockit(沒有永久代概念)虛擬機合二爲一
虛擬機棧
  • Java虛擬機棧是線程私有的內存空間,它跟Java線程一塊兒被建立,當建立一個線程時,會在虛擬機棧中申請一個棧幀,用來保存方法的局部變量,操做數棧,動態連接方法和返回地址等信息,並參與方法的調用和返回
  • 每一個方法的調用都是一個入棧操做,方法的返回則是棧幀的出棧操做
本地方法棧
  • 同Java虛擬機棧功能相似,Java虛擬機棧用來管理java函數調用的,本地方法棧用來管理本地方法的調用,是由C語言實現的

JIT運行時編譯(優化Java)

類編譯加載執行過程
  • Java編譯到運行過程
  1. 類編譯
    • 將.java文件編譯成.class文件(使用javac命令生成),編譯後的字節碼文件主要包括常量池和方法表集合這兩個部分
    • 常量池主要記錄的是類文件中出現的字面量以及符號引用
      • 字面常量包括字符串常量,聲明爲final的屬性以及一些基本類型的屬性
      • 符號引用包括類和接口的全限定名,類引用,方法引用以及成員變量引用(如String Str = "abc",其中str就是成員變量引用)
  2. 類加載
    • 當一個類被建立實例或者被其餘對象引用時,虛擬機在沒有加載過該類狀況下,會經過類加載器將字節碼文件加載到內存中
    • 不一樣的實現類有不一樣的類加載器加載,JDK中本地方法類通常由根加載器加載,JDK中內部實現的擴展類通常由擴展加載器實現加載,程序中的類文件則由系統加載器實現加載
    • 在類加載後,class類文件中的常量池信息以及其餘數據會被保存到JVM內存的方法區中
  3. 類連接: 驗證,準備,解析
    • 驗證: 驗證類符合Java規範和JVM規範,在保證符合規範的前提下,避免危害虛擬機安全
    • 準備: 爲類的靜態變量分配內存,初始化爲系統的初始值,對於final static修飾的常量,直接賦值爲用戶定義值,對於static修飾變量會賦值爲默認初始值
      private final static int value = 123 //賦值爲123
      private static int value = 123 //賦值爲0
      複製代碼
    • 解析: 將符號引用轉爲直接引用的過程:編譯時,Java類並不知道所引用的類的實際地址,所以只能使用符號引用來代替
      • 類結構文件的常量池中存儲了符號引用:包括類和接口的全限定名,類引用,方法引用以及成員變量引用等,若是須要使用以上類和方法,就西藥將他們轉化爲JVM能夠直接獲取的內存地址或指針,即直接引用;
  4. 類初始化
    • 類初始化是類加載的最後一個階段,初始化時,JVM首先將執行構造器方法,編譯器會將.java文件編譯成.class文件時,收集全部類初始化代碼,包括靜態變量賦值語句,靜態代碼塊,靜態方法,收集在一塊兒成爲方法
    • 初始化類的靜態變量和靜態代碼塊爲用戶自定義的值,初始化的順序和Java源碼從上到下的順序一致
    • 子類初始化時會首先調用父類的()方法,在執行子類的方法
    • JVM會保證()方法的線程安全,保證同一時間只有一個線程執行
    • JVM在初始化執行代碼時,若是實例化一個新對象,會調用方法對實例變量就行初始化,並執行對應的構造方法內的代碼
  • 思考題: 反射中Class.forName()和ClassLoader.loadClass()的區別
裝載:經過累的全限定名獲取二進制字節流,將二進制字節流轉換成方法區中的運行時數據結構,在內存中生成Java.lang.class對象; 
連接:執行下面的校驗、準備和解析步驟,其中解析步驟是能夠選擇的; 
    校驗:檢查導入類或接口的二進制數據的正確性;(文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證) 
    準備:給類的靜態變量分配並初始化存儲空間; 
    解析:將常量池中的符號引用轉成直接引用;
初始化:激活類的靜態變量的初始化Java代碼和靜態Java代碼塊,並初始化程序員設置的變量值。

Class.forName(className)方法,內部實際調用的方法是  Class.forName(className,true,classloader);
第2個boolean參數表示類是否須要初始化,  Class.forName(className)默認是須要初始化。
一旦初始化,就會觸發目標對象的 static塊代碼執行,static參數也也會被再次初始化。

ClassLoader.loadClass(className)方法,內部實際調用的方法是  ClassLoader.loadClass(className,false);
第2個 boolean參數,表示目標對象是否進行連接,false表示不進行連接,由上面介紹能夠,
不進行連接意味着不進行包括初始化等一些列步驟,那麼靜態塊和靜態對象就不會獲得執行
複製代碼
即時編譯
  • 初始化完成後,類在調用執行過程當中,執行引擎會把字節碼轉爲機器碼,而後在操做系統中才能執行在字節碼轉換爲機器碼的過程當中,虛擬機中還存在着一道編譯,即爲即時編譯
    1. 虛擬機中的字節碼是由解釋器(Interpreter)完成編譯的,當虛擬機發現某個方法或代碼塊的運行特別頻繁時,就會把這些代碼認定爲熱點代碼
    2. 爲了提升熱點代碼的執行效率,在運行時 JIT會把這些代碼編譯成與本地平臺相關的機器碼,並進行層次的優化,而後保存到內存中
  1. 即時編譯器類型
    • HotSpot虛擬機中,內置了兩個JIT,分別爲C1編譯器和C2編譯器,他們的過程不一樣
      • C1編譯器是一個簡單快速的編譯器,主要關注點在局部性的優化,適用於執行時間較短或對啓動性能有要求的程序,如GUI應用對界面啓動速度有必定要求
      • C2編譯器是爲長期運行的服務器端應用程序作性能調優的編譯器,適用於執行時間較長或對峯值性能優要求的程序,這兩種編譯器也被稱爲Client Compiler和Server Compiler
    • Java7以前,根據程序特性來選擇對應的JIT,虛擬機默認採用解釋器和其中一個編譯器配合工做
    • Java7引入了分層編譯,綜合了C1啓動性能優點和C2的峯值性能優點,經過設置參數可強制更改
    • 分層將JVM的執行狀態分爲5個層次
      • 第0層: 程序解釋執行,默認開啓性能監控功能(Profiling),若是不開啓,可觸發第二層編譯
      • 第1層: 可稱爲C1編譯,將字節碼編譯爲本地代碼,進行簡單可靠的優化,不開啓Profiling
      • **第2層:**也稱爲C1編譯,開啓Profiling,僅執行帶方法調用次數和循環回邊執行次數Profiling
      • 第3層 也稱爲C1編譯,執行全部帶Profiling的C1編譯
      • 第4層 可稱爲C2編譯,也是將字節碼編譯成本地代碼,可是會啓用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化
      • 經過 java -version 命令行可查看當前系統使用的編譯模式
        java --version -> mixed mode :混合編譯模式
        java -Xint -version -> interpreted mode : 只有解釋器編譯,關閉JIT
        java -Xcomp -version -> compiled mode: 只有JIT編譯,關閉解釋器編譯
        複製代碼
熱點探測:JVM編譯優化條件
  • HotSpot虛擬機的熱點探測是基於計數器的熱點探測,虛擬機會爲每一個方法創建計數器統計方法的執行次數,若是次數超過必定的閾值就認爲爲熱點方法
  • 虛擬機爲每一個方法準備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter) ,在肯定虛擬機運行參數的前提下,這兩個計數器都有一個肯定的閾值,當計數器超過這個閾值就會觸發JIT編譯
  • 方法調用計數器: 用於統計方法被調用的次數,默認閾值在C1模式下1500次,在C2模式下是1萬次,而在分層編譯下,將會根據當前待編譯的方法數以及編譯線程數來動態調整
  • 回邊計數器: 用於統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲回邊(Back Edge),在不開啓分層編譯的狀況下,C1默認13995,C2默認10700,在分層狀況下,將根據當前編譯的方法數以及編譯線程數來動態調整
  • 創建回邊計數器主要目的是爲了出發OSR(On StackReplacement)編譯,即棧上編譯,對於一些循環週期比較長的代碼段,當循環達到回邊計數器閾值時,JVM認爲這段是熱點代碼,JIT編譯器就會將其編譯成機器語言並緩存,並在該循環時間段內,執行緩存的機器語言

編譯優化技術

方法內聯
  • 因爲調用一個方法一般要經歷壓棧和出棧:調用方法是將程序執行順序轉移到存儲該方法的內存地址,將方法的內容執行完成後,在返回到執行該方法前的位置
  • 這樣執行要求執行前保護線程並記憶執行的地址,執行後恢復現場並按照原來保存的地址繼續執行該方法調用會纏上必定的時間和空間方面的開銷
  • 可是對於方法體代碼不大有頻繁調用的方法,這個開銷就很大了
  • 方法內聯的優化就是將目標方法的代碼複製到發起調用的方法之中,避免發生真是的方法調用,如kotlin擴展函數中的inline關鍵字
  • JVM會自動識別熱點方法,並對它們使用方法內聯進行優化,可是熱點方法並不必定會被JVM作內聯優化,若是這個方法太大將不會執行內聯操做
    • 常常執行的方法,默認狀況下,方法體大小小於325字節都會進行內聯,可設置
    • 不是常常執行的方法,默認狀況下,方法大小小於35字節纔會進行內聯,可設置
    • 咱們能夠經過配置JVM參數參看(Intellij 類上Edit configurations 中設置VM options)
      -XX:+PrintCompilation // 在控制檯打印編譯過程信息
      -XX:+UnlockDiagnosticVMOptions // 解鎖對 JVM 進行診斷的選項參數。默認是關閉的,開啓後支持一些特定參數對 JVM 進行診斷
      -XX:+PrintInlining // 將內聯方法打印出來
      複製代碼
  • 熱點方法內聯優化能夠有效提升系統性能,咱們有一下方法提升:
    • 經過設置JVM參數來減少熱點閾值或增長方法體閾值,可是須要佔用更多的內存
    • 在編程中,避免在一個方法中寫大量代碼,習慣使用小方法體
    • 儘可能使用final,private ,static關鍵字修飾方法,編碼方法由於繼承,會須要額外的類型檢查
逃逸分析
  • 逃逸分析基本行爲就是分析對象動態做用域:當一個對象在方法中被定義後,他可能被外部所引用,例如做爲參數傳遞到其餘地方中,稱爲方法逃逸
    public static StringBuffer craeteStringBuffer(String s1, String s2) { //sb對象逃逸了
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
    }
    
    public static String createStringBuffer(String s1, String s2) { //sb對象沒有逃逸
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
    複製代碼
  • 使用逃逸分析,編譯器能夠作一下優化:(Jdk 1.7開始默認開啓)
    1. 同步省略: 若是一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操做就能夠不考慮同步
    2. 將堆分配轉化爲棧分配:若是一個對象在子程序中被分配,要使其指向改對象的指針永遠不會逃逸,對象能夠在棧上分配而不是堆分配
    3. 分離對象或標量替換: 有的對象可能不須要做爲一個連續的內存結構存在也能夠被訪問到,那麼對象的部分或所有能夠不存儲在內存,而是存儲在CPU寄存器中
同步省略(鎖消除)
  • 在動態編譯同步塊時,JIT編譯器會藉助逃逸分析來判斷同步塊所使用的鎖對象是否只可以被一個線程訪問兒沒有被髮布到其餘線程,若是是隻能被一個線程訪問,則會取消這部分代碼的同步,好比在使用synchronized時,若是JIT通過逃逸分析發現並沒有線程安全問題,就會作鎖消除
    public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();//只在當前線程,因此會取消同步操做,鎖消除
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
    
    public void f() {
        Object hollis = new Object();
        synchronized(hollis) { //鎖方法內部對象,會鎖消除
            System.out.println(hollis);
        }
    }
    //至關於
    public void f() {
        System.out.println(hollis);
    }
    複製代碼
棧上分配
  • Java默認建立一個對象在堆中分配內存的,當對象再也不使用時,則須要經過垃圾回收機制回收,這個過程相對於分配在棧中的對象的建立和銷燬來講,更加消耗時間和性能.這個時候逃逸分析若是發現一個對象只在方法中使用,就會將對象分配在棧上
  • 遺憾的是:HotSpot虛擬機目前的實現致使棧上分配實現比較複雜,暫時沒有實現這項優化,相信不久未來會實現的
  • 雖然這項技術並不十分紅熟,可是她也是即時編譯器優化技術中一個十分重要的手段
標量替換
  • 標量(Scalar)是指一個沒法再分解成更小的數據的數據,Java中的原始數據類型就是標量
  • 聚合量:相對於標量那些還能夠分解的數據叫作聚合量,Java中的對象就是聚合量,由於他能夠分解成其餘聚合量和標量(如String爲 char[] 數組和int hash)
  • 應用: 在JIT階段,若是通過逃逸分析,發現一個對象不會被外界訪問,那麼通過JIT優化,就會吧這個對象拆分紅若干個其中包含的若干個成員變量來代替(當程序真正執行時不用建立這個對象,而是直接建立他的成員變量來代替,拆分後,能夠分配對象的成員變量在棧或寄存器上,則本來的對象就無需分配內存空間了),這個過程就是標量替換
    public static void main(String[] args) {
        Point point = new Point(1,2);
          System.out.println("point.x="+point.x+"; point.y="+point.y);
    }
    
    class Point{
        public int x;
        public int y;
        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    //Point對象會被替換成兩個int型
    public static void main(String[] args) {
        x = 1;
        y = 2;
        System.out.println("point.x="+ x +"; point.y="+ y);
    }
    複製代碼
  • 逃逸分析測試代碼
    public class HelloTest {
        public static void alloc() {
            byte[] b = new byte[2];
            b[0] = 1;
        }
    
        public static void main(String[] args) {
            long b = System.currentTimeMillis();
                for (int i = 0; i < 100000000; i++) {
                    alloc();
                }
                long e = System.currentTimeMillis();
                System.out.println(e - b);
            }
        }
    }
    複製代碼
  • 使用下方命令配置JVM(上面有如何在IDEA中配置,自己默認開啓了,可關閉查看數據)
    //C1編譯器參數 -client C2編譯器 -server
    //開/關 逃逸分析(JDK 6u23以上) 開/關鎖消除        開/關標量替換                打印GC日誌
    //-XX:+DoEscapeAnalysis -XX:+EliminateLocks -XX:+EliminateAllocations -XX:+PrintGCDetails -Xmx10m -Xms10m
    //-XX:-DoEscapeAnalysis -XX:-EliminateLocks -XX:-EliminateAllocations -XX:+PrintGCDetails -Xmx10m -Xms10m
    
    //開啓標量替換結果
    [GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->672K(9728K), 0.0014005 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    [GC (Allocation Failure) [PSYoungGen: 2536K->488K(2560K)] 2720K->712K(9728K), 0.0007950 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    [GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 2760K->736K(9728K), 0.0015657 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    10
    //關閉標量替換結果
    無數次GC,運行時長 1873毫秒
    複製代碼
  • 總結: 棧上的空間通常而言是很是小的,只能存放若干變化和小的數據結構,大容量的存儲結構是作不到。這裏的例子是一個極端的千萬次級的循環,突出了經過逃逸分析,讓其直接從棧上分配,從而極大下降了GC的次數,提高了程序總體的執行效能。因此,逃逸分析的效果只能在特定場景下,知足高頻和高數量的容量比較小的變量分配結構,才能夠生效!
相關文章
相關標籤/搜索