關於JVM翻越內存管理的墻

對於Java程序員來說,在虛擬機自動內存管理機制的幫助下,不再需要為每一個new操作去寫配對 的delete/free代碼釋放內存,也由此不容易出現內存泄漏和內存溢出問題。但凡事都有兩面性,由虛擬機管理內存看起來一切都很美好,但也正是因為把控制內存的權力交給瞭Java虛擬機,一旦出現內存泄漏和溢出方面的問題,就不得不從Java虛擬機角度上去排查問題。因此我們需要瞭解虛擬機是怎樣使用內存的,才能準確的定位到錯誤,從而正確的解決問題。

主要內容:

  • JVM運行時數據區域
  • JVM垃圾回收機制

JVM運行時數據區域

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分為若幹個不同的數據區域,有的區域隨著虛擬機進程的啟動而一直存在,有些區域則是依賴用戶線程的啟動和結束而建立和銷毀。

線程私有內存:

由於JVM多線程是通過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個內核)都隻會執行一條線程中的指令。

因此,為瞭線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。

程序計數器

程序計數器是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。 它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令。

如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址。

如果正在執行的是本地(Native)方法,這個計數器值則應為空。

Java虛擬機棧

Java虛擬機棧描述的是Java方法執行的線程內存模型,它也是線程私有內存區域,生命周期和線程一樣。

棧楨

每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀用於存儲局部變量表、操作數棧、動態連接、方法出口等信息。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。

1.局部變量表

局部變量表存放瞭編譯期可知的:基本數據類型、對象引用、和returnAddress類型(指向瞭一條字節碼指令的地址)

局部變量表中的存儲空間以局部變量槽表示。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小(這裡說的“大小”是指變量槽的數量,一個變量槽多大是由具體虛擬機實現的)

2.異常情況

1.StackOverflowError異常:線程請求的棧深度大於虛擬機所允許的深度

2.OutOfMemoryError異常:Java虛擬機棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存。 在HotSpot虛擬機上是不會由於虛擬機棧無法擴展而導致OutOfMemoryError異常。隻要線程申請棧空間成功瞭就不會有OOM,但是如果申請時就失敗,仍然是會出現OOM異常的。

本地方法棧

與虛擬機棧所發揮的作用是非常相似的。本地方法棧則是為虛擬機使用到的本地(Native)方法服務。

HotSpot虛擬機直接就把本地方法棧和虛擬機棧合二為一

Java堆

Java堆是虛擬機所管理的內存中最大的一塊,被所有線程共享的一塊內存區域 Java堆是垃圾收集器管理的內存區域。所以也經常被稱為GC堆

Java堆會在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,Java世界裡“幾乎”所有的對象實例都在這裡分配內存。

從回收內存的角度看,由於現代垃圾收集器大部分都是基於分代收集理論設計的,所以Java堆中經常會出現“新生代”“老年代”“永久代”“Eden空間”“From Survivor空 間”“To Survivor空間”等名詞。

在之前(以G1收集器的出現為分界),作為業界絕對主流的HotSpot虛擬機,它內部的垃圾收集器全部都基於“經典分代” 來設計,需要新生代、老年代收集器搭配才能工作,在這種背景下,上述說法還算是不會產生太大歧義。但是到瞭今天,垃圾收集器技術與十年前已不可同日而語,HotSpot裡面也出現瞭不采用分代設計的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方瞭。

分配緩沖區TLAB(Thread Local Allocation Buffer)

如果從分配內存的角度看,所有線程共享的Java堆中可以劃分出多個線程私有的分配緩沖區 (Thread Local Allocation Buffer,TLAB)。

無論如何劃分,都不會改變Java堆中存儲內容的共性,無論是哪個區域,存儲的都隻能是對象的實例,將Java堆細分的目的隻是為瞭更好地回收內存,或者更快地分配內存。

Java堆的大小設定

Java堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的Java虛擬機都是按照可擴展來實現的(通過參數-Xmx-Xms設定)。如果在Java堆中沒有內存完成實例分配,並且堆也無法再擴展時,Java虛擬機將會拋出OutOfMemoryError異常。

方法區

方法區別名叫作“非堆”。它用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。是各個線程共享的內存區域

很多人都更願意把方法區稱呼為“永久代”(PermanentGeneration),或將兩者混為一談。本質上這兩者並不是等價的。因為僅僅是當時的HotSpot虛擬機設計團隊選擇把收集器的分代設計擴展至方法區,或者說使用永久代來實現方法區而已,這樣使得 HotSpot的垃圾收集器能夠像管理Java堆一樣管理這部分內存,省去專門為方法區編寫內存管理代碼的工作。

相對Java堆而言,垃圾收集行為在這個區域的確是比較少出現的,但並非數據進入瞭方法區就永久”存在瞭。 這區域的內存回收目標主要是針對常量池的回收和對類型的卸載,一般來說這個區域的回收效果比較難令人滿意,尤其是類型的卸載,條件相當苛刻,但是這部分區域的回收有時又確實是必要的。

運行時常量池

運行時常量池是方法區的一部分。Class文件中除瞭有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表,用於存放編譯期生成的各種字面量與符號引用,在類加載後存放到方法區的運行時常量池中

運行時常量池相對於Class文件常量池的另外一個重要特征是具備動態性,Java語言並不要求常量一定隻有編譯期才能產生,也就是說,並非預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可以將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的 intern()方法。

深入解析String#intern

Java中,直接使用雙引號聲明出來的String對象會直接存儲在常量池中。不是用雙引號聲明的String對象,可以使用String提供的intern方法。

intern 方法:如果字符串常量池中已經包含一個等於此String對象的字符串,則返回代表池中這個字符串的String對象的引用;否則,會將此String對象包含的字符串添加 到常量池中,並且返回此String對象的引用。

小結

整理下上面介紹的JVM運行時數據區域:

JVM垃圾回收機制

上面介紹瞭程序計數器、虛擬機棧、本地方法棧都是線程私有區域,這三個區域隨線程而生,隨線程而滅。 在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者線程結束時,內存自然就跟隨著回收瞭。

比如棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少內存基本上是在類結構確定下來時就已知的(盡管在運行期會由即時編譯器進行一些優化,但在基於概念模型的討論裡,大體上可以認為是編譯期可知的),因此這幾個區域的內存分配和回收都具備確定性。

但是Java堆和方法區這兩個區域則有著很顯著的不確定性:

1.一個接口的多個實現類需要的內存可能會不一樣,一個方法所執行的不同條件分支所需要的內存也可能不一樣,隻有處於運行期間,我們才能知道程序究竟會創建哪些對象,創建多少個對象,這部分內存的分配和回收是動態的。垃圾收集器所關註的正是這部分內存該如何管理。

2.方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。回收廢棄常量與回收 Java堆中的對象非常類似。

比如已經沒有任何字符串對象引用常量池中的某常量,且虛擬機中也沒有其他地方引用這個字面量。如果在這時發生內存回收,而且垃圾收集器判斷確有必要的話,該常量就將會被系統清理出常量池。常量池中其他類(接口)、方法、字段的符號引用也與此類似。

方法區垃圾收集的“性價比”通常也是比較低的:在Java堆中,尤其是在新生代中,對常規應用進行一次垃圾收集通常可以回收70%至99%的內存空間,相比之下,方法區回收囿於苛刻的判定條件,其區域垃圾收集的回收成果往往遠低於此。

判斷對象存活

垃圾回收的是死亡的對象,所以在回收前要做的事確定這個對象是否還存活。判斷對象存活的方式主流的有兩種算法:引用計數算法和可達性分析算法。

引用計數算法

在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一。任何時刻計數器為零的對象就是不可能再被使用的。

該算法的缺點是:當兩個對象互相引用,會導致無法回收;因為互相引用著對方,導致它們的引用計數都不為零,引用計數算法也就無法回收它們。

引用計數算法(Reference Counting)雖然占用瞭一些額外的內存空間來進行計數,但 它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的算法。也有一些比較著名的應用 案例,例如微軟COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在遊戲腳本領域得到許多應用的Squirrel中都使用瞭引用計數算法進行內存管理。但是,在Java 領域,至少主流的Java虛擬機裡面都沒有選用引用計數算法來管理內存。

