通常來講,程序使用內存的方式遵循先向操做系統申請一塊內存,使用內存,使用完畢以後釋放內存歸還給操做系統。然而在傳統的C/C++等要求顯式釋放內存的編程語言中,記得在合適的時候釋放內存是一個頗有難度的工做,所以Java等編程語言都提供了基於垃圾回收算法的內存管理機制:html
常見的垃圾回收算法有引用計數法(Reference Counting)、標註並清理(Mark and Sweep GC)、拷貝(Copying GC)和逐代回收(Generational GC)等算法,其中Android系統採用的是標註並刪除和拷貝GC,並非大多數JVM實現裏採用的逐代回收算法。因爲幾個算法各有優缺點,因此在不少垃圾回收實現中,經常能夠看到將幾種算法合併使用的場景,本節將一一講解這幾個算法。java
引用計數法的原理很簡單,即記錄每一個對象被引用的次數。每當建立一個新的對象,或者將其它指針指向該對象時,引用計數都會累加一次;而每當將指向對象的指針移除時,引用計數都會遞減一次,當引用次數降爲0時,刪除對象並回收內存。採用這種算法的較出名的框架有微軟的COM框架,如代碼清單14 - 1演示了一個對象引用計數的增減方式。android
代碼清單14 - 1 引用計數增減方式演示僞碼web
Object *obj1 = new Object(); // obj1的引用計數爲1算法 Object *obj2 = obj1; // obj1的引用技術爲2數據庫 Object *obj3 = new Object();編程
obj2 = NULL; // obj1的引用計數遞減1次爲1。數組 obj1 = obj3; // obj1的引用計數遞減1次爲0,能夠回收其內存。緩存 |
一般對象的引用計數都會跟對象放在一塊兒,系統在分配完對象的內存後,返回的對象指針會跳過引用計數部分,如代碼清單14 - 1所示:網絡
圖 14 - 1 採用引用計數對象的內存佈局示例
然而引用計數回收算法有一個很大的弱點,就是沒法有效處理循環引用的問題,因爲Android系統沒有使用該算法,因此這裏不作過多的描述,請有興趣的讀者自行查閱相關文檔。
在這個算法中,程序在運行的過程當中不停的建立新的對象並消耗內存,直到內存用光,這時再要建立新對象時,系統暫停其它組件的運行,觸發GC線程啓動垃圾回收過程。內存回收的原理很簡單,就是從所謂的"GC Roots"集合開始,將內存整個遍歷一次,保留全部能夠被GC Roots直接或間接引用到的對象,而剩下的對象都看成垃圾對待並回收,如代碼清單14 - 3:
代碼清單14 - 2 標註並清理算法僞碼
void GC() { SuspendAllThreads();
List<Object> roots = GetRoots(); foreach ( Object root : roots ) { Mark(root); }
Sweep();
ResumeAllThreads(); } |
算法一般分爲兩個主要的步驟:
代碼清單14 - 3 標註並清理的標註階段僞碼
4. pObj->Mark(); 5. // 深度優先遍歷對象引用到的全部對象 6. List<Object *> fields = pObj->GetFields(); 7. foreach ( Object* field : fields ) { 8. Make(field); // 遞歸處理引用到的對象 9. } 10. } 11. } |
若是對象引用的層次過深,遞歸調用消耗完虛擬機內GC線程的棧空間,從而致使棧空間溢出(StackOverflow)異常,爲了不這種狀況的發生,在具體實現時,一般是用一個叫作標註棧(Mark Stack)的數據結構來分解遞歸調用。一開始,標註棧(Mark Stack)的大小是固定的,但在一些極端狀況下,若是標註棧的空間也不夠的話,則會分配一個新的標註棧(Mark Stack),並將新老棧用鏈表鏈接起來。
與引用計數法中對象的內存佈局相似,對象是否被標註的標誌也是保存在對象頭裏的,如圖 14 - 2所示。
圖 14 - 2 標註和清理算法中的對象佈局
如圖 14 - 2是垃圾回收前的對象之間的引用關係;GC線程遍歷完整個內存堆以後,標識出因此能夠被"GC Roots"引用到的對象-即代碼清單14 - 2中的第4行,結果如圖 14 - 3中高亮的部分,對於全部未被引用到(即未被標註)的對象,都將其做爲垃圾收集。
圖 14 - 3 回收內存垃圾以前的對象引用關係
圖 14 - 4 GC線程標識出全部不能被回收的對象實例
代碼清單14 - 4 標註和清理法中的清理過程僞碼
9. pIter = MoveNext(pIter); 10. } 11. } |
圖 14 - 5 GC線程執行完垃圾回收過程後的對象圖
這個方法的優勢是很好地處理了引用計數中的循環引用問題,並且在內存足夠的前提下,對程序幾乎沒有任何額外的性能開支(如不須要維護引用計數的代碼等),然而它的一個很大的缺點就是在執行垃圾回收過程當中,須要中斷進程內其它組件的執行。
這個是前面標註並清理法的一個變種,系統在長時間運行的過程當中,反覆分配和釋放內存頗有可能會致使內存堆裏的碎片過多,從而影響分配效率,所以有些採用此算法的實現(Android系統中並無採用這個作法),在清理(SWEEP)過程當中,還會執行內存中移動存活的對象,使其排列的更緊湊。在這種算法中,,虛擬機在內存中依次排列和保存對象,能夠想象GC組件在內部保存了一個虛擬的指針 – 下個對象分配的起始位置 ,如圖 14 - 6中演示的示例應用,其GC內存堆中已經分配有3個對象,所以"下個對象分配的起始位置"指向已分配對象的末尾,新的對象"object 4"(虛線部分)的起始位置將從這裏開始。
這個內存分配機制和C/C++的malloc分配機制有很大的區別,在C/C++中分配一塊內存時,一般malloc函數須要遍歷一個"可用內存空間"鏈表,採起"first-first"(即返回第一塊大於內存分配請求大小的內存塊)或"best-fit"( 即返回大於內存分配請求大小的最小內存塊),不管是哪一種機制,這個遍歷過程相對來講都是一個較爲耗時的時間。然而在Java語言中,理論上,爲一個對象分配內存的速度甚至可能比C/C++更快一些,這是由於其只須要調整指針"下個對象分配的起始位置"的位置便可,據Sun的工程師估計,這個過程大概只須要執行10個左右的機器指令。
圖 14 - 6 在GC中爲對象分配內存
因爲虛擬機在給對象分配內存時,一直不停地向後遞增指針"下個對象分配的起始位置",潛臺詞就是將GC堆當作一個無限大的內存對待的,爲了知足這個要求,GC線程在收集完垃圾內存以後,還須要壓縮內存 – 即移動存活的對象,將它們緊湊的排列在GC內存堆中,如圖 14 - 7是Java進程內GC前的內存佈局,執行回收過程時,GC線程從進程中全部的Java線程對象、各線程堆棧裏的局部變量、全部的靜態變量和JNI引用等GC Root開始遍歷。
圖 14 - 7中,能夠被GC Root訪問到的對象有A、C、D、E、F、H六個對象,爲了不內存碎片問題,和知足快速分配對象的要求,GC線程移動這六個對象,使內存使用更爲緊湊,如圖 14 - 7所示。因爲GC線程移動了存活下來對象的內存位置,其必須更新其餘線程中對這些對象的引用,如圖 14 - 7中,因爲A引用了E,移動以後,就必須更新這個引用,在更新過程當中,必須中斷正在使用A的線程,防止其訪問到錯誤的內存位置而致使沒法預料的錯誤。
圖 14 - 7 垃圾回收前的GC堆上的對象佈局及引用關係
圖 14 - 8 GC線程移動存活的對象使內存佈局更爲緊湊
注意現代操做系統中,針對C/C++的內存分配算法已經作了大量的改進,例如在Windows中,堆管理器提供了一個叫作"Look Aside List"的緩存針對大部分程序都是頻繁分配小塊內存的情形作的優化,具體技術細節請能夠參閱筆者的在線付費技術視頻:
這也是標註法的一個變種, GC內存堆實際上分紅乒(ping)和乓(pong)兩部分。一開始,全部的內存分配請求都有乒(ping)部分知足,其維護"下個對象分配的起始位置"指針,分配內存僅僅就是操做下這個指針而已,當乒(ping)的內存快用完時,採用標註(Mark)算法識別出存活的對象,如圖 14 - 9所示,並將它們拷貝到乓(pong)部分,後續的內存分配請求都在乓(pong)部分完成,如圖 14 - 10。而乓(pong)裏的內存用完後,再切換回乒(ping)部分,使用內存就跟打乒乓球同樣。
圖 14 - 9 拷貝回收法中的乒乓內存塊
圖 14 - 10 拷貝回收法中的切換乒乓內存塊以知足內存分配請求
回收算法的優勢在於內存分配速度快,並且還有可能實現低中斷,由於在垃圾回收過程當中,從一塊內存拷貝存活對象到另外一塊內存的同時,還能夠知足新的內存分配請求,但其缺點是須要有額外的一個內存空間。不過對於回收算法的缺點,也能夠經過操做系統地虛擬內存提供的地址空間申請和提交分佈操做的方式實現優化,所以在一些JVM實現中,其Eden區域內的垃圾回收採用此算法。
也是標註法的一個變種,標註法最大的問題就是中斷的時間過長,此算法是對標註法的優化基於下面幾個發現:
能夠將逐代回收法當作拷貝GC算法的一個擴展,一開始全部的對象都是分配在"年輕一代對象池" 中 – 在JVM中其被稱爲Young,如圖 14 - 11:
圖 14 - 11 逐代(generational) GC中開始對象都是分配在年輕一代對象池(Young generation)中
第一次垃圾回收事後,垃圾回收算法通常採用標註並清理算法,存活的對象會移動到"老一代對象池"中– 在JVM中其被稱爲Tenured,如圖 14 - 12,然後面新建立的對象仍然在"年輕一代對象池"中建立,這樣進程不停地重複前面兩個步驟。等到"老一代對象池"也快要被填滿時,虛擬機此時再在"老一代對象池"中執行垃圾回收過程釋放內存。在逐代GC算法中,因爲"年輕一代對象池"中的回收過程很快 – 只有不多的對象會存活,而執行時間較長的"老一代對象池"中的垃圾回收過程執行不頻繁,實現了很好的平衡,所以大部分虛擬機,如JVM、.NET的CLR都採用這種算法。
圖 14 - 12 逐代GC中將存活的對象挪到老一代對象池
在逐代GC中,有一個較棘手的問題須要處理 – 即如何處理老一代對象引用新一代對象的問題,如圖 14 - 13中。因爲每次GC都是在單獨的對象池中執行的,當GC Root之一R3被釋放後,在"年輕一代對象池"中執行GC過程時,R3所引用的對象f、g、h、i和j都會被當作垃圾回收掉,這樣就致使"老一代對象池"中的對象c有一個無效引用。
圖 14 - 13 逐代GC中老一代對象引用新對象的問題
爲了不這種狀況,在"年輕一代對象池"中執行GC過程時,也須要將對象C當作GC Root之一。一個名爲"Card Table"的數據結構就是專門設計用來處理這種狀況的,"Card Table"是一個位數組,每個位都表示"老一代對象池"內存中一塊4KB的區域 – 之因此取4KB,是由於大部分計算機系統中,內存頁大小就是4KB。當用戶代碼執行一個引用賦值(reference assignment)時,虛擬機(一般是JIT組件)不會直接修改內存,而是先將被賦值的內存地址與"老一代對象池"的地址空間作一次比較,若是要修改的內存地址是"老一代對象池"中的地址,虛擬機會修改"Card Table"對應的位爲 1,表示其對應的內存頁已經修改過 - 不乾淨(dirty)了,如圖 14 - 14。
圖 14 - 14 逐代GC中Card Table數據結構示意圖
當須要在 "年輕一代對象池"中執行GC時, GC線程先查看"Card Table"中的位,找到不乾淨的內存頁,將該內存頁中的全部對象都加入GC Root。雖然初看起來,有點浪費, 可是據統計,一般從老一代的對象引用新一代對象的概率不超過1%,所以"Card Table"的算法是一小部分的時間損失換取空間。
在Android中 ,實現了標註與清理(Mark and Sweep)和拷貝GC,可是具體使用什麼算法是在編譯期決定的,沒法在運行的時候動態更換 – 至少在目前的版本上(4.2)仍是這樣。在Android的dalvik虛擬機源碼的Android.mk文件(路徑是/dalvik/vm/Dvm.mk)裏,有相似代碼清單14 - 5的代碼,即若是在編譯dalvik虛擬機的命令中指明瞭"WITH_COPYING_GC"選項,則編譯"/dalvik/vm/alloc/Copying.cpp"源碼 – 此是Android中拷貝GC算法的實現,不然編譯"/dalvik/vm/alloc/HeapSource.cpp" – 其實現了標註與清理GC算法,也就是本節分析的重點。
代碼清單14 - 5 編譯器指定使用拷貝GC仍是標註與清理GC算法
WITH_COPYING_GC := $(strip $(WITH_COPYING_GC))
ifeq ($(WITH_COPYING_GC),true) LOCAL_CFLAGS += -DWITH_COPYING_GC LOCAL_SRC_FILES += \ alloc/Copying.cpp.arm else LOCAL_SRC_FILES += \ alloc/DlMalloc.cpp \ alloc/HeapSource.cpp \ alloc/MarkSweep.cpp.arm endif |
注意本節中分析的Android源碼,能夠在網址:http://androidxref.com/source/xref/ 中在線瀏覽。
在Android源碼中,這個過程分爲下面幾步:
代碼清單14 - 6 dvmHeapStartup初始化GC內存堆
75 bool dvmHeapStartup() 76 { 77 GcHeap *gcHeap; 78 79 if (gDvm.heapGrowthLimit == 0) { 80 gDvm.heapGrowthLimit = gDvm.heapMaximumSize; 81 } 82 83 gcHeap = dvmHeapSourceStartup(gDvm.heapStartingSize, 84 gDvm.heapMaximumSize, 85 gDvm.heapGrowthLimit); 86 if (gcHeap == NULL) { 87 return false; 88 } 89 gcHeap->ddmHpifWhen = 0; 90 gcHeap->ddmHpsgWhen = 0; 91 gcHeap->ddmHpsgWhat = 0; 92 gcHeap->ddmNhsgWhen = 0; 93 gcHeap->ddmNhsgWhat = 0; 94 gDvm.gcHeap = gcHeap; 95 96 /* Set up the lists we'll use for cleared reference objects. 97 */ 98 gcHeap->clearedReferences = NULL; 99 100 if (!dvmCardTableStartup(gDvm.heapMaximumSize, gDvm.heapGrowthLimit)) { 101 LOGE_HEAP("card table startup failed."); 102 return false; 103 } 104 105 return true; 106 } |
除了建立和初始化用於存儲普通Java對象的內存堆,Android還建立三個額外的內存堆:用來存放堆上內存被佔用狀況的位圖索引"livebits"、在GC時用於標註存活對象的位圖索引"markbits",和用來在GC中遍歷存活對象引用的標註棧(Mark Stack)。
dvmHeapSourceStartup函數運行完成後,HeapSource、Heap、livebits、markbits以及mark stack等數據結構的關係如圖 14 - 15所示。
圖 14 - 15 GC堆上HeapSource、Heap等數據結構的關係
其中虛擬機經過一個名爲gHs的全局HeapSource變量來操控GC內存堆,而HeapSource裏經過heaps數組能夠管理多個堆(Heap),以知足動態調整GC內存堆大小的要求。另外HeapSource裏還維護一個名爲"livebits"的位圖索引,以跟蹤各個堆(Heap)的內存使用狀況。剩下兩個數據結構"markstack"和"markbits"都是用在垃圾回收階段,後面會講解。
圖 14 - 16 GC向操做系統申請地址空間和內存
代碼清單14 - 7 在虛擬機中經過dvmVisitRoot遍歷GC Roots
// // visitor是一個回調函數,dvmHeapMarkRootSet傳進來的是rootMarkObjectVisitors // (位於/dalvik/vm/alloc/MarkSweep.cpp:145),這個回調函數的做用就是標註(Mark) // 全部的GC root,並將它們的指針壓入標註棧(Mark Stack)中。 // // 第二個參數arg其實是GcMarkContext對象,用於找到GC Roots後,回傳給回調函數visitor // 的參數。 // void dvmVisitRoots(RootVisitor *visitor, void *arg) { assert(visitor != NULL); // 全部已加載的類型都是GC Roots,這也意味着類型中全部的靜態變量都是GC Roots visitHashTable(visitor, gDvm.loadedClasses, ROOT_STICKY_CLASS, arg);
// 基本類型也是GC Roots,包括 // void, boolean, byte, short, char, int, long, float, double visitPrimitiveTypes(visitor, arg);
// 調試器對象註冊表中的對象(debugger object registry),這些對象 // 基本上是調試器建立的,所以不能把它們看成垃圾回收了,不然調試器 // 就沒法正常工做了。 if (gDvm.dbgRegistry != NULL) { visitHashTable(visitor, gDvm.dbgRegistry, ROOT_DEBUGGER, arg); }
// 全部interned的字符串,interned string是虛擬機中保證的只有惟一一份拷貝的字符串 if (gDvm.literalStrings != NULL) { visitHashTable(visitor, gDvm.literalStrings, ROOT_INTERNED_STRING, arg); }
// 全部的JNI全局引用對象(JNI global references),JNI全局引用對象是 // JNI代碼中,經過NewGlobalRef函數建立的對象 dvmLockMutex(&gDvm.jniGlobalRefLock); visitIndirectRefTable(visitor, &gDvm.jniGlobalRefTable,, ROOT_JNI_GLOBAL, arg); dvmUnlockMutex(&gDvm.jniGlobalRefLock);
// 全部的JNI局部引用對象(JNI local references) // 關於JNI局部和所有變量的使用,能夠參考下面的網頁連接: // http://journals.ecs.soton.ac.uk/java/tutorial/native1.1/implementing/refs.html dvmLockMutex(&gDvm.jniPinRefLock); visitReferenceTable(visitor, &gDvm.jniPinRefTable,, ROOT_VM_INTERNAL, arg); dvmUnlockMutex(&gDvm.jniPinRefLock);
// 全部線程堆棧上的局部變量和其它對象,如線程本地存儲裏的對象等等 visitThreads(visitor, arg);
// 特殊的異常對象,如OOM異常對象須要在內存不夠的時候建立,爲了防止內存不夠而沒法建立 // OOM對象,所以虛擬機會在啓動時事先建立這些對象。 (*visitor)(&gDvm.outOfMemoryObj,, ROOT_VM_INTERNAL, arg); (*visitor)(&gDvm.internalErrorObj,, ROOT_VM_INTERNAL, arg); (*visitor)(&gDvm.noClassDefFoundErrorObj,, ROOT_VM_INTERNAL, arg); } |
dvmHeapMarkRootSet是執行標註過程的主要代碼,在前文說過,一般的實現會在對象實例前面放置一個對象頭,裏面會存放是否標註過的標誌,而在Android系統裏,採起的是分離式策略,而是將標註用的標誌位放到HeapSource裏的"markbits"這個位圖索引結構,筆者猜想這麼作的目的是爲了節省內存。圖 14 - 17是dvmHeapMarkRootSet函數快要標註完存活對象時(正在標註最後一個對象H),GC內存堆的數據結構。
圖 14 - 17 GC執行完標註過程後的HeapSource結構
其中"livebits"位圖索引仍是維護堆上已用的內存信息;而"markbits"這個位圖索引則指向存活的對象,在圖 14 - 17中, A、C、F、G、H對象須要保留,所以"markbits"分別指向他們(最後的H對象尚在標註過程當中,所以沒有指針指向它);而"markstack"就是在標註過程當中跟蹤當前須要處理的對象要用到的標誌棧了,此時其保存了正在處理的對象F、G和H。
圖 14 - 18 GC清理完內存後堆上的數據結構
如代碼清單14 - 1是一個實現finalize函數的對象,在Java中,finalize對象定義在System.Object類中,即意味着全部對象都有這個函數,當子類重載了這個函數,即向虛擬機代表本身須要與其餘類型區別對待。
代碼清單14 - 8 實現finalize函數的簡單對象
1 class DemoClass { 2 public int X; 3 4 public void testMethod() { 5 System.out.println("X: " + new Integer(X).toString()); 6 } 7 8 @Override 9 protected void finalize () throws Throwable { 10 System.out.println("finalize函數被調用了!"); 11 // 實現自定義的資源清除邏輯! 12 super.finalize(); 13 } 14 } |
一些有C++編程經驗的讀者可能很容易將finalize函數與析構函數對應起來,可是二者是徹底不一樣的東西,在C++中,調用了析構函數以後,對象就被釋放了,然而在Java中,若是一個類型實現了finalize函數,其會帶來一些不利影響,首先對象的存活週期會更長,至少須要兩次垃圾回收才能銷燬對象;第二對象同時會延長其所引用到的對象存活週期。如代碼清單14 - 2中(示例代碼javagc-simple)在第3行建立並使用了DemoClass以在內存中生成一些垃圾,並執行三次GC。
代碼清單14 - 9 實現finalize函數的簡單對象
1 public class gcdemo { 2 public static void main(String[] args) throws Exception { 3 generateGarbage(); 4 System.gc(); 5 Thread.sleep(1000); 6 7 System.gc(); 8 Thread.sleep(1000); 9 10 System.gc(); 11 Thread.sleep(1000); 12 } 13 14 public static void generateGarbage() { 15 DemoClass g = new DemoClass(); 16 g.X =123; 17 g.testMethod(); 18 } 19 } |
鏈接好設備,打開logcat日誌,並執行示例代碼根目錄中的run.sh,獲得的輸出相似圖 14 - 8,每一行輸出對應代碼清單14 - 2中的一次System.gc調用,能夠看到第一次GC過程當中釋放了223個對象,若是運行示例程序javagc,會發現第一次GC以後,DemoClass的finalize函數就會被調用 – 爲了不System.out.println中的字符串對象影響GC的輸出,圖 14 - 8是javagc-simple的輸出結果。第二次GC過程當中又釋放了34個對象,其中就有DemoClass的實例,以及其所引用到的其它對象。這時全部垃圾對象都被回收了,所以在執行第三次GC過程時,沒有回收到任何內存。
圖 14 - 19 程序中使用了實現finalize函數對象以後實施三次GC的結果
前文講到Android源碼中經過dvmHeapScanMarkedObjects函數在GC堆上掃描垃圾對象,並將finalizable對象添加到finalize隊列中,其具體過程以下: