1. 程式人生 > >精通java之JVM詳解(每日研讀2次以上,對java理解必有提升)

精通java之JVM詳解(每日研讀2次以上,對java理解必有提升)

在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<String>softreference = new SoftReference<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<String>weakReference = new WeakReference<String>(str);  
  4. str=null;  
  5. System.gc();  
  6. assertNull(weakReference.get());  
  7. }  

Phantom reference

按照基本回收策略分

引用計數(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=&lt;N>進行設定。

什麼情況下觸發垃圾回收

        由於物件進行了分代處理,因此垃圾回收區域、時間也不一樣。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演算法的一個重要原因。

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

    我們需要分配如此大的記憶體空間給應用嗎?

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

我們的記憶體中都放了什麼

    記憶體中需要放什麼呢?個人認為,記憶體中需要放的是你的應用需要在不久的將來再次用到到的東西