深入理解Java虛擬機 筆記

基於《深入理解Java虛擬機第二版》周志明  一書整理的筆記

注:使用Sublime Text編輯的,博客顯示效果並不理想,可粘貼到本地使用Sublime Text打開閱讀。

 

運行時數據區:
    程序計數器(Program Counter Register):當前線程執行字節碼的行號指示器,通過修改指示器位置來取下一條指令。
        如果執行的是Native方法,則計數器指爲Undefined

    Java虛擬機棧(Java Virtual Machine Stacks):基本單位,棧幀 ,每個方法在執行的同時就有一個棧幀入棧。棧幀由1局部變量表2操作數棧3動態鏈接4方法出口等
        局部變量表可能存放編譯期可知的各種基本數據類型、對象引用和returnAddress類型(指向了一條字節碼指令的地址)
        局部變量表的內存空間在編譯期間完成分配

    本地方法棧(Native Method Stacks):類似於虛擬機棧,不過虛擬機棧爲Java方法服務,本地方法棧爲Native方法服務

    Java堆(Java Heap):線程共享,用於存放對象實例。所有對象實例及數組都要在堆上分配,垃圾收集器管理的主要區域。

    方法區(Method Area):線程共享,用於存儲已被虛擬機加載的類信息,常量,靜態變量,即時編譯器編譯後的代碼等數據。堆的一個邏輯部分。
        
    運行時常量池(Runtime Constant Pool):方法區的一部分,Class文件的常量池信息用於存放編譯期生成的各種字面量和符號引用,這些信息在類加載後將在方法區的運行時常量池中存儲,運行期間也可能將新的常量放入池中,如String.intern()。
        String.intern()是Native方法,作用是如果字符串常量池中已經包含等於該字符串的常量,則直接返回對該String的引用,否則先將該String加入常量池。

    直接內存(Direct Memory):不是虛擬機運行時數據區的一部分,NIO使用Native函數庫直接分類堆外內存。


HotSpot虛擬機中
對象的創建:語言層面上通過new關鍵字完成
    1)虛擬機遇到new指令,檢查常量池中是否有被new類的符號引用,檢查這個符號引用是否已被加載、解析和初始化過,若沒有則先進行類加載
    2)類加載通過後,虛擬機爲新生成的對象分配內存空間(類加載完成對象所需的內存空間就以確定),分配內存的方式可分爲兩種情況
        1Java堆內存絕對規整,則只需將區分已用內存和未分配內存的指示器向後挪動所需分配內存的大小即可,這種方式稱爲指針碰撞。(使用Serial、ParNew等帶Compact過程的收集器時使用)
        2Java堆內存不規整,空閒內存和已用內存交替存在,此時虛擬機要維護一個列表來記錄那些內存是可用的,分配時找個一個足夠大的內存空間分配給新對象,這種方法稱爲空閒列表法。(CMS這種基於Mark-Sweep算法的收集器時使用)
    內存分配時還考慮線程安全。(可能出現正在給A分配內存,指針還沒來得及修改對象B又使用了原來的指針。)解決辦法1:同步處理,採用CAS加重試。解決辦法2:把內存分配動作按照線程劃分在不同空間中,爲每個線程在Java堆相預先分配一塊小內存,稱爲本地線程分配緩衝區(TLAB),線程要分配內存時,現在線程的TLAB上分配。
    3)內存分配完後,虛擬機將分配的內存空間初始化爲零值(保證了Java對象不賦初值就可以直接使用)
    4)設置對象頭信息,(包括如何找到此類的元數據信息,對象的哈希碼,對象的GC分代年齡等),以及是否啓用偏向鎖
    5)此時虛擬機認爲一個對象就產生了,此時對象的<init>方法還沒執行。

對象的內存佈局:對象頭,實例數據,對齊填充。
    對象頭:分爲兩部分。
        第一部分存儲對象自身的運行時數據,如哈希碼,GC分代年齡,鎖狀態標誌,線程持有的鎖,偏向線程ID,偏向時間戳等
        第二部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定該對象是哪個類的實例。
        (如果該對象是Java數組那麼對象頭還應有一塊記錄數組長度的數據)
    實例數據:程序代碼中定義的各種類型的字段內容。(包括父類繼承的和子類自己定義的)
    對齊填充:確保對象大小爲8字節整數倍
(元數據解釋: http://www.ruanyifeng.com/blog/2007/03/metadata.html)

對象的訪問定位:Java程序通過棧上的reference數據操作堆上的具體數據。(虛擬機規範並未定義reference如何定位、訪問堆上數據)目前主流的訪問方式有:使用句柄和直接指針。
    使用句柄:Java堆上會劃分出一塊內存來存儲句柄池,reference中存儲的就是對象的句柄地址,句柄中包含了對象實例數據和類型數據的各自具體地址信息。(49頁圖2-2)
        好處:reference中有穩定的句柄地址 。對象被移動時只會句柄中的實例數據指針,不必改變reference中句柄地址。
    直接指針訪問:reference中存儲的直接是對象地址。(Java對象佈局中則要考慮放置訪問對象類型數據的地址)(49頁圖2-3)
        好處:速度更快,因爲節省了一次指針定位的時間。


第三章、垃圾收集器和內存分配策略
程序計數器、虛擬機棧、本地方法棧這些內存都是隨線程生隨線程滅,在運行前其所佔用內存就已確定下來。不需要過多考慮回收問題。
Java堆和方法區則無法確定,我們只有在程序運行期間在知道該創建哪些對象,這部分的內存分配都是動態的。

如何判斷對象已‘死’?
    1引用計數算法:給對象添加一個引用計數器,增加引用則加1,引用失效則減1。
        特點:實現簡單,但很難解決對象循環引用的問題。 (A.obj1=B,B.obj2=A)
    2可達性分析算法:通過一系列稱爲"GC Roots"的對象爲起點,從這些節點向下搜索,走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有引用鏈時,則表明該對象不可用。
        GC Roots對象可以有下列幾種:
            1)虛擬機棧(棧幀的本地變量表)引用的對象
            2)方法區中類靜態屬性引用的對象
            3)方法區中常量引用的對象
            4)本地方法棧中JNI引用的對象

Java引用
    1.2以前,只存在引用和未引用兩種狀態。但是有需求:一些對象在,內存足夠時能夠保存在內存中,當GC進行垃圾收集後內存仍然緊張則將其回收。
    1.2以後,分爲四種引用:強引用(Strong Reference),軟引用(Soft Reference),弱引用(Weak Reference),虛引用(Phantom Reference)。引用強度遞減。
        強引用:只要強引用在,對象永遠不會被回收.
        軟引用:描述還有用但並非必需的對象,系統將要發生內存溢出前進行第二次內存回收就會回收這種對象,如果還沒有足夠內存則會發生內存溢出。
        弱引用:也是描述非必需對象,強度比軟引用更弱。弱引用關聯的對象只能存活到下一次垃圾收集前。
        虛引用:最弱的引用關係,無法通過虛引用獲取對象實例,引入目的是在對象被回收時收到一個系統通知。


回收前的兩次標記
    即使可達性分析得出不可達的對象,也不是立即回收,在此之前至少會進行兩次標記:如果一個對象沒有和GC Roots有引用鏈,則進行第一次標記和篩選,篩選的條件是:該對象是否有必要執行finalize()方法。不必要執行的兩種情況:對象沒有覆蓋finalize()方法,虛擬機已經調用過該對象的finalize()方法。在對象執行finalinze()方法時如果該對象再次與引用鏈建立關聯,則可以逃脫死亡。  (建議絕不使用finalize()方法)

回收方法區:    方法區(HotSpot中永久代)主要回收兩部分:廢棄常量和無用的類
    廢棄常量回收:當該常量沒有被引用時回收。
    無用的類回收:同時滿足3個條件:
        1)該類的所有實例已被回收
        2)加載該類的ClassLoader已被回收。
        3)該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

垃圾收集算法
    標記清除算法(Mark-Sweep):先標記所有要回收的對象,標記完成後統一回收被標記的對象,
        特點:效率問題,標記和清除效率不高。空間問題,標記清除產生大量不連續的內存碎片,繼續給較大對象分配內存時可能導致繼續觸發GC。

    複製算法(Copying):將內存按容量分爲大小相等的兩塊,每次只使用其中一塊,當這一塊用完,將存活的對象複製到另一塊,這樣每次都對整個半區進行內存回收,就不用考慮內存碎片的問題,只需移動堆頂指針順序分配內存即可。
        特點:實現簡單,運行高效,但是將內存縮小爲原來的一半,代價太大。

    標記整理算法(Mark-Compact):標記部分和標記清楚算法一樣,在標記完成後不直接對可回收對象進行清理,而是將存活對象移動到內存空間的一端,直接清除其他的內存。
        特點:相比複製算法,更適合對象存活率高的場景(如老年代),相比標記清除算法又沒有內存碎片問題。

    分代收集算法(Generational Collection):將Java堆分爲新生代和老年代,新生代中存活率較低,可以採用複製算法,老年代中存活率較高,可採用標記清除算法或標記整理算法。

