Android GC 簡史

貓貓能有什么錯呢?就這樣被GC了...

Android 開發(fā)者對于 GC 既熟悉又陌生,聽說過很多虎狼之詞,對一些問題又不置可否;今天聊聊 Android 里的 GC,如果你對于下面的問題有興趣又沒答案,那你應該會有些收獲:

  1. JVM、Dalvik、ART, 它們之間是什么關系?
  2. 所有版本的 Android 都是分代管理堆內存嗎?
  3. 垃圾對象到底是怎么被回收的?
  4. 「內存抖動」你怕不怕?
  5. 作為一個應用層開發(fā)者,我真的需要關心 Android GC 嗎?

前言:概念辨析

為了避免一些朋友不是很清楚概念,在正文開始之前,先簡單辨析一下:

GC
GC,是指垃圾回收 (Garbage Collection),是一些語言管理內存的方式,如 Java 語言等;程序員不需要主動管理內存,程序運行時環(huán)境(虛擬機)會做垃圾回收的工作,就是在合適的時機 自動釋放不再需要的內存。

與 GC 對應的是 native 語言,它需要程序員主動釋放申請內存,忘記釋放或者釋放時機不合適都會產生問題。

GC Root
在 native 語言中,內存的申請和釋放需要程序員來操作,做到正確地申請和釋放內存就是程序員要考慮的問題。

在類似 Java 這種使用了 GC 的語言中,程序員不關心內存的釋放。正確地釋放內存就是 GC 的責任,GC 的原則是保證正確性的前提下,盡可能提升性能。 于是就使用了 GC Root 的機制,邏輯上就是:GC 認為 GC Root 以及它引用的對象是程序后面的可能會用到的,所以不會釋放;沒有被 GC Root 直接或間接引用的對象,后面一定不會被用到,可以被釋放掉。

在 Java 中常被用于GC Root的類型如下:

  • (函數(shù)未出棧時的)局部變量
  • 靜態(tài)變量
  • 存活狀態(tài)的線程
  • Native 方法中 JNI 引用的對象

另外,Java 中內存泄漏的本質,就是對象 (不當?shù)? 被GC Root 引用導致無法釋放。

之所以會在這里提到 GC Root, 因為后面的流程中上來就先找 GC Root 集合,看完這個你就知道那是在干嘛了。

JVM / Dalvik / ART
JVM 是 Java 語言提出的虛擬機標準,基于這個標準各個廠商會有自己的實現(xiàn)。比如 Oracle 的 HotSpot,以及 Android 中的 DVM (Dalvik Virtual Machine) 和 ART (Android Runtime) 兩個實現(xiàn)。其中,

  • Dalvik 是 Android 4.4 (Kitkat) 以及之前的版本的虛擬機;

  • ART 在 Android 4.4 (Kitkat) 引入,并在Android 5.0 (Lollipop) 開始取代Dalvik.

分代管理
分代管理是很多 JVM 虛擬機對于堆 (heap) 內存的管理機制,最為人熟知的是新生代 (Young Generation)、老年代 (Old Generation) 和永久代 (Permanent Generation) 這一組名詞,這也是很多人講 GC 時的默認組合。事實上,這一組名詞是 Oracle 的 HotSpot 中對于 JDK 1.7及之前的實現(xiàn)方式。至于其他的 JVM 實現(xiàn),可以選擇是否采用分代管理的機制。

比如 Dalvik 就沒有采用分代管理的機制,ART 在 Android 8 (Oero) 和 9 (Pie) 版本未使用分代管理、其他的版本又采用了該機制。

在分代管理的虛擬機中,新生代和老年代正如它們的名字一樣,分別存儲了新創(chuàng)建的對象和存活了很久的對象;新的對象會在新生代中創(chuàng)建,經過一定次數(shù)的 GC 后依然存活的對象,會被復制到老年代中(有些虛擬機新生代分為更多的區(qū)域,以達到的最佳的性能表現(xiàn))。


前言結束,現(xiàn)在進入今天最重要的部分了

Android GC 的演進

1. 史前時代的 Dalvik

