深入理解JVM 類加載機制 類執行機制 內存回收

之前我們文章提到過反射,說的比較淺顯,我們這裏來理解JVM。

一個標準的JVM是這樣的



JVM負責裝載class文件並執行,我們首先來了解類加載和執行的機制。

類加載機制

JVM將.class文件加載到JVM,並形成Class對象,之後就可以對Class對象進行實例化並調用。
該過程分爲三個步驟:

  1. 裝載。
  2. 鏈接。
  3. 初始化。

  • 裝載

負責找到二進制字節碼並加載到JVM中。
JVM通過類的全限定名以及類加載器完成類的加載。
比如Object[] o=new Object[10],o的全限定名:[Ljava.lang.Object,並由數組型中的元素類型所在的ClassLoader進行加載。


  • 鏈接

鏈接過程負責對二進制字節碼進行校驗、初始化裝載類中的靜態變量以及解析類中調用的接口、類。


  • 初始化

初始化過程即執行類中的靜態初始化代碼,構造器代碼以及靜態屬性的初始化。

類執行機制

在完成將class文件信息加載到JVM併產生Class對象後,就可執行Class對象的靜態方法或實例化對象進行調用了。在源碼編譯階段,將源碼編譯爲JVM字節碼,JVM字節碼是一種中間代碼的方式,要由JVM在運行期間對其進行解釋並執行。這種方式稱爲:字節碼解釋執行方式。

字節碼解釋執行

由於採用JVM字節碼,也就是說JVM有一套自己的指令來執行中間碼:

  • invokestatic
    調用static方法
  • invokevirtual
    調用對象實例的方法
  • invokeinterface
    調用接口
  • invokespecial
    調用private方法和<init>對象初始化方法

比如下面這一段代碼:

public class Demo{
    public void execute(){
        A.execute();
        A a=new A();
        a.bar();
        IFoo b=new B();
        b.bar();
    }
}
class A{
    public static int execute(){
        return 1+2;
    }
    public int bar(){
        return 1+2;
    }
}
class B implements IFoo{
    public int bar(){
        return 1+2;
    }
}
public interface IFoo{
    public int bar();
}

通過javac 編譯上面的代碼後,使用javap -c Demo 查看其execute方法的字節碼:

Compiled from "Demo.java"
public class Demo {
  public Demo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void execute();
    Code:
       0: invokestatic  #2                  // Method A.execute:()I
       3: pop
       4: new           #3                  // class A
       7: dup
       8: invokespecial #4                  // Method A."<init>":()V
      11: astore_1
      12: aload_1
      13: invokevirtual #5                  // Method A.bar:()I
      16: pop
      17: new           #6                  // class B
      20: dup
      21: invokespecial #7                  // Method B."<init>":()V
      24: astore_2
      25: aload_2
      26: invokeinterface #8,  1            // InterfaceMethod IFoo.bar:()I
      31: pop
      32: return
}

從上面的栗子可以看出,四種指令對應調用方法的情況。
Sun JDK基於棧的體系結構來執行字節碼,基於棧方式的好處就是代碼緊湊,體積小。

線程在創建後,都會產生程序計數器(PC或者稱爲PC registers)和棧(Stack);PC存放了下一條要執行的指令在方法內的偏移量;棧中存放了棧幀(StackFrame),每個方法每次調用都會產生棧幀,棧幀主要分爲局部變量區和操作數棧兩個部分,局部變量區用於存放方法體中的局部變量和參數,操作數棧中用於存放方法執行過程中產生的中間結果,棧幀中還有一些其他空間,例如只想方法已解析的常量池的引用、其他一些VM內部需要的數據等,具體結構如下圖所示:

下面來看一個方法執行時過程的栗子:

public class Demo2{
    public static void foo(){
        int a=1;
        int b=2;
        int c=(a+b)*5;
    }
}

同樣的方法獲得JVM字節碼:

 public class Demo2 {
  public Demo2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void foo();
    Code:
       0: iconst_1
       1: istore_0
       2: iconst_2
       3: istore_1
       4: iload_0
       5: iload_1
       6: iadd
       7: iconst_5
       8: imul
       9: istore_2
      10: return
}

每條字節碼及其對應的解釋如下:



對於方法的指令解釋執行,執行方式爲經典馮諾伊曼體系中的FDX循環方式,即獲取下一條指令,解碼並分派,然後執行。在實現FDX循環時有switch-threading、token-threading、direct-threading等多種方式。

第一種swith-threading,代碼大致如下:

while(true){
    int code=fetchNextCode();//下一條指令
    switch(code){
        case IADD: //do add
        case ...: //do sth
    }
}

每次執行完都得重新回到循環開始點,然後重新獲取下一條指令,並繼續switch,這導致了大部分時間都花在了跳轉和獲取下一條指令上,真的的業務邏輯代碼非常短。

token-threading在上面的基礎上稍微有所修改:

IADD:{
    //do add
    fetchNextCode();//下一條指令
    dispatch();
}
ICONST_0:{
    push(0);
    fetchNextCode();下一條指令
    dispatch();
}
...

該方法相對第一種switch-threading而言,冗餘了fetch next code和dispatch,相對比較消耗內存,但是由於去除了switch,因此性能會稍微好一些。
其他的xxx-threading做了更多的優化,在此不做贅述。Sun JDK的重點爲編譯成機器碼,並沒有在解釋器上做太複雜的處理,因此採用了token-threading方法,爲了讓解釋執行能夠更加高效,Sun JDK還做了一些其他的優化,主要是:棧頂緩存(top-of-stack caching)部分棧幀共享

棧頂緩存
在方法執行過程中,可以看到有很多操作要將值放入操作數棧,這導致了寄存器和內存要不斷的交換數據,Sun JDK採用了一個棧頂緩存,即將本來位於操作數棧頂的值直接緩存到寄存器上,可直接在寄存器計算,然後放回操作數棧。

部分棧幀共享
當一個方法調用另一個方法時,通常傳入另一個方法的參數爲已存放在操作數棧的數據,Sun JDK採用:當調用方法時,後一個方法將前一個方法的操作數棧作爲當前方法的局部變量,從而節省數據copy帶來的消耗。

(運行時)編譯執行

由於解釋執行的效率太低,Sun JDK提供將字節碼編譯爲機器碼,在執行過程中,對執行頻率高的代碼進行編譯執行,對執行不頻繁的代碼採用解釋執行,因此Sun JDK也稱爲Hotspot VM,在編譯上Sun JDK提供了兩種模式,client compiler(-client) 和 server compiler(-server)。


  • client compiler

client compiler比較輕量級,製作少量性能開銷比較高的優化,它佔用內存較少,適合於桌面交互式應用,主要的優化有:方法內聯、去虛擬化、冗餘消除等。
1.方法內聯
例如這樣一段代碼:

public void bar(){
    ...
    bar2();
    ...
}
public void bar2(){
    //bar2執行代碼
}

當編譯時,如果bar2代碼編譯後的字節數小雨等於35個字節(可以通過啓動參數-XX:MaxInlineSize=35來控制),那麼會演變稱爲這樣的結構:

public void bar(){
    ...
    //bar2執行代碼
    ...
}

可在debug版本的JDK的啓動參數上加上-XX:+PrintInlining來查看方法內聯信息。

2.去虛擬化
去虛擬化是指在裝載class文件後,進行類層次的分析,如發現類中的方法只提供一個實現類,那麼對於調用了此方法的代碼,也可進行方法內聯,從而提升執行的性能。

例如這樣的代碼:

public interface IFoo{
    public void bar();
}
public class Foo implements IFoo{
    public void bar(){
        //Foo bar method
    }
}
public class Demo{
    public void execute(IFoo foo){
        foo.bar();
    }
}

當整個JVM只有Foo實現了IFoo接口,Demo execute方法被編譯的時候,就會演變成類似這樣的結構:

public void execute(){
    //Foo bar method
}

3.冗餘消除
冗餘消除是指在編譯時,根據運行時狀況進行摺疊或消除代碼。
比如:

private static final Log=log.LogFactory.getLog("BLUEDAVY");
private static final boolean isDebug=log.isDebugEnabled();
public void execute(){
    if(isDebug){
        log.debug(xxx);
    }
    //do something else
}

如果boolean值是false那麼會演變爲如下的結構:

public void execute(){
    //do something else
}

  • server compiler

server compiler較爲重量級,採用了大量傳統編譯優化技巧,佔用內存相對較多,適合服務端的應用,下面介紹幾個優化:

1.標量替換
例如:

Point p=new Point(1,2);
sout("point.x="+p.x+";point.y="+p.y);

當p對象在後面沒用到的時候,會演變成下面的結構:

int x=1;
int y=2;
sout("point.x="+x+";point.y="+y);

2.同步消除
如果發現同步的對象沒必要,那麼會直接去掉:

Point p=new Point();
sysnchronized(p){
    //do something
}

演變爲:

Point p=new Point();
//do somehing





從上面兩種重量級和輕量級的編譯來看,它們做了很多努力來優化。爲什麼不再一開始就編譯稱爲機器碼呢?
主要有下面幾方面的原因:

  1. 靜態編譯並不能根據程序的運行狀況來優化執行的代碼,server compiler收集運行數據越長,編譯出來的代碼會越優化。
  2. 解釋執行比編譯執行更省內存。
  3. 啓動時解釋執行的啓動速度比編譯後再啓動更快。

那麼什麼時候就需要編譯呢?這需要一個權衡值,Sun JDK有兩個計數器來計算閾值:

  • CompileThreshold
    當方法被調用多少次後,編譯爲機器碼。通過-XX:CompileThreshold=10000來設置該值。client默認1500次,server默認10000;
  • OnStackReplacePercentage
    棧上替換的百分比,該值用於/參與計算是否觸發OSR編譯的閾值,通過-XX:OnStackReplacePercentage=140來設置。在client模式下,計算規則爲:CompileThreshold * (OnStackReplacePercentage/100);在server模式下,計算規則爲:(OnStackReplacePercentage – InterpreterProfilePercentage))/100

