1. 程式人生 > >Java記憶體管理(一)

Java記憶體管理(一)

好久沒有寫部落格了,深感慚愧,今天聊一下Java的記憶體管理

簡介

Java相比傳統語言(C,C++)的一個優勢在於其能夠自動管理記憶體,從而將開發者管理記憶體任務剝離開來。
本文大體描述了J2SE 5.0 release中JVM對於記憶體是如何管理的。並且為選擇和配置對應的收集器,配置收集器的引數提供了一些建議和參考。

手動VS自動記憶體管理

記憶體管理是能夠識別哪些釋放的物件不再使用,釋放掉這些物件所佔用空間的一個過程。在很多程式語言中,記憶體管理是開發者的責任。但是管理記憶體的任務具有一定的複雜性,會導致很多錯誤,影響到應用的行為,並且令程式崩潰掉。結果,開發者很大的一部分時間都是在debug和修正這些錯誤。

在手動記憶體管理中一個經常發生的問題就是dangling references。很有可能當在釋放某個物件佔用的空間時,仍然包含其他對該銷燬物件的引用,在這個時候,當這些引用指向了新的物件時,執行結果是無法預期的。
另一個常見的問題就是space leaks。產生洩露的原因在於,當記憶體被分配了,但是卻沒有引用的情況下,以後就無法再次釋放掉了。舉個例子,如果開發者試著釋放一個連結串列,但是寫的程式出了點小bug,值釋放了頭結點,那麼連結串列中後面的物件就無法找到了。也就再被回收掉。一旦洩露過多,整個記憶體就會崩潰掉。

而相對於手動管理記憶體,在面向物件程式語言中,通常使用是自動管理記憶體技術,也稱之為垃圾收集器。自動的記憶體管理對介面進行了更高層次的抽象。

垃圾收集器解決了dangling reference問題,因為如果一個物件還被引用的話,是不會被垃圾收集器回收掉的。同時,垃圾收集器也解決了space leaks問題,因為那些洩露的空間,屬於沒有被引用的物件,會被垃圾收集器回收掉。

垃圾收集器的概念

垃圾收集器主要有一下一些職責:

  • 分配記憶體
  • 確保引用的物件仍然還在記憶體中
  • 釋放掉那些不可達物件所佔用的空間

有引用物件通常稱之為存活的物件。沒有引用的物件通常稱之為死亡物件,也被認為是垃圾。檢索和釋放掉死亡物件的過程就稱之為垃圾收集。

垃圾收集器解決了很多很多的記憶體管理問題,但是並不是全部。當然了,開發者可以持續不斷的建立物件,並且始終保持對他們的引用,直到沒有可用的記憶體為止。垃圾收集本身也是一個複雜的任務,需要消耗相當的時間和資源的。

關於組織物件,分配和釋放空間的演算法都是由垃圾收集器處理的,是被隱藏在開發者的視線之外的。空間通常是從一個很大的記憶體池來釋放的,稱之為

垃圾收集的排程通常是取決於垃圾收集器本身的。通常來說,整個堆或者堆的子集會在其填充滿或者到達一定佔比閾值的時候進行垃圾回收。

分配的任務包含在堆中找到一塊沒有使用的記憶體,當然,這一任務並不簡單。這個動態分配空間的演算法主要的問題就是避免碎片,儘量保證分配空間和釋放空間的高效。

令人滿意的垃圾收集特性

垃圾收集器必須既保證安全,並且充分理解程式碼。也就意味著,存活的資料必須不能夠被錯誤的釋放,而垃圾不應該在幾個回收週期之後,仍然存活。

當然,如果垃圾收集器能夠高效的執行,不會在應用正在執行的過程中,進行長時間的停頓,肯定是非常好的。然而,在絕大多數的系統中,通常都會需要在空間,時間,頻率上做出權衡的。舉個例子,如果堆空間很小的話,垃圾收集的速度會很快,但是堆會更快的充滿物件,也會需要進行更為頻繁的垃圾收集。相反,如果堆空間配置的較大的話,那麼堆充滿需要的時間會更久,垃圾收集也不會執行的很頻繁,但是單次的垃圾回收需要的時間會更久。

垃圾回收如果能夠有效限制分片的話,無疑也是非常好的。當回收掉部分垃圾物件所佔用的記憶體空間之後,空閒的空間可能以小塊的形式存在於多個區域的。當出現這種情況時,當再次為一個較大的物件申請空間的時候,可能會無法獲得足夠的空間。一種消除碎片的方式叫做叫做記憶體緊縮。

擴充套件性同樣是垃圾收集器所需要的。分配操作不應該成為多程序,多執行緒應用的擴充套件性瓶頸,收集操作同樣不應該成為瓶頸。

設計選擇

在設計和選擇垃圾回收演算法的時候,通常需要作出一些抉擇:

  • 選擇序列回收還是並行回收。當使用序列回收的時候,每個時間節點都只會發生一件事情。舉個例子,甚至是在多個CPU可用的情況下,也只會有一個CPU來執行垃圾收集操作。而當使用並行收集的情況下,垃圾回收操作會分成不同的子模組,由不同的CPU並行執行。並行的操作會令回收操作速度更快,但是會有更高的複雜性成本以及潛在的碎片情況。
  • 並行回收VS全域性暫停回收。當stop-the-world垃圾收集器執行的時候,應用的執行會在進行垃圾回收的時候完全暫停。當然,也可以同時並行執行垃圾回收操作和應用本身的處理。通常來說,併發收集器會將絕大多數任務並行執行完成,但是有時也仍然會有較少的暫停應用的情況。stop-the-world垃圾收集器比並發收集器要更簡單一些。因為在收集時,會將堆鎖定,物件在此期間是不會發生變動的。當然,缺點是有些應用是不希望應用暫停的。相應的,使用併發收集器的話,應用暫停的時間會更短,但是收集器必須額外考慮,當應用在使用物件的時候,是否該執行更新操作。這回為併發收集帶來額外的工作,在堆較大的時候,會帶來一定的效能影響。
  • 壓縮VS不壓縮VS拷貝。當垃圾收集器決定了那些記憶體中的物件是存活的,那些是垃圾的時候,可以選擇壓縮記憶體,將存活的物件收集到一起,重新利用剩餘的空間。在壓縮之後,可以很容易的給新的物件分配空間。可以使用一個指標來跟蹤分配物件的結尾。相對於壓縮收集演算法,非壓縮收集演算法會釋放垃圾物件所在的位置。但是並不會將存活的物件壓縮到一起,所以不會像壓縮演算法那樣,可以留出較大的空間在新分配物件的時候使用。非壓縮演算法的好處是垃圾收集的速度很快,但是記憶體碎片問題會比較嚴重。一般來說,非壓縮演算法的分配成本也要高於壓縮演算法。因為必須要搜尋一塊足夠大的連續記憶體空間來給新的物件。還有一個演算法就是拷貝收集,講所有存活的物件拷貝到另一塊記憶體區域。好處在於,之前使用的記憶體區域就可以當成是完全全新的了。劣勢就是需要拷貝所需的記憶體空間。

效能上的度量

在考慮垃圾收集器效能的時候,有以下一些方面需要考慮:

  • 吞吐:指的是不在垃圾回收上面使用的時間佔比。
  • 垃圾收集負載:是吞吐的對立面,也就是垃圾回收上面的時間佔比。
  • 暫停時間:當在執行垃圾回收的時候,應用停止執行的時間。
  • 收集頻率:收集多久執行一次,這個值通常和應用的執行時相關的。
  • 佔用的空間:對空間的佔用的衡量,比如堆得大小。
  • 迅捷:當一個物件成為了垃圾物件和它佔用空間可用的時間間隔。

互動式的應用需要較低的暫停時間,而總執行時間要比非互動式的應用要求要搞。而實時應用會在垃圾回收的暫停時間和垃圾回收的時間佔比上都有較高的要求。而在個人計算機或者是嵌入式系統中,佔用空間可能是應用更應該考慮的問題。

分代收集

當使用了分代收集技術的時候,記憶體是分成不同的代的,也就是將不同年紀的物件分放到不同的物件池中。舉個例子,Java中最常使用的配置有兩個不同的年代:年輕代,老年代,分別用來存放年輕的物件和年老的物件。

在每個不同的代中,可以使用不同的垃圾回收演算法,而每個演算法可以在其自己的年代中根據該年代的特性進行優化。每一代的垃圾收集器都有如下的一種假設,稱之為weak generational hypothesis,認為多數語言中實現的應用(包括Java),有如下特點:

  • 大多數分配的物件都不會存活很長的時間。
  • 少數存活很久的物件會一直存在著。

如圖所示:
這裡寫圖片描述

年輕代進行的垃圾回收相對來說,會相對更頻繁,並且執行也更迅速,因為年輕代物件通常較小,並且會引用很多生命週期很短的物件。

而一些物件在幾次年輕代回收都沒有回收掉的話,就會晉升成為老年代物件。如下圖:老年代通常比年輕代要大,其佔用的增長速度會變慢。所以,老年代垃圾回收不會很頻繁,但是回收的時間要更久一些。

這裡寫圖片描述

為年輕代選擇的垃圾回收演算法通常會優先考慮速度,因為年輕代的回收通常來說是更頻繁。另一方面,老年代考慮的演算法通常是更考慮空間的有效性,因為老年代會佔用更多的堆內空間,老年代演算法需要更好的處理低密度垃圾回收。

J2SE JVM中的垃圾回收器

J2SE JVM中包含四種垃圾收集器。所有的垃圾收集器都是分代的。本節描述了回收的分代和型別,以及討論為何物件的空間分配通常是高效和迅速的。然後為每種垃圾收集器提供了詳細的資訊。

HotSpot分代

在JVM中,記憶體被分成三代來管理的,分別是前面提到的年輕代,老年代以及永久代。絕大多數物件都是被初始化到年輕代的。而老年代中包含的物件通常是多次回收都沒有回收掉的年輕代物件,以及部分很大的物件,這些物件是直接分配到老年代的。永久代中包含一些對JVM方便進行垃圾收集管理的資訊,比如描述類和方法的物件,還有類和方法本身。