可達性分析算法

當前主流的商用程序語言(Java、C#,Lisp)的內存管理子系統,都是通過可達性分析(Reachability Analysis)算法來判定對象是否存活的。

該算法的基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連, 或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

其中GC Root的對象有很多種,常見的有:

  • 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
  • 在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。
  • 在方法區中常量引用的對象,譬如字符串常量池(String Table)裡的引用。
  • 在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
  • 所有被同步鎖(synchronized)持有的對象

幾種引用方式

無論是通過引用計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象是否引用鏈可達,判定對象是否存活都和“引用”離不開關系。

根據引起的強度從強到弱排序:

  • 強引用:強引用是我們最常用的,在程序代碼之中普遍存在的引用賦值,即類似“Object obj=new Object()”這種引用關系。無論任何情況下,隻要強引用關系還存在,垃圾收集器就永遠不會回收掉被引用的對象
  • 軟引用:描述一些還有用,但非必須的對象。隻被軟引用關聯著的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收范圍之中進行第二次回收,如果這次回收還沒有足夠的內存, 才會拋出內存溢出異常。
  • 弱引用:用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象隻能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉隻被弱引用關聯的對象。
  • 虛引用:也稱為“幽靈引用”或者“幻影引用”,它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的隻是為瞭能在這個對象被收集器回收時收到一個系統通知

垃圾回收算法

標記清除算法

算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。

缺點:

  • 執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低
  • 內存空間的碎片化問題,標記、清除之後會產生大 量不連續的內存碎片,空間碎片太多可能會導致當以後在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作

標記復制算法

它將可用內存按容量劃分為大小相等的兩塊,每次隻使用其中的一塊。當這一塊的內存用完瞭,就將還存活著的對象復制到另外一塊上面,然後再把已使用過的內存空間一次清理掉。

優點:

解決標記清除法的缺點。每次都是對整個半區進行回收,不用考慮內存碎片浪費。

缺點:

  • 缺陷在於將可用內存縮小為瞭原來的一半,空間浪費未免太多瞭一點。

  • 如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷。

現在的商用Java虛擬機大多都優先采用瞭這種收集算法去回收新生代。

新生代中的對象有98%熬不過第一輪收集。因此並不需要按照1∶1的比例來劃分新生代的內存空間。HotSpot虛擬機的Serial、ParNew等新生代收集器均采用瞭這種策略來設 計新生代的內存佈局。Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的 Survivor空間,每次分配內存隻使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍 然存活的對象一次性復制到另外一塊Survivor空間上,然後直接清理掉Eden和已用過的那塊Survivor空 間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新 生代容量的90%(Eden的80%加上一個Survivor的10%),隻有一個Survivor空間,即10%的新生代是會 被“浪費”的。

標記整理法

該算法讓所有存活的對象都向內存空間一端移動,然後直接清理掉邊界以外的內存。

優點:不會存在標記整理內存浪費的問題。

缺點:復制收集算法在對象存活率高的情況下就會出現復制操作,移動操作多,效率會變低。

標記清除法和標記整理法的選擇是一種權衡:

標記整理法,通過移動存活對象,尤其是在老年代這種每次回收都有大量對象存活區域,移動存活對象並更新所有引用這些對象的地方將會是一種極為負重的操作,而且這種對象移動操作必須**全程暫停用戶應用程序(Stop The World)**才能進行。

如果跟標記-清除算法那樣完全不考慮移動和整理存活對象的話,彌散於堆中的存活對象導致的空間碎片化問題就隻能依賴更為復雜的內存分配器和內存訪問器來解決。譬如通過“分區空閑分配鏈表”來解決內存分配問題(計算機硬盤存儲大文件就不要求物理連續的磁盤空間,能夠在碎片化的硬盤上存儲和訪問就是通過硬盤分區表實現的)。內存的訪問是用戶程序最頻繁的操作,假如在這個環節上增加瞭額外的負擔,勢必會直接影響應用程序的吞吐量。

基於以上兩點,是否移動對象都存在弊端,移動則內存回收時會更復雜,不移動則內存分配時會更復雜。從垃圾收集的停頓時間來看,不移動對象停頓時間會更短,甚至可以不需要停頓,但是從整個程序的吞吐量來看,移動對象會更劃算。

即使不移動對象會使得收集器的效率提升一些,但因內存分配和訪問相比垃圾收集頻率要高得多,這部分的耗時增加,總吞吐量仍然是下降的。

HotSpot虛擬機裡面關註吞吐量的Parallel Scavenge收集器是基於標記-整理算法的,而關註延遲的CMS收集器則是基於標記-清除算法的,

為瞭平衡二者的弊端,就有一種中和的方式。讓虛擬機平時多數時間都采用標記-清除算法,暫時容忍內存碎片的存在,直到內存空間的碎片化程度已經大到影響對象分配時,再采用標記-整理算法收集一次,以獲得規整的內存空間。比如基於標記-清除算法的CMS收集器面臨空間碎片過多時采用的就是這種處理辦法。

分代收集算法

當前商業虛擬機的垃圾收集器,大多數都遵循瞭“分代收集”的理論進行設計。

多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然後將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。

這樣做的優點是:

  • 如果一個區域中大多數對象都難以熬過垃圾收集過程的話,那麼把它們集中放在一起,每次回收時隻關註如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間。

  • 如果剩下的都是難以消亡的對象,那把它們集中放在一塊, 虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧瞭垃圾收集的時間開銷和內存的空間有效利用。

Java堆劃分出不同的區域之後,垃圾收集器才可以每次隻回收其中某一個或者某些部分的區域 。因而才有瞭Minor GCMajor GCFull GC這樣的回收類型的劃分。也才能夠針對不同的區域安排與裡面存儲對象存亡特征相匹配的垃圾收集算法。

收集概念的區分:

新生代收集(Minor GC/Young GC):指目標隻是新生代的垃圾收集

老年代收集(Major GC/Old GC):指目標隻是老年代的垃圾收集。請註意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指, 讀者需按上下文區分到底是指老年代的收集還是整堆收集。

整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。

Java堆·劃分為新生代和老年代。在新生代中,每次垃圾收集時都發現有大批對象死去,而每次回收後存活的少量對象,將會逐步晉升到老年代中存放。

ps: 這些區域劃分僅僅是一部分垃圾收集器的共同特性或者說設計風格而已,而非某個JVM具體實現的固有內存佈局,更不是《Java虛擬機規范》裡對Java堆的進一步細致劃分。作為業界絕對主流的HotSpot虛擬機,它內部的垃圾收集器全部都基於“經典分代” 來設計,需要新生代、老年代收集器搭配才能工作。但到瞭今天,HotSpot裡面也出現瞭不采用分代設計的新垃圾收集器。

內存回收策略

下面介紹的回收策略是基於“經典分代” 設計的回收過程:

1.新生代的分配和回收

1.大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。把新生代分為一塊較大的Eden空間和兩塊較小的 Survivor空間,每次分配內存隻使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍 然存活的對象一次性復制到另外一塊Survivor空間上,然後直接清理掉Eden和已用過的那塊Survivor空 間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1

2.大對象直接進入老年代

2.大對象直接進入老年代。大對象就是指需要大量連續內存空間的Java對象,最典型的大對象便是那種很長的字符串,或者元素數量很龐大的數組。

為什麼要這麼做呢?這樣做的目的就是避免在Eden區及兩個Survivor區之間來回復制,產生大量的內存復制操作。

大對象對虛擬機的內存分配來說是一個壞消息,比遇到一個大對象更壞的消息就是遇到一群“朝生夕滅”的短命大對象。我們寫程序的時候應註意避免大對象。在Java虛擬機中要避免大對象的原因是,在分配空間時,它容易導致內存明明還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能安置好它們,而當復 制對象時,大對象就意味著高額的內存復制開銷。

3.長期存活的對象將進入老年代

如果經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,該對象會被移動到Survivor空間中,並且將其對象年齡設為1歲。對象在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定的年齡閾值(默認為15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX: MaxTenuringThreshold設置。

參考

  • 《深入理解Java虛擬機(第三版)》
  • 深入解析String#intern

到此這篇關於關於JVM翻越內存管理的墻的文章就介紹到這瞭,更多相關JVM內存管理內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: