Java虛擬機10-特點

虛擬機

內存佈局與對象創建

在這裏插入圖片描述

從圖片中看,一共分爲了5大區域,分別是:方法區、堆、棧、本地方法區、程序計數器。

這裏我們主要了解下 方法區、堆、 *棧、*這三個區域。

2.方法區:
方法區是一塊所有線程共享的內存區域。
需要保存類型信息和常量池。
類型信息
對每個加載的類型,jvm必須在方法區中存儲以下類型信息:
一 這個類型的完整有效名
二 這個類型直接父類的完整有效名(除非這個類型是interface或是
java.lang.Object,兩種情況下都沒有父類)
三 這個類型的修飾符(public,abstract, final的某個子集)
四 這個類型直接接口的一個有序列表

類型名稱在java類文件和jvm中都以完整有效名出現。在java源代碼中,完整有效名由類的所屬包名稱加一個".",再加上類名
組成。例如,類Object的所屬包爲java.lang,那它的完整名稱爲java.lang.Object,但在類文件裏,所有的"."都被
斜槓“/”代替,就成爲java/lang/Object。完整有效名在方法區中的表示根據不同的實現而不同。

除了以上的基本信息外,jvm還要爲每個類型保存以下信息:
類型的常量池( constant pool)
域(Field)信息
方法(Method)信息
除了常量外的所有靜態(static)變量

常量池
jvm爲每個已加載的類型都維護一個常量池。常量池就是這個類型用到的常量的一個有序集合,包括實際的常量(string,
integer, 和floating point常量)和對類型,域和方法的符號引用。池中的數據項象數組項一樣,是通過索引訪問的。
因爲常量池存儲了一個類型所使用到的所有類型,域和方法的符號引用,所以它在java程序的動態鏈接中起了核心的作用。

域信息
jvm必須在方法區中保存類型的所有域的相關信息以及域的聲明順序,
域的相關信息包括:
域名
域類型
域修飾符(public, private, protected,static,final volatile, transient的某個子集)

方法信息
jvm必須保存所有方法的以下信息,同樣域信息一樣包括聲明順序
方法名
方法的返回類型(或 void)
方法參數的數量和類型(有序的)
方法的修飾符(public, private, protected, static, final, synchronized, native, abstract的一個子集)除了abstract和native方法外,其他方法還有保存方法的字節碼(bytecodes)操作數棧和方法棧幀的局部變量區的大小
異常表

類變量(
Class Variables
譯者:就是類的靜態變量,它只與類相關,所以稱爲類變量
)
類變量被類的所有實例共享,即使沒有類實例時你也可以訪問它。這些變量只與類相關,所以在方法區中,它們成爲類數據在邏輯上的一部分。在jvm使用一個類之前,它必須在方法區中爲每個non-final類變量分配空間。

常量(被聲明爲final的類變量)的處理方法則不同,每個常量都會在常量池中有一個拷貝。non-final類變量被存儲在聲明它的
類信息內,而final類被存儲在所有使用它的類信息內。

對類加載器的引用
jvm必須知道一個類型是由啓動加載器加載的還是由用戶類加載器加載的。如果一個類型是由用戶類加載器加載的,那麼jvm會將這個類加載器的一個引用作爲類型信息的一部分保存在方法區中。

jvm在動態鏈接的時候需要這個信息。當解析一個類型到另一個類型的引用的時候,jvm需要保證這兩個類型的類加載器是相同的。這對jvm區分名字空間的方式是至關重要的。

對Class類的引用
jvm爲每個加載的類型(譯者:包括類和接口)都創建一個java.lang.Class的實例。而jvm必須以某種方式把Class的這個實例和存儲在方法區中的類型數據聯繫起來。

方法區的大小決定了系統可以保存多少個類,如果系統定義了太多的類,導致方法區溢出,虛擬機同樣會拋出內存溢出的錯誤

jdk1.6和jdk1.7方法區可以理解爲永久區。

jdk1.8已經將方法區取消,替代的是元數據區。

jdk1.8的元數據區可以使用參數-XX:MaxMetaspaceSzie設定大小,這是一塊堆外的直接內存,與永久區不同,如果不指定大小,默認情況下,虛擬機會耗盡可用系統內存。

