學習java虛擬機已經很久了,最近有空,於是將我所知道的一些關於java虛擬機的知識寫出來。首先當做是重新複習一下,其次是給想了解java虛擬機的朋友一些參考。筆記內容大量參看《深入理解java虛擬機》這本書。
一、虛擬機內存組成模塊
java虛擬機規範中規定了以下組成部分:程序計數器、虛擬機方法棧、本地方法棧(Hotspot中將虛擬機方法棧和本地方法棧合併成方法棧)、java堆、方法區(java8以後將方法區移到了虛擬機外)、運行常量池。
另外java虛擬機還可以額外分配直接內存,不過這不屬於java虛擬機內存組成。整體組成如下圖:
程序計數器
java虛擬機之所以被稱爲虛擬機是因爲它模仿物理機運行實現的,它的程序計數器也類似於操作系統中的程序計數器,是線程私有的,作用是存儲線程將要執行的下一個操作指令(java模仿物理機,也自己實現了多種操作指令)。程序計數器只佔用了一小塊內存區域。
方法棧和本地方法棧
java中每一次方法調用都對應了方法棧的進棧和出站操作,方法棧中每一個棧幀都對應着java代碼中相應的方法調用,棧幀中局部變量表存儲了基礎數據類型(boolean、byte、char、int、long、float、double、)和reference(reference包括兩種:句柄和指針,各自有各自的好處,使用句柄則在改變對象位置時不改變局部變量表裏的引用只用改變句柄本身的指針即可,指針的優點則是查詢效率快)。
這裏有個知識點,實際上Java中的數組是Java虛擬機動態生成的一個對象,不屬於基礎數據類型,我們常用的數組的length屬性其實就是它的對象的一個public屬性。
java堆
java堆是虛擬機中最大的內存組成部分,用來存儲程序執行中產生的對象(不包括常量、靜態常量引用的對象)。java堆會因爲垃圾回收以及對應的垃圾回收器的不同而採用不用的劃分方式,但整體還是劃分爲新生代和老年代兩個部分。新生代又分爲eden區(伊甸區)和survivor區域(倖存區域)。java默認Eden區域是survivor區域的8倍大小(垃圾回收複製算法執行過程統計出來的合適倍數)。不過存在survivor區域又分爲兩塊相同大小的survivor區域:from survivor區域和to survivor區域,作爲輪轉備用。簡單的說,java程序運行中對象就是Eden區域survivor區域和老年代中創建、清理、複製、整理。
方法區
方法區用於存儲虛擬機加載的類信息、常量、靜態常量等,也被稱爲永久代。Hotspot在java8之前用永久代來實現方法區,java8後永久代被移出虛擬機內存,使用native memory存儲。
運行時常量池
運行時常量池屬於方法區的一部分,用來存儲常量的值,存儲內容分爲兩種:字面量和符號引用。這個的理解需要結合Class類的前端編譯來解讀。在虛擬機加載類的時候比如類的名字、字段的名字、常量等的值需要存儲下來,而且會頻繁使用。Java中的基本數據類型和String類型都可以在虛擬機加載類的時候理解爲虛擬機可以描述的值,並不是程序員自己定義的對象。這些值是需要並可以存儲在虛擬機中並供後期使用的,這些值便是運行時常量池中的字面量。另外如string.intern()方法也可以在運行期間將一個String的值放到常量池中並返回常量池的引用,只不過這個不是在虛擬機加載類時候放入的。常量池中另一種數據類型是符號引用,這個跟class的結構也是相關的,前端編譯階段,對class結構的描述過程中,一個字面量是可以反覆被使用的,於是便可以給字面量編一個索引,在符號引用中引用這個索引去得到值,當然,符號引用本身也會被索引供其他符號引用使用。這個便是常量池中的內容,需要結合對class結構的瞭解才能更好的理解爲什麼會有常量池以及常量池中 存儲的內容,不能錯誤的直接理解爲我們在開發時在class中自己定義的 “常量”,它包含了我們通常理解的“常量”,但遠不止如此。另外,對於我自己常說的自己定義的“常量”,只有static 和 final修飾的基礎類型和string類型才屬於constantvalue,對象不屬於constantvalue。對象的內存是分配在Java堆中,常量是分配在方法區中的運行時常量池中的。舉一個常量的特殊性的例子:如在使用ClassA.CONSTANT_VALUEA時,這個時候虛擬機使用的是常量,假如這個時候ClassA還沒有被加載,使用這個ClassA.CONSTANT_VALUEA的值時是不會觸發ClassA的加載的。
直接內存
java直接allocat出來的內存。NIO使用的緩衝區就是直接內存。
二、虛擬機的垃圾回收
虛擬機的垃圾回收基本可等同於對Java堆的垃圾回收。
虛擬機中判斷對象是否死亡的算法——可達性算法
可達性算法的描述非常簡單 :對象是否被GC Roots所直接引用,是則存活;是否被GC Roots直接引用的對象所直接或通過其他對象間接引用,是則存活;不滿足則被標記爲死亡。
以下是從網上找的可達性算法示意:
GC Roots包含:
1.虛擬機棧
2.方法區中的靜態屬性
3.方法區中的常量
4.本地方法棧
對象的finalize方法
finalize方法經常會在面試中被問到,它提供了類似C/C++中析構函數的功能,當Java中的對象將要被回收時,如果對象有重寫finalize方法,那麼finalize方法將會被調用一次,當第二次要被回收時則不會被觸發調用。我們可以嘗試在finalize方法中拯救對象本身不被虛擬機回收,例如將對象被GC Roots引用,那樣便可以使對象免於被回收。但是finalize方法並不能一定保證這種操作一定能成功,成功的關鍵在於finalize方法中的代碼執行的要比虛擬機垃圾回收要快,因此finalize方法中拯救對象本身不具備確定性。finalize方法所Java早期爲贏得使用者的產物,建議不使用,它完全能被finally和其他方式代替。
垃圾回收算法——標記清除算法
下圖是從網上找的標記清除算法的示意圖,其原理非常簡單:首先對對象的可達性進行標記,然後清除掉不可達的對象。
標記清除的算法的問題是清除之後留下的可用的存儲空間非常零碎,當我們需要一個比較大的存儲空間來存儲大對象時,這將是個災難。虛擬機不直接使用標記清除算法來回收垃圾,但是標記清除算法是其他優化過的算法的基礎。
垃圾回收算法——複製算法
複製算法的原理也很簡單:將內存劃分爲兩塊相同大小的區域,只使用其中一塊,當進行垃圾回收時,將還存活的對象移至另一塊內存中,本身則全部清除掉,這樣就不會產生內存碎片。
下圖是複製算法的示意圖,圖片來自網上:
我們可以看出,複製算法是基於標記清除算法的思想進行的,複製算法的缺陷是浪費了太多內存,Java虛擬機使用複製算法時當然不會直接這樣去做。實際上覆制算法是java堆中新生代的基本算法思想(實際上並沒有這麼直接使用)。
Java虛擬機根據對象的存活時間不同的特點將Java堆分成新生代和老年代。新生代的對象“朝生夕死”,存活時間短,內存重新分配頻繁,適合使用複製算法進行垃圾回收。Java虛擬機將新生代劃分爲eden區和survivor區(survivor區域有兩塊,一塊from區域,一塊to區域,輪轉備用),對應複製算法需要的兩塊內存區域。因爲經過垃圾回收後剩下的對象其實是少數,所以survivor區域並不需要和eden區域一樣大,那樣太浪費內存空間,虛擬機默認的大小是eden區域是survivor區域的8倍大小,虛擬機啓動時支持配置。
另外,複製算法只是基礎,虛擬的不同回收器實際執行時還進行了優化。
垃圾回收算法——標記整理算法
標記整理算法也是基於標記清除算法實現的,不同點是在標記之後不是將對象直接清除,而是將存活對象前移,清除存活對象內存空間之外的內存空間。
下圖也是從網上找的示意圖:
當內存大對象多,且對象頻繁產生死亡的時候,效率是非常低下的,因此不適合新生代的垃圾回收。但是老年代的對象存活率高,內存相對較小,很適合標記整理算法。
三種算法總結:標記清除算法是其他兩種算法以及其他優化過的垃圾回收算法的基礎,複製算法適用於新生代,標記整理算法適用於老年代,實際上,Java虛擬機也確實是分代進行垃圾回收的。
概念——STW
STW:stop the world。Java虛擬機進行垃圾回收時是需要中斷工作線程的執行的,期間Java程序出現了短暫的停頓。當然,現在虛擬機對垃圾回收的不斷優化,幾乎可以忽略STW時間了。
垃圾回收器——CMS(current mark sweep)回收器
從名字就可以看出CMS回收器是基於標記清除算法的回收器,它的運作過程分爲四步驟:
1.初始標記
初始標記的作用是標記出那些被GC Roots直接引用的對象。這個期間或產生短暫的STW時間
2.併發標記
併發標記是同時和用戶線程執行的,標記出被所有被引用的對象。不會產生STW。
3.重新標記
併發標記的時間相對長一些,這個期間可能用於用戶線程的操作,併發標記的結果可能已經跟實際產生了偏差,重新標記便是糾正這個偏差的。期間會停止用戶線程,產生STW。
4.併發清除
併發清除就很好理解了,就是垃圾的清除工作是和用戶線程一起進行的,不會導致用戶線程的停頓。
在進行以上四步後並不能保證所有的垃圾都被清除掉了,因爲用戶線程是在併發進行的。遺漏的垃圾對象需要依賴於下次垃圾回收進行清除。
CMS回收器是多線程併發執行的,因此是對CPU敏感的,比較佔用CPU資源。
CMS回收器因爲是基於標記清除算法的,單純的進行這種算法也會產生內存碎片。當無法分配大的內存空間時,會導致Full GC來整理內存空間。
垃圾回收器——G1回收器
G1回收器和CMS在過程上有很多類似之處,只是稍有不同,但是兩個回收器的目的和實現方式時完全不一樣的。
G1回收器更專注於對於CPU資源的使用,充分發揮現代多核超線程CPU的優勢。G1收集器不能確切的劃分爲標記清除算法、複製算法或者標記整理算法,它在原來新生代老年代的基礎上將內存劃分爲多個區域Region,新生代和老年代都是由多個Region組成的集合。同時,它會跟各個Region垃圾的多少對各個Region進行優先級劃分,這種將內存化整爲零的做法避免來對全部內存的操作。
G1回收器的實現細節遠比上面描述的要複雜,但是其過程也可以劃分爲以下四步:
1.初始標記
2.併發標記
3.最終標記
4.篩選回收
前面3個步驟都和CMS很類似,只不過是分Region進行的,篩選回收則是對所有Region進行篩選,只選擇對那些有必要的Region進行垃圾回收。
Minor GC 和 Major GC / Full GC
這三種稱呼其實有點混亂,而且也只是對Java虛擬機垃圾回收的一種思考角度,不能代表虛擬機的垃圾回收算法的劃分。
Minor GC 和 Major GC、Full GC的分界還是很清晰的。Minor GC是指對年新生代的垃圾回收動作,它的執行頻率非常頻繁,回收速度也比較快。
Major GC是指對老年代的劃分,一般會伴隨一次Minor GC,一般速度較慢。Full GC可以理解爲對整個堆的垃圾回收,其實和Major GC語意有點重複,它的另一個語意是產生了STW。
對象的一生
我們現在已經知道,從整體來說,對象是被分配在新生代和老年代中,新生代又被分爲eden區域和survivo區域。當一個創建時,它優先是被分配在新生代的eden區域的,但是大的對象(默認3M,可以在JVM啓動時設置)直接會被分配到老年代。JVM會爲每個對象的“年齡”計數。當存在於eden區域的對象經歷過一次垃圾回收後,它就被移到survivor區域,同時它的年齡就被+1,當它的年齡達到15(虛擬機啓動時可通過參數配置)的時候就會被移到老年代。另外,虛擬機會survivor區域的大小是否充足,如果內存不足,對象也將直接移至老年代。
三、類文件結構
在分析類文件結構前,我們先寫一個簡單的類:
package me.wxh.clazzstd; public class TestClass { private int m; public static String CLASS_VARIABLE = "我是類變量"; public final static String CONSTANT_VALUE = "我纔是常量"; public final static int CONSTANT_INT = 1; public int inc() { return m + 1; } public static void main(String[] args) throws Exception{ System.out.println(CLASS_VARIABLE); System.out.println(CONSTANT_VALUE); System.out.println(CONSTANT_INT); catInt("wuxuehai"); } public static Integer catInt(String intValue) throws Exception{ try { return Integer.parseInt(intValue); } catch (NumberFormatException e) { return 0; } finally { System.out.println("finally 塊執行"); } } }
然後我們使用javap -verbose 命令查看它的class文件結構,如下:
/System/Library/Frameworks/JavaVM.framework/Versions/A/Commands/javap -verbose TestClass.class Classfile /Users/wuxuehai/IdeaProjects/algorithm/target/classes/me/wxh/clazzstd/TestClass.class Last modified 2019-4-24; size 1459 bytes MD5 checksum fdc48a22d072179c43e64b1a57226ef1 Compiled from "TestClass.java" public class me.wxh.clazzstd.TestClass minor version: 0 major version: 49 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #16.#48 // java/lang/Object."<init>":()V #2 = Fieldref #6.#49 // me/wxh/clazzstd/TestClass.m:I #3 = Fieldref #50.#51 // java/lang/System.out:Ljava/io/PrintStream; #4 = Fieldref #6.#52 // me/wxh/clazzstd/TestClass.CLASS_VARIABLE:Ljava/lang/String; #5 = Methodref #53.#54 // java/io/PrintStream.println:(Ljava/lang/String;)V #6 = Class #55 // me/wxh/clazzstd/TestClass #7 = String #56 // 我纔是常量 #8 = Methodref #53.#57 // java/io/PrintStream.println:(I)V #9 = String #58 // wuxuehai #10 = Methodref #6.#59 // me/wxh/clazzstd/TestClass.catInt:(Ljava/lang/String;)Ljava/lang/Integer; #11 = Methodref #60.#61 // java/lang/Integer.parseInt:(Ljava/lang/String;)I #12 = Methodref #60.#62 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer; #13 = String #63 // finally 塊執行 #14 = Class #64 // java/lang/NumberFormatException #15 = String #65 // 我是類變量 #16 = Class #66 // java/lang/Object #17 = Utf8 m #18 = Utf8 I #19 = Utf8 CLASS_VARIABLE #20 = Utf8 Ljava/lang/String; #21 = Utf8 CONSTANT_VALUE #22 = Utf8 ConstantValue #23 = Utf8 CONSTANT_INT #24 = Integer 1 #25 = Utf8 <init> #26 = Utf8 ()V #27 = Utf8 Code #28 = Utf8 LineNumberTable #29 = Utf8 LocalVariableTable #30 = Utf8 this #31 = Utf8 Lme/wxh/clazzstd/TestClass; #32 = Utf8 inc #33 = Utf8 ()I #34 = Utf8 main #35 = Utf8 ([Ljava/lang/String;)V #36 = Utf8 args #37 = Utf8 [Ljava/lang/String; #38 = Utf8 Exceptions #39 = Class #67 // java/lang/Exception #40 = Utf8 catInt #41 = Utf8 (Ljava/lang/String;)Ljava/lang/Integer; #42 = Utf8 e #43 = Utf8 Ljava/lang/NumberFormatException; #44 = Utf8 intValue #45 = Utf8 <clinit> #46 = Utf8 SourceFile #47 = Utf8 TestClass.java #48 = NameAndType #25:#26 // "<init>":()V #49 = NameAndType #17:#18 // m:I #50 = Class #68 // java/lang/System #51 = NameAndType #69:#70 // out:Ljava/io/PrintStream; #52 = NameAndType #19:#20 // CLASS_VARIABLE:Ljava/lang/String; #53 = Class #71 // java/io/PrintStream #54 = NameAndType #72:#73 // println:(Ljava/lang/String;)V #55 = Utf8 me/wxh/clazzstd/TestClass #56 = Utf8 我纔是常量 #57 = NameAndType #72:#74 // println:(I)V #58 = Utf8 wuxuehai #59 = NameAndType #40:#41 // catInt:(Ljava/lang/String;)Ljava/lang/Integer; #60 = Class #75 // java/lang/Integer #61 = NameAndType #76:#77 // parseInt:(Ljava/lang/String;)I #62 = NameAndType #78:#79 // valueOf:(I)Ljava/lang/Integer; #63 = Utf8 finally 塊執行 #64 = Utf8 java/lang/NumberFormatException #65 = Utf8 我是類變量 #66 = Utf8 java/lang/Object #67 = Utf8 java/lang/Exception #68 = Utf8 java/lang/System #69 = Utf8 out #70 = Utf8 Ljava/io/PrintStream; #71 = Utf8 java/io/PrintStream #72 = Utf8 println #73 = Utf8 (Ljava/lang/String;)V #74 = Utf8 (I)V #75 = Utf8 java/lang/Integer #76 = Utf8 parseInt #77 = Utf8 (Ljava/lang/String;)I #78 = Utf8 valueOf #79 = Utf8 (I)Ljava/lang/Integer; { public static java.lang.String CLASS_VARIABLE; descriptor: Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC public static final java.lang.String CONSTANT_VALUE; descriptor: Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: String 我纔是常量 public static final int CONSTANT_INT; descriptor: I flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: int 1 public me.wxh.clazzstd.TestClass(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lme/wxh/clazzstd/TestClass; public int inc(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field m:I 4: iconst_1 5: iadd 6: ireturn LineNumberTable: line 14: 0 LocalVariableTable: Start Length Slot Name Signature 0 7 0 this Lme/wxh/clazzstd/TestClass; public static void main(java.lang.String[]) throws java.lang.Exception; descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 3: getstatic #4 // Field CLASS_VARIABLE:Ljava/lang/String; 6: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 12: ldc #7 // String 我纔是常量 14: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 17: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 20: iconst_1 21: invokevirtual #8 // Method java/io/PrintStream.println:(I)V 24: ldc #9 // String wuxuehai 26: invokestatic #10 // Method catInt:(Ljava/lang/String;)Ljava/lang/Integer; 29: pop 30: return LineNumberTable: line 18: 0 line 19: 9 line 20: 17 line 21: 24 line 22: 30 LocalVariableTable: Start Length Slot Name Signature 0 31 0 args [Ljava/lang/String; Exceptions: throws java.lang.Exception public static java.lang.Integer catInt(java.lang.String) throws java.lang.Exception; descriptor: (Ljava/lang/String;)Ljava/lang/Integer; flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: aload_0 1: invokestatic #11 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I 4: invokestatic #12 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 7: astore_1 8: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 11: ldc #13 // String finally 塊執行 13: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 16: aload_1 17: areturn 18: astore_1 19: iconst_0 20: invokestatic #12 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 23: astore_2 24: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 27: ldc #13 // String finally 塊執行 29: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 32: aload_2 33: areturn 34: astore_3 35: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 38: ldc #13 // String finally 塊執行 40: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 43: aload_3 44: athrow Exception table: from to target type 0 8 18 Class java/lang/NumberFormatException 0 8 34 any 18 24 34 any LineNumberTable: line 26: 0 line 32: 8 line 26: 16 line 28: 18 line 29: 19 line 32: 24 line 29: 32 line 32: 34 line 33: 43 LocalVariableTable: Start Length Slot Name Signature 19 15 1 e Ljava/lang/NumberFormatException; 0 45 0 intValue Ljava/lang/String; Exceptions: throws java.lang.Exception static {}; descriptor: ()V flags: ACC_STATIC Code: stack=1, locals=0, args_size=0 0: ldc #15 // String 我是類變量 2: putstatic #4 // Field CLASS_VARIABLE:Ljava/lang/String; 5: return LineNumberTable: line 7: 0 } SourceFile: "TestClass.java"
接下開,我們利用這兩個文件來簡單解釋下類文件的結構,順便會涉及到部分class加載的過程解析和虛擬機內存模型的知識。實際上,我們開發過程中並不太可能需要去閱讀class文件,但瞭解class文件的結構有助於我們理解和驗證Java虛擬機的執行的過程結構。
常量池——constant pool
類的開始是一些基礎的描述,相信並不需要過多的解讀,真正需要理解的地方便是從constant pool這裏開始,constant pool其實便是我經常所說的常量池。
下面我們來解讀下常量池裏內容。
常量池中每一行便是一個常量,最左邊的#1、#2、#3……是常量的索引。常量分爲字面量和符號引用兩種,符號引用會引用其他符號引用和字面量最終也可以解析成一個固定格式的值。
“=” 號後的值如“Utf8”、“NameAndType”等都是常量的類型描述,常量的類型有很多種,需要了解更多的可結合資料和書籍去了解,我們只能注重於理解。在這裏舉一個例子:索引#7的常量是一個String類型的常量,它的第三列是#56,這時我們看第二張圖,索引#56的常量是一個“Utf8”類型的常量,表示一個Uft8編碼的文本,也就是我們代碼裏的“public final static String CONSTANT_VALUE = "我纔是常量”;”這行中的值,這裏編譯器是把“我纔是常量”這個值生成了一個字面量,然後被#7引用,定義成了一個String類型的符號引用。
第三列是常量的值,如果是字面量,則會是“我纔是常量”、1….這樣的值,如果是符號引用,則會是對其他常量的索引引用。
最後“//”後面的是對常量的註釋,如果是字面量,則沒有這一列,如果是符號引用則備註了符號引用的實際值。
常量池在虛擬機中非常重要,javac編譯(前端編譯)出的字節碼中代碼中大量引用到常量池中的值,如我們的代碼中:
這裏的字節碼指令中#3、#4….等等都是對常量池中的常量的引用。而前端編譯是爲了後面的類加載提供的基礎的。
字段表
字段表緊跟常量池之後,包含了我們在類中定義的類變量和實例變量,在我們的代碼中如下:
我們可以看到它描述了我們定義的CLASS_VARIABLE、CONSTANT_VALUE、CONSTANT_INT 這三個字段,表示出了它們的訪問權限、類型、返回值等等。同時在我們的常量池中,我們也可以看到它們的字段名被分別定義成#19、#21、#23這三個Utf8常量。
比較下CLASS_VARIABLE和CONSTANT_VALUE、CONSTANT_INT的區別,我們可以發現後面兩個變量多了一個ConstantValue屬性,這就是我們之前在介紹虛擬機內存組成模塊時介紹常量池時所說的,只有同時被static和final修飾的纔是常量,這一點很重要,常量的賦值是虛擬機自動執行,而類變量的賦值是在<clinit>(類初始化)方法中執行,實例變量是在<init>(對象初始化)方法中執行,這就導致我們在其他類中使用常量時,不會觸發類的加載。
方法表
方法表存在於字段表之後,我們可以注意到,正如我們才學Java時所知道的一樣,當我們沒有寫構造方法時,編譯器會爲我們默認實現一個構造方法(雖然當時不知道原理,但確實是這樣的):
下面我們用我們代碼中的main函數來解析下方法表中方法的構造。main函數的代碼如下:
它經過前端編譯後的代碼如下:
我們來對應着看,首先在字節碼的最上部描述出了方法的名稱、參數、返回信息、拋出的異常、訪問權限等等,這些都非常的直觀,我們不多做贅述。
Code屬性
Code屬性是方法表裏核心信息,它將我們方法裏的代碼描述成Java的字節碼指令,然後在虛擬機中執行這些指令便是我們代碼的執行(實際上還需要翻譯成彙編指令,翻譯行爲又分爲解釋執行和編譯執行兩種)。指令後不帶參數的都是對操作數棧(後面會解釋到)棧頂元素的操作,帶參數的指令需要結合常量池的索引翻譯成完整的指令。invoke*這樣的指令是對方法的調用,不過方法分很多種,invokevirtual指令是帶參數的,但也就是對棧頂對象實例方法的調用,調用棧頂實例的指定方法(我們這裏是System.out的println方法,System.out就是我們的棧頂的實例)。這裏又個很重要的知識點,invokevirtual這樣的調用形式,雖然我們的指令形式都是一樣的,但是我們的棧頂對象是可變的,如果我們父類和子類都有同樣名字的方法,那麼在棧頂的對象是父類還是子類將決定我們調用的實際方法的不同,實際上,這便是Java中多態的實現基礎!
簡單介紹下這裏的指令:
getstatic 訪問類字段
invokevirtual 調用虛方法,這只是方法調用的一種,後面我們會知道所有的方法調用指令。
invokestatic 調用靜態方法
iconst_1 將int類型的常量1加載到操作數棧
ldc 將一個常量加載到操作數棧
return 方法返回void
LineNumberTable
不知道大家有沒有想過這樣的問題:爲什麼我們在debug時候,開發工具能夠找到我們對應的代碼的源碼呢?!有時候我們的class文件和我們的源碼版本不一樣,那debug時候就亂跑一通?!實際上就是這個LineNumberTable造成的,它的功能非常簡單,將方法中的指令的行號和源碼中的行號進行對應,這一點我們從截圖中看它的形式便能夠非常輕鬆的理。在進行前端編譯的時候我們可以選擇是否保留LineNumberTable,javac -g:none 選擇不保留,javac -g:lines選擇保留,當然不保留時,我們就無法從源碼中設置斷點了。
LocalVariableTable
這是個非常重要的部分,它描述了運行時局部變量表中的變量和源碼中變量的關係,直觀的給我們展示了Java虛擬機運行時的組成。前面我們在說Java虛擬機內存組成的時候提到過Java虛擬機棧,它的棧貞的每一個元素便包含了一個局部變量表,方法的調用對應着虛擬機棧的進棧操作,調用結束後返回對應着一個出棧操作。這是我們Java虛擬機執行的系統的核心之一!不過這裏不是運行時的局部變量表,現在只是前端編譯階段,但是正如我們開始時所說的那樣,我們可以從class文件的結構中窺探Java虛擬機運行時的樣貌。它和LineNumberTable一樣,也不是Java虛擬機執行時必須的,可以在前端編譯時選擇javac -g:none或者javac -g:vars來選擇取消或者生成這個部分,但是Java虛擬機運行時一定會有對應的局部變量表。
這裏有個知識點:如果方法是實例方法,那麼局部變量表的第一個變量就是this,代表實例本身,這也是爲何我們可以在實例方法中可以使用this關鍵字的原因。
字段表和方法表中還包含了很多其他很重要的屬性,但是我們無法寫完整,主要原因是:
1.我也不是很瞭解~
2.那太多啦!
我們只說了幾個能很好反應虛擬機運行機制的部分,能夠理解虛擬機的運行就達到了我們的目的了。
Java中的異常控制流程——try catch finally在字節碼中的體現
下面我們還是先看一下我們的示例代碼中的方法:
然後是它轉成字節碼後的代碼:
Java中的異常控制是通過Exception table實現的,以我們代碼中的Exception table爲例,它定義了try catch finally代碼塊執行的三個流程:
1.0-8行指令執行,當出現java/lang/NumberFormatException異常時跳轉到18行的指令。
2.0-8行指令執行,當出現任何異常時,跳轉至34行指令。
3.18-24行的指令執行,當出現任何異常時,跳轉至34行指令。
正好對應了try catch finally的語言。
Java中方法的調用指令
invokevirtual 調用虛方法,指調用實例的方法(公共的方法)。
invokeinterface 調用接口方法,在運行時找到實現接口方法的對象,調用對象的合適(方法的重載重寫)方法執行。
invokespecial 調用特殊的實例方法:實例初始化方法、私有方法、父方法
invokestatic 調用類方法
invkedynamic 這個比較特殊,是對動態語言的支持,並且在Java編譯器中無法看到
Java中方法的調用指令很重要,它搭建出了Java用語言方法調用的基本特性。
四、Java虛擬機類加載機制
類加載的過程
java虛擬機加載類的過程可以細分爲以下7個階段:
加載(Class Loading)
這個過程是指:
1.使用類的全限定名來獲取此類的二進制流。
2.將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
3.生成一個代表這個類的lava.lang.Class對象,class對象雖然是一個對象,但是會存在方法區中(HotSpot虛擬機就是這麼做的)。
Java虛擬機對類的加載是比較封閉的,字節流的獲取是我們少數能控制的部分,因此也產生了很多我們熟知的技術:
從zip包中獲取,如我們熟知的jar、ear、war包
從網絡中獲取,如已經沒落的Applet技術
代碼動態生成的,如jdk的動態代理cglib技術
由其他文件生成,如jsp(jsp生成的字節流的加載器有些特殊,每一次jsp文件的加載變會生成一個新的加載器,這個加載器生成的目的就是爲了被廢棄)
假如我們看了jdk的類加載器的代碼,就會知道,實際上,每個類加載器都會有一個定義自己管轄的目錄的構造方法,然後在這個路徑下取字節流。
這裏有一個特殊的情況,那就是對於數組的加載,我們知道數組不屬於java的基本數據類型,也不是一個簡單的引用類型,沒有對應的Class,實際上數組對象是由Java虛擬機直接創建的。數組去掉維度後的類型如果是引用類型則會觸發這個引用類型的加載數組類型的可見性和引用類型保持一致;如果是基礎數據類型,則可見性爲public。
驗證
這個階段的目的就是爲了保證Class字節流中包含的信息符合虛擬機的要求,並且不會危害到虛擬機的安全。這個過程我覺得只要知道大概意思即可,沒有必要過多研究。
準備
準備階段是正式爲類變量分配內存並設置類變量的初始值的階段,類變量如果是基本數據類型或者String類型的數據,則內存劃分是在方法區中進行的。這裏我們仍然以我們的之前的字節碼文件證明一下:
在我們的字節碼文件中的最後部分,我們能看到一個static{}代碼塊,實際上這個便是虛擬機生成的<clinit>(class init)方法裏的指令,用來初始化類。我們可以看到,首先第一條指令ldc是從常量池中獲取“我是類變量”這個常量,然後putstatic賦值給#4 (me/wxh/clazzstd/TestClass.CLASS_VARIABLE:Ljava/lang/String;)這個類變量。不過這個是初始化階段的代碼,我們只是用來證明下這種數據類型的類變量的內存劃分的位置。
對於實例變量類型的類變量,自然不用說,它一定是在Java堆中劃分內存的。
非引用類型的類變量的初始值都是0值,但是我們還是需要注意,常量和類變量的區別,同時被static和final修飾的變量也就是常量的初始值會被直接賦值爲對應的ConstantValue的值,這個我們在前面已經說到過了。
解析
解析是將常量池中的符號引用翻譯爲直接引用的過程。在之前我們已經知道,常量池中包含兩種類型的數據:字面量和符號引用。字面量包含基本數據類型和String類型的數值,符號引用引用開其他符號引用或者字面量。但是虛擬機執行不會在執行的時候去翻譯這些符號引用,而是在解析階段就將其翻譯爲直接引用,即句柄或者指針。
解析過程的觸發是在虛擬機指令操作符號引用時觸發。
類變量的初始化
根據我的理解,解析和初始化過程是交替進行的,應該沒有嚴格的先後順序。
再次看一下我們之前的示例的字節碼的最後部分:
在之前的準備階段,我們已經提到,虛擬機會自動生成<clinit>方法,這個方法的作用是初始化類的,它裏面的指令內容包含兩種,順序由在源碼中出現的先後順序決定:
對類變量進行賦值,這個我們在實例代碼中已經可以清楚的看到。
第二種是源碼中的staic代碼塊中的內容將會被生成到<clinit>方法中。
虛擬機啓動時並不會立刻將所有代碼裏的所有類都加載到虛擬機中,而是在運行時動態加載的,當運行中遇到下面情況時,虛擬機會加載類:
遇到new、getstatic、putstatic、invokestatic這4條指令時,如果類沒有加載則出發類的加載過程。注意:使用static final修飾的常量時,並不是使用getstatic指令,並不會觸發類的加載。
使用java.lang.reflect包的方法進行反射調用時,如果類沒有被初始化
初始化一個類時,發現其父類還沒有被初始化,則先初始化其父類
虛擬機啓動時main方法所在的類會被優先初始化。
Java支持動態語言時解析出的REF_getstatic、REF_putstatic、REF_invokestatic的方法句柄對應的類沒有初始化則先進行初始化。
關於<clinit>方法:
首先它不是必要的,當一個類中既沒有類變量,也沒有static代碼塊時,Java虛擬機不會產生<clinit>方法
當執行一個類的<clinit>方法時,如果父類的<clinit>方法還沒有被執行,那麼就先執行父類的<clinit>方法。
<clinit>方法在併發執行的時候時加鎖的。
類加載器:
首先,類加載器的作用是完成類的加載動作的,即類加載的第一個階段。Java中可以擁有很多個類加載器,其中有虛擬機提供的,也會有自定義的類加載器。每一個類加載器都有其類命名空間,當一個Class的字節碼由不同類加載器加載時,那麼它們就是不相同的類。
Java中的類加載器和雙親委派模型
虛擬機的類加載器可分爲兩種:一種是虛擬機提供的加載器——啓動加載器,由C++實現;另一種是由Java語言實現的類加載器,它們都繼承於抽象類java.lang.ClassLoader。
從另一個維度,按功能劃分,我們可以將Java中默認提供的類加載器分爲以下幾種:
啓動類加載器(Bootstrap ClassLoader),它的作用就是將javahome\lib目錄下或者指定的-Xbootclasspath目錄下的類庫按名字查找並加載到虛擬機中。比如我們的rt.jar,如果改名叫其他名字,那麼,即使它在以上目錄下,那麼它也不能正常被加載。它是由C++實現的,是Java虛擬機的組成部分。
擴展類加載器(ExtClassLoader),它是用來加載“java.ext.dirs”系統變量指定目錄下的類庫的。它是由Java語言實現的。
應用加載器(AppClassLoader),它和ExtClassLoader一樣都是在sun.misc.Launcher類中的內部類,負責加載classpath中的指定的類庫。
自定義的類加載器。我們自己用java代碼實現的類加載器。
加載器的雙親委派模型
如果一個類加載器收到了類加載的請求,它首先不會嘗試自己去加載這個類,而是委派給父類加載器去完成,所以每一次加載請求會先傳到頂部的啓動加載器,當父類加載器無法完成加載請求時子類加載器纔會去嘗試加載類。加載器雙親委派模型如下:
假如我們自己實現了一個java.lang.Object類,因爲有雙親委派模型的存在,類加載請求最終會被轉到啓動加載器中去,而啓動加載器只會在自己管轄的路徑裏去查找類,所以我們無法自己寫一個Object類放到classpath中去替換jdk提供的Object類(實際上我們可以下載Openjdk去修改代碼並編譯)。
五、虛擬機字節碼執行引擎
大學時候我們學習編譯原理時候,我們知道程序執行分爲兩種:解釋執行和編譯執行。解釋執行不提前編譯代碼,通過解釋器去執行代碼;編譯執行則預先編譯好代碼產生本地代碼去執行,而Java程序運行中時時進行編譯和優化的技術叫做JIT。我們將java代碼編譯成class文件的動作被稱爲前端編譯,後期將class文件的內容編譯爲本地文件的工作叫做即時編譯(JIT)。總體來說,解釋執行的有點是啓動速度快,而編譯執行的優點則是執行效率快。
前面我們在說Java內存模塊的時候提到過虛擬機棧,它是Java虛擬機執行的根本構造,它是屬於線程的,每個線程都擁有自己的方法棧。虛擬機棧中的一個棧幀對應了一次方法的調用,所有方法的調用在一起便是我們程序的執行! Java中運行時內存模型是工作內存——主內存的模型,主內存負責存儲數據,工作內存從主內存獲取數據的副本在工作內存中運算,結束後將變量的值存儲到主內存 。虛擬機棧就是我們的工作內存,下圖展示了棧幀的基本結構:
這個圖簡單的示意了虛擬機棧的結構,實際上虛擬機棧實現的時候,相鄰棧幀的操作數棧和局部變量表會設計成相交的,以實現方法調用的返回值。
局部變量表
這個我們在解釋class文件結構時便介紹過,在此我們可以前後照應。局部變量表的作用是存放方法的參數和方法內定義的局部變量,局部變量表的最小單位以solt計算,每一個slot中存放着boolean、byte、char、short、int、float、reference和returnAddress類型的數據,long和double類型的數據則分配兩個連續的slot存儲。reference就是我們通常說的引用,它分爲句柄和指針兩種。returnAdress現在不怎麼使用了,最初被用來實現異常處理,現在已經被異常表代替。
如果我們讀過《effectiv java》這本書,應該會對書中有一章有所印象,這章提到要儘量最小化變量的作用域,在這裏我們可以得到印證。因爲局部變量表的每個slot是可以複用的減少變量的作用域不僅可以減少局部變量表的長度,而且確定不用的變量會被垃圾回收器回收掉。
如果方法是實例方法,那麼局部變量表的第0位存放則是這個實例的reference,也就是我們一直用的this!
操作數棧
前面我們解釋class文件方法中的Code屬性時介紹過,字節碼指令有的是不帶參數的,而不帶參數的指令則操作的目標則是操作數棧中的數據,例如字節碼中的iadd則是將操作數棧棧頂的兩個int數據相加,ldc指令則是將常量放入操作數棧的棧頂。
方法的調用
Java中方法的調用指令有以下5種:
invokevirtual 調用虛方法,指調用實例的方法(公共的方法)。
invokeinterface 調用接口方法,在運行時找到實現接口方法的對象,調用對象的合適(方法的重載重寫)方法執行。
invokespecial 調用特殊的實例方法:實例初始化方法、私有方法、父方法
invokestatic 調用類方法
invkedynamic 這個比較特殊,是對動態語言的支持,支持了lambda表達式的語法。
在我們之前說的類加載過程的解釋過程中,invokespecial和invokestatic指令帶的符號引用已經被翻譯成方法的入口地址,它們的調用時固定的,因此它們被稱爲非虛方法的調用。invokeinterface和invokevirtual調用的時候需要根據操作數棧棧頂的對象來獲取調用方法的實際入口地址,它們則被稱爲虛方法的調用。
方法的靜態分派
接下來,我們用書上的例子來解釋下方法的靜態分派:
這個例子的執行結果大家應該沒有什麼異議的,不論方法的實際類型時什麼,虛擬機執行的結果都是“hello,guy”。這是因爲兩個sayHello方法調用的方法在編譯期間已經確定,我們傳入的參數woman和man的靜態類型(Static Type)都是Human,因此調用的都是參數類型爲Human的方法。
我們來看下這個代碼編譯後調用sayHello方法時的字節碼指令:
從後面對#13符號引用的備註我們可以很明顯的看到調用的方法在前端編譯期已經被確定是參數類型是Human的sayHello方法。這個跟我們後面要說的動態分配可以做個對比,可以很清晰地從字節碼層面理解動態分配和靜態分配的區別。
方法的靜態分配按照靜態類型分配是它叫做靜態分配的主要原因,不過我覺得這個分配是在編譯期已經確定了的也是它叫做這個名字的另一個主要原因吧。
關於方法的重載overload,靜態分配會在編譯期決定了到底調用哪個版本的代碼,不過如果方法的參數個數一致,編譯期在確定版本的時候會按照一定的規則來確定死亡(這個規則比較難用語言描述,編譯器會選擇最合適的版本),例如上面的例子,我們把參數類型爲Man的sayHello方法註釋掉,然後main方法裏man的類型聲明爲Man,則代碼執行的結果會是這樣的:
這裏,我們把參數類型爲Man的方法已經註釋掉了,按照靜態類型分配,已經無法分配到參數類型爲Man的方法了,於是編譯期給我們分配到了參數類型爲Human的方法。
再用《深入理解Java虛擬機》這本書上的例子來更好地演示下編譯期在靜態分配上做的最合適的選擇:
這裏我們重寫了很多sayHello方法,當我們調用時使用’a’作爲參數時,編譯器給我們選擇的最優方法是sayHello(char arg),這時候我們看編譯後的結果:
方法調用選擇的是sayHello:(C)V,這裏的C便是char類型。但是當我們把sayHello(char arg)這個方法註釋掉,那麼編譯的結果就會是這樣:
編譯期把同一段代碼編譯成了sayHello:(I)V這個不一樣的結果(I表示int),有興趣的可以逐個註釋掉這些方法,看看編譯器選擇的優先級。
方法的動態分派
前面說的靜態分派是前端編譯期已經決定的,動態分派則不是,它是在虛擬機運行期間對象的實際類型來確定執行的方法的,這也是它名字的由來。
下面我們還是用《深入理解Java虛擬機》這本書上的例子結合編譯後的字節碼來演示下什麼叫動態分派:
注意:我們這裏的sayHello方法都是沒有參數的,或者說參數一樣的,無法通過靜態分配區分出。
相信任何Java程序員對這段代碼的結果應該都沒有異議,這非常面向對象~ 那麼接下來我們需要從Java虛擬機的角度來考慮,爲什麼結果會是這樣的!首先,我們還是來看一下這段代碼中main函數編譯後的字節碼:
從字節碼的LineNumberTable中可以看出,源碼中三句sayHello方法——23行、24行、26行的調用分別對應着Code屬性裏的16-17行、20-21行、32-33行。這裏的一行源碼被編譯成了兩行字節碼指令,這是爲什麼能?
我們之前說過Java虛擬機運行時內存模型時說過操作數棧這個概念,它存儲了方法執行過程中的臨時數據。aload_<n>這個字節碼指令的意思是將局部變量表中的第n個slot中局部變量加載到操作數棧中,對應代碼裏,就是將我們聲明並實例化的man、woman的變量引用存儲到操作數棧中(man是第一個生聲明的變量,n是1,woman是第二個聲明的變量,n是2)。緊接着是invokevirtual指令,它是對實例方法的調用,但我們只看到了它的參數只有一個對方法的描述,卻沒有對象,對的,它執行的對象就是操作數棧棧頂的變量。
這個時候,我們看到的對方法的描述都是“Method me/wxh/clazzstd/DynamicDispatch$Human.sayHello:()V”,但是卻因爲棧頂元素的不同,執行了不同的方法。實際上我們在介紹靜態分配時舉的第一個例子編譯的字節碼也是invokevirtual,不過那裏是同一個對象,()V裏面的參數類型限定符的不同,這裏是限定符相同,對象不同。
對於invokevirtual指令,它的解析過程大致是這樣的:
找到棧頂的第一個元素所指的對象的實際類型,記做C
如果在類型C中找到常量的中描述符和簡單名稱都相符的方法,則進行方法的權限檢驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過則返回IllealAccessError
否則按照繼承關係從下往上對C的各個父類進行第2步查找
如果最終沒有找到合適的方法,則拋出java.lang.AstractMethodError
順便我們利用這個代碼在驗證下我們之前說的Java虛擬機運行時的java虛擬機棧的概念,代碼裏我們new裏三個對象,我們拿第一個new的man的對象來解釋下,代碼很簡單:Human man = new Man();它編譯後的字節碼指令對應爲:
我們來解釋下這四行字節碼:
0行:new指令創建me/wxh/clazzstd/DynamicDispatch$Man類的實例,這個時候操作數棧棧頂會有一個這個實例的引用。
3行:dup指令將棧頂的元素複製一份再壓回棧頂,這個時候操作數棧有兩個一個一樣的me/wxh/clazzstd/DynamicDispatch$Man類的實例的引用
4行:invokespecial指令調用類實例的<init>方法,操作數棧棧頂出棧,只剩下一個me/wxh/clazzstd/DynamicDispatch$Man類的實例的引用
7行:astore_1指令將操作數棧棧頂的變量存儲到局部變量表的第1位(實例方法的第0位是this保留的,可能靜態方法也保留了只不過沒有值,這個有待考證,不過不影響我們介紹流程),這個時候操作數棧第二個me/wxh/clazzstd/DynamicDispatch$Man類的實例的引用也出棧了。
花了這麼多時間來舉這個例子,我覺得是非常值得的,通過它,我們知道了局部變量表和操作數棧是如何協同工作的了
動態語言支持
首先對動態語言的理解:拿javascript舉例,變量的聲明都是“var”,變量是不明確類型的,變量的值才具有類型,方法的調用在運行時纔去判斷。
之前說過的invokedynamic指令是java對動態語言的支持,但是在java7及以下版本是看不到invokedynamic,並且invokedynamic指令設計的目的也是提高Java虛擬機對動態語言的支持,使得其他在java虛擬機上能支持動態語言的執行。
java7的動態語言支持——java.lang.invoke.MethodHandle
下面是一個java.lang.invoke.MethodHandle的使用例子,來自於《深入理解java虛擬機》這本書:
但是這個類的字節碼中我們無法找到invokedynamic指令,有興趣的可以用javap看一下,不需要用java7去編譯。
java8後的lambda表達式
下面我們用一個lambda表達式的例子來看一下invokedynamic指令是什麼樣子的。
源代碼:
javap查看字節碼:
第一個紅圈對應的是
Arrays.sort(names, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.compareTo(o2);
}
});
這個代碼,我們可以清楚看到,產生了一個LambdaTest$1這個匿名內部類,並new了一個它的對象。
第二個紅圈對應的代碼是
list.forEach((String name) -> {
System.out.println(name);
});
這個用lambda表達式產生的字節碼,這裏我可以看出,編譯器產生的是一個invokedynamic指令。
五、虛擬機運行期間優化
首先我們得有個大的概念:虛擬機的優化分爲兩個階段,第一個階段是前端編譯階段,這個期間Java編譯器將我們的源碼編譯成字節碼,字節碼與平臺無關,爲後期進一步解釋編譯提供基礎;第二個階段是運行時解釋/編譯,這時候Java虛擬機會進一步將字節碼翻譯成機器碼後執行。
學過編譯原理,我們都知道,程序的執行分爲解釋執行和編譯執行兩種:解釋執行是使用解釋器執行,不提前編譯代碼,優點是啓動速度快、省去編譯的時間,缺點是解釋執行的效率相對編譯執行會慢很多;編譯執行則是提前將代碼編譯成機器碼後執行,相對於解釋執行的缺點就是啓動慢,優點則是執行效率高。編譯器在前端編譯期間就在生成字節碼上進行了優化,不過這個不是我們要討論的內容。
主流的Java虛擬機都同時包含解釋器和編譯期,並不會單一的使用解釋執行或者編譯器。虛擬機會統計代碼的執行頻度,當代碼反覆執行後,虛擬機就知道這段代碼是一段“熱點代碼”,這時候就會對這段代碼進行重新優化編譯,這種技術就是JIT(Just In Time Compiler)技術。
熱點代碼:
多次被調用的方法
多次被執行的循環體裏的代碼塊
client模式和server模式
Java虛擬機(HotSpot)在啓動的時候可以選擇-client以客戶端模式啓動,-server以服務端模式啓動。
Hotspot虛擬機中包含了兩個編譯器,分別稱爲Client Compiler和Server Compiler,也分別被簡稱爲C1和C2編譯器。虛擬機中一般是默認採用解釋器和編譯器混合執行的策略。虛擬機啓動時可以通過-Xint指定爲只使用解釋執行,那麼虛擬機將完全使用解釋執行;-Xcomp指定爲編譯執行,這時候虛擬機優先採用編譯器執行程序,但是在編譯器無法進行的情況下進行解釋執行。
Server模式在虛擬機中被默認開啓。分層編譯分爲:
第0層,程序解釋執行,解釋器不開啓性能監控,可觸發第1層編譯
第1層,被稱爲C1編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,如有必要將加入性能監控的邏輯。
第2層,也被稱爲C2編譯,也是將字節碼轉位本地代碼,但是會啓用一些耗時比較長的優化,甚至會根據性能監控i 信息進行一些不可靠的激進優化。
六、Java的內存模型(JMM)與線程
前面我們已經提到過java的工作空間和主內存的概念,下圖示意裏線程、工作內存和主內存的之間的交互關係:
這個內存模型實現了併發變成的基礎,非常類似於物理機的內存模型。線程在使用主內存中的數據時,需要先將變量從主內存中拷貝到工作內存中形成變量的副本,然後在工作內存中進行賦值、讀取等操作。這種內存模型使線程對數據的操作都是在工作內存中進行的,虛擬機能夠將工作內存優先存儲於比物理內存更快的高速緩存和寄存器中,從而提高了程序的運行速度。
但是我們思考下,這樣做也會產生一個不好的後果:我們同時會有主內存中變量的多個副本,多個線程對器進行讀取、賦值操作也就造成,某些副本的值已經失去失效,然後用失效的值進行運算,再將錯誤的結果寫入主內存,這都導致了我們常說的“多線程安全”問題。正式因爲出於這個考慮虛擬機則在主內存和工作內存進行交互時定義了一些列規定和協議,正確使用則能保證相對的線程安全(沒有絕對的線程安全)。
Java內存模型定義了一些基本操作,這些操作都是原子的、不可再分的(操作不代表指令):
lock(鎖定):用於主內存的變量,它把變量標識爲一條線程獨佔的狀態。
Unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
read(讀取):作用於主內存的變量,它把一個變量的值傳輸到線程的工作內存中,以便隨後的load動作使用。
load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存變量副本中。
use(使用):作用於工作內存的變量,它把工作內存中的一個變量的值傳輸給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將執行這個操作。
assign(賦值):作用於工作內存中的變量,它把一個從執行引擎中接收到的值賦工作內存中的變量,每當虛擬機遇到一個給變量賦值的字節碼時執行這個操作。
store(存儲):作用於工作內存的變量,它將工作中一個變量的值傳輸到主內存中,以便隨後的write操作使用。
write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入到主工作內存中。
這些操作本身的意義非常好理解,無非是圍繞着變量的鎖定解鎖、變量值複製、傳遞、賦值過程進行定義的。圍繞着這些定義的基本操作,Java虛擬機規範提出了必須要遵守的規則,然後通過這些規則限定,保重了數據在工作內存和主內存之間交互的線程安全性。
volatile修飾符的語意
volatile修飾符保證了變量讀寫的多線程可見性
基於上面的知識,我們考慮一下這種情況:在多線程環境下,我們一個線程A從主內存中read、load了一個變量的值,然後另一個線程B store、wtrite修改了主內存中這個變量的值,這時候已經拿了變量值的線程A在use變量副本的值的時候是不知道線程B對變量的賦值操作的。但是如果我們使用volatile來修飾我們的變量,那麼過程就不是這樣了,虛擬機規範中對volatile修飾的變量作出了以下規定:
線程對volitale變量的副本的load、use操作必須是連續在一起的,也就是說每次使用volitale變量時,肯定是從主內存中最新同步的。
線程對工作內存中的副本變量進行sotre、write操作前的前一條操作必須是assign操作,也就是說,賦值完必須馬上存儲到主內存中去。
由於這兩條規定,虛擬機就保證了線程執行過程對volitale變量的賦值和使用時都是類似於原子的操作,也就保證了它多線程讀寫的可見性!
volatile修飾符修飾的代碼不會被指令重排序
我們知道,Java虛擬機在運行期間會進行代碼的優化,中間會對結果不變的多個指令操作進行重新排序,但是這可能對其執行的先後順序進行了修改,如果我們的線程B的代碼依賴於線程A指令執行的先後順序而產生的結果,那麼就可能導致產生錯誤的判斷結果,這個是非常可怕的。如果使用了voatile修飾一個變量,那麼虛擬機將不會對volatile修飾的變量進行指令重排序優化。注意:線程A代碼依賴於自身代碼指令的先後順序而產生的結果,那麼即使進行了重新排序,那麼也不會影響判斷,這是重排序優化自己做的限制。