聊一聊JVM

JVM

什麼是JVM?

​ JVM是java虛擬機的縮寫,本質上是一個程式,能識別.class位元組碼文件(.java文件編譯後產生的二進位程式碼),並且能夠解析它的指令,最終調用作業系統上的函數,完成我們想要的操作。

​ 關於java語言的跨平台性(一次編譯,多次運行),就是應為JVM,可以把它想像出一個抽象層,運行在作業系統之上的,與硬體沒有直接的交互,只要這個抽象層JVM正確執行了.class文件,就能運行在各種作業系統之上了。

​ 介紹幾個術語:

  • JDK:java開發工具包,JDK=JRE+javac/java/jar等指令工具
  • JRE:java運行環境,JRE=JVM+java基本類庫

JVM體系結構

​ java虛擬機主要分為五大模組:

  • 類載入器
  • 運行時數據區
  • 執行引擎
  • 本地方法介面
  • 垃圾收集模組

img

​ 方法區是一種特殊的堆,棧裡面不回有垃圾,用完就彈出了,否則阻塞了main方法。垃圾幾乎都在堆里,所以JVM性能調優%99都針對與堆。

​ 目前最常用的JVM是Sun公司的HotSpot,此外還有BEA公司的JRockit和IBM公司的J9 VM。

類載入器

​ 作用:載入.class位元組碼文件。

new一個對象的過程

//運行時,JVM將Test的資訊放入方法區
public class Test{
  public static void main(String[] args){
    Student s1 = new Student("Tom");//引用放在棧里,具體的實例放在堆里
    Student s2 = new Student("Jerry");
    Student s3 = new Student("Victor");
    //三個hashCode是不同的,因為是三個不同的對象,對象是具體的
    System.out.println(s1.hashCode());
    System.out.println(s2.hashCode());
    System.out.println(s3.hashCode());
    //class1,class2,class3為同一個對象,因為這是類模版,模版是抽象的
    Class<? extends Stedent> class1 = s1.getClass();
    Class<? extends Stedent> class2 = s2.getClass();
    Class<? extends Stedent> class3 = s3.getClass();
    System.out.println(class1.hashCode());
    System.out.println(class2.hashCode());
    System.out.println(class3.hashCode());
  }
}
  1. 首先Class Loader讀取位元組碼文件,載入初始化生成Student模版類。
  2. 通過Student模版類new出三個對象。

類載入器的類別

public class Test{
  public static void main(String[] args){
    Student s = new Student("Tom");
    Class<? extends Student> c = s.getClass();
    ClassLoader classLoader = c.getClassLoader();
    System.out.println(classLoader);//APPClassLoader
    System.out.println(classLoader.getParent());//PlatformClassLoader
    System.out.println(classzLader.getParent().getParent());//null,獲取不到(C++寫的)
  }
}

​ 根據返回結果,級別從高到低有三種載入器:

  1. 啟動類(根)載入器:BootStrapClassLoader。
    • c++編寫的,載入java核心庫,構造拓展類載入器和應用程式載入器
    • 根載入器載入拓展類載入器,並且將拓展類載入器的父載入器設置為根載入器
    • 然後在載入應用程式載入器,應將應用程式的載入器的父載入器設置為拓展類載入器
    • 由於根載入器涉及到虛擬機本地實現的細節,我們無法直接獲取到啟動類載入器的引用,這就是上面第三個結果為null的原因
    • 載入文件存在於/jdk/jdk1.8/jre/lib/rt.jar
  2. 拓展類載入器:PlatformClassLoader
    • java編寫,載入擴展庫,開發者可以直接使用標準擴展類載入器
    • java9之前稱為ExtClassLoader
    • 載入文件存在於…/lib/ext
  3. 應用程式載入器:AppClassLoader
    • Java編寫,載入程式所在的目錄,是java默認的類載入器
  4. 用戶自定義載入器:CustomeClassLoader
    • java編寫,用戶自定義的類載入器,可載入指定路徑的class文件

​ 實際上,這些載入器的區別就是載入不同範圍或不同路徑的.class文件。

雙親委派機制

​ 雙親委派機制是類載入器收到類載入的請求,會將這個請求向上委託給父類載入器去完成,一直向上委託,直到根載入器BootStrapClassLoader。根載入器檢查是否能夠載入當前類,能載入就結束,使用當前類載入器,否則就拋出異常,通知子載入器進行載入。

​ 舉個例子,我們重寫java.lang包下的String類:

package java.lang;
public class String{
  public String toString(){
    return "xing";
  }
  public static void main(String[] args){
    new String().toString;
  }
}

//Error:(1,1) java:程式包已存在於另一個模組中:java:base

