JVM內存模型你只要看這一篇就夠了

讓我們不厭其煩的從內存模型開始說起:作爲一般人需要瞭解到的,JVM的內存區域可以被分爲:線程棧,堆,靜態方法區(實際上還有更多功能的區域,並且這裏說的是JVM的內存區域,實際上Java程序還可以調用native方法使用直接內存)。
本文接下來就重點說說這三個區域。

1. 線程棧

簡介

注意這個棧和數據結構中的stack有相似之處,但並不是用戶態的。準確的講它壓入的每個棧幀(Stack Frame)是程序指令以及局部變量表,每個方法調用對應一個棧幀。局部變量表包括各種基本數據類型:boolean、byte、char、short、int、float、long、double以及對象的引用。我們需要注意到每個線程都有獨立的棧並且是互相隔離的。

棧的大小

棧的大小可以受到幾個因素影響,一個是jvm參數 -XSS,默認值隨着虛擬機版本以及操作系統影響,從Oracle官網上我們可以找到:

In Java SE 6, the default on Sparc is 512k in the 32-bit VM, and 1024k in the 64-bit VM. On x86 Solaris/Linux it is 320k in the 32-bit VM and 1024k in the 64-bit VM.

我們可以認爲64位linux默認是1m的樣子。
除了JVM設置,我們還可以在創建Thread的時候手工指定大小:

 

public Thread(ThreadGroup group, Runnable target, String name , long stackSize)

棧的大小影響到了線程的最大數量,尤其在大流量的server中,我們很多時候的併發數受到的是線程數的限制,這時候需要了解限制在哪裏。
第一個限制在操作系統,以ubuntu爲例,/proc/sys/kernel/threads-max 和/proc/sys/vm/max_map_count 定義了總的最大線程數(根據資料windows總的來說線程數會更少)和mmap這個system_call的最大數量(也就是從內存方面限制了線程數)
第二個限制自然是在JVM,理論上我們能分配給線程的內存除以單個線程佔用的內存就是最大線程數。所以說對Java進程來講,既然分配給了堆,棧和靜態方法區(或叫永久代,perm區),我們可以大致認爲

 

線程數 = (系統空閒內存-堆內存(-Xms, -Xmx)- perm區內存(-XX:MaxPermSize)) / 線程棧大小(-Xss)

注意這只是幫助我們樹立一個概念,實際上還有許多因素影響。

棧的大小還影響到一個就是如果單個棧超過了這個大小,就會拋出StackOverflowError,一般來說遞歸調用是常見的原因。

如何查看線程棧

使用命令 jstack <pid>可以列出當前pid對應jvm的所有線程棧描述,描述主要包括了每個線程的狀態以及堆棧內各棧幀的方法全限定名,代碼位置。注意這只是爲了可閱讀性,並不是說棧裏存着的就是這些字符串。
截取一段tomcat的jstack輸出(線程方面的知識可以參考另一篇拙作《Java多線程你只需要看這一篇就夠了》,本文不再贅述):

tomcat的jstack輸出片段

2.堆和垃圾收集

堆的結構

對於大多數應用來說,Java 堆(Java Heap)是Java 虛擬機所管理的內存中最大的一塊。Java 堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裏分配內存。

分代的內存管理

首先堆可以劃分爲新生代和老年代。

新生代

然後新生代又可以劃分爲一個Eden區和兩個Survivor(倖存)區。
按照規定,新對象會首先分配在Eden中(如果對象過大,比如大數組,將會直接放到老年代)。在GC中,Eden中的對象會被移動到survivor中,直至對象滿足一定的年紀(定義爲熬過minor GC的次數),會被移動到老年代。

新生代 ( Young ) 與老年代 ( Old ) 的比例的值爲 1:2 ( 該值可以通過參數 –XX:NewRatio 來指定 )
默認的,Eden : from : to = 8 : 1 : 1 ( 可以通過參數 –XX:SurvivorRatio 來設定 ),即: Eden = 8/10 的新生代空間大小,from = to = 1/10 的新生代空間大小。

然後講講垃圾收集

堆內存和垃圾收集是密不可分的兩個主題,講垃圾收集的資料很多,但總的來說講的比較混亂,在這裏我試圖從一個系統的視角展示垃圾收集。

  • 垃圾收集的意義

    • 垃圾收集的出現解放了C++中手工對內存進行管理的大量繁雜工作,手工malloc,free不僅增加程序複雜度,還增加了bug數量。
    • 分代收集。即在新生代和老生代使用不同的收集方式。在垃圾收集上,目標主要有:加大系統吞吐量(減少總垃圾收集的資源消耗);減少最大STW(Stop-The-World)時間;減少總STW時間。不同的系統需要不同的達成目標。而分代這一里程碑式的進步首先極大減少了STW,然後可以自由組合來達到預定目標。
  • 可達性檢測

    • 引用計數:一種在jdk1.2之前被使用的垃圾收集算法,我們需要了解其思想。其主要思想就是維護一個counter,當counter爲0的時候認爲對象沒有引用,可以被回收。缺點是無法處理循環引用。目前iOS開發中的一個常見技術ARC(Automatic Reference Counting)也是採用類似的思路。在當前的JVM中應該是沒有被使用的。
    • 根搜算法:思想是從gc root根據引用關係來遍歷整個堆並作標記,稱之爲mark,等會在具體收集器中介紹並行標記和單線程標記。之後回收掉未被mark的對象,好處是解決了循環依賴這種『孤島效應』。這裏的gc root主要指:
      • a.虛擬機棧(棧楨中的本地變量表)中的引用的對象
      • b.方法區中的類靜態屬性引用的對象
      • c.方法區中的常量引用的對象
      • d.本地方法棧中JNI的引用的對象
  • 整理策略

    • 複製:主要用在新生代的回收上,通過from區和to區的來回拷貝。需要特定的結構(也就是Young區現在的結構)來支持,對於新生成的對象來說,頻繁的去複製可以最快的找到那些不用的對象並回收掉空間。所以說在JVM裏YGC一定承擔了最大量的垃圾清除任務。
    • 標記清除/標記整理:主要用在老生代回收上,通過根搜的標記然後清除或者整理掉不需要的對象。

整理的過程

清除的過程

這裏可以看到清除會產生碎片空間,對內存利用不是很好,但不是說整理優於清除,畢竟整理會更慢。比如CMSGC就是使用清除而不是整理的。

思考一下複製和標記清除/整理的區別,爲什麼新生代要用複製?因爲對新生代來講,一次垃圾收集要回收掉絕大部分對象,我們通過冗餘空間的辦法來加速整理過程(不冗餘空間的整理操作要做swap,而冗餘只需要做move)。同時可以記錄下每個對象的『年齡』從而優化『晉升』操作使得中年對象不被錯誤放到老年代。而反過來老年代偏穩定,我們哪怕是用清除,也不會產生太多的碎片,並且整理的代價也並不會太大。

  • 具體的垃圾收集器
    • 新生代收集器:有Serial收集器、ParNew收集器、Parallel Scavenge收集器
    • 老生代收集器:Serial Old收集器、Parallel Old收集器、CMS收集器、G1收集器

垃圾收集器大家庭

以上所有的垃圾收集器都會發生STW,只不過FGC的STW時間更長。

幾款重點研究的垃圾收集器:

CMSGC:

CMS(Concurrent Mark-Sweep)是以犧牲吞吐量爲代價來獲得最短回收停頓時間的垃圾回收器。對於要求服務器響應速度的應用上,這種垃圾回收器非常適合,因此我們又叫它低延遲垃圾收集器。在啓動JVM參數加上-XX:+UseConcMarkSweepGC ,這個參數表示對於老年代的回收採用CMS,注意此時新生代默認使用的是ParNew。CMS採用的基礎算法是:標記—清除。

MSCGC vs CMSGC

和普通序列化整理(MSC)區別在於有三個mark階段(實際上還有個預清理過程,但對於解釋清楚CMSGC沒有幫助就忽略了)。CMSGC的精髓在於因爲做到了不STW的情況下進行mark,我們得到了更短的總STW時間,代價是因爲並行mark產生了『髒數據』即在mark的同時又生成了需要mark的對象,我們必須再進行一次STW,並收尾(remark)。
同時,我們要注意到得到更短的STW的同時,我們犧牲了系統吞吐量,CMSGC總吞吐量比ParOld要更低。

G1GC

作爲最新的垃圾收集器,有可能在jdk9中成爲默認的垃圾收集器。
主要思路是將新生代老生代進一步分爲多個region,每次gc可以針對部分region而不是整個堆內存。由此可以降低stw的單次最長時間,代價是可能在總時間上會更高。
G1GC讓系統在整體吞吐量略降的情況下變得更加平滑穩定。

爲了比較ParOld,CMSGC和G1GC,附上從某篇博客上轉載的評測截圖:

靜態方法區

最後講一講靜態方法區,又稱爲永久代(Perm Generation)。它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。
常見的JVM配置包括:

 

-XX:MaxPermSize=512m

我們有時候會看到java進程報一個錯誤類似

 

Exception in thread "State Saver" java.lang.OutOfMemoryError: PermGen space

說明我們此時要調整配置了,或者說代碼中有一些bug導致大量的perm區被佔用,可能是用到了太多的靜態變量(一般懷疑map)或者說用到ASM框架導致產生了大量的類信息。

附錄

1.JVM的GC日誌的主要參數

-XX:+PrintGC 輸出GC日誌
-XX:+PrintGCDetails 輸出GC的詳細日誌
-XX:+PrintGCTimeStamps 輸出GC的時間戳(以基準時間的形式)
-XX:+PrintGCDateStamps 輸出GC的時間戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在進行GC的前後打印出堆的信息
-XX:+PrintGCApplicationStoppedTime // 輸出GC造成應用暫停的時間
-Xloggc:../logs/gc.log 日誌文件的輸出路徑
-XX:+HeapDumpOnOutOfMemoryError //發生OOM的時候自動dump堆棧方便分析

2.如何看垃圾收集策略

jmap -heap <pid>

3.如何實時看堆內存的使用情況

jstat -gcutil [pid] [interval] //實時打印gc情況以及各代內存佔用比例
jmap -dump:format=b,file=f1 <pid> //dump內存到二進制文件
jmap -histo [pid] //按佔大小倒序列出內存中的實例類型

4.關於晉升到老年代的條件

對象有兩種可能會進入old區:

  1. 存活對象過多。在s1和s2都已經溢出了。如果從eden遷往survior區時,發現放不下,則直接進入 old Gen
  2. 從eden到s區來回拷貝次數達到一定的數量,總沒有回收掉,進入old區。(從eden到survior1遷到,引用持有中,s1中放不下新遷對象,則清理s1,存活對象,晉升入s2;再下次或繼續遷移,就把s2中的。準備說,可能是,這些個對象從s1<->s2來回拷貝一定次數後,會進入old Gen)。這塊Servivor Space 調整合適的存活次數 Threshold 通過-XX:MaxTenuringThreshold。但也只是一個建議,最終仍由虛擬機決定

 

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