3.堆:
用來存放動態產生的數據,比如new出來的對象。注意創建出來的對象只包含屬於各自的成員變量,並不包括成員方法。因爲同一個類的對象擁有各自的成員變量,存儲在各自的堆中,但是他們共享該類的方法,並不是每創建一個對象就把成員方法複製一次。在堆中只會存儲成員方法的地址,在調用的時候,根據地址去方法區中執行對應的成員方法。

  1. 棧:
    棧生命週期與線程相同。啓動一個線程,程序調用函數,棧幀被壓入棧中,函數調用結束,相應的是棧幀的出棧。

棧幀由局部變量表,操作數棧,幀數據區組成。

局部變量表:存放的是函數的入參,以及局部變量。

操作數棧:存放調用過程中的計算結果的臨時存放區域。

幀數據區:存放的是異常處理表和函數的返回,訪問常量池的指針。

對象的創建實際應該是很複雜的。這裏我們僅僅站在抽象的角度去解釋。我們創建普通對象

Apple apple=new Apple(“甜”)

首先 虛擬機VM 遇到new 這個關鍵字子 會生成對應的字節碼指令 newinstance ,這個就是要創建對象了。

然後根據後面的Apple這個參數 ,去方法區的常量池中 是否能找到對應的 類的信息,如果沒有 那麼出發類加載流程,如果有則 在堆中 分配內存大小。VM之所以知道要分配多大的內存,是因爲在類在編譯完後就已經確認了大小。這裏 可能會有一個遺憾。我的一個類中如果有ArrayList 大小應該是動態增加的 怎麼說是已經固定了大小了?

其實這是因爲,對象存儲的並不是實際的內存,它只會存儲 指向 另一個對象的 內存地址。一般是4個字節大小。意思就是,你這個類中 一個int類型數據 要佔4個字節大小。 一個應用類型數據 也是佔4個大小。就能計算出 對象 應該要佔用的內存大小了。

GC

gc就是垃圾回收。哪些對象需要回收 主要通過可達性分析。即這個對象最終是否被root引用了。

在Java語言中,可作爲GC Roots的對象包括下面幾種:

虛擬機棧(棧幀中的本地變量表)中引用的對象。

方法區中類靜態屬性引用的對象。 方法區中常量引用的對象。

本地方法棧中JNI(即一般說的Native方法)引用的對象。

收集算法有

標記清除

複製算法

標記整理

分代回收

堆中的內存分配和回收,採取分代:

在這裏插入圖片描述

新對象先在伊甸區 分配。

Class文件結構

任何一個Class文件都對應着唯一一個類或接口的定義信息,但反過來說,類或接 口並不一定都得定義在文件裏(譬如類或接口也可以通過類加載器直接生成)。本章中,筆 者只是通俗地將任意一個有效的類或接口所應當滿足的格式稱爲“Class文件格式”,實際上它 並不一定以磁盤文件的形式存在。 Class文件是一組以8位字節爲基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地 排列在Class文件之中,中間沒有添加任何分隔符,這使得整個Class文件中存儲的內容幾乎 全部是程序運行的必要數據,沒有空隙存在。

虛擬機會把這些信息解析出來包括

全限定名,

簡單名稱,

符號引用,常量信息等等

存儲在方法區的常量池中。

後續字節碼指令都是通過 這些信息爲基礎 進行操作。

類加載與解釋執行

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

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載 (Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個 部分統稱爲連接(Linking),這7個階段的發生順序如圖7-1所示。

在這裏插入圖片描述

圖7-1中,加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程 必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階 段之後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。注 意,這裏筆者寫的是按部就班地“開始”,而不是按部就班地“進行”或“完成”,強調這點是因 爲這些階段通常都是互相交叉地混合式進行的,通常會在一個階段執行的過程中調用、激活 另外一個階段。

什麼情況下需要開始類加載過程的第一個階段:加載?Java虛擬機規範中並沒有進行強 制約束,這點可以交給虛擬機的具體實現來自由把握。但是對於初始化階段,虛擬機規範則 是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要 在此之前開始):

1)遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初 始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字 實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常 量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。

2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化, 則需要先觸發其初始化。

3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父 類的初始化。

4)當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個 類),虛擬機會先初始化這個主類。

5)當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後
的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄 所對應的類沒有進行過初始化,則需要先觸發其初始化。

“加載”是“類加載”(Class Loading)過程的一個階段,希望讀者沒有混淆這兩個看起來 很相似的名詞。在加載階段,虛擬機需要完成以下3件事情: 1)通過一個類的全限定名來獲取定義此類的二進制字節流。 2)將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。 3)在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據 的訪問入口。