​ 我們會發現報錯,這就是雙親委派機制起的作用,當類載入器委託到根載入器的時候,String類已經被根載入器載入過一遍了,所以不會再載入,從一定程度上防止了危險程式碼的植入。

作用總結:

  1. 防止重複載入同一個.class,通過不斷委託父載入器直到根載入器,如果父載入器載入過了,就不用再載入一遍,保證數據安全。
  2. 保證系統核心.class不被篡改。通過委託方式,不會去篡改核心.class,即使篡改也不會去載入,即使載入也不會是同一個.class對象了。不同的載入器載入同一個.class也不是同一個class對象,這樣保證了class執行安全。

沙箱安全機制

什麼是沙箱

​ java安全模型的核心就是java沙箱(sandbox)。

​ 沙箱是一個限制程式運行的環境。沙箱機制就是將java程式碼限定在虛擬機特定的運行範圍中,並且嚴格限制程式碼對本地系統資源的訪問,通過這樣的措施來保證對程式碼的有效隔離,防止對本地系統的破壞。

​ 沙箱主要限制系統資源訪問,包括CPU、記憶體、文件系統、網路。不同級別的沙箱對這些資源訪問的限制也不一樣。

​ 所有的java程式運行都可以指定沙箱,可以訂製安全策略。

java中安全模型的演進

​ 在java中將執行程式分為本地程式碼和遠程程式碼兩種:本地程式碼可信任,可以訪問一起本地資源。遠程程式碼不可信任,在早期的java實現中,安全依賴於沙箱機制。

​ 如此嚴格的安全機制也給程式的功能擴展帶來障礙,比如當用戶希望遠程程式碼訪問本地系統文件的時候,就無法實現。因此在後續的java1.1中,針對安全機製做了改進,增加了安全策略,允許用戶指定程式碼對本地資源的訪問許可權。

​ 在java1.2版本中,再次改進了安全機制,增加了程式碼簽名。不論本地程式碼或者遠程程式碼,都會按照用戶的安全策略設定,由類載入器載入到虛擬機中許可權不同的運行空間,來實現差異化的程式碼執行許可權控制。

​ 當前最新的安全機制實現,則引入了域(Domain)的概念。虛擬機會把所有的程式碼載入到不同的系統域和應用域,系統域部分專門負責與關鍵資源進行交互,應用域部分則通過系統域的部分代理來對各種需要的資源進行訪問。虛擬機中不同的受保護域對應不一樣的許可權,存在於不同域中的類文件就具有了當前域的全部許可權。

組成沙箱的基本組件

  1. 位元組碼校驗器(bytecode verifier)

    確保java類文件遵循java語言規範。這樣可以幫助java程式實現記憶體保護。但並不是所有的類文件都會經過位元組碼校驗,比如核心類。

  2. 類裝載器(class loader)

    類裝載器在3個方面對java沙箱起作用

    • 防止惡意程式碼去干涉善意的程式碼
    • 守護了被信任的類庫邊界
    • 將程式碼歸入保護域,確定了程式碼可以進行哪些操作。

    虛擬機為不同的類載入器載入的類提供不同的命名空間,命名空間由一系列唯一的名稱組成,每一個被裝載的類將有一個名字,這個命名空間是由java虛擬機為每一個類裝載器維護的,他們互相之間甚至不可見。

  3. 存取控制器(access controller):存取控制器可以控制核心API對作業系統的存取許可權,而這個控制的策略設定可以由用戶指定。

  4. 安全管理器(security manager):是核心API和作業系統之間的主要介面。實現許可權控制,比如存取控制器優先順序高。

  5. 安全軟體包(security package):java.security下的類和擴展包下的類,允許用戶為自己的應用增加新的安全特性,包括安全提供者、消息摘要、數字簽名、加密、鑒別。

Native本地方法介面

​ JNI:java native interface

​ 本地介面的作用是融合不同的程式語言為java所用,它的初衷是融合C/C++程式。

​ 凡是帶native關鍵字的,就說明java的作用範圍達不到了,會去調用底層c語言庫,進入本地方法棧,調用本地方法介面JNI,拓展java的使用,融合不同的語言為java所用。

​ java誕生的時候C/C++橫行,為了立足,必須要能夠調用C/C++程式,於是在記憶體區域中專門開闢了一塊標記區域:Native Method Stack,登記Native方法,最終在執行引擎上執行的時候通過JNI載入本地方法庫中的方法。目前該方法的使用越來越少了,除非是與硬體有關的應用,比如通過java程式驅動印表機或者java系統管理生產設備,在企業級應用中已經比較少見。因為現在的異構領域間通訊很發達,比如可以用Socket通訊,也可以使用Web Service等。

運行時數據區

PC暫存器(Program Counter Register)

