什么是Java虛擬機?
Java虛擬機,從字面上來看,像是某種機器,但Java虛擬機之所以被稱之為“虛擬”的,是因為它是由一個規范來定義的抽象計算機,所以在我們說Java虛擬機的時候,可能指的是如下三種不同的東西:
抽象規范
一個具體的實現
一個運行中的虛擬機實例
Java虛擬機的生命周期
當啟動一個Java程序時,一個虛擬機實例也就誕生了。該程序關閉退出時,虛擬機實例也就隨之消亡。Java虛擬機通過調用某個初始類的main()方法作為Java程序運行的起點。在Java虛擬機內容,有兩種線程,守護線程和非守護線程。守護線程一般為虛擬機自己使用,比如垃圾收集線程,非守護線程比如運行main()的線程。當程序中所有非守護線程終止時,則虛擬機實例自動退出。
Java虛擬機的體系結構
在Java虛擬機規范中,一個虛擬機實例的行為按照子系統、內存區、數據類型以及指令幾個術語來描述。每個Java虛擬機都有一個類裝載子系統,它根據全限定名來裝入系統,同樣,每個Java虛擬機都有一個執行引擎,它負責執行那些包含在被裝載類的方法中的指令。
當Java虛擬機運行一個程序時,它需要內存來存儲許多東西,比如,字節碼、對象、局部變量、運算的中間結果等。某些運行時數據區由程序中所有線程共享,還有一些只能由一個線程擁有。每個虛擬機都有一個方法區和一個堆,他們是由該虛擬機中所有線程共享的。當虛擬機裝載一個class文件時,會把類型信息存入方法區,程序運行時虛擬機會把運行時創建的對象存入堆中。
每當一個新線程創建的時候,它都將獲得它自己的PC寄存器和一個Java棧,若線程正在執行一個Java方法(非本地方法),那么PC計數器的值總是指示下一條將被執行的指令,Java棧則包含線程中Java方法的調用狀態----包括它的局部變量,被調用時傳進來的參數、它的返回值、計算的中間結果等。本地方法調用,則是以某種依賴於具體實現的方式存儲在本地方法棧中,也可能是寄存器和其他某些與特定實現相關的內存區。
Java棧是由許多棧幀或者說幀組成,一個棧幀包含一個Java方法的調用狀態。當線程調用一個Java方法時,虛擬機壓入一個新的棧幀到棧中,方法返回后此棧幀彈出並拋棄。
數據類型
數據類型可分為兩類:基本類型和引用類型,基本類型持有原始值,引用類型持有引用值。Java基本類型的值域在任何地方都是一致的,比如一個long類型在任何虛擬機中都是64位二進制補碼表示的有符號整數。
注意:boolean有些特別,雖然boolean也是基本類型,但是在編譯為字節碼時,它會用int或者byte來表示boolean,false表示為整數0,true為整數1。另外boolean數組是被當做byte數組類訪問的。
字長的考量
Java虛擬機中,最基本的數據單元就是字,虛擬機實現者最少選擇32位作為字長,或者選擇更為高效的字長。通常根據底層主機平台的指針長度來選擇字長。
類裝載器子系統
Java虛擬機中有兩種類裝載器:啟動類裝載器和用戶自定義裝載器。前者是Java虛擬機實現的一部分,后者是Java程序的一部分。不同類裝載器放在虛擬機內部的不同命名空間中。
ClassLoader中定義的方法為程序提供了訪問類裝載器機制的接口。每一個被裝載的類型,Java虛擬機都會為它創建一個java.lang.Class類的實例來代表該類型。用戶自定義的類裝載器以及Class類的實例都放在內存中的堆區,而裝載的類型信息則都位於方法區。
類裝載器除了要定位和導入二進制class文件外,還需要負責導入類的正確性,分配變量和初始化內存,解析符號引用等。這些動作必須嚴格按照以下步驟進行:
1、裝載 -------- 查找並載入二進制數據
2、連接 -------- 執行驗證,准備,已經解析(可選)
驗證: 確保被導入類型的正確性
准備:為類變量分配內存,並將其初始化為默認值
解析:把類型中的符號引用轉換為直接引用
3、初始化 -------- 把類變量初始化為正確的初始值
方法區
在Java虛擬機中,關於被裝載類型的信息存儲在一個邏輯上被稱為方法區的內存中。當虛擬機裝載某個類型時,首先使用類裝載器定位相應的class文件,然后讀入class文件 ------- 一個線性二進制數據流,然后將它傳輸到虛擬機中。之后虛擬機提取出其中的類型信息存入方法區,同時該類中的靜態變量也是存儲在方法區中。
所有線程都共享方法區,所以它們對方法區的訪問必須為線程安全的,比如,如果兩個線程同時都企圖訪問名為Lava的類,而此類尚未裝載進虛擬機,那么,這時只應該有一個線程去裝載它,另一個只能等待。
方法區大小不是固定的,可以根據需要自己調整。同樣方法區也不必是連續的,方法區可以在同一個堆中自由分配,也可以由程序員指定方法區的初始大小的最大尺寸和最小尺寸等。
方法區也可以被垃圾收集,虛擬機允許用戶定義的類裝載器來動態擴展Java程序(反射),因此一些類也會成為程序“不再引用”的類。當某個類不再被引用時,Java虛擬機可以卸載此類。
對應每個裝載的類型,虛擬機會在方法區存儲以下類型信息:
此類的全限定名
此類的直接超類全限定名
此類是類類型還是接口類型
此類的訪問修飾符
任何直接超類的全限定名的有序列表
除以上列出的基本類型信息,虛擬機還得為每個裝載的類型存儲以下信息
該類型的常量池
字段信息
方法信息
除了常量以外所有類的(靜態)變量
一個到類的ClassLoader引用
一個到Class類的引用
堆
Java程序運行時創建的所有類實例和數組都放在同一個堆中,而一個Java虛擬機實例中只有存在一個堆空間,因此所有線程都將共享這個堆。由於每一個Java程序獨占一個堆空間,因此所有的線程將共享這個堆。但是同一個程序的多個線程卻共享着同一個空間,此種情況下,需要考慮多線程訪問對象(堆數據)的同步問題。
Java虛擬機有一條在堆中分配新對象的指令,卻沒有釋放內存的指令。正如我們無法用Java代碼去明確釋放一個對象一樣,字節碼中也沒有相關功能。需要虛擬機自己負責決定如何已及何時開始垃圾收集。程序本身也不需要關心何時回收,通常虛擬機把這個任務交給垃圾收集器。
Java虛擬機規范並沒有規定Java對象在堆中是如何表示的。對象的內部表示影響整個堆以及垃圾收集器的設計,它由虛擬機的實現者決定。
一種可能的堆空間設計為,把堆分為兩個部分:一個句柄池,一個對象池,而一個對象引用則是指向句柄池的本地指針。句柄池分為兩個部分:一個指向對象實例變量的指針,一個指向方法區類型的指針。這種設計的有點為有利於堆碎片的整理,缺點為每次訪問對象都要經過兩次指針的傳遞。
另一種設計是使對象指針直接指向一組數據,而該數據包括對象的實例以及方法區中的類數據指針。此設計優缺點與上一中方法正好相反。
虛擬機必須要能通過對象的引用獲取到類(類型)數據:在程序運行時需要轉換某個對象引用為另一種類型時,虛擬機需要檢查這種轉換是否被允許,及被轉換的對象是否確定是被引用的類型以及它的超類。比如程序執行instanceof()時,虛擬機都需要查看被引用數據的對象的類數據。最后,當程序中調用某個實例方法時,虛擬機必須進行動態綁定。
不管虛擬機的實現是用了什么樣的對象表示法,每個對象都可能有一個方法表,因為方法表可以加快調用實例的速度。但是Java虛擬機的實現規范中並未要求必須使用方法表,所有並不是所有實現都會使用它。而且為每一個對象都建一個方法表會占用更多的內存,所以該方案只適用於內存足夠的系統。
上圖中展示了一種把方法表和對象引用聯系起來的方式,每個對象都包含一個指向特殊數據結構的指針,這個數據結構位於方法區,它包括兩部分:
一個指向方法區對應類數據的指針
此對象的方法表:方法表是一個指針數組,其中每一項都是一個“實例方法的指針”,方法表指向的實例方法數據包括以下信息:
此方法的操作數棧和局部變量區的大小
此方法的字節碼
異常表
方法表中包含方法指針,指向類或其超類聲明的方法數據:也就是說方法表中所指向的方法可能是此類聲明的,也可能是繼承下來的。
堆上的對象數據中還有一個邏輯部分,那就是對象鎖,這是一個互斥對象。虛擬機上每個對象都有一個對象鎖,被用於協調多個線程訪問同一個對象的同步。在任何時刻,只能由一個線程“擁有”這個對象鎖,因此只有這個線程能訪問此對象的數據,其他想訪問此對象的線程只能等待,直到擁有此鎖的線程釋放鎖。(某個線程擁有一個對象鎖后,可以繼續對此鎖追加請求。需要注意的是,請求幾次,對應地需要釋放幾次,比如一個線程請求了三次鎖,在它釋放三次鎖之前,他一直“擁有”此鎖)。很多對象在其生命周期內沒有被任何線程加鎖,所以鎖數據不是必須存在的,只有在第一次加鎖的時候才會分配鎖數據,這時虛擬機需要用某種方法來聯系對象數據和對應的鎖數據,比如把鎖數據放在一個以對象地址為索引的搜索樹中。
除了實現鎖所需要的數據外,每個Java對象邏輯上與實現等待集合的數據相關聯。等待集合和通知方法聯合使用,每個類都從Object繼承三個等待方法(三個名為wait()的重載方法)和兩個通知方法(notify()和notifyAll())。當某個線程在一個對象上調用等待方法時,虛擬機就阻塞這個線程,並把它放到相應的等待集合中,直到另一個線程在同一個對象上調用通知方法,虛擬機才會在某個時刻喚醒一個或多個等待集合中被阻塞的線程。
最后一種數據類型 ------- 可以作為堆中某個對象映像的一部分,是與垃圾收集器有關的數據。垃圾收集器必須以某種方式跟蹤引用的每個對象。此任務不可避免需要附加一些數據給這些對象,數據類型需要根據垃圾收集器的算法而定。
數組的內部表示,數據也擁有一個與它們的類相關聯的Class類實例。同樣也在堆中表示,與其他對象一樣,數組也擁有一個與它們的類相關聯的Class類實例,所有具有相同維度和類型的數組都是同一個類的實例。而不管長度是多少。
數組類的名稱由兩部分組成:每一維用一個方括號表示"[",用字符或字符串表示元素類型,比如,類型為int的一維數組“[1”,元素類型為byte的三維數組為“[[[B”,類型為Object的二維數組為"[[Ljava/lang/Object"。
程序計數器
對於每一個運行中的Java程序而言,其中的每一個線程都有它自己的PC寄存器,它是在該線程啟動時創建的,PC寄存器的大小是一個字長。當線程執行某個Java方法時,PC寄存器的內容總是下一條將被執行的"地址"。 這里“地址”可以是一個本地指針,也可以是方法字節碼中相對於該方法起始的偏移量。
Java棧
每當啟動一個線程,Java虛擬機都會為它分配一個Java棧。Java棧以幀的形式為單位保存線程的運行狀態,虛擬機只會對棧執行兩種操作:以幀為單位的壓棧和出棧。
每當線程調用一個Java方法時,虛擬機都會在該線程的Java棧中壓入一個新幀。當前使用的棧幀被稱為當前幀,在執行這個方法時,它使用這個幀來存儲參數,局部變量,中間運算結果等等數據。
Java方法可以由兩種方式完成,一種是通過return返回,一種是拋出異常終止。不管以哪種方式返回,虛擬機都會將當前棧幀彈出Java棧然后釋放掉。此外,Java棧上的所有數據都是此線程私有的。任何線程不可訪問另一個線程的棧數據。
棧幀
棧幀有三部分組成:局部變量區,操作數棧和幀數據區
當虛擬機調用一個Java方法時,它從對於類的類型信息得到此方法的局部變量區和操作數棧的大小,並據此分配棧幀內存,然后壓入Java棧中。
局部變量區:Java棧幀的局部變量區被組織為一個字長為單位、從0開始計數的數組。字節碼通過從0開始的索引來使用其中的數據。類型為int,float,reference和returnAddress的值在數組中只占據一項,類型byte,short和char的值存入數組前將被轉換為int,因而同樣占據一項,類型為long和double的值在數組中占據連續的兩項。
需要注意的是,在runInstanceMethod()中,局部變量的第一個參數是一個reference引用類型,這個參數this用於表示對象本身,對於任何一個實例方法this都是隱含加入的。
操作數棧:和局部變量區一樣,操作數棧也是被組織成一個以字長為單位的數組。但是和前者的區別是,它不是通過索引來訪問的,而是通過標注的棧操作---壓棧和出棧來訪問的。
不同於程序計數器,Java虛擬機沒有寄存器,程序計數器也無法被程序指令直接訪問,Java虛擬機指令是從操作數棧中而不是寄存器中取得操作數的,因此它的運行方式是基於棧的而不是基於寄存器的。下圖演示了兩個局部變量相加的過程
幀數據區:除了局部變量和操作數棧外,Java棧幀還需要一些數據來支持常量池的解析,正常方法的返回以及異常派發機制。這些信息都存儲在Java幀棧的幀數據區中。
Java中大多數指令都涉及到常量池入口。有些指令僅僅是從常量池中取出數據壓入Java棧(這些數據包括int,long,float,double和String),還有些指令使用常量池中的數據來指示要實例化的類或數組、要訪問的字段或者要調用的方法。
當虛擬機需要使用到常量池中的數據時,它會通過幀數據區中指向常量池的指針來訪問他它。常量池中對於類型、字段和方法的引用在開始的時候都是符號。當虛擬機在常量池中搜索的時候,如果遇到的類型、字段和方法仍然是符號,虛擬機這時才會去解析。
Java棧可能的實現方式:一種可能的方式,從堆中分配每一個幀,例如,下面考慮下面的類
下圖顯示了addAndPrint()方法的三次快照。在這個Java虛擬機實現中,每個幀都單獨從堆中分配。為了調用方法addTowTypes(),方法addAndPrint()首先把1和88.88壓入它的操作數棧中,然后調用addTowTypes()方法。
調用addTowTypes()的指令指向一項常量池的數據,因此在常量池中查找這些數據,這期間有必要還需進行解析。
解析后的常量池數據將指向方法區中對應的addTwoTypes()的信息。虛擬機需要利用這些信息來確定addTwoTypes()的局部變量區和操作數棧的大小。
本地方法棧:
前面所有運行時數據區都是在Java虛擬機規范中明確定義的,不過程序運行時可能還會使用到一些與本地方法相關的數據區。當某個線程調用一個本地方法時,它就進去了一個全新的並且不受虛擬機限制的世界。
當線程調用Java方法時,虛擬機會創建一個新的棧幀並壓入Java棧中。但它調用的是本地方法,虛擬就就會保持Java棧不變,不再在線程的Java棧中壓入新的幀,虛擬機只是簡單的動態鏈接並直接調用使用本地方法。
下面展示了線程調用本地方法的過程:
我們首先調用兩個方法,然后在這兩個方法中的第二個方法中調用一個本地方法,導致虛擬機使用一個本地方法棧,假設這是一個C語言棧,期間有兩個C函數,第一個C函數被第二個Java方法調用,而后第一個C函數調用第二個C函數,第二個C函數又調用一個Java方法。之后第二個C函數又通過本地方法接口回調一個Java方法,最終這個Java方法又調用一個Java方法。
與其他運行時內存區一樣,本地方法棧所占用的內存區也不是固定大小的,它可以根據需要動態擴展或者收縮。某些實現也允許用戶或者程序員指定內存區初始大小以及最大最小。
參考:深入理解Java虛擬機第二版