JVM原理總結

學而時習之,不亦說乎。分享一下JVM原理

1.JVM結構

爲方便理解可把上圖分爲“功能區”和"數據區”(好好理解功能和數據的含義(一動一靜)):功能區:類加載器、執行引擎;數據區:也就是整個運行時數據區和本地方法庫。

2.jvm內部執行運行流程

首先,當一個程序啓動之前,它的class會被類裝載器裝入方法區,執行引擎讀取方法區的字節碼自適應解析,邊解析就邊運行(其中一種方式),然後pc寄存器指向了main函數所在位置,虛擬機開始爲main函數在java棧中預留一個棧幀(每個方法都對應一個棧幀),然後開始跑main函數,main函數裏的代碼被執行引擎映射成本地操作系統裏相應的實現,然後調用本地方法接口,本地方法運行的時候,操縱系統會爲本地方法分配本地方法棧,用來儲存一些臨時變量,然後運行本地方法,調用操作系統API等等。

3.JVM在操作系統中的運行流程


操作系統的堆和棧:
堆(操作系統):一般由程序員分配釋放,若程序員不釋放,程序結束時可能由OS回收,分配方式類似於鏈表。
棧(操作系統):由操作系統自動分配釋放,存放函數的參數值,局部變量值等。操作方式與數據結構中的棧相類似。

jvm虛擬機位於操作系統的堆中,並且,程序員寫好的類加載到虛擬機執行的過程是:當一個classLoder啓動的時候,classLoader的生存地點在jvm中的堆,然後它會去主機硬盤上將A.class裝載到jvm的方法區,方法區中的這個字節文件會被虛擬機拿來new A字節碼(),然後在堆內存生成了一個A字節碼的對象,然後A字節碼這個內存文件有兩個引用一個指向A的class對象,一個指向加載自己的classLoader。

java自動管理堆(heap)和(棧),程序員不能直接的設置堆和棧。

爲什麼jvm的內存是分佈在操作系統的堆中呢?因爲操作系統的棧是操作系統管理的,它隨時會被回收,所以如果jvm放在棧中,那java的一個null對象就很難確定會被誰回收了,那gc的存在就一點意義都莫有了,而要對棧做到自動釋放也是jvm需要考慮的,所以放在堆中就最合適不過了。

4.JVM生命週期

JVM的生命週期:聲明週期起點是當一個java應用main函數啓動時虛擬機也同時被啓動,而只有當在虛擬機實例中的所有非守護進程都結束時,java虛擬機實例才結束生命。

JVM與main方法的關係:main函數就是一個java應用的入口,main函數被執行時,java虛擬機就啓動了。啓動了幾個main函數就啓動了幾個java應用,同時也啓動了幾個java的虛擬機。

啓動一個jvm虛擬機程序就是啓動了一個進程。啓動的同時就在操作系統的堆內存中開闢一塊jvm內存區,對於各個小模塊的聲明週期:

虛擬機棧、本地方法棧、程序計數器這三個模塊是線程私有的,有多少線程就有多少個這三個模塊,生命週期跟所屬線程的生命週期一致。以程序計數器爲例,因爲多線程是通過線程輪流切換和分配執行時間來實現,所以當線程切回到正確執行位置,每個線程都有獨立的程序技術器,各個線程之間的計數器互不影響,獨立存儲。

其餘是跟JVM虛擬機的生命週期一致。

5.類加載雙親委派機制

JVM在加載類時默認採用的是雙親委派機制。通俗的講,就是某個特定的類加載器在接到加載類的請求時,首先將加載任務委託給父類加載器,依次遞歸,如果父類加載器可以完成類加載任務,就成功返回;只有父類加載器無法完成此加載任務時,才自己去加載。

例如:當jvm要加載Test.class的時候,

  (1)首先會到自定義加載器中查找(其實是看運行時數據區的方法區有沒有加載),看是否已經加載過,如果已經加載過,則返回字節碼。

  (2)如果自定義加載器沒有加載過,則詢問上一層加載器(即AppClassLoader)是否已經加載過Test.class。

  (3)如果沒有加載過,則詢問上一層加載器(ExtClassLoader)是否已經加載過。

  (4)如果沒有加載過,則繼續詢問上一層加載(BoopStrap ClassLoader)是否已經加載過。

  (5)如果BoopStrap ClassLoader依然沒有加載過,則到自己指定類加載路徑下("sun.boot.class.path")查看是否有Test.class字節碼,有則返回,沒有通

知下一層加載器ExtClassLoader到自己指定的類加載路徑下(java.ext.dirs)查看。

  (6)依次類推,最後到自定義類加載器指定的路徑還沒有找到Test.class字節碼,則拋出異常ClassNotFoundException。

爲什麼要使用這種加載方式呢?這裏要注意幾點,1,類加載器代碼本身也是java類,因此類加載器本身也是要被加載的,因此顯然必須有第一個類加載器不是Java類,這就是bootStrap,是使用c++寫的其他這是java了。2,雖說bootStrap、extclassLoader、appclassloader三個是父子類加載器關係,但是並沒有使用繼承,而是使用了組合關係。3,優點,具備了一種帶優先級的層次關係,越是基礎的類,越是被上層的類加載器進行加載,可以比較籠統的說像jdk自帶的幾個jar包肯定是位於最頂級的,再就是我們引用的包,最後是我們自己寫的,保證了java程序的穩定性。

6.類加載的過程:

上圖左半部分其實不是在JVM中,程序員在IDE上寫的是.java文件,經過編譯成.class文件(比如maven工程需要maven install,打成jar報,jar包裏面都是.calss文件);這些步驟都是在IDE上進行的。然後類加載器(classloader)一直到解釋器是屬於JVM的。

7.jdk,jre,JVM的關係

JDK(Java Development Kit) 是 Java 語言的軟件開發工具包(SDK)。在JDK的安裝目錄下有一個jre目錄,裏面有兩個文件夾bin和lib,在這裏可以認爲bin裏的就是jvm,lib中則是jvm工作所需要的類庫,而jvm和 lib合起來就稱爲jre。

8.JVM結構圖各模塊的內容:

程序計數器(Program Counter Register):也叫PC寄存器,是一塊較小的內存空間,它可以看做是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型裏,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令、分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

(1)區別於計算機硬件的pc寄存器,兩者不略有不同。計算機用pc寄存器來存放“僞指令”或地址,而相對於虛擬機,pc寄存器它表現爲一塊內存(一個字長,虛擬機要求字長最小爲32位),虛擬機的pc寄存器的功能也是存放僞指令,更確切的說存放的是將要執行指令的地址。

(2)當虛擬機正在執行的方法是一個本地(native)方法的時候,jvm的pc寄存器存儲的值是undefined。

(3)程序計數器是線程私有的,它的生命週期與線程相同,每個線程都有一個。

(4)此內存區域是唯一一個在Java虛擬機規範中沒有規定任何OutOfMemoryError情況的區域。

Java虛擬機棧(Java Virtual Machine Stack):

(1)線程私有的,它的生命週期與線程相同,每個線程都有一個。