​ 每個執行緒都有一個程式計數器,是執行緒私有的,就是一個指針,指向方法區中的方法位元組碼(用來存儲指向像一條指令的地址,也即將要執行的指令程式碼),在執行引擎讀取下一條指令,是一個非常小的記憶體空間,幾乎可以忽略不計。

方法區(Method Area)

​ 方法區與java堆一樣,是各個執行緒共享的記憶體區域,用於存儲已被虛擬機載入的類資訊、常量、靜態變數、即時編譯器遍以後的程式碼等數據。雖然java虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名Non-Heap,因此實際上應該和堆區分開。

方法區中有啥?

  • 靜態變數(static)
  • 常量(final)
  • 類資訊(構造方法,介面定義)
  • 運行時的常量池

創建對象記憶體分析

public class Person{
  int age;
  String name = "xing";
  public Person(int age, String name){
    this.age = age;
    this.name = name;
  }
  
  public static void main(String[] agrs){
    Person s1 = new Person(18,"Tom");
  }
}
/*
創建一個對象時,方法區中會生成對應類的抽象模版;還有對應的常量池、靜態變數、類資訊、常量。
我們通過類模版去new對象的時候,堆中存放實例對象,棧中存放對象的引用,每個對象對應一個地址指向堆中相同地址的實例對象。
*/

​ 主管程式的運行,生命周期和執行緒同步。執行緒結束,棧記憶體就釋放了,不存在垃圾回收。棧中存放8大基本類型,對象引用,實例的方法。

棧運行的原理

​ 棧表示java方法執行的記憶體模型,每調用一個方法就會為每個方法生成一個棧幀(Stack Frame),每個方法被調用的完成的過程,都對應一個棧幀從虛擬機棧上入棧和出棧的過程。程式正在執行的方法一定在棧的頂部。

堆棧溢出(StackOverflowError)

public class Test{
  public static void main(String[] args){
    new Test().a();
  }
  
  public void a(){
    b();
  }
  public void b(){
    a();
  }
}

//最開始,main()方法壓入棧中,然後執行a(),a()押入棧中,在調用b(),b()押入棧棧中,以此往複,最終導致棧溢出

​ 一個JVM只有一個堆記憶體(棧是執行緒級的),堆記憶體的大小是可以調節的,堆中存放實例化的對象。

堆記憶體詳解

  1. 年輕代

    對象的誕生、成長甚至死亡的區

    • Eden Space(伊甸園區):所有對象都是在此new出來的
    • Survivor Space(倖存區)
      • 倖存0區(From Space),動態的From和To會互相交換
      • 倖存1區(To Space)

    Eden區佔大容量,Survivor兩個區佔小容量,默認比例是8:1:1。

  2. 老年代

  3. Perm元空間

    存儲的是java運行時的一些環境或類資訊,這個區域不存在垃圾回收。關閉虛擬機就會釋放這個區域的記憶體,這個區域常駐記憶體,用來存放JDK自身攜帶的Class對象、Interface元數據。jdk1.8之前被稱為永久代。

    注意:元空間在邏輯上存在,在物理上不存在。新生代+老年代的記憶體空間=JVM分配的總記憶體。

什麼是OOM

​ 記憶體溢出,產生原因:

  • 分配的太少
  • 用的太多
  • 用完沒釋放

GC垃圾回收

​ 主要在年輕代和老年代。

​ 首先對象出生在伊甸園區,假設伊甸園區只能存在一定數量的對象,則每當存滿時就會出發一次輕GC(Minor GC)。輕GC清理後,有的對象可能還存在引用,就活下來了,活下來的對象就進入倖存區;有的對象沒用了,就被GC清理掉了;每次輕GC都會使得伊甸園區為空。

​ 如果倖存區和伊甸園區都滿了,則會進入老年代,如果老年代滿了,就會出發一次重GC(FullGC),年輕代+老年代的對象都會清理一次,活下來的對象都進入老年代。

​ 如果新生代和老年代都滿了,則OOM。

  • Minor GC:伊甸園區滿時觸發,從年輕代回收記憶體
  • Full GC:老年代滿時觸發,清理整個堆空間
  • Major GC:清理老年代

​ 什麼情況下永久區會崩?一個啟動類載入了大量的第三方jar包,Tomcat部署了過多應用,或者大量動態生成的反射類,這些東西不斷的被載入,知道記憶體滿,就會出現OOM。

堆記憶體調優

查看並設置JVM堆記憶體

public class Test{
  public static void main(String[] args){
    //返回jvm試圖使用的最大記憶體
    long max = Runtime.getRuntime().maxMemory();
    //返回jvm的初始化記憶體
    long total = Runtime.getRuntime().totalMemory();
    //默認情況下:分配的總記憶體為電腦記憶體的1/4,初始化記憶體為電腦記憶體的1/64
    System.out.println("max=" + max / (double) 1024 / 1024 / 1024 + "G");
    System.out.println("total=" + total / (double) 1024 / 1024 / 1024 + "G");
  }
}

​ 我們可以手動調整堆記憶體的大小,在VM options 中可以指定jvm試圖使用的最大記憶體和jvm初始化記憶體的大小。

-Xms1024m -Xmx1024m -Xlog:gc*
  • -Xms用來設置jvm試圖使用的最大記憶體
  • -Xmx用來設置jvm初始化記憶體
  • -Xlog:gc*用來列印GC垃圾回收資訊

怎麼排除OOM錯誤?

  1. 嘗試擴大堆記憶體看結果

  2. 利用記憶體快照工具JProfiler

    作用:分析Dump記憶體文件,快速定位記憶體泄漏;獲得堆中的文件;獲得大的對象

    Dump文件是進程的記憶體鏡像,可以把程式的執行狀態通過調試器保存到dump文件中

    import java.util.ArrayList;
    
    public class Test{
      byte[] array = new byte[1024*1024];//1M
      public static void main(String[] args){
        ArrayList<Test> list = new ArrayList<>();
        int count = 0;
        try{
          while(true){
            list.add(new Test());
            count++;
          }
        }catch(Exception e){
          System.out.println("count="+count);
          e.printStackTrace();
        }
      }
    }
    

    運行程式,報錯OOM。

    接下來設置一下堆記憶體並附加生成dump文件的指令

    -Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
    

    -XX:+HeapDumpOnOutOfMemoryError表示當JVM發生OOM時,自動生成DUMP文件。再次點擊運行,下載了對應的Dump文件。

    分析步驟:

    • 右鍵該類,點擊Show in Explorer
    • 一直點擊上級目錄,直到找到.hprof文件

    每次打開dump文件查看完後,建議刪除,打開文件後生成了很多內容,占記憶體。

GC垃圾回收

​ 之前已經堆GC垃圾回收流程進行了大概的講解:JVM在進行GC時,大部分回收都是在年輕代。

GC演算法

  1. 引用計數法(很少使用)

    • 每個對象在創建的時候,就給這個對象綁定一個計數器。
    • 每當有一個引用指向該對象時,計數器加一;每當有一個指向它的引用被刪除時,計數器減一;
    • 這樣,當沒有引用指向該對象時,該對象死亡,計數器為0,這時就應該對這個對象進行垃圾回收操作。
  2. 複製演算法

    複製演算法主要發生在年輕代(倖存0區和倖存1區)

    • 當Eden區滿的時候,會觸發輕GC,每觸發一次,活的對象就被轉移到倖存區,死的對象就被GC清理掉,所以每次觸發輕GC時,Eden區就會清空
    • 對象被轉移到了倖存區,倖存區又分為From SpaceTo Space,這兩塊區域是動態交換的,誰是空的誰就是To Space,然後From Space就會把全部對象轉移到To Space去;
    • 那如果兩塊區域都不為空呢?這就用到了複製演算法,其中一個區域會將存活的對象轉移到另一個區域去,然後將自己區域的記憶體空間清空,這樣該區域為空,又成為了To Space
    • 所以每次觸發輕GC後,Eden區清空,同時To區也清空了,所有的對象都在From區

    好處:沒有記憶體碎片

    壞處:浪費記憶體空間(浪費倖存區一半的空間);對象存活率較高的場景下,需要複製的東西太多,效率會下降。

    最佳使用環境:對象存活率較低的時候,也就是年輕代。

  3. 標記-清除演算法

    為每個對象存儲一個標記位,記錄對象的生存狀態。

    • 標記階段:這個階段內,為每個對象更新標記位,檢查對象是否死亡。
    • 清除階段:該階段對死亡的對象進行清除,執行GC操作。

    缺點:兩次掃描嚴重浪費時間;會產生記憶體碎片

    優點:不需要額外的空間

  4. 標記-整理演算法

    這個是標記-清除演算法的一個改進版,又叫做標記-清除-壓縮演算法。不同的是在第二個階段,該演算法並沒有直接對死亡的對象進行清理,而是將所有存貨的對象整理一下,放到另一處空間,然後把剩下的所有對象全部清除。可以進一步優化,在記憶體碎片不太多的情況下,就繼續標記清除,到達一定量的時候再壓縮。

有沒有最優的演算法?

​ 沒有最優演算法,只有最合適的。

​ GC也稱為分代收集演算法,對於年輕代,對象存活率低用複製演算法;對於老年代,區域大,對象存活率高,用標記清除+標記壓縮混合實現。

Tags: