JVM的運行原理以及JDK 7增長的新特性(二)

JVM結構

 

Java編寫的代碼會按照下圖的流程來執行java

類裝載器裝載負責裝載編譯後的字節碼,並加載到運行時數據區(Runtime Data Area),而後執行引擎執行會執行這些字節碼。web

類加載器(Class Loader)

 

Java提供了動態的裝載特性;它會在運行時的第一次引用到一個class的時候對它進行裝載和連接,而不是在編譯期進行。JVM的類裝載器負責動態裝載。Java類裝載器有以下幾個特色:編程

  •  層級結構:Java裏的類裝載器被組織成了有父子關係的層級結構。Bootstrap類裝載器是全部裝載器的父親。
  • 代理模式:基於層級結構,類的裝載能夠在裝載器之間進行代理。當裝載器裝載一個類時,首先會檢查它是否在父裝載器中進行裝載了。若是上層的裝載器已經裝載了這個類,這個類會被直接使用。反之,類裝載器會請求裝載這個類。
  • 可見性限制:一個子裝載器能夠查找父裝載器中的類,可是一個父裝載器不能查找子裝載器裏的類。
  • 不容許卸載:類裝載器能夠裝載一個類可是不能夠卸載它,不過能夠刪除當前的類裝載器,而後建立一個新的類裝載器。

 

每一個類裝載器都有一個本身的命名空間用來保存已裝載的類。當一個類裝載器裝載一個類時,它會經過保存在命名空間裏的類全侷限定名(Fully Qualified Class Name)進行搜索來檢測這個類是否已經被加載了。若是兩個類的全侷限定名是同樣的,可是若是命名空間不同的話,那麼它們仍是不一樣的類。不一樣的命名空間表示class被不一樣的類裝載器裝載。bootstrap

 

下圖展現了類裝載器的代理模型。數組

當一個類裝載器(class loader)被請求裝載類時,它首先按照順序在上層裝載器、父裝載器以及自身的裝載器的緩存裏檢查這個類是否已經存在。簡單來講,就是在緩存裏查看這個類是否已經被本身裝載過了,若是沒有的話,繼續查找父類的緩存,直到在bootstrap類裝載器裏也沒有找到的話,它就會本身在文件系統裏去查找而且加載這個類。緩存

 

  • 啓動類加載器(Bootstrap class loader):這個類裝載器是在JVM啓動的時候建立的。它負責裝載Java API,包含Object對象。和其餘的類裝載器不一樣的地方在於這個裝載器是經過native code來實現的,而不是用Java代碼。
  • 擴展類加載器(Extension class loader):它裝載除了基本的Java API之外的擴展類。它也負責裝載其餘的安全擴展功能。
  •  系統類加載器(System class loader):若是說bootstrap class loader和extension class loader負責加載的是JVM的組件,那麼system class loader負責加載的是應用程序類。它負責加載用戶在$CLASSPATH裏指定的類。
  • 用戶自定義類加載器(User-defined class loader):這是應用程序開發者用直接用代碼實現的類裝載器。

 

相似於web應用服務(WAS)之類的框架會用這種結構來對Web應用和企業級應用進行分離。換句話來講,類裝載器的代理模型能夠用來保證不一樣應用之間的相互獨立。WAS類裝載器使用這種層級結構,不一樣的WAS供應商的裝載器結構有稍許區別。安全

 

若是類裝載器查找到一個沒有裝載的類,它會按照下圖的流程來裝載和連接這個類:性能優化

每一個階段的描述以下:架構

  •  Loading: 類的信息從文件中獲取而且載入到JVM的內存裏。
  •  Verifying:檢查讀入的結構是否符合Java語言規範以及JVM規範的描述。這是類裝載中最複雜的過程,而且花費的時間也是最長的。而且JVM TCK工具的大部分場景的用例也用來測試在裝載錯誤的類的時候是否會出現錯誤。
  • Preparing:分配一個結構用來存儲類信息,這個結構中包含了類中定義的成員變量,方法和接口的信息。
  • Resolving:把這個類的常量池中的全部的符號引用改變成直接引用。
  • Initializing:把類中的變量初始化成合適的值。執行靜態初始化程序,把靜態變量初始化成指定的值。

JVM規範定義了上面的幾個任務,不過它容許具體執行的時候可以有些靈活的變更。框架

運行時數據區(Runtime Data Areas)

 

