Java 學習之 JVM

概念:Java虛擬機(Java  Virtual  Machine 簡稱 JVM)是運行所有Java程序的抽象計算機,是Java語言的運行環境,也是java的重要特性之一。

介紹:Java (JVM)一種用於計算機設備的規範,可用不同的方式(軟件或硬件)加以實現。編譯虛擬機的指令集與編譯微處理器的指令集非常類似。JVM虛擬機包括一套字節碼指令集、一組存儲器、一個棧、一個垃圾回收堆、一個存儲方法域。

           JVM也可以理解爲一臺可運行Java程序的假象計算機。只要根據JVM的規則描述將解釋器移植到特定的計算機上,就能夠保證經過編譯的任何Java代碼能夠在該程序上運行。通俗點說就是Java 程序能夠運行在任意一臺運行安裝了JVM的平臺,也體現了Java語言的一處編譯,隨處運行的語言特性。JVM可以以一次一條指令的方式來解釋字節碼(把它映射到實際的處理器指令),或者字節碼也可以由實際處理器中稱作just-in-time的編譯器進行進一步編譯。

特點:上邊介紹了Java的“一處編譯,隨處運行”,可見Java語言的與平臺的無關性。而這一特點的關鍵就在“一處編譯”這一環節,也就是JVM(Java虛擬機)。對於要在特定平臺上,JVM 只需要把字節碼解釋成具體平臺上的機器指令去執行就可以了。

應用:Java虛擬機(JVM)是Java語言底層實現的基礎,這有助於理解Java語言的一些性質,也有助於使用Java語言。對於要在特定平臺上實現Java虛擬機的軟件人員,Java語言的編譯器作者以及要用硬件芯片實現Java虛擬機的人來說,則必須深刻理解Java虛擬機的規範。如果想擴展Java語言,或是把其他語言編譯成Java的字節碼就需要深入瞭解Java的JVM.

一、Java虛擬機的內存佈局;

       JVM虛擬機在執行Java程序過程中會在內存空間中分配一片區域,用於程序的運行。

                                                                             圖  1-1

       如上圖;虛擬機又會把這塊管理的內存劃分爲若干個不同的數據區域,即虛擬機棧、本地方法區、程序計數器、堆、方法區。左側兩個爲線程私有區域,右側三個爲線程共享區域。

  • 線程共享

在運行時數據區中,方法區和堆是屬於線程公有的,也就是這兩塊區域是“循環利用”的,所以要對其進行垃圾回收。其是在虛擬機啓動時創建。

  • 線程私有

虛擬機棧、本地方法棧、程序計數器是屬於線程私有的,其與線程“同生死”,屬於“一次性”的,所以不用對其進行垃圾回收。

       1、程序計數器:當線程所執行的字節碼的行號指示器(實際是指令的偏移地址)。是JVM 分給程序運行的一塊較小的內存空間,字節碼解釋器工作時就是通過計數器的值來選取下一條索要執行的字節碼指令。

        既然是描述字節碼的行號指示器,我們通過dome來模擬一段字節碼,便於理解

public void JustDoIt(){
    1.  String name = "";
    2.  String age = "";
    3.  String phoneON = "";
    4.  xxxxx......
    
}

       上述dome中的 1  2  3  4  代表的就是字節碼行號(指令的偏移地址),字節碼解釋器就是根據這個值來執行字節指令的。瞭解了其概念之後我們來理解它的特點及工作原理。

        我們知道,java 語言是多線程的,當CPU 執行權從A線程切換至B 線程的時候,程序調用sleep()函數將線程A掛起,線程B 開始執行,線程B在執行完或者線程A 重新拿到CPU執行權以後,需要切回至A線程繼續完成A線程所要處理的程序的時候,這時候程序計數器的值則記錄着A線程上次執行的行號(偏移地址),字節碼解釋器此時根據這個值,來繼續A線程沒有完成的工作。

         綜上,我們知道程序計算器的數量取決於線程數,各個線程對應自己獨立的程序計數器,這樣纔不至於線程執行中造成的混亂,也是爲什麼說程序計算器是線程私有的了。

         明白了其原理後,最後就是什麼時候創建程序計數器的問題了,瞭解線程機制就知道,線程在執行過程中隨時都會失去CPU 的執行權,所以程序計數器在一個線程開始執行的時候就被創建了。

         程序計數器記錄的是字節指令的偏移地址,所以,一個線程在執行過程當中,其程序計數器記錄的值只會發生改變,而不需要重新分配內存來記錄新的指令偏移地址,所以不存在內存溢出的情況,所以這也就是爲什麼程序計數器是唯一一個沒有規定OutOfMemoryError 異常 的區域

        2、虛擬機棧:Java方法的執行模型,用於存儲局部變量表,操作數棧、動態鏈接、方法出口等。

              每個Java 方法在執行的時候會創建一個棧幀(“stack frame”), 此時的棧幀結構包含局部變量表、操作數棧、動態鏈接、方法出口四部分。通常所說的“堆內存”、“棧內存”,指的就是虛擬機棧內存,再準確點說就是指的是虛擬機棧中的局部變量表內存,因爲這裏存放了一個方法的所有局部變量。具體流程:線程執行->調用方法->創建棧幀->並壓入虛擬機棧->方法執行完畢->棧幀出棧並被銷燬。網絡配圖如下;

       如上圖,每一個方法執行開始到結束,都是一個棧幀從入棧到出棧的過程。

       存放的是編譯期可知的各種基本數據類型,對象引用,returnAddress類型。所以其所需的內存空間在編譯期間就能完成  分配,在運行期間不會改變其大小。  在分配基本數據類型所佔的空間時,除了64位的long和double類型的數據會佔用2個局部變量空間,其餘的數據類型只 佔用1個。

        如果線程請求的棧深度,大於虛擬機所允許的深度,將拋出StackoverflowError(棧溢出異常):線程運行時,JVM會爲虛擬機棧分配一定的內存,因此,虛擬機棧能容納的棧幀數量是有限的,若棧幀不斷的進棧但不出棧,最終會導致虛擬機棧的內存耗盡,比如死循環,此時會報StackOverflowError(棧溢出異常)錯誤。

        如果擴展時無法申請到足夠的內存,就會拋出OutOfMemoryErrorr(內存溢出異常);與StackOverflowError異常相類似,當虛擬機棧將能夠使用的最大內存耗盡時,便會拋出OutOfMemoryErrorr。

       3、本地方法棧:本地方法棧和虛擬機棧的作用是相同的,只不過虛擬機棧執行的是java方法,本地方法棧執行的是Native方法。java方法就是開發人員寫的java代碼,Native方法就是一個java調用非java代碼的接口。

       4、堆:用於存放對象的內存區域,因此,堆是垃圾回收器(GC)管理的主要目標。其具有如下特點;

  • 堆在邏輯上劃分爲“新生代”和“老年代”。由於JAVA中的對象大部分是朝生夕滅,還有一小部分能夠長期的駐留在內存中,爲了對這兩種對象進行最有效的回收,將堆劃分爲新生代和老年代,並且執行不同的回收策略。不同的垃圾收集器對這2個邏輯區域的回收機制不盡相同,在後續的章節中我們將詳細的講解。
  • 堆佔用的內存並不要求物理連續,只需要邏輯連續即可。
  • 堆一般實現成可擴展內存大小,使用“-Xms”與“-Xmx”控制堆的最小與最大內存,擴展動作交由虛擬機執行。但由於該行爲比較消耗性能,因此一般將堆的最大最小內存設爲相等。
  • 堆是所有線程共享的內存區域,因此每個線程都可以拿到堆上的同一個對象。
  • 堆的生命週期是隨着虛擬機的啓動而創建。

      5、方法區:也稱非堆(Non  Heap),線程共享的內存區域,其主要存儲的是加載的類字節碼、class/method/field等元數據對象,static-final常量、static變量、jit編譯器編譯後的代碼等數據。另外需要特別說明的有一個特殊區域“運行時常量池”,在JDK 1.7以前,運行時常量池屬於方法區,以後則被劃入到堆中。

  • 加載的類字節碼:就是將java代碼編譯的字節碼文件,
  • class/method/field等元數據對象:字節碼在加載之後,JVM會根據其中的內容生成class/method/field等對象,他們用於描述一個類,通常在反射中用的比較多。
  • static-final常量、static變量:對於這兩種類型的類成員,JVM會在方法區爲他們創建一份數據,因此同一個類的static修飾的類成員只有一份。
  • 堆佔用的內存並不要求物理連續,只需要邏輯連續即可。

方法區無法滿足內存分配需求時,拋出OutOfMemoryError異常。

      6、直接內存:當class文件被加載後,虛擬機所控制(圖1-1 JVM 運行時數據內存區域)之外的內存空間,NIO可以通過native方法庫直接 分配內存,擴展時也會出現OutOfMemoryError 異常。

