Android GC 那點事

騰訊雲加社群發表於2017-05-04

騰訊雲技術社群-掘金主頁持續為大家呈現雲端計算技術文章,歡迎大家關注!


作者:陳昱全

想寫一篇關於Android GC的想法來源於追查一個魅族手機圖片滑動卡頓問題,由於不斷的GC導致的丟幀卡頓的問題讓我們想了很多方案去解決,所以就打算詳細的看看記憶體分配和GC的原理,為什麼會不斷的GC, GC ALLOC和GC COCURRENT有什麼區別,能不能想辦法擴大堆記憶體減少GC的頻次等等。

1. JVM記憶體回收機制

1.1. 回收演算法

  • 標記回收演算法(Mark and Sweep GC)
    從"GC Roots"集合開始,將記憶體整個遍歷一次,保留所有可以被GC Roots直接或間接引用到的物件,而剩下的物件都當作垃圾對待並回收,這個演算法需要中斷程式內其它元件的執行並且可能產生記憶體碎片。
  • 複製演算法 (Copying)
    將現有的記憶體空間分為兩快,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,之後,清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,完成垃圾回收。
  • 標記-壓縮演算法 (Mark-Compact)
    先需要從根節點開始對所有可達物件做一次標記,但之後,它並不簡單地清理未標記的物件,而是將所有的存活物件壓縮到記憶體的一端。之後,清理邊界外所有的空間。這種方法既避免了碎片的產生,又不需要兩塊相同的記憶體空間,因此,其價效比比較高。
  • 分代
    將所有的新建物件都放入稱為年輕代的記憶體區域,年輕代的特點是物件會很快回收,因此,在年輕代就選擇效率較高的複製演算法。當一個物件經過幾次回收後依然存活,物件就會被放入稱為老生代的記憶體空間。對於新生代適用於複製演算法,而對於老年代則採取標記-壓縮演算法。

1.2. 複製和標記-壓縮演算法的區別

乍一看這兩個演算法似乎並沒有多大的區別,都是標記了然後挪到另外的記憶體地址進行回收,那為什麼不同的分代要使用不同的回收演算法呢?

其實2者最大的區別在於前者是用空間換時間後者則是用時間換空間。

前者的在工作的時候是不沒有獨立的“Mark”與“Copy”階段的,而是合在一起做一個動作,就叫Scavenge(或Evacuate,或者就叫Copy)。也就是說,每發現一個這次收集中尚未訪問過的活物件就直接Copy到新地方,同時設定Forwarding Pointer,這樣的工作方式就需要多一份空間。

後者在工作的時候則需要分別的Mark與Compact階段,Mark階段用來發現並標記所有活的物件,然後compact階段才移動物件來達到Compact的目的。如果Compact方式是Sliding Compaction,則在Mark之後就可以按順序一個個物件“滑動”到空間的某一側。因為已經先遍歷了整個空間裡的物件圖,知道所有的活物件了,所以移動的時候就可以在同一個空間內而不需要多一份空間。

所以新生代的回收會更快一點,老年代的回收則會需要更長時間,同時壓縮階段是會暫停應用的,所以給我們應該儘量避免物件出現在老年代。

2. Dalvik虛擬機器

2.1. Java堆

Java堆實際上是由一個Active堆和一個Zygote堆組成的,其中,Zygote堆用來管理Zygote程式在啟動過程中預載入和建立的各種物件,而Active堆是在Zygote程式Fork第一個子程式之前建立的。以後啟動的所有應用程式程式是被Zygote程式Fork出來的,並都持有一個自己的Dalvik虛擬機器。在建立應用程式的過程中,Dalvik虛擬機器採用Cow策略複製Zygote程式的地址空間。

Cow策略:一開始的時候(未複製Zygote程式的地址空間的時候),應用程式程式和Zygote程式共享了同一個用來分配物件的堆。當Zygote程式或者應用程式程式對該堆進行寫操作時,核心就會執
行真正的拷貝操作,使得Zygote程式和應用程式程式分別擁有自己的一份拷貝,這就是所謂的Cow。因為Copy是十分耗時的,所以必須儘量避免Copy或者儘量少的Copy。