運行時數據區是在JVM運行的時候操做所分配的內存區。運行時內存區能夠劃分爲6個區域。在這6個區域中,一個PC Register,JVM stack 以及Native Method Statck都是按照線程建立的,Heap,Method Area以及Runtime Constant Pool都是被全部線程公用的。

  •  PC寄存器(PC register):每一個線程啓動的時候,都會建立一個PC(Program Counter ,程序計數器)寄存器。PC寄存器裏保存有當前正在執行的JVM指令的地址。
  • JVM 堆棧(JVM stack):每一個線程啓動的時候,都會建立一個JVM堆棧。它是用來保存棧幀的。JVM只會在JVM堆棧上對棧幀進行push和pop的操做。若是出現了異常,堆棧跟蹤信息的每一行都表明一個棧幀立的信息,這些信息它是經過相似於printStackTrace()這樣的方法來展現的。

  • -棧幀(stack frame):每當一個方法在JVM上執行的時候,都會建立一個棧幀,而且會添加到當前線程的JVM堆棧上。當這個方法執行結束的時候,這個棧幀就會被移除。每一個棧幀裏都包含有當前正在執行的方法所屬類的本地變量數組,操做數棧,以及運行時常量池的引用。本地變量數組的和操做數棧的大小都是在編譯時肯定的。所以,一個方法的棧幀的大小也是固定不變的。
  • -局部變量數組(Local variable array):這個數組的索引從0開始。索引爲0的變量表示這個方法所屬的類的實例。從1開始,首先存放的是傳給該方法的參數,在參數後面保存的是方法的局部變量。
  • 操做數棧(Operand stack):方法實際運行的工做空間。每一個方法都在操做數棧和局部變量數組之間交換數據,而且壓入或者彈出其餘方法返回的結果。操做數棧所需的最大空間是在編譯期肯定的。所以,操做數棧的大小也能夠在編譯期間肯定。
  •  本地方法棧(Native method stack):供用非Java語言實現的本地方法的堆棧。換句話說,它是用來調用經過JNI(Java Native Interface Java本地接口)調用的C/C++代碼。根據具體的語言,一個C堆棧或者C++堆棧會被建立。
  •  方法區(Method area):方法區是全部線程共享的,它是在JVM啓動的時候建立的。它保存全部被JVM加載的類和接口的運行時常量池,成員變量以及方法的信息,靜態變量以及方法的字節碼。JVM的提供者能夠經過不一樣的方式來實現方法區。在Oracle 的HotSpot JVM裏,方法區被稱爲永久區或者永久代(PermGen)。是否對方法區進行垃圾回收對JVM的實現是可選的。
  •   運行時常量池(Runtime constant pool):這個區域和class文件裏的constant_pool是相對應的。這個區域是包含在方法區裏的,不過,對於JVM的操做而言,它是一個核心的角色。所以在JVM規範裏特別提到了它的重要性。除了包含每一個類和接口的常量,它也包含了全部方法和變量的引用。簡而言之,當一個方法或者變量被引用時,JVM經過運行時常量區來查找方法或者變量在內存裏的實際地址。
  • 堆(Heap):用來保存實例或者對象的空間,並且它是垃圾回收的主要目標。當討論相似於JVM性能之類的問題時,它常常會被說起。JVM提供者能夠決定怎麼來配置堆空間,以及不對它進行垃圾回收。

如今咱們再會過頭來看看以前反彙編的字節碼

public void add(java.lang.String);
   Code:
    0 :   aload_0
    1 :   getfield        # 15 ; //Field admin:Lcom/nhn/user/UserAdmin;
    4 :   aload_1
    5 :   invokevirtual   # 23 ; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
    8 :   pop
    9 :   return

把上面的反彙編代碼和咱們平時所見的x86架構的彙編代碼相比較,咱們會發現這二者的結構有點類似,都使用了操做碼;不過,有一點不一樣的地方是Java字節碼並不會在操做數裏寫入寄存器的名稱、內存地址或者偏移量。以前已經說過,JVM用的是棧,它不會使用寄存器。和使用寄存器的x86架構不一樣,它本身負責內存的管理。它用索引例如15和23來代替實際的內存地址。15和23都是當前類(這裏是UserService類)的常量池裏的索引。簡而言之,JVM爲每一個類建立了一個常量池,而且這個常量池裏保存了實際目標的引用。