年輕代包含一個叫做Eden的區域和兩個稍小的survivor區域,如下圖。

這裡寫圖片描述

大多數物件都是直接初始化在Eden區域的。(前面提到過,少數很大的物件可能直接分配到老年代的)survivor空間持有那些至少一次從年輕代垃圾回收下存活的物件。垃圾收集器會給這些物件再進入老年代之前一些機會,讓他們在進入老年代之前仍然在年輕代中,可以被回收掉。在任何給定的時間,一個Survivor的空間(從圖中標記為From)持有這樣的物件,而另一個直到下一次垃圾回收之前都是空的。

垃圾回收型別

當年輕代物件空間慢了,年輕代的垃圾收集就開始了(有的時候,也稱之為minorGC)。當老年代或者永久代物件空間慢了,執行的垃圾回收稱之為majorGC。通常來說,年輕代是優先收集的,使用的回收演算法也是根據其年代的特點來特別設計的,因為通常年輕代對垃圾的識別和回收對效率要求更高。老年代的回收演算法是同時執行在老年代和永久代的。一旦發生記憶體壓縮,每一代都是分別進行記憶體壓縮的。

有的時候,老年代已經空間不足,無法繼續接受年輕代的物件了。在這種情況下,除了CMS收集器,全部的手機都不會執行,年輕代的回收演算法也不會執行。相反,會在整個堆上使用老年代回收演算法。(CMS老年代演算法屬於特殊情況,因為它不會對年輕代進行收集)

快速分配

在很多情況下,記憶體中都有很大的連續空間用來給物件使用。這些記憶體塊的空間分配是配合簡單的bump-the-pointer技術是十分高效的。bump-the-pointer技術就是通過一個指標來跟蹤上一次釋放物件空間的結尾。當新的分配請求過來的時候,JVM只需要判斷指標和當前代結尾之間的空間是否足夠就可以了,如果可以的話,就挪動指標,並且初始化物件。

對於多執行緒應用來說,分配操作是必須保證執行緒安全的。如果使用全域性鎖來保證分配操作是執行緒安全的,那麼分配操作進入某一代將會成為一個性能上的瓶頸。相反,JVM使用了一個技術叫做Thread-Local Allocation Buffer技術(TLABs).該技術會將分配操作先寫入執行緒本身的緩衝區中,來提高多執行緒分配操作的吞吐量。因為,一旦每個執行緒將分配操作寫入到自己的緩衝區的話,就可以使用bump-the-pointer技術實現快速分配,並且全程是不需要鎖來進行阻塞操作的。當然,偶然的情況下,當執行緒內部的緩衝區已經填滿了,無法寫入更多的物件的時候,就必須使用同步操作來保證分配的執行緒安全性了。當然,使用TLABs同時也有一些減少空間浪費的技術。TLABs的空間的浪費平均不到Eden區的1%。使用TLABs技術和bump-the-pointer技術令分配操作效能很高,大概只需要10個本地指令的時間。

序列收集器

當使用序列收集器的時候,無論是年輕代還有老年代的手機,都是序列收集的(使用一個CPU),收集的過程中,會停止應用的一切執行。

序列收集——年輕代

下圖展示了年輕代使用序列收集器收集的一些操作。存貨的物件從Eden拷貝到空的survivor空間,也就是圖中的TO區域,當然,如果物件太大是不會進入到To區域的,而是直接進入老年代。在survivor中From區域的物件中,仍然相對年輕的物件拷貝到To空間,而比較老的物件會進入到老年代。注意,如果To空間滿了,沒有拷貝到To區域的Eden和From區域的物件將直接進入老年代,而不會管這些物件到底經過了多少次年輕代的回收。而其他沒有拷貝的EdenFrom區域的物件,就不再是存活的物件了。

這裡寫圖片描述

在一次年輕代收集完成之後,無論是Eden還是survivor的From區域,就都是空的了,只有survivor的To區域還有存活的物件,這個時候FromTo兩者的職責會調換過來,參考下圖:

這裡寫圖片描述

序列收集——老年代

老年代使用序列收集器收集的演算法是mark-sweep-compact收集演算法,在mark階段,收集器識別出所有的存活的物件。而在sweep階段,會清除垃圾。收集器會執行滑動壓縮,將存活的物件依次向老年代空間的起始位置滑動(永久代也一樣),而在老年代的末尾處留出較大的連續空間。當回收完畢以後,老年代仍然支援bump-the-pointer技術來實現快速分配。參考下圖:

這裡寫圖片描述

何時使用序列收集器

序列收集器一般來說只有執行在Client端的應用,並且這些應用對於應用暫停時長沒有太多的需求的情況下會使用。以今天的裝置來說,序列收集器可以在不到半秒的時間內,收集64M的堆空間。

J2SE5.0的釋出時間為2005年的6月,上面的測試結果以當時的硬體效能為準。

序列收集器的選擇

在J2SE 5.0中,在非伺服器的JVM中,預設的收集器就是序列收集器。如果使用其他的JVM的話,可以通過如下引數來指定使用序列收集器:

-XX:+UseSerialGC