jvm內存模型概述


一、Jvm 的介紹

1、JVM體系結構

2、JVM運行時數據區

3、JVM內存模型

JVM運行時內存 = 共享內存區 + 線程內存區

3.1、共享內存區

共享內存區 = 持久帶(方法區 + 其他)+ 堆(Old Space + Young Space(den + S0 + S1))

持久代
JVM用持久帶(Permanent Space)實現方法區,主要存放所有已加載的類信息,方法信息,常量池等等。可通過-XX:PermSize和-XX:MaxPermSize來指定持久帶初始化值和最大值。Permanent Space並不等同於方法區,只不過是Hotspot JVM用Permanent Space來實現方法區而已,有些虛擬機沒有Permanent Space而用其他機制來實現方法區。
堆(heap):
主要用來存放類的對象實例信息(包括new操作實例化的對象和定義的數組)。
堆分為Old Space(又名,Tenured Generation)和Young Space。Old Space主要存放應用程序中生命周期長的存活對象;Eden(伊甸園)主要存放新生的對象;S0和S1是兩個大小相同的內存區域,主要存放每次垃圾回收后Eden存活的對象,作為對象從Eden過渡到Old Space的緩沖地帶(S是指英文單詞Survivor Space)。堆之所以要划分區間,是為了方便對象創建和垃圾回收.

3.2、線程內存區


線程內存區(JVM棧):
線程內存區=單個線程內存+單個線程內存+.......
單個線程內存=PC Regster+JVM棧+本地方法棧
JVM棧=棧幀+棧幀+.....
棧幀=局域變量區+操作數區+幀數據區
在Java中,一個線程會對應一個JVM棧(JVM Stack),JVM棧里記錄了線程的運行狀態。JVM棧以棧幀為單位組成,一個棧幀代表一個方法調用。棧幀由三部分組成:局部變量區、操作數棧、幀數據區。
線程在棧區,不能共享數據,只能通過復制共享區的數據作為一塊緩存,所有多線程寫會有bug,voliate使得取到的數據不做緩存,是實時更新的。關鍵字 volatile 是輕量級的同步機制。
Volatile 變量對於all線程的可見性,指當一條線程修改了這個變量的值,新值對於其他 線程來說是可見的、立即得知的。 Volatile 變量在多線程下不一定安全,因為他只有可見性、有序性,但是沒有原子性。

二、JVM內存空間管理

JVM把內存划分了如下幾個區域:

共享內存區 = 持久帶(方法區 + 其他)+ 堆(Old Space + Young Space(den + S0 + S1));
Java 內存模型和線程:
每個線程都有一個工作內存,線程只可以修改自己工作內存中的數據,然后再同步回主內存,主內存由多個內存共享。

2.1 方法區(共享內存區的持久帶

  • 方法區 (又稱為持久代):要加載的類的信息(名稱、修飾符等)、類中的靜態變量、類中定義為final類型的常量、類中的Field信息、類中的方法信息方法區域也是全局共享的,當開發人員調用類對象中的getName、isInterface等方法來獲取信息時,這些數據都來源於方法區。
  • 在一定條件下它也會被GC,當方法區域要使用的內存超過其允許的大小時,會拋出OutOfMemory:PermGen Space異常。的錯誤信息。在Sun JDK中這塊區域對應Permanet Generation,,默認最小值為16MB,最大值為64MB,可通過-XX:PermSize及-XX:MaxPermSize來指定最小值和最大值。
  • 在Hotspot虛擬機中,這塊區域對應的是Permanent Generation(持久代),一般的,方法區上執行的垃圾收集是很少的,因此方法區又被稱為持久代的原因之一,但這也不代表着在方法區上完全沒有垃圾收集,其上的垃圾收集主要是針對常量池的內存回收和對已加載類的卸載。在方法區上進行垃圾收集,條件苛刻而且相當困難,關於其回后面再介紹。

運行時常量池(Runtime Constant Pool)是方法區的一部分,用於存儲編譯期就生成的字面常量、符號引用、翻譯出來的直接引用(符號引用就是編碼是用字符串表示某個變量、接口的位置,直接引用就是根據符號引用翻譯出來的地址,將在類鏈接階段完成翻譯);
運行時常量池除了存儲編譯期常量外,也可以存儲在運行時間產生的常量,比如String類的intern()方法,作用是String維護了一個常量池,如果調用的字符“abc”已經在常量池中,則返回池中的字符串地址,否則,新建一個常量加入池中,並返回地址。JVM方法區的相關參數,最小值:--XX:PermSize;最大值 --XX:MaxPermSize。

2.2 堆區(堆區由所有線程共享)

堆用於存儲對象實例及數組值,可以認為Java中所有通過new創建的對象的內存都在此分配,堆區由所有線程共享。Heap中對象所占用的內存由GC進行回收,在32位操作系統上最大為2GB,在64位操作系統上則沒有限制,其大小可通過-Xms和-Xmx來控制,-Xms為JVM啟動時申請的最小Heap內存,默認為物理內存的1/64但小於1GB;-Xmx為JVM可申請的最大Heap內存,默認為物理內存的1/4但小於1GB,默認當空余堆內存小於40%時,JVM會增大Heap到-Xmx指定的大小,可通過-XX:MinHeapFreeRatio=來指定這個比例;當空余堆內存大於70%時,JVM會減小Heap的大小到-Xms指定的大小,可通過-XX:MaxHeapFreeRatio=來指定這個比例,對於運行系統而言,為避免在運行時頻繁調整Heap 的大小,通常將-Xms和-Xmx的值設成一樣。
堆區是理解JavaGC機制最重要的區域。在JVM所管理的內存中,堆區是最大的一塊,堆區也是JavaGC機制所管理的主要內存區域,堆區由所有線程共享,在虛擬機啟動時創建。堆區用來存儲對象實例及數組值,可以認為java中所有通過new創建的對象都在此分配。

2.3 本地方法棧(Native Method Stack)

本地方法棧用於支持native方法的執行,存儲了每個native方法調用的狀態。本地方法棧和虛擬機方法棧運行機制一致,它們唯一的區別就是,虛擬機棧是執行Java方法的,而本地方法棧是用來執行native方法的,在很多虛擬機中(如Sun的JDK默認的HotSpot虛擬機),會將本地方法棧與虛擬機棧放在一起使用。

2.4 虛擬機棧(JVM Stack)(線程私有)

JVM方法棧:為線程私有,其在內存分配上非常高效。當方法運行完畢時,其對應的棧幀所占用的內存也會自動釋放。當JVM方法棧空間不足時,會拋出StackOverflowError的錯誤,在Sun JDK中可以通過-Xss來指定其大小。
虛擬機棧占用的是操作系統內存,每個線程都對應着一個虛擬機棧,它是線程私有的,而且分配非常高效。一個線程的每個方法在執行的同時,都會創建一個棧幀(Statck Frame),棧幀中存儲的有局部變量表、操作站、動態鏈接、方法出口等,當方法被調用時,棧幀在JVM棧中入棧,當方法執行完成時,棧幀出棧。
局部變量表中存儲着方法的相關局部變量,包括各種基本數據類型,對象的引用,返回地址等。在局部變量表中,只有long和double類型會占用2個局部變量空間(Slot,對於32位機器,一個Slot就是32個bit),其它都是1個Slot。需要注意的是,局部變量表是在編譯時就已經確定好的,方法運行所需要分配的空間在棧幀中是完全確定的,在方法的生命周期內都不會改變。
虛擬機棧中定義了兩種異常,如果線程調用的棧深度大於虛擬機允許的最大深度,則拋出StatckOverFlowError(棧溢出);不過多數Java虛擬機都允許動態擴展虛擬機棧的大小(有少部分是固定長度的),所以線程可以一直申請棧,直到內存不足,此時,會拋出OutOfMemoryError(內存溢出)。

2.5 程序計數器(Program Counter Register)(線程私有)

程序計數器是一個比較小的內存區域,可能是CPU寄存器或者操作系統內存,其主要用於指示當前線程所執行的字節碼執行到了第幾行,可以理解為是當前線程的行號指示器。字節碼解釋器在工作時,會通過改變這個計數器的值來取下一條語句指令。 每個程序計數器只用來記錄一個線程的行號,所以它是線程私有(一個線程就有一個程序計數器)的。
如果程序執行的是一個Java方法,則計數器記錄的是正在執行的虛擬機字節碼指令地址;如果正在執行的是一個本地(native,由C語言編寫完成)方法,則計數器的值為Undefined,由於程序計數器只是記錄當前指令地址,所以不存在內存溢出的情況,因此,程序計數器也是所有JVM內存區域中唯一一個沒有定義OutOfMemoryError的區域。

三、內存溢出與內存泄漏

內存泄漏:分配出去的內存回收不了
內存溢出:指系統內存不夠用了

1、堆溢出

可以分為:內存泄漏和內存溢出,這兩種情況都會拋出OutOfMemoryError:java heap space異常:

a、內存泄漏:

內存泄漏是指對象實例在新建和使用完畢后,仍然被引用,沒能被垃圾回收釋放,一直積累,直到沒有剩余內存可用。如果內存泄露,我們要找出泄露的對象是怎么被GC ROOT引用起來,然后通過引用鏈來具體分析泄露的原因。分析內存泄漏的工具有:Jprofiler,visualvm等。

public class OOMTest {  
public static void main(String[] args) {  	          
        List<UUID> list = new ArrayList<UUID>();  
       while(true){  
            list.add(UUID.randomUUID());  
        }  
    }  	  
}

看看控制台的輸出結果,因為我這邊的JVM設置的參數內存足夠大,所以需要等待一定的時間,才能看到效果:

b、內存溢出

內存溢出是指當我們新建一個實力對象時,實例對象所需占用的內存空間大於堆的可用空間。如果出現了內存溢出問題,這往往是程序本生需要的內存大於了我們給虛擬機配置的內存,這種情況下,我們可以采用調大-Xmx來解決這種問題。

public class OOMTest_1 {  
    public static void main(String args[]){  
        List<byte[]> byteList = new ArrayList<byte[]>();  
        byteList.add(new byte[1000 * 1024 * 1024]);  
    }  
}  

2、棧溢出

棧(JVM Stack)存放主要是棧幀( 局部變量表, 操作數棧 , 動態鏈接 , 方法出口信息 )的地方。注意區分棧和棧幀:棧里包含棧幀。
與線程棧相關的內存異常有兩個::
a:StackOverflowError(方法調用層次太深,內存不夠新建棧幀)
b:OutOfMemoryError(線程太多,內存不夠新建線程)

a、java.lang.StackOverflowError

棧溢出拋出java.lang.StackOverflowError錯誤,出現此種情況是因為方法運行的時候,請求新建棧幀時,棧所剩空間小於棧幀所需空間。例如,通過遞歸調用方法,不停的產生棧幀,一直把棧空間堆滿,直到拋出異常 :

public class SOFTest {  
    public void stackOverFlowMethod(){  
        stackOverFlowMethod();  
    }  
    /** 
        * 通過遞歸調用方法,不停的產生棧幀,一直把棧空間堆滿,直到拋出異常 : 
        * @param args 
        */  
    public static void main(String[] args) {  
        SOFTest sof = new SOFTest();  
        sof.stackOverFlowMethod();  
    }  
    
}  

b、OutOfMemoryError(暫不介紹)

四、JVM內存分配

Java對象所占用的內存主要在堆上實現,因為堆是線程共享的,因此在堆上分配內存時需要進行加鎖,這就導致了創建對象的開銷比較大。當堆上空間不足時,會出發GC,如果GC后空間仍然不足,則會拋出OutOfMemory異常。

為了提升內存分配效率,在年輕代的Eden區HotSpot虛擬機使用了兩種技術來加快內存分配 ,分別是bump-the-pointer和TLAB(Thread-Local Allocation Buffers)。由於Eden區是連續的,因此bump-the-pointer技術的核心就是跟蹤最后創建的一個對象,在對象創建時,只需要檢查最后一個對象后面是否有足夠的內存即可,從而大大加快內存分配速度;而對於TLAB技術是對於多線程而言的, 它會為每個新創建的線程在新生代的Eden Space上分配一塊獨立的空間,這塊空間稱為TLAB(Thread Local Allocation Buffer),其大小由JVM根據運行情況計算而得。可通過-XX:TLABWasteTargetPercent來設置其可占用的Eden Space的百分比,默認是1%。在TLAB上分配內存不需要加鎖,一般JVM會優先在TLAB上分配內存,如果對象過大或者TLAB空間已經用完,則仍然在堆上進行分配。因此,在編寫程序時,多個小對象比大的對象分配起來效率更高。可在啟動參數上增加-XX:+PrintTLAB來查看TLAB空間的使用情況。

對象如果在年輕代存活了足夠長的時間而沒有被清理掉(即在幾次Minor GC后存活了下來),則會被復制到年老代,年老代的空間一般比年輕代大,能存放更多的對象,在年老代上發生的GC次數也比年輕代少。當年老代內存不足時,將執行Major GC,也叫 Full GC。
可以使用-XX:+UseAdaptiveSizePolicy開關來控制是否采用動態控制策略,如果動態控制,則動態調整Java堆中各個區域的大小以及進入老年代的年齡。如果對象比較大(比如長字符串或大數組),年輕代空間不足,則大對象會直接分配到老年代上(大對象可能觸發提前GC,應少用,更應避免使用短命的大對象)。用 -XX:PretenureSizeThreshold來控制直接升入老年代的對象大小,大於這個值的對象會直接分配在老年代上。

五、內存的回收方式

1、收集器:引用計數收集器、跟蹤收集器

1、引用計數收集器:


在上圖中,ObjectA釋放了對ObjectB的引用后,ObjectB的引用計數器變為0,此時可回收ObjectB所占有的內存。引用計數器需要在每次對象賦值時進行引用計數器的增減,他有一定消耗。另外,引用計數器對於循環引用的場景沒有辦法實現回收。例如在上面的例子中,如果ObjectB和ObjectC互相引用,那么即使ObjectA釋放了對ObjectB和ObjectC的引用,也無法回收ObjectB、ObjectC,因此對於java這種會形成復雜引用關系的語言而言,引用計數器是非常不適合的,SunJDK在實現GC時也未采用這種方式。

2、 跟蹤收集器實現算法:

跟蹤收集器采用的為集中式的管理方式,會全局記錄數據引用的狀態。基於一定條件的觸發(例如定時、空間不足時),執行時需要從根集合來掃描對象的引用關系,這可能會造成應用程序暫停。主要有復制(Copying):年輕代的Eden區、標記-清除(Mark-Sweep)和標記-壓縮(Mark-Compact)三種實現算法。

1、復制:

特征:當要回收的空間中存活對象較少時,復制算法會比較高效,其帶來的成本是要增加一塊空的內存空間及進行對象的移動。
停止-復制算法:它將可用內存按照容量划分為大小相等的兩塊,每次只使用其中一塊。 當這一塊的內存用完了,則就將還存活的對象復制到另一塊上面,然后再把已經使用過的內 存空間一次清理掉。商業虛擬機:將內存分為一塊較大的 eden 空間和兩塊較小的 survivor 空間,默認比例是 8:1:1,即每次新生代中可用內存空間為整個新生代容量的 90%,每次使 用 eden 和其中一個 survivour。當回收時,將 eden 和 survivor 中還存活的對象一次性復制到 另外一塊 survivor 上,最后清理掉 eden 和剛才用過的 survivor,若另外一塊 survivor 空間沒有足夠內存空間存放上次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。
復制采用的方式為從根集合掃描出存活的對象,並將找到的存活的對象復制到一塊新的完全未被使用的空間中,如圖所示:

復制收集器方式僅需要從根集合掃描所有存活對象,當要回收的空間中存活對象較少時,復制算法會比較高效(年輕代的Eden區就是采用這個算法),其帶來的成本是要增加一塊空的內存空間及進行對象的移動。

2、標記-清除:

特征:在空間中存活對象較多的情況下較為高效,但由於標記-清除采用的為直接回收不存活對象所占用的內存,因此會造成內存碎片。
標記-清除采用的方式為從根集合開始掃描,對存活的對象進行標記,標記完畢后,再掃描整個空間中未標記的對象,並進行清除,標記和清除過程如下圖所示:

上圖中藍色的部分是有被引用的存活的對象,褐色部分沒被引用的可回收的對象。在marking階段為了mark對象,所有的對象都會被掃描一遍,掃描這個過程是比較耗時的。

清除階段回收的是沒有被引用的對象,存活的對象被保留。內存分配器會持有空閑空間的引用列表,當有分配請求時會查詢空閑空間引用列表進行分配。標記-清除動作不需要進行對象移動,且僅對其不存活的對象進行處理。在空間中存活對象較多的情況下較為高效,但由於標記-清除直接回收不存活對象占用的內存,因此會造成內存碎片。

3、標記-壓縮

特征:在標記-清除的基礎上還須進行對象的移動,成本相對更高,好處則是不產生內存碎片。
標記-壓縮和標記-清除一樣,是對活的對象進行標記,但是在清除后的處理不一樣,標記-壓縮在清除對象占用的內存后,會把所有活的對象向左端空閑空間移動,然后再更新引用其對象的指針,如下圖所示:

很明顯,標記-壓縮在標記-清除的基礎上對存活的對象進行了移動規整動作,解決了內存碎片問題,得到更多連續的內存空間以提高分配效率,但由於需要對對象進行移動,因此成本也比較高。

總結
JVM通過GC來回收堆和方法區中的內存,這個過程是自動執行的。說到Java GC機制,其主要完成3件事:確定哪些內存需要回收;確定什么時候需要執行GC;如何執行GC。JVM主要采用收集器的方式實現GC,主要的收集器有引用計數收集器和跟蹤收集器。
垃圾回收算法:1.引用計數算法2. 追蹤回收算法3.壓縮回收算法4.復制回收算法5.按代回收算法。為什么要按代回收。Java對象的生命周期一般不長。

6、虛擬機中的GC過程

6.1 為什么要分代回收?

在一開始的時候,JVM的GC就是采用標記-清除-壓縮方式進行的,這么做並不是很高效,因為當對象分配的越來越多時,對象列表也越來也大,掃描和移動越來越耗時,造成了內存回收越來越慢。然而,經過根據對java應用的分析,發現大部分對象的存活時間都非常短,只有少部分數據存活周期是比較長的,
分代收集:
新生代 停止-復制算法
老年代 標記-清理或標記-清除

6.2 虛擬機中GC的過程

經過上面介紹,我們已經知道了JVM為何要分代回收,下面我們就詳細看一下整個回收過程。

  • 1在初始階段,新創建的對象被分配到Eden區,survivor的兩塊空間都為空。

    當Eden區滿了的時候,minor garbage**(Minor GC年輕代垃圾回收機制) **被觸發
  • 2經過掃描與標記,存活的對象被復制到S0,不存活的對象被回收
  • 3在下一次的Minor GC中,Eden區的情況和上面一致,沒有引用的對象被回收,存活的對象被復制到survivor區。然而在survivor區,S0的所有的數據都被復制到S1,需要注意的是,在上次minor GC過程中移動到S0中的兩個對象在復制到S1后其年齡要加1。此時Eden區S0區被清空,所有存活的數據都復制到了S1區,並且S1區存在着年齡不一樣的對象,過程如下圖所示:
  • 4再下一次MinorGC則重復這個過程,這一次survivor的兩個區對換,存活的對象被復制到S0,存活的對象年齡加1,Eden區和另一個survivor區被清空。
  • 5下面演示一下Promotion過程,再經過幾次Minor GC之后,當存活對象的年齡達到一個閾值之后(可通過參數配置,默認是8),就會被從年輕代Promotion到老年代。
  • 6隨着MinorGC一次又一次的進行,不斷會有新的對象被promote到老年代。
  • 7上面基本上覆蓋了整個年輕代所有的回收過程。最終,MajorGC將會在老年代發生,老年代的空間將會被清除和壓縮。

總結:
從上面的過程可以看出,Eden區是連續的空間,且Survivor總有一個為空。經過一次GC和復制,一個Survivor中保存着當前還活着的對象,而Eden區和另一個Survivor區的內容都不再需要了,可以直接清空,到下一次GC時,兩個Survivor的角色再互換。因此,這種方式分配內存和清理內存的效率都極高,這種垃圾回收的方式就是著名的“停止-復制(Stop-and-copy)”清理法(將Eden區和一個Survivor中仍然存活的對象拷貝到另一個Survivor中),這不代表着停止復制清理法很高效,其實,它也只在這種情況下(基於大部分對象存活周期很短的事實)高效,如果在老年代采用停止復制,則是非常不合適的。老年代存儲的對象比年輕代多得多,而且不乏大對象,對老年代進行內存清理時,如果使用停止-復制算法,則相當低效。一般,老年代用的算法是標記-壓縮算法,即:標記出仍然存活的對象(存在引用的),將所有存活的對象向一端移動,以保證內存的連續。在發生Minor GC時,虛擬機會檢查每次晉升進入老年代的大小是否大於老年代的剩余空間大小,如果大於,則直接觸發一次Full GC,否則,就查看是否設置了-XX:+HandlePromotionFailure(允許擔保失敗),如果允許,則只會進行MinorGC,此時可以容忍內存分配失敗;如果不允許,則仍然進行Full GC(這代表着如果設置-XX:+Handle PromotionFailure,則觸發MinorGC就會同時觸發Full GC,哪怕老年代還有很多內存,所以,最好不要這樣做)。
關於方法區(共享內存區的持久代)即永久代的回收,永久代的回收有兩種:常量池中的常量,無用的類信息,常量的回收很簡單,沒有引用了就可以被回收。對於無用的類進行回收,必須保證3點:

  1. 類的所有實例都已經被回收
  2. 加載類的ClassLoader已經被回收
  3. 類對象的Class對象沒有被引用(即沒有通過反射引用該類的地方)
    永久代的回收並不是必須的,可以通過參數來設置是否對類進行回收。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM