java基礎(一):談談java記憶體管理與垃圾回收機制

從入門到放棄的攻城獅發表於2019-02-27

看了很多java記憶體管理的文章或者部落格,寫的要麼籠統,要麼劃分的不正確,且很多文章都千篇一律。例如部分地方將jvm籠統的分為堆、棧、程式計數器,這麼分太過於籠統,無法清晰的闡述java的記憶體管理模型;部分地方將jvm分為堆、棧、程式計數器、常量池、方法區,這麼分,很全面,但是過於混亂,因為這些區域之間存在並列和包含關係,而最近再次刷《Java Thinking》這本書的時候,從新學習了關於記憶體模型的內容。基於上述原因,我決定來談談jvm虛擬機器的記憶體劃分。

至於垃圾回收機制,個人覺得應該和記憶體管理一同討論,所以在此,我也將記憶體回收機制拿出來進行一起討論。

本片部落格的大致結構:1.記憶體區域;2.記憶體回收機制;3.垃圾回收器

1.記憶體區域

首先看看官方的記憶體模型圖片:圖片來自《Java虛擬機器規範(第2版)》

java基礎(一):談談java記憶體管理與垃圾回收機制

1.1.程式計數器:

程式計數器是一個比較小的記憶體區域,用於指示當前執行緒所執行的位元組碼執行到了第幾行,可以理解為是當前執行緒的行號指示器。位元組碼直譯器在工作時,會通過改變這個計數器的值來取下一條語句指令。由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間的計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。 如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是Natvie方法,這個計數器值則為空(Undefined),由於程式計數器只是記錄當前指令地址,所以不存在記憶體溢位的情況,因此,程式計數器也是所有JVM記憶體區域中唯一一個沒有定義OutOfMemoryError的區域。

1.2.棧

棧分為虛擬機器棧和本地方法棧,既然都是棧,那麼就具有相同的特性:私有的,執行緒安全,棧中儲存了基本資料型別和物件的引用

1.2.1.java虛擬機器棧

一個執行緒的每個方法在執行的同時,都會建立一個棧幀(Statck Frame),棧幀中儲存的有區域性變數表、操作站、動態連結、方法出口等,當方法被呼叫時,棧幀在JVM棧中入棧,當方法執行完成時,棧幀出棧。實際上每個方法的呼叫相當於是棧幀的入棧已經出棧操作。區域性變數表中儲存著方法的相關區域性變數,包括各種基本資料型別,物件的引用,返回地址等。在區域性變數表中,只有long和double型別會佔用2個區域性變數空間(Slot,對於32位機器,一個Slot就是32個bit),其它都是1個Slot。需要注意的是,區域性變數表是在編譯時就已經確定好的,方法執行所需要分配的空間在棧幀中是完全確定的,在方法的生命週期內都不會改變。虛擬機器棧中定義了兩種異常,如果執行緒呼叫的棧深度大於虛擬機器允許的最大深度,則丟擲StatckOverFlowError(棧溢位);不過多數Java虛擬機器都允許動態擴充套件虛擬機器棧的大小(有少部分是固定長度的),所以執行緒可以一直申請棧,直到記憶體不足,此時,會丟擲OutOfMemoryError(記憶體溢位)。

1.2.2.本地方法棧

本地方法棧在作用,執行機制,異常型別等方面都與虛擬機器棧相同,唯一的區別是:虛擬機器棧是執行Java方法的,而本地方法棧是用來執行native方法的,如呼叫C++,C#編寫的方法。目前在很多虛擬機器中(如Sun的JDK預設的HotSpot虛擬機器),會將本地方法棧與虛擬機器棧放在一起使用。

1.3.堆

堆是執行緒共享的,儲存的是物件的例項,有的地方寫儲存的是物件的例項和陣列,實際上陣列是特殊的類,那麼陣列也屬於物件的例項。在JVM所管理的記憶體中,堆區是最大的一塊,堆區也是Java GC發生的主要場所,在虛擬機器啟動時建立,所以堆也成為GC堆,按照java垃圾回收的概念,堆又可以分為新生代和老年代,永久代(只有部分虛擬機器中有永久代的概念,sun公司的HotSpot虛擬機器就有,其它的一般沒有,而hotSpot應用的比較廣泛),其中新生代又可以分為Eden,From Survivor,To Survivor,其中每一塊具體的作用在垃圾回收模組會詳細介紹。原則上講,所有的物件都在堆區上分配記憶體,但是隨著JIT編譯器的發展與逃逸分析技術的逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也漸漸變得不是那麼“絕對”了。一般的,根據Java虛擬機器規範規定,堆記憶體需要在邏輯上是連續的(在物理上不需要),在實現時,可以是固定大小的,也可以是可擴充套件的,目前主流的虛擬機器都是可擴充套件的(通過-Xmx和-Xms控制)。如果在執行垃圾回收之後,仍沒有足夠的記憶體分配,也不能再擴充套件,將會丟擲OutOfMemoryError:Java heap space異。

1.4.方法區

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的應該是與Java堆區分開來。對於習慣在HotSpot虛擬機器上開發和部署程式的開發者來說,很多人願意把方法區稱為“永久代”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot虛擬機器的設計團隊選擇把GC分代收集擴充套件至方法區,或者說使用永久代來實現方法區而已。對於其他虛擬機器(如BEA JRockit、IBM J9等)來說是不存在永久代的概念的。即使是HotSpot虛擬機器本身,根據官方釋出的路線圖資訊,現在也有放棄永久代並“搬家”至Native Memory來實現方法區的規劃了。Java虛擬機器規範對這個區域的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外(通過設定permsize和MaxPermsize設定方法區的初始化大小和最大記憶體),還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收“成績”比較難以令人滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收確實是有必要的。在Sun公司的BUG列表中,曾出現過的若干個嚴重的BUG就是由於低版本的HotSpot虛擬機器對此區域未完全回收而導致記憶體洩漏。 根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。

1.5.常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述等資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。 Java虛擬機器對Class檔案的每一部分(自然也包括常量池)的格式都有嚴格的規定,每一個位元組用於儲存哪種資料都必須符合規範上的要求,這樣才會被虛擬機器認可、裝載和執行。但對於執行時常量池,Java虛擬機器規範沒有做任何細節的要求,不同的提供商實現的虛擬機器可以按照自己的需要來實現這個記憶體區域。不過,一般來說,除了儲存Class檔案中描述的符號引用外,還會把翻譯出來的直接引用也儲存在執行時常量池中。 執行時常量池相對於Class檔案常量池的另外一個重要特徵是具備動態性,Java語言並不要求常量一定只能在編譯期產生,也就是並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。 既然執行時常量池是方法區的一部分,自然會受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲OutOfMemoryError異常。

2.垃圾回收演算法

java 語言中一個顯著的特點就是引入了java回收機制,是c++程式設計師最頭疼的記憶體管理的問題迎刃而解,它使得java程式設計師在編寫程式的時候不在考慮記憶體管理。由於有個垃圾回收機制,java中的額物件不在有“作用域”的概念,只有物件的引用才有“作用域”。垃圾回收可以有效的防止記憶體洩露,有效的使用空閒的記憶體;java語言規範沒有明確的說明JVM 使用哪種垃圾回收演算法,但是任何一種垃圾回收演算法一般要做兩件基本事情:(1)發現無用的資訊物件;(2)回收將無用物件佔用的記憶體空間。使該空間可被程式再次使用。

2.1.引用計數法(Reference Counting Collector)

引用計數是垃圾收集器中的早期策略。在這種方法中,堆中每個物件例項都有一個引用計數。當一個物件被建立時,且將該物件例項分配給一個變數,該變數計數設定為1。當任何其它變數被賦值為這個物件的引用時,計數加1(a = b,則b引用的物件例項的計數器+1),但當一個物件例項的某個引用超過了生命週期或者被設定為一個新值時,物件例項的引用計數器減1。任何引用計數器為0的物件例項可以被當作垃圾收集。當一個物件例項被垃圾收集時,它引用的任何物件例項的引用計數器減1。

2.1.2優缺點

優點:引用計數收集器可以很快的執行,交織在程式執行中。對程式需要不被長時間打斷的實時環境比較有利。

缺點:無法檢測出迴圈引用。如父物件有一個對子物件的引用,子物件反過來引用父物件。這樣,他們的引用計數永遠不可能為0.

