聊聊JVM吧

我們爲什麼要學習JVM呢,首先,面試會問!!!其次,當程序觸發內存溢出等異常的時候,我們通過異常來判斷異常產生原因,然後就是我們可以來優化我們的性能,避免垃圾代碼的產生。

JVM是幹什麼的

衆所周知,java是跨平臺的語言,主打的就是一次編譯,到處運行。而之所以能實現這個功能,就是因爲JVM,那麼JVM幹什麼了呢?

JVM說白了就是從軟件層面屏蔽了底層硬件,指令層面的細節。它將字節碼文件解釋成爲特定的機器碼進行運行,使之實現上面說的跨平臺效果。

JVM裏面有什麼

JVM由三個主要的子系統構成

1、類加載子系統

2、運行時數據區(內存結構)

3、執行引擎

類裝載器

    每一個Java虛擬機都由一個類加載器子系統(class loader subsystem),負責加載程序中的類型(類和接口),並賦予唯一的名字。

執行引擎

它或者在執行字節碼,或者執行本地方法

運行時數據區(重要)

操作系統說白了就是對數據和指令的操作,所以運行時數據區我們也可以分爲數據和指令兩大類,同時GC也發生在方法區和堆裏面。

程序計數器

記錄了當前線程執行的位置。說白了就是記住我執行到那步了,我們都知道現在的系統都是併發的,所以,我們才需要知道我們執行到那步了,等到線程再次執行才能無縫接軌。

虛擬機棧

Java線程執行方法的內存模型,一個線程對應一個棧,所以說,他是線程私有的。每個方法在執行的同時都會創建一個棧幀(用於存儲局部變量表,操作數棧,動態鏈接,方法出口等信息)不存在垃圾回收問題,只要線程一結束該棧就釋放,生命週期和線程一致

棧中的元素用於支持虛擬機進行方法調用,每個方法從開始調用到執行完成的過程,就是棧幀從入棧到出棧的過程在活動線程中,只有位於棧頂的幀纔是有效的,稱爲當前棧幀,正在執行的方法稱爲當前方法,棧幀是方法運行的基本結構

在執行引擎運行時,所有指令都只能針對當前棧幀進行操作

在 Java 虛擬機規範中,對這個區域規定了兩種異常情況:

  • 如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常。(單線程獨有)
  • 如果虛擬機在動態擴展棧時無法申請到足夠的內存空間,則拋出 OutOfMemoryError 異常。(多線程會發生)

如果你想看具體的方法是怎麼執行的  我們可以查看java的字節碼文件反彙編出來的指令文件

局部變量:

我們的局部變量裏面保存的是方法參數以及局部變量。

局部變量以一個字長爲單位(32位長度),所以聲明double類型會佔用兩個單位。

局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。(因爲我們java是強類型語言,每個變量已經確定好了類型)

操作數棧:

操作棧是一個初始狀態爲空的桶式結構棧

在方法執行過程中,會有各種指令往棧中寫入和提取信息

JVM的執行引擎是基於棧的執行引擎,其中的棧指的就是操作數棧

動態鏈接:

每個棧幀都包含一個指向運行時常量池(在方法區中,後面介紹)中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態連接。

虛擬機運行的時候,運行時常量池會保存大量的符號引用,這些符號引用可以看成是每個方法的間接引用

如果代表棧幀A的方法想調用代表棧幀B的方法,那麼這個虛擬機的方法調用指令就會以B方法的符號引用作爲參數,但是因爲符號引用並不是直接指向代表B方法的內存位置,所以在調用之前還必須要將符號引用轉換爲直接引用,然後通過直接引用纔可以訪問到真正的方法。(說白了就是嵌套調用其他方法用的)

出口

沒啥說的,就是方法或者順利或者不順利(異常)的執行結束。

本地方法棧

和棧作用很相似,區別不過是Java棧爲JVM執行Java方法服務,而本地方法棧爲JVM執行操作系統native方法服務。(沒啥用吧)


重要的來了

方法區

方法區,又被成爲non-heap,就是爲了和堆區分開,方法區也是各個線程共享的內存區域,其中保存了類信息(class信息),常量,還有靜態常量,運行時常量池

方法區又被稱爲“永久代”(<JDK1.8),另外,虛擬機規範允許該區域可以選擇不實現垃圾回收。相對而言,垃圾收集行爲在這個區域比較少出現。該區域的內存回收目標主要針是對廢棄常量的和無用類的回收。

在JDK1.8之後,元空間替代了永久代,它是方法區的實現,區別在於元數據區不在虛擬機當中,而是用的本地內存,永久代在虛擬機當中,永久代邏輯結構上也屬於堆,但是物理上不屬於。

根據 Java 虛擬機規範的規定,當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError 異常(很少出現)。

堆(heap)

虛擬機啓動時自動分配創建,用於存放對象的實例,幾乎所有對象(包括常量池)都在堆上分配內存,當對象無法在
該空間申請到內存是將拋出OutOfMemoryError異常。同時也是垃圾收集器管理的主要區域。

新生代

新生代分爲三個區域,一個Eden區和兩個Survivor區,它們之間的比例爲(8:1:1),這個比例也是可以修改的。

通常情況下,對象主要分配在新生代的Eden區上,少數情況下也可能會直接分配在老年代中(分配擔保機制,下面會說)。

Java虛擬機每次使用新生代中的Eden和其中一塊Survivor(From),在經過一次Minor GC(eden區滿了)後,將Eden和Survivor中還存活的對象一次性地複製到另一塊Survivor空間上(這裏使用的複製回收算法進行GC),最後清理掉Eden和剛纔用過的Survivor(From)空間,此時from和to會互換身份。

將此時在Survivor空間存活下來的對象的年齡設置爲1,以後這些對象每在Survivor區熬過一次GC,它們的年齡就加1,當對象年齡達到某個年齡(默認值爲15)時,就會把它們移到老年代中。

在新生代中進行GC時,有可能遇到另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象將直接通過分配擔保機制進入老年代;

注:堆=新生代+老年代

內存泄漏

內存泄漏一般可以理解爲系統資源(各方面的資源,堆、棧、線程等)在錯誤使用的情況下,導致使用完畢的資源無法回收(或沒有回收),從而導致新的資源分配請求無法完成,引起系統錯誤。

整個JVM內存大小=年輕代大小 + 年老代大小 + 持久代大小。

目前來說,常遇到的泄漏問題如下:

1、老年代堆空間被佔滿

 異常: java.lang.OutOfMemoryError: Java heap space

這是最典型的內存泄漏方式,簡單說就是所有堆空間都被無法回收的垃圾對象佔滿,虛擬機無法再在分配新空間。

這種情況一般來說是因爲內存泄漏或者內存不足造成的。

某些情況因爲長期的無法釋放對象,運行時間長了以後導致對象數量增多,從而導致的內存泄漏。

另外一種就是因爲系統的原因,大併發加上大對象,Survivor Space區域內存不夠,大量的對象進入到了老年代,然而老年代的內存也不足時,從而產生了Full GC,但是這個時候Full GC也無發回收。這個時候就會產生

java.lang.OutOfMemoryError: Java heap space

2、方法區被佔滿

異常:java.lang.OutOfMemoryError: PermGen space

Perm空間被佔滿。無法爲新的class分配存儲空間而引發的異常。

這個異常以前是沒有的,但是在Java反射大量使用的今天這個異常比較常見了。主要原因就是大量動態反射生成的類不斷被加載,最終導致Perm區被佔滿。

3、堆棧溢出

異常:java.lang.StackOverflowError

一般就是遞歸,或者循環調用造成

4、線程堆棧滿

異常:Fatal: Stack size too small

java中一個線程的空間大小是有限制的。JDK5.0以後這個值是1M。與這個線程相關的數據將會保存在其中。但是當線程空間滿了以後,將會出現上面異常。

GC

JVM分別對新生代和老年代採用不同的垃圾回收機制。

GC觸發條件:

Eden區滿了觸發Minor GC,這時會把Eden區存活的對象複製到S區,當對象在Survivor區熬過一定次數的Minor GC之後,就會晉升到老年代,當老年代滿了,就會報OutofMemory異常。

新生代的GC(Minor GC):

新生代通常存活時間較短,基於複製算法進行回收,所謂複製算法就是掃描出存活的對象,並複製到一塊新的完全未使用的空間中。

對應於新生代,就是在Eden和FromSpace或ToSpace之間copy。新生代採用空閒指針的方式來控制GC觸發,指針保持最後一個分配的對象在新生代區間的位置,當有新的對象要分配內存時,用於檢查空間是否足夠,不夠就觸發GC。當連續分配對象時,對象會逐漸從Eden到Survivor,最後到老年代。

在執行機制上JVM提供了串行GC(SerialGC)、並行回收GC(ParallelScavenge)和並行GC(ParNew):

串行GC

在整個掃描和複製過程採用單線程的方式來進行,適用於單CPU、新生代空間較小及對暫停時間要求不是非常高的應用上,是client級別默認的GC方式,可以通過-XX:+UseSerialGC來強制指定。

並行回收GC

在整個掃描和複製過程採用多線程的方式來進行,適用於多CPU、對暫停時間要求較短的應用上,是server級別默認採用的GC方式,可用-XX:+UseParallelGC來強制指定,用-XX:ParallelGCThreads=4來指定線程數。

並行GC

與老年代的併發GC配合使用。

老年代的GC(Major GC/Full GC):

Major GC 是清理老年代。 Full GC 是清理整個堆空間(包括新生代和永久代)。

老年代與新生代不同,老年代對象存活的時間比較長、比較穩定,因此採用標記(Mark)算法來進行回收,所謂標記就是掃描出存活的對象,然後再進行回收未被標記的對象,回收後對用空出的空間要麼進行合併、要麼標記出來便於下次進行分配,總之目的就是要減少內存碎片帶來的效率損耗。

在執行機制上JVM提供了串行GC(Serial MSC)、並行GC(Parallel MSC)和併發GC(CMS)。

串行GC(Serial MSC)

client模式下的默認GC方式,可通過-XX:+UseSerialGC強制指定。每次進行全部回收,進行Compact,非常耗費時間。

並行GC(Parallel MSC)

吞吐量大,但是GC的時候響應很慢

server模式下的默認GC方式,也可用-XX:+UseParallelGC=強制指定。可以在選項後加等號來制定並行的線程數。

併發GC(CMS)

響應比並行gc快很多,但是犧牲了一定的吞吐量

垃圾回收算法

1.1 引用計數器算法

引用計數器算法是給每個對象設置一個計數器,當有地方引用這個對象的時候,計數器+1,當引用失效的時候,計數器-1,當計數器爲0的時候,JVM就認爲對象不再被使用,是“垃圾”了。

引用計數器實現簡單,效率高;但是不能解決循環引用問問題(A對象引用B對象,B對象又引用A對象,但是A,B對象已不被任何其他對象引用),同時每次計數器的增加和減少都帶來了很多額外的開銷,所以在JDK1.1之後,這個算法已經不再使用了。

1.2 可達性分析算法

可達性分析算法是通過一些“GC Roots”對象作爲起點,從這些節點開始往下搜索,搜索通過的路徑成爲引用鏈(Reference Chain),當一個對象沒有被GC Roots的引用鏈連接的時候,說明這個對象是不可用的,如下圖所示。

GC Roots對象包括:

  1. 虛擬機棧(棧幀中的本地變量表)中的引用的對象。

  2. 方法區中的類靜態屬性引用的對象。

  3. 方法區中常量引用的對象。

  4. 本地方法棧中JNI(Native方法)的引用的對象。

上面只是標記了對象是否可以被回收,實際上在java中首先會標記下對象,會調用對象裏面的protected void finalize()這個方法,這個時候對象還有救,只要在這個方法把該對象和引用鏈對接上,其實可以逃脫被回收

1.3 標記—清除算法

標記—清除算法包括兩個階段:“標記”和“清除”。在標記階段,確定所有要回收的對象,並做標記。清除階段緊隨標記階段,將標記階段確定不可用的對象清除。

標記—清除算法是基礎的收集算法,標記和清除階段的效率不高,而且清除後回產生大量的不連續空間,這樣當程序需要分配大內存對象時,可能無法找到足夠的連續空間。如下圖所示:

 

1.4 複製算法

複製算法是把內存分成大小相等的兩塊,每次使用其中一塊,當垃圾回收的時候,把存活的對象複製到另一塊上,然後把這塊內存整個清理掉。這種方式聽上去確實是非常不錯的方案,但是總的來說對內存的消耗十分高。

複製算法實現簡單,運行效率高,但是由於每次只能使用其中的一半,造成內存的利用率不高。現在的JVM用複製方法收集新生代,由於新生代中大部分對象(98%)都是朝生夕死的,所以兩塊內存的比例不是1:1(大概是8:1),也就是常提到的一塊Eden(80%)和兩塊Survivor(20%)。當然也會存在10%不夠用的情況,這個後面在進行梳理,會有一個補償機制,也就是分配擔保

1.5 標記—整理算法

複製收集算法會存在一種極端情況,就是對象都沒死。這種情況會在老年代有機率的出現,所以根據老年代的特點提出了標記—整理算法。 標記—整理算法和標記—清除算法一樣,但是標記—整理算法不是把存活對象複製到另一塊內存,而是把存活對象往內存的一端移動,然後直接回收邊界以外的內存,如下圖所示:

 

1.6 分代收集

分代收集是根據對象的存活時間把內存分爲新生代和老年代,根據個代對象的存活特點,每個代採用不同的垃圾回收算法。

新生代採用標記—複製算法,老年代採用標記—整理算法。

垃圾算法的實現涉及大量的程序細節,而且不同的虛擬機平臺實現的方法也各不相同。上面介紹的只不過是基本思想。

 

 

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