垃圾收集器
新生代(Youth generation)收集器(採用複製算法)
    Serial收集器:單線程收集器,並且在執行垃圾收集時,要暫停其他所有工作線程,直到收集完成。
        特點:簡單高效,沒有線程交互開銷,但停頓時間較長。

    ParNew收集器:多線程版本的Serial收集器,使用多個GC線程進行垃圾收集。

    Parallel Scavenge收集器:並行的多線程收集器。目標是達到一個可控制的吞吐量(CPU運行用戶代碼時間/(CPU運行用戶代碼時間+垃圾收集時間)),可以使用自適應調節策略。
        GC停頓時間縮短以犧牲吞吐量和新生代內存爲代價,可能會導致GC更頻繁。
老年代(Tenured generation)收集器
    Serial Old收集器:老年代版本Serial收集器,單線程收集器,採用標記-整理算法。

    Parallel Old收集器:老年代版本的Parallel Scavenge收集器,和Parallel Scavenge配合,在注重吞吐量以及CPU敏感的場景發揮良好。

    CMS(Concurrent Mark Sweep)收集器:一種以獲取最短停頓回收時間爲目標的收集器    ,採用標記清除算法。
        其運作分爲四個步驟:
            1初始標記:Stop The World,標記GC Roots直接關聯的對象。
            2併發標記:沿着GC Roots的引用鏈往下標記。
            3重新標記:Stop The World,修正併發標記期間由於用戶程序運行導致的標記變動的對象
            4併發清除
        特點:停頓時間短,對CPU資源敏感,無法處理浮動垃圾,容易產生內存碎片。
            (浮動垃圾:CMS併發清理階段,用戶線程產生的垃圾,只能等到下一次GC再清理)
G1收集器:特點:並行和併發,分代收集,空間整合(整體來看像標記整理算法,局部像複製算法),可預測的停頓,橫跨新生代和老年代。
        其運作分爲四個步驟:1初始標記2併發標記3最終標記4篩選回收

內存分配與回收策略:對象的分配往大了講是在堆上分配,細節來說對象主要在新生代Eden區上分配(如果開啓了本地線程緩衝,則按線程優先在TLAB上分配),少數會分配在老年代。
    對象優先在Eden區分配:大多數時候對象優先分配在新生代Eden區,當Eden區沒有足夠空間時,會觸發一次Minor GC.
    大對象直接進入老年代:避免Eden區和Survivor區發生大量內存複製
    長期存活的對象將進入老年代:虛擬機給每個對象設置了對象年齡(Age)計數器,如果對象在Eden區出生,並且經過一次Minor GC仍然存活,並且能夠被Survivor區容納的話,就進入Survivor區,並且Age設置爲1,對象在Survivor區每熬過一次Minor GC,Age都會+1,Age增長到一定值時則會進入老年代。(除此判定法還有動態對象年齡判定)

Minor GC具體過程:https://blog.csdn.net/u010385090/article/details/101955659
Minor GC ,Full GC 觸發條件
Minor GC觸發條件:當Eden區滿時,觸發Minor GC。
Full GC觸發條件:
    (1)調用System.gc時,系統建議執行Full GC,但是不必然執行
    (2)老年代空間不足
     (3)方法區空間不足
    (4)通過Minor GC後進入老年代的平均大小大於老年代的可用內存
    (5)由Eden區、From Space區向To Space區複製時,對象大小大於To Space可用內存,則把該對象轉存到老年代,且老年代的可用內存小於該對象大小


第六章、類文件結構
Class類文件的結構:一組8字節爲基本單位的二進制流。有兩種數據類型,無符號數和表
    魔數與Class文件版本:Class文件的頭4個字節爲魔數(magic),其唯一作用是來確實此文件是否是一個能被虛擬機接受的Class文件,第5~6個字節代表次版本號(minor _version),第7~8個代表主版本號(major_version)

    常量池(constant_pool):常量池前有u2的常量池容量計數器(constant_pool_count)。常量數量=constant_pool_count-1。常量池主要存放兩大類變量:字面量和符號引用。
        字面量:如文本字符串,聲明爲final的常量值等(還有疑惑)。
        符號引用:1類和接口的全限定名2字段的名稱和描述符3方法的名稱和描述符。

    未完。。。


