1. 程式人生 > >關於 Java虛擬機:內存處理與執行引擎

關於 Java虛擬機:內存處理與執行引擎

reflect const method runt 類方法 數據驗證 lib 定義 作用

一.Java技術體系簡介:

Java技術體系包括以下幾個組成部分:

  1. java程序設計語言
  2. 各種硬件平臺上的java虛擬機
  3. Class文件格式
  4. Java API 類庫
  5. 來自商業機構和開源社區的第三方類庫

JDK(java Development Kit):包括java程序設計語言,java虛擬機,java API類庫。JDK是用於支持java程序開發的最小環境。

JRE(java Runtime Environment) 包括java API類庫中的java SE API子集,java虛擬機。JRE是支持java程序運行的標準環境。

下圖展示了Java技術體系所包含的內容,以及JDK和JRE所涵蓋的範圍:

技術分享圖片

按照技術所服務的領域來分,Java技術體系可以分為四個平臺,分別是:

  1. java Card:支持一些java小程序(Applet)運行在小內存設備上的平臺
  2. Java ME(Micro Edition):支持java程序運行在移動終端上的平臺,對java APi 有所精簡,並加入了針對移動終端的支持。
  3. Java SE(Standard Edition):支持面向桌面級應用的java平臺,提供了完整的java核心API。
  4. Java EE(Enterprise Edition):支持使用多層架構的企業應用的java平臺,除了提供java SE API之外,還對其做了大量的擴充並提供了相關的部署支持。

二,java內存管理機制

  1. 運行時數據區域:

java虛擬機在執行java程序的過程中會把它所管理的內存劃分為若幹不同的數據區域。這些區域都有各自的用途,以及創建和銷毀的時間,有的區域隨著虛擬機進程的啟動而存在,有些區域則依賴於用戶線程的啟動和結束而建立和銷毀。

Java虛擬機所管理的內存包括以下幾個運行時數據區域:

技術分享圖片

1.1 程序計數器:

程序計數器是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。

在虛擬機的概念模型裏,字節碼解釋器工作時就是通過改變程序計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴於程序計數器來完成。

由於Java虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條線程中的指令。因此,為了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為線程私有的內存

如果線程正在執行的是一個Java方法,程序計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是Native方法,則程序計數器的值為空(Undefined)。

程序計數器是唯一一個在Java虛擬機規範中沒有規定任何OutOfMemoryError情況的區域。

1.2 Java虛擬機棧

與程序計數器一樣,java虛擬機棧也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame,棧幀是方法運行時的基礎數據結構)用於存儲局部變量表,操作數棧,動態鏈接,方法出口等信息。每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。

對於C/C++等程序來說,其內存管理常常分為棧、堆等。對於Java,棧即指代虛擬機棧,或者說是虛擬機棧中局部變量表部分。

局部變量表存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等同於對象本身,可能是一個指向對象起始地址的引用地址,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。

局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。

可以通過 -Xss 這個虛擬機參數來指定一個程序的 Java 虛擬機棧內存大小:

java -Xss=512M HackTheJava

該區域可能拋出以下異常:

  • 當線程請求的棧深度超過最大值,會拋出StackOverflowError 異常;
  • 棧進行動態擴展時如果無法申請到足夠內存,會拋出OutOfMemoryError 異常。

在Java虛擬機規範中,對這個區域規定了兩種異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常;如果虛擬機棧可以動態擴展,如果擴展時無法申請到足夠的內存,就會拋出OutOfMemoryError異常

1.3本地方法棧

本地方法棧(Native Method Stack)與虛擬機棧所發揮的作用是非常相似的,它們的區別是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的Native方法服務。與虛擬機棧一樣,本地方法棧也會拋出StackOverflowError異常和OutOfMemoryError異常。

1.4 Java堆(Heap)

對於大多數應用來說,Java堆(Java Heap)是Java虛擬機所管理的內存中最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例。在JVM中,幾乎所有的對象實例都在這裏分配內存。Java虛擬機規範中的描述是:所有的對象實例以及數組都要在堆上分配,但是隨著JIT編譯器的發展和逃逸技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也變得不是那麽絕對了

Java堆是垃圾收集器管理的主要區域,因此,Java堆也被稱為“GC堆”(Garbage Collected Heap)。

現代的垃圾收集器基本都是采用分代收集算法,該算法的思想是針對不同的對象采取不同的垃圾回收算法,因此虛擬機把Java堆分成以下三塊:

  • 新生代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Generation)

當一個對象被創建時,它首先進入新生代,之後有可能被轉移到老年代中。新生代存放著大量的生命很短的對象,因此新生代在三個區域中垃圾回收的頻率最高。為了更高效的進行垃圾回收,把新生代繼續劃分為以下三個空間:

  • Eden
  • From Survivor
  • To Survivor
  • 技術分享圖片
  • 從內存分配的角度來看,線程共享的Java堆中可能劃分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer, TLAB)。根據Java虛擬機規範的規定,Java堆可以處於物理上不連續的內存空間,只要邏輯上是連續的即可。在實現時,既可以實現成固定大小的,也可以是可擴展的,如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,將會拋出OutOfMemoryError異常。

    可以通過 -Xms 和 -Xmx 兩個虛擬機參數來指定一個程序的 Java 堆內存大小,第一個參數設置最小值,第二個參數設置最大值。

    java -Xms=1M -XmX=2M HackTheJava

    1.5方法區

    方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的應該與Java堆區分開來。

    Java虛擬機規範對方法區的限制非常寬松,除了和Java堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾回收。相對而言,垃圾收集行為在這個區域是比較少出現的,但並非數據進入了方法區就如同永久代名字一樣永久存在。該區域的內存回收目標主要是針對常量池的回收和對類型的卸載。

    根據Java虛擬機規範的規定,當方法區無法滿足內存分配需求時,將拋出OutOfMemoryError異常。

    運行時常量池(Runtime Costant Pool)是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。

    運行時常量池相對於Class文件常量池的另外一個重要特征是具備動態性。Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class文件常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,如String類的intern()方法。

    既然運行時常量區是方法區的一部分,當常量池無法申請到內存時會拋出OutOfMemoryError異常。

    Class類文件解析

    java:一次編寫,到處運行。Write Once,Run Anywhere。

    各種不同平臺的虛擬機與所有平臺都統一使用的程序存儲格式——字節碼(ByteCode是構成平臺無關性的基石。

    Java虛擬機不和包括Java在內的任何語言綁定,它只與“Class文件”這種特定的二進制文件格式所關聯。

    Class文件中包含了Java虛擬機指令集和符號表以及若幹其他輔助信息。

    1. 1. Class類文件的結構

    任何一個Class文件都對應著唯一一個類或接口的定義信息,但反過來說,類或接口並不一定都得定義在文件裏(譬如類或接口也可以通過類加載器直接生成)。

    Class文件是一組以8位字節為基礎單元的二進制流,各個數據項目嚴格按照順序緊湊地排列在Class文件之中,中間沒有添加任何分隔符。

    Class文件中只有兩種數據類型:無符號數和表

    無符號數屬於基本的數據類型,可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。

    表是有多個無符號數或者其他表作為數據項構成的復合數據類型,所有表都習慣性地以“_info”結尾。

    Class文件中的數據項,無論是順序還是數量,甚至於數據存儲的字節序,都是被嚴格限定的,哪個字節代表什麽含義,長度是多少,先後順序如何,都不允許改變。

    Class文件格式如下:

  • 技術分享圖片
    • 常量池(constant_pool
  • 常量池入口,常量池可以理解為Class文件之中的資源倉庫,它是Class文件結構中與其他項目關聯最多的數據類型,也是占用Class文件空間最大的數據項目之一,同時它還是在Class文件中第一個出現的表類型數據項目。

    常量池中主要存放兩大類常量:字面量和符號引用

    字面量比較接近於Java層面的常量概念,如文本字符串、被聲明為final的常量值等。

    符號引用總結起來則包括了下面三類常量:

    • 類和接口的全限定名(即帶有包名的Class名,如:org.lxh.test.TestClass)
    • 字段的名稱和描述符(private、static等描述符)
    • 方法的名稱和描述符(private、static等描述符)

    虛擬機在加載Class文件時才會進行動態連接,也就是說,Class文件中不會保存各個方法和字段的最終內存布局信息,因此,這些字段和方法的符號引用不經過轉換是無法直接被虛擬機使用的。當虛擬機運行時,需要從常量池中獲得對應的符號引用,再在類加載過程中的解析階段將其替換為直接引用,並翻譯到具體的內存地址中。

    符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標並不一定已經加載到了內存中。

    直接引用:直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那說明引用的目標必定已經存在於內存之中了。

    • 訪問標誌(access_flags

    訪問標誌(access_flags,這個標誌用於識別一些類或者接口層次的訪問信息。包括:

    • 這個Class是類還是接口;
    • 是否定義為public類型;
    • 是否定義為abstract類型;
    • 如果是類的話,是否被聲明為final等。

    訪問標誌包括public/protected/private/abstract/final等等。

    虛擬機類加載機制:

    虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

    類加載過程

    在Java語言裏面,類型的加載、連接、初始化過程都是在程序運行期間完成的。

    特點:靈活性、動態擴展(運行期動態加載和動態連接)

  • 技術分享圖片
  • 類從被加載到虛擬機內存中開始,到卸載出內存為止,整個生命周期包括:

    • 加載(Loading
    • 驗證(Verification
    • 解析(Resolution
    • 初始化(Initialization
    • 使用(Using
    • 卸載(Unloading

    那麽,什麽情況下需要開始類加載過程的第一個階段加載呢?!!有且只有五種情況!!

    • 遇到new/getstatic/putstatic/invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化(分別對應於:使用new實例化對象、讀取或設置類的靜態字段、調用一個類的靜態方法)。
    • 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
    • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
    • 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
    • 當使用動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

    被動引用:

    • 通過子類引用父類的靜態字段,不會導致子類初始化;
    • 通過數組定義來引用類,不會觸發此類的初始化;
    • 常量在編譯階段會調入類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。(常量傳播優化)

    對於接口的加載過程,我們需要註意的是:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口時才會初始化。

    類加載過程主要包括加載、驗證、準備、解析和初始化5個階段。

    1.1 加載

    在加載階段,虛擬機需要完成以下三件事情:

    1. 通過一個類的全限定名來獲取定義此類的二進制字節流;
    2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
    3. 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

    1.2 驗證

    驗證階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害到虛擬機自身的安全。驗證是虛擬機對自身保護的一項重要工作。

    從整體上看,驗證階段大致上會完成下面4個階段的檢驗動作:

    • 文件格式驗證

    第一階段要驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。該驗證階段的主要目的是保證輸入的字節流能正確地解析並存儲於方法區之內,格式上符合一個Java類型信息的要求

    • 元數據驗證

    第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求。該驗證階段的主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息

    • 字節碼驗證

    第三階段是對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件。該驗證階段的主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的

    • 符號引用驗證

    第四階段是對類自身以外的信息進行匹配性校驗。該驗證階段的主要目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麽將會拋出一個java.lang.IncompatibleClassChangeError異常的子類

    對於虛擬機的類加載機制來說,驗證階段是一個非常重要的、但不是一定必要的階段。

    1.3 準備

    準備階段是為類變量分配內存並設置類變量初始值的階段。

    註意:此時進行內存分配的僅包括類變量(static修飾的變量),而不包括實例變量。

    考慮下面一個問題:

    試比較下面兩種情況下在準備階段後value對應的值是多少。

    // 情形一

    public static int value = 123;

    // 情形二

    public static final int value = 123;

    答案是:對於情形一,準備階段後value的值為0;對於情形二,準備階段後value的值為123。

    原因在於,情形一下value的賦值操作是在<init>部分完成的,而在情形二下,value對應為ConstantValue屬性。

    1.4 解析

    解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程

    符號引用(Symbolic References:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
    直接引用(Direct References:直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。

    虛擬機規範中並未規定解析階段發生的具體時間。

    解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。

    1.5 初始化

    類初始化階段是類加載的最後一步。在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源。也就是說,初始化階段是執行類構造器<clinit>方法的過程。

    <clinit>方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合並產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。例如:

    public class Test {

    static {

    i = 0; // 給變量賦值可以正常編譯通過

    System.out.println(i); // 非法前向引用!!!

    }

    static int i = 1;

    }

    • <clinit>方法與類的構造函數(實例構造器<init>)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>方法執行之前,父類的<clinit>方法已經執行完畢
    • 由於父類的<clinit>方法先執行,也就有,父類中定義的靜態語句塊要優先於子類的變量賦值操作
    • <clinit>方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麽編譯器可以不為這個類生成<clinit>方法。
    • 接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作。但是接口與類不同的是,執行接口的<clinit>方法不需要先執行父接口的<clinit>方法,只有當父接口中定義的變量使用時,父接口才會初始化
    • 虛擬機會保證一個類的<clinit>方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麽只會有一個線程去執行這個類的<clinit>方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>方法完畢。同時,需要註意的是,其他線程雖然會被阻塞,但如果執行<clinit>方法的那條線程退出<clinit>方法後,其他線程喚醒之後不會再次進入<clinit>方法。同一個類加載器下,一個類型只會初始化一次。

    2. 類加載器

    虛擬機設計團隊把類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流 ( 即字節碼 )”這個動作放到 Java 虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊稱為“類加載器”。

    2.1 類與類加載器

    對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。通俗而言:比較兩個類是否“相等”(這裏所指的“相等”,包括類的 Class 對象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括使用 instanceof() 關鍵字做對象所屬關系判定等情況),只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

    2.2 類加載器分類

    從 Java 虛擬機的角度來講,只存在以下兩種不同的類加載器:

    • 啟動類加載器(Bootstrap ClassLoader),這個類加載器用 C++ 實現,是虛擬機自身的一部分;
    • 所有其他類的加載器,這些類由 Java 實現,獨立於虛擬機外部,並且全都繼承自抽象類 java.lang.ClassLoader。

    從 Java 開發人員的角度看,類加載器可以劃分得更細致一些:

    • 啟動類加載器(Bootstrap ClassLoader) 此類加載器負責將存放在 <JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的(僅按照文件名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被加載)類庫加載到虛擬機內存中。 啟動類加載器無法被 Java 程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給啟動類加載器,直接使用 null 代替即可。
    • 擴展類加載器(Extension ClassLoader) 這個類加載器是由 ExtClassLoader實現的。它負責將<JAVA_HOME>/lib/ext或者被 java.ext.dir系統變量所指定路徑中的所有類庫加載到內存中,開發者可以直接使用擴展類加載器。
    • 應用程序類加載器(Application ClassLoader) 這個類加載器是由AppClassLoader實現的。由於這個類加載器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱為系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

關於 Java虛擬機:內存處理與執行引擎