JVM Stack和Heap、GC

在JVM中,內存分爲兩個部分,Stack(棧)和Heap(堆),這裏,我們從JVM的內存管理原理的角度來認識Stack和Heap,並通過這些原理認清Java中靜態方法和靜態屬性的問題。

  一般,JVM的內存分爲兩部分:Stack和Heap。

  Stack(棧)是JVM的內存指令區。Stack管理很簡單,push一定長度字節的數據或者指令,Stack指針壓棧相應的字節位移;pop一定字節長度數據或者指令,Stack指針彈棧。Stack的速度很快,管理很簡單,並且每次操作的數據或者指令字節長度是已知的。所以Java 基本數據類型,Java 指令代碼,常量都保存在Stack中。

  Heap(堆)是JVM的內存數據區。Heap 的管理很複雜,每次分配不定長的內存空間,專門用來保存對象的實例。在Heap 中分配一定的內存來保存對象實例,實際上也只是保存對象實例的屬性值,屬性的類型和對象本身的類型標記等,並不保存對象的方法(方法是指令,保存在Stack中),在Heap 中分配一定的內存保存對象實例和對象的序列化比較類似。而對象實例在Heap 中分配好以後,需要在Stack中保存一個4字節的Heap 內存地址,用來定位該對象實例在Heap 中的位置,便於找到該對象實例。

  由於Stack的內存管理是順序分配的,而且定長,不存在內存回收問題;而Heap 則是隨機分配內存,不定長度,存在內存分配和回收的問題;因此在JVM中另有一個GC進程,定期掃描Heap ,它根據Stack中保存的4字節對象地址掃描Heap ,定位Heap 中這些對象,進行一些優化(例如合併空閒內存塊什麼的),並且假設Heap 中沒有掃描到的區域都是空閒的,統統refresh(實際上是把Stack中丟失了對象地址的無用對象清除了),這就是垃圾收集的過程;關於垃圾收集的更深入講解請參考51CTO之前的文章《JVM內存模型及垃圾收集策略解析》。


JVM的體系結構

  我們首先要搞清楚的是什麼是數據以及什麼是指令。然後要搞清楚對象的方法和對象的屬性分別保存在哪裏。

  1)方法本身是指令的操作碼部分,保存在Stack中;

  2)方法內部變量作爲指令的操作數部分,跟在指令的操作碼之後,保存在Stack中(實際上是簡單類型保存在Stack中,對象類型在Stack中保存地址,在Heap 中保存值);上述的指令操作碼和指令操作數構成了完整的Java 指令。

  3)對象實例包括其屬性值作爲數據,保存在數據區Heap 中。

  非靜態的對象屬性作爲對象實例的一部分保存在Heap 中,而對象實例必須通過Stack中保存的地址指針才能訪問到。因此能否訪問到對象實例以及它的非靜態屬性值完全取決於能否獲得對象實例在Stack中的地址指針。

  非靜態方法和靜態方法的區別:

  非靜態方法有一個和靜態方法很重大的不同:非靜態方法有一個隱含的傳入參數,該參數是JVM給它的,和我們怎麼寫代碼無關,這個隱含的參數就是對象實例在Stack中的地址指針。因此非靜態方法(在Stack中的指令代碼)總是可以找到自己的專用數據(在Heap 中的對象屬性值)。當然非靜態方法也必須獲得該隱含參數,因此非靜態方法在調用前,必須先new一個對象實例,獲得Stack中的地址指針,否則JVM將無法將隱含參數傳給非靜態方法。

  靜態方法無此隱含參數,因此也不需要new對象,只要class文件被ClassLoader load進入JVM的Stack,該靜態方法即可被調用。當然此時靜態方法是存取不到Heap 中的對象屬性的。

  總結一下該過程:當一個class文件被ClassLoader load進入JVM後,方法指令保存在Stack中,此時Heap 區沒有數據。然後程序技術器開始執行指令,如果是靜態方法,直接依次執行指令代碼,當然此時指令代碼是不能訪問Heap 數據區的;如果是非靜態方法,由於隱含參數沒有值,會報錯。因此在非靜態方法執行前,要先new對象,在Heap 中分配數據,並把Stack中的地址指針交給非靜態方法,這樣程序技術器依次執行指令,而指令代碼此時能夠訪問到Heap 數據區了。

  靜態屬性和動態屬性:

  前面提到對象實例以及動態屬性都是保存在Heap 中的,而Heap 必須通過Stack中的地址指針才能夠被指令(類的方法)訪問到。因此可以推斷出:靜態屬性是保存在Stack中的,而不同於動態屬性保存在Heap 中。正因爲都是在Stack中,而Stack中指令和數據都是定長的,因此很容易算出偏移量,也因此不管什麼指令(類的方法),都可以訪問到類的靜態屬性。也正因爲靜態屬性被保存在Stack中,所以具有了全局屬性。

  在JVM中,靜態屬性保存在Stack指令內存區,動態屬性保存在Heap數據內存區。


 

JVM內存模型是Java的核心技術之一,之前51CTO曾爲大家介紹過JVM分代垃圾回收策略的基礎概念,現在很多編程語言都引入了類似Java JVM的內存模型和垃圾收集器的機制,下面我們將主要針對Java中的JVM內存模型及垃圾收集的具體策略進行綜合的分析。

一 JVM內存模型

1.1 Java棧

Java棧是與每一個線程關聯的,JVM在創建每一個線程的時候,會分配一定的棧空間給線程。它主要用來存儲線程執行過程中的局部變量,方法的返回值,以及方法調用上下文。棧空間隨着線程的終止而釋放。StackOverflowError:如果在線程執行的過程中,棧空間不夠用,那麼JVM就會拋出此異常,這種情況一般是死遞歸造成的。

1.2 堆

Java中堆是由所有的線程共享的一塊內存區域,堆用來保存各種JAVA對象,比如數組,線程對象等。

1.2.1 Generation

JVM堆一般又可以分爲以下三部分:

◆ Perm

Perm代主要保存class,method,filed對象,這部門的空間一般不會溢出,除非一次性加載了很多的類,不過在涉及到熱部署的應用服務器的時候,有時候會遇到java.lang.OutOfMemoryError : PermGen space 的錯誤,造成這個錯誤的很大原因就有可能是每次都重新部署,但是重新部署後,類的class沒有被卸載掉,這樣就造成了大量的class對象保存在了perm中,這種情況下,一般重新啓動應用服務器可以解決問題。

◆ Tenured

Tenured區主要保存生命週期長的對象,一般是一些老的對象,當一些對象在Young複製轉移一定的次數以後,對象就會被轉移到Tenured區,一般如果系統中用了application級別的緩存,緩存中的對象往往會被轉移到這一區間。

◆ Young

Young區被劃分爲三部分,Eden區和兩個大小嚴格相同的Survivor區,其中Survivor區間中,某一時刻只有其中一個是被使用的,另外一個留做垃圾收集時複製對象用,在Young區間變滿的時候,minor GC就會將存活的對象移到空閒的Survivor區間中,根據JVM的策略,在經過幾次垃圾收集後,任然存活於Survivor的對象將被移動到Tenured區間。

1.2.2 Sizing the Generations

JVM提供了相應的參數來對內存大小進行配置。正如上面描述,JVM中堆被分爲了3個大的區間,同時JVM也提供了一些選項對Young,Tenured的大小進行控制。

◆ Total Heap

-Xms :指定了JVM初始啓動以後初始化內存

-Xmx:指定JVM堆得最大內存,在JVM啓動以後,會分配-Xmx參數指定大小的內存給JVM,但是不一定全部使用,JVM會根據-Xms參數來調節真正用於JVM的內存

-Xmx -Xms之差就是三個Virtual空間的大小

◆ Young Generation

-XX:NewRatio=8意味着tenured 和 young的比值8:1,這樣eden+2*survivor=1/9

堆內存

-XX:SurvivorRatio=32意味着eden和一個survivor的比值是32:1,這樣一個Survivor就佔Young區的1/34.

-Xmn 參數設置了年輕代的大小

◆ Perm Generation

-XX:PermSize=16M -XX:MaxPermSize=64M

Thread Stack

-XX:Xss=128K

1.3 堆棧分離的好處

呵呵,其它的先不說了,就來說說面向對象的設計吧,當然除了面向對象的設計帶來的維護性,複用性和擴展性方面的好處外,我們看看面向對象如何巧妙的利用了堆棧分離。如果從JAVA內存模型的角度去理解面向對象的設計,我們就會發現對象它完美的表示了堆和棧,對象的數據放在堆中,而我們編寫的那些方法一般都是運行在棧中,因此面向對象的設計是一種非常完美的設計方式,它完美的統一了數據存儲和運行。

二 JAVA垃圾收集器

2.1 垃圾收集簡史

垃圾收集提供了內存管理的機制,使得應用程序不需要在關注內存如何釋放,內存用完後,垃圾收集會進行收集,這樣就減輕了因爲人爲的管理內存而造成的錯誤,比如在C++語言裏,出現內存泄露時很常見的。Java語言是目前使用最多的依賴於垃圾收集器的語言,但是垃圾收集器策略從20世紀60年代就已經流行起來了,比如Smalltalk,Eiffel等編程語言也集成了垃圾收集器的機制。

2.2 常見的垃圾收集策略

所有的垃圾收集算法都面臨同一個問題,那就是找出應用程序不可到達的內存塊,將其釋放,這裏面得不可到達主要是指應用程序已經沒有內存塊的引用了,而在JAVA中,某個對象對應用程序是可到達的是指:這個對象被根(根主要是指類的靜態變量,或者活躍在所有線程棧的對象的引用)引用或者對象被另一個可到達的對象引用。

2.2.1 Reference Counting(引用計數)
 
引用計數是最簡單直接的一種方式,這種方式在每一個對象中增加一個引用的計數,這個計數代表當前程序有多少個引用引用了此對象,如果此對象的引用計數變爲0,那麼此對象就可以作爲垃圾收集器的目標對象來收集。

優點:

簡單,直接,不需要暫停整個應用

缺點:

1.需要編譯器的配合,編譯器要生成特殊的指令來進行引用計數的操作,比如每次將對象賦值給新的引用,或者者對象的引用超出了作用域等。

2.不能處理循環引用的問題

2.2.2 跟蹤收集器

跟蹤收集器首先要暫停整個應用程序,然後開始從根對象掃描整個堆,判斷掃描的對象是否有對象引用,這裏面有三個問題需要搞清楚:

1.如果每次掃描整個堆,那麼勢必讓GC的時間變長,從而影響了應用本身的執行。因此在JVM裏面採用了分代收集,在新生代收集的時候minor gc只需要掃描新生代,而不需要掃描老生代。

2.JVM採用了分代收集以後,minor gc只掃描新生代,但是minor gc怎麼判斷是否有老生代的對象引用了新生代的對象,JVM採用了卡片標記的策略,卡片標記將老生代分成了一塊一塊的,劃分以後的每一個塊就叫做一個卡片,JVM採用卡表維護了每一個塊的狀態,當JAVA程序運行的時候,如果發現老生代對象引用或者釋放了新生代對象的引用,那麼就JVM就將卡表的狀態設置爲髒狀態,這樣每次minor gc的時候就會只掃描被標記爲髒狀態的卡片,而不需要掃描整個堆。具體如下圖:
3.GC在收集一個對象的時候會判斷是否有引用指向對象,在JAVA中的引用主要有四種:Strong reference,Soft reference,Weak reference,Phantom reference.

◆ Strong Reference

