[譯]深刻理解JVM

深刻理解JVM

原文連接:http://www.cubrid.org/blog/dev-platform/understanding-jvm-internalsjava

每一個使用Java的開發者都知道Java字節碼是在JRE中運行(JRE: Java 運行時環境)。JVM則是JRE中的核心組成部分,承擔分析和執行Java字節碼的工做,而Java程序員一般並不須要深刻了解JVM運行狀況就能夠開發出大型應用和類庫。儘管如此,若是你對JVM有足夠了解,就會對Java有更好的掌握,而且能解決一些看起來簡單但又還沒有解決的問題。程序員

因此,在本篇文章中,我將會介紹JVM工做原理,內部結構,Java字節碼的執行及指令的執行順序,並會介紹一些常見的JVM錯誤及其解決方案。最後會簡單介紹下Java SE7帶來的新特性。apache

虛擬機

JRE由Java API和JVM組成,JVM經過類加載器(Class Loader)加類Java應用,並經過Java API進行執行。編程

虛擬機(VM: Virtual Machine)是經過軟件模擬物理機器執行程序的執行器。最初Java語言被設計爲基於虛擬機器在而非物理機器,重而實現WORA(一次編寫,處處運行)的目的,儘管這個目標幾乎被世人所遺忘。因此,JVM能夠在全部的硬件環境上執行Java字節碼而無須調整Java的執行模式。數組

JVM的基本特性:緩存

  • 基於棧(Stack-based)的虛擬機: 不一樣於Intel x86和ARM等比較流行的計算機處理器都是基於寄存器(register)架構,JVM是基於棧執行的
  • 符號引用(Symbolic reference): 除基本類型外的全部Java類型(類和接口)都是經過符號引用取得關聯的,而非顯式的基於內存地址的引用。
  • 垃圾回收機制: 類的實例經過用戶代碼進行顯式建立,但卻經過垃圾回收機制自動銷燬。
  • 經過明確清晰基本類型確保平臺無關性: 像C/C++等傳統編程語言對於int類型數據在同平臺上會有不一樣的字節長度。JVM卻經過明確的定義基本類型的字節長度來維持代碼的平臺兼容性,從而作到平臺無關。
  • 網絡字節序(Network byte order): Java class文件的二進制表示使用的是基於網絡的字節序(network byte order)。爲了在使用小端(little endian)的Intel x86平臺和在使用了大端(big endian)的RISC系列平臺之間保持平臺無關,必需要定義一個固定的字節序。JVM選擇了網絡傳輸協議中使用的網絡字節序,即基於大端(big endian)的字節序。

Sun 公司開發了Java語言,但任何人均可以在遵循JVM規範的前提下開發和提供JVM實現。因此目前業界有多種不一樣的JVM實現,包括Oracle Hostpot JVM和IBM JVM。Google公司使用的Dalvik VM也是一種JVM實現,儘管其並未徹底遵循JVM規範。與基於棧機制的Java 虛擬機不一樣的是Dalvik VM是基於寄存器的,Java 字節碼也被轉換爲Dalvik VM使用的寄存器指令集。安全

Java 字節碼

JVM使用Java字節碼—一種運行於Java(用戶語言)和機器語言的中間語言,以達到WORA的目的。Java字節碼是部署Java程序的最小單元。性能優化

在介紹Java 字節碼以前,咱們先來看一下什麼是字節碼。下面涉及的案例是曾在一個真實的開發場景中遇到過的情境。服務器

現象

一個曾運行無缺的程序在更新了類庫後卻不能再次運行,並拋出了以下異常:網絡

1 Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
2     at com.nhn.service.UserService.add(UserService.java:14)
3     at com.nhn.service.UserService.main(UserService.java:19)

程序代碼以下,並在更新類庫以前不曾對這段代碼作過變動:

1 // UserService.java
2 3 public void add(String userName) {
4     admin.addUser(userName);
5 }

類庫中更新過的代碼先後對好比下:

 1 // UserAdmin.java - Updated library source code
 2  3 public User addUser(String userName) {
 4     User user = new User(userName);
 5     User prevUser = userMap.put(userName, user);
 6     return prevUser;
 7 }
 8 // UserAdmin.java - Original library source code
 9 10 public void addUser(String userName) {
11     User user = new User(userName);
12     userMap.put(userName, user);
13 }

簡單來講就是addUser()方法在更新以後返回void而在更新以後返回了User類型實例。而程序代碼由於不關心addUser的返回值,因此在使用的過程當中並未作過改變。

初看起來,com.mhn.user.UserAdmin.addUser()依然存在,但爲何會出現NoSuchMethodError?

問題分析

主要緣由是程序代碼在更新類庫時並未從新編譯代碼,也就是說,雖然程序代碼看起來依然是在調用addUser方法而不關心其返回值,而對編譯的類文件來講,他是要明確知道調用方法的返回值類型的。

能夠經過下面的異常信息說明這一點:

 java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/langString;)V 

NoSuchMethodError 是由於"com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V"方法找不到引發的。看一下"Ljava/lang/String;"和後面的"V"。在Java字節碼錶示中,"L;"表示類的實例。因此上面的addUser方法須要一個java/lang/String對象做爲參數。就這個案例中,類庫中的addUser()方法的參數未發生變化,因此參數是正常的。再看一下異常信息中最後面的"V",它表示方法的返回值類型。在Java字節碼錶示中,"V"意味着該方法沒有返回值。因此上面的異常信息就是說須要一個java.lang.String參數且沒有任何返回值的com.nhn.user.UserAdmin.addUser方法找不到。

由於程序代碼是使用以前版本的類庫進編譯的,class文件中定義的是應該調用返回"V"類型的方法。然而,在改變類庫後,返回"V"類型的方法已不存在,取而代之的是返回類型爲"Lcom/nhn/user/User;"的方法。因此便發生了上面看到的NoSuchMethodError。

註釋

由於開發者未針對新類庫從新編譯程序代碼,因此發生了錯誤。儘管如此,類庫提供者卻也要爲此負責。由於以前沒有返回值的addUser()方法既然是public方法,但後面卻改爲了會返回user實現,這意味着方法簽名發生了明顯的變化。這意味了該類庫不能對以前的版本進行兼容,因此類庫提供者必須事前對此進行通知。

咱們從新回到Java 字節碼,Java 字節碼是JVM的基本元素,JVM自己就是一個用於執行Java字節碼的執行器。Java編譯器並不會把像C/C++那樣把高級語言轉爲機器語言(CPU執行指令),而是把開發者能理解的Java語言轉爲JVM理解的Java字節碼。由於Java字節碼是平臺無關的,因此它能夠在安裝了JVM(準確的說,是JRE環境)的任何硬件環境執行,即便它們的CPU和操做系統各不相同(因此在Windows PC機上開發和編譯的class文件在不作任何調整的狀況下就能夠在Linux機器上執行)。編譯後文件的大小與源文件大小基本一致,因此比較容易經過網絡傳輸和執行Java字節碼。

Java class文件自己是基於二進制的文件,因此咱們很難直觀的理解其中的指令。爲了管理這些class 文件, JVM提供了javap命令來對二進制文件進行反編譯。執行javap獲得的是直觀的java指令序列。在上面的案例中,經過對程序代碼執行javap -c就可獲得應用中的UserService.add()方法的指令序列,以下:

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

在上面的Java指令中,addUser()方法是在第五行被調用,即"5: invokevirtual #23"。這句的意思是索引位置爲23的方法會被調用,方法的索引位置是由javap程序標註的。invokevirtual是Java 字節碼中最經常使用到的一個操做碼,用於調用一個方法。另外,在Java字節碼中有4個表示調用方法的操做碼: invokeinterfaceinvokespecialinvokestaticinvokevirtual 。他們每一個的含義以下:

  • invokeinterface: 調用接口方法
  • invokespecial: 調用初始化方法、私有方法、或父類中定義的方法
  • invokestatic: 調用靜態方法
  • invokevirtual: 調用實例方法

Java 字節碼的指令集包含操做碼(OpCode)和操做數(Operand)。像invokevirtual這樣的操做碼須要一個2字節長度的操做數。

對上面案例中的程序代碼,若是在更新類庫後從新編譯程序代碼,而後咱們再反編譯字節碼將看到以下結果:

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

如上咱們看到#23對應的方法變成了具備返回值類型"Lcom/nhn/user/User;"的方法。

在上面的反編譯結果中,代碼前面的數字是具備什麼含義?

它是一個一字節數字,也許正所以JVM執行的代碼被稱爲「字節碼」。像 aload0, getfield_ 和 invokevirtual 都被表示爲一個單字節數字。(aload_0 = 0x2a, getfiled = 0xb4, invokevirtual = 0xb6)。所以Java字節碼錶示的最大指令碼爲256。

像aload0和aload1這樣的操做碼不須要任何操做數,所以aload_0的下一個字節就是下一個指令的操做碼。而像getfield和invokevirtual這樣的操做碼卻須要一個2字節的操做數,所以第一個字節裏的第二個指令getfield指令的一下指令是在第4個字節,其中跳過了2個字節。經過16進制編輯器查看字節碼以下:

  2a b4 00 0f 2b b6 00 17 57 b1

在Java字節碼中,類實例表示爲"L;",而void表示爲"V",相似的其餘類型也有各自的表示。下表列出了Java字節碼中類型表示。

表1: Java字節碼裏的類型表示