二、JVM體系總體分爲四大塊;

        2.1、類的加載機制

                 全盤負責,當一個類加載器負責加載某個Class時,該Class依賴的和引用的其他Class也將由該類加載器負責                載入,除非顯示使用另外一個加載器來載入

                 父類委託,先讓父類加載器加載該類,只有在父類加載器無法加載該類時才嘗試從自己的類路徑中加載該類。

                 緩存機制,緩存機制將會保證所有加載過的Class都會被緩存,當程序需要某個Class時,類加載器先從緩存區              尋找該Class,只有緩存區不存在,系統才能讀取該類對應的二進制數據,並將其轉換成Class對象,存入緩存區,這就是修        改了Class之後必須重啓JVM,程序的修改纔會生效。

        2.2、JVM內存結構

                 如圖1-1

        2.3、GC算法,內存回收

                2.3.1、內存分配與回收策略

                            先看下圖;

                        

        圖中所示爲堆中內存分配示意圖,當程序創建一個對象首先會在eden當中分配一塊區域,如果內存不夠,就    會將年齡較大的對象轉移到survivor 區域,當該區域存放不下則會將對象轉入老年代區域。對於一些靜態變量不需要使用對象,直接被調用的則會被放入永生代(即非堆內存),一般來說長期存活的對象最終會被放入年老代 ,或者創建大對象,比如數據之類的需要申請聯繫空間,且空間較大的,則直接放入到年老代。

        在回收過程中,有個描述對象年齡的參數,如果在一次垃圾回收過程中,有使用過該對象則系統對該對象年齡參數+1,否則-1,當計數至0時,則進行垃圾回收。如果年齡達到一定峯值,則對象進入老年代。總的來說內存分配機制只要體現在創建的對象是否還在使用,不使用則回收,使用則對年齡進行更新,最終將放入年老代。

                 2.3.2、垃圾回收算法

                             2.3.2.1、標記-清除算法

           該算法先標記後清除,將所有需要回收的算法進行標記,然後清除,這種算法的缺點是;效率低,標記清除後會出現大量零散的內存碎片,又是一種內存消耗,造成內存浪費以及時間的消耗。

          首先解釋效率低的問題,當JVM執行垃圾回收時,GC線程被觸發啓動並將程序(正在運行的JAVA程序)暫停,隨後根搜索算法遞歸遍歷全堆對象,將依舊存活的對象標記,接着將沒有標記的對象清除,最後讓程序恢復運行。

          再來解釋內存消耗和時間消耗,被清除的死亡對象都是零散的分佈在內存中,清除之後內存佈局亂七八糟,爲了解決這個問題,JVM又不得不維持一個空閒的內存列表,浪費內存空間,用此算法執行垃圾回收需要兩步,一、遞歸對象並標記。 二、遞歸併清除,由此造成的時間浪費。

          最後,從用戶體驗角度來說,又是一個缺點,上邊提到當執行垃圾回收的時候,GC線程啓動並將程序暫停,爲什麼呢?試想一下,當GC線程執行完標記對象A後,此時我們new 了一個對象B ,B對象又引用對象A ,此時的B對象是沒有被標記的,這時候清除的時候就會將B 清除(明明new 了一個對象結果是個null 這怎麼玩?),所以暫停程序-執行回收-恢復程序,由此帶來的用戶體驗是很差的。

                             2.3.2.2、複製算法

           複製算法將可用的內存分爲兩份,每次使用其中一份,當這塊回收之後把未回收的複製到另一    塊內存中,然後把使用的清除。這種算法運行簡單,解決了標記清除算法的碎片問題,但是這種算    法的代價過高,需要將可用的內存縮小一半,對象存活率較高時,需要持續的複製工作,效率低。

           通俗點解釋就是複製算法會將內存劃分爲兩塊,在任意時候,所有動態的對象都只能分配在其中一個的一個區間(稱爲活動區間),而另一個區間則是空閒的(成爲空閒區間)。當有效的內存耗盡時,JVM將暫停程序運行,開啓複製算法GC線程,接下來GC線程會把活動區間內的存活對象全部複製到空閒區間,且嚴格按照內存地址依次排列,與此同時,GC線程將更新存活對象的內存引用地址到新的內存地址。此時,活動區間與空閒區間已經交換,原來活動區間上殘留的就是垃圾對象,系統將這一部分對象回收。

         優點:對內存利用率較高。

         最後,不難看出這種算法也有其致命的缺點;一、一半內存的浪費。二、複製算法對於存活率很高的對象,每一次回收前都要對其複製,然後更新其指向的內存地址,將耗費很大一部分時間。

                             2.3.2.3、標記整理 算法

           標記整理算法是針對複製算法在對象存活率較高時而需要進行持續複製造成的效率低的問題的    改進,該算法是在標記清除算法的基礎上不直接清理,而是使存貨對象往一端遊走,然後清除一端    邊界以外的內存,這樣既可以避免不連續空間出現,還可以避免對象成活率較高的持續複製。這種算法可以百分百避免對象存活的極端狀況,因此老年代不能直接使用該算法。

            具體點來說:標記整理算法回收也分爲兩部分;一、當JVM執行垃圾回收時,GC線程被觸發啓動並將程序(正在運行的JAVA程序)暫停,隨後根搜索算法遞歸遍歷全堆對象,將依舊存活的對象標記。二、移動所有存活的對象,且按照內存地址次序依次排列,然後將內存地址末端的內存全部回收,這就是整理階段。

             好處:彌補了標記\整理算法後內存分散的特點,也消除了複製算法當中內存減半的高額代價。

             缺點:實質是對前兩種算法結合優化,所以效率是最低的,因爲首先要對全堆對象進行遍歷標記,然後對標記過的對象的引用地址進行整理。再其次就是這個算法適用於低存活率對象的侷限性。

                              2.3.2.4、分代收集算法 

            綜上所述,前三種回收算法都有其侷限性,也就是說他們只適用在合適的堆內存分區中,所以第四種算法應運而生,分代回收算法就是虛擬機目前使用的回收算法,它解決了標記整理不適用年老代的問題,將內存分爲各個年代, 在不同年代使用合適的算法,新生代存活率低,可以是引用複製算法。而年老代  存活率較高,沒有額外的空間對其進行分配擔保,所以只能使用標記清除或者標記整理算法。      

        2.4、GC分析  命令調優

                GC調優的概念:在軟件工程領域,如果希望提高一個系統的吞吐量,可以着手從延遲,吞吐量兩方面來考慮,要麼投入更多的硬件成本,要麼提高系統性能,縮短延遲時間。希望對給定的硬件環境充分利用,在給定  的硬件環境基礎上實現最優性能。

               2.4.1、延遲(Latency)

               2.4.2、吞吐量(Throughput)

               2.4.3、系統容量(Capacity)

               根據上述三個性能方面的衡量維度