每行反彙編代碼的解釋以下:

  •  aload_0:把局部變量數組中索引爲#0的變量添加到操做數棧上。索引#0所表示的變量是this,便是當前實例的引用。
  • getfield #15:把當前類的常量池裏的索引爲#15的變量添加到操做數棧。這裏添加的是UserAdmin的admin成員變量。由於admin變量是個類的實例,所以添加的是一個引用。
  • aload_1:把局部變量數組裏的索引爲#1的變量添加到操做數棧。來自局部變量數組裏的索引爲1的變量是方法的一個參數。所以,在調用add()方法的時候,會把userName指向的String的引用添加到操做數棧上。
  • invokevirtual #23:調用當前類的常量池裏的索引爲#23的方法。這個時候,經過getfile和aload_1添加到操做數棧上的引用都被做爲方法的參數。當方法運行完成而且返回時,它的返回值會被添加到操做數棧上。
  •  pop:把經過invokevirtual調用的方法的返回值從操做數棧裏彈出來。你能夠看到,在前面的例子裏,用老的類庫編譯的那段代碼是沒有返回值的。簡而言之,正由於以前的代碼沒有返回值,因此不必吧把返回值從操做數棧上給彈出來。
  •  return:結束當前方法調用

下圖能夠幫助你更好地理解上面的內容。

順便提一下,在這個方法裏,局部變量數組沒有被修改。因此上圖只顯示了操做數棧的變化。不過,大部分的狀況下,局部變量數組也是會改變的。局部變量數組和操做數棧之間的數據傳輸是使用經過大量的load指令(aload,iload)和store指令(astore,istore)來實現的。

在這個圖裏,咱們簡單驗證了運行時常量池和JVM棧的描述。當JVM運行的時候,每一個類的實例都會在堆上進行分配,User,UserAdmin,UserService以及String等類的信息都會保存在方法區。

執行引擎(Execution Engine)

經過類裝載器裝載的,被分配到JVM的運行時數據區的字節碼會被執行引擎執行。執行引擎以指令爲單位讀取Java字節碼。它就像一個CPU同樣,一條一條地執行機器指令。每一個字節碼指令都由一個1字節的操做碼和附加的操做數組成。執行引擎取得一個操做碼,而後根據操做數來執行任務,完成後就繼續執行下一條操做碼。

不過Java字節碼是用一種人類能夠讀懂的語言編寫的,而不是用機器能夠直接執行的語言。所以,執行引擎必須把字節碼轉換成能夠直接被JVM執行的語言。字節碼能夠經過如下兩種方式轉換成合適的語言。

  • 解釋器:一條一條地讀取,解釋而且執行字節碼指令。由於它一條一條地解釋和執行指令,因此它能夠很快地解釋字節碼,可是執行起來會比較慢。這是解釋執行的語言的一個缺點。字節碼這種「語言」基原本說是解釋執行的。
  • 即時(Just-In-Time)編譯器:即時編譯器被引入用來彌補解釋器的缺點。執行引擎首先按照解釋執行的方式來執行,而後在合適的時候,即時編譯器把整段字節碼編譯成本地代碼。而後,執行引擎就沒有必要再去解釋執行方法了,它能夠直接經過本地代碼去執行它。執行本地代碼比一條一條進行解釋執行的速度快不少。編譯後的代碼能夠執行的很快,由於本地代碼是保存在緩存裏的。

不過,用JIT編譯器來編譯代碼所花的時間要比用解釋器去一條條解釋執行花的時間要多。所以,若是代碼只被執行一次的話,那麼最好仍是解釋執行而不是編譯後再執行。所以,內置了JIT編譯器的JVM都會檢查方法的執行頻率,若是一個方法的執行頻率超過一個特定的值的話,那麼這個方法就會被編譯成本地代碼。

JVM規範沒有定義執行引擎該如何去執行。所以,JVM的提供者經過使用不一樣的技術以及不一樣類型的JIT編譯器來提升執行引擎的效率。

大部分的JIT編譯器都是按照下圖的方式來執行的:

JIT編譯器把字節碼轉換成一箇中間層表達式,一種中間層的表示方式,來進行優化,而後再把這種表示轉換成本地代碼。

