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的時候手工指定大小:
publicThread(ThreadGroup group, Runnable target, String name ,longstackSize)
棧的大小影響到了執行緒的最大數量,尤其在大流量的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對應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程序報一個錯誤類似
Exceptioninthread"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
3.如何實時看堆記憶體的使用情況
jstat -gcutil [pid] [interval] //實時列印gc情況以及各代記憶體佔用比例
jmap -dump:format=b,file=f1 //dump記憶體到二進位制檔案
jmap -histo [pid] //按佔大小倒序列出記憶體中的例項型別
4.關於晉升到老年代的條件
物件有兩種可能會進入old區:
存活物件過多。在s1和s2都已經溢位了。如果從eden遷往survior區時,發現放不下,則直接進入 old Gen
從eden到s區來回拷貝次數達到一定的數量,總沒有回收掉,進入old區。(從eden到survior1遷到,引用持有中,s1中放不下新遷物件,則清理s1,存活物件,晉升入s2;再下次或繼續遷移,就把s2中的。準備說,可能是,這些個物件從s1<->s2來回拷貝一定次數後,會進入old Gen)。這塊Servivor Space 調整合適的存活次數 Threshold 通過-XX:MaxTenuringThreshold。但也只是一個建議,最終仍由虛擬機器決定
