趣談GC技術,解密垃圾回收的玄學理論(一)

大多數程序員在平常開發中經常會碰到GC的問題:OOM異常、GC停頓等,這些異常直接致使糟糕的用戶體驗,若是不能獲得及時處理,還會嚴重影響應用程序的性能。本系列從GC的基礎入手,逐步幫助讀者熟悉GC各類技術及問題根源。c++

GC的由來

想當初,盤古開天闢地......git

好吧,扯遠了,這也不是仙俠小說...程序員

GC究竟是怎麼來的呢?這個問題要從C語言聊起, 你們都知道, C/C++語言在編寫程序的時候, 須要碼神們本身管理內存, 直觀的說就是使用內存的時候要malloc,以後這段內存會一直保留給程序進程,直到程序顯式的調用free纔會得以釋放。github

一個例子引起的問題

//第 0 步: char* aMem;
//第 1 步:
aMem = (char*) malloc(sizeof(char) * 1024);
//第 2 步:
strcpy(aMem, "I am a bunch of memory");
//第 3 步:
free(aMem);
複製代碼

看到沒有,就3步, 和把大象放進冰箱裏同樣:算法

  1. 打開冰箱門, 看看有沒有空:用malloc申請空間。
  2. 把大象裝進冰箱裏:strcpy把字符串拷貝到空間裏。
  3. 關上冰箱門:不用的時候, free還回內存 (嚴謹的說,這裏應該是先把大象請出來, 騰出冰箱的空間,以備下一次可以再裝大象)。

是否是很簡單?須要的時候malloc申請內存,用完以後free釋放內存。但實際上就這麼簡單的3行代碼,可能會引起很多問題, 讓咱們step by step的看一下:編程

問題1:若是上面第0步也變成 aMem = (char *) malloc(sizeof(char)), 這裏直接執行line 1, 有什麼問題?bash

答: 內存泄漏,全部malloc申請的內存,必需要free釋放以後才能再次被分配使用, 若是不free,那麼程序會一直佔用這段內存,直到整個進程結束。雖然程序邏輯執行沒有問題, 可是若是內存泄漏過多,極可能在後面的程序中出現內存不足的問題,產生各類未知錯誤。可是要注意的是,若是第0步用malloc分配了空間給aMem,(假設地址是aMem=0x1234),第1步這裏的malloc一樣分配了空間給aMem,(假設此次malloc返回地址是aMem=0x5678), 也就是說, 0x1234指向的那段空間一直被佔用,而後你的程序裏卻沒法經過有效手段得到這個地址,也就沒有辦法再free它了。(由於aMem被修改爲0x5678了)因此除非程序退出,否則咱們再也沒有機會釋放這個0x1234指向的空間了。微信

問題2:這裏實際上申請了1024個byte的空間, 若是系統沒有這麼多空閒空間,有什麼問題?數據結構

答:直接報錯, 這個時候要調查一下是否是存在內存泄漏。運維

問題3:若是copy的字符串不是「I am a bunch of memory」, 而是「1,2,3,4 ... 1025" 會怎麼樣?

答:因爲strcpy不進行越界檢查,這裏第一步malloc出來的1024個字符, 卻裝載了1026個字符(包括'\0'), 也就是說內存被污染了, 這種狀況輕的會致使內存溢出,若是被別有用心的人利用了, 可能就把你的程序全部信息dump出來...好比你的小祕密...

問題 4:若是以前內存沒有申請成功,第3步free會有什麼問題?

答:出錯,若是malloc以前失敗了,其實就是第二步出錯了。假設沒有第二步, malloc失敗以後,調用free程序會直接crash。

問題 5:若是這裏調用兩次free會怎麼樣?

答: 一樣會出錯, 兩次free會致使未知錯誤、或程序crash。

問題 6:若是這裏free以後, aMem裏面存的是什麼值?

答:free不會修改aMem的值,若是malloc以前返回0x1234給aMem,那麼這裏free以後,aMEM仍是0x1234。試想一下,若是後面還用aMem訪問0x1234會有什麼問題?

GC的意義

有人可能會說:上面6個問題徹底能夠避免, 只要我能保證malloc和free用的對就行啦。若是現實真的這麼美好,那就萬事大吉了。惋惜現實狀況是更爲複雜的程序, 好比1000行的代碼裏存在if...else...、for /while循環就會容易出現上面的問題。並且內存泄漏一般埋伏在你不知道的地方,慢慢積累,直到有一天產品的業務量達到必定程度後,服務進程就會忽然崩潰。更可怕的是咱們每每缺乏有效的分析手段(或者高級的在線調試手段)來定位內存到底在哪裏泄露了。

因此除了嚴格執行編程規範,還有別的辦法能夠減小Memory leak嗎?一些大牛們想到了一個辦法:程序員只負責分配和使用內存,由計算機負責識別須要free釋放的內存,而且自動把這些不用的內存free掉。這樣程序員只要malloc/new,不須要free/delete。 若是計算機能識別而且回收不用的內存(垃圾),那麼一方面減小了代碼量,另外一方面也會避免內存泄漏的可能性,豈不美哉?這就是Automatic Memory Management概念的由來,也就是GC的由來。

