Java JVM

轉自:http://www.cnblogs.com/tianchi/archive/2012/11/11/2761631.html

在C裏面我們想執行一段自己編寫的機器指令的方法大概如下:

typedef void(*FUNC)(int);
char* str = "your code";
FUNC f = (FUNC)str;
(*f)(0);

  也就是說,我們完全可以做一個工具,從一個文件中讀入指令,然後將這些指令運行起來。上面代碼中“編好的機器指令”當然指的是能在CPU上運行的,如果這裏我還實現了一個翻譯機器:從自己定義的格式指令翻譯到CPU指令,那麼就可以執行根據自定義格式的代碼了。那麼上面這段代碼是不是相當於最簡單的一個虛擬機了?下面來看JVM的總體結構:


ClassLoader的作用是裝載能被JVM識別的指令(當然不只是從磁盤文件或內存去裝載),那麼我們先了解一下該格式:

魔數以及版本就不說了(滿大街的文件格式都是這個東西),接着的便是常量池,其中無非是兩種東西:

  1. 字面常量(比如Integer、Long、String等);
  2. 符號引用(方法是哪裏的?什麼樣的?);

而我們知道,在JVM裏面Class都是根據全限定名去找的,那麼方法的描述當然也應該如此,那麼就得到這些常量之間的關係如下:

 

在接下來的“訪問權限”中表明瞭該Class是public還是private等,而this&super&interface則表面了“本類”、“繼承自哪個類”、“實現了哪些接口”,實際上這裏只是保存了代表這些信息的CONSTANT_Class_info的下標(u2)。

 

感覺這裏的NameIndex和DescriptorIndex加起來和NameAndType有點像,那麼爲什麼不直接用一個NameAndType的索引值表示?MethodInfo和FieldInfo之間最大的不同點就是Attributes。比如FieldInfo的屬性表中存放的是變量的初始值,而MethodInfo的屬性表中存放的則是字節碼。那麼我們來依次看這些Attributes,首先是Code:

 

有幾個有意思的地方:

  1. 從Class文件中可以知道在執行的過程中棧的深度;
  2. 對於非靜態方法,編譯器會將this通過參數傳遞給方法;
  3. 異常表中記錄的範圍是指令的行數(而不是源代碼的);
  4. 這裏的異常是指try-catch中的,而與Code同級的異常表中的則是指throws出去的;

Exceptions則非常簡單:

LineNumberTable保存了字節碼和源碼之間的關係,結構如下:

 

LocalVariableTable描述了棧幀中局部變量表的變量和源代碼中定義的變量之間的關係,結構如下:

 

SourceFile指明瞭生成該Class文件的Java源碼文件名(比如在一個Java文件中申明瞭很多類的時候會生成很多Class文件),結構如下:

Deprecated和Synthetic屬性只存在“有”和“沒有”的區別:

  1. Deprecated:被程序作者定爲不再推薦使用,通過@deprecated註釋說明;
  2. Synthetic:表示字段或方法是由編譯器自動生成的,比如<init>; 

這也就是爲什麼Code屬性後面會有Attribute的原因?

類加載的時機就很簡單了:在用到的時候就加載(廢話!)。下來看一下類加載的過程:

 

執行上面這段過程的是:ClassLoader,這個東西還是非常重要的,在JVM中是通過ClassLoader和類本身共同去判斷兩個Class是否相同。換句話說就是:不同的ClassLoader加載同一個Class文件,那麼JVM認爲他們生成的類是不同的。有些時候不會從Class文件中加載流(比如Java Applet是從網絡中加載),那麼這個ClassLoader和普通的實現邏輯當然是不一樣的,通過不同的ClassLoader就可以解決這個問題。

但是允許使用不同的ClassLoader又引發了新的問題:如果我也聲明瞭一個java.lang.Integer,但是裏面的代碼非常危險,怎麼辦?這裏就引出了雙親委派模式:

除了頂層的啓動類加載器外,其餘的類加載器都應該有父類加載器(通過組合實現),它在接到加載類的請求時優先委派給父類加載器去完成。

這樣的話,在加載java.lang.Integer的時候會優先使用系統的類加載器,這樣就不會加載用戶自己寫的。在Java程序員看到有3種系統提供的類加載器:

  1. Bootstrap ClassLoader:負責加載<JAVA_HOME>\lib目錄中的類庫,無法被Java程序直接引用;
  2. Extension ClassLoader:負責加載<JAVA_HOME>\lib\ext,開發者可以直接使用;
  3. Application ClassLoader:加載ClassPath上所指定的類庫,如果沒有自己定義過自己的類加載器則會使用它;

這樣默認的類會是有Application ClassLoader去加載類,然後如果發現要使用新的類型的時候則會遞歸地使用Application ClassLoader去加載(在前面的加載過程中提到)。這樣,只有在自己的程序中能使用自己編寫的ClassLoader去加載類,並且這個被加載的類是不能被別人使用的。

