首頁 > 軟體

關於JVM翻越記憶體管理的牆

2022-05-18 19:01:57

對於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設定。

參考

到此這篇關於關於JVM翻越記憶體管理的牆的文章就介紹到這了,更多相關JVM記憶體管理內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com