天天看點

認識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();

   0:   ldc     #2; //string a

   2:   areturn

   line 14: 0

public static void main(java.lang.string[]);

   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

   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的定義時我們就知道自下向上有多少個重寫方法,而不是運作時才知道的,這個也叫做編譯時的層次分析。

==>從上面方法的應用上,我們在适當的條件下如何去編寫代碼,适當的條件下去選擇單例和工廠、适當的條件下去選擇靜态和非靜态、适當的條件下去選擇繼承和多态等在通過上面的指令說明後,可以自己做一些簡單的實驗,就更加清楚啦。

文章寫到這裡結束,歡迎拍磚!