三、類的加載機制;

       3.1、什麼是類的加載

                將Java類加載到JVM的東西,我們稱之爲類加載器。JVM使用類的方式如下;Java源程序(.java文件),在經過Java          編譯器編譯之後就被轉換成Java字節代碼(.class文件)。類加載器負責讀取Java字節代碼,並轉換成java.lang.Class類的          一個實例。每個這樣的實例表示一個Java類。通過此實例的 newlnstance()方法就可以創建出該類的一個對象。

       3.2、Java類加載的時機

                3.2.1、類加載的生命週期

                 類加載的生命週期是指類從被加載到內存開始,直至卸載出內存爲止的這一過程。整個週期分爲;加載、          驗證、準備、解析、初始化、使用、卸載。其中驗證、準備、解析稱爲連接。如下示意圖;

                     

                              下面簡單介紹類加載器所執行的生命週期的過程;

                                     (1)、裝載:查找和導入Class文件;

                                     (2)、鏈接:把類的二進制數據合併到JRE中;      

                                                  1、校驗:檢查載入Class文件數據的正確性。

                                                  2、準備:給類的靜態變量分配存儲空間。

                                                  3、解析:將符號引用轉成直接引用。

                                      (3)、初始化:對類的靜態變量,靜態代碼塊執行初始化操作。

                3.2.2、類加載的時機

                            類加載的時機JVM使用規範中並沒有強制規定,但是在一下五個場景必須立即執行初始化,被稱作主動引用

                            (1) 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸 發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候,讀取或設      置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一      個類的靜態方法的時候。

                            (2)、使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初    始化。

                            (3)、當初始化一個類的時候,發現其父類還沒有進行過初始化,則要先觸發其父類的初始化。

                            (4)、當JVM啓動時,用戶需要指定一個要執行的主類(包含main方法的那個類),虛擬機會先初始化這個主    類。

                            (5)、當使用JDK 1.7動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果                  REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且該方法句柄所對應的類沒有初始      化過,則先觸發初始化。

                3.2.3、Java類加載的過程

                           3.2.3.1、加載

                                         (1)、通過一個類的權限定名來獲取定義此類的二進制字節流;

                                         (2)、將這個字節流所代表的靜態存儲結構轉換爲方法區的運行時數據結構。

                                         (3)、在Java堆中生成一個代表這個類的java.lang.Class對象,作爲方法區這些數據的訪問入口。

                           3.2.3.2、驗證

                                                 驗證階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危  害JVM自身的安全。會對四個方面進行驗證;

                                         (1)、文件格式驗證

                                         (2)、元數據驗證

                                         (3)、字節碼驗證

                                         (4)、符號引用驗證

                           3.2.3.3、準備

                                                準備階段是正是爲類變量分配並設置類變量初始值的階段,這些內存都將在方法區中進行分配。    注:這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量會      在對象實例化時隨着對象一起分配在Java堆中,這裏所說的初始值通常情況下是數據類型的零值。

                           3.2.3.4、解析

                                                 解析階段是JVM將常量池中的符號引用替換成直接引用的過程。直接引用是直接指向目標的指      針相對偏移量或是一個能間接定位到目標的句柄。直接引用與JVM 實現的內存有關,同一個符號引      用在不同JVM的實例上翻譯出來的直接引用不盡相同。

                           3.2.3.5、初始化

                                                 初始化階段是類加載過程的最後一步,到了該階段才真正開始執行類定義的Java程序代碼,根據程序員通過代碼定製的主觀計劃去初始化類變量和其他資源,是執行類構造器初始化方法的過程。