雙親委派模式不是一個強制性的約束,而是Java設計者推薦給開發者的類加載實現方式。雙親委派模式出現過的3次“破壞”:

  1. 爲了兼容JDK 1.0,建議使用者去覆蓋findClass方法;
  2. 在基礎類要訪問用戶類的代碼會出現問題(比如JNDI):線程山下文類加載器;
  3. 用戶的一些需求,比如HotSwap、OSGI等; 

加載完完成後,接下來就要看程序是怎麼運行的。棧幀是用於支持虛擬機進行方法調用和執行,幀的意思就是一個單位,在調用其他方法的時候會向棧中壓入棧幀,結構如下:

 

在Class文件編譯完成之後,在運行的時候需要多少個局部變量就已經確定(在前面Class文件中也已經看到過了),那麼這裏需要注意這個特性可能會引發GC(具體如何引發就不在這裏細說了)。在棧中,總是底層的棧去調用高層的棧(並且一定的相鄰的),那麼他們在參數傳遞(返回結果)的時往往是通過將其壓入操作數棧,有些虛擬機爲了提高這部分的效率使得相鄰棧幀“糾纏”在一起:

 

那麼我們接下來要去看是方法是如何執行的,第一個問題就是執行哪個方法?在“面向過程”的編程中似乎不存在在個問題,但是在Java OR C++中這都是比較蛋疼的一個問題。原因就是平時不會這麼用,但是你必須去搞明白= =。JVM確定目標方法的時候有兩種方法:

  1. 靜態分派:根據參數類型和方法名稱來決定調用哪個方法。但是,並不是說沒有發現匹配的類型就報錯,比如有:func(int a),而在調用func('a')的時候也會調用該方法(當然是在沒有func(char a)的前提下),這樣給人的關鍵就有點像一個處理的鏈條。不管多麼複雜,這些都是在編譯期間確定的,因爲這裏是向上找的。
  2. 動態分派:最普遍的就是Interface a = new Implements(),a調用方法到底應該是哪個類的在編譯期間是無法確定的。其實動態分派實現起來也很簡單:在調用方法的時候先拿到對象的實際類型。

其實“靜態”和“動態”給人的感覺還是比較模糊的,“靜態分派”給人的感覺是根據參數的類型向上查找方法,“動態分派”給人的感覺則是根據實例的真實類型向上查找。虛擬機優化動態分派的效率一般是爲類在方法區中建立一個虛方法表:

虛方法表中存放各個方法實際入口地址,如果某個方法在子類中沒有被重寫,那麼子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類重寫了這個方法,子類方法表中的地址將會被替換爲指向子類實現版本的入口地址。其實往簡單裏說,就是一個預處理。

具體單個方法的執行非常簡單,寫一個簡單的程序然後使用javap -c,再結合每條指令的含義就能大概知道程序時怎麼執行以及返回的了(大體上就是基於棧),這裏就不深入和細說了。

一般情況下,從Java文件到運行起來,總的會經歷兩個階段:Java到Class文件和執行Class文件。第一個階段其實就是編譯了,在這個過程中比較有意思的是“語法糖”(其他的比如詞法分析和語法分析就不說了,此處省略1萬字~!~)。所謂Java的語法糖是:for遍歷的簡寫、自動裝箱、泛型等(其實有沒有感覺String+String也是語法糖,在實際中會變成StringBuffer的append)。其中比較有意思的是泛型:

Java中的泛型和C++中的泛型原理是上不一樣的:對於C++來說List<A>和List<B>就是兩個東西,而在Java中List<A>和List<B>都是List<Object>,因爲在Java中Object是所有對象的父對象,那麼Object o可以指向所有的對象,那麼就可以用List<Object>來保存所有的對象集了(感覺實現的有點廢)。

這裏涉及到一個問題就是對象刪除,比如下面代碼:

static void func(List<Integer> a){
        return;
}

在使用javap查看生成的Class的時候會發現:

static void func(java.util.List);
  Signature: (Ljava/util/List;)V
  Code:
   0:   return

其中根本沒有任何Integer的痕跡,但是如果加上返回值,也就是:

static Integer func(List<Integer> a){
        return null;
}

此時再查看的時候就會變成:
static java.lang.Integer func(java.util.List);
  Signature: (Ljava/util/List;)Ljava/lang/Integer;
  Code:
   0:   aconst_null
   1:   areturn
通過泛型實現的原理可以理解很多在實際中會遇到的問題,比如使用List的時候莫名其妙的類型強制轉換錯誤。

接下來開始討論第二個部分,也就是Class文件的實際的執行。在C++中常會提到的兩個概念是:Debug和Release,而在Java中常提到的兩個概念是Server和Client(雖然他們劃分的根據完全不一樣),Client和Server兩種模式對應兩種編譯器:

  1. Client對應C1編譯:將字節碼編譯成本地代碼並進行耗時短且可靠的優化,在必要的時候加入性能監控。
  2. Server對應C2編譯:將字節碼編譯成本地代碼並進行耗時比較長的優化,還可能會根據性能監控的結果進行一些不可靠激進的優化。