第七章、虛擬機類加載機制
    概述:虛擬機把描述類的Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

    生命週期:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading)。(驗證、準備、解析統稱連接(Linking))

    類加載時機:加載、驗證、準備、初始化、卸載這5個階段的順序是確定的。加載過程必須這樣按部就班的‘開始’。

    虛擬機規範並未說明什麼情況開始類加載的第一個階段,但對類初始化只能嚴格在以下5種情況下進行(主動引用):
        1.遇到new、getstatic、putstatic、invokestatic這4條字節碼指令時,如果類沒有初始化,則要先進行初始化。分別對應的場景:使用new實例化對象、獲得類靜態字段(被final修飾、已在編譯期把結果放入常量池除外,後邊修改也是如此),修改類靜態字段、調用一個類的靜態方法。
        2.使用java.lang.reflect包對類進行反射調用時,如果類沒有初始化,則先要進行初始化。
        3.初始化一個類的時候如果發現其父類還沒有進行初始化,則先要對其父類進行初始化。
        4.當虛擬機啓動時,需要指定一個執行的主類(包含main()的類),虛擬機會先初始化這個類。
        5.當使用虛擬機動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果是REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄時,並且這個方法句柄所對應的類沒有進行初始化,則需要先觸發其初始化。

        注:接口初始化時第三條有所不同,一個接口初始化時,並不要求其父接口都完成了初始化,只有真正用到父接口(如引用父接口定義的常量)纔會進行父接口初始化。

    被動引用的例子:
        1.通過子類引用父類的靜態字段,不會導致子類被初始化。    Sub.superValue
        2.通過數組定義來引用類,不會觸發此類的初始化。  A[] a = new A[10];
        3.常量在編譯階段會存入調用類的常量池,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。 System.out.print(A.HELLO);

    加載:完成三件事。
        1.通過一個類的全限定名來獲取此類的二進制字節流。
        2.將這個字節流所代表的靜態存儲結構轉化爲運行時數據結構。
        3.在內存中生成這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。
    開發人員可以通過自定義類加載器(重寫一個類加載器的loadClass()方法),來控制字節流的獲取方式。
    數組類加載:數組類本身不用類加載器加載,而是Java虛擬機直接創建,但是數組類元素(指數組去掉所有維度的類型)仍需要類加載器加載。數組類創建需遵循的規則:
        1.如果數組的組件類型(Component Type指數組去掉一個維度的類型)是引用類型,就遞歸的用前邊的加載過程加載這個組件類型,該數組將在加載該組件類型的類加載器的類名稱空間上被標誌。
        2.如果該數組的組件類型不是引用類型(如int[]),則會把該數組標記爲與引導類加載器相關聯。
        3.數組的可見性與它的組件類型的可見性一致,如果組件不是引用類型,則數組的可見性默認是public。

    驗證:是連接的第一步(可以沒有這步驟)。作用是:確保Class文件中的字節流所包含的信息符合虛擬機規範,並不會危害虛擬機的安全。分爲四個階段:
        1.文件格式驗證:驗證字節流是否符合Class文件的規範,並且能被當前版本虛擬機處理。該階段驗證基於二進制字節流進行,只有通過了這個階段驗證,二進制字節流纔會進入方法區存儲,後邊三個階段的驗證都是基於方法區的存儲結構進行的。
        2.源數據驗證:對字節碼描述的信息進行語義分析,保證其符合Java語言規範
        3.字節碼驗證:通過數據流和控制流的分析,確保程序邏輯是合法、符合邏輯的。
        4.符號引用驗證: 對類自身意外的信息進行匹配性校驗。

    準備:正式爲類變量分配內存並設置類變量初始值(一般是0值)的階段,在方法區進行分配(即不會分配實例變量,只分配類變量(static變量))。

    解析:將符號引用換爲直接引用的過程。
        符號引用:一組符號用於描述所引用的目標,符號引用可以是任何字面量,常見的如:com.test.ClassA。引用的目標不一定已加載到內存,與虛擬機的內存佈局無關。
        直接引用:可以是直接指到目標的指針、相對偏移量或一個能夠間接定位到目標的句柄。與虛擬機的內存佈局有關,引用的目標必須已加載到內存中。

    初始化:執行類構造器<clinit>()的過程。在準備階段已經賦過一次初始值,在初始化階段按照程序員通過程序制定的主觀計劃去初始化類變量和其他資源。
        關於<clinit>():
            1.<clinit>()由編譯器自動收集類中的所有類變量和靜態語句塊(static{}塊)的語句合併而成的,收集的順序是按照語句在源程序中的順序決定的。靜態語句塊只能訪問定義在其之前的變量,可以賦值但不能訪問定義在其之後的變量。(225頁代碼清單7-5)
            2.<clinit>()和<init>()的區別(<init>()即類的構造函數):<clinit>()不需要顯式調用父類的構造器,虛擬機會保證子類的<clinit>()執行前,父類的<clinit>()已經執行完畢。(可以推導第一個執行的<clinit>()方法是屬於java.lang.Object的)(同時也意味着父類的靜態語句塊執行早於子類)
            3.類或接口可以沒有<clinit>(),如果沒有靜態語句塊和對類變量的賦值操作,則虛擬機可以不生成<clinit>()方法
            4.接口的<clinit>()不需要先執行父接口的<clinit>(),只有當父接口中定義的變量被使用時,纔會調用父接口的<clinit>()    。
            5.虛擬機會保證<clinit>()在多線程環境會被正確加鎖,同步。會採用阻塞方式,如果一個類的<clinit>()耗時很長,會導致其他進程阻塞。

    類加載器:根據一個類的全限定名去獲取描述這個類的二進制字節流的代碼模塊(加載階段進行)。
        類與類加載器:比較兩個類是否相等,只有在這兩個類屬於同一個類加載器纔有意義,每個類加載器都擁有一個獨立的類名稱空間。
        雙親委派模型:(231頁圖7-2)
            從虛擬機角度類加載器分爲兩類:啓動類加載器(Bootstrap ClassLoader)(C++實現)和其它類加載器(Java實現,繼承自java.lang.ClassLader)。
            從Java開發人員角度分類類加載器可以分爲三類:
                1.啓動類加載器(Bootstrap ClassLoader):將<JAVA_HOME>/lib目錄下的或者被-Xbootclasspath指定的,並且是被虛擬機識別(僅按文件名識別,如rt.jar名字不符合,就不會被識別)的類庫加載到虛擬機內存中。
                2.擴展類加載器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader實現,負責加載<JAVA_HOME>/lib/ext下或者被java.ext.dirs指定的目錄的所有類庫,開發者可以直接使用擴展類加載器。
                3.應用程序類加載器。(Application ClassLoader):由sun.misc.Launcher$AppClassLoader實現,負責加載用戶類路徑(ClassPath)上指定的類庫。ClassLoader的getSystemClassLoader()的方法返回的即是此類加載器,程序默認的類加載器,開發和可以直接使用應用程序類加載器。
            雙親委派模型:啓動類加載器<-擴展類加載器<-應用程序類加載器<-自定義類加載器
                除了頂層的啓動類加載器,其它類加載器均有父類加載器,這裏的父子關係一般不會以繼承關係來實現,而是以組合的方式複用父類加載器。
            工作過程:一個類加載器收到類加載請求時,首先將請求委派給父類加載器完成,因此所有的類加載工作都從頂層向下傳遞,只有當父類加載器無法完成加載任務,子類加載器纔會嘗試加載。