反射執行

反射執行是基於反射來動態調用某對象實例中對應的方法,訪問查看對象的屬性等等,之前的文章寫的很清楚。
Java中通過如下的方法調用:

Class actionClass=Class.forName(外部實現類);
Method method=actionClass.getMethod(“execute”,null);
Object action=actionClass.newInstance();
method.invoke(action,null);

這樣在創建對象過程和方法調用過程是動態的,具有很高的靈活性。

內存回收

內存空間

Sun JDK在實現時,將內存空間劃分爲方法區、堆、本地方法棧、PC寄存器以及JVM方法棧。如下圖所示:



  • 方法區

方法區存放了要加載的類的信息、靜態變量、final類型常量等信息。方法區是全局共享的。
通過-XX:PermSize和-XX:MaxPermSize來指定最小最大的值,保證方法區內存大小。


堆用於存儲對象實例及數組值,可以認爲Java中所有new的對象都在此分配。


  • 本地方法棧

用於支持native方法的執行。在Sun JDK的實現中本地方法棧和JVM方法棧是同一個。


  • PC寄存器和JVM方法棧

每個線程單獨創建自己的PC寄存器和JVM方法棧(私有的)。當方法運行完畢時,其對應的棧幀所用內存也會自動釋放。

收集器

JVM通過GC來回收堆和方法區中的內存,GC的基本原理是首先找到程序中不再被使用的對象,然後回收這些對象所佔用的內存。
主要的收集器有引用計數收集器跟蹤收集器

  1. 引用計數收集器
    顧名思義,通過計數器記錄對象引用數目,當引用數目爲0時回收對象。
  2. 跟蹤收集器
    跟蹤收集器採用的爲集中式的管理方式,全局記錄數據的引用狀態。基於一定條件觸發(例如定時觸發或者空間不足時觸發),執行時需要從根集合來掃描對象的引用關係,這可能會造成應用程序暫停,主要有複製、標記-清除、標記-壓縮三種實現算法。
    (其實就是清理內存的算法,計算機原理也學過)
    複製:從根集合中掃描存活的對象,複製到未使用的空間中。



    標記清除:從根集合中掃描,對存活的對象標記,然後再掃描整個空間中未標記的對象,進行回收。



    標記壓縮:和標記清除一樣也要進行標記,不過第二步在回收不存活的對象的內存後,會將對象左移壓縮。