為了實現這個目的,當建立第一個應用程式程式時,會將已經使用了的那部分堆記憶體劃分為一部分,還沒有使用的堆記憶體劃分為另外一部分。前者就稱為Zygote堆,後者就稱為Active堆。這樣只需把zygote堆中的內容複製給應用程式程式就可以了。以後無論是Zygote程式,還是應用程式程式,當它們需要分配物件的時候,都在Active堆上進行。這樣就可以使得Zygote堆儘可能少地被執行寫操作,因而就可以減少執行寫時拷貝的操作。在Zygote堆裡面分配的物件其實主要就是Zygote程式在啟動過程中預載入的類、資源和物件了。這意味著這些預載入的類、資源和物件可以在Zygote程式和應用程式程式中做到長期共享。這樣既能減少拷貝操作,還能減少對記憶體的需求。

2.2. 和GC有關的一些指標

記得我們之前在優化魅族某手機的gc卡頓問題時,發現他很容易觸發GC_FOR_MALLOC,這個GC類別後續會說到,是分配物件記憶體不足時導致的。可是我們又設定了很大的堆Size為什麼還會記憶體不夠呢,這裡需要了解以下幾個概念:分別是Java堆的起始大小(Starting Size)、最大值(Maximum Size)和增長上限值(Growth Limit)。
在啟動Dalvik虛擬機器的時候,我們可以分別通過-Xms、-Xmx和-XX:HeapGrowthLimit三個選項來指定上述三個值,以上三個值分別表示表示:

  • Starting Size: Dalvik虛擬機器啟動的時候,會先分配一塊初始的堆記憶體給虛擬機器使用。
  • Growth Limit: 是系統給每一個程式的最大堆上限,超過這個上限,程式就會OOM。
  • Maximum Size: 不受控情況下的最大堆記憶體大小,起始就是我們在用largeheap屬性的時候,可以從系統獲取的最大堆大小。

同時除了上面的這個三個指標外,還有幾個指標也是值得我們關注的,那就是堆最小空閒值(Min Free)、堆最大空閒值(Max Free)和堆目標利用率(Target Utilization)。假設在某一次GC之後,存活物件佔用記憶體的大小為LiveSize,那麼這時候堆的理想大小應該為(LiveSize / U)。但是(LiveSize / U)必須大於等於(LiveSize + MinFree)並且小於等於(LiveSize + MaxFree),每次GC後垃圾回收器都會盡量讓堆的利用率往目標利用率靠攏。所以當我們嘗試手動去生成一些幾百K的物件,試圖去擴大可用堆大小的時候,反而會導致頻繁的GC,因為這些物件的分配會導致GC,而GC後會讓堆記憶體回到合適的比例,而我們使用的區域性變數很快會被回收理論上存活物件還是那麼多,我們的堆大小也會縮減回來無法達到擴充的目的。 與此同時這也是產生CONCURRENT GC的一個因素,後文我們會詳細講到。

2.3. GC的型別

  • GC_FOR_MALLOC: 表示是在堆上分配物件時記憶體不足觸發的GC。
  • GC_CONCURRENT: 當我們應用程式的堆記憶體達到一定量,或者可以理解為快要滿的時候,系統會自動觸發GC操作來釋放記憶體。
  • GC_EXPLICIT: 表示是應用程式呼叫System.gc、VMRuntime.gc介面或者收到SIGUSR1訊號時觸發的GC。
  • GC_BEFORE_OOM: 表示是在準備拋OOM異常之前進行的最後努力而觸發的GC。

實際上,GC_FOR_MALLOC、GC_CONCURRENT和GC_BEFORE_OOM三種型別的GC都是在分配物件的過程觸發的。而併發和非併發GC的區別主要在於前者在GC過程中,有條件地掛起和喚醒非GC執行緒,而後者在執行GC的過程中,一直都是掛起非GC執行緒的。並行GC通過有條件地掛起和喚醒非GC執行緒,就可以使得應用程式獲得更好的響應性。但是同時並行GC需要多執行一次標記根集物件以及遞迴標記那些在GC過程被訪問了的物件的操作,所以也需要花費更多的CPU資源。後文在ART的併發和非併發GC中我們也會著重說明下這兩者的區別。

2.4. 物件的分配和GC觸發時機

  1. 呼叫函式dvmHeapSourceAlloc在Java堆上分配指定大小的記憶體。如果分配成功,那麼就將分配得到的地址直接返回給呼叫者了。函式dvmHeapSourceAlloc在不改變Java堆當前大小的前提下進行記憶體分配,這是屬於輕量級的記憶體分配動作。
  2. 如果上一步記憶體分配失敗,這時候就需要執行一次GC了。不過如果GC執行緒已經在執行中,即gDvm.gcHeap->gcRunning的值等於true,那麼就直接呼叫函式dvmWaitForConcurrentGcToComplete等到GC執行完成就是了。否則的話,就需要呼叫函式gcForMalloc來執行一次GC了,引數false表示不要回收軟引用物件引用的物件。
  3. GC執行完畢後,再次呼叫函式dvmHeapSourceAlloc嘗試輕量級的記憶體分配操作。如果分配成功,那麼就將分配得到的地址直接返回給呼叫者了。
  4. 如果上一步記憶體分配失敗,這時候就得考慮先將Java堆的當前大小設定為Dalvik虛擬機器啟動時指定的Java堆最大值,再進行記憶體分配了。這是通過呼叫函式dvmHeapSourceAllocAndGrow來實現的。
  5. 如果呼叫函式dvmHeapSourceAllocAndGrow分配記憶體成功,則直接將分配得到的地址直接返回給呼叫者了。
  6. 如果上一步記憶體分配還是失敗,這時候就得出狠招了。再次呼叫函式gcForMalloc來執行GC。引數true表示要回收軟引用物件引用的物件。
  7. GC執行完畢,再次呼叫函式dvmHeapSourceAllocAndGrow進行記憶體分配。這是最後一次努力了,成功與事都到此為止。
    示例圖如下:
    Android GC 那點事

通過這個流程可以看到,在物件的分配中會導致GC,第一次分配物件失敗我們會觸發GC但是不回收Soft的引用,如果再次分配還是失敗我們就會將Soft的記憶體也給回收,前者觸發的GC是GC_FOR_MALLOC型別的GC,後者是GC_BEFORE_OOM型別的GC。而當記憶體分配成功後,我們會判斷當前的記憶體佔用是否是達到了GC_CONCURRENT的閥值,如果達到了那麼又會觸發GC_CONCURRENT。
那麼這個閥值又是如何來的呢,上面我們說到的一個目標利用率,GC後我們會記錄一個目標值,這個值理論上需要再上述的範圍之內,如果不在我們會選取邊界值做為目標值。虛擬機器會記錄這個目標值,當做當前允許總的可以分配到的記憶體。同時根據目標值減去固定值(200~500K), 當做觸發GC_CONCURRENT事件的閾值。

2.5. 回收演算法和記憶體碎片

主流的大部分Davik採取的都是標註與清理(Mark and Sweep)回收演算法,也有實現了拷貝GC的,這一點和HotSpot是不一樣的,具體使用什麼演算法是在編譯期決定的,無法在執行的時候動態更換。如果在編譯dalvik虛擬機器的命令中指明瞭"WITH_COPYING_GC"選項,則編譯"/dalvik/vm/alloc/Copying.cpp"原始碼 – 此是Android中拷貝GC演算法的實現,否則編譯"/dalvik/vm/alloc/HeapSource.cpp" – 其實現了標註與清理GC演算法。
由於Mark and Sweep演算法的缺點,容易導致記憶體碎片,所以在這個演算法下,當我們有大量不連續小記憶體的時候,再分配一個較大物件時,還是會非常容易導致GC,比如我們在該手機上decode圖片,具體情況如下:

Android GC 那點事

所以對於Dalvik虛擬機器的手機來說,我們首先要儘量避免掉頻繁生成很多臨時小變數(比如說:getView, onDraw等函式中new物件),另一個又要儘量去避免產生很多長生命週期的大物件。

3. ART記憶體回收機制

3.1. Java堆

ART執行時內部使用的Java堆的主要組成包括Image Space、Zygote Space、Allocation Space和Large Object Space四個Space,Image Space用來存在一些預載入的類, Zygote Space和Allocation Space與Dalvik虛擬機器垃圾收集機制中的Zygote堆和Active堆的作用是一樣的,
Large Object Space就是一些離散地址的集合,用來分配一些大物件從而提高了GC的管理效率和整體效能,類似如下圖:

Android GC 那點事

在下文的GC Log中,我們也能看到在ART的GC Log中包含了LOS的資訊,方便我們檢視大記憶體的情況。

3.2. GC的型別

kGcCauseForAlloc: 當要分配記憶體的時候發現記憶體不夠的情況下引起的GC,這種情況下的GC會Stop World.
kGcCauseBackground: 當記憶體達到一定的閥值的時候會去出發GC,這個時候是一個後臺GC,不會引起Stop World.
kGcCauseExplicit,顯示呼叫的時候進行的gc,如果ART開啟了這個選項的情況下,在system.gc的時候會進行GC.
其他更多。

3.3. 物件的分配和GC觸發時機

由於ART下記憶體分配和Dalvik下基本沒有任何區別,我直接貼圖帶過了。

Android GC 那點事

3.4. 併發和非併發GC

ART在GC上不像Dalvik僅有一種回收演算法,ART在不同的情況下會選擇不同的回收演算法,比如Alloc記憶體不夠的時候會採用非併發GC,而在Alloc後發現記憶體達到一定閥值的時候又會觸發併發GC。同時在前後臺的情況下GC策略也不盡相同,後面我們會一一給大家說明。

  • 非併發GC
    步驟1. 呼叫子類實現的成員函式InitializePhase執行GC初始化階段。
    步驟2. 掛起所有的ART執行時執行緒。
     步驟3. 呼叫子類實現的成員函式MarkingPhase執行GC標記階段。
     步驟4. 呼叫子類實現的成員函式ReclaimPhase執行GC回收階段。
     步驟5. 恢復第2步掛起的ART執行時執行緒。
     步驟6. 呼叫子類實現的成員函式FinishPhase執行GC結束階段。複製程式碼
  • 併發GC
    步驟1. 呼叫子類實現的成員函式InitializePhase執行GC初始化階段。
    步驟2. 獲取用於訪問Java堆的鎖。
    步驟3. 呼叫子類實現的成員函式MarkingPhase執行GC並行標記階段。
    步驟4. 釋放用於訪問Java堆的鎖。
    步驟5. 掛起所有的ART執行時執行緒。複製程式碼
    步驟6. 呼叫子類實現的成員函式HandleDirtyObjectsPhase處理在GC並行標記階段被修改的物件。
    步驟7. 恢復第4步掛起的ART執行時執行緒。
    步驟8. 重複第5到第7步,直到所有在GC並行階段被修改的物件都處理完成。
    步驟9. 獲取用於訪問Java堆的鎖。
    步驟10. 呼叫子類實現的成員函式ReclaimPhase執行GC回收階段。
    步驟11. 釋放用於訪問Java堆的鎖。
    步驟12. 呼叫子類實現的成員函式FinishPhase執行GC結束階段。複製程式碼
    所以不論是併發還是非併發,都會引起Stop World的情況出現,併發的情況下單次Stop World的時間會更短,基本區別和Dalvik類似。

    3.5. ART併發和Dalvik併發GC的差異

    首先可以通過如下2張圖來對比下。
    Dalvik GC:
    Android GC 那點事

    ART GC:
    Android GC 那點事

ART的併發GC和Dalvik的併發GC有什麼區別呢,初看好像2者差不多,雖然沒有一直掛起執行緒,但是也會有暫停執行緒去執行標記物件的流程。通過閱讀相關文件可以瞭解到ART併發GC對於Dalvik來說主要有三個優勢點:

  1. 標記自身
    ART在物件分配時會將新分配的物件壓入到Heap類的成員變數allocationstack描述的Allocation Stack中去,從而可以一定程度上縮減物件遍歷範圍。
  2. 預讀取
    對於標記Allocation Stack的記憶體時,會預讀取接下來要遍歷的物件,同時再取出來該物件後又會將該物件引用的其他物件壓入棧中,直至遍歷完畢。
  3. 減少Suspend時間
    在Mark階段是不會Block其他執行緒的,這個階段會有髒資料,比如Mark發現不會使用的但是這個時候又被其他執行緒使用的資料,在Mark階段也會處理一些髒資料而不是留在最後Block的時候再去處理,這樣也會減少後面Block階段對於髒資料的處理的時間。