2.2.tracing演算法(Tracing Collector) 或 標記-清除演算法(mark and sweep)

該演算法是從離散數學中的圖論引入的,程式把所有的引用關係看作一張圖,從一個節點GC ROOT開始,尋找對應的引用節點,找到這個節點以後,繼續尋找這個節點的引用節點,當所有的引用節點尋找完畢之後,剩餘的節點則被認為是沒有被引用到的節點,即無用的節點。

java中可作為GC Root的物件有

1.虛擬機器棧中引用的物件(本地變數表)

2.方法區中靜態屬性引用的物件

  1. 方法區中常量引用的物件

4.本地方法棧中引用的物件(Native物件)

標記-清除演算法採用從根集合進行掃描,對存活的物件物件標記,標記完畢後,再掃描整個空間中未被標記的物件,進行回收。分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。具體過程如下圖所示。標記-清除演算法不需要進行物件的移動,並且僅對不存活的物件進行處理,在存活物件比較多的情況下極為高效,但由於標記-清除演算法直接回收不存活的物件,因此會造成記憶體碎片。

java基礎(一):談談java記憶體管理與垃圾回收機制

2.3.compacting演算法 或 標記-整理演算法

標記-整理演算法採用標記-清除演算法一樣的方式進行物件的標記,但在清除時不同,在回收不存活的物件佔用的空間後,會將所有的存活物件往左端空閒空間移動,並更新對應的指標。標記-整理演算法是在標記-清除演算法的基礎上,又進行了物件的移動,因此成本更高,但是卻解決了記憶體碎片的問題。在基於Compacting演算法的收集器的實現中,一般增加控制程式碼和控制程式碼表。

java基礎(一):談談java記憶體管理與垃圾回收機制

2.4.copying演算法(Compacting Collector)

該演算法的提出是為了克服控制程式碼的開銷和解決堆碎片的垃圾回收。它開始時把堆分成 一個物件 面和多個空閒面, 程式從物件面為物件分配空間,當物件滿了,基於copying演算法的垃圾 收集就從根集中掃描活動物件,並將每個 活動物件複製到空閒面(使得活動物件所佔的記憶體之間沒有空閒洞),這樣空閒面變成了物件面,原來的物件面變成了空閒面,程式會在新的物件面中分配記憶體。一種典型的基於coping演算法的垃圾回收是stop-and-copy演算法,它將堆分成物件面和空閒區域面,在物件面與空閒區域面的切換過程中,程式暫停執行。這種演算法雖然實現簡單,執行高效且不容易產生記憶體碎片,但是卻對記憶體空間的使用做出了高昂的代價,因為能夠使用的記憶體縮減到原來的一半。很顯然,Copying演算法的效率跟存活物件的數目多少有很大的關係,如果存活物件很多,那麼Copying演算法的效率將會大大降低。

java基礎(一):談談java記憶體管理與垃圾回收機制

2.5.generation演算法(Generational Collector)

分代的垃圾回收策略,是基於這樣一個事實:不同的物件的生命週期是不一樣的。因此,不同生命週期的物件可以採取不同的回收演算法,以便提高回收效率。分代收集演算法是目前大部分JVM的垃圾收集器採用的演算法。它的核心思想是根據物件存活的生命週期將記憶體劃分為若干個不同的區域。一般情況下將堆區劃分為老年代(Tenured Generation)和新生代(Young Generation)和持久代,老年代的特點是每次垃圾收集時只有少量物件需要被回收,而新生代的特點是每次垃圾回收時都有大量的物件需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。

2.5.1.年輕代(Young Generation)

1.所有新生成的物件首先都是放在年輕代的。年輕代的目標就是儘可能快速的收集掉那些生命週期短的物件。

2.新生代記憶體按照8:1:1的比例分為一個eden區和兩個survivor(survivor0,survivor1)區。一個Eden區,兩個 Survivor區(一般而言)。大部分物件在Eden區中生成。回收時先將eden區存活物件複製到一個survivor0區,然後清空eden區,當這個survivor0區也存放滿了時,則將eden區和survivor0區存活物件複製到另一個survivor1區,然後清空eden和這個survivor0區,此時survivor0區是空的,然後將survivor0區和survivor1區交換,即保持survivor1區為空, 如此往復。

3.當survivor1區不足以存放 eden和survivor0的存活物件時,就將存活物件直接存放到老年代。若是老年代也滿了就會觸發一次Full GC,也就是新生代、老年代都進行回收

4.新生代發生的GC也叫做Minor GC,MinorGC發生頻率比較高(不一定等Eden區滿了才觸發)

2.5.2.年老代(Old Generation)

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

2.記憶體比新生代也大很多(大概比例是1:2),當老年代記憶體滿時觸發Major GC即Full GC,Full GC發生頻率比較低,老年代物件存活時間比較長,存活率標記高。

2.5.3.持久代(Permanent Generation)

用於存放靜態檔案,如Java類、方法等。持久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者呼叫一些class,例如Hibernate 等,在這種時候需要設定一個比較大的持久代空間來存放這些執行過程中新增的類。

目前大部分垃圾收集器對於新生代都採取Copying演算法,因為新生代中每次垃圾回收都要回收大部分物件,也就是說需要複製的操作次數較少,但是實際中並不是按照1:1的比例來劃分新生代的空間的,一般來說是將新生代劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden空間和其中的一塊Survivor空間,當進行回收時,將Eden和Survivor中還存活的物件複製到另一塊Survivor空間中,然後清理掉Eden和剛才使用過的Survivor空間。

而由於老年代的特點是每次回收都只回收少量物件,一般使用的是Mark-Compact演算法。

java基礎(一):談談java記憶體管理與垃圾回收機制

當Eden區滿的時候,會觸發第一次Minor gc,把還活著的物件拷貝到Survivor From區;當Eden區再次出發Minor gc的時候,會掃描Eden區和From區,對兩個區域進行垃圾回收,經過這次回收後還存活的物件,則直接複製到To區域,並將Eden區和From區清空。 當後續Eden區又發生Minor gc的時候,會對Eden區和To區進行垃圾回收,存活的物件複製到From區,並將Eden區和To區清空 部分物件會在From區域和To區域中複製來複制去,如此交換15次(由JVM引數MaxTenuringThreshold決定,這個引數預設是15),最終如果還存活,就存入老年代。

3.垃圾回收(瞭解)

新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge

老年代收集器使用的收集器:Serial Old、Parallel Old、CMS

Serial收集器(複製演算法)

新生代單執行緒收集器,標記和清理都是單執行緒,優點是簡單高效。

Serial Old收集器(標記-整理演算法)

老年代單執行緒收集器,Serial收集器的老年代版本。

ParNew收集器(停止-複製演算法)

新生代收集器,可以認為是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現。

Parallel Scavenge收集器(停止-複製演算法)

並行收集器,追求高吞吐量,高效利用CPU。吞吐量一般為99%, 吞吐量= 使用者執行緒時間/(使用者執行緒時間+GC執行緒時間)。適合後臺應用等對互動相應要求不高的場景。

Parallel Old收集器(停止-複製演算法)

Parallel Scavenge收集器的老年代版本,並行收集器,吞吐量優先

CMS(Concurrent Mark Sweep)收集器(標記-清理演算法)

高併發、低停頓,追求最短GC回收停頓時間,cpu佔用比較高,響應時間快,停頓時間短,多核cpu 追求高響應時間的選擇

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

Scavenge GC

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

Full GC

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

1.年老代(Tenured)被寫滿

2.持久代(Perm)被寫滿

3.System.gc()被顯示呼叫

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

1.靜態集合類像HashMap、Vector等的使用最容易出現記憶體洩露,這些靜態變數的生命週期和應用程式一致,所有的物件Object也不能被釋放,因為他們也將一直被Vector等應用著。

2.各種連線,資料庫連線,網路連線,IO連線等沒有顯示呼叫close關閉,不被GC回收導致記憶體洩露。

3.監聽器的使用,在釋放物件的同時沒有相應刪除監聽器的時候也可能導致記憶體洩露。

www.cnblogs.com/andy-zcx/p/…

www.cnblogs.com/wabi8754756…

blog.csdn.net/yubujian_l/…

www.cnblogs.com/dz-boss/p/1…

相關文章