附零散知識點:

JIT編譯器:部分商用虛擬機中,Java程序最初是通過解釋器對.class文件進行解釋執行的,當虛擬機發現某個方法或代碼塊運行的特別頻繁時,就會認爲這些代碼是熱點代碼Hot Spot Code。爲了提高熱點代碼的執行效率,運行時虛擬機會將這些代碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器叫即時編譯器(JIT編譯器)

到了Java 7之後,常量池已經不在持久代之中進行分配了,而是移到了堆中。
接着到了Java 8之後的版本,持久代已經被永久移除,取而代之的是Metaspace(元數據區)。
Metaspace與持久代最大的區別在於:Metaspace並不在虛擬機內存中而是使用本地內存。

JDK 8 中永久代向元空間(Metaspace)的轉換的幾點原因
1、字符串存在永久代中,容易出現性能問題和內存溢出。
2、類及方法的信息等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢出,太大則容易導致老年代溢出。
3、永久代會爲 GC 帶來不必要的複雜度,並且回收效率偏低。
4、Oracle 可能會將HotSpot 與 JRockit 合二爲一。

可以被invokestatic、invokespecial調用的方法,符合這個條件的有靜態方法,私有方法,實例構造器,父類方法。由於其不能被繼承或採用其他方法重寫成其他版本,在類加載階段就完成了將其符號引用解析爲對應的直接引用,這些方法也稱非虛方法。
另外被final修飾的方法,雖然是採用invokevirtual進行調用,但是它無法被覆蓋,只有一個版本,所以也是非虛方法。

Human man = new Man();
左邊爲靜態類型,右邊爲實際類型,靜態類型在編譯器可知,實際類型運行到此代碼纔可知

Java線程實現
就SUN JDK來說,他的Windows和Linux版本都採用的是一對一的線程模型,一條Java線程映射到一條輕量級進程中。

協同式線程:線程執行時間由線程本身來控制,線程把自己的工作執行完後會主動通知系統切換到另一個線程執行,也就不存在線程同步問題。
搶佔式線程:每個線程由系統來分配執行時間,線程的切換不用線程自己決定。

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

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