Dalvik 虛擬機隨著 Android 一起誕生,一直到 Android 4.4。

HTC G1 是第一款 Android 設備,內存為192MB,應用程序可用的堆內存僅為30MB左右,Dalvik 就是在這樣的環(huán)境下誕生的。它的設計原則中,節(jié)省空間 (盡量讓程序跑起來) 是第一優(yōu)先級。

HTC G1

內存分配

分配內存時,Dalvik 使用的算法是 dlmalloc (以 java.util.concurrent 作者 Doug Lea 命名的算法),使用了單獨的進程來分配內存,內存的分配效率較低。

  • 在整個堆中尋找適合分配的內存


    在整個堆中尋找適合分配的內存
  • 找到了適合分配的內存


    找到了適合分配的內存
  • 內存分配成功


    內存分配成功
  • 上面的圖中給出了順利分配內存的流程,如果當前堆中沒有合適分配的內存,就會觸發(fā)一個 GC_FOR_ALLOC 進入GC流程。

    沒有合適分配的內存,就會觸發(fā)一個 `GC_FOR_ALLOC` 進入GC流程

回收流程

  • 標記GC Root 集合,這一步會導致應用暫停


    標記GC Root 集合,這一步會導致應用暫停
  • 標記可觸達對象1


    標記可觸達對象1
  • 標記可觸達對象2,這一步會導致應用暫停


    標記可觸達對象2,這一步會導致應用暫停
  • 清理對象


    清理對象

在 Dalvik 的一次回收過程中,有兩個步驟會導致應用暫停(所有線程掛起)共10ms左右,這個時間還是比較長的,在一幀16.6ms的繪制時間里如果發(fā)生一次 GC, 很可能導致丟幀。

如果回收過后依然沒有足夠的空間,此時可能發(fā)生兩件事:

  • 增大堆體積
  • (堆體積已經最大) 拋出 OutOfMemoryError

Dalvik 的問題:碎片化
前面已經提到過,Dalvik 使用 dlmalloc 作為分配內存的算法,作者 Doug Lea 先生自己的文章 A Memory Allocator 中也提到了避免碎片化的問題,但Dalvik的內存碎片化問題依然嚴重。

  • 比如Google I/O 中的例子:有 200MB 剩余空間 (200個 1MB 的內存碎片),嘗試分配 2MB 空間拋出了 OOM 錯誤。


    有 200MB 剩余空間 (200個 1MB 的內存碎片),嘗試分配 2MB 空間拋出了OOM錯誤。

時代滾滾向前,Dalvik 在 Android 4.4之后被 ART取代,我們就把 Dalvik 的歲月稱作史前時代吧~

2. 走向共和的 ART

ART 在 Android 4.4 引入(與 Dalvik 同時存在),從 Android 5.0 開始成為唯一選擇,并一直更新到現(xiàn)在(寫作這篇文章的時間是2021年)。

Android 5.0 ~ 7.0

Android 5.0 ~ 7.0 (Nougat), ART做了很多更新,但從 GC 的角度來看,可以放在一起討論。

引入分代管理

將堆分為新生代 (Young Generation) 和老年代 (Old Generation),對應的GC也分為兩種:

  • Minor GC: 針對新生代的垃圾回收
  • Major GC (Full GC) : 針對整個堆的垃圾回收

引入分代管理是基于這樣的假設:生命周期短的對象 (如臨時生成的對象) 的創(chuàng)建和銷毀要比 (生命周期) 長的對象更加頻繁。

程序運行時,觸發(fā)的大部分是 Minor GC,只會針對新生代做處理,這樣比 Dalvik 的全局回收要高效很多,極大降低了創(chuàng)建臨時變量的開銷。

內存分配

與 Dalvik 不同, ART 中的內存分配使用了 RosAlloc 算法。做了以下的改進:

  • 改為當前線程 (Thread-Local) 分配內存,與之對應的是 Dalvik 的單一線程負責內存分配。
  • 降低了鎖的顆粒度
  • 針對小塊內存的優(yōu)化:歸并處理
  • 針對大塊內存分配的優(yōu)化

