閱讀450 返回首頁    go 阿裏雲 go 技術社區[雲棲]


JVM第三篇(簡單demo)

本來寫完前麵兩篇JVM,已經不再想寫這類似的東西,因為很多知識點很難吃透,即使寫出來也很難讓人理解,即使理解還不如看官方資料,不過還是鼓起勇氣寫下這篇文章,本文主要是demo去理解一些JVM的內存知識,版本為hotspot的1.6.24版本,不過本文不講指令,隻是模擬一些東西,類似於出題目,和大家一起來做下;本文幾個簡單實驗不能說明所有問題,僅僅是分享一下理解JVM的內在和一些不可告人的秘密,以及告訴分享一些方法,以後可以自己做實驗去擴展。

 

1、首先來模擬一個簡單的溢出,代碼很簡單,我們也把GC的過程拿出來看看:

import java.util.*;

public class Hello {
    
    private final static int BYTE_SIZE = 4 * 1024 * 1024;
    
    public static void main(String []args) {
        List <Object> List = new ArrayList<Object>();
        for(int i = 0 ; i < 10 ; i ++) {
                List.add(new byte[BYTE_SIZE]);
                System.out.println(i);
        }
    }    
}

我們采用下麵的命令運行一下:

C:\>javac Hello.java

C:\>java -Xmn4m -Xms20m -Xmx20m -XX:+PrintGCDetails Hello
0
1
2
[GC [DefNew: 266K->145K(3712K), 0.0012704 secs][Tenured: 12288K->12433K(16384K), 0.0078754 secs] 12554K->12433K(20096K), [Perm : 367K->367K(12288K)], 0.0097094
.01 secs]
[Full GC [Tenured: 12433K->12420K(16384K), 0.0081298 secs] 12433K->12420K(20096K), [Perm : 367K->362K(12288K)], 0.0085821 secs] [Times: user=0.02 sys=0.00, rea
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at Hello.main(Hello.java:10)
Heap
 def new generation   total 3712K, used 133K [0x32690000, 0x32a90000, 0x32a90000)
  eden space 3328K,   4% used [0x32690000, 0x326b1500, 0x329d0000)
  from space 384K,   0% used [0x32a30000, 0x32a30000, 0x32a90000)
  to   space 384K,   0% used [0x329d0000, 0x329d0000, 0x32a30000)
 tenured generation   total 16384K, used 12420K [0x32a90000, 0x33a90000, 0x33a90000)
   the space 16384K,  75% used [0x32a90000, 0x336b1338, 0x336b1400, 0x33a90000)
 compacting perm gen  total 12288K, used 362K [0x33a90000, 0x34690000, 0x37a90000)
   the space 12288K,   2% used [0x33a90000, 0x33aea960, 0x33aeaa00, 0x34690000)
    ro space 10240K,  54% used [0x37a90000, 0x3800c510, 0x3800c600, 0x38490000)
    rw space 12288K,  55% used [0x38490000, 0x38b2fb78, 0x38b2fc00, 0x39090000)

 

看到打印語句中,在輸出2以後,也就是在下標3還沒有輸出來的時候(第四次),就出現了GC,也就是其實Eden始終是放不下4M的空間,而總的Heap隻有20M,所以Tenured就是16M,當第4次放入4M內容到Tenured時,發現放不下,但是也回收不掉,所以就內存溢出了;我們看懂了為什麼,但是奇怪的事情發生了,其實這個時候應該不需要前麵的Young的GC,你看到它先發生了一次,這個算什麼呢,這個算是一個BUG哈,不過知道就OK了,不算是什麼大問題,後來在服務器端的一些回收就沒有這個問題,不過有一些其他的問題,嗬嗬。

此時我們通過jps查看下進程號,然後通過,jstat跟蹤下回收的過程,為了能夠實時的跟蹤到代碼,在代碼申請內存前(記住是在每次做new空間之前,也就是循環體前麵),可以將其做一定時間的延時處理,代碼細節就不多說了,一下為給出監控命令得到的結果:

C:\>jstat -gcutil 5024 1000 100
 S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
 0.00   0.00   8.01   0.00   2.99      0    0.000     0    0.000    0.000
0.00   0.00   8.01  25.00   2.99      0    0.000     0    0.000    0.000
0.00   0.00   8.01  50.00   2.99      0    0.000     0    0.000    0.000
0.00   0.00   4.00  75.81   2.95      1    0.004     2    0.022    0.025

大致得出的結果如上所示,可以看出,Eden區域的空間幾乎沒有什麼變化,因為它本身就放不下東西,不過為什麼還是有一些空間呢,因為程序本身的一些開銷的一些內容會放在這裏,而O指代Old區域,其實就是Tenured,這也是非常原始的說法,可以看到它隨著時間的偏移,內存使用按照比例上升,而比例上也是我們預期的,但是奇怪的事情發生了,就是YGC做了一次,而FGC做了兩次下麵我先解釋下這些參數後再說明這個問題:

在前序的文章中已經說明目前絕大部分的hotspot JVM,都是用Eden+S0+S1+Old組成了Heap區域,以及一個Perm組成JVM所管轄區域,當然還包含native的內存,通過jstat命令可以查看到非native的區域,也就是堆棧的內存監控,更多的監控工具在本文中不做介紹。

上述關鍵字E代表Eden、O就是代表Old、P代表Perm、YGC就是Yong區域發生的GC次數、YGCT就是發生GC的時間開銷、FGC就是Full GC的次數,GCT就是FGCT就是Full GC的時間開銷,GCT就是總體的GC時間開銷延遲,注意:所謂時間開銷延遲就是指影響應用業務運行的延遲動作,這段時間,這部分內存是無法工作的,但是YGC僅僅影響Yong區域的內存而已。

jstat -gcutil 5024 1000 100這個命令,前兩個不用多說,可以攜帶的參數可以使用jstat –help進行查看,而5024為查看到的進程號,在Linux下可以直接通過動態參數賦值進去即可,而1000代表每秒采集一次數據,最後100代表最多采集100次,如果對應的進程結束,采集也會結束。

再來解釋下GC的情況:

上麵看到的DefNew、Tenured這些個關鍵字,其實就是代碼的名稱,從這裏就可以看出來我使用的GC是最原始的GC方法,串行而且需要等待的一種GC方法,當你使用各種GC的模式的時候會發現每種GC輸出的日誌是不一樣的,而開頭就是他們的代碼類的類名(本文後麵會介紹並行GC);而類似於這種數據:[Tenured: 12288K->12433K(16384K), 0.0078754 secs]應該如何看呢?這裏代表Tenured這個類對Old區域進行回收,回收前的內存使用是:12288K,回收後的內存是:12433K,Old區域的總大小為:16384K,本次回收對應用的延遲為0.0078754秒,發現回收的內存非常少,因為這些內存本身就沒有得到釋放,但是也回收了一點點,因為程序為了配合測試本身就有一些開銷在裏麵。

這是一種非常原始的GC,為什麼發生了一次YONG GC,而且發生了兩次FULL GC,理論上Yong區域不會有內存進去,不會有任何東西在裏麵,所以不會發生任何的YGC才對,而且應該隻有一次Full GC,但是奇怪的事情發生了,最後得出的結論是這種GC機製是非常原始的,這是GC這段代碼本身存在的BUG,也算是一種十分悲觀的一種做法。

付:其實你在監控中如果時間控製得不是很好的話,有可能最後這條信息采集不到,因為內存溢出的時候,進程就會結束,進程結束,采集的jstat程序也采集不到內容了,所以這個模擬在時間上要把控得比較好才可以。

 

2、如果你第一個實驗看得很明白我說的是什麼,那麼我們來看看第二個實驗,我們將GC的方法改成比較老的並行GC方法,但是代碼也稍微修改下:

import java.util.*;

public class Hello {
    
    private final static int BYTE_SIZE = 3 * 1024 * 1024;
    
    public static void main(String []args) {
        List <Object> List = new ArrayList<Object>();
        for(int i = 0 ; i < 9 ; i ++) {
       listInfo.add(new byte[BYTE_SIZE]);
       if(i == 6) {
                listInfo.clear();
       }
       sleepTime(1000);
     }
    }    
}

注意看下,這裏循環10次,每次申請的內存變成3M,主要為了測試方便,另外sleepTime方法是我自己寫的一個方法用於當前線程休眠,大家可以寫一個方法來完成,代碼很簡單,這裏就不給出源碼了。

我們這裏測試時為了方便,把Yong區域設置為10M,按照默認的話,Eden就會是8M,也就是最多裝入2次循環的申請,也就是每兩次後就讓它晉升到Old區域,每次晉升6M。而Old區域我們設置為20M,也就是晉升3次後,就是18M了,此時i=6,我們將Old區域的內存清理掉,然後再申請內存看下有什麼事情發生。本次測試用ParallelGC來完成,直接啟用-server也是默認啟動該參數。

運行時命令如下:

C:\>java -Xmn10m -Xms30m -Xmx30m -XX:+UseParallelOldGC -XX:+PrintGCDetails Hello
 [GC [PSYoungGen: 6468K->176K(8960K)] 6468K->6320K(29440K), 0.0052248 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [PSYoungGen: 6400K->160K(8960K)] 12544K->12448K(29440K), 0.0056272 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [PSYoungGen: 6357K->160K(8960K)] 18645K->18592K(29440K), 0.0051462 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC [PSYoungGen: 160K->0K(8960K)] [PSOldGen: 18432K->18577K(20480K)] 18592K->18577K(29440K) [PSPermGen: 2081K->2081K(12288K)], 0.0064599 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[Full GC [PSYoungGen: 6178K->0K(8960K)] [PSOldGen: 18577K->3204K(20480K)] 24756K->3204K(29440K) [PSPermGen: 2081K->2081K(12288K)], 0.0067249 secs] [Times: user=0.00 sys=0.00, real=0.02 secs]
Heap
 PSYoungGen      total 8960K, used 3225K [0x08ee0000, 0x098e0000, 0x098e0000)
  eden space 7680K, 42% used [0x08ee0000,0x092066e8,0x09660000)
  from space 1280K, 0% used [0x09660000,0x09660000,0x097a0000)
  to   space 1280K, 0% used [0x097a0000,0x097a0000,0x098e0000)
 PSOldGen        total 20480K, used 3204K [0x07ae0000, 0x08ee0000, 0x08ee0000)
  object space 20480K, 15% used [0x07ae0000,0x07e01268,0x08ee0000)
 PSPermGen       total 12288K, used 2086K [0x03ae0000, 0x046e0000, 0x07ae0000)
  object space 12288K, 16% used [0x03ae0000,0x03ce9a30,0x046e0000)


此時你會發現進行了很多次GC,而GC的日誌和我們第一個實驗GC的日誌輸出不太一樣,後麵多出來一堆東西,而DefNew與Tenured已經不複存在,現在叫做:PSYoungGen、PSOldGen,同理你如果使用了-XX:+UseParallelOldGC打印出來的內容對Old的回首也會有所區別,你會看到ParOldGen這樣的關鍵字出現,但是其它兩個不會變化,注意:-XX:+UseParallelOldGC和-XX:+UseParallelGC兩個參數不要混在一起使用,它們所調用的代碼都是不一樣的,另外還有CMS GC你看到的內容也是不一樣的,那麼我們這裏闡述的關鍵性問題不是這個,而是在並行GC下的一個隱藏機製。

大家通過常規的筆算得到的結果應該是這樣的(理論結果):

i(下標)  Yong   Old   YGC  FGC   備注

0            3          0       0        0       第一次申請3M

1            6          0       0        0       第二次申請3M,Eden區域已經存放6M

2            3          6       1        0       再申請3M,發現Eden放不下(Eden隻有8M默認為Yong的80%),先YGC發現Survivor區域也放不下,晉升到Old。

3            6          6       1        0       重複步驟【1】

4            3          12      2        0       重複步驟【2】

5            6          12      2        0       重複步驟【1】

6            3          18      3        0       重複步驟【2】但是執行申請後,執行了clear,Old區域的內存將全部被認為是垃圾內存,不過當前肯定還沒有回收。

7            6          18      3        0       重複步驟【1】

8            3          6       3        1       重複步驟【2】不過此處由於Old區域18M內存需要回收,所以發生一次FullGC操作,循環到此結束

那麼在理論上就應該發生3次YGC,1次FullGC,但是看下日誌的輸出,竟然發生了2次FullGC,怎麼回事了呢,用jstat監控下看下:

C:\ >jstat -gcutil 6088 1000 100
  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
  0.00   0.00   4.22   0.00  16.88      0    0.000     0    0.000    0.000
  0.00   0.00  44.22   0.00  16.88      0    0.000     0    0.000    0.000
  0.00   0.00  84.22   0.00  16.88      0    0.000     0    0.000    0.000
  0.00  13.13  40.00  30.00  16.94      1    0.009     0    0.000    0.009
  0.00  13.13  81.05  30.00  16.94      1    0.009     0    0.000    0.009
 12.50   0.00  40.00  60.00  16.94      2    0.018     0    0.000    0.018
 12.50   0.00  80.69  60.00  16.94      2    0.018     0    0.000    0.018
  0.00   0.00  40.00  90.71  16.93      3    0.031     1    0.018    0.049
  0.00   0.00  80.45  90.71  16.93      3    0.031     1    0.018    0.049
  0.00   0.00  40.00  15.65  16.89      3    0.031     2    0.028    0.059

果然發生了兩次FullGC,奇怪了,為什麼發生了兩次FullGC,看下日誌詳情,在第三次剛開始發生YGC的時候,它發生了一次FullGC,後麵又出現了一次,第三次YongGC理論上發生完成後,Old區域也隻有18M的內存,而Old區域有20M的空間,為什麼會發生這個GC呢,再回到開始Full GC的第一個日誌輸出看下:

[Full GC [PSYoungGen: 160K->0K(8960K)] [PSOldGen: 18432K->18577K(20480K)] 18592K->18577K(29440K) [PSPermGen: 2081K->2081K(12288K)], 0.0064599 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

此時看下PSOldGen中的內存的確和我們理論值一樣18M,但是它做了一個從18M回收到18M,第一次我看到覺得很無奈而且很搞笑,後來知道了一個內部一個很神奇的原理,就是平均晉升大小的問題,當每次從Yong向Old區域晉升過程中,都有一個內存大小的記錄,平均記錄將會影響JVM對於當前內存穩定性的判別,它發現每次從YGC中每次平均向OLD晉升大概6M,此時當第三次YGC晉升後,它發現Old隻剩下2M,平均每次是6M,剩餘空間已經不足以支撐平均晉升的大小了,所以應該做一次Full GC才能讓下一次YGC能夠成功晉升,不過我不知道JVM為什麼要這樣做,因為下次晉升不夠再做FullGC也不遲,為什麼要這個時候做呢?這樣往往導致了更多的FullGC。

在GC的參數上,前麵已經有一些文章說明了,其實很多時候在服務器端設置的時候,隻需要有一個-server就行了,而服務器端一般這個參數也是默認的,在絕大部分情況下,不要去設置過多的參數,除非你的應用場景非常的特殊,因為JVM給的默認值很多都是正確的,不過建議將一些自適應和手動GC關閉掉是比較好的,一般關心的部分主要是-Xms、-Xmx、-Xss、-Xmn這幾個參數就可以了,其餘的類似並行GC線程數、平均YGC次數晉升、自適應等這些內容用默認的一般就是對的,特殊場景比如:你的係統是用來做cache類的係統和做高並發訪問的係統設置就有一些區別(經常做cache的內容,如果cache住的內容幾乎是長駐的,那麼我想讓稍微大一點的內容直接進入Old,而不要在Yong區域來回倒騰;而高並發一般請求量較小,而且請求完成後基本都釋放掉了,所以一般不讓他進入Old,因為Old會導致FullGC,Yong內部每次倒騰隻會尋找活著的節點,如果也就是Yong內部幾乎都是掛掉的節點),如果你的係統用於做大內存OS也有很大的區別(這個時候你要考慮使用並發GC即CMSGC去解決回收時間的問題,現在的G1),而四不像的係統,或者叫什麼都有的係統,也就是既有一些高並發,又有自己的cache,而且cache的時間不長也不短,真的就有點鬱悶了,G1的出現希望可以解決掉這個問題,不過也曾有人自己通過應用的代碼來解決這個問題,當然應用也有一定的特殊性,那就是應用的數據行,每行數據大概都是1M多,而且不會超過2M,這群人是將處理的數據全部劃分為2M等長度的空間片段,將數據向內部填充,然後回收時,隻找尋垃圾內存,並回收掉,但是他們不會去做compaction的操作,也就是不會去做重新排序的操作,這樣節省了大量的FullGC的時間,並使得內存不會出現碎片的問題。

OK,JVM內存的浪費有很多種情況,我們討論很多就是要討論OOM的問題,運行時最關心的問題就是GC會暫停多久的運行業務,一般在內存溢出在線上運行由於代碼造成的可能性為絕大部分,少部分是因為配置引起,除非配置參數全部亂寫的,而代碼就要從很多方麵去說明了,從java代碼本身的角度來說一般有的情況:

1、大量使用session存放數據,甚至於存放List、Map這些集合類,逐步嵌套,隻要session沒有注銷,這些集合類以及集合類所引用的對象(可能是多層引用)導致這些內存不會被GC認為是垃圾內存;這種唯一的辦法就是殺掉,誰寫這種代碼,就把誰殺掉,曾經有人提出過獨立管理這類session,甚至於存入數據庫中,不過這樣放數據放在什麼地方都會爆掉,而且放在數據庫中也會極大的降低session的提取時間,甚至於可以說根本不用session存放數據了,因為我可以自己從數據庫中拿,session應當隻存放一些用戶關鍵信息,可以使用獨立的分布式緩存來存放,這樣保證session是可以被切換的,如果通過服務器本身的session切換的話會有很多序列化的問題存在。

2、自定義的靜態句柄使用不當,我並不是不推薦大家使用這個,主要是有人經常用這個指向一些集合類而且做一些內存管理過程中會有很多集合類來回引用,本來想帶的GC來回引用GC已經可以識別出來它也是垃圾,但是如果你的頂層有一個靜態句柄,那麼就沒法了,如果你不做clear,你永遠釋放不掉。

3、線程broken住或者被stop等操作或者waiting的時間過長,該線程內部的棧針指向的內存片段將不會被得到釋放,其實從程序效率的角度來說,就是當你的線程在等待一個網絡反饋結果之前的這個時間範圍內,你在這行代碼之前申請的內存,都不會被認為是垃圾內存,除非你自己做過一個 = null的操作,其實 = null或clear也是在這種情況下使用會提高性能,正常情況下這種操作意義並不大。

4、由於讀取數據比較多,導致網絡時間較長,這個時候,也和第三種情況差不多,而且這些數據也會被轉入到內存中,甚至於進入old區域,那麼也是會導致急速上漲,對於這類情況,如果業務的確經常有這種情況,可以適當調整Yong區域的大小,另外代碼上要注意對數據提取的流量控製,甚至於拒絕單機上的高並發,以及每次提取的數量;在必要時應當控製每次輸出的流量,適當情況可以選取斷點傳送。

5、文件下載,文件下載和上麵類似,隻是它是文件而不是基本的數據,這類內容在讀取和輸出過程中都會占用內存很多的開銷,有人說用gzip壓縮,的確,這個技術的確不錯,不過gzip唯一解決的問題是服務器在輸出時向客戶端傳送的網絡流量,但是它本身也是需要占用CPU的,用過壓縮工具的人都知道,壓縮是非常占用CPU的工具之一,所以在業務允許的情況下,在預先處理之前就將一些必要的大文件進行壓縮存放,輸出式也是一個壓縮包,不論在內存還是向客戶端輸出時都是一個壓縮文件。

6、大量使用ThreadLocal來作為數據存放的中間量,如果經常使用這個內容的朋友請多多看下這個類的源碼到底是怎麼寫的,其實ThreadLocal隻是一個中間量,數據根本不是存放在這個類裏麵的,也就是即使ThreadLocal這個類結束掉,這些數據依然存在,它是存放在當前被訪問的Thread中的,Thread中有一段源碼就是這樣定義了對應的Map結構來存放當前線程的參數信息,而ThreadLocal隻是一個中間傳輸信息的工具,並自動判定當前線程信息,它根本什麼數據都不存放,而絕大部分應用中,Thread都是一個隊列池,也就是這些線程基本都是不會被釋放的,那麼這些線程所對應的Map以及下麵的節點內容將永遠得不到釋放,所以要善用這個類。

7、濫用Thread,或-Xmx設置得過大,導致沒有native內存,Thread本身是占用非JVM堆棧內存之外的native內存,-Xss決定其大小,1.5以後默認是1M的大小,它用於存放當前Thread所申請的對象的棧針信息,也就是通過它找到對象的,也就是沒申請一個Thread就會從OS中開銷1M的內存,這樣不用多說,你懂的。

8、JNI的濫用,在使用native方法,也是使用native的內存,比如去調用一些C、C++語言執行,執行完成後,在C、C++中使用自己的malloc、realloc、new等方法申請的內存空間,沒有使用free或delete去釋放掉,那麼它將永遠釋放不掉,除非該JVM進程停止掉,由OS判定出來這塊內存是由這個進程所使用的。

9、網絡輸出,在網絡輸出時,會產生buffer,而buffer的大小,超越了OS級別的限製,它使用的也不是堆棧中的內存,而是外部的內存,在輸出時需要注意網絡流量的限製。

10、其他的注意點其實並不多,代碼上的細節問題說起來就太多了,前麵有說明過代碼細節上的一些常見注意事項,以及通過javap來查看代碼被編譯後的命令到底是怎麼執行的方法,通過這種方法就可以看出代碼為什麼會執行得很慢;另外代碼要跑得快除了一些常見的注意事項外,還需要注意就是如果程序等待需要考慮什麼內存可以釋放掉,複雜的邏輯程序什麼動作可以不做或者簡化做,算法方麵不要糾結太多,因為常規的應用業務不會存在太複雜的算法,當你遇到的時候再去糾結也是可以的。

代碼內存溢出一般要麼是並發上的確是扛不住了引起,不過這種一般是大型網站才會遇到,一般的係統是不會遇到的;另一類就是代碼的確太難了,就和上麵的情況比較符合,很多人問我,為什麼JVM不能自動識別出來回收掉這些內存呢,我隻能說,java真的還需要學下才行,所謂自動並非什麼東西都是自動的,首先需要明白GC認為什麼是垃圾才可以,如果你有東西隻想他,隻要這個內存通過棧針、final static句柄、以及native的空間他們是可達的,那麼它就不是垃圾內存,如果放在一些常駐內存或根本不會被釋放的內存的引用下或者被間接引用的子對象下,那麼JVM永遠也不會認為它是垃圾,這也沒有什麼辦法讓JVM自動知道它就是垃圾。

其餘的不想多說,後麵專門寫幾篇文章說明一個java的應用工程如何從服務器的前端到後端設計上提高的訪問量以及性能,由於內容較多會分解為多篇文章說明不同的部分。

最後文章推薦大家使用一些監控工具,如:jconsole、virsualVM集成插件virsualGC、jmap、jstat以及異常強大的btree工具,由於工具說起來比較多,而且每種工具都有自己的特征和優勢所在,在不同的場景下發揮作用,本文也不是重點,隻是推薦使用,這裏就簡單截圖一張看看圖形如何說明GC的運行狀態的,這裏看下:virsualVM中的virsualGC插件對內存監控的效果是什麼樣的(對堆棧部分的內存使用和GC狀態展現的非常的清晰,不過再次強調,這部分僅僅針對於Hotspot VM,即開源版本的Oracle的JVM):

 

 

最後更新:2017-04-02 06:51:52

  上一篇:go 北漂之惠普H3C麵試經曆
  下一篇:go OPhone網絡應用編程實例: 豆瓣電台客戶端