這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
GC記憶體回收機制: 淺析與理解
對記憶體回收進行分析前,我們先來瞭解一些基本概念
基本概念
記憶體管理:記憶體管理對於程式設計語言至關重要。彙編允許你操作所有東西,或者說要求你必須全權處理所有細節更合適。C 語言中雖然標準庫函數提供一些記憶體管理支援,但是對於之前調用 malloc 申請的記憶體,還是依賴於你親自 free 掉。從C++、Python、Swift 和 Java 開始,才在不同程度上支援記憶體管理。
記憶體壓縮:對記憶體片段進行壓縮。(和win10的那個“記憶體壓縮”不太一樣啦)
win10記憶體壓縮:實體記憶體已經見底,將一部分不常使用的記憶體資料打包壓縮起來,等到有程式需要訪問那些資料的時候,再解壓縮出來。
引用與指標:
引用被建立的同時必須被初始化(指標則可以在任何時候被初始化)。
不能有NULL 引用,引用必須與合法的儲存單元關聯(指標則可以是NULL)。
一旦引用被初始化,就不能改變引用的關係(指標則可以隨時改變所指的對象)。
引用只是某塊記憶體的別名。
實際上“引用”可以做的任何事情“指標”也都能夠做,為什麼還要“引用” 這東西?
答案是“用適當的工具做恰如其分的工作”。比如說,某人需要一份證明,本來在檔案上蓋 上公章的印子就行了,如果把取公章的鑰匙交給他,那麼他就獲得了不該有的權利。(什麼情況下,就用什麼對策)
為什麼還要說“只有指標,沒有引用是一個重要改變?”?
答案是雖然引用在某些情況下好用,但他也會導致致命錯誤。如下:
char *pc = 0; // 設定指標為空白值 char& rc = *pc; // 讓引用指向空值
這是非常有害的,毫無疑問。結果將是不確定的(編譯器能產生一些輸出,導致任何事情都有可能發生),應該躲開寫出這樣代碼的人除非他們同意改正錯誤。如果你擔心這樣的代碼會出現在你的軟體裡,那麼你最好完全避免使用引用,要不然就去讓更優秀的程式員去做。
最後上附圖,協助理解
堆(heap)和棧(stack)
平常說的“堆棧”其實是棧。
棧,就是那些由編譯器在需要的時候分配,在不需要的時候自動清除的變數的儲存區。裡面的變數通常是局部變數、函數參數等。
堆,就是那些由new分配的記憶體塊,他們的釋放編譯器不去管,由我們的應用程式去控 制,一般一個new就要對應一個delete。如果程式員沒有釋放掉,那麼在程式結束後,作業系統會自動回收。
程式的棧結構
程式的地址空間布局: 程式運行靠四個東西:代碼、棧、堆、資料區段。程式碼片段主要存放的就是可執行檔(通常可執行檔內,含有以二進位編碼的微處理器指令,也因此可執行檔有時稱為二進位檔案)中的代碼;資料區段存放的就是程式中全域變數和靜態變數;堆中是程式的動態記憶體地區,當程式使用malloc或new得到的記憶體是來自堆的;棧中維護的是函數調用的上下文,離開了棧就不可能實現函數的調用。
棧幀: 也叫活動記錄,儲存的是一個函數調用所需要維護的所有資訊。如下:
1.函數的返回地址和參數
2.臨時變數:包括函數的非靜態局部變數以及編譯器自動產生的其它臨時變數
3.儲存的上下文:包括在函數調用前後需要儲存不變的寄存器值
就是它,先
]
1.返回地址:一個main函數中斷執行的執行點.
2.ebp:指向函式活動記錄的一個固定位置,ebp又被稱為幀指標.固定位置是,這樣在函數返回的時候,ebp就可以通過這個恢複到調用前的值。
3.esp始終指向棧頂,因此隨著函數的執行,它總是變化的。
4.入棧順序:先壓此次調用函數參數入棧,接著是main函數返回地址,然後是ebp等寄存器。
Link:C程式的函數棧作用機理(這個講得好,有執行個體,所以不再熬述)
這裡我們對比瞭解不同的 “找到需要標記的對象”的方法
可回收對象的判定
給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時, 計數器值就減1;任何時刻計數器為0的對象就是不可能再被使用的。如所示:
優點:引用計數收集器可以很快地執行,交織在程式的運行之中。這個特性對於程式不能被長時間打斷的即時環境很有利。缺點:很難處理循環參考,比中相互引用的兩個對象則無法釋放。應用:Python 和 Swift 採用引用計數方案。
從GC Roots(每種具體實現對GC Roots有不同的定義)作為起點,向下搜尋它們引用的對象,可以產生一棵引用樹,樹的節點視為可達對象,反之視為不可達。如所示:
GC Roots對象:
虛擬機器棧(幀棧中的本地變數表)中引用的對象。方法區中靜態屬性引用的對象。方法區中常量引用的對象。本地方法棧中JNI引用的對象。本地方法棧則為虛擬機器所使用的Native方法服務。Native方法是指本地方法,當在方法中調用一些不是由java語言寫的代碼或者在方法中用java語言直接操縱電腦硬體。JNI:Java Native Interface縮寫,允許Java代碼和其他語言寫的代碼進行互動。
上述,關於root地區的詳細解釋參考這裡
這裡我們介紹幾種不同的 “標記對象”的方法
可回收對象的標記
非侵入式標記位定義
既然記憶體回收演算法要求給對象加上記憶體回收的標記,顯然是需要有標記位的。一般的做法
會將對象結構體中加上一個標記域,一些最佳化的做法會利用對象指標的低位進行標記,這
都只是些奇技淫巧罷了。Go沒有這麼做,它的對象和C的結構體對象完全一致,使用的是
非侵入式的標記位。
具體實現
堆地區對應了一個標記位元影像地區,堆中每個字(不是byte,而是word)都會在標記位地區
中有對應的標記位。每個機器字(32位或64位)會對應4位的標記位。因此,64位系統中
相當於每個標記位元影像的位元組對應16個堆中的位元組。
雖然是一個堆位元組對應4位標記位,但標記位元影像地區的記憶體布局並不是按4位一組,而是
16個堆位元組為一組,將它們的標記位資訊打包儲存的。每組64位的標記位元影像從上到下依
次包括:
16位的 特殊位 標記位16位的 記憶體回收 標記位16位的 無指標/塊邊界 的標記位16位的 已指派 標記位
這樣設計使得對一個類型的相應的位進行遍曆很容易。
前面提到堆地區和堆地址的標記位元影像地區是分開儲存的,其實它們是以
mheap.arena_start地址為邊界,向上是實際使用的堆地址空間,向下則是標記位元影像區
域。以64位系統為例,計算堆中某個地址的標記位的公式如下:
位移 = 地址 - mheap.arena_start標記位地址 = mheap.arena_start - 位移/16 - 1移位 = 位移 % 16標記位 = *標記位地址 >> 移位
然後就可以通過 (標記位 & 記憶體回收標記位),(標記位 & 分配位),等來測試相應的位。
(也就是說,本來64位是一個字,需要4位標記位。但是,為了與字長相對,16個標記位
放一起(剛好一個字長)一起表示16個字。並且每類標記位都放在一起
AA..AABB...BB)
為什麼要判斷哪些是資料,哪些是指標?
假設堆中有一個long的變數,它的值是8860225560。但是我們不知道它的類型是
long,所以在進行記憶體回收時會把個當作指標處理,這個指標引用到了0x2101c5018位
置。假設0x2101c5018碰巧有某個對象,那麼這個對象就無法被釋放了,即使實際上已
經沒任何地方使用它。
由於沒有類型資訊,我們並不知道這個結構體成員不包含指標,因此我們只能對結構體
的每個位元組遞迴地標記下去,這顯然會浪費很多時間。
(能不能清除 變成了機率事件)。
垃圾收集器(CMS收集器為例)
幾個階段:
初始標記
並發標記
最終標記
篩選回收
初始標記僅僅是標記一下GC Roots能直接關聯到的對象,速度很快,並發標記就是進行
GC Roots Trancing的過程,而重新標記階段則是為了修正並發標記期間因使用者程式繼
續運行而導致標記產生變動那一部分對象的標記記錄,這個階段的停頓時間比初始標記稍
長一些,但遠比並發標記時間短。
stop the world
因為記憶體回收的時候,需要整個的引用狀態保持不變,否則判定是垃圾,等我稍後回
收的時候它又被引用了,這就全亂套了。所以,GC的時候,其他所有的程式執行處於暫停
狀態,卡住了。
這個概念提前引入,在這裡進行對比,效果會更好些。與標記階段對比,stop the world發生在回收階段。
這裡我們介紹幾種不同的記憶體回收演算法
記憶體回收演算法
標記-清除演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的對象,清除階段就是回收被標記的對象所佔用的空間。
優點是簡單,容易實現。
缺點是容易產生記憶體片段,片段太多可能會導致後續過程中需要為大對象分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。(因為沒有對不同生命週期的對象採用不同演算法,所以片段多,記憶體容易滿,gc頻率高,耗時,看了後面的方法就明白了)
根據對象存活的生命週期將記憶體劃分為若干個不同的地區。不同地區採用不同演算法(複製演算法,標記整理演算法),這就是分代回收演算法。
一般情況下將堆區劃分為老年代(Old Generation)和新生代(Young Generation),老年代的特點是每次垃圾收集時只有少量對象需要被回收,而新生代的特點是每次記憶體回收時都有大量的對象需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。
1.新生代回收
新生代使用Scavenge演算法進行回收。在Scavenge演算法的實現中,主要採用了Cheney演算法。
Cheney演算法是一種採用複製的方式實現的記憶體回收演算法。它將記憶體一分為二,每一部分空間稱為semispace。在這兩個semispace中,一個處於使用狀態,另一個處於閑置狀態。簡而言之,就是通過將存活對象在兩個semispace空間之間進行複製。複製過程採用的是BFS(廣度優先遍曆)的思想,從根對象出發,廣度優先遍曆所有能到達的對象優點:時間效率上表現優異(犧牲空間換取時間)缺點:只能使用堆記憶體的一半
新生代的空間劃分比例為什麼是比例為8:1:1(不是按照上面演算法中說的1:1)?
新建立的對象都是放在Eden空間,這是很頻繁的,尤其是大量的局部變數產生的臨時對象,這些對象絕大部分都應該馬上被回收,能存活下來被轉移到survivor空間的往往不多。所以,設定較大的Eden空間和較小的Survivor空間是合理的,大大提高了記憶體的使用率,緩解了Copying演算法的缺點。8:1:1就挺好的,當然這個比例是可以調整的,包括上面的新生代和老年代的1:2的比例也是可以調整的。
Eden空間和兩塊Survivor空間的工作流程是怎樣的?
具體的執行過程是怎樣的?
假設有類似如下的引用情況:
+----- A對象 |根對象----+----- B對象 ------ E對象 | +----- C對象 ----+---- F對象 | +---- G對象 ----- H對象 D對象
在執行Scavenge之前,From區長這幅模樣:
+---+---+---+---+---+---+---+---+--------+| A | B | C | D | E | F | G | H | |+---+---+---+---+---+---+---+---+--------+
那麼首先將根對象能到達的ABC對象複製到To區,於是乎To區就變成了這個樣子:
allocationPtr ↓ +---+---+---+----------------------------+| A | B | C | |+---+---+---+----------------------------+ ↑scanPtr
接下來進入迴圈,掃描scanPtr所指的A對象,發現其沒有指標,於是乎scanPtr移動,變成如下這樣
allocationPtr ↓ +---+---+---+----------------------------+| A | B | C | |+---+---+---+----------------------------+ ↑ scanPtr
接下來掃描B對象,發現其有指向E對象的指標,且E對象在From區,那麼我們需要將E對象複製到allocationPtr所指的地方並移動allocationPtr指標:
allocationPtr ↓ +---+---+---+---+------------------------+| A | B | C | E | |+---+---+---+---+------------------------+ ↑ scanPtr
中間過程省略,具體參考[新生代的記憶體回收具體的執行過程][3]From區和To區在複製完成後的結果:
//From區+---+---+---+---+---+---+---+---+--------+| A | B | C | D | E | F | G | H | |+---+---+---+---+---+---+---+---+--------+//To區+---+---+---+---+---+---+---+------------+| A | B | C | E | F | G | H | |+---+---+---+---+---+---+---+------------+
最終當scanPtr和allocationPtr重合,說明複製結束。
注意:如果指向老生代我們就不必考慮它了。(通過寫屏障)
對象何時晉陞?
1.當一個對象經過多次新生代的清理依舊倖存。2.如果To空間已經被使用了超過25%(後面還要進來許多新對象,不敢佔用太多)3.大對象(其實這部分,包括次數,比例等,是視情況設定的。)
2.老生代回收
Mark-Sweep(標記清除)
標記清除分為標記和清除兩個階段。主要是標記清除只清除死亡對象,而死亡對象在老生代中佔用的比例很小,所以效率較高。
Mark-Compact(標記整理)
標記整理正是為瞭解決標記清除所帶來的記憶體片段的問題。大體過程就是 雙端隊列標記黑(鄰接對象已經全部處理),白(待釋放垃圾),灰(鄰接對象尚未全部處理)三種對象. 標記演算法的核心就是深度優先搜尋.
1.觸發GC(何時發生記憶體回收?)
一般都是記憶體滿了就回收,下面列舉幾個常見原因:GC_FOR_MALLOC: 表示是在堆上指派至時記憶體不足觸發的GC。GC_CONCURRENT: 當我們應用程式的堆記憶體達到一定量,或者可以理解為快要滿的時候,系統會自動觸發GC操作來釋放記憶體。GC_EXPLICIT: 表示是應用程式調用System.gc、VMRuntime.gc介面或者收到SIGUSR1訊號時觸發的GC。GC_BEFORE_OOM: 表示是在準備拋OOM異常之前進行的最後努力而觸發的GC。
2.寫屏障(一個老年代的對象需要引用年輕代的對象,該怎麼辦?)
如果新生代中的一個對象只有一個指向它的指標,而這個指標在老生代中,我們如何判斷這個新生代的對象是否存活?為瞭解決這個問題,需要建立一個列表用來記錄所有老生代對象指向新生代對象的情況。每當有老生代對象指向新生代對象的時候,我們就記錄下來。當記憶體回收發生在年輕代時,只需對這張表進行搜尋以確定是否需要進行記憶體回收,而不是檢查老年代中的所有對象引用。
3.深度、廣度優先搜尋(為什麼新生代用廣度搜尋,老生代用深度搜尋)
深度優先DFS一般採用遞迴方式實現,處理tracing的時候,可能會導致棧空間溢出,所以一般採用廣度優先來實現tracing(遞迴情況下容易爆棧)。廣度優先的拷貝順序使得GC後對象的空間局部性(memory locality)變差(相關變數散開了)。廣度優先搜尋法一般無回溯操作,即入棧和出棧的操作,所以運行速度比深度優先搜尋演算法法要快些。深度優先搜尋法佔記憶體少但速度較慢,廣度優先搜尋演算法占記憶體多但速度較快。結合深搜和廣搜的實現,以及新生代移動數量小,老生代數量大的情況,我們可以得到瞭解答。