如今你們應該明白GC的意義了吧,主要包括下面兩方面:

  • 一方面減小開發者的代碼成本。開發者無需關心內存如何回收,能夠減小思考程序內存使用邏輯的時間。
  • 另外一方面保證程序的正確性。沒有了開發者的介入,減小了各類人爲產生的內存泄漏和誤free等問題,計算機更能夠保證程序的正確性。程序就會更健壯, 也減小了運維人員半夜爬起來排障的機會。

GC算法

下面咱們來介紹GC裏面各類牛閃閃的算法:Reference Counting,Mark Sweep,Concurrent Mark Sweep,Generational Concurrent Mark Sweep等,這些算法其實可粗略的分爲兩大類:

  • 一種是找到垃圾,回收之。

  • 另外一種是找到不是垃圾的對象,保留之。剩下的就做爲垃圾對象,將它們回收掉。

由此而來,目前GC算法主要分爲兩類:Reference Counting(引用計數) 與 Object Tracing (對象追蹤)。今天咱們主要談談Reference Counting。

Reference Counting

引用計數(Reference Counting)就是一種發現垃圾對象,並回收的算法。廣義上講,垃圾對象是指再也不被程序訪問的Object,具體細分的話,「再也不被程序訪問的對象」實際上還要分紅兩類。來來來,讓咱們對Object進行一次靈魂拷問:你是什麼樣的垃圾?

再也不被程序訪問的Object,具體能夠細分爲兩大類:

1. 對象被還能被訪問到, 可是程序之後再也不用到它了。

舉個例子:

public class A {

private void method() {
   System.out.println("I am a method");
}

public static void main (String args[]) {
    A a1 = new A();
    A a2 = new A();
    a1.method();
    // The following code has noting to do with a2
    ....
    .... // a2.method();
}
}
複製代碼

這個例子裏面,a2還能被訪問到,可是程序後面也不會用到它了。從程序邏輯角度,這個a2指向的對象就是垃圾,可是從計算機的角度,這個垃圾「不夠垃圾」。由於若是程序後面忽然後悔了,想用a2這個對象了 (好比code裏面最後一行註釋), 程序仍是能夠正常訪問到這個對象的。 因此從計算機的角度,a2所指向的對象不是垃圾。

看到這裏,你們可能會疑問:編碼時已經註釋了a2.method(),那麼程序確定不會運行這段代碼, 這樣的話,a2引用的對象仍是垃圾,爲何從計算機的角度來說a2對象卻不是垃圾?

實際上,咱們有不少語言是支持動態代碼修改的,好比Java的Bytecode Instrument,徹底能夠在運行時插入a2.method()的字節碼,因此仍是能夠訪問的。另外,這段代碼的邏輯就是a2在函數棧上,a2引用的對象在堆裏,因此只要a2一直引用這個對象,這個對象對程序來講可見的,計算機不會認爲它是垃圾,因此這種垃圾是不可回收物。

計算機: 我不要你以爲,我要我以爲!

2. 對象已經不能被訪問了, 程序想用也沒有辦法找到它。

仍是舉個例子:

public class A {

private void method() {
   System.out.println("I am a method");
}

public static void main (String args[]) {
    A a1 = new A();
    A a2 = new A();
    a1.method();
    // The following code has noting to do with a2
    ....
    ....
    a2 = a1;
}

}
複製代碼

和前面例子幾乎一致,只是最後咱們把a1賦值給a2。這裏a2的值就變了,也就是說a2指向的對象變成了a1指向的對象,a2原來的對象就沒有別的東西引用它了,程序在此以後沒有任何辦法能夠訪問到它。因此它就變成了真正的垃圾。請看下圖:

因此咱們一般所講的垃圾回收技術,也主要用來處理這種對象。那麼問題來了, 如何找到這種對象呢? 按照剛纔的思路,沒有再被任何東西引用的對象,就是可回收垃圾,由此得出一個簡單直觀的回收算法:引用計數。

引用計數的概念, Wikipedia的解釋:

In computer science, reference counting is a programming technique of storing the number of references, pointers, or handles to a resource, such as an object, a block of memory, disk space, and others.

簡單說來就是如下幾點:

  • 全部對象都存在一個記錄引用計數的計數器,可能在對象裏面,也可能單獨的數據結構,總之是一種記錄數據的地方。
  • 全部對象在建立的時候(好比new), 引用計數爲1。
  • 當有別的變量或者對象對其進行引用,引用計數+1。
  • 當有別的對象進行引用變動時,原先被引用的對象引用計數-1。
  • 當引用計數爲0的時候,回收對象

看不懂?不要緊,上代碼:

public class A {

private void method() {
   System.out.println("I am a method");
}

public static void main (String args[]) {
    // 假設每一個對象有一個引用計數變量rc
    A a1 = new A(); // 在堆上建立對象A, A.rc++;
    A a2 = new A(); // 在堆上建立對象A1,A1.rc++;
    a2 = a1; // A1.rc--,if ( A1.rc == 0 ) { 回收A1 }, A.rc++;
} // 函數退出:
   // a1銷燬, A.rc--;
   // a2銷燬, A.rc--;
   // if ( A.rc == 0 ) { 回收A }
}
複製代碼

還沒看懂?上圖:

讀到這裏,你應該就明白Reference Counting的核心原理了。看起來很簡單,只須要一個計數器和一些加減法就能夠進行內存回收了。可是,Reference Counting存在一個比較大的問題,也是我我的認爲目前Reference Counting算法研究的核心問題:循環引用 。

循環引用

請看下面的僞代碼:

class Parent {
        Child child;
}

class Child {
       Parent parent;
}

public class Main {
     public static void main (String[] args) {
            Parent p = new Parent();
            Child c = new Child();
            p.child = c;
            c.parent = p;
     }
}
複製代碼

圖就是這樣的:

這個互相引用產生了環狀引用, 引用計數器一致保持在1, Object沒法被回收,形成了內存泄漏。可能你會問:不就是一個環,兩個Object嗎?這一點泄漏不是大問題,誰寫代碼不泄漏點內存。可是遇到下面這種狀況呢?

單單一個環,帶了一個長長的小尾巴,致使整個鏈上的全部對象沒法回收,Heap內存逐漸失控,最終出現OOM異常,系統崩,代碼卒。那麼如何處理這個循環引用的問題呢?

破環之道

就如前面所說, Reference Counting目前主要的研究課題都在破壞環形引用上。在我看來,目前主要是如下兩種模式:

1. 左邊跟我一塊兒畫條龍: 把問題拋給程序員

就是在程序設計語言層面提供一些辦法,能夠是API、註解、新的關鍵字等等,而後把破環的能力交給程序員。

好比Swift 提供的weak/unbound關鍵字,包括C++的weak_ptr,相對於strong或者默認的引用,weak在進行引用時不作引用計數的增減,而是判斷所引用的對象是否已經被回收,這樣全部構成環的引用都用weak來作引用,這樣在計數器中,構成環的部分就不計數了。這樣作的優缺點是:

優勢:計算機不須要考慮環狀問題,只要按照計數器進行對象回收就能夠了。

缺點:程序員的意識直接決定了內存會不會溢出。若是程序員不使用weak關鍵字,那麼有可能形成上述的內存泄漏。

2. 右邊再劃一道彩虹:把問題拋給計算機

這種辦法就是讓計算機本身找到方法去檢測循環引用,一種常見的方法是配合Tracing GC,找到沒有被環之外的對象引用的環,把它們回收掉。關於Tracing GC 我們放到後續討論。你們這裏只要理解,爲了幫助引用計數處理環形引用,計算機必須在適當的時候觸發一個單獨算法來找到環,而後再作處理。這樣作的優缺點:

優勢:程序員徹底不須要介入,只需專一本身的業務實現。weak pointer、strong pointer分不清楚也無所謂。

缺點:須要加入新的環形引用檢測機制,算法複雜度,對於程序的影響都是問題。

說了這麼多,我們總結一下Reference Counting的優缺點

優勢: Reference Counting算法設計簡單,只須要在引用發生變化時進行計數就能夠決定Object是否變成垃圾。而且能夠隨着對象的引用計數歸零作到實時回收對象。因此Reference Counting是沒有單獨的GC階段的,程序不會出現所謂的GC stop the world 階段。

缺點: 程序在運行過程當中會不斷的生成對象,給對象成員變量賦值,改變對象變量等等。這全部的操做都須要引入一次++和--,程序性能必然受影響。(目前一種優化方法就是利用編譯器優化技術,減小Reference Counting引入的計數問題,但也沒法徹底避免)。

處理環形引用問題,不管是交給程序員處理,仍是交給計算機處理,都增長了程序的複雜度,還有可能會引入GC stop the world phase,這些都會在必定程度上影響程序的性能和吞吐量。

好啦!今天就聊到這裏吧,預知後事如何,且聽下回分解!下次給你們分享另外一類GC算法:Tracing GC,這也是目前應用比較普遍的一類算法。不管是Javascript的V八、Android的ART、Java的Hotspot、OpenJ9,仍是Golang的GC,都採用了Tracing GC算法。

做者介紹

臧琳,騰訊雲中間件JVM工程師,主要負責騰訊雲中間件JDK定製化開發及優化工做。專一於JVM中內存管理、Runtime運行時以及執行引擎在雲業務中的性能分析及優化。

開源項目介紹

騰訊開源JDK項目 Tencent Kona-8,咱們致力於從JDK的層面解決雲上的痛點,提升Java的業務能力。開源連接: github.com/Tencent/Ten…

歡迎掃碼關注咱們的微信公衆號,期待與你相遇~

相關文章
相關標籤/搜索