相比于 Android 4.4 的 Dalvik,Android 5.0 內存分配的性能提升到 4 - 5x,在Android 7.0 提升到了10x左右。

內存回收

與 Dalvik 類似,Android 5.0 ~ 7.0 的內存回收也分為4個階段(如下圖),其中第一個階段(標記 GC Root 集合) 改為并發(fā)處理,使整個過程只需要暫停應用(掛起所有線程) 3ms 左右,提升明顯。


ART 5.0~7.0 內存回收過程

整理內存

為了解決碎片化的問題,ART 會在應用后臺時整理內存 (compaction),就是把不連續(xù)的內存整理(使用copy)為連續(xù)的內存;這樣空閑的內存中就有了更多的連續(xù)內存,避免了碎片化的問題,重復上面在 Kitkat的實驗也不會拋出 OOM 了。

當然,當應用在前臺時,內存整理也可能被觸發(fā);比如應用 GC 后仍然沒有足夠的連續(xù)內存。

Android 8.0 ~ 9.0

Android 8.0 開始,ART 引入了 Concurrent Copying (CC) GC,將整理內存的工作由后臺轉移到了前臺,并且移除了分代管理機制。

內存分配

從Android 8.0 開始,ART 引入了 Bump Allocator 的機制來分配內存,處理方式如下:

  • 堆內存被分割為多個 region
  • 每個線程對應一個 region, 并且維護一個指向下一個空閑空間的指針 Bump Pointer
  • 分配內存時,直接 Bump Pointer 指針指向的地址分配即可,由于每個線程有對應的region,所以分配的過程是并發(fā)的,非常高效。
  • 當前 region 剩余空間不夠時,觸發(fā) compaction 操作,具體步驟見下一節(jié)。

基于上面的優(yōu)化,相比 Android 4.4 的 Dalvik,ART 在 Android 8.0 的內存分配性能提升到18x,表現(xiàn)已經好于大部分的帶鎖對象池實現(xiàn)了。所以在使用對象池機制之前,先測試一下性能對比吧。

內存回收

在 Concurrent Copying (CC) GC 中,GC時會遍歷每個region,根據(jù)當前的對象狀態(tài)來決定是否進行 copy 操作,具體過程如下:

  • 找到 GC Root 并標記 GC Root 引用的對象


    找到 GC Root 并標記 GC Root 引用的對象
  • 標記出未被 GC Root 引用的對象(垃圾對象)


    標記出未被 GC Root 引用的對象(垃圾對象)
  • 對region進行分析,決定每一個region是否進行copy


    對region進行分析,決定每一個region是否進行copy

經過分析,垃圾對象較多的區(qū)域會被搬移,
而垃圾對象較少的區(qū)域不會被搬移,原因這個區(qū)域大部分對象后面還會用到,copy操作是有成本的,全量copy不劃算

  • copy 對象到新的 region


    copy 對象到新的 region
  • 清空搬移后的region,完成回收


    清空搬移后的region,完成回收

Android 10 及以后

從 Android 10 開始,Concurrent Copying (CC) GC 中加入了分代管理機制(也不知道當時為啥要移除),有了分代管理,對應的 Minor GC 與 Major GC 也就回來,這一塊我們重點講一下對應的工作流程:

在 Generational CC 中,堆內存并沒有顯式地劃分為不同的代,而是在運行時 把不同的 region 標記為新生代或者老年代;

CC Minor GC

  • 根據(jù) GC Root,標記新生代 region 的對象


    標記新生代 region 的對象
  • Minor GC 不會導致追蹤(trace)老年代region中的對象,但如果新生代region中的對象被老年代region引用,還是要在copy后更新對應的引用,這里用到了一個Remember Set的機制,將這一操作的開銷講到最小。


    Minor GC 不會導致追蹤(trace)老年代region中的對象
  • 新生代對象 copy 到新的 region 中,原region被回收,Minor GC 完成


    新生代對象 copy 到新的 region 中,原region被回收,Minor GC 完成

CC Major GC:

  • 追蹤所有的region,(根據(jù)規(guī)則)標記要回收的 region


    追蹤所有的region,(根據(jù)規(guī)則)標記要回收的 region
  • 將需要回收的 region 中的對象 copy 到新的 region 中,回收原來的 region


    將需要回收的 region 中的對象 copy 到新的 region 中,回收原來的 region

根據(jù)上面的流程,我們可以看到在未加入分代回收之前,Concurrent Copying GC 中每一次GC,都是一次 Major GC,這樣回收性能得到提升,尤其是對于生命周期較短的對象。

Summary 性能總結

各個版本性能對比圖

- Android 4.4 Android 5~6 Android 7 Android 8~9 Android 10
回收算法 CMS CMS CMS CC CC
內存分配機制 Single-Thread Per-Thread Per-thread Bump Pointer Bump Pointer
內存分配性能 1x 4-5x 10x 18x 18x
臨時變量開銷 低(分代) 低(分代) 低(分代)
內存整理(防止碎片化) 后臺 后臺/事件 后臺/事件 前臺并發(fā) 前臺并發(fā)

Tip:

CMS: Concurrent Mark Sweap
CC: Concurrent Copying


另外幾個問題

0. GC 的日志要怎么看???

Dalvik 與 ART 的 GC 日志不太一樣,具體差異可以參考這里,有一點需要注意: ART 已經不會打印全部的GC日志了,那樣就太頻繁了。它只會打印以下三種:

  • 程序主動請求的GC
  • pause 時間超過5ms
  • 總時間超過100ms

1. Android 對于 Bitmap 內存處理的演進

這里還是要提一下 Bitmap,作為 Android 中最常見的“大對象”,它與 GC 的關系也很密切;Bitmap 最占內存的是它的像素數(shù)據(jù),對于像素數(shù)據(jù)的儲存,Android 發(fā)生過一些變化。

Android 3.0 之前

Bitmap像素數(shù)據(jù)存儲在 native 堆中,數(shù)據(jù)的釋放依賴 Java對象的 finalize() 方法回調,該方法的調用不太可靠,而且現(xiàn)在已經被 Java 標記為廢棄。

Android 3.0 ~ 7.0

Bitmap 像素數(shù)據(jù)存儲在 Java 堆中,確實解決了可靠釋放的問題;也帶來一個新問題:Android 應用程序對 Java 堆的限制是很嚴格的,一般不超過512MB (因廠家而已),創(chuàng)建Bitmap這種大的對象很容易引起 GC,另外,如果大家經歷過相對早期的Android開發(fā),一定很熟悉部分設備上創(chuàng)建 Bitmap 導致 OOM 的問題。

Android 8.0 及以后

Bitmap 像素數(shù)據(jù)存儲在 native 堆中,同時引入了 NativeAllocationRegistry 機制保證了 native 內存釋放的可靠性,同時可以用的空間大大增加。

程序員最煩這種來回改的需求( native ==> Java ==> native ),但這里并不是沒事找事,而是為了解決當時面臨的問題。時至今日,Bitmap 依然可能造成內存使用的錯誤,即使 native 的最大可用空間為幾個 GB,但也不能毫無顧忌地使用,一方面,native 內存壓力大時也會觸發(fā) Java 內存的 GC (想不到吧);另一個原因留給下一個問題。


2. 如果應用真的用了幾個 GB 的內存,會發(fā)生什么?

正如上一個問題所述,native 內存的最大可用空間可以為幾個 GB,如果真的用了這么多內存,也不釋放。會發(fā)生什么呢?

我們上面討論的所有 GC 問題都是關于當前應用的內存使用,它也會影響系統(tǒng)整體的內存使用情況。系統(tǒng)也有對應的機制來統(tǒng)籌系統(tǒng)資源的使用:kswapd 和 lmk

kswapd

kernel swap daemon,作為一個守護進程會一直監(jiān)控系統(tǒng)內存的使用,剩余內存達到低點(閾值)時觸發(fā)回收操作,剩余內存達到高點(閾值)時停止回收操作?;厥詹呗裕?/p>

  1. 刪除緩存的內存(緩存本是用來以空間換時間的,現(xiàn)在空間不足了,就釋放掉)
  2. 壓縮內存中的數(shù)據(jù)(這些數(shù)據(jù)刪除就丟失了,于是壓縮后放在內存中的特定區(qū)域,節(jié)省了空間)