Java 字節碼 類型 描述
B byte 單字節
C char Unicode字符
D double 雙精度浮點數
F float 單精度浮點數
I int 整型
J long 長整型
L 引用 classname類型的實例
S short 短整型
Z boolean 布爾類型
[ 引用 一維數組

表2: Java代碼的字節碼示例

java 代碼 Java 字節碼錶示
double d[][][] [[[D
Object mymethod(int i, double d, Thread t) mymethod(I,D,Ljava/lang/Thread;)Ljava/lang/Object;

在《Java虛擬機技術規範第二版》的4.3 描述符(Descriptors)章節中有關於此的詳細描述,在第6章"Java虛擬機指令集"中介紹了更多不一樣的指令。

類文件格式

在解釋類文件格式以前,先看一個在Java Web應用中常常發生的問題。

現象

在Tomcat環境裏編寫和運行JSP時,JSP文件未被執行,並伴隨着以下錯誤:

Servlet.service() for servlet jsp threw exception org.apache.jasper.JasperException: Unable to compile class for JSP Generated servlet error:
The code of method _jspService(HttpServletRequest, HttpServletResponse) is exceeding the 65535 bytes limit"
問題分析

對於不一樣的Web應用容器,上面的錯誤信息會有些微差別,但核心信息是一致的,即65535字節的限制。這個限制是JVM定義的,用於規定方法的定義不能大於65535個字節

下面我將先介紹65535個的字節限制,而後詳細說明爲何要有這個限制。

Java字節碼中,"goto"和"jsr"指令分別表示分支和跳轉。

goto [branchbyte1] [branchbyte2]
jsr [branchbyte1] [branchbyte2]

這兩個操做指令都跟着一個2字節的操做數,而2個字節能表示的最大偏移量只能是65535。然而爲了支持更大範圍的分支,Java字節碼又分別定義了"gotow" 和 "jsrw" 用於接收4個字節的分支偏移量。

goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]

受這兩個指令所賜,分支能表示的最大偏移遠遠超過了65535,這麼說來java 方法就不會再有65535個字節的限制了。然而,因爲Java 類文件的各類其餘限制,java方法的定義仍然不可以超過65535個字節的限制。下面咱們經過對類文件的解釋來看看java方法不能超過65535字節的其餘緣由。

Java類文件的大致結構以下:

ClassFile {
    u4 magic;
    u2 minor_version;
    u2 major_version;
    u2 constant_pool_count;
    cp_info constant_pool[constant_pool_count-1];
    u2 access_flags;
    u2 this_class;
    u2 super_class;
    u2 interfaces_count;
    u2 interfaces[interfaces_count];
    u2 fields_count;
    field_info fields[fields_count];
    u2 methods_count;
    method_info methods[methods_count];
    u2 attributes_count;
    attribute_info attributes[attributes_count];}

上面的文件結構出自《Java虛擬機技術規範第二版》的4.1節"類文件結構"。

以前講過的UserService.class文件的前16個字節的16進製表示以下:

ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b

咱們經過對這一段符號的分析來了解一個類文件的具體格式。

  • magic: 類文件的前4個字節是一組魔數,是一個用於區分Java類文件的預約義值。如上所看到的,其值固定爲0xCAFEBABE。也就是說一個文件的前4個字節若是是0xCAFABABE,就能夠認爲它是Java類文件。"CAFABABE"是與"JAVA"有關的一個有趣的魔數。
  • minorversion, majorversion: 接下來的4個字節表示類的版本號。如上所示,0x00000032表示的類版本號爲50.0。由JDK 1.6編譯而來的類文件的版本號是50.0,而由JDK 1.5編譯而來的版本號則是49.0。JVM必須保持向後兼容,即保持對比其版本低的版本的類文件的兼容。而若是在一個低版本的JVM中運行高版本的類文件,則會出現java.lang.UnsupportedClassVersionError的發生。
  • constantpoolcount, constant_pool[]: 緊接着版本號的是類的常量池信息。這裏的信息在運行時會被分配到運行時常量池區域,後面會有對內存分配的介紹。在JVM加載類文件時,類的常量池裏的信息會被分配到運行時常量池,而運行時常量池又包含在方法區內。上面UserService.class文件的constantpoolcount爲0x0028,因此按照定義contant_pool數組將有(40-1)即39個元素值。
  • access_flags: 2字節的類的修飾符信息,表示類是否爲public, private, abstract或者interface。
  • thisclass, superclass: 分別表示保存在constant_pool數組中的當前類及父類信息的索引值。
  • interface_count, interfaces[]: interfacecount爲保存在constantpool數組中的當前類實現的接口數的索引值,interfaces[]即表示當前類所實現的每一個接口信息。
  • fields_count, fields[]: 類的字段數量及字段信息數組。字段信息包含字段名、類型、修飾符以及在constant_pool數組中的索引值。
  • methods_count, methods[]: 類的方法數量及方法信息數組。方法信息包括方法名、參數的類型及個數、返回值、修飾符、在constant_pool中的索引值、方法的可執行代碼以及異常信息。
  • attributes_count, attributes[]: attributeinfo有多種不一樣的屬性,分別被fieldinfo, method_into使用。

javap程序把class文件格式以可閱讀的方式輸出來。在對UserService.class文件使用"javap -verbose"命令分析時,輸出內容以下:

Compiled from "UserService.java"

public class com.nhn.service.UserService extends java.lang.Object
  SourceFile: "UserService.java"
  minor version: 0
  major version: 50
  Constant pool:const #1 = class        #2;     //  com/nhn/service/UserService
const #2 = Asciz        com/nhn/service/UserService;
const #3 = class        #4;     //  java/lang/Object
const #4 = Asciz        java/lang/Object;
const #5 = Asciz        admin;
const #6 = Asciz        Lcom/nhn/user/UserAdmin;;// … omitted - constant pool continued …

{
// … omitted - method information …

public void add(java.lang.String);
  Code:
   Stack=2, Locals=2, Args_size=2
   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  LineNumberTable:
   line 14: 0
   line 15: 9  LocalVariableTable:
   Start  Length  Slot  Name   Signature
   0      10      0    this       Lcom/nhn/service/UserService;
   0      10      1    userName       Ljava/lang/String; // … Omitted - Other method information …
}

 

因爲篇幅緣由,上面只抽取了部分輸出結果。在所有的輸出信息中,會爲你展現包括常量池和每一個方法內容等各類信息。

方法的65535個字節的限制受到了結構體method_info的影響。如上面"javap -verbose"的輸出所示,結構體methodinfo包括代碼(Code)、行號表(LineNumberTable)以及本地變量表(LocalVariableTable)。其中行號表、本地變量表以及代碼裏的異常表(exceptiontable)的總長度爲一個固定2字節的值。所以方法的大小不能超過行號表、本地變量表、異常表的長度,即不能超過65535個字節。

儘管不少人抱怨方法的大小限制,JVM規範也聲稱將會對此大小進行擴充,然而到目前爲止並無明確的進展。由於JVM技術規範裏定義要把幾乎整個類文件的內容都加載到方法區,所以若是方法長度將會對程序的向後兼容帶來極大的挑戰。

對於一個由Java編譯器錯誤而致使的錯誤的類文件將發生怎樣的狀況?若是是在網絡傳輸或文件複製過程當中,類文件被損壞又將發生什麼?

爲了應對這些場景,Java類加載器的加載過程被設計爲一個很是嚴謹的處理過程。JVM規範詳細描述了這個過程。

註釋

咱們如何驗證JVM成功執行了類文件的驗證過程?如何驗證不一樣的JVM實現是否符合JVM規範?爲此,Oracle提供了專門的測試工具:TCK(Technology Compatibility Kit)。TCK經過執行大量的測試用例(包括大量經過不一樣方式生成的錯誤類文件)來驗證JVM規範。只有經過TCK測試的JVM才能被稱做是JVM。

相似TCK,還有一個JCP(Java Community Process; http://jcp.org),用於驗證新的Java技術規範。對於一個JCP,必須具備詳細的文檔,相關的實現以及提交給JSR(Java Specification Request)的TCK測試。若是用戶想像JSR同樣使用新的Java技術,那他必須先從RI提供者那裏獲得許可,或者本身直接實現它並對之進行TCK測試。

JVM 結構

Java程序的執行過程以下圖所示:

Java代碼執行過程
圖1: Java代碼執行過程

類加載器把Java字節碼載入到運行時數據區,執行引擎負責Java字節碼的執行。

類加載

Java提供了動態加載的特性,只有在運行時第一次遇到類時纔會去加載和連接,而非在編譯時加載它。JVM的類加載器負責類的動態加載過程。Java類加載器的特色以下:

  • 層次結構:Java的類加載器按是父子關係的層次結構組織的。Boostrap類加載器處於層次結構的頂層,是全部類加載器的父類。
  • 代理模型:基於類加載器的層次組織結構,類加載器之間是能夠進行代理的。當一個類須要被加載,會先去請求父加載器判斷該類是否已經被加載。若是父類加器已加載了該類,那它就能夠直接使用而無需再次加載。若是還沒有加載,才須要當前類加載器來加載此類。
  • 可見性限制:子類加載器能夠從父類加載器中獲取類,反之則不行。
  • 不能卸載: 類加載器能夠載入類卻不能卸載它。可是能夠經過刪除類加載器的方式卸載類。

每一個類加載器都有本身的空間,用於存儲其加載的類信息。當類加載器須要加載一個類時,它經過FQCN)(Fully Quanlified Class Name: 全限定類名)的方式先在本身的存儲空間中檢測此類是否已存在。在JVM中,即使具備相同FQCN的類,若是出如今了兩個不一樣的類加載器空間中,它們也會被認爲是不一樣的。存在於不一樣的空間意味着類是由不一樣的加載器加載的。

下圖解釋了類加載器的代理模型:

類加載器的代理模型
圖2: 類加載器的代理模型

當JVM請示類加載器加載一個類時,加載器老是按照從類加載器緩存、父類加載器以及本身加載器的順序查找和加載類。也就是說加載器會先從緩存中判斷此類是否已存在,若是不存在就請示父類加載器判斷是否存在,若是直到Bootstrap類加載器都不存在該類,那麼當前類加載器就會從文件系統中找到類文件進行加載。

  • Bootstrap加載器:Bootstrap加載器在運行JVM時建立,用於加載Java APIs,包括Object類。不像其餘的類加載器由Java代碼實現,Bootstrap加載器是由native代碼實現的。
  • 擴展加載器(Extension class loader):擴展加載器用於加載除基本Java APIs之外擴展類。也用於加載各類安全擴展功能。
  • 系統加載器(System class loader):若是說Bootstrap和Extension加載器用於加載JVM運行時組件,那麼系統加載器加載的則是應用程序相關的類。它會加載用戶指定的CLASSPATH裏的類。
  • 用戶自定義加載器:這個是由用戶的程序代碼建立的類加載器。

像Web應用服務器(WAS: Web Application Server)等框架經過使用用戶自定義加載器使Web應用和企業級應用能夠隔離開在各自的類加載空間獨自運行。也就是說能夠經過類加載器的代理模型來保證應用的獨立性。不一樣的WAS在自定義類加載器時會有略微不一樣,但都不外乎使用加載器的層次結構原理。

若是一個類加載器發現了一個未加載的類,則該類的加載和連接過程以下圖:

類加載步驟
圖3: 類加載步驟

每一步的具體描述以下:

  • 加載(Loading): 從文件中獲取類並載入到JVM內存空間。
  • 驗證(Verifying): 驗證載入的類是否符合Java語言規範和JVM規範。在類加載流程的測試過程當中,這一步是最爲複雜且耗時最長的部分。大部分JVM TCK的測試用例都用於檢測對於給定的錯誤的類文件是否能獲得相應的驗證錯誤信息。
  • 準備(Preparing): 根據內存需求準備相應的數據結構,並分別描述出類中定義的字段、方法以及實現的接口信息。
  • 解析(Resolving): 把類常量池中全部的符號引用轉爲直接引用。
  • 初始化(Initializing): 爲類的變量初始化合適的值。執行靜態初始化域,併爲靜態字段初始化相應的值。

JVM規範定義了規則,但也容許在運行時靈活處理。

運行時數據區

配置時數據區結構
圖4: 運行時數據區結構

運行時數據區是JVM程序運行時在操做系統上分配的內存區域。運行時數據區又可細分爲6個部分,即:爲每一個線程分別建立的PC寄存器JVM棧本地方法棧和被全部線程共用的數據堆方法區運行時常量池

  • PC 寄存器:每一個線程都會有一個PC(Program Counter)寄存器,並跟隨線程的啓動而建立。PC寄存器中存有將執行的JVM指令的地址。
  • JVM 棧:每一個線程都有一個JVM棧,並跟隨線程的啓動而建立。其中存儲的數據無素稱爲棧幀(Stack Frame)。JVM會每把棧楨壓入JVM棧或從中彈出一個棧幀。若是有任何異常拋出,像printStackTrace()方法輸出的棧跟蹤信息的每一行表示一個棧幀。 JVM棧結構
    圖5: JVM棧結構

    • 棧幀:在JVM中一旦有方法執行,JVM就會爲之建立一個棧幀,並把其添加到當前線程的JVM棧中。當方法運行結束時,棧幀也會相應的從JVM棧中移除。棧幀中存放着對本地變量數組、操做數棧以及屬於當前運行方法的運行時常量池的引用。本地變量數組和操做數棧的大小在編譯時就已肯定,因此屬在運行時屬於方法的棧幀大小是固定的。
    • 本地變量數組:本地變量數組的索引從0開始計數,其位置存儲着對方法所屬類實例的引用。從索引位置1開始的保存的是傳遞給該方法的參數。其後存儲的就是真正的方法的本地變量了。
    • 操做數棧:是方法的實際運行空間。每一個方法變換操做數棧和本地變量數組,並把調用其它方法的結果從棧中彈或壓入。在編譯時,編譯器就能計算出操做數棧所需的內存窨,所以操做數棧的大小在編譯時也是肯定的。
  • 本地方法棧:爲非Java編寫的本地代程定義的棧空間。也就是說它基本上是用於經過JNI(Java Native Interface)方式調用和執行的C/C++代碼。根據具體狀況,C棧或C++棧將會被建立。

  • 方法區:方法區是被全部線程共用的內存空間,在JVM啓動時建立。它存儲了運行時常量池、字段和方法信息、靜態變量以及被JVM載入的全部類和接口的方法的字節碼。不一樣的JVM提供者在實現方法區時會一般有不一樣的形式。在Oracle的Hotspot JVM裏方法區被稱爲Permanent Area(永久區)或Permanent Generation(PermGen, 永久代)。JVM規範並對方法區的垃圾回收未作強制限定,所以對於JVM實現者來講,方法區的垃圾回收是可選操做。

  • 運行時常量池:一個存儲了類文件格式中的常量池表的內存空間。這部分空間雖然存在於方法區內,但卻在JVM操做中扮演着舉足輕重的角色,所以JVM規範單獨把這一部分拿出來描述。除了每一個類或接口中定義的常量,它還包含了全部對方法和字段的引用。所以當須要一個方法或字段時,JVM經過運行時常量池中的信息從內存空間中來查找其相應的實際地址。

  • 數據堆:堆中存儲着全部的類實例或對象,而且也是垃圾回收的目標場所。當涉及到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架構中使用的寄存器。由於JVM本身管理內存,因此Java字節碼中使用像1五、23這樣的索引值而非直接的內存地址。上面的15和23指向的是當前類的常量池中的位置(即UserService類)。也就是JVM爲每一個類建立一個常量池,並在常量池中存儲真實對象的引用。

上面每行代碼的解釋以下:

  • aload_0: 把本地變量數組的0號元素添加到操做數棧。本地變量數組的0號元素始終是 this ,即當前類實例對象的引用。
  • getfield #15: 在當前類的常量池中,把15號元素添加到操做數棧。上面的15號元素是UserAdmin admin字段。由於admin是一個類實例對象,所以其引用被加入到操做數棧。
  • aload_1: 把本地變量數組的1號元素添加到操做數棧中。本地變量數組中從第1個位置開始的元素存儲着方法的參數。所以調用add()方法傳入的String userName參數的引用將會添加到操做數棧。
  • invokevirtual #23: 調用當前類常量池中的第23號元素所引用的方法,同時被aload_1和getField #15操做添加到操做數棧中的引用信息將被傳給方法調用。當方法調用完成後,其結果將被添加到操做數棧。
  • pop: 把經過invokevirtual方法調用獲得的結果從操做數棧中彈出。在前面講述中使用以前類庫時沒有返回值,也就不須要把結果從操做數棧中彈出了。
  • return: 方法完成。

下圖將幫忙容易理解上面的文字解釋:

從運行時數據區加載字節碼示例
圖6: 從運行時數據區加載Java字節碼示例

做爲示例,上面的方法中本地變量數組中的值不曾有任何改變,因此上圖中咱們只看到操做數棧的變化。實際上,在大多數場景中本地變量數組也是被髮生變化的。數據經過加載指令(aload, iload)和存儲指令(astore, istore)在本地變量數組和操做數棧之間發生變化和移動。

在本章節咱們對運行時常量池和JVM棧做了清晰的介紹。在JVM運行時,每一個類的實例被分配到數據堆上,類信息(包括User, UserAdmin, UserService, String)等被存儲在方法區。

執行引擎

JVM經過類加載器把字節碼載入運行時數據區是由執行引擎執行的。執行引擎以指令爲單位讀入Java字節碼,就像CPU一個接一個的執行機器命令同樣。每一個字節碼命令包含一字節的操做碼和可選的操做數。執行引擎讀取一個指令並執行相應的操做數,而後去讀取並執行下一條指令。

儘管如此,Java字節碼仍是以一種能夠理解的語言編寫的,而不像那些機器直接執行的沒法讀懂的語言。因此JVM的執行引擎必需要把字節碼轉換爲能被機器執行的語言指令。執行引擎有兩種經常使用的方法來完成這一工做:

  • 解釋器(Interpreter):讀取、解釋並逐一執行每一條字節碼指令。由於解釋器逐一解釋和執行指令,所以它可以快速的解釋每個字節碼,但對解釋結果的執行速度較慢。全部的解釋性語言都有相似的缺點。叫作字節碼的語言人本質上就像一個解釋器同樣運行。
  • 即時編譯器(JIT: Just-In-Time):即時編譯器的引入用來彌補解釋器的不足。執行引擎先以解釋器的方式運行,而後在合適的時機,即時編譯器把整修字節碼編譯成本地代碼。而後執行引擎就再也不解釋方法的執行而是經過使用本地代碼直接執行。執行本地代碼較逐一解釋執行每條指令在速度上有較大的提高,而且經過對本地代碼的緩存,編譯後的代碼能具備更快的執行速度。

然而,即時編譯器在編譯代碼時比逐一解釋和執行每條指令更耗時,因此若是代碼只會被執行一次,解釋執行可能會具備更好的性能。因此JVM經過檢查方法的執行頻率,而後只對達到必定頻率的方法纔會作即時編譯。

Java編譯器和即時編譯器
圖7: Java編譯器和即時編譯器

JVM規範中並未強行約束執行引擎如何運行。因此不一樣的JVM在實現各類的執行引擎時經過各類技術手段並引入多種即時編譯器來提高性能。

大部分的即時編譯器運行流程以下圖:

即時編譯器
圖8: 即時編譯器

即時編譯器先把字節碼轉爲一種中間形式的表達式(IR: Itermediate Representation),並對之進行優化,而後再把這種表達式轉爲本地代碼。

Oracel Hotspot VM使用的即時編譯器稱爲Hotspot編譯器。之因此稱爲Hotspot是由於Hotspot Compiler會根據分析找到具備更高編譯優先級的熱點代碼,而後所這些熱點代碼轉爲本地代碼。若是一個被編譯過的方法再也不被頻繁調用,也即再也不是熱點代碼,Hotspot VM會把這些本地代碼從緩存中刪除並對其再次使用解釋器模式執行。Hotspot VM有Server VM和Client VM以後,它們所使用的即時編譯器也有所不一樣。

Hotspot Client VM和Server VM
圖9: Hotspot ClientVM 和Server VM

Client VM和Server VM使用相同的運行時環境,如上圖所示,它們的區別在於使用了不一樣的即時編譯器。Server VM經過使用多種更爲複雜的性能優化技術從而具備更好的表現。

IBM VM在他的IBM JDK6中引入了AOT(Ahead-Of-Time) 編譯器技術。經過此種技術使得多個JVM之間能經過共享緩存分享已編譯的本地代碼。也就是說經過AOT編譯器編譯的代碼能被其餘JVM直接使用而無須再次編譯。另外IBM JVM經過使用AOT編譯器把代碼預編譯爲JXE(Java Executable)文件格式從而提供了一種快速執行代碼的方式。

大多數的Java性能提高都是經過優化執行引擎的性能實現的。像即時編譯等各類優化技術被不斷的引入,從而使得JVM性能獲得了持續的優化和提高。老舊的JVM與最新的JVM之間最大的差別其實就來自於執行引擎的提高。

Hotspot編譯器從Java 1.3開始便引入到了Oracle Hotspot VM中,而即時編譯器從Android 2.2開始便被引入到了Android Dalvik VM中。

註釋

像其餘使用了像字節碼同樣的中間層語言的編譯語言,VM在執行中間層字節碼時也像JVM執行字節碼同樣,引入了即時編譯等技術來提升VM的執行效率。像Microsoft的.Net語言,其運行時的VM叫作CLR(Common Language Runtime)。CLR執行一種相似字節碼的語言CIL(Common Intermediate Language)。CLR同時提供了AOT編譯器和即時編譯器。由於若是使用C#或VB.NET編寫程序,編譯器會把源碼編譯成CIL,CLR經過使用即時編譯器來執行CIL。CLR也有垃圾回收,而且和JVM同樣也是以基於棧的方式運行。

結束語

雖然使用Java並不須要瞭解Java是如何被創造出來的,而且不少程序員在並無深刻研究JVM的狀況下依然開發出了不少偉大的應用和類庫。可是若是可以瞭解JVM,就能對Java 有更多深刻的提升,並在解決文中案例問題場景時有所幫助。

除了上文所述,JVM還有不少特性和技術細節。JVM技術規範爲JVM開發者提供了靈活的規範空間,以幫忙開發者能使用多種技術手段創造出具備更好性能的JVM實現。另外雖然垃圾回收手術已被不少具備相似VM能力的編程語言做爲經常使用的性能提高的新手段,但因有不少對其詳細介紹的資料,因此這裏沒有深刻講解。

原文做者:Se Hoon Park,消息平臺開發團隊,NHN公司。

相關文章
相關標籤/搜索