認識JVM--第二篇-java對象內存模型
前一段寫了一篇《認識JVM》,不過在一些方麵可以繼續闡述的,在這裏繼續探討一下,本文重點在於在heap區域內部對象之間的組織關係,以及各種粒度之間的關係,以及JVM常見優化方法,文章目錄如下所示:
1、回顧--java基礎的對象大概有哪些特征
2、上一節中提到的Class加載是如何加載的
3、一個對象放在內存中的是如何存放的
4、調用的指令分析
5、對象寬度對其問題及空間浪費
6、指令優化
正文如下:
1、回顧--java基礎的對象大概有哪些特征?
相信學習過java或者叫做麵向對象的人至少能說出麵向對象的三大特征:封裝、繼承、多態,在這裏我們從另一個角度來看待問題,也就是從設計語言的角度來說,要設計一門類似於java的語言,它需要的特征是什麼?
->首先所有的內容都應該當基於“類”來完成。
->單繼承特征,並且為單根繼承(所有類的頂層父類都是java.lang.Object)
->重載(OverLoad)、重寫(Overriding)
->每個區域可以劃分為對象類型、原生態的變量、方法,他們都可以加上各種作用域、靜態等修飾詞等。
->支持內部類和內部靜態類。
->支持反射模型。
通過上麵,我們知道了java的對象都是由一個class的描述來完成的(其實class本身也是由一個數據結構的描述,隻不過用它來描述對象的形狀和模型,所以我們暫時理解class就是一個描述,不然這裏一層一層向下想最終可能什麼都想不出來或者可能想到的是匯編語言,嗬嗬,站在這一層要研究它就將下一層當成底層,原理上的支撐都是一樣的道理),那麼最關鍵就是如何通過構造出一個class的樣子出來,此處我們不討論關於javaCC方麵的話題,也就是編譯器的編譯器問題,就單純如何構建這種模型而探討;在jvm的規範中明確說明了一點:java不強製規定用某種格式來限定對象的存儲方法。也就是說,無論你怎麼存儲,隻要你能將類的意義表達出來,並可以相互關聯即可。
在語言構建語言的基礎上,很多時候都是通過底層語言去編寫高級語言的編譯器或解釋器,編寫任何一門語言的基礎都離不開這門語言的對象存儲模型,也就是對象存儲方式;如java,標準的SUN(現在是ORACLE),它是通過C++編寫的,而BEA的jdk是純C語言編寫的,IBM的jdk有一部分是C,一部分是C++語言。
你也可以實現一個類似於java的對象模型,比如你用java再去編寫一門更加高級的語言(如:Groovy),你要構建這門語言的對象模型就是這樣一個思路,至於javaCC隻是將其翻譯為對應運行程序可以識別模型來表達出來而已,就像常規的應用設計中,要設計業務的實現,就要先設計業務的模型,也就是數據結構了;語言也是這樣,沒有結構什麼也談及不上,數據結構也就是在最基本、最底層的架構層麵,脫離出一些邏輯結構,來達到某些編程方麵的目的,如有些是為了高效、有些是為了清晰編碼。
在內存中本身是不存在類這種概念的,甚至於可以說連C的結構體也是不存在的,內存中最基本的對象隻有兩種:一個是鏈表、一個是數組,所有其他的模型都是基於這些模型邏輯構建出來的,那麼當要去構建一個java的對象的時候,根據上麵的描述你應當如何去構建呢?下一章就事論事的方式來討論一下。
2、上一篇文章中的class是如何加載和對象如何綁定的
在上一篇文章中已經提及到了class初始化加載到內存中的結構以及動態裝載的基本說明;而在裝載的過程中java本身有一個不成文的規定,那就是在默認情況下或者說一般情況下Perm區域的Class類的區域,是不會被修改的,也就是說一個class類在被裝入內存後,它的地址是不會再被修改的,除非用一些特殊的API去完成,常規的應用中,我們也沒有必要的說明這個問題,也不推薦去使用這個東西;在後麵的運行時優化中會提到jvm會利用這個特征去做一些優化,如果失去這個特征就會失去一些優化的途徑。
那麼如果要組織一個對象,對象首先肯定需要劃分代碼段、數據段,代碼段需要和數據段進行綁定;
首先我們用最基本、最簡單的方法來做:就是當我們發起一個new XXX();時,此時將代碼段從Perm中拷貝一份給對象所處的空間,首先我們的代碼段應該寫入的內容就是:屬性列表、方法列表,而每一個列表中通過名稱要找到對應的實體,通過最底層的數據結構是什麼呢?最簡單的就是我們定義一個數組,存放所有的目標的數據地址位置,而名稱用另一個數組,此時遍曆前一個數組逐個匹配,找到下標,通過相同下標,找到實際數據的地址。你看到這裏是不是有一些疑惑,這樣的思路就像小學生一樣,太爛了,不過JVM還真經曆過這個過程,我們先把基本的思路提出來,接下來再來看如何優化和重構模型。
通過上麵的簡單思路不難發現了兩個問題:一個問題就是相同的對象經常被反複的構造,我們先不知道代碼段的大小,先拋開這個問題,後麵看看如果在內存中要構造一個代碼段應該如何構造再看代碼段的大小;另一個問題是你會發現這樣是不是很慢,而且在對象的名稱與地址之間,這個二元組上就很像我們所謂的K-V格式,那麼Hash表呢,Hash表不正是表達K-V格式的嗎,而且匹配效率要高出很多(其實Hash表也是通過數學算法加上數組和鏈表來實現的),隻是又從邏輯上抽象了一下而已;而不論是通過什麼數據結構來完成,它始終有一個名稱對應值的結構,隻要能實現它,java不在乎於你使用什麼結構來實現(JVM規範中說明,隻要你能表達出java的對象內部的描述信息,以及調用關係,你就是符合JVM規範的,它並不在乎於你是用什麼具體的數據結構來表達),那麼一起來看看如果你要構建一個java的語言的對象模型應當如何構建呢?
綜上,我們要首先定義一個java的基本類,我們首先在邏輯上假設,要在代碼段內部知道的事情是:
HashMap<String,Class<? extends Object>> classes;
由此可以通過名稱定位到代碼段的空間。
而在一個Class內部要找到對應的屬性,我們也需要定義它們的關係:
Field []params;//表示該列的參數列表
Method[]methods;//表示該類的方法列表
Class []innerClass;//該類的內部類和靜態內部類列表
Class parentClass;//該類的直接父親類引用
Map<String , Object>;//用於存放hash表
其實代碼段隻是由相對底層的語言,構造的另一種結構,也就是它本身也是數據結構,隻是從另一個角度來展現,所以你在理解class的定義的時候,你本身就可以將其理解為一個對象,存儲在Perm中的一個對象;
上麵為一種偽語言的描述,因為java不要求你用什麼去實現,隻要能描述它的結構就可以,所以這種描述有很多的版本,至於這個不想做過多的追究,繼續深入的就是通過發現,要構造一個class的定義,是不容易的,它也會開銷不小的內存;如果像上麵我們說的,你定義一個對象,就把class拷貝過來,也就是上麵說到存儲在Perm定義部分的對象,那麼這個空間浪費將會成倍數上漲,所以我們想到的下一個辦法就是在利用JVM的class初始化後,它的地址就不會發生變化(上麵說了,除非用特殊的API,否則不會發生變化),那麼我們在定義對象的時候,就用一個變量指向這個class的首地址就可以了,這樣對象的定義部分就隻有一份公共的存儲了,類似靜態常量等JVM也采用相同的手段,抽象出來存儲一份,這樣來節約空間的目的。
好了,空間是節約下來了,接下來,當要對對象加鎖synchronize的時候(這裏也不討論純java實現的Lock實現類和Atomic相關包裝類),加在哪裏,當要對所有的同類對象加鎖的時候加在哪裏?它就是加在對象的頭部,前麵說了,class的定義也可以當成一個已經被初始化好的對象,所以鎖就是可以在兩個粒度的頭部上去加鎖了,當代碼運行到要加鎖頭部的時候,就會去找這個對應的位置是否已經被加鎖,如果已經被加鎖,會處於一個等待池中,根據優先級然後被調用(順便提及一下,java對線程優先級是10個級別(1-10),默認是5,但是操作係統未必支持,所以有些時候優先級太細在多數操作係統上是沒有作用的,很多時候就設置為最大、最小或者不設置)。
順便補充一下,在上一節中已經提到,關於對象頭部,在早期的JVM中,對象是沒有所謂的頭部的,這部分內容在JVM的一塊獨立區域中(也就是有一塊獨立的handle區域,也就是一個引用首先是經過handle才會找到對象,java在對象引用之間關係比較長,這樣會導致非常長的引用問題,另外在GC的算法上也會更加複雜,並且擴展空間時,handle和對象本身是在兩塊不同的空間,但是由於都需要擴展空間,可能會導致更多的問題出現;最後它將會在申請空間時由於處理的複雜性多使用更多的CPU指令,現在的JVM每個new的開銷大概是10個CPU指令,效率上可以和C、C++媲美),不過後來發現這樣的設計存在很多的問題,所以現在的jvm所有的都是有頭部的問題,至於頭部是什麼在第五章中一起探討一下。
上麵探討了一下關於Class定義的問題,假設出來是什麼樣的了,如果你要構造一個對象的基本描述,應該如何描述呢?下一章來詳細說明一下。
3、一個對象在內存中是如何存放的?
有關一個對象在對象中如何移動以及申請在上一篇文章中已經描述,目前我們模擬一下,如果你要設計一個對象在內存中如何存放應當如何呢?
在上麵說明了Class有定義部分,用獨立的位置來存放,對象用一個指針指向它,在這裏我們先隻考慮變量的問題,那麼有兩種思路去存放,一種就是采用一個HashMap<String,? exntends Object>去存放對象的名稱、和對象的的值,但是你發現這樣又把代碼段的名稱拷貝過來了,我們不想這樣做,那麼就定義一個和代碼段中數組等長的Object數組,即Object []obj = new Object[params.length];當然需要一個指向代碼段Class的地址向量,此時我們用一個:Class clazz來代表,其實當你用對象.class就是獲取這個地址,此時當需要獲取某個對象的值的時候,通過這個地址,找到對應的Class定義部分,在Class定義內部做一個Hash處理,找到對應的下標,然後再返回回來找到我們對應變量的對應下標,此時再獲取到對應的值。
問題是可以這樣解決,是不是感覺很繞,其實不論找到一個變量還是一個方法去執行的時候,都要通過Class的統一入口地址進去,然後通過遍曆或者Hash找到對應的下標位置或者說實際的地址,然後去調用對應的指令才開始執行;那麼這樣做我們感覺很繞,有沒有什麼方法來優化它呢,因為這樣java肯定會很慢,答案是肯定的,隻要有結構肯定就有辦法優化,在下麵說明了指令以及對象空間寬度問題後,在最後一章說明他有哪些優化方案。
貌似第三章就這麼簡單,也沒有什麼內容,java這個對象這麼簡單就被描述了?是的,往往越簡單的對象可以解決越加複雜的內容,複雜的問題隻有簡單化才能解決問題,不過要深入探討肯定還是有很多話題的,如:繼承、實現等方法,在java中,要求繼承中子類能夠包含父親類的protected、public的所有屬性和方法,並且可以重寫方法,在屬性上,java在實例化時,是完全可以在Class定義部分就完成的,因為在Class定義部分就可以完全將父類的相應的內容包含進來(不過它會標記出那些是父類的東西,那些是當前類的東西,這樣在this、super等多個重寫方法調用上可以分辨出來),避免運行時去遞歸的過程,而在實例化時,由於相應的Class中有這些標記,那麼就可以非常輕鬆的實現這些定義了,而在構造方法上,它通過子類構造方法入口,默認調用父親類,逐層向上再反向回來即可。
那麼目前看到這裏,可能比較關心的問題就是方法是如何調用的?對象頭部到底是什麼?調用的優化是如何做的?繼承關係的調用是怎麼回事,好吧,我們下麵來討論下如何做這些事情:
4、調用的指令分析:
要明白調用的指令,那麼首先要看看JVM為我們提供了哪些指令,在jdk 1.6已經提供的主要方法調用指令有:
invokestatic、invokespecial、invokevirtual、invokeinterface,在jdk 1.7的時候,提出了一條invokedynamic的指令,用以應付在以前版本的jdk中對動態代碼調用的性能問題,jdk 1.7以後用專門的指令要解決這一問題,至於怎麼回事,我也不清楚,隻是看文檔是這樣的,嗬嗬;下麵簡單介紹下前麵幾個指令大概怎麼回事(先說指令是什麼意思,後麵再說怎麼優化的)。
invokestatic一看就知道是調用靜態代碼段的,當你定義個static方法的時候,外部調用肯定是通過類名.靜態方法名調用,那麼運行時就會被解釋為invokestatic的JVM指令;由於靜態類型是非常特殊的,所以編譯時我們就完全獨立的確立它的位置,所以它的調用是無需再被通過一連串的跳轉找到的。
invokespecial這個是由JVM內部的一個父類調用的指令,也就是但我們發生一個super()或super.xxx()時或super.super.xxx()等,就會調用這個指令。
invokevirtual由jvm提供的最基本的方法調用命令,也就是直接通過 對象.xxx() 來調用的指令。
invokeinterface當然就是接口調用啦,也就是通過一個interface的引用,指向一個實現類的實例,並通過調用interface的類的對應方法名,用以找到實現類的實際方法。
這裏的指令在第一次運行時都需要去找到一個所謂的入口調用點,也成為call side,最基本的就是通過名稱,找到對應的java的class的入口,找到一個非動態調用的方法以及其多個版本號,根據實際的指令調用的對應的方法,編譯為指令運行。
明白了這些指令我們比較疑惑的就是在繼承與接口的方法調用上如何做到快速,因為一門語言如果本身就很慢的話,外部要調優也是無濟於事的,於是在找到多個實現類的時候,我們一般提出以下幾種查找到底要調用哪一個方法的假設,每一種假設他們都有一個性能的標準值。
當存在多層的繼承時,並存在著重寫等情況的時候,要考慮到實際調用的方法的時候,我們做以下幾種假設:
1、假如在初始化類中,將父類的相應的方法也包含進來,隻是做相應的標識碼,並且按照數組存放,此時,就會存在同名方法,做hash的話就有些困難了,當然你可以帶上標識符做hash,但是hash的KEY是唯一的,此時需要的不僅僅是自己的方法調用,還需要一連串的,不過可以按照製定的規則逐個查找。
2、另一種是不包含進來自下而上遞歸查找,也是較為原始的方法,雖然效率上有點低,不過大部分集成關係不會超過3層以上。
3、在這個角度,另一種方法是基於方法名的地址做縱向向量,也就是在自下向上的查找中,隻需要定位最後一個入口地址,直接調用便直接使用,當使用super的時候,就按照數組進行反向偏移量,這貌似是一個不錯的方法,不過查找呢,我們將這個數組做為一個整體的Value,來讓Hash找到,每個方法標識這自己來源於哪一個類,以及,由類關聯出他們的子孫關係即可。也就是說,在一般情況下,jvm認為繼承關係不是太長的,或者說是即使繼承關係很長,在繼承的關係鏈表中,自上而下任意找一條鏈上上去,重寫的方法個數並不是很多,一般最多保持在3、4個左右,所以在直接記錄層次上,是完全可行的;但是問題是,這種層次分析不允許在對象內部發生任何的層次變化,也就是純靜態的,但是java本身是支持動態Load的,所以靜態編譯器無法完成這個操作,而動態編譯器可以,在變化的過程中需要知道退路。
其實這部分有點類似於調用優化了,不過後麵還會說明更多的調用優化內容,因為從上述的閱讀中你應該會發現,一個操作後的調用會導致非常多的尋址,而且很多是沒有必要的,我們在最後一章配合一些簡單例子再來說明(例子中會說到上述的一些指令的調用),下一章先說明下對象在內存中到底是如何存儲和浪費空間的。
5、對象寬度及空間浪費
對象寬度這個說法很多時候都是C語言、C++這些底層語言中經常討論的話題,而通過這些語言轉變過來的人大多數對java比較反感的就是為什麼沒有一個sizeof的函數來讓我知道這個對象占用了多大的內存空間;java其實即使讓你知道大小也是不準確的,因為它中間有很多的對齊和中間開銷,如在多維數組中,java的前麵幾個維度都是浪費的空間,隻有最後一個維度的數據,也就是N多個一維數組才是真正的空間大小,而且它中間存在很多對象的對象等等概念。
那麼一個簡單對象,java的對象到底是如何存放的呢?首先明白一點,Hotspot的JVM中,java的所有對象要求都是按照8個byte對齊的,不論任何操作係統都是這樣,主要是為了在尋址時的偏移量比較方便。
然後,對象內部各個變量按照類型,如果對象是按照類型long/double占用8個byte、int/float占用4個byte,而short/char是占用2個byte,byte當然是占用一個了,boolean要看情況,一般也是一個byte,而對象內部的指向其他對象的引用呢?這個也是需要占用空間的,這個空間和OS和JVM的地址位數有關係,當然OS為32位時,這個就占用4個byte,當OS為64位數時,就占用8個byte,在根引用中,操作係統的stack指向的那個引用大小也是這樣,不過這裏是對象內部指向目標對象的寬度。
對象內部的每個定義的變量是否按照順序存儲進去呢?可以是也可以不是(上麵已經說了,JVM並不強製規定你在內存中是如何存放的,隻需要表達出具體的描述),但是一般不是,因為當采用這種方式的時候,當再內部定義的變量由於順序的問題,導致空間的浪費,比如在一個32位的OS中定義個byte,再定義一個int,再定義一個char,如果按照順序來存儲,byte占用一個字節,而int是4個字節,在一個內存單元下,下麵隻剩下3個byte,放不下了,所以隻有另外找一個內存單元存放下來,接下來的char也不得不單獨在用一塊4byte的內存單元來存放,這樣導致空間浪費(不過這樣尋址是最快的,因為按照OS的位數進行,是因為這是尋址的基本單位,也就是一個CPU指令發出某個地址尋址時,是按照地址帶寬為基本單位進行尋址的,而並非直接按照某個byte,如果將兩個變量放在同一個地址單元,那麼就會產生2次偏移量才能找到真正的數據,有關邏輯地址、線性地址、物理地址上的區別在上一篇文章說有概要的介紹);
不過在java默認的類中一般是按照順序的(比如java的一個java.lang.String這些類內存的順序都是按照定義變量的順序的),虛擬機知道這些類,相當於一些硬代碼或者說硬配置項,這也是虛擬機要認名字的一特征就像實例化序列化接口一樣,其實什麼都不用寫隻是告訴虛擬機而已;由於這些類在很多運行時虛擬機知道這些是自己的類,所以他們在內存上麵會做一些特殊的優化方案,而和外部的不是一樣的。
在Hotspot的JVM對參數FieldsAllocationStyle可以設置為0、1、2三種模式,默認情況下參數模式1,當采用0的時候:采用的是先將對象的引用放進去(記住,String或者數組,都是存放的引用地址),然後其他的基本變量類型的順序為從大到小的順序,這樣就大量避免了空間開銷;采用模式1的時候,也就是默認格式的時候,和0格式的唯一區別就是將對象引用放在了最後,其實沒什麼多大的區別;當采用模式2的時候,就會將繼承關係的實例化類中父子關係的變量按照順序進行0、1兩種模式的交叉存放;而另一個參數CompactFields則是在分配變量時嚐試將變量分配到前麵地址單元的空隙中,設置為true或者false,默認是true。
那麼一個對象分配出來到底有哪些內容呢,那我們來分析下一個對象除了正常的數據部分以及指向代碼段的部分,一般需要存放些什麼吧:
1、唯一標識碼,每一個對象都應該有一個這樣的編碼,唯一hash碼。
2、在標記清除時,需要標記出這個對象是否可以被GC,此時標記就應該標記在對象的頭部,所以這裏需要一個標識碼。
3、在前一篇文章中說明,在Young區域的GC次數,那麼就要記錄下來需要多少次GC,那麼這個也需要記錄下來。
4、同步的標識,當發生synchronized的時候,需要將對象的頭部記錄下對象已經被同步,同時需要記錄下同步該對象的線程ID。
5、描述自身對象的個數等內容的一個地方。等等。。也許還有很多,不過我們至少有這麼一些內容。
不過這些內容是不是每個時候都需要呢,也就是對象申請就需要呢?其實不然,如線程同步的ID我們隻需要在同步的時候在某個外部位置存放就可以了,因為我們可以認為線程同步一般是不會經常發生的,經常發生線程同步的係統也肯定性能不好,所以可以用一個單獨的地方存放。
前麵1-4,很多時候我們把這個區域叫做:_mark區域,而第五個地方很多時候叫做:_kclass區域。加在一起叫做對象的頭部(這個頭部一般是占用8個byte的空間,其中_mark和_kclass各自占用4個byte)。
現在明白了對象的頭部了,那麼對象除了頭部以外,還有其他的空間開銷嗎?那就是前麵提到Hotspot的java的對象都是按照8個byte的偏移量,也就是對象的寬度必須是8byte的整數倍,當對象的寬度不是8的整數倍數的時候,就會采用一些對其方式了,由於頭部本身是8個byte,所以大家寫程序可以注意一點,當你使用數據的空間byte為8的整數倍,這個對其空間就會被節約出來。
隨著上麵的說明,對其和頭部,我們來看看幾個基本變量和外包類的區別,Byte與byte、Integer與int、String a = "b";
首先byte隻占用一個byte,當使用Byte為一個對象時,對象頭部為8個字節,數據本身占用1個byte,對其寬度需要7個byte,那麼對象本身的開銷將需要16個byte,此時,也就是說兩者的空間開銷是16倍的差距,你的空間利用率此隻有6.25%,非常小;而int與Integer算下來是25%,String a = "b"的利用率是12.5%(其實String內部還有數組引用的開銷、數組長度記錄、數組offset記錄、hash值的開銷、以及序列化編碼的開銷,這裏都沒有計算進去, 這部分開銷如果要計算進去,利用率就低得不好描述了,嗬嗬,當然如果數組長度長一點利用率會提高的,但是很多時候我們的數組並不是很長),嗬嗬,說起來蠻嚇人的,其實空間利用還是靠個人,並不是說大家以後就不敢用對象了,關鍵是靈活應用,在jdk 1.5以後所謂的自動拆裝箱,隻是JVM幫你完成了相互之間的轉換,中間的空間開銷是免不掉的,隻是如果你的係統對空間存儲要求還是比較高的話,在能夠使用原生態類型的情況下,用原生態的類型空間開銷將會小很多。
補充說明一下,C、C++中的對象,直接在結構體得typedef後麵定義的默認接的那個對象,是沒有頭部的,純定義類型,當然C++中也有一個按照高位寬度對其的說法,並且和OS的地址寬度有關係,通過sizeof可以做下測試;但是通過指針=malloc等方式獲取出來的堆對象仍然是有一個頭部的,用於存放一些metadata內容,如對象的長度之類的。
好了。看到了指令,看到對象如何存儲,迫不及待的想要看看如何去優化的了,那麼我們看看虛擬機一般會對指令做哪些優化吧。
6、指令優化:
在談到優化之前我們先看一個簡單例子,非常簡單的例子,查看編譯後的文件的的指令是什麼樣子的,一個非常簡單的java程序,Hello.java
public class Hello {
public String getName() {
return "a";
}
public static void main(String []args) {
new Hello().getName();
}
}
我們看看這段代碼編譯後指令會形成什麼樣子:
C:\>javac Hello.java
C:\>javap -verbose -private Hello
Compiled from "Hello.java"
public class Hello extends java.lang.Object
SourceFile: "Hello.java"
minor version: 0
major version: 50
Constant pool:
const #1 = Method #6.#17; // java/lang/Object."<init>":()V
const #2 = String #18; // a
const #3 = class #19; // Hello
const #4 = Method #3.#17; // Hello."<init>":()V
const #5 = Method #3.#20; // Hello.getName:()Ljava/lang/Stri
const #6 = class #21; // java/lang/Object
const #7 = Asciz <init>;
const #8 = Asciz ()V;
const #9 = Asciz Code;
const #10 = Asciz LineNumberTable;
const #11 = Asciz getName;
const #12 = Asciz ()Ljava/lang/String;;
const #13 = Asciz main;
const #14 = Asciz ([Ljava/lang/String;)V;
const #15 = Asciz SourceFile;
const #16 = Asciz Hello.java;
const #17 = NameAndType #7:#8;// "<init>":()V
const #18 = Asciz a;
const #19 = Asciz Hello;
const #20 = NameAndType #11:#12;// getName:()Ljava/lang/String;
const #21 = Asciz java/lang/Object;
{
public Hello();
Code:
Stack=1, Locals=1, Args_size=1
0: aload_0
1: invokespecial #1; //Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 11: 0
public java.lang.String getName();
Code:
Stack=1, Locals=1, Args_size=1
0: ldc #2; //String a
2: areturn
LineNumberTable:
line 14: 0
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=1, Args_size=1
0: new #3; //class Hello
3: dup
4: invokespecial #4; //Method "<init>":()V
7: invokevirtual #5; //Method getName:()Ljava/lang/String;
10: pop
11: return
LineNumberTable:
line 26: 0
line 30: 11
}
看起來亂七八糟,不要著急,這是一個最簡單的java程序,我們按照正常的程序思路從main方法開始看,首先第一行是告訴你new #3;//class Hello,這個地方相當於執行了new Hello()這個命令,而#3是什麼意思呢,在前麵編譯的指令列表中,找到對應的#3的位置,這就是我們所謂的入口位置,如果指令還要去尋找下一個指令就跟著#找到就可以了,就想剛才#3又找到#19,其實是要找到Hello的定義,也就是要引用到Class的定義的位置。
繼續看下一步(關於內部入棧出棧的指令我們這裏不多說明),invokespecial #4; //Method "<init>":()V,這個貌似看不太懂,不過可以看到後麵是一個init方法,它到底初始化了什麼,我們這裏因為隻有一行代碼,我們姑且相信它初始化了Hello,不過invokespecial不是對super進行調用的時候才用到的嗎?所以這裏需要補充一下的就是當對象的初始化的時候,也會調用它,這裏的初始化方法就是構造方法了,在指令的時候統一命名為init的說法;
那麼調用它的構造方法,如果沒有構造方法,肯定會進入Hello的默認構造方法,我們看看上麵的public Hello(),發現它內部就執行了一條指令就是調用又調用一個invokespecial指令,這個指令其實就是初始化Object父對象的。
再繼續看下一條指令:invokevirtual #5; //Method getName:()Ljava/lang/String;你會發現是調用了getName的方法,采用的就是我們原先說的invokevirtual的指令,那麼根據到getName方法部分去:
會發現直接做了一個ldc #2; //String a操作就返回了,獲取到對應的數據的地址後就直接返回了,執行的指令在位置#2,也就是在常量池中的一個2。
好了一個簡單的程序指令就分析到這裏了,更多的指令大家可以自己去分析,你就可以看明白java在指令上是如何處理的了,甚至於可以看出java在繼承、內部類、靜態內部類的包含關係是如何實現的了,它並不是沒用,當你想成為一個更為專業和優秀的程序員,你應該知道這些,才能讓你對這門駕馭得更加自如。
幾個簡單的測試下來,會發現一些常見的東西,比如
==>你繼承一個類,那個類裏麵有一個public方法,在編譯後,你會發現這個父親類的方法的指令部分會被拷貝到子類中的最後麵來
==>而當使用String做 “+” 的時候,那怕是多個 "+" ,JVM會自動編譯指令時編譯為StringBuilder的append的操作(JDK 1.5以前是StringBuffer),大家都知道append的操作將比 + 操作快非常的倍數,既然JVM做了這個指令轉換,那麼為什麼還這麼慢呢,當你發現java代碼中的每一行做完這種+操作的時候,StringBuilder將會做一個toString()操作,如果下一次再運行就要申請一個新的StringBuilder,它的空間浪費在於toString和反複的空間申請;並且我們在前麵探討過,在默認情況下這個空間數組的大小是10,當超過這個大小時,將會申請一個雙倍的空間來存放,並進行一次數組內容的拷貝,此時又存在一個內部空間轉換的問題,就導致更多的問題,所以在單個String的加法操作中而且字符串不是太長的情況下,使用+是沒有問題的,性能上也無所謂;當你采用很多循環、或者多條語句中字符串進行加法操作時,你就要注意了,比如讀取文件這類;比如采用String a = "dd" + "bb" + "aa";它在運行時的效率將會等價於StringBuilder buf = new StringBuilder().append("dd").append("bb").append("aa");
但是當發生以下情況的時候就不等價了(也就是不要在所有情況下寄希望於JVM為你優化所有的代碼,因為代碼具有很多不確定因素,JVM隻是去優化一些常見的情況):
1、字符串總和長度超過默認10個字符長度(一般不是太長也看不出區別,因為本身也不慢)。
2、多次調用如上麵的語句修改為String a = "dd";a += "bb"; a += "aa";與上麵的那條語句的執行效率和空間開銷都是完全不一樣的,尤其是很多的時候。
3、循環,其實循環的基礎就是來源於第二點的多次調用加法,當循環時肯定是多次調用這條語句;因為Java不知道你下一條語句要做什麼,所以加法操作,它不得不將它toString返回給你。
==>繼續測試你會發現內部類、靜態內部類的一些特征,其實是將他編輯成為一個外部的class文件,用了一些$標誌符號來分隔,並且你會發現內部類編譯後的指令會將外包類的內容包含進來,隻是他們通過一些標誌符號來標誌出它是內部類,它是那個類的內部類,而它是靜態的還是靜態的特征,用以在運行時如何來完成調用。
==>另外通過一些測試你還會發現java在編譯時就優化的一個動作,當你的常量在編譯時可能會在一些判定語句中直接被解析完成,比如一個boolean類型常量IS_PROD_SYS(表示是否為生產環境),如果這個常量如果是false,在一段代碼中如果出現了代碼片段:
if(IS_PROD_SYS) {
.....
}
此時JVM編譯器在運行時將會直接放棄這段代碼,認為這段代碼是沒有意義的;反之,當你的值為true的時候,編譯器會認為這個判定語句是無效的,編譯後的代碼,將會直接拋棄掉if語句,而直接運行內部的代碼;這個大家在編譯後的class文件通過反編譯工具也可以看得出來的;其實java在運行時還做了很多的動作,下麵再說說一些簡單的優化,不過很多細節還是需要在工作中去發現,或者參考一些JVM規範的說明來完善知識。
上麵雖然說明了很多測試結果所表明的JVM所為程序所做的優化,但是實際的情況卻遠遠不止如此,本文也無法完全詮釋JVM的真諦,而隻是一個開頭,其餘的希望各位自己可以做相應的測試操作;
說完了一些常見的指令如何查看,以及通過查看指令得到一些結論,我們現在來看下指令在調用時候一般的優化方法一般有哪些(這裏主要是在跨方法調用上,大家都知道,java方法建議很小,而且來回層次調用非常多,但是java依然推薦這樣寫,由上麵的分析不得不說明的是,這樣寫了後,java來回調用會經過非常的class尋址以及在class對對內部的方法名稱進行符號查表操作,雖然Hash算法可以讓我們的查表提速非常的倍數,但是畢竟還是需要查表的,這些不變化的東西,我們不願意讓他反複的去做,因為作為底層程序,這樣的開銷是傷不起的,JVM也不會那麼傻,我們來看看它到底做了什麼):
==>在上麵都看到,要去調用一個方法的call site,是非常麻煩的事情,雖然說static的是可以直接定位的,但是我們很多方法都不是,都是需要找到class的入口(雖然說Class的轉換隻需要一次,但是內部的方法調用並不是),然後查表定位,如果每個請求都是這樣,就太麻煩了,我們想讓內部的放入入口地址也隻有一次,怎麼弄呢?
==>在前麵我們說了,JVM在加載後,一般不使用特殊的API,是不會造成Class的變化的,那麼它在計算偏移量的時候,就可以在指令執行的過程中,將目標指令記憶,也就是在當前方法第一次翻譯為指令時,在查找到目標方法的調用點後,我們希望在指令的後麵記錄下調用點的位置,下次多個請求調用到這個位置時,就不用再去尋找一次代碼段了,而直接可以調用到目標地址的指令。
==>通過上麵的優化我們貌似已經滿足了自己的想法,不過很多時候我們願意將性能進行到底,也就是在C++中有一種傳說中的內聯,inline,所以JVM在運行時優化中,如果發現目標方法的方法指令非常小的情況下,它會將目標方法的指令直接拷貝到自己的指令後麵,而不需要再通過一次尋址時間,而是直接向下運行,所以JVM很多時候我們推薦使用小方法,這樣對代碼很清晰,對性能也不錯,大的方法JVM是拒絕內聯的(在C++中,這種內聯需要自己去指定,而並非由係統完成,正常C++的指令也是按照入口+偏移量來找到的)
==>而對於繼承關係的優化,通過層次模型的分析,我們在第四章中就已經說明,也就是利用一般情況下多態中的單個鏈中對應的對象的重寫方法數組肯定不會太長,所以在Class的定義時我們就知道自下向上有多少個重寫方法,而不是運行時才知道的,這個也叫做編譯時的層次分析。
==>從上麵方法的應用上,我們在適當的條件下如何去編寫代碼,適當的條件下去選擇單例和工廠、適當的條件下去選擇靜態和非靜態、適當的條件下去選擇繼承和多態等在通過上麵的指令說明後,可以自己做一些簡單的實驗,就更加清楚啦。
文章寫到這裏結束,歡迎拍磚!
最後更新:2017-04-02 06:51:42