在監測器發現有熱點代碼(被調用了很多次的方法或者是執行很多次的循環體)的情況下,將會想即時編譯器提交一個該該方法的代碼編譯請求。當這個方法再次被調用時,會先檢查改方法是否存在被JIT編譯過的版本,如果存在則優先使用編譯後的本地代碼。在默認的情況下,編譯本地代碼的過程和舊的代碼(也就是解釋執行字節碼)是並行的,可以使用-XX:-BackgroundCompilation來禁止後臺編譯,也就是說執行線程會登島編譯完成後再去執行生成的本地代碼。

在具體編譯優化的時候有一個比較好玩的東西,逃逸分析(所謂逃逸是指能被從方法外引用),對於不會逃逸的對象可以進行優化:

  1. 在棧上分配對象,可以減少GC的壓力;
  2. 不需要對爲逃逸的對象進行線程同步;
  3. 如果一個對象無法逃逸,可以在方法裏面不申明這個對象,而是放一些“零件”;

關於Java和C++效率的問題,感覺討論起來就沒有什麼意義了:語言到最後肯定是要生成機器指令的,在語言的機制上面各有千秋,導致不同的語言之間生成機器指令的過程可能不同,但是這個生成的過程跟我們這些碼農沒有半毛錢關係(更準確的說我們生成的過程我們毛都不知道),所以在搞清楚之前就不要爭到底哪個效率高(甚至是哪個更好)。 

程序的併發主要是考慮不同的線程操作同一塊內存時候可能發生的一些問題(至於文件鎖之類的東西,咳咳),首先就先了解線程和內存的關係:

 

這裏的主內存就像是內存條,工作內存就像是寄存器+Cache。Java內存模型定義了8中操作,他們的執行如下:

 

Java虛擬機中最輕量級的同步機制:volatile,它的性質如下:

  1. 變量發生修改的時候會立刻被其他線程看到;
  2. 禁止指令重排序優化;

從Java內存模型操作的角度來看volatile的實現還是挺簡單的:在use之前必須load,在assign之後必須store,這樣就保證了每次用都是從主內存中讀取,每次賦值之後都會同步到主內存(貌似說的是廢話)。線程的同步主要是從三個方面考慮:

  1. 原子性:Long和Double需要特殊考慮;
  2. 可見性;除了volatile之外還有final(synchronized就不說了吧);
  3. 有序性:指令重排,當然可以禁止指令重排;

如果任何時候都考慮同步那代碼寫起來就累死了。下面是Java內存模型的天然先行發生關係:

  1. 控制流被執行的順序和代碼的順序保持一致;
  2. unlock先行發生於後面對同一個鎖的lock操作;
  3. 對volatile變量的寫操作先行於後面對這個變量的讀操作;
  4. Thread的start方法先行於線程的任何一個動作;
  5. 線程的所有動作都先行於線程的終止檢測;
  6. 對線程interrupt方法的調用先行於被中斷線程的代碼檢測到的中斷事件的發生;
  7. 對象的初始化完成先行於finalize方法調用;
  8. 傳遞性;

其實上面的這八條規則還是很有意思的,如果其中的某一條不成立會發生什麼?說到底Java線程還是用戶級的線程,那麼它究竟是個什麼東西(在學C的時候也糾結過這個問題- -)。實現線程主要有幾種方式:

  1. 使用一個內核線程(輕量級進程)來代理;
  2. 完全在用戶態實現,內核都感覺不到;
  3. 用戶和內核混合實現,各自做自己擅長的事情;

這裏就不深入的去看了(雖然這裏的介紹根沒說一樣),想想看都知道不同虛擬機在不同的操作系統上面的實現方式很可能是不一樣,如果想深入看還是pthread比較有意思一點。關於線程的其他要注意的地方(比如狀態轉移什麼的)就不在這裏討論了。

線程安全:當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,那這個對象是線程安全的。

Java中線程共享的變量可以分爲以下五種:

  1. 不可變:這個就不需要解釋了(並不一定非得用final修飾);
  2. 絕對線程安全:也就是滿足上面的線程安全描述的;
  3. 相對線程安全:簡單的說應該是對單個行爲的調用不會出錯;
  4. 線程兼容:對象並不是線程安全,但可以通過調用方的同步來彌補;
  5. 線程對立:不管調用方怎麼處理都不能在多線程環境下使用;

鎖的話有以下幾種實現方式:

  1. 互斥同步,並不是說等待的線程會一直等下去;
  2. 非阻塞同步,樂觀(衝突並沒有我們想象的那麼多);

如果線程之間的切換非常頻繁的話自旋鎖是一個不錯的選擇,這樣就不需要線程切換時候的系統調用的開銷了。如果一個任務能夠很快的完成的話,將整個過程都鎖住或許是個不錯的選擇(而不是給每個子過程上鎖)。其他的鎖優化包括“輕量級鎖”和“偏心鎖”

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