強引用是JAVA中默認採用的一種方式,我們平時創建的引用都屬於強引用。如果一個對象沒有強引用,那麼對象就會被回收。

  1. public void testStrongReference(){  
  2. Object referent = new Object();  
  3. Object strongReference = referent;  
  4. referent = null;  
  5. System.gc();  
  6. assertNotNull(strongReference);  

◆ Soft Reference

軟引用的對象在GC的時候不會被回收,只有當內存不夠用的時候纔會真正的回收,因此軟引用適合緩存的場合,這樣使得緩存中的對象可以儘量的再內存中待長久一點。

  1. Public void testSoftReference(){  
  2. String  str =  "test";  
  3. SoftReference<</span>String> softreference = new SoftReference<</span>String>(str);  
  4. str=null;  
  5. System.gc();  
  6. assertNotNull(softreference.get());  
  7. }  

Weak reference

弱引用有利於對象更快的被回收,假如一個對象沒有強引用只有弱引用,那麼在GC後,這個對象肯定會被回收。

  1. Public void testWeakReference(){  
  2. String  str =  "test";  
  3. WeakReference<</span>String> weakReference = new WeakReference<</span>String>(str);  
  4. str=null;  
  5. System.gc();  
  6. assertNull(weakReference.get());  
  7. }  

Phantom reference


回收算法轉自http://pengjiaheng.iteye.com/blog/520228

按照基本回收策略分

引用計數(Reference Counting):

比較古老的回收算法。原理是此對象有一個引用,即增加一個計數,刪除一個引用則減少一個計數。垃圾回收時,只用收集計數爲0的對象。此算法最致命的是無法處理循環引用的問題。

 

標記-清除(Mark-Sweep):

 

 

此算法執行分兩階段。第一階段從引用根節點開始標記所有被引用的對象,第二階段遍歷整個堆,把未標記的對象清除。此算法需要暫停整個應用,同時,會產生內存碎片。

 

複製(Copying):

 

 

此算法把內存空間劃爲兩個相等的區域,每次只使用其中一個區域。垃圾回收時,遍歷當前使用區域,把正在使用中的對象複製到另外一個區域中。次算法每次只處理正在使用中的對象,因此複製成本比較小,同時複製過去以後還能進行相應的內存整理,不會出現“碎片”問題。當然,此算法的缺點也是很明顯的,就是需要兩倍內存空間。

 

標記-整理(Mark-Compact):

 

 

此算法結合了“標記-清除”和“複製”兩個算法的優點。也是分兩階段,第一階段從根節點開始標記所有被引用對象,第二階段遍歷整個堆,把清除未標記對象並且把存活對象“壓縮”到堆的其中一塊,按順序排放。此算法避免了“標記-清除”的碎片問題,同時也避免了“複製”算法的空間問題。

按分區對待的方式分

增量收集(Incremental Collecting):實時垃圾回收算法,即:在應用進行的同時進行垃圾回收。不知道什麼原因JDK5.0中的收集器沒有使用這種算法的。

 

分代收集(Generational Collecting):基於對對象生命週期分析後得出的垃圾回收算法。把對象分爲年青代、年老代、持久代,對不同生命週期的對象使用不同的算法(上述方式中的一個)進行回收。現在的垃圾回收器(從J2SE1.2開始)都是使用此算法的。

 

按系統線程分

串行收集:串行收集使用單線程處理所有垃圾回收工作,因爲無需多線程交互,實現容易,而且效率比較高。但是,其侷限性也比較明顯,即無法使用多處理器的優勢,所以此收集適合單處理器機器。當然,此收集器也可以用在小數據量(100M左右)情況下的多處理器機器上。

 

並行收集:並行收集使用多線程處理垃圾回收工作,因而速度快,效率高。而且理論上CPU數目越多,越能體現出並行收集器的優勢。

 

併發收集:相對於串行收集和並行收集而言,前面兩個在進行垃圾回收工作時,需要暫停整個運行環境,而只有垃圾回收程序在運行,因此,系統在垃圾回收時會有明顯的暫停,而且暫停時間會因爲堆越大而越長。


如何區分垃圾

 

    上面說到的“引用計數”法,通過統計控制生成對象和刪除對象時的引用數來判斷。垃圾回收程序收集計數爲0的對象即可。但是這種方法無法解決循環引用。所以,後來實現的垃圾判斷算法中,都是從程序運行的根節點出發,遍歷整個對象引用,查找存活的對象。那麼在這種方式的實現中,垃圾回收從哪兒開始的呢?即,從哪兒開始查找哪些對象是正在被當前系統使用的。上面分析的堆和棧的區別,其中棧是真正進行程序執行地方,所以要獲取哪些對象正在被使用,則需要從Java棧開始。同時,一個棧是與一個線程對應的,因此,如果有多個線程的話,則必須對這些線程對應的所有的棧進行檢查。

    同時,除了棧外,還有系統運行時的寄存器等,也是存儲程序運行數據的。這樣,以棧或寄存器中的引用爲起點,我們可以找到堆中的對象,又從這些對象找到對堆中其他對象的引用,這種引用逐步擴展,最終以null引用或者基本類型結束,這樣就形成了一顆以Java棧中引用所對應的對象爲根節點的一顆對象樹,如果棧中有多個引用,則最終會形成多顆對象樹。在這些對象樹上的對象,都是當前系統運行所需要的對象,不能被垃圾回收。而其他剩餘對象,則可以視爲無法被引用到的對象,可以被當做垃圾進行回收。

因此,垃圾回收的起點是一些根對象(java棧, 靜態變量, 寄存器...)。而最簡單的Java棧就是Java程序執行的main函數。這種回收方式,也是上面提到的“標記-清除”的回收方式

 

 

如何處理碎片

   由於不同Java對象存活時間是不一定的,因此,在程序運行一段時間以後,如果不進行內存整理,就會出現零散的內存碎片。碎片最直接的問題就是會導致無法分配大塊的內存空間,以及程序運行效率降低。所以,在上面提到的基本垃圾回收算法中,“複製”方式和“標記-整理”方式,都可以解決碎片的問題。

 

 

如何解決同時存在的對象創建和對象回收問題

    垃圾回收線程是回收內存的,而程序運行線程則是消耗(或分配)內存的,一個回收內存,一個分配內存,從這點看,兩者是矛盾的。因此,在現有的垃圾回收方式中,要進行垃圾回收前,一般都需要暫停整個應用(即:暫停內存的分配),然後進行垃圾回收,回收完成後再繼續應用。這種實現方式是最直接,而且最有效的解決二者矛盾的方式。

但是這種方式有一個很明顯的弊端,就是當堆空間持續增大時,垃圾回收的時間也將會相應的持續增大,對應應用暫停的時間也會相應的增大。一些對相應時間要求很高的應用,比如最大暫停時間要求是幾百毫秒,那麼當堆空間大於幾個G時,就很有可能超過這個限制,在這種情況下,垃圾回收將會成爲系統運行的一個瓶頸。爲解決這種矛盾,有了併發垃圾回收算法,使用這種算法,垃圾回收線程與程序運行線程同時運行。在這種方式下,解決了暫停的問題,但是因爲需要在新生成對象的同時又要回收對象,算法複雜性會大大增加,系統的處理能力也會相應降低,同時,“碎片”問題將會比較難解決。



由於不同對象的生命週期不一樣,因此在JVM的垃圾回收策略中有分代這一策略。本文介紹了分代策略的目標,如何分代,以及垃圾回收的觸發因素。

文章總結了JVM垃圾回收策略爲什麼要分代,如何分代,以及垃圾回收的觸發因素。

爲什麼要分代

        分代的垃圾回收策略,是基於這樣一個事實:不同的對象的生命週期是不一樣的。因此,不同生命週期的對象可以採取不同的收集方式,以便提高回收效率。

        在Java程序運行的過程中,會產生大量的對象,其中有些對象是與業務信息相關,比如Http請求中的Session對象、線程、Socket連接,這類對象跟業務直接掛鉤,因此生命週期比較長。但是還有一些對象,主要是程序運行過程中生成的臨時變量,這些對象生命週期會比較短,比如:String對象,由於其不變類的特性,系統會產生大量的這些對象,有些對象甚至只用一次即可回收。

        試想,在不進行對象存活時間區分的情況下,每次垃圾回收都是對整個堆空間進行回收,花費時間相對會長,同時,因爲每次回收都需要遍歷所有存活對象,但實際上,對於生命週期長的對象而言,這種遍歷是沒有效果的,因爲可能進行了很多次遍歷,但是他們依舊存在。因此,分代垃圾回收採用分治的思想,進行代的劃分,把不同生命週期的對象放在不同代上,不同代上採用最適合它的垃圾回收方式進行回收。

如何分代

如圖所示:

如何分代 

        虛擬機中的共劃分爲三個代:年輕代(Young Generation)、年老點(Old Generation)和持久代(Permanent Generation)。其中持久代主要存放的是Java類的類信息,與垃圾收集要收集的Java對象關係不大。年輕代和年老代的劃分是對垃圾收集影響比較大的。

年輕代:

        所有新生成的對象首先都是放在年輕代的。年輕代的目標就是儘可能快速的收集掉那些生命週期短的對象。年輕代分三個區。一個Eden區,兩個Survivor區(一般而言)。大部分對象在Eden區中生成。當Eden區滿時,還存活的對象將被複制到Survivor區(兩個中的一個),當這個Survivor區滿時,此區的存活對象將被複制到另外一個Survivor區,當這個Survivor去也滿了的時候,從第一個Survivor區複製過來的並且此時還存活的對象,將被複制“年老區(Tenured)”。需要注意,Survivor的兩個區是對稱的,沒先後關係,所以同一個區中可能同時存在從Eden複製過來 對象,和從前一個Survivor複製過來的對象,而複製到年老區的只有從第一個Survivor去過來的對象。而且,Survivor區總有一個是空的。同時,根據程序需要,Survivor區是可以配置爲多個的(多於兩個),這樣可以增加對象在年輕代中的存在時間,減少被放到年老代的可能。

年老代:

        在年輕代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代中。因此,可以認爲年老代中存放的都是一些生命週期較長的對象。

持久代:

        用於存放靜態文件,如今Java類、方法等。持久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者調用一些class,例如Hibernate等,在這種時候需要設置一個比較大的持久代空間來存放這些運行過程中新增的類。持久代大小通過-XX:MaxPermSize=進行設置。

什麼情況下觸發垃圾回收

        由於對象進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種類型:Scavenge GC和Full GC。

Scavenge GC

        一般情況下,當新對象生成,並且在Eden申請空間失敗時,就會觸發Scavenge GC,對Eden區域進行GC,清除非存活對象,並且把尚且存活的對象移動到Survivor區。然後整理Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。因爲大部分對象都是從Eden區開始的,同時Eden區不會分配的很大,所以Eden區的GC會頻繁進行。因而,一般在這裏需要使用速度快、效率高的算法,使Eden去能儘快空閒出來。

        對整個堆進行整理,包括Young、Tenured和Perm。Full GC因爲需要對整個對進行回收,所以比Scavenge GC要慢,因此應該儘可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對於FullGC的調節。有如下原因可能導致Full GC:

 

· 年老代(Tenured)被寫滿

· 持久代(Perm)被寫滿

· System.gc()被顯示調用

·上一次GC之後Heap的各域分配策略動態變化


 

常見配置彙總

 

堆設置

  -Xms:初始堆大小

  -Xmx:最大堆大小

  -XX:NewSize=n:設置年輕代大小

  -XX:NewRatio=n:設置年輕代和年老代的比值。如:爲3,表示年輕代與年老代比值爲1:3,年輕代佔整個年輕代年老代和的1/4

  -XX:SurvivorRatio=n:年輕代中Eden區與兩個Survivor區的比值。注意Survivor區有兩個。如:3,表示Eden:Survivor=3:2,一個Survivor區佔整個年輕代的1/5

  -XX:MaxPermSize=n:設置持久代大小

收集器設置

  -XX:+UseSerialGC:設置串行收集器

  -XX:+UseParallelGC:設置並行收集器

  -XX:+UseParalledlOldGC:設置並行年老代收集器

  -XX:+UseConcMarkSweepGC:設置併發收集器

垃圾回收統計信息

  -XX:+PrintGC

  -XX:+PrintGCDetails

  -XX:+PrintGCTimeStamps

  -Xloggc:filename

並行收集器設置

  -XX:ParallelGCThreads=n:設置並行收集器收集時使用的CPU數。並行收集線程數。

  -XX:MaxGCPauseMillis=n:設置並行收集最大暫停時間

  -XX:GCTimeRatio=n:設置垃圾回收時間佔程序運行時間的百分比。公式爲1/(1+n)

併發收集器設置

  -XX:+CMSIncrementalMode:設置爲增量模式。適用於單CPU情況。

  -XX:ParallelGCThreads=n:設置併發收集器年輕代收集方式爲並行收集時,使用的CPU數。並行收集線程數。

 

調優總結

年輕代大小選擇

響應時間優先的應用:儘可能設大,直到接近系統的最低響應時間限制(根據實際情況選擇)。在此種情況下,年輕代收集發生的頻率也是最小的。同時,減少到達年老代的對象。

吞吐量優先的應用:儘可能的設置大,可能到達Gbit的程度。因爲對響應時間沒有要求,垃圾收集可以並行進行,一般適合8CPU以上的應用。

 

 

年老代大小選擇

 

響應時間優先的應用:年老代使用併發收集器,所以其大小需要小心設置,一般要考慮併發會話率會話持續時間等一些參數。如果堆設置小了,可以會造成內存碎片、高回收頻率以及應用暫停而使用傳統的標記清除方式;如果堆大了,則需要較長的收集時間。最優化的方案,一般需要參考以下數據獲得:

  1. 併發垃圾收集信息

  2. 持久代併發收集次數

  3. 傳統GC信息

  4. 花在年輕代和年老代回收上的時間比例

減少年輕代和年老代花費的時間,一般會提高應用的效率

 

 

吞吐量優先的應用

一般吞吐量優先的應用都有一個很大的年輕代和一個較小的年老代。原因是,這樣可以儘可能回收掉大部分短期對象,減少中期的對象,而年老代盡存放長期存活對象。

 

 

較小堆引起的碎片問題

因爲年老代的併發收集器使用標記、清除算法,所以不會對堆進行壓縮。當收集器回收時,他會把相鄰的空間進行合併,這樣可以分配給較大的對象。但是,當堆空間較小時,運行一段時間以後,就會出現“碎片”,如果併發收集器找不到足夠的空間,那麼併發收集器將會停止,然後使用傳統的標記、清除方式進行回收。如果出現“碎片”,可能需要進行如下配置:

    1. -XX:+UseCMSCompactAtFullCollection:使用併發收集器時,開啓對年老代的壓縮。

    2. -XX:CMSFullGCsBeforeCompaction=0:上面配置開啓的情況下,這裏設置多少次Full GC後,對年老代進行壓縮



 

垃圾回收的瓶頸

 

    傳統分代垃圾回收方式,已經在一定程度上把垃圾回收給應用帶來的負擔降到了最小,把應用的吞吐量推到了一個極限。但是他無法解決的一個問題,就是Full GC所帶來的應用暫停。在一些對實時性要求很高的應用場景下,GC暫停所帶來的請求堆積和請求失敗是無法接受的。這類應用可能要求請求的返回時間在幾百甚至幾十毫秒以內,如果分代垃圾回收方式要達到這個指標,只能把最大堆的設置限制在一個相對較小範圍內,但是這樣有限制了應用本身的處理能力,同樣也是不可接收的。

    分代垃圾回收方式確實也考慮了實時性要求而提供了併發回收器,支持最大暫停時間的設置,但是受限於分代垃圾回收的內存劃分模型,其效果也不是很理想。

    爲了達到實時性的要求(其實Java語言最初的設計也是在嵌入式系統上的),一種新垃圾回收方式呼之欲出,它既支持短的暫停時間,又支持大的內存空間分配。可以很好的解決傳統分代方式帶來的問題。

 

 

增量收集的演進

    增量收集的方式在理論上可以解決傳統分代方式帶來的問題。增量收集把對堆空間劃分成一系列內存塊,使用時,先使用其中一部分(不會全部用完),垃圾收集時把之前用掉的部分中的存活對象再放到後面沒有用的空間中,這樣可以實現一直邊使用邊收集的效果,避免了傳統分代方式整個使用完了再暫停的回收的情況。

    當然,傳統分代收集方式也提供了併發收集,但是他有一個很致命的地方,就是把整個堆做爲一個內存塊,這樣一方面會造成碎片(無法壓縮),另一方面他的每次收集都是對整個堆的收集,無法進行選擇,在暫停時間的控制上還是很弱。而增量方式,通過內存空間的分塊,恰恰可以解決上面問題。

 

 

Garbage Firest(G1)

這部分的內容主要參考這裏,這篇文章算是對G1算法論文的解讀。我也沒加什麼東西了。

 

 

目標

從設計目標看G1完全是爲了大型應用而準備的。

支持很大的堆

高吞吐量

  --支持多CPU和垃圾回收線程

  --在主線程暫停的情況下,使用並行收集

  --在主線程運行的情況下,使用併發收集

實時目標:可配置在N毫秒內最多隻佔用M毫秒的時間進行垃圾回收

當然G1要達到實時性的要求,相對傳統的分代回收算法,在性能上會有一些損失。

 

 

算法詳解

    G1可謂博採衆家之長,力求到達一種完美。他吸取了增量收集優點,把整個堆劃分爲一個一個等大小的區域(region)。內存的回收和劃分都以region爲單位;同時,他也吸取了CMS的特點,把這個垃圾回收過程分爲幾個階段,分散一個垃圾回收過程;而且,G1也認同分代垃圾回收的思想,認爲不同對象的生命週期不同,可以採取不同收集方式,因此,它也支持分代的垃圾回收。爲了達到對回收時間的可預計性,G1在掃描了region以後,對其中的活躍對象的大小進行排序,首先會收集那些活躍對象小的region,以便快速回收空間(要複製的活躍對象少了),因爲活躍對象小,裏面可以認爲多數都是垃圾,所以這種方式被稱爲Garbage First(G1)的垃圾回收算法,即:垃圾優先的回收。

 

 

回收步驟:

 

初始標記(Initial Marking)

    G1對於每個region都保存了兩個標識用的bitmap,一個爲previous marking bitmap,一個爲next marking bitmap,bitmap中包含了一個bit的地址信息來指向對象的起始點。

    開始Initial Marking之前,首先併發的清空next marking bitmap,然後停止所有應用線程,並掃描標識出每個region中root可直接訪問到的對象,將region中top的值放入next top at mark start(TAMS)中,之後恢復所有應用線程。

    觸發這個步驟執行的條件爲:

    G1定義了一個JVM Heap大小的百分比的閥值,稱爲h,另外還有一個H,H的值爲(1-h)*Heap Size,目前這個h的值是固定的,後續G1也許會將其改爲動態的,根據jvm的運行情況來動態的調整,在分代方式下,G1還定義了一個u以及soft limit,soft limit的值爲H-u*Heap Size,當Heap中使用的內存超過了soft limit值時,就會在一次clean up執行完畢後在應用允許的GC暫停時間範圍內儘快的執行此步驟;

    在pure方式下,G1將marking與clean up組成一個環,以便clean up能充分的使用marking的信息,當clean up開始回收時,首先回收能夠帶來最多內存空間的regions,當經過多次的clean up,回收到沒多少空間的regions時,G1重新初始化一個新的marking與clean up構成的環。

 

併發標記(Concurrent Marking)

    按照之前Initial Marking掃描到的對象進行遍歷,以識別這些對象的下層對象的活躍狀態,對於在此期間應用線程併發修改的對象的以來關係則記錄到remembered set logs中,新創建的對象則放入比top值更高的地址區間中,這些新創建的對象默認狀態即爲活躍的,同時修改top值。

 

 

最終標記暫停(Final Marking Pause)

    當應用線程的remembered set logs未滿時,是不會放入filled RS buffers中的,在這樣的情況下,這些remebered set logs中記錄的card的修改就會被更新了,因此需要這一步,這一步要做的就是把應用線程中存在的remembered set logs的內容進行處理,並相應的修改remembered sets,這一步需要暫停應用,並行的運行。

 

 

存活對象計算及清除(Live Data Counting and Cleanup)

    值得注意的是,在G1中,並不是說Final Marking Pause執行完了,就肯定執行Cleanup這步的,由於這步需要暫停應用,G1爲了能夠達到準實時的要求,需要根據用戶指定的最大的GC造成的暫停時間來合理的規劃什麼時候執行Cleanup,另外還有幾種情況也是會觸發這個步驟的執行的:

    G1採用的是複製方法來進行收集,必須保證每次的”to space”的空間都是夠的,因此G1採取的策略是當已經使用的內存空間達到了H時,就執行Cleanup這個步驟;

    對於full-young和partially-young的分代模式的G1而言,則還有情況會觸發Cleanup的執行,full-young模式下,G1根據應用可接受的暫停時間、回收young regions需要消耗的時間來估算出一個yound regions的數量值,當JVM中分配對象的young regions的數量達到此值時,Cleanup就會執行;partially-young模式下,則會盡量頻繁的在應用可接受的暫停時間範圍內執行Cleanup,並最大限度的去執行non-young regions的Cleanup。

 

 

展望

    以後JVM的調優或許跟多需要針對G1算法進行調優了。





 

垃圾回收的悖論

 

    所謂“成也蕭何敗蕭何”。Java的垃圾回收確實帶來了很多好處,爲開發帶來了便利。但是在一些高性能、高併發的情況下,垃圾回收確成爲了制約Java應用的瓶頸。目前JDK的垃圾回收算法,始終無法解決垃圾回收時的暫停問題,因爲這個暫停嚴重影響了程序的相應時間,造成擁塞或堆積。這也是後續JDK增加G1算法的一個重要原因。

    當然,上面是從技術角度出發解決垃圾回收帶來的問題,但是從系統設計方面我們就需要問一下了:

    我們需要分配如此大的內存空間給應用嗎?

    我們是否能夠通過有效使用內存而不是通過擴大內存的方式來設計我們的系統呢?    

 

我們的內存中都放了什麼

    內存中需要放什麼呢?個人認爲,內存中需要放的是你的應用需要在不久的將來再次用到到的東西。想想看,如果你在將來不用這些東西,何必放內存呢?放文件、數據庫不是更好?這些東西一般包括:

1. 系統運行時業務相關的數據。比如web應用中的session、即時消息的session等。這些數據一般在一個用戶訪問週期或者一個使用過程中都需要存在。

2. 緩存。緩存就比較多了,你所要快速訪問的都可以放這裏面。其實上面的業務數據也可以理解爲一種緩存。

3.  線程。

    因此,我們是不是可以這麼認爲,如果我們不把業務數據和緩存放在JVM中,或者把他們獨立出來,那麼Java應用使用時所需的內存將會大大減少,同時垃圾回收時間也會相應減少。

    我認爲這是可能的。

 

 

解決之道

 

數據庫、文件系統

    把所有數據都放入數據庫或者文件系統,這是一種最爲簡單的方式。在這種方式下,Java應用的內存基本上等於處理一次峯值併發請求所需的內存。數據的獲取都在每次請求時從數據庫和文件系統中獲取。也可以理解爲,一次業務訪問以後,所有對象都可以進行回收了。

    這是一種內存使用最有效的方式,但是從應用角度來說,這種方式很低效。

 

 

內存-硬盤映射

    上面的問題是因爲我們使用了文件系統帶來了低效。但是如果我們不是讀寫硬盤,而是寫內存的話效率將會提高很多。

    數據庫和文件系統都是實實在在進行了持久化,但是當我們並不需要這樣持久化的時候,我們可以做一些變通——把內存當硬盤使。

    內存-硬盤映射很好很強大,既用了緩存又對Java應用的內存使用又沒有影響。Java應用還是Java應用,他只知道讀寫的還是文件,但是實際上是內存。

    這種方式兼得的Java應用與緩存兩方面的好處。memcached的廣泛使用也正是這一類的代表。

 

 

同一機器部署多個JVM

    這也是一種很好的方式,可以分爲縱拆和橫拆。縱拆可以理解爲把Java應用劃分爲不同模塊,各個模塊使用一個獨立的Java進程。而橫拆則是同樣功能的應用部署多個JVM。

    通過部署多個JVM,可以把每個JVM的內存控制一個垃圾回收可以忍受的範圍內即可。但是這相當於進行了分佈式的處理,其額外帶來的複雜性也是需要評估的。另外,也有支持分佈式的這種JVM可以考慮,不要要錢哦:)

 

 

程序控制的對象生命週期

    這種方式是理想當中的方式,目前的虛擬機還沒有,純屬假設。即:考慮由編程方式配置哪些對象在垃圾收集過程中可以直接跳過,減少垃圾回收線程遍歷標記的時間。

    這種方式相當於在編程的時候告訴虛擬機某些對象你可以在*時間後在進行收集或者由代碼標識可以收集了(類似C、C++),在這之前你即便去遍歷他也是沒有效果的,他肯定是還在被引用的。

    這種方式如果JVM可以實現,個人認爲將是一個飛躍,Java即有了垃圾回收的優勢,又有了C、C++對內存的可控性。

 

 

線程分配

    Java的阻塞式的線程模型基本上可以拋棄了,目前成熟的NIO框架也比較多了。阻塞式IO帶來的問題是線程數量的線性增長,而NIO則可以轉換成爲常數線程。因此,對於服務端的應用而言,NIO還是唯一選擇。不過,JDK7中爲我們帶來的AIO是否能讓人眼前一亮呢?我們拭目以待。

 

 

其他的JDK

    本文說的都是Sun的JDK,目前常見的JDK還有JRocket和IBM的JDK。其中JRocket在IO方面比Sun的高很多,不過Sun JDK6.0以後提高也很大。而且JRocket在垃圾回收方面,也具有優勢,其可設置垃圾回收的最大暫停時間也是很吸引人的。不過,系統Sun的G1實現以後,在這方面會有一個質的飛躍。

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