四、類加載器

              類加載器負責加載所有的類,爲所有被載入到內存中的類生成一個java.lang.Class中的一個實例對象。如果一個類被加載到JVM中,同一個類就不會被再次載入了,正如一個對象只有一個唯一標識一樣,一個被載入到JVM的類也會有 一個唯一標識。在Java中(即未被編譯的.java文件)一個類用全限定類名(類名+包名)作爲標識,但是在JVM 中(即經過JVM 編譯過的.class文件),一個類用其權限定名和類加載器作爲唯一標識。

               JVM預定義有三種類加載器,當一個JVM啓動的時候,Java開始使用以下三種類加載器:

               (1)、根類加載器(bootstrap  class loader):用來加載Java的核心類,此加載類爲C++實現,所以並不繼承自java.lang.Classloader,換句話說與其他加載器不同,根類加載器並不是java.lang.Classloader的子類。

               (2)、擴展類加載器(extensions class loader):負責加載JRE的擴展目錄,lib/ext或者由java.ext.dirs系統屬性指定的目錄中的JAR包的類,由java語言實現,福類加載器爲null.

               (3)、系統類加載器(system class loader ):被稱爲系統類加載器(也稱爲應用類加載器),它負責JVM啓動時加載來自Java命名的 -classpath選項,java.class.path系統屬性。系統可以通過ClassLoader的靜態方法getSystemClassLoader()來獲取類加載器,由Java語言實現,父類記載器爲ExtClassLoader。

       類加載器加載Class大致需要8個步驟;

              1、檢測此Class是否被載入過,即在緩存區是否有次Class,如果有直接進入第8步,否在第2步。

              2、如果沒有父類加載器,則要麼Parent是根類加載器,要麼本身就是根類加載器,則跳入第4步,如果父類記載器存在則第3步。

              3、請求使用父類加載器載入目標類,如果載入成功則跳入第8步,否則接着執行第5步。

              4、請求使用根類加載器載入目標類,如果成功則跳入第8步,否則第7步。

              5、當前類的加載器嘗試尋找CLass文件,如果找到則執行第 6步,否則執行第7步。

              6、從文件中載入Class,成功則跳至第8步。

              7、拋出ClassNotFountException異常。

              8、返回對應的java.lang.Class對象。

五、Java引用的四種狀態;

              1、強引用:應用最廣泛,平時 new 一個對象放在堆內存,然後用一個引用指向它,這就是強引用。

              如果一個對象具有強引用,那垃圾回收機制不會回收它。當內存不足,Java虛擬機寧願出OutOfMemoryError異常,是程序終止,也不會靠隨意回收具有強引用的對象來解決內存不足的問題。

              2、軟引用:如果一個對象具有軟引用,則內存空間足夠時,垃圾回收器就不會回收它,但是內存如果不足,則隨時都有可能被回收。只要未被回收,則程序可以繼續使用該對象。軟引用用來實現敏感的高速緩存。

              3、弱引用:與軟引用類似,區別在於擁有弱引用的生命週期更爲短暫,每次執行GC的時候,一旦發現有弱引用的對      象,不論當前內存空間足夠與否,都會回收它的內存。但是,垃圾回收器是一個優先級和低的線程。 

              4、虛引用:與其他幾種引用不盡相同,虛引用不會決定對象的生命週期。如果一個對象具有虛引用,那麼它和沒有任    何引用一樣,在任何時候都可能被垃圾回收器回收。

六、結尾,下面通過一張圖對本文的技術點進行一個直觀的展示(圖片來源於網絡)

 

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