Sun JDK可用的GC

以上三種跟蹤收集器各有優缺點,Sun JDK認爲:程序中大部分對象存活時間都是較短的,只有少部分是長期存活的。根據這一分析,JVM被劃分爲新生代和舊生代,根據兩代generation有不同的GC實現。

新生代中對象存活期短,因此選用複製算法進行回收。由於在複製的時候,需要一塊未使用的空間來存放存活的對象(和固態硬盤一樣,也是要預留空間),所以新生代又被分爲Eden、S0、S1三塊空間。
Eden Space存放新創建的對象,S0或S1其中一塊作爲複製的目標空間(輪流):當一塊作爲複製的目標空間,另一塊被清空。因此S0和S1也被稱爲:From Space和To Space。

Sun JDK提供了串行GC、並行回收GC和並行GC三種方式來回收,在此不做贅述。

舊生代與新生代不同,對象存活的時間比較長,比較穩定,因此採用標記(Mark)算法來進行回收,所謂標記就是掃描出存活的對象,然後再進行回收未被標記的對象,回收後對用空出的空間要麼進行合併,要麼標記出來便於下次進行分配,總之就是要減少內存碎片帶來的效率損耗。在執行機制上JVM提供了串行 GC(SerialMSC)、並行GC(parallelMSC)和併發GC(CMS),具體算法細節還有待進一步深入研究。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章