lmk

low memory killer, kswapd處理后內存依然不夠用的話,就會觸發(fā) lmk 機制,簡單講就是按照優(yōu)先級(一個分數(shù))從低到高,有序地關閉進程來釋放資源。

在回收的各個階段,應用可以實現(xiàn) onTrimMemory(level) 方法收到回調,可以根據(jù)對應 level 主動釋放一些緩存數(shù)據(jù),進程的優(yōu)先級可以查看下面這張圖:

[站外圖片上傳中...(image-2a4b94-1621955632887)]

這里只是簡單講述邏輯,關于 kwapd 和 lmk 的更多細節(jié)可以參考官方文檔

回到當前問題,應用用了幾個 GB 的內存后的表現(xiàn),會設備相關,可能會繼續(xù)流暢運行,也可能進程被關閉。


3. “內存抖動” 你怕不怕?

“內存抖動”可能是被面試官帶火的一個詞,實際的原理是比較簡單的:

如果高頻地申請較大尺寸的內存,則可能導致短時間內頻繁觸發(fā) GC,造成內存的頻繁申請和釋放,使用Profiler查看內存使用時,看起來就是一個抖動的曲線。


看,內存“抖動”起來了~

在 ART 之后,單次 GC 導致應用暫停時間不會很長,但如果連續(xù)觸發(fā)多次 GC 就可能導致一幀 UI 繪制的延遲,造成丟幀。大家都知道不要在 onDraw() 里創(chuàng)建對象;我從來沒有在 onDraw() 里創(chuàng)建大的對象,事實上在編碼的時候如果有關注性能,寫出“內存抖動”的代碼是不容易的;但為了驗證這個問題,我測試了各個Android “內存抖動”場景的表現(xiàn),具體參數(shù)就請查看針對「內存抖動」的一次測試

直接說結論吧: Dalvik上遭遇內存抖動的代價是致命的,ART 上各個版本的表現(xiàn)差異不大,都會在1秒內有幾次繪制延時,我們看到 ART 的巨大進步,同時也必須要避免寫出這樣的代碼。。。


寫在最后:

作為應用層開發(fā)者,我真的需要關心GC嗎?

寫這篇文章的時間是2021年,隨著時間的推移,Dalvik 設備的占比已經很小了,小到10億日活的微信也不再支持。


微信不再支持Lollipop以下的版本

伴隨著 ART 的推出和迭代,它變得很好了,我們能對 GC 做的優(yōu)化越來越少了,我們不需要格外關心GC,關心業(yè)務,關心應用的性能表現(xiàn)就足夠了。

Android已經迭代了十幾年,相比于之前的大刀闊斧,現(xiàn)在的重點在于細節(jié)上的深耕;隨著版本的更替,有些問題被時間解決了;當然,我們針對 Dalvik 做過的所有優(yōu)化,也隨之塵封到不為人知的歷史中,煙消云散;

Android 團隊的所有努力,就是讓應用層的程序員只關心應用層的業(yè)務。從 GC 的角度,他們基本做到了這一點。


能看到這里的朋友,建議閱讀/觀看下面的參考資料,價值連城~

Reference

Manage your app's memory

Memory allocation among processes

Trash talk (Android Dev Summit '18)

Android memory and games (Google I/O'19)

Deep dive into the ART runtime (Android Dev Summit '18)

Understanding Android Runtime (ART) for faster apps (Google I/O'19)

Episode 79: Picking Up Garbage (Android Developers Backstage)

Episode 160: ART History (Android Developers Backstage)

Episode 156: Android Runtime Classic (Dalvik) (Android Developers Backstage)

Episode 83: The Deal of the ART (Android Developers Backstage)

[https://proandroiddev.com/collecting-the-garbage-a-brief-history-of-gc-over-android-versions-f7f5583e433c

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容