Oracle Hotspot VM使用一種叫作熱點編譯器的JIT編譯器。它之因此被稱做」熱點「是由於熱點編譯器經過分析找到最須要編譯的「熱點」代碼,而後把熱點代碼編譯成本地代碼。若是已經被編譯成本地代碼的字節碼再也不被頻繁調用了,換句話說,這個方法再也不是熱點了,那麼Hotspot VM會把編譯過的本地代碼從cache裏移除,而且從新按照解釋的方式來執行它。Hotspot VM分爲Server VM和Client VM兩種,這兩種VM使用不一樣的JIT編譯器。

Client VM 和Server VM使用徹底相同的運行時,不過如上圖所示,它們所使用的JIT編譯器是不一樣的。Server VM用的是更高級的動態優化編譯器,這個編譯器使用了更加複雜而且更多種類的性能優化技術。

IBM 在IBM JDK 6裏不只引入了JIT編譯器,它同時還引入了AOT(Ahead-Of-Time)編譯器。它使得多個JVM能夠經過共享緩存來共享編譯過的本地代碼。簡而言之,經過AOT編譯器編譯過的代碼能夠直接被其餘JVM使用。除此以外,IBM JVM經過使用AOT編譯器來提早把代碼編譯器成JXE(Java EXecutable)文件格式來提供一種更加快速的執行方式。

大部分Java程序的性能都是經過提高執行引擎的性能來達到的。正如JIT編譯器同樣,不少優化的技術都被引入進來使得JVM的性能一直可以獲得提高。最原始的JVM和最新的JVM最大的差異之處就是在於執行引擎。

Hotspot編譯器在1.3版本的時候就被引入到Oracle Hotspot VM裏了,JIT編譯技術在Anroid 2.2版本的時候被引入到Dalvik VM裏。

引入一種中間語言,例如字節碼,虛擬機執行字節碼,而且經過JIT編譯器來提高JVM的性能的這種技術以及普遍應用在使用中間語言的編程語言上。例如微軟的.Net,CLR(Common Language Runtime 公共語言運行時),也是一種VM,它執行一種被稱做CIL(Common Intermediate Language)的字節碼。CLR提供了AOT編譯器和JIT編譯器。所以,用C#或者VB.NET編寫的源代碼被編譯後,編譯器會生成CIL而且CIL會執行在有JIT編譯器的CLR上。CLR和JVM類似,它也有垃圾回收機制,而且也是基於堆棧運行。

Java 虛擬機規範,Java SE 第7版

2011年7月28日,Oracle發佈了Java SE的第7個版本,而且把JVM規也更新到了相應的版本。在1999年發佈《The Java Virtual Machine Specification,Second Edition》後,Oracle花了12年來發布這個更新的版本。這個更新的版本包含了這12年來累積的衆多變化以及修改,而且更加細緻地對規範進行了描述。此外,它還反映了《The Java Language Specificaion,Java SE 7 Edition》裏的內容。主要的變化總結以下:

  • 來自Java SE 5.0裏的泛型,支持可變參數的方法
  • 從Java SE 6以來,字節碼校驗的處理技術所發生的改變
  • 添加invokedynamic指令以及class文件對於該指令的支持
  • 刪除了關於Java語言概念的內容,而且指引讀者去參考Java語言規範
  •  刪除關於Java線程和鎖的描述,而且把它們移到Java語言規範裏

最大的改變是添加了invokedynamic指令。也就是說JVM的內部指令集作了修改,使得JVM開始支持動態類型的語言,這種語言的類型不是固定的,例如腳本語言以及來自Java SE 7裏的Java語言。以前沒有被用到的操做碼186被分配給新指令invokedynamic,並且class文件格式裏也添加了新的內容來支持invokedynamic指令。

Java SE 7的編譯器生成的class文件的版本號是51.0。Java SE 6的是50.0。class文件的格式變更比較大,所以,51.0版本的class文件不可以在Java SE 6的虛擬機上執行。

儘管有了這麼多的變更,可是Java方法的65535字節的限制仍是沒有被去掉。除非class文件的格式完全改變,否者這個限制未來也是不可能去掉的。

值得說明的是,Oracle Java SE 7 VM支持G1這種新的垃圾回收機制,不過,它被限制在Oracle JVM上,所以,JVM自己對於垃圾回收的實現不作任何限制。也所以,在JVM規範裏沒有對它進行描述。

switch 語句裏的String

Java SE 7裏添加了不少新的語法和特性。不過,在Java SE 7的版本里,相對於語言自己而言,JVM沒有多少的改變。那麼,這些新的語言特性是怎麼來實現的呢?咱們經過反彙編的方式來看看switch語句裏的String(把字符串做爲switch()語句的比較對象)是怎麼實現的?

例如,下面的代碼:

// SwitchTest
public class SwitchTest {
     public int doSwitch(String str) {
         switch (str) {
         case "abc" :        return 1 ;
         case "123" :        return 2 ;
         default :         return 0 ;
         }
     }
}
由於這是Java SE 7的一個新特性,因此它不能在Java SE 6或者更低版本的編譯器上來編譯。用Java SE 7的javac來編譯。下面是經過javap -c來反編譯後的結果。
C:Test>javap -c SwitchTest.classCompiled from "SwitchTest.java"
public class SwitchTest {
   public SwitchTest();
     Code:
        0 : aload_0
        1 : invokespecial # 1                  // Method java/lang/Object."<init>":()V
        4 : return  public int doSwitch(java.lang.String);
     Code:
        0 : aload_1
        1 : astore_2
        2 : iconst_m1
        3 : istore_3
        4 : aload_2
        5 : invokevirtual # 2                  // Method java/lang/String.hashCode:()I
        8 : lookupswitch  { // 2
                  48690 : 50
                  96354 : 36
                default : 61
           }
       36 : aload_2
       37 : ldc           # 3                  // String abc
       39 : invokevirtual # 4                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
       42 : ifeq          61
       45 : iconst_0
       46 : istore_3
       47 : goto          61
       50 : aload_2
       51 : ldc           # 5                  // String 123
       53 : invokevirtual # 4                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
       56 : ifeq          61
       59 : iconst_1
       60 : istore_3
       61 : iload_3
       62 : lookupswitch  { // 2
                      0 : 88
                      1 : 90
                default : 92
           }
       88 : iconst_1
       89 : ireturn
       90 : iconst_2
       91 : ireturn
       92 : iconst_0
       93 : ireturn

在#5和#8字節處,首先是調用了hashCode()方法,而後它做爲參數調用了switch(int)。在lookupswitch的指令裏,根據hashCode的結果進行不一樣的分支跳轉。字符串「abc"的hashCode是96354,它會跳轉到#36處。字符串」123「的hashCode是48690,它會跳轉到#50處。生成的字節碼的長度比Java源碼長多了。首先,你能夠看到字節碼裏用lookupswitch指令來實現switch()語句。不過,這裏使用了兩個lookupswitch指令,而不是一個。若是反編譯的是針對Int的switch()語句的話,字節碼裏只會使用一個lookupswitch指令。也就是說,針對string的switch語句被分紅用兩個語句來實現。留心標號爲#5,#39和#53的指令,來看看switch()語句是如何處理字符串的。

在第#36,#37,#39,以及#42字節的地方,你能夠看見str參數被equals()方法來和字符串「abc」進行比較。若是比較的結果是相等的話,‘0’會被放入到局部變量數組的索引爲#3的位置,而後跳抓轉到第#61字節。

在第#50,#51,#53,以及#56字節的地方,你能夠看見str參數被equals()方法來和字符串「123」進行比較。若是比較的結果是相等的話,10’會被放入到局部變量數組的索引爲#3的位置,而後跳轉到第#61字節。

在第#61和#62字節的地方,局部變量數組裏索引爲#3的值,這裏是'0',‘1’或者其餘的值,被lookupswitch用來進行搜索並進行相應的分支跳轉。

換句話來講,在Java代碼裏的用來做爲switch()的參數的字符串str變量是經過hashCode()和equals()方法來進行比較,而後根據比較的結果,來執行swtich()語句。

在這個結果裏,編譯後的字節碼和以前版本的JVM規範沒有不兼容的地方。Java SE 7的這個用字符串做爲switch參數的特性是經過Java編譯器來處理的,而不是經過JVM來支持的。經過這種方式還能夠把其餘的Java SE 7的新特性也經過Java編譯器來實現。

總結

我不認爲爲了使用好Java必須去了解Java底層的實現。許多沒有深刻理解JVM的開發者也開發出了不少很是好的應用和類庫。不過,若是你更加理解JVM的話,你就會更加理解Java,這樣你會有助於你處理相似於咱們前面的案例中的問題。

相關文章
相關標籤/搜索