驗證是連接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息 符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存 都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這 時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將 會在對象實例化時隨着對象一起分配在Java堆中。其次,這裏所說的初始值“通常情況”下是 數據類型的零值,假設一個類變量的定義爲: public static int value=123; 那變量value在準備階段過後的初始值爲0而不是123,因爲這時候尚未開始執行任何Java 方法,而把value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方 法之中,所以把value賦值爲123的動作將在初始化階段纔會執行。

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,符號引用在前一章講 解Class文件格式的時候已經出現過多次,在Class文件中它以CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所 說的直接引用與符號引用又有什麼關聯呢? 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可 以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的 內存佈局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內存佈局可以各 不相同,但是它們能接受的符號引用必須都是一致的,因爲符號引用的字面量形式明確定義 在Java虛擬機規範的Class文件格式中。 直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是 一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引 用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目 標必定已經在內存中存在。 虛擬機規範之中並未規定解析階段發生的具體時間,只要求了在執行anewarray、 checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、 invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個用於 操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可 以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到 一個符號引用將要被使用前纔去解析它。

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點 限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、 CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、 CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7種常量類型

類初始化階段是類加載過程的最後一步,前面的類加載過程中,除了在加載階段用戶應 用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化 階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。 在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通 過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始 化階段是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊 (static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決 定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前 面的靜態語句塊可以賦值,但是不能訪問,如代碼清單7-5中的例子所示。 代碼清單7-5 非法向前引用變量 public class Test{ static{ i=0;//給變量賦值可以正常編譯通過 System.out.print(i);//這句編譯器會提示"非法向前引用" }static int i=1; }<clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不 需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的< clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定 是java.lang.Object。 由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子 類的變量賦值操作,如在代碼清單7-6中,字段B的值將會是2而不是1。 代碼清單7-6<clinit>()方法執行順序
static class Parent{ public static int A=1; static{ A=2; }}static class Sub extends Parent{ public static int B=A; }public static void main(String[]args){ System.out.println(Sub.B); }<clinit>()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也 沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成<clinit>()方法。 接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會 生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行 父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另
外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多 個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他 線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。

類加載器:

從Java虛擬機的角度來講,只存在兩種不同的類加載器:一種是啓動類加載器 (Bootstrap ClassLoader),這個類加載器使用C++語言實現[1],是虛擬機自身的一部分;另 一種就是所有其他的類加載器,這些類加載器都由Java語言實現,獨立於虛擬機外部,並且 全都繼承自抽象類java.lang.ClassLoader。

啓動類加載器(Bootstrap ClassLoader):前面已經介紹過,這個類將器負責將存放在< JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機 識別的(僅按照文件名識別,如rt.jar,名字不符合的類庫即使放在lib目錄中也不會被加載) 類庫加載到虛擬機內存中。啓動類加載器無法被Java程序直接引用,用戶在編寫自定義類加 載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可。我們一般使用的類都是 它加載的。

擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher $ExtClassLoader實現,它負責加載<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系 統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。

應用程序類加載器(Application ClassLoader):這個類加載器由sun.misc.Launcher $App- ClassLoader實現。由於這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回 值,所以一般也稱它爲系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類 庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一 般情況下這個就是程序中默認的類加載器。

虛擬機字節碼執行引擎

執行引擎是Java虛擬機最核心的組成部分之一。“虛擬機”是一個相對於“物理機”的概 念,這兩種機器都有代碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、硬 件、指令集和操作系統層面上的,而虛擬機的執行引擎則是由自己實現的,因此可以自行制 定指令集與執行引擎的結構體系,並且能夠執行那些不被硬件直接支持的指令集格式。

方法調用 方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本 (即調用哪一個方法),暫時還不涉及方法內部的具體運行過程。在程序運行時,進行方法 調用是最普遍、最頻繁的操作,但前面已經講過,Class文件的編譯過程中不包含傳統編譯中 的連接步驟,一切方法調用在Class文件裏面存儲的都只是符號引用,而不是方法在實際運行 時內存佈局中的入口地址(相當於之前說的直接引用)。這個特性給Java帶來了更強大的動 態擴展能力,但也使得Java方法調用過程變得相對複雜起來,需要在類加載期間,甚至到運 行期間才能確定目標方法的直接引用。

所有方法調用中的目標方法在Class文件裏面都是一個常 量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化爲直接引用

這 種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方 法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編 譯時就必須確定下來。這類方法的調用稱爲解析(Resolution)。

在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和 私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決 定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解 析。

方法 分派

1.靜態分派

所有依賴靜態類型來定位方法執行版本的分派動作稱爲靜態分派。靜態分派的典型應用 是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執 行的。另外,編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本並不 是“唯一的”,往往只能確定一個“更加合適的”版本。這種模糊的結論在由0和1構成的計算機 世界中算是比較“稀罕”的事情,產生這種模糊結論的主要原因是字面量不需要定義,所以字 面量沒有顯式的靜態類型,它的靜態類型只能通過語言上的規則去理解和推斷。

2.動態分派

瞭解了靜態分派,我們接下來看一下動態分派的過程,它和多態性的另外一個重要體 現[3]——重寫(Override)有着很密切的關聯。

代碼在內存中運行過程

在這裏插入圖片描述

從上圖我們看到了一個程序在內存中執行的過程。

上圖的執行流程:

1.從 disk 中將 MainApp.class 加載到 jvm 的方法區中。

2.執行 main 方法,將該 main 方法中包含的變量和函數,壓到棧中。

3.開始執行 main 方法中的指令,創建一個 animal 對象, 將 new 出來的 animal 對象存儲到堆中,animal 引用指向堆中的 animal 對象,堆中的 animal 對象指向方法區中的 Animal 類。

4.繼續執行 main 方法中的指令,調用 animal 對象中的 printName() 方法,這時 animal 應用調用 animal 對象, animal 對象找到方法區的 Animal 類中的 printName() 字節碼信息,根據該描述信息,開始執行 printName方法。

當然實際可能更加複雜。

在這裏插入圖片描述

從左側我們看到有兩個類,按照Java程序的執行流程,會把這兩個類編譯成 .class 文件,即圖中最右邊的 Phone.class he Demo01PhoneOne.class。

首先程序開始執行是從 main() 方法開始,這個時候會把 main() 方法壓到棧中,main() 方法中的第一句代碼是先創建一個 Phone 對象,當我們 new 一個對象時,會把 new 出來的對象放到堆中,相對應的給這個對象分配一個地址值,在棧中會產生一個實例 one 會指向這個地址,可以看到堆中的對象包含了自身的成員變量和成員方法的引用。

接着繼續執行下面的代碼,直接打印對象的屬性值,由於對象屬性沒有進行賦值,所以輸出的都是對應數據類型的默認值。 繼續下面的操作,就是給對象的屬性進行賦值,由於 one 是指向了對象,所以直接可以進行操作,這時在堆中的屬性值就會被賦予對應的值了。再次打印的時候就會打印出對應的值。

再到後面,繼續調用了對象的成員方法,這個時候需要先在堆中找到這個成員方法的應用,然後找到方法區中將對應的代碼壓到棧中,繼續執行。調用方法會傳入對應的參數,也是放到棧中的,執行完這個方法之後,壓到棧中的這一部分代碼就會出棧,直到 main() 方法中所有的代碼執行完,棧中的內容也就全部消失,內存也就隨之釋放。

總結

分清什麼是實例什麼是對象。Class a= new Class(); 此時 a 叫實例,而不能說 a 是對象。實例在棧中,對象在堆中,操作實例實際上是通過實例的指針間接操作對象。多個實例可以指向同一個對象。

棧中的數據和堆中的數據銷燬並不是同步的。方法一旦結束,棧中的局部變量立即銷燬,但是堆中對象不一定銷燬。因爲可能有其他變量也指向了這個對象,直到棧中沒有變量指向堆中的對象時,它才銷燬,而且還不是馬上銷燬,要等垃圾回收掃描時纔可以被銷燬。

以上的棧、堆、代碼段、數據段等等都是相對於應用程序而言的。每一個應用程序都對應唯一的一個JVM實例,每一個JVM實例都有自己的內存區域,互不影響。並且這些內存區域是所有線程共享的。這裏提到的棧和堆都是整體上的概念,這些堆棧還可以細分。

類的成員變量在不同對象中各不相同,都有自己的存儲空間(成員變量在堆中的對象中)。而類的方法卻是該類的所有對象共享的,只有一套,對象使用方法的時候方法才被壓入棧,方法不使用則不佔用內存。

對象類型作爲方法的參數或者方法的返回值時,傳遞的都是對象的地址值。再其他地方修改這個對象的屬性值時,原有的值就會被覆蓋掉。

本文,僅作爲自己的學習筆記記錄,參考資料

Java程序在內存中運行詳解

《深入理解Java虛擬機第二版》

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