這次我們主要關注的是黃色部分,記憶體的分配與回收
衆所周知,Java 和 C++語言的區別,就在於垃圾收集技術和記憶體動態分配上,C語言沒有垃圾收集技術,需要我們手動的收集。
垃圾收集,不是Java語言的伴生產物。早在1960年,第一門開始使用記憶體動態分配和垃圾收集技術的Lisp語言誕生。 關於垃圾收集有三個經典問題:
垃圾收集機制 機製是 Java 的招牌能力,極大地提高了開發效率。如今,垃圾收集幾乎成爲現代語言的標配,即使經過如此長時間的發展,Java的垃圾收集機制 機製仍然在不斷的演進中,不同大小的裝置、不同特徵的應用場景,對垃圾收集提出了新的挑戰,這當然也是面試的熱點。
一些大廠面試題:
system.gc()
和runtime.gc()
會做什麼事情垃圾是指在執行程式中沒有任何指針指向的物件,這個物件就是需要被回收的垃圾。
如果不及時對記憶體中的垃圾進行清理,那麼,這些垃圾物件所佔的記憶體空間會一直保留到應用程式的結束,被保留的空間無法被其它物件使用,甚至可能導致記憶體溢位。
對於高階語言來說,一個基本認知是如果不進行垃圾回收,記憶體遲早都會被消耗完,因爲不斷地分配記憶體空間而不進行回收,就好像不停地生產生活垃圾而從來不打掃一樣。
除了釋放沒用的物件,垃圾回收也可以清除記憶體裡的記錄碎片。碎片整理將所佔用的堆記憶體移到堆的一端,以便JVM將整理出的記憶體分配給新的物件。
隨着應用程式所應付的業務越來越龐大、複雜,使用者越來越多,沒有GC就不能保證應用程式的正常進行。而經常造成STW的GC又跟不上實際的需求,所以纔會不斷地嘗試對GC進行優化。
在早期的C/C++時代,垃圾回收基本上是手工進行的。
開發人員可以使用new關鍵字
進行記憶體申請
,並使用delete
關鍵字進行記憶體釋放
。比如以下程式碼:
MibBridge *pBridge= new cmBaseGroupBridge();
// 如果註冊失敗,使用 Delete 釋放該物件所佔記憶體區域
if(pBridge->Register(kDestroy)!=NO_ERROR)
delete pBridge;
這種方式可以靈活控制記憶體釋放的時間,但是會給開發人員帶來頻繁申請和釋放記憶體的管理負擔。
倘若有一處記憶體區間由於程式設計師編碼的問題忘記被回收,那麼就會產生記憶體漏失(物件不用了,但是也沒辦法回收),垃圾物件永遠無法被清除;
隨着系統執行時間的不斷增長,垃圾物件所耗記憶體可能持續上升,直到出現記憶體溢位並造成應用程式崩潰。
有了垃圾回收機制 機製後,上述程式碼極有可能變成這樣
MibBridge *pBridge=new cmBaseGroupBridge();
pBridge->Register(kDestroy);
現在,除了Java以外,C#、Python、Ruby等語言都使用了自動垃圾回收的思想,也是未來發展趨勢,可以說這種自動化的記憶體分配和來及回收方式已經成爲了線代開發語言必備的標準。
優點
自動記憶體管理,無需開發人員手動參與記憶體的分配與回收,這樣降低記憶體漏失和記憶體溢位的風險
沒有垃圾回收器,java也會和cpp一樣,各種懸垂指針,野指針,泄露問題讓你頭疼不已。
自動記憶體管理機制 機製,將程式設計師從繁重的記憶體管理中釋放出來,可以更專心地專注於業務開發
Oracle官網關於垃圾回收的介紹 https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
擔憂
對於Java開發人員而言,自動記憶體管理就像是一個黑匣子,如果過度依賴於「自動」,那麼這將會是一場災難,最嚴重的就會弱化Java開發人員在程式出現記憶體溢位時定位問題和解決問題的能力。
此時,瞭解JVM的自動記憶體分配和記憶體回收原理就顯得非常重要,只有在真正瞭解JVM是如何管理記憶體後,我們才能 纔能夠在遇見OutOfMemoryError
時,快速地根據錯誤異常日誌定位問題和解決問題。
當需要排查各種記憶體溢位、記憶體漏失問題時,當垃圾收整合爲系統達到更高併發量的瓶頸時,我們就必須對這些「自動化」的技術實施必要的監控和調節。
GC主要關注的區域
垃圾收集器可以對年輕代回收,也可以對老年代回收,甚至是全棧和方法區的回收
從次數上講:
Young
區Old
區Perm
永久區(元空間)在堆裡存放着幾乎所有的Java物件範例,在GC執行垃圾回收之前,首先需要區分出記憶體中哪些是存活物件,哪些是已經死亡的物件。
只有被標記爲己經死亡的物件,GC纔會在執行垃圾回收時,釋放掉其所佔用的記憶體空間,因此這個過程我們可以稱爲垃圾標記階段。
那麼在JVM中究竟是如何標記一個死亡物件呢?
判斷物件存活一般有兩種方式:參照計數演算法和可達性分析演算法。
參照計數演算法(Reference Counting)比較簡單,對每個物件儲存一個整型的參照計數器屬性,用於記錄物件被參照的情況。
優點
缺點
回圈參照
p指針
斷開的時候,內部的參照形成一個回圈(稱爲回圈參照),從而造成記憶體漏失舉個例子:我們使用一個案例來測試Java中是否採用的是參照計數演算法
/**
* 參照計數演算法測試
* -XX:+PrintGCDetails
*/
public class RefCountGC {
// 這個成員屬性的唯一作用就是佔用一點記憶體
// 如果沒有被回收,堆記憶體的佔用空間肯定大於這個
private byte[] bigSize = new byte[5*1024*1024]; // 5MB
// 參照
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();
// 回圈參照
obj1.reference = obj2;
obj2.reference = obj1;
// 指針斷開
obj1 = null;
obj2 = null;
// 顯示執行垃圾收集行爲
// 這裏發生 GC,obj1 和 obj2是否被回收?
System.gc();
}
}
問題
檢視列印的 GC 資訊
[GC (System.gc()) [PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K), 0.0061980 secs] [Times: user=0.00 sys=0.00, real=0.36 secs]
[Full GC (System.gc()) [PSYoungGen: 808K->0K(76288K)] [ParOldGen: 8K->672K(175104K)] 816K->672K(251392K), [Metaspace: 3479K->3479K(1056768K)], 0.0045983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 76288K, used 655K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
eden space 65536K, 1% used [0x000000076b500000,0x000000076b5a3ee8,0x000000076f500000)
from space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
to space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
ParOldGen total 175104K, used 672K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1ea8070,0x00000006cc900000)
Metaspace used 3486K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 385K, capacity 388K, committed 512K, reserved 1048576K
655K
)小結
參照計數演算法,是很多語言的資源回收選擇,例如因人工智慧而更加火熱的Python,它更是同時支援參照計數和垃圾收集機制 機製。
具體哪種最優是要看場景的,業界有大規模實踐中僅保留參照計數機制 機製,以提高吞吐量的嘗試。
Java並沒有選擇參照計數,是因爲其存在一個基本的難題,也就是很難處理回圈參照關係。Python如何解決回圈參照?
weakref
,weakref
是Python提供的標準庫,旨在解決回圈參照。概念
可達性分析演算法:也可以稱爲 根搜尋演算法、追蹤性垃圾收集
相對於參照計數演算法而言,可達性分析演算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該演算法可以有效地解決在參照計數演算法中回圈參照的問題,防止記憶體漏失的發生。
相較於參照計數演算法,這裏的可達性分析就是Java、C#選擇的。這種型別的垃圾收集通常也叫作追蹤性垃圾收集(TGC 、 Tracing Garbage Collection
)
思路
所謂"GCRoots
」根集合就是一組必須活躍的參照。
基本思路:
例如之前很火的電視劇《人民的名義》中的人物關係
GC Roots 可以是哪些?
synchronized
持有的物件NullPointerException
、OutOfMemoryError
),系統類載入器。Java虛擬機器
內部情況的JMXBean
、JVMTI
中註冊的回撥、原生代碼快取等。總結
總結一句話就是,除了堆空間外的一些結構,比如 虛擬機器棧、本地方法棧、方法區、字串常數池 等地方對堆空間進行參照的,都可以作爲GC Roots進行可達性分析
除了這些固定的GC Roots集合以外,根據使用者所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件「臨時性」地加入,共同構成完整GC Roots集合。比如:分代收集和區域性回收(PartialGC)。
小技巧
由於Root採用棧方式存放變數和指針,所以如果一個指針,它儲存了堆記憶體裏面的物件,但是自己又不存放在堆記憶體裏面,那它就是一個Root。
注意
如果要使用可達性分析演算法來判斷記憶體是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行。
Java語言提供了物件終止(finalization
)機制 機製來允許開發人員提供物件被銷燬之前的自定義處理邏輯。
當垃圾回收器發現沒有參照指向一個物件,即:垃圾回收此物件之前,總會先呼叫這個物件的finalize()
方法。
finalize()
方法允許在子類中被重寫,用於在物件被回收時進行資源釋放。
注意
永遠不要主動呼叫某個物件的finalize()
方法應該交給垃圾回收機制 機製呼叫。理由包括下面 下麪三點:
finalize()
時可能會導致物件復活。finalize()
方法的執行時間是沒有保障的,它完全由GC執行緒決定,極端情況下,若不發生GC,則finalize()
方法將沒有執行機會。
finalize()
會嚴重影響GC
的效能。從功能上來說,finalize()
方法與 c++ 中的解構函式比較相似,但是Java採用的是基於垃圾回收器的自動記憶體管理機制 機製,所以finalize()
方法在本質上不同於C++中的解構函式。
由於finalize()
方法的存在,虛擬機器中的物件一般處於三種可能的狀態。
生存還是死亡?
如果從所有的根節點都無法存取到某個物件,說明物件己經不再使用了。一般來說,此物件需要被回收。
但事實上,也並非是「非死不可」的,這時候它們暫時處於「緩刑」階段。
一個無法觸及的物件有可能在某一個條件下「復活」自己,如果這樣,那麼對它的回收就是不合理的,爲此,定義虛擬機器中的物件可能的三種狀態。
finalize()
中復活。finalize()
被呼叫,並且沒有復活,那麼就會進入不可觸及狀態。
finalize()
只會被呼叫一次。以上3種狀態中,是由於finalize()
方法的存在,進行的區分。只有在物件不可觸及時纔可以被回收。
具體過程
判定一個物件 objA 是否可回收,至少要經歷兩次標記過程:
如果物件 objA 到 GC Roots 沒有參照鏈,則進行第一次標記。
進行篩選,判斷此物件是否有必要執行 finalize()方法
F-Queue
佇列中,由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒
觸發其 finalize()方法執行。F-Queue
佇列中的物件進行第二次標記。如果 objA 在 finalize() 方法中與參照鏈上的任何一個物件建立了聯繫,那麼在第二次標記時,objA 會被移出「即將回收」集合。之後,物件會再次出現沒有參照存在的情況。在這個情況下,finalize方法 不會被再次呼叫,物件會直接變成不可觸及的狀態,也就是說,一個物件的finalize方法只會被呼叫一次。程式碼演示
我們使用重寫 finalize()
方法,然後在方法的內部,重寫將其存放到 GC Roots 中
/**
* 測試Object類中finalize()方法
* 物件復活場景
*/
public class CanReliveObj {
// 類變數,屬於GC Roots的一部分
public static CanReliveObj canReliveObj;
// 所有類的父類別都是 Object,所以可以直接重寫
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("呼叫當前類重寫的finalize()方法");
canReliveObj = this; // 當前待回收的物件在 finalize() 方法中與參照鏈上的任何一個物件建立了聯繫
}
public static void main(String[] args) throws InterruptedException {
canReliveObj = new CanReliveObj();
canReliveObj = null;
System.gc();
System.out.println("-----------------第一次gc操作------------");
// 因爲 Finalizer 執行緒的優先順序比較低,暫停 2秒,以等待它
Thread.sleep(2000);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("-----------------第二次gc操作------------");
canReliveObj = null;
System.gc();
// 下面 下麪程式碼和上面程式碼是一樣的,但是 canReliveObj卻自救失敗了
Thread.sleep(2000);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else````{
System.out.println("obj is still alive");
}
}
}
// 執行結果
-----------------第一次gc操作------------
呼叫當前類重寫的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead
在進行第一次清除的時候,我們會執行finalize方法
,然後 物件 進行了一次自救操作
,但是因爲finalize()
方法只會被呼叫一次
,因此第二次該物件將會被垃圾清除
。
MAT是什麼?
MAT是Memory Analyzer
的簡稱,它是一款功能強大的 Java堆記憶體分析器。用於查詢記憶體漏失以及檢視記憶體消耗情況。
命令列使用
jmap
獲得Dump
檔案
使用 JVIsualVM 獲取 Dump 檔案
捕獲的heap dump檔案是一個臨時檔案(快照),關閉JVisualVM後自動刪除,若要保留,需要將其另存爲檔案。可通過以下方法捕獲heap dump:
在左側「Application
"(應用程式)子視窗中右擊相應的應用程式,選擇Heap Dump
(堆Dump)。
在Monitor
(監視)子標籤頁中點選Heap Dump
(堆Dump)按鈕。本地應用程式的Heap dumps
作爲應用程式標籤頁的一個子標籤頁開啓。同時,heap dump
在左側的Application
(應用程式)欄中對應一個含有時間戳的節點。
右擊這個節點選擇save as
(另存爲)即可將heap dump
儲存到本地。
使用 MAT 開啓Dump檔案,檢視堆資訊
匯入剛纔儲存的 Dump檔案
開啓後,我們就可以看到有哪些可以作爲GC Roots的物件
JProfiler 的 GC Roots溯源
我們在實際的開發中,一般不會查詢全部的GC Roots,可能只是查詢某個物件的整個鏈路,稱爲GC Roots 溯源,這個時候,我們就可以使用JProfiler
點選Live memory -> All Objects
,右側視窗中選中要溯源的物件,單擊右鍵,Show Selection In Heap Walker
,右側視窗上測點選References
即可看到。
如何判斷什麼原因造成OOM ?(通過 JProfiler)
當我們程式出現OOM的時候,我們就需要進行排查,需要看某一個物件的參照鏈,從源頭切斷參照,避免記憶體漏失。
我們首先使用下面 下麪的例子進行說明
/**
* 記憶體溢位排查
* -Xms8m -Xmx8m -XX:HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
// 建立1M的檔案
byte [] buffer = new byte[1 * 1024 * 1024];
public static void main(String[] args) {
ArrayList<HeapOOM> list = new ArrayList<>();
int count = 0;
try {
// 不斷加入列表中
while (true) {
list.add(new HeapOOM());
count++;
}
} catch (Exception e) {
e.getStackTrace();
System.out.println("count:" + count);
}
}
}
上述程式碼就是不斷的建立一個1M
小位元組陣列,然後讓記憶體溢位,我們需要限制一下記憶體大小,同時使用HeapDumpOnOutOfMemoryError
將出錯時候的 Dump檔案 輸出:-Xms8m -Xmx8m -XX:HeapDumpOnOutOfMemoryError
我們將生成的 dump檔案 開啓,然後點選Biggest Objects
就能夠看到超大物件
然後我們通過執行緒,還能夠定位到哪裏出現OOM
當成功區分出記憶體中存活物件和死亡物件後,GC接下來的任務就是執行垃圾回收,釋放掉無用物件所佔用的記憶體空間,以便有足夠的可用記憶體空間爲新物件分配記憶體。
目前在JVM中比較常見的三種垃圾收集演算法是
標記-清除演算法(Mark-Sweep
)是一種非常基礎和常見的垃圾收集演算法,該演算法被J.McCarthy
等人在1960年提出並並應用於Lisp語言
。
執行過程
當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被稱爲stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除
可達物件
。
Header
中沒有標記爲可達物件,則將其回收什麼是清除?
這裏所謂的清除並不是真的置空,而是把需要清除的物件地址儲存在空閒的地址列表裡。下次有新物件需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放覆蓋原有的地址。
關於空閒列表是在爲物件分配記憶體的時候 提過
缺點
背景
爲了解決標記-清除演算法在垃圾收集效率方面的缺陷,M.L.Minsky
於1963
年發表了著名的論文,「使用雙儲存區的 Lisp 語言垃圾收集器(A LISP Garbage Collector Algorithm Using Serial Secondary Storage
)」。
M.L.Minsky在該論文中描述的演算法被人們稱爲複製(Copying)演算法,它也被M.L.Minsky
本人成功地引入到了 Lisp 語言的一個實現版本中。
核心思想
將活着的記憶體空間分爲兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,之後清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,最後完成垃圾回收。
把可達的物件,直接複製到另外一個區域中複製完成後,A區就沒有用了,裏面的物件可以直接清除掉,其實新生代(倖存者0區和1區)裏面就用到了複製演算法
優點
缺點
region
(分割區)的GC,複製而不是移動,意味着GC需要維護region之間物件參照關係,不管是記憶體佔用或者時間開銷也不小,即當複製移動的時候,需要維護參照物件的地址,如果使用控制代碼存取就能提高一定的效率,關於控制代碼存取詳情可看物件的存取定位。需要注意的問題!!
如果系統中的垃圾物件很多,複製演算法就不會很理想,因爲複製演算法需要複製的存活物件數量並不會太大,或者說非常低才行
例如老年代大量的物件存活,那麼複製的物件將會有很多,效率會很低(極端情況就是什麼垃圾都沒回收,還全部都複製了一遍)
在新生代,對常規應用的垃圾回收,一次通常可以回收70% - 99% 的記憶體空間。回收性價比很高。所以現在的商業虛擬機器都是用這種收集演算法回收新生代。
標記 - 壓縮
演算法(也稱標記 - 整理
演算法)Mark - Compact
背景
複製演算法的高效性是建立在存活物件少、垃圾物件多的前提下的。
標記一清除演算法的確可以應用在老年代中,但是該演算法不僅執行效率低下,而且在執行完記憶體回收後還會產生記憶體碎片,所以 JVM 的設計者需要在此基礎之上進行改進
。標記-壓縮(Mark-Compact
)演算法由此誕生。
1970年前後,G.L.Steele、C.J.Chene和D.s.Wise
等研究者發佈標記-壓縮演算法。在許多現代的垃圾收集器中,人們都使用了標記-壓縮演算法或其改進版本。
執行過程
雖說是標記清理演算法的改進版,那兩者之間有什麼區別呢?
標記-壓縮演算法的最終效果等同於標記-清除演算法執行完成後,再進行一次記憶體碎片整理,因此,也可以把它稱爲標記-清除-壓縮(Mark-Sweep-Compact
)演算法。
二者的本質差異在於標記-清除演算法是一種非移動式的回收演算法,標記-壓縮是移動式的。
優點
缺點
標記壓縮演算法相當於是一個折中的演算法
效率上來說,複製演算法是當之無愧的老大,但是卻浪費了太多記憶體。
而爲了儘量兼顧上面提到的三個指標,標記-整理演算法相對來說更平滑一些
但是效率上不盡如人意,它比複製演算法多了一個標記的階段,比標記-清除多了一個整理記憶體的階段。
也比複製演算法節省了空間,比標記-清除演算法少維護了一個空閒列表
綜合來說,沒有最好的演算法,只有最合適的演算法
標記 - 清除 | 標記 - 整理 | 複製演算法 | |
---|---|---|---|
速度(三者相對) | 中等 | 最慢 | 最快 |
空間開銷 | 少(但是會堆積碎片) | 少(不堆積碎片) | 桶長需要活物件的2倍空間(不堆積碎片) |
移動物件 | 否 | 是 | 是 |
分代收集演算法,是基於這樣一個事實:不同的物件的生命週期是不一樣的。
因此,不同生命週期的物件可以採取不同的收集方式,以便提高回收效率。
在Java程式執行的過程中,會產生大量的物件,其中有些物件是與業務資訊相關,比如 HTTP 請求中的Session物件、執行緒、Socket連線,這類物件跟業務直接掛鉤,因此生命週期比較長。
但是還有一些物件,主要是程式執行過程中生成的臨時變數,這些物件生命週期會比較短,比如:String物件,由於其不變類的特性,系統會產生大量的這些物件,有些物件甚至只用一次即可回收。
目前幾乎所有的GC都採用分代手機演算法執行垃圾回收的
在 HotSpot 中,基於分代的概念,GC所使用的記憶體回收演算法必須結合年輕代和老年代各自的特點。
年輕代(Young Gen)
年輕代特點:區域相對老年代較小,物件生命週期短、存活率低,回收頻繁。
這種情況複製演算法的回收整理,速度是最快的。複製演算法的效率只和當前存活物件大小有關,因此很適用於年輕代的回收。
而複製演算法記憶體利用率不高的問題,通過 HotSpot 中的兩個 Survivor 的設計得到緩解。
老年代(Tenured Gen)
老年代特點:區域較大,物件生命週期長、存活率高,回收不及年輕代頻繁。
這種情況存在大量存活率高的物件,複製演算法明顯變得不合適。一般是由標記-清除或者是標記-清除與標記-整理的混合實現。
以 HotSpot 中的 CMS回收器 爲例,CMS 是基於 Mark-Sweep 實現的,對於物件的回收效率很高。而對於碎片問題, CMS 採用基於 Mark-Compact 演算法的 Serial old 回收器 作爲補償措施:
Concurrent Mode Failure
時),將採用 Serial old 執行 FullGC 以達到對老年代記憶體的整理。分代的思想被現有的虛擬機器廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代
減少垃圾回收過程中因 STW 而產生的影響
概述
上述現有的演算法,在垃圾回收過程中,應用軟體將處於一種 Stop the World 的狀態。在 Stop the World 狀態下,應用程式所有的執行緒都會掛起,暫停一切正常的工作,等待垃圾回收的完成。
如果垃圾回收時間過長,應用程式會被掛起很久,將嚴重影響使用者體驗或者系統的穩定性。爲了解決這個問題,即對實時垃圾收集演算法的研究直接導致了增量收集(Incremental Collecting)演算法的誕生。
如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集執行緒和應用程式執行緒交替執行。每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接着切換到應用程式執行緒。依次反覆 反復,直到垃圾收集完成
。
總的來說,增量收集演算法的基礎仍是傳統的標記-清除和複製演算法。增量收集演算法通過對執行緒間衝突的妥善處理,允許垃圾收集執行緒以分階段的方式完成標記、清理或複製工作
優缺點
使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,所以能減少系統的停頓時間。
但是,因爲執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。
一般來說,在相同條件下,堆空間越大,一次 GC 時所需要的時間就越長,有關GC產生的停頓也越長。
爲了更好地控制 GC 產生的停頓時間,將一塊大的記憶體區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若幹個小區間,而不是整個堆空間,從而減少一次GC所產生的停頓。
分代演算法將按照物件的生命週期長短劃分成兩個部分;
分割區演算法將整個堆空間劃分成連續的不同小區間。
注意,這些只是基本的演算法思路,實際GC實現過程要複雜的多,目前還在發展中的前沿GC都是複合演算法,並且並行和併發兼備。
在預設情況下,通過system.gc()
或者Runtime.getRuntime().gc()
的呼叫,會顯式觸發FullGC,同時對老年代和新生代進行回收,嘗試釋放被丟棄物件佔用的記憶體。
然而system.gc()
呼叫附帶一個免責宣告,無法保證對垃圾收集器的呼叫。(不能確保立即生效)
JVM實現者可以通過system.gc()
呼叫來決定JVM的GC行爲。而一般情況下,垃圾回收應該是自動進行的,無須手動觸發,否則就太過於麻煩了。
System.gc()
程式碼演示是否出發GC操作
/**
* System.gc()
* -XX:+PrintGCDetails
*/
public class SystemGCTest {
public static void main(String[] args) {
new SystemGCTest();
// 提醒JVM進行垃圾回收,但是並不一定會馬上執行 gc
// 底層呼叫 Runtime.getRuntime().gc()
System.gc(); // 執行結果也確實證明不一定及時進行 GC
//System.runFinalization(); // 如果執行這行,就一定會呼叫下面 下麪的方法
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("SystemGCTest 執行了 finalize方法");
}
}
// 輸出
SystemGCTest 執行了 finalize方法
當呼叫System.gc();
時不一定會觸發銷燬的方法,但是呼叫System.runFinalization()
會強制呼叫 失去參照的物件的finalize()
手動 GC 來理解不可達物件的回收
/**
* 區域性變數回收
*/
public class LocalVarGC {
/**
* 觸發 Minor GC 沒有回收物件,然後觸發 Full GC 將該物件存入 old區
*/
public void localvarGC1() {
byte[] buffer = new byte[10*1024*1024]; // 10M
System.gc();
}
/**
* 觸發 YoungGC 的時候,已經被回收了
*/
public void localvarGC2() {
byte[] buffer = new byte[10*1024*1024];
buffer = null; // 失去參照物件了
System.gc();
}
/**
* 不會被回收,因爲它還存放在區域性變數表索引爲 1 的槽中(Slot複用)
*/
public void localvarGC3() {
// 程式碼塊中的物件
{
byte[] buffer = new byte[10*1024*1024];
}
System.gc();
}
/**
* 會被回收,因爲它還存放在區域性變數表索引爲1的槽中,但是後面定義的value把這個槽給替換了
*/
public void localvarGC4() {
{
byte[] buffer = new byte[10*1024*1024];
}
// 多定義了一個變數
int value = 10;
System.gc();
}
/**
* localvarGC5中的陣列已經被回收
*/
public void localvarGC5() {
localvarGC1();
System.gc();
}
// main方法呼叫執行
public static void main(String[] args) {
LocalVarGC localVarGC = new LocalVarGC();
localVarGC.localvarGC3();
}
}
記憶體溢位 OOM
記憶體溢位相對於記憶體漏失來說,儘管更容易被理解,但是同樣的,記憶體溢位也是引發程式崩潰的罪魁禍首之一。
由於GC一直在發展,所有一般情況下,除非應用程式佔用的記憶體增長速度非常快,造成垃圾回收已經跟不上記憶體消耗的速度,否則不太容易出現OOM的情況。
大多數情況下,GC會進行各種年齡段的垃圾回收,實在不行了就放大招,來一次獨佔式的Full GC
操作,這時候會回收大量的記憶體,供應用程式繼續使用。
javadoc 中對 OutOfMemoryError
的解釋是:沒有空閒記憶體,並且垃圾收集器也無法提供更多記憶體。
首先說沒有空閒記憶體的情況:說明Java虛擬機器的堆記憶體不夠。原因有二:
Java虛擬機器的堆記憶體不夠。
-Xms
、-Xmx
來調整。程式碼中建立了大量大物件,並且長時間不能被垃圾收集器收集(存在被參照)
OutOfMemoryError
也非常多見,尤其是在執行時存在大量動態型別生成的場合;類似intern
字串快取佔用太多空間,也會導致OOM問題。對應的異常資訊,會標記出來和永久代相關:「java.lang.OutOfMemoryError:PermGen space"
。隨着元數據區的引入,方法區記憶體已經不再那麼窘迫,所以相應的 OOM 有所改觀,出現 OOM,異常資訊則變成了:「java.lang.OutofMemoryError: Metaspace"
。直接記憶體不足,也會導致 OOM。
這裏面隱含着一層意思是,在拋出OutOfMemoryError
之前,通常垃圾收集器會被觸發,盡其所能去清理出空間。
java.nio.BIts.reserveMemory()
方法中,我們能清楚的看到,System.gc()
會被呼叫,以清理空間。當然,也不是在任何情況下垃圾收集器都會被觸發的
OutOfMemoryError
。記憶體漏失
也稱作「儲存滲漏」。嚴格來說,只有物件不會再被程式用到了,但是GC又不能回收他們的情況,才叫記憶體漏失。
但實際情況很多時候一些不太好的實踐(或疏忽)會導致物件的生命週期變得很長甚至導致OOM,也可以叫做寬泛意義上的「記憶體漏失」。
儘管記憶體漏失並不會立刻引起程式崩潰,但是一旦發生記憶體漏失,程式中的可用記憶體就會被逐步蠶食,直至耗盡所有記憶體,最終出現OutOfMemory
異常,導致程式崩潰。
Java使用可達性分析演算法,最上面的數據不可達,就是需要被回收的。後期有一些物件不用了,按道理應該斷開參照,但是存在一些鏈沒有斷開,從而導致沒有辦法被回收。
舉個例子
單例模式
一些提供 close 的資源未關閉導致記憶體漏失
dataSourse.getConnection()
),網路連線(socket)和io
連線必須手動close,否則是不能被回收的。stop-the-world,簡稱STW,指的是GC事件發生過程中,會產生應用程式的停頓,停頓產生時整個應用程式執行緒都會被暫停,沒有任何響應,有點像卡死的感覺,這個停頓稱爲STW。
STW是JVM在後台自動發起和自動完成的。在使用者不可見的情況下,把使用者正常的工作執行緒全部停掉。
可達性分析演算法中==列舉根節點(GC Roots)==會導致所有Java執行執行緒停頓。
被STW中斷的應用程式執行緒會在完成GC之後恢復,頻繁中斷會讓使用者感覺像是網速不快造成電影卡帶一樣,所以我們需要減少STW的發生。
STW事件和採用哪款GC無關,所有的GC都有這個事件。
system.gc()
,因爲會導致stop-the-world的發生。併發
在操作系統中,是指一個時間段中有幾個程式都處於已啓動執行到執行完畢之間,且這幾個程式都是在同一個處理器上執行。
併發不是真正意義上的「同時進行」,只是CPU把一個時間段劃分成幾個時間片段(時間區間),然後在這幾個時間區間之間來回切換,由於CPU處理的速度非常快,只要時間間隔處理得當,即可讓使用者感覺是多個應用程式同時在進行。
也就是某一時刻仍然只有一個程式在執行
並行
當系統有一個以上CPU時,當一個CPU執行一個進程時,另一個CPU可以執行另一個進程,兩個進程互不搶佔CPU資源,可以同時進行,我們稱之爲並行(Parallel)。
其實決定並行的因素不是CPU的數量,而是CPU的核心數量,比如一個CPU多個核也可以並行。
適合科學計算,後臺處理等弱互動場景
併發和並行對比
併發,指的是多個事情,在同一時間段內同時發生了。
並行,指的是多個事情,在同一時間點上同時發生了。
併發的多個任務之間是互相搶佔資源的。並行的多個任務之間是不互相搶佔資源的。
只有在多CPU或者一個CPU多核的情況中,纔會發生並行;否則,看似同時發生的事情,其實都是併發執行的。
垃圾回收的並行與併發
併發和並行,在談論垃圾收集器的上下文語境中,它們可以解釋如下:
並行(Paralle1):指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍處於等待狀態。
序列(Serial)
安全點
程式執行時並非在所有地方都能停頓下來開始GC,只有在特定的位置才能 纔能停頓下來開始GC,這些位置稱爲「安全點(Safepoint)」。
Safe Point的選擇很重要
大部分指令的執行時間都非常短暫,通常會根據「是否具有讓程式長時間執行的特徵」爲標準。
方法呼叫
、回圈跳轉
和異常跳轉
等。如何在GC發生時,檢查所有執行緒都跑到最近的安全點停頓下來呢?
安全區域
Safepoint 機制 機製保證了程式執行時,在不太長的時間內就會遇到可進入GC的Safepoint。
但是,**程式「不執行」的時候呢?**例如執行緒處於 Sleep 狀態或 Blocked 狀態,這時候執行緒無法響應JVM的中斷請求,「走」到安全點去中斷掛起,JVM也不太可能等待執行緒被喚醒。對於這種情況,就需要安全區域(Safe Region)來解決。
安全區域是指在一段程式碼片段中,物件的參照關係不會發生變化,在這個區域中的任何位置開始GC都是安全的。
被擴充套件了的Safepoint
。執行流程:
概述
我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體中;如果記憶體空間在進行垃圾收集後還是很緊張,則可以拋棄這些物件。
【既偏門又非常高頻的面試題】強參照、軟參照、弱參照、虛參照有什麼區別?具體使用場景是什麼?
在JDK1.2版之後,Java對參照的概念進行了擴充,將參照分爲:
這4種參照強度依次逐漸減弱。除強參照外,其他3種參照均可以在java.lang.ref
包中找到它們的身影。如下圖,顯示了這3種參照型別對應的類(FinalReference爲終端子參照
),開發人員可以在應用程式中直接使用它們。
Reference子類中只有終端子參照是包內可見的,其他3種參照型別均爲public
,可以在應用程式中直接使用
Object obj=new Object()
」這種參照關係。無論任何情況下,只要強參照關係還存在,垃圾收集器就永遠不會回收掉被參照的物件。(實際開發中,絕大多數都是強參照)強參照: 不回收
在Java程式中,最常見的參照型別是強參照(普通系統99%以上都是強參照),也就是我們最常見的普通物件參照,也是預設的參照型別。
當在Java語言中使用new操作符
建立一個新的物件,並將其賦值給一個變數的時候,這個變數就成爲指向該物件的一個強參照。
強參照的物件是可觸及的,垃圾收集器就永遠不會回收掉被參照的物件。
對於一個普通的物件,如果沒有其他的參照關係,只要超過了參照的作用域或者顯式地將相應(強)參照賦值爲 null ,就是可以當做垃圾被收集了,當然具體回收時機還是要看垃圾收集策略。
相對的,軟參照、弱參照和虛參照的物件是軟可觸及、弱可觸及和虛可觸及的,在一定條件下,都是可以被回收的。
所以,強參照是造成Java記憶體漏失的主要原因之一。
舉個例子
StringBuffer str = new StringBuffer("hello mogublog");
區域性變數 str 指向 StringBuffer 範例所在堆空間,通過 str 可以操作該範例,那麼 str 就是 StringBuffer 範例的強參照對應記憶體結構:
如果此時,再執行一個賦值語句
StringBuffer str = new StringBuffer("hello mogublog");
StringBuffer str1 = str;
對應的記憶體空間爲
那麼我們將 str = null;
則 原來堆中的物件也不會被回收,因爲還有其它物件指向該區域
本例中的兩個參照,都是強參照,強參照具備以下特點:
軟參照是用來描述一些還有用,但非必需的物件。
只被軟參照關聯着的物件,在系統將要發生記憶體溢位異常前,會把這些物件列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的記憶體,纔會拋出記憶體溢位異常,但是不是軟參照導致的,因爲軟參照都回收完了。
軟參照通常用來實現記憶體敏感的快取。比如:快取記憶體就有用到軟參照。
垃圾回收器在某個時刻決定回收軟可達的物件的時候,會清理軟參照,並可選地把參照存放到一個參照佇列(Reference Queue
)。
類似弱參照,只不過Java虛擬機器會盡量讓軟參照的存活時間長一些,迫不得已才清理。
一句話概括:
在JDK1.2版之後提供了java.lang.ref.SoftReference
類來實現軟參照
// 宣告強參照
Object obj = new Object();
// 建立一個軟參照
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; //銷燬強參照,這是必須的,不然會存在強參照和軟參照
弱參照也是用來描述那些非必需物件,只被弱參照關聯的物件只能生存到下一次垃圾收集發生爲止。
但是,由於垃圾回收器的執行緒通常優先順序很低,因此,並不一定能很快地發現持有弱參照的物件。
弱參照和軟參照一樣,在構造弱參照時,也可以指定一個參照佇列,當弱參照物件被回收時,就會加入指定的參照佇列,通過這個佇列可以跟蹤物件的回收情況。
軟參照、弱參照都非常適合來儲存那些可有可無的快取數據。
在 JDK1.2 版之後提供了java.lang.ref.WeakReference
類來實現弱參照
// 宣告強參照
Object obj = new Object();
// 建立一個弱參照
WeakReference<Object> sf = new WeakReference<>(obj);
obj = null; //銷燬強參照,這是必須的,不然會存在強參照和弱參照
弱參照物件與軟參照物件的最大不同就在於:
面試題:你開發中使用過WeakHashMap
嗎?
WeakHashMap
用來儲存圖片資訊,可以在記憶體不足的時候,及時回收,避免了OOM虛參照也稱爲「幽靈參照」或者「幻影參照」,是所有參照型別中最弱的一個
一個物件是否有虛參照的存在,完全不會決定物件的生命週期。如果一個物件僅持有虛參照,那麼它和沒有參照幾乎是一樣的,隨時都可能被垃圾回收器回收。
它不能單獨使用,也無法通過虛參照來獲取被參照的物件。
get()
方法取得物件時,總是null
爲一個物件設定虛參照關聯的唯一目的在於跟蹤垃圾回收過程。
虛參照必須和參照佇列一起使用。虛參照在建立時必須提供一個參照佇列作爲參數。
當垃圾回收器準備回收一個物件時,如果發現它還有虛參照,就會在回收物件後,將這個虛參照加入參照佇列,以通知應用程式物件的回收情況。
由於虛參照可以跟蹤物件的回收時間,因此,也可以將一些資源釋放操作放置在虛參照中執行和記錄。
在JDK1.2版之後提供了PhantomReference
類來實現虛參照。
// 宣告強參照
Object obj = new Object();
// 宣告參照佇列
ReferenceQueue phantomQueue = new ReferenceQueue();
// 宣告虛參照(還需要傳入參照佇列)
PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue);
obj = null;
舉個例子
我們使用一個案例,來結合虛參照,參照佇列,finalize
進行講解
public class PhantomReferenceTest {
// 當前類物件的宣告
public static PhantomReferenceTest obj;
// 參照佇列
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;
@Override
// finalize 方法只能被呼叫一次
protected void finalize() throws Throwable {
super.finalize();
System.out.println("呼叫當前類的finalize方法");
obj = this;
}
public static void main(String[] args) {
// 設定一個執行緒操作參照佇列
Thread thread = new Thread(() -> {
// 一直回圈
while(true) {
// 一旦將 obj 物件回收,就會將此虛參照存放到參照佇列中
if (phantomQueue != null) {
PhantomReference<PhantomReferenceTest> objt = null;
try {
// 取出最近的參照
objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
} catch (Exception e) {
e.getStackTrace();
}
// 不爲空就說明可以取出來
if (objt != null) {
System.out.println("追蹤垃圾回收過程:PhantomReferenceTest範例被GC了");
}
}
}
}, "t1");
// 前面設定爲死回圈,這裏要設爲守護執行緒以便終止:當程式中沒有非守護執行緒時,守護執行緒結束
thread.setDaemon(true);
thread.start();
phantomQueue = new ReferenceQueue<>();
obj = new PhantomReferenceTest();
// 構造了PhantomReferenceTest物件的虛參照,並指定了參照佇列
PhantomReference<PhantomReferenceTest> phantomReference = new PhantomReference<>(obj, phantomQueue);
try {
System.out.println(phantomReference.get());
// 去除強參照
obj = null;
// 第一次進行GC,由於物件可復活,GC無法回收該物件
System.out.println("第一次GC操作");
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 不是 null");
}
System.out.println("第二次GC操作");
obj = null;
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 不是 null");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
}
}
}
// 最後執行結果
null
第一次GC操作
呼叫當前類的finalize方法
obj 不是 null
第二次GC操作
追蹤垃圾回收過程:PhantomReferenceTest範例被GC了
obj 是 null
從上述執行結果我們知道,第一次嘗試獲取虛參照的值,發現無法獲取的,這是因爲虛參照是無法直接獲取物件的值,然後進行第一次 GC,因爲會呼叫 finalize 方法,將物件復活了,所以物件沒有被回收;
但是呼叫第二次 GC 操作的時候,因爲 finalize 方法只能執行一次,所以就觸發了GC操作,將物件回收了,同時將會觸發第二個操作就是 將回收的值存入到參照佇列中。
它用於實現物件的finalize()
方法,也可以稱爲終端子參照
無需手動編碼,其內部配合參照佇列使用
在GC時,終端子參照入隊。由Finalizer執行緒
通過終端子參照找到被參照物件呼叫它的finalize()
方法,第二次GC時纔回收被參照的物件