本文所有內容來于:http:///a/100ww java代碼是如何執行的JVM如何加載類??java引用類型分為四種:類、接口、數組類和泛型參數。其中泛型參數會在編譯過程中被擦除。因此 Java 虛擬機實際上只有前三種。在類、接口和數組類中,數組類是由 Java 虛擬機直接生成的,其他兩種則有對應的字節流(接口,類)。 - 加載:指的是查找字節流,數組類由JVM生成,所以這一過程可以省了。類加載是通過類加載器完成的。在 Java 虛擬機中,類的唯一性是由類加載器實例以及類的全名一同確定的。即便是同一串字節流,經由不同的類加載器加載,也會得到兩個不同的類。類加載通過雙親委派模型,先由父類加載,父類加載不了再由子類加載。除了啟動類加載器,類加載器都繼承自java.lang.ClassLoader。類加載器分為:
1:啟動類加載器:由C++編寫,不對應于任何對象。加載JRE/lib目錄下的JAR包和虛擬機參數 -Xbootclasspath 指定的類。 2:擴展類加載器:父類加載器是啟動類加載器,負責加載JRE 的 lib/ext 目錄下 jar 包中的類(以及由系統變量 java.ext.dirs 指定的類)。 3:應用類加載器:父類加載器是擴展類加載器,它負責加載應用程序路徑下的類。這里的應用程序路徑,便是指虛擬機參數 -cp/-classpath、系統變量 java.class.path 或環境變量 CLASSPATH 所指定的路徑。 - 鏈接:是指將類合并至JVM中,使之能夠執行的過程。分為驗證,準備,解析。
1:驗證階段:主要是保證加載的類滿足JVM的約束,也是為了保證JVM的安全性。 2:準備階段:為被加載類的靜態字段分配內存。只是分配內存,具體的初使化,則在初使化階段。在這個階段也會構造類的方法表。 3:解析階段(非必須):在 class 文件被加載至 Java 虛擬機之前,這個類無法知道其他類及其方法、字段所對應的具體地址,甚至不知道自己方法、字段的地址。因此,每當需要引用這些成員時,Java 編譯器會生成一個符號引用。在運行階段,這個符號引用一般都能夠無歧義地定位到具體目標上。解析階段的目的,正是將這些符號引用解析成為實際引用。如果符號引用指向一個未被加載的類,或者未被加載類的字段或方法,那么解析將觸發這個類的加載(但未必觸發這個類的鏈接以及初始化。) - 初使化:初使化靜態變量(由static修飾的變量)并執行static代碼塊。所有的static代碼塊會放到同一方法中,并命名為,這個方法會由JVM加鎖保證同步。類的初使化時機:
1:當虛擬機啟動時,初始化用戶指定的主類; 2:當遇到用以新建目標類實例的 new 指令時,初始化 new 指令的目標類; 3:當遇到調用靜態方法的指令時,初始化該靜態方法所在的類; 4:當遇到訪問靜態字段的指令時,初始化該靜態字段所在的類; 5:子類的初始化會觸發父類的初始化; 6:如果一個接口定義了 default 方法,那么直接實現或者間接實現該接口的類的初始化,會觸發該接口的初始化; 7:使用反射 API 對某個類進行反射調用時,初始化這個類; 8:當初次調用 MethodHandle 實例時,初始化該 MethodHandle 指向的方法所在的類。
JVM如何執行方法調用??Java 虛擬機識別方法的關鍵在于類名、方法名、方法的參數類型以及返回類型。在同一個類中,如果同時出現多個名字相同且描述符也相同的方法,那么 Java 虛擬機會在類的驗證階段報錯。 ??Java 虛擬機與 Java 語言不同,它并不限制名字與參數類型相同,但返回類型不同的方法出現在同一個類中,對于調用這些方法的字節碼來說,由于字節碼所附帶的方法描述符包含了返回類型,因此 Java 虛擬機能夠準確地識別目標方法。 ??Java 虛擬機中關于方法重寫的判定同樣基于方法描述符。也就是說,如果子類定義了與父類中非私有、非靜態方法同名的方法,那么只有當這兩個方法的參數類型以及返回類型一致,Java 虛擬機才會判定為重寫。對于 Java 語言中重寫而 Java 虛擬機中非重寫的情況,編譯器會通過生成橋接方法來實現 Java 中的重寫語義。 ??Java 虛擬機中的靜態綁定指的是在解析時便能夠直接識別目標方法的情況,而動態綁定則指的是需要在運行過程中根據調用者的動態類型來識別目標方法的情況。 ??Java 字節碼中與調用相關的指令共有五種: 1:invokestatic:用于調用靜態方法,編譯期就可以確定調用的方法。 2:invokespecial:用于調用私有實例方法、構造器,以及使用 super 關鍵字調用父類的實例方法或構造器,和所實現接口的默認方法。編譯期就可以確定調用的方法。 3:invokevirtual:用于調用非私有實例方法,需要在運行期確定需要調用的方法。 4:invokeinterface:用于調用接口方法,需要在運行期確定需要調用的方法。 5:invokedynamic:用于調用動態方法。 ??在編譯過程中,我們并不知道目標方法的具體內存地址。因此,Java 編譯器會暫時用符號引用來表示該目標方法。這一符號引用包括目標方法所在的類或接口的名字,以及目標方法的方法名和方法描述符。符號引用存儲在 class 文件的常量池之中。根據目標方法是否為接口方法,這些引用可分為接口符號引用和非接口符號引用。如果虛方法(invokevirtual)調用指向一個標記為 final 的方法,那么Java虛擬機也可以靜態綁定該虛方法調用的目標方法。 ??Java 虛擬機中采取了一種用空間換取時間的策略來實現動態綁定。它為每個類生成一張方法表(類加載的鏈接階段實現),用以快速定位目標方法。方法表分為虛方法表(invokevirtual調用)與接口方法表(invokeinterface)調用。方法表本質上是一個數組,每個數組元素指向一個當前類及其祖先類中非私有的實例方法。方法表滿足兩個特質: - 子類方法表中包含父類方法表中的所有方法;
- 子類方法在方法表中的索引值,與它所重寫的父類方法的索引值相同。
??方法調用指令中的符號引用會在執行之前解析成實際引用。對于靜態綁定的方法調用而言,實際引用將指向具體的目標方法。對于動態綁定的方法調用而言,實際引用則是方法表的索引值(實際上并不僅是索引值)。在執行過程中,Java 虛擬機將獲取調用者的實際類型,并在該實際類型的虛方法表中,根據索引值獲得目標方法。這個過程便是動態綁定。Java 虛擬機中的即時編譯器會使用內聯緩存來加速動態綁定。Java 虛擬機所采用的單態內聯緩存將紀錄調用者的動態類型,以及它所對應的目標方法。當碰到新的調用者時,如果其動態類型與緩存中的類型匹配,則直接調用緩存的目標方法。否則,Java 虛擬機將該內聯緩存劣化為超多態內聯緩存,在今后的執行過程中直接使用方法表進行動態綁定。 JVM異常處理??拋出異常可分為顯式和隱式兩種。顯式拋異常的主體是應用程序,它指的是在程序中使用“throw”關鍵字,手動將異常實例拋出。隱式拋異常的主體則是Java 虛擬機,它指的是 Java 虛擬機在執行過程中,碰到無法繼續執行的異常狀態,自動拋出異常。 ??異常實例的構造十分昂貴。這是由于在構造異常實例時,Java 虛擬機需要生成該異常的棧軌跡(stack trace)。該操作會逐一訪問當前線程的 Java 棧幀,并且記錄下各種調試信息,包括棧幀所指向方法的名字,方法所在的類名、文件名,以及在代碼中的第幾行觸發該異常。 ??在編譯生成的字節碼中,每個方法都附帶一個異常表。異常表中的每一個條目代表一個異常處理器,并且由 from 指針、to 指針、target 指針以及所捕獲的異常類型構成。這些指針的值是字節碼索引(bytecode index,bci),用以定位字節碼。 ??當程序觸發異常時,Java 虛擬機會從上至下遍歷異常表中的所有條目。當觸發異常的字節碼的索引值在某個異常表條目的監控范圍內,Java 虛擬機會判斷所拋出的異常和該條目想要捕獲的異常是否匹配。如果匹配,Java 虛擬機會將控制流轉移至該條目 target 指針指向的字節碼。如果遍歷完所有異常表條目,Java 虛擬機仍未匹配到異常處理器,那么它會彈出當前方法對應的 Java 棧幀,并且在調用者(caller)中重復上述操作。在最壞情況下,Java 虛擬機需要遍歷當前線程 Java 棧上所有方法的異常表。 ??finally 代碼塊的編譯比較復雜。當前版本 Java 編譯器的做法,是復制 finally 代碼塊的內容,分別放在 try-catch 代碼塊所有正常執行路徑以及異常執行路徑的出口中。 對象的內存布局??通過 new 指令新建出來的對象(分存在堆中),它的內存其實涵蓋了所有父類中的實例字段。也就是說,雖然子類無法訪問父類的私有實例字段,或者子類的實例字段隱藏了父類的同名實例字段,但是子類的實例還是會為這些父類實例字段分配內存的。 ??在 Java 虛擬機中,每個 Java 對象都有一個對象頭(object header),這個由標記字段(Mark Word)和類型指針所構成。其中,標記字段用以存儲 Java 虛擬機有關該對象的運行數據,如哈希碼、GC 信息以及鎖信息,而類型指針則指向該對象的類。 ??在 64 位的 Java 虛擬機中,對象頭的標記字段占 64 位,而類型指針又占了 64 位。也就是說,每一個 Java 對象在內存中的額外開銷就是 16 個字節。為了盡量較少對象的內存使用量,64 位 Java 虛擬機引入了壓縮指針 的概念(對應虛擬機選項 -XX:+UseCompressedOops,默認開啟),將堆中原本 64 位的 Java 對象類型指針壓縮成 32 位,這樣對象頭就只占用 12位(原來占用16位)。 ??默認情況下,Java 虛擬機堆中對象的起始地址需要對齊至 8 的倍數。如果一個對象用不到 8N 個字節,那么空白的那部分空間就浪費掉了。這些浪費掉的空間我們稱之為對象間的填充(padding)。 &essp;?內存對齊不僅存在于對象與對象之間,也存在于對象中的字段之間。比如說,Java 虛擬機要求 long 字段、double 字段,以及非壓縮指針狀態下的引用字段地址為 8 的倍數。 ??在默認情況下,Java 虛擬機中的32 位壓縮指針可以尋址到 2 的 35 次方個字節,也就是 32GB 的地址空間(超過 32GB 則會關閉壓縮指針)。 具體的內存布局可以參考:https://www.jianshu.com/p/3d38cba67f8b JVM垃圾回收??目前 Java 虛擬機的主流垃圾回收器采取的是可達性分析算法。這個算法的實質在于將一系列 GC Roots 作為初始的存活對象合集(live set),然后從該合集出發,探索所有能夠被該集合引用到的對象,并將其加入到該集合中,這個過程我們也稱之為標記(mark)。最終,未被探索到的對象便是死亡的,是可以回收的。GC Roots 包括(但不限于)如下幾種: 1:Java 方法棧楨中的局部變量; 2:已加載類的靜態變量; 3:JNI handles; 4:已啟動且未停止的 Java 線程。 ??Java 虛擬機中的 Stop-the-world 是通過安全點(safepoint)機制來實現的。當 Java 虛擬機收到 Stop-the-world 請求,它便會等待所有的線程都到達安全點,才允許請求 Stop-the-world 的線程進行獨占的工作。安全點的初始目的并不是讓其他線程停下,而是找到一個穩定的執行狀態。在這個執行狀態下,Java 虛擬機的堆棧不會發生變化。這么一來,垃圾回收器便能夠“安全”地執行可達性分析。 ??回收死亡對象的內存共有三種方式,分別為:會造成內存碎片的清除、性能開銷較大的壓縮、以及堆使用效率較低的復制。 ??Java 虛擬機將堆劃分為新生代和老年代。其中,新生代又被劃分為 Eden 區,以及兩個大小相同的 Survivor 區。如下圖所示: 堆空間是線程共享的,JVM通過為每個線程預分配一塊空間來避免線程間申請內存發生沖突。這項技術被稱之為 TLAB(Thread Local Allocation Buffer,對應虛擬機參數 -XX:+UseTLAB,默認開啟)。 ??Java 虛擬機會記錄 Survivor 區中的對象一共被來回復制了幾次。如果一個對象被復制的次數為 15(對應虛擬機參數 -XX:+MaxTenuringThreshold),那么該對象將被晉升(promote)至老年代。另外,如果單個 Survivor 區已經被占用了 50%(對應虛擬機參數 -XX:TargetSurvivorRatio),那么較高復制次數的對象也會被晉升至老年代。 ??因為 Minor GC 只針對新生代進行垃圾回收,所以在枚舉 GC Roots 的時候,它需要考慮從老年代到新生代的引用。為了避免掃描整個老年代,Java 虛擬機引入了名為卡表的技術,大致地標出可能存在老年代到新生代引用的內存區域。JVM如下區域會發生OutOfMemoryError- 堆內存不足是最常見的 OOM 原因之一,拋出的錯誤信息是“java.lang.OutOfMemoryError:Java heap space”。
- 而對于 Java 虛擬機棧和本地方法棧,如果我們寫一段程序不斷的進行遞歸調用,而且沒有退出條件,就會導致不斷地進行壓棧。類似這種情況,JVM 實際會拋出 StackOverFlowError。
- 對于老版本的 Oracle JDK,因為永久代的大小是有限的,并且 JVM 對永久代垃圾回收(如,常量池回收、卸載不再需要的類型)非常不積極,所以當我們不斷添加新類型的時候,永久代出現 OutOfMemoryError 也非常多見,尤其是在運行時存在大量動態類型生成的場合;類似 Intern 字符串緩存占用太多空間,也會導致 OOM 問題。對應的異常信息,會標記出來和永久代相關:“java.lang.OutOfMemoryError: PermGen space”。
- 隨著元數據區的引入,方法區內存已經不再那么窘迫,所以相應的 OOM 有所改觀,出現 OOM,異常信息則變成了:“java.lang.OutOfMemoryError: Metaspace”。
- 程序計數器是唯一一塊不會拋出內存OutOfMemoryError 的區域,程序計數器會存儲當前線程正在執行的 Java 方法的 JVM 指令地址;或者,如果是在執行本地方法,則是未指定值(undefined)。
Java內存模型??即時編譯器(和處理器)需要保證程序能夠遵守 as-if-serial 屬性。通俗地說,就是在單線程情況下,要給程序一個順序執行的假象。即經過重排序的執行結果要與順序執行的結果保持一致。但這在多線程執行的情況下,就有可能出現意想不到的結果。 ??Java 內存模型通過定義了一系列的 happens-before 操作,讓應用程序開發者能夠輕易地表達不同線程的操作之間的內存可見性。 Java 內存模型還定義了下述線程間的 happens-before 關系。 1:解鎖操作 happens-before 之后(這里指時鐘順序先后)對同一把鎖的加鎖操作。 2:volatile 字段的寫操作 happens-before 之后(這里指時鐘順序先后)對同一字段的讀操作。 3:線程的啟動操作(即 Thread.starts()) happens-before 該線程的第一個操作。 4:線程的最后一個操作 happens-before 它的終止事件(即其他線程通過 Thread.isAlive() 或 Thread.join() 判斷該線程是否中止)。 5:線程對其他線程的中斷操作 happens-before 被中斷線程所收到的中斷事件(即被中斷線程的 InterruptedException 異常,或者第三個線程針對被中斷線程的 Thread.interrupted 或者 Thread.isInterrupted 調用)。 6:構造器中的最后一個操作 happens-before 析構器的第一個操作。 ??在遵守 Java 內存模型的前提下,即時編譯器以及底層體系架構能夠調整內存訪問操作,以達到性能優化的效果。如果開發者沒有正確地利用 happens-before 規則,那么將可能導致數據競爭。 ??Java 內存模型是通過內存屏障來禁止重排序的。對于即時編譯器來說,內存屏障將限制它所能做的重排序優化。對于處理器來說,內存屏障會導致緩存的刷新操作。 Java基本類型基本類型如下圖: 盡管他們的默認值看起來不一樣,但在內存中都是 0。- 在 Java 虛擬機規范中,boolean 類型被映射成 int 類型。具體來說,“true”被映射為整數 1,而“false”被映射為整數 0。Java 代碼中的邏輯運算以及條件跳轉,都是用整數相關的字節碼來實現的。
- Java 的浮點類型采用 IEEE 754 浮點數格式。
- 除 long 和 double 外,其他基本類型與引用類型在解釋執行的方法棧幀中占用的大小是一致的(32位JVM占4個字節,64位JVM占8個字節),但它們在堆中占用的大小的確不同。在將 boolean、byte、char 以及 short 的值存入字段或者數組(存放堆數據時)單元時,Java 虛擬機會進行掩碼操作。在讀取時,Java 虛擬機則會將其擴展為 int 類型boolean與char因為沒符號,高位直接以零填充,byte和short因為有符號,以符號位填充。
- boolean 字段和 boolean 數組比較特殊。在 HotSpot 中,boolean 字段占用一字節,而 boolean 數組則直接用 byte 數組來實現。為了保證堆中的 boolean 值是合法的,HotSpot 在存儲時顯式地進行掩碼操作,也就是說,只取最后一位的值存入 boolean 字段或數組中。
JVM實現反射??在默認情況下,方法的反射調用為委派實現,委派給本地實現來進行方法調用。在調用超過 15 次之后(可以通過 -Dsun.reflect.inflationThreshold= 來調整),委派實現便會將委派對象切換至動態實現。這個動態實現的字節碼是自動生成的,它將直接使用 invoke 指令來調用目標方法。動態實現和本地實現相比,其運行效率要快上 20 倍 。這是因為動態實現無需經過 Java 到 C++ 再到 Java 的切換,但由于生成字節碼十分耗時,僅調用一次的話,反而是本地實現要快上 3 到 4 倍。反射調用的 Inflation 機制是可以通過參數(-Dsun.reflect.noInflation=true)來關閉的。這樣一來,在反射調用一開始便會直接生成動態實現,而不會使用委派實現或者本地實現。 ??方法的反射調用會帶來不少性能開銷,原因主要有三個:變長參數方法導致的 Object 數組,基本類型的自動裝箱、拆箱,還有最重要的方法內聯。 JVM實現synchronized??當聲明 synchronized 代碼塊時,編譯而成的字節碼將包含 monitorenter 和 monitorexit 指令。這兩種指令均會消耗操作數棧上的一個引用類型的元素(也就是 synchronized 關鍵字括號里的引用),作為所要加鎖解鎖的鎖對象。 ??關于 monitorenter 和 monitorexit 的作用,我們可以抽象地理解為每個鎖對象擁有一個鎖計數器和一個指向持有該鎖的線程的指針。當執行 monitorenter 時,如果目標鎖對象的計數器為 0,那么說明它沒有被其他線程所持有。在這個情況下,Java 虛擬機會將該鎖對象的持有線程設置為當前線程,并且將其計數器加 1。在目標鎖對象的計數器不為 0 的情況下,如果鎖對象的持有線程是當前線程,那么 Java 虛擬機可以將其計數器加 1,否則需要等待,直至持有線程釋放該鎖。當執行 monitorexit 時,Java 虛擬機則需將鎖對象的計數器減 1。當計數器減為 0 時,那便代表該鎖已經被釋放掉了。HotSpot 虛擬機中具體的鎖實現分為: - 重量級鎖: Java 虛擬機中最為基礎的鎖實現。在這種狀態下,Java 虛擬機會阻塞加鎖失敗的線程,并且在目標鎖被釋放的時候,喚醒這些線程。Java 線程的阻塞以及喚醒,都是依靠操作系統來完成的開銷非常大。為了盡量避免昂貴的線程阻塞、喚醒操作,Java 虛擬機會在線程進入阻塞狀態之前,以及被喚醒后競爭不到鎖的情況下,進入自旋狀態,在處理器上空跑并且輪詢鎖是否被釋放。如果此時鎖恰好被釋放了,那么當前線程便無須進入阻塞狀態,而是直接獲得這把鎖。
- 輕量級鎖:對象頭中的標記字段(mark word)。它的最后兩位便被用來表示該對象的鎖狀態。其中,00 代表輕量級鎖,01 代表無鎖(或偏向鎖),10 代表重量級鎖,11 則跟垃圾回收算法的標記有關。當進行加鎖操作時,Java 虛擬機會判斷是否已經是重量級鎖。如果不是,它會在當前線程的當前棧楨中劃出一塊空間,作為該鎖的鎖記錄,并且將鎖對象的標記字段復制到該鎖記錄中。然后,Java 虛擬機會嘗試用 CAS(compare-and-swap)操作將鎖對象的標記字段替換為一個指針,指向當前線程棧上的一塊空間,存儲著鎖對象原本的標記字段。
- 偏向鎖:在線程進行加鎖時,如果該鎖對象支持偏向鎖,那么 Java 虛擬機會通過 CAS 操作,將當前線程的地址記錄在鎖對象的標記字段之中,并且將標記字段的最后三位設置為 101。
編譯器橋接方法??對于 Java 語言中重寫而 Java 虛擬機中非重寫的情況,編譯器會通過生成橋接方法來實現 Java 中的重寫語義。下機的圖可以通過字節碼看出是如何實現的: 方法內聯方法內聯是指:在編譯過程中遇到方法調用時,將目標方法的方法體納入編譯范圍之中,并取代原方法調用的優化手段。以 getter/setter 為例,如果沒有方法內聯,在調用 getter/setter 時,程序需要保存當前方法的執行位置,創建并壓入用于 getter/setter 的棧幀、訪問字段、彈出棧幀,最后再恢復當前方法的執行。而當內聯了對 getter/setter 的方法調用后,上述操作僅剩字段訪問。 即時編譯??通常而言,代碼會先被 Java 虛擬機解釋執行,之后反復執行的熱點代碼則會被即時編譯成為機器碼,直接運行在底層硬件之上。即時編譯器有C1,C2,Grral。 - C1:通過-client指定,通常運用于執行時間較短,對啟動性能有要求的程序。
- C2:通過-server指定,對峰值性能有要求的程序,C2比C1的執行效率更快,但是編譯時間更久。
- Grral是一個實驗性質的編譯器,通過參數 -XX:+UnlockExperimentalVMOptions 啟用。
- Java 7 引入了分層編譯(對應參數 -XX:+TieredCompilation)的概念,綜合了 C1 的啟動性能優勢和 C2 的峰值性能優勢。從 Java 8 開始,Java 虛擬機默認采用分層編譯的方式。它將執行分為五個層次,
1:0 層解釋執行(也會收集程序的profiling); 2:1 層執行沒有 profiling 的 C1 代碼; 3:2 層執行部分 profiling 的 C1 代碼; 4:3 層執行全部 profiling 的 C1 代碼; 5: 4 層執行 C2 代碼。 其中profiling為運行時的程序的執行狀態數據,比如循環調用的次數,方法調用的次數,分支跳轉次數,類型轉換次數等。 - 即時編譯是由方法調用計數器和循環回邊計數器觸發的。在使用分層編譯的情況下,觸發編譯的閾值是根據當前待編譯的方法數目動態調整的。
- 基于分支 profile 的優化以及基于類型 profile 的優化都將對程序今后的執行作出假設。這些假設將精簡所要編譯的代碼的控制流以及數據流。在假設失敗的情況下,Java 虛擬機將采取去優化(從執行即時編譯生成的機器碼切換回解釋執行),退回至解釋執行并重新收集相關的 profile。
逃逸分析??逃逸分析將判斷新建的對象是否逃逸。即時編譯器判斷對象是否逃逸的依據,一是對象是否被存入堆中(靜態字段或者堆中對象的實例字段),二是對象是否被傳入未知代碼中。 當發現一個對象只在某個方法里,或者這個方法的內聯方法里,則可以認為這個對象是逃逸的。主要的優化有:1:鎖消除,對逃逸的對象加鎖是沒有意義的。2:采用標量替換的技術將需要分配在堆上的對象直接在棧上采用變量的方式進行替換。
|