3.6. 前後臺GC

前臺Foreground指的就是應用程式在前臺執行時,而後臺Background就是應用程式在後臺執行時。因此,Foreground GC就是應用程式在前臺執行時執行的GC,而Background就是應用程式在後臺執行時執行的GC。
應用程式在前臺執行時,響應性是最重要的,因此也要求執行的GC是高效的。相反,應用程式在後臺執行時,響應性不是最重要的,這時候就適合用來解決堆的記憶體碎片問題。因此,Mark-Sweep GC適合作為Foreground GC,而Mark-Compact GC適合作為Background GC。
由於有Compact的能力存在,碎片化在ART上可以很好的被避免,這個也是ART一個很好的能力。

3.7. ART大法好

總的來看,ART在GC上做的比Dalvik好太多了,不光是GC的效率,減少Pause時間,而且還在記憶體分配上對大記憶體的有單獨的分配區域,同時還能有演算法在後臺做記憶體整理,減少記憶體碎片。對於開發者來說ART下我們基本可以避免很多類似GC導致的卡頓問題了。另外根據谷歌自己的資料來看,ART相對Dalvik記憶體分配的效率提高了10倍,GC的效率提高了2-3倍。

4. GC Log

當我們想要根據GC日誌來追查一些GC可能造成的卡頓時,我們需要了解GC日誌的組成,不同資訊代表了什麼含義。

4.1. Dalvik GC日誌

Dalvik的日誌格式基本如下:
D/dalvikvm:<GC_Reason><Amount_freed>,<Heap_stats>,<Pause_time>,<Total_time>
GC_Reason: 就是我們上文提到的,是gc_alloc還是gc_concurrent,瞭解到不同的原因方便我們做不同的處理。
Amount_freed: 表示系統通過這次GC操作釋放了多少記憶體。
Heap_stats: 中會顯示當前記憶體的空閒比例以及使用情況(活動物件所佔記憶體 / 當前程式總記憶體)。
Pause_time: 表示這次GC操作導致應用程式暫停的時間。關於這個暫停的時間,在2.3之前GC操作是不能併發進行的,也就是系統正在進行GC,那麼應用程式就只能阻塞住等待GC結束。而自2.3之後,GC操作改成了併發的方式進行,就是說GC的過程中不會影響到應用程式的正常執行,但是在GC操作的開始和結束的時候會短暫阻塞一段時間,所以還有後續的一個total_time。
Total_time: 表示本次GC所花費的總時間和上面的Pause_time,也就是stop all是不一樣的,卡頓時間主要看上面的pause_time。

4.2. ART GC日誌

I/art:<GC_Reason><Amount_freed>,<LOS_Space_Status>,<Heap_stats>,<Pause_time>,<Total_time>
基本情況和Dalvik沒有什麼差別,GC的Reason更多了,還多了一個OS_Space_Status.

LOS_Space_Status:Large Object Space,大物件佔用的空間,這部分記憶體並不是分配在堆上的,但仍屬於應用程式記憶體空間,主要用來管理 bitmap 等佔記憶體大的物件,避免因分配大記憶體導致堆頻繁 GC。
寫在最後:圖片來源自網路,特別鳴謝羅昇陽。

文章來源於公眾號:QQ空間終端開發團隊(qzonemobiledev)

相關推薦
Android開發入門的正確姿勢
Android 新一代多渠道打包神器
React + Redux 元件化方案


此文已由作者授權騰訊雲技術社群釋出,轉載請註明文章出處
原文連結:www.qcloud.com/community/a…
獲取更多騰訊海量技術實踐乾貨,歡迎大家前往騰訊雲技術社群

相關文章