(2)每個線程創建的同時會創建一個JVM棧,JVM棧中每個棧幀存放的爲當前線程中局部基本類型的變量(java中定義的八種基本類型:boolean、char、byte、short、int、long、float、double;和reference (32 位以內的數據類型,具體根據JVM位數(64爲還是32位)有關,因爲一個solt(槽)佔用32位的內存空間 )、部分的返回結果,非基本類型的對象在JVM棧上僅存放一個指向堆上的地址。

(3)每一個方法從被調用直至執行完成的過程就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。

(4)棧運行原理:棧中的數據都是以棧幀(Stack Frame)的格式存在,棧幀是一個內存區塊,是一個數據集,是一個有關方法和運行期數據的數據集,當一個方法A被調用時就產生了一個棧幀F1,並被壓入到棧中,A方法又調用了B方法,於是產生棧幀F2也被壓入棧,B方法又調用了C方法,於是產生棧幀F3也被壓入棧…… 依次執行完畢後,先彈出後進......F3棧幀,再彈出F2棧幀,再彈出F1棧幀。

(5)JAVA虛擬機棧的最小單位可以理解爲一個個棧幀,一個方法對應一個棧幀,一個棧幀可以執行很多指令。

(6)對上圖中的動態鏈接解釋下,比如當出現main方法需要調用method1()方法的時候,操作指令就會觸動這個動態鏈接就會找打方法區中對於的method1(),然後把method1()方法壓入虛擬機棧中,執行method1棧幀的指令;此外如果指令表示的代碼是個常量,這也是個動態鏈接,也會到方法區中的運行時常量池找到類加載時就專門存放變量的運行時常量池的數據。

本地方法棧(Native Method Stack):

(1)先解釋什麼是本地方法:jvm中的本地方法是指方法的修飾符是帶有native的但是方法體不是用java代碼寫的一類方法,這類方法存在的意義當然是填補java代碼不方便實現的缺陷而提出的。案例介紹將在 下面22知識點仔細介紹。

(2)作用同java虛擬機棧類似,區別是:虛擬機棧爲虛擬機執行Java方法服務,而本地方法棧則是爲虛擬機使用到的Native方法服務。

(3)是線程私有的,它的生命週期與線程相同,每個線程都有一個。

Java 堆(Java Heap):

(1)是Java虛擬機所管理的內存中最大的一塊。

(2)不同於上面3個,堆是jvm所有線程共享的。

(3)在虛擬機啓動的時候創建。

(4)唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都要在這裏分配內存。

(5)Java堆是垃圾收集器管理的主要區域。

(6)因此很多時候java堆也被稱爲“GC堆”(Garbage Collected Heap)。從內存回收的角度來看,由於現在收集器基本都採用分代收集算法,所以Java堆還可以細分爲:新生代和老年代;新生代又可以分爲:Eden 空間、From Survivor空間、To Survivor空間。(23知識點詳細介紹)

(7)java堆是計算機物理存儲上不連續的、邏輯上是連續的,也是大小可調節的(通過-Xms和-Xmx控制)。

(8)如果在堆中沒有內存完成實例的分配,並且堆也無法再擴展時,將會拋出OutOfMemoryError異常。

方法區(Method Area):

(1)在虛擬機啓動的時候創建。

(2)所有jvm線程共享。

(3)除了和堆一樣不需要不連續的內存空間和可以固定大小或者可擴展外,還可以選擇不實現垃圾收集。

(4)用於存放已被虛擬機加載的類信息、常量、靜態變量、以及編譯後的方法實現的二進制形式的機器指令集等數據。

(5)被裝載的class的信息存儲在Methodarea的內存中。當虛擬機裝載某個類型時,它使用類裝載器定位相應的class文件,然後讀入這個class文件內容並把它傳輸到虛擬機中。

(6)運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。

方法區補充:指令集是個非常重要概念,因爲程序員寫的代碼其實在jvm虛擬機中是被轉成了一條條指令集執行的。

類加載器子系統(class loader subsystem):

(1)根據給定的全限定名類名(如java.lang.Object)來裝載class文件的內容到Runtimedataarea中的methodarea(方法區域)。Java程序員可以extends java.lang.ClassLoader類來寫自己的Classloader。

(2) 對(1)中的加載過程是:當一個classloader啓動時,classloader的生存地點在jvm中的堆,然後它去主機硬盤上去裝載A.class到jvm的methodarea(方法區),方法區中的這個字節文件會被虛擬機拿來new A字節碼,然後在堆內存生成了一個A字節碼的對象,然後A自己碼這個內存文件有兩個引用,一個指向A的class對象,一個指向加載自己的classloader。

執行引擎(Executionengine子系統):

(1)負責執行來自類加載器子系統(class loader subsystem)中被加載類中在方法區包含的指令集,通俗講就是類加載器子系統把代碼邏輯(什麼時候該if,什麼時候該相加,相減)都以指令的形式加載到了方法區,執行引擎就負責執行這些指令就行了。

用網上最流行的一張圖表示就是:

  (1)程序在JVM主要執行的過程是執行引擎與運行時數據區不斷交互的過程,可理解爲上面“方法區中的動圖” 。

(2)但是執行引擎拿到的方法區中的指令還是人能夠看懂的,這裏執行引擎的工作就是要把指令轉成JVM執行的語言(也可以理解成操作系統的語言),最後操作系統語言再轉成計算機機器碼。

(3)解釋器:一條一條地讀取,解釋並且執行字節碼指令。因爲它一條一條地解釋和執行指令,所以它可以很快地解釋字節碼,但是執行起來會比較慢。這是解釋執行的語言的一個缺點。字節碼這種“語言”基本來說是解釋執行的。
即時(Just-In-Time)編譯器:即時編譯器被引入用來彌補解釋器的缺點。執行引擎首先按照解釋執行的方式來執行,然後在合適的時候,即時編譯器把整段字節碼編譯成本地代碼。然後,執行引擎就沒有必要再去解釋執行方法了,它可以直接通過本地代碼去執行它。執行本地代碼比一條一條進行解釋執行的速度快很多。編譯後的代碼可以執行的很快,因爲本地代碼是保存在緩存裏的。
上面也是才能夠別處拷來的,是對上圖的解釋,這裏的字節碼解釋器也就對應20中的解釋器。簡單理解jit就是當代碼中某些方法複用次數比較高的,並超過一個特定的值就成爲了“熱點代碼”。那麼這個這些熱點代碼就會被編譯成本地代碼(其實可以理解成緩存)加快訪問速度。

本地(native)方法講解:

(1)本地方法就是帶有native標識符修飾的方法;

(2)native修飾符修飾的方法並不提供方法體,但因爲其實現體是由非java代碼在在外部實現的,因此不能與abstract連用;

(3)存在的意義:不方便用java語言寫的代碼,使用更爲專業的語言寫更合適;甚至有些JVM的實現就是用c編寫的,所以只能使用c來寫,

(4)更多的本地方法最好是與jdk的執行引擎的解釋器語言一致(執行引擎、解釋器:參考21的執行引擎);

(5)Windows、Linux、UNIX、Dos操作系統的核心代碼大部分是使用C和C++編寫,底層接口用匯編編寫.

(6)爲什麼native方法修飾的修飾的方法PC程序計數器爲undefined。讀懂上面的所有知識點可以就很容易自己理解了。在一開始類加載時,native修飾的方法就被保存在了本地方法棧中,當需要調用native方法時,調用的是一個指向本地方法棧中某方法的地址,然後執行方法直接與操作系統交互,返回運行結果。整個過程並沒有經過執行引擎的解釋器把字節碼解釋成操作系統語言,PC計數器也就沒有起作用。

9.GC垃圾回收機制

瞭解堆內存:

類加載器讀取了類文件後,需要把類、方法、常變量放到堆內存中,以方便執行器執行,堆內存分爲三部分:

       ① 新生區

       新生區是類的誕生、成長、消亡的區域,一個類在這裏產生,應用,最後被垃圾回收器收集,結束生命。新生區又分爲兩部分:伊甸區(Eden space)和倖存者區(Survivor pace),所有的類都是在伊甸區被new出來的。倖存區有兩個:0區(Survivor 0 space)和1區(Survivor 1 space)。當伊甸園的空間用完時,程序又需要創建對象,JVM的垃圾回收器將對伊甸園進行垃圾回收(Minor GC),將伊甸園中的剩餘對象移動到倖存0區。若倖存0區也滿了,再對該區進行垃圾回收,然後移動到1區。那如果1去也滿了呢?再移動到養老區。若養老區也滿了,那麼這個時候將產生Major GC(FullGCC),進行養老區的內存清理。若養老區執行Full GC 之後發現依然無法進行對象的保存,就會產生OOM異常“OutOfMemoryError”。

     如果出現java.lang.OutOfMemoryError: Java heap space異常,說明Java虛擬機的堆內存不夠。原因有二:

    a.Java虛擬機的堆內存設置不夠,可以通過參數-Xms、-Xmx來調整。

     b.代碼中創建了大量大對象,並且長時間不能被垃圾收集器收集(存在被引用)。

     ② 養老區

         養老區用於保存從新生區篩選出來的 JAVA 對象,一般池對象都在這個區域活躍。

     ③ 永久區

         永久存儲區是一個常駐內存區域,用於存放JDK自身所攜帶的 Class,Interface 的元數據,也就是說它存儲的是運行環境必須的類信息,被裝載進此區域的數據是不會被垃圾回收器回收掉的,關閉 JVM 纔會釋放此區域所佔用的內存。

     如果出現java.lang.OutOfMemoryError: PermGen space,說明是Java虛擬機對永久代Perm內存設置不夠。原因有二:

     a. 程序啓動需要加載大量的第三方jar包。例如:在一個Tomcat下部署了太多的應用。

     b. 大量動態反射生成的類不斷被加載,最終導致Perm區被佔滿。

     說明:

     Jdk1.6及之前:常量池分配在永久代 。

     Jdk1.7:有,但已經逐步“去永久代” 。

     Jdk1.8及之後:無(java.lang.OutOfMemoryError: PermGen space,這種錯誤將不會出現在JDK1.8中)。

堆內存大小-Xms -Xmx設置相同,因爲-Xmx越大tomcat就有更多的內存可以使用,這就意味着JVM調用垃圾回收機制的頻率就會減少(垃圾回收機制被調用是jvm內存不夠時自動調用的)可以避免每次垃圾回收完成後JVM重新分配內存。

GC具體什麼時候執行,這個是由系統來進行決定的,是無法預測的。
對象的實例保存在堆上,對象的元數據(instantKlass)保存在方法區,對象的引用保存在棧上。
類加載是會先看方法區有沒有已經加載過這個類,因此方法區中的類是唯一的。方法區中的類都是運行時的,都是正在使用的,是不能被GC的,所以可以理解成永久代。

10.java 內存模型

讀完上面那麼多最後一個知識點,理解下多線程的一點知識。我們應該知道了在運行時數據內存區中虛擬機棧、pc寄存器、本地方法棧是每個線程都有的,很明顯這些都是獨立的不會發生線程不安全的問題,但是我們平時討論的線程不安全、要加鎖等等情況是怎麼回事呢?

其實,發生線程不安全問題的原因在於cpu,看下圖,簡單理解cpu

在CPU內部有一組CPU寄存器,也就是CPU的儲存器。CPU操作寄存器的速度要比操作計算機主存快的多。在主存和CPU寄存器之間還存在一個CPU緩存,CPU操作CPU緩存的速度快於主存但慢於CPU寄存器。某些CPU可能有多個緩存層(一級緩存和二級緩存)。計算機的主存也稱作RAM,所有的CPU都能夠訪問主存,而且主存比上面提到的緩存和寄存器大很多。
當一個CPU需要訪問主存時,會先讀取一部分主存數據到CPU緩存,進而在讀取CPU緩存到寄存器。當CPU需要寫數據到主存時,同樣會先flush寄存器到CPU緩存,然後再在某些節點把緩存數據flush到主存。

Java內存模型和硬件架構之間的橋接
正如上面講到的,Java內存模型和硬件內存架構並不一致。硬件內存架構中並沒有區分棧和堆,從硬件上看,不管是棧還是堆,大部分數據都會存到主存中,當然一部分棧和堆的數據也有可能會存到CPU寄存器中,如下圖所示,Java內存模型和計算機硬件內存架構是一個交叉關係:

當對象和變量存儲到計算機的各個內存區域時,必然會面臨一些問題,其中最主要的兩個問題是:

1.共享對象對各個線程的可見性

2. 共享對象的競爭現象


問題1:

共享對象的可見性
當多個線程同時操作同一個共享對象時,如果沒有合理的使用volatile和synchronization關鍵字,一個線程對共享對象的更新有可能導致其它線程不可見。

想象一下我們的共享對象存儲在主存,一個CPU中的線程讀取主存數據到CPU緩存,然後對共享對象做了更改,但CPU緩存中的更改後的對象還沒有flush到主存,此時線程對共享對象的更改對其它CPU中的線程是不可見的。最終就是每個線程最終都會拷貝共享對象,而且拷貝的對象位於不同的CPU緩存中。

下圖展示了上面描述的過程。左邊CPU中運行的線程從主存中拷貝共享對象obj到它的CPU緩存,把對象obj的count變量改爲2。但這個變更對運行在右邊CPU中的線程不可見,因爲這個更改還沒有flush到主存中:

要解決共享對象可見性這個問題,我們可以使用java volatile關鍵字。 Java’s volatile keyword. volatile 關鍵字可以保證變量會直接從主存讀取,而對變量的更新也會直接寫到主存。volatile原理是基於CPU內存屏障指令實現的。

問題2:

競爭現象
如果多個線程共享一個對象,如果它們同時修改這個共享對象,這就產生了競爭現象。
如下圖所示,線程A和線程B共享一個對象obj。假設線程A從主存讀取Obj.count變量到自己的CPU緩存,同時,線程B也讀取了Obj.count變量到它的CPU緩存,並且這兩個線程都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU緩存中。

如果這兩個加1操作是串行執行的,那麼Obj.count變量便會在原始值上加2,最終主存中的Obj.count的值會是3。然而下圖中兩個加1操作是並行的,不管是線程A還是線程B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,儘管一共有兩次加1操作。

要解決上面的問題我們可以使用java synchronized代碼塊。synchronized代碼塊可以保證同一個時刻只能有一個線程進入代碼競爭區,synchronized代碼塊也能保證代碼塊中所有變量都將會從主存中讀,當線程退出代碼塊時,對所有變量的更新將會flush到主存,不管這些變量是不是volatile類型的。

volatile和 synchronized區別
volatile本質是在告訴jvm當前變量在寄存器(工作內存)中的值是不確定的,需要從主存中讀取; synchronized則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。

volatile僅能使用在變量級別;synchronized則可以使用在變量、方法、和類級別的
volatile僅能實現變量的修改可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性

volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞。
volatile標記的變量不會被編譯器優化;synchronized標記的變量可以被編譯器優化。

11.其他

11.1方法區與堆的區別

方法區存放了類的信息,有類的靜態變量、final類型變量、field自動信息、方法信息,處理邏輯的指令集,我們仔細想想一個類裏面也就這些東西,而堆中存放是對象和數組,咋一看好像方法區跟堆的作用是一樣的。

其實呢,區別就是:

1,這裏就關係到我們平時說的對象是類的實例,是不是有點恍然大悟了?這裏的對應關係就是 “方法區--類”  “堆--對象”,以“人”爲例就是,堆裏面放的是你這個“實實在在的人,有血有肉的”,而方法區中存放的是描述你的文字信息,如“你的名字,身高,體重,還有你的行爲,如吃飯,走路等”。

2,再者我們從另一個角度理解,就是從前我們得知方法區中的類是唯一的,同步的。但是我們在代碼中往往同一個類會new幾次,也就是有多個實例,既然有多個實例,那麼在堆中就會分配多個實例空間內存。

 

11.2方法區的內容是一次把一個工程的所有類信息都加載進去再去執行還是邊加載邊執行呢?

其實單從性能方面也能猜測到是隻加載當前使用的類,也就是邊加載邊執行。例如我們使用tomcat啓動一個spring工程,通常啓動過程中會加載數據庫信息,配置文件中的攔截器信息,service的註解信息,一些驗證信息等,其中的類信息就會率先加載到方法區。但如果我們想讓程序啓動的快一點就會設置懶加載,把一些驗證去掉,如一些類信息的加載等真正使用的時候再去加載,這樣說明了方法區的內容可以先加載進去,也可以在使用到的時候加載。

 

11.3方法區,棧、堆之間的過程

類加載器加載的類信息放到方法區,--》執行程序後,方法區的方法壓入棧的棧頂--》棧執行壓入棧頂的方法--》遇到new對象的情況就在堆中開闢這個類的實例空間。(這裏棧是有此對象在堆中的地址的)

 

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