一篇文章帶你瞭解 Java 自動記憶體管理機制及效能優化

滌生_Woo發表於2018-07-15

同樣的,先來個思維導圖預覽一下本文結構。

一圖帶你看完本文

一、執行時資料區域

首先來看看Java虛擬機器所管理的記憶體包括哪些區域,就像我們要了解一個房子,我們得先知道這個房子大體構造。根據《Java虛擬機器規範(Java SE 7 版)》的規定,請看下圖:

Java 虛擬機器執行時資料區

1.1 程式計數器

程式計數器是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。

  • 由於 Java 虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。
  • 為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。
  • 此記憶體區域是唯一一個在 Java 虛擬機器規範中沒有規定任何 OutOfMemoryError 情況的區域。

1.2 Java 虛擬機器棧

與程式計數器一樣,Java 虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是 Java 方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。請看下圖:

Java 虛擬機器棧

  • 有人把 Java 記憶體區分為堆記憶體和棧記憶體,而所指的“棧”就是這裡的虛擬機器棧,或者說是虛擬機器棧中區域性變數表部分。
  • 區域性變數表存放了編譯期可知的各種基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用和 returnAddress 型別(指向了一條位元組碼指令的地址),其中64位長度的 long 和 double 型別的資料佔用2個區域性變數空間,其餘資料型別只佔用1個。
  • 運算元棧也常被稱為操作棧,它是一個後入先出棧。當一個方法剛剛執行的時候,這個方法的運算元棧是空的,在方法執行的過程中,會有各種位元組碼指向運算元棧中寫入和提取值,也就是入棧與出棧操作。
  • 每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。在Class檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用為引數。這些符號引用一部分會在類載入階段或第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次的執行期期間轉化為直接引用,這部分稱為動態連線。
  • 當一個方法執行完畢之後,要返回之前呼叫它的地方,因此在棧幀中必須儲存一個方法返回地址。方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變數表和運算元棧,把返回值(如果有的話)壓入呼叫都棧幀的運算元棧中,呼叫PC計數器的值以指向方法呼叫指令後面的一條指令等。
  • 虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀中,例如與高度相關的資訊,這部分資訊完全取決於具體的虛擬機器實現。在實際開發中,一般會把動態連線,方法返回地址與其它附加資訊全部歸為一類,稱為棧幀資訊。
  • 在 Java 虛擬機器規範中,規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲 StackOverflowError 異常;如果虛擬機器棧可以動態擴充套件,當擴充套件時無法申請到足夠的記憶體,就會丟擲 OutOfMemoryError 異常。
1.2.1 虛擬機器棧溢位
  1. 如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲 StackOverflowError 異常。
  2. 如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲 OutOfMemoryError 異常。
  • 當棧空間無法繼續分配時,到底是記憶體太小,還是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。
  • 系統分配給每個程式的記憶體是有限制的,除去 Java 堆、方法區、程式計數器,如果虛擬機器程式本身耗費的記憶體不計算在內,剩下記憶體就由虛擬機器棧和本地方法棧“瓜分”了。每個執行緒分配到的棧容量越大,可以建立的執行緒數量自然就越少,建立執行緒時就越容易把剩下的記憶體耗盡。
  • 出現 StackOverflowError 異常時有錯誤棧可以閱讀,棧深度在大多數情況下達到1000~2000完全沒有問題,對於正常的方法呼叫(包括遞迴),這個深度應該完全夠用了。
  • 但是,如果是建立過多執行緒導致的記憶體溢位,在不能減少執行緒數或者更換 64 位虛擬機器的情況下,就只能通過減少最大堆和減少棧容量來換取更多的執行緒。

1.3 本地方法棧

  • 本地方法棧與虛擬機器棧所發揮的作用非常相似,它們之間的區別是虛擬機器棧為虛擬機器執行 Java 方法服務,而本地方法棧則為虛擬機器棧使用到的 Native 方法服務。
  • 與虛擬機器棧一樣,本地方法棧區域也會丟擲 StackOverflowError 和 OutOfMemoryError 異常。

1.4 Java 堆

Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體(但是,隨著技術發展,所有物件都分配在堆上也漸漸變得不是那麼“絕對”了)。請看下圖:

Generational Heap Memory 模型

  • 對於大多數應用來說,Java 堆是 Java 虛擬機器所管理的記憶體中最大的一塊。
  • Java 堆是垃圾收集器管理的主要區域,也被稱為“GC堆”。
  • Java 堆可以細分為新生代、老年代、永久代;再細緻一點可以分為 Eden、From Survivor、To Survivor、Tenured、Permanent 。
  • Java 堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像磁碟空間一樣。
  • 從記憶體分配的角度來看,執行緒共享的 Java 堆中可能劃分出多個執行緒私有的分配緩衝區(TLAB)。
  • 如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲 OutOfMemoryError 異常。
1.4.1 Java 堆溢位
  • Java 堆用於儲存物件例項,只要不斷地建立物件,並且保證 GC Roots 到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件數量到達最大堆的容量限制後就會產生記憶體溢位異常。
  • Java 堆記憶體的 OOM 異常是實際應用中常見的記憶體溢位異常情況。當出現 Java 堆記憶體溢位時,異常堆疊資訊 “java.lang.OutOfMemoryError” 會跟著進一步提示 “Java heap space” 。
  • 通常是先通過記憶體映像分析工具對 Dump 出來的堆轉儲快照進行分析,重點是確認記憶體中的物件是否是必要的,也就是要先分清楚到底是出現了記憶體洩漏還是記憶體溢位。
  • 如果是記憶體洩漏,可進一步通過工具檢視洩露物件到 GC Roots 的引用鏈。於是就能找到洩露物件的型別資訊及 GC Roots 引用鏈的資訊,就可以比較準確地定位出洩露程式碼的位置。
  • 如果不存在洩露,就是記憶體中的物件確實都還必須存活著,那就應當檢查虛擬機器的堆引數(-Xmx 與 -Xms),與機器實體記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長的情況,嘗試減少程式執行期的記憶體消耗。

1.5 方法區

方法區與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

  • Java 虛擬機器規範對方法區的限制非常寬鬆,除了和 Java 堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。
  • 這區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝。
  • 當方法區無法滿足記憶體分配需求時,將丟擲 OutOfMemoryError 異常。
1.5.1 執行時常量池
  • 執行時常量池是方法區的一部分。
  • 常量池用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。
  • 執行時常量池相對於 Class 檔案常量池的一個重要特徵是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是並非預置入 Class 檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是 String 類的 intern() 方法。
  • 當常量池無法再申請到記憶體時會丟擲 OutOfMemoryError 異常。在 OutOfMemoryError 後面跟隨的提示資訊時 “PermGen space” 。

1.6 直接記憶體

  • 直接記憶體並不是虛擬機器執行時資料區的一部分,也不是 Java 虛擬機器規範中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致 OutOfMemoryError 異常出現。
  • NIO 類,一種基於通道與緩衝區的 I/O 方式,它可以使用 Native 函式庫直接分配堆外記憶體,然後通過一個儲存在 Java 堆中的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆中來回複製資料。
  • 本機直接記憶體的分配不會受到 Java 堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體(包括 RAM 以及 SWAP 區或者分頁檔案)大小以及處理器定址空間的限制。
  • 由 DirectMemory 導致的記憶體溢位,一個明顯的特徵是在 Heap Dump 檔案中不會看見明顯的異常,如果我們發現 OOM 之後 Dump 檔案很小,而程式中有直接或間接使用了 NIO ,那就可以考慮檢查一下是不是這方面的原因。

二、記憶體分配策略

物件的記憶體分配,往大方向講,就是在堆上分配(但也可能經過 JIT 編譯後被拆散為標量型別並間接地棧上分配),物件主要分配在新生代的 Eden 區上,如果啟動了本地執行緒分配緩衝,將按執行緒優先在 TLAB 上分配。少數情況下也可能會直接分配在老年代中,分配的規則並不是固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器中與記憶體相關的引數的設定。

2.1 物件優先在 Eden 分配

大多數情況下,物件在新生代 Eden 區中分配。當 Eden 區沒有足夠的空間進行分配時,虛擬機器將發起一次 Minor GC 。舉個例子,看下面的程式碼:

private static final int _1MB = 1024 * 1024;

    /**
     * VM 引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     */
    private static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];//出現一次 Minor GC
    }
複製程式碼

執行上面的testAllocation() 程式碼,當分配 allocation4 物件的語句時會發生一次 Minor GC ,這次 GC 的結果是新生代 6651KB 變為 148KB ,而總記憶體佔用量則幾乎沒有減少(因為 allocation1、allocation2、allocation3 三個物件都是存活的,虛擬機器幾乎沒有找到可回收的物件)。這次 GC 發生的原因是給 allocation4 分配記憶體時,發現 Eden 已經被佔用了 6MB ,剩餘空間已不足以分配 allocation4 所需的 4MB 記憶體,因此發生 Minor GC 。GC 期間虛擬機器又發現已有的 3 個 2MB 大小的物件全部無法放入 Survivor 空間(從上圖中可看出 Survivor 空間只有 1MB 大小),所以只好通過分配擔保機制提前轉移到老年代去。

2.2 大物件直接進入老年代

  • 所謂的物件是指,需要大量連續記憶體空間的 Java 物件,最典型的大物件就是那種很長的字串以及陣列。經常出現大物件容易導致記憶體還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。
  • 虛擬機器提供了一個 -XX:PretenureSizeThreshold 引數,令大於這個設定值的物件直接在老年代分配。這樣做的目的是避免在 Eden 區及兩個 Survivor 區之間發生大量的記憶體複製(新生代採用複製演算法收集記憶體)。

2.3 長期存活的物件將進入老年代

既然虛擬機器採用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識別到哪些物件應放在新生代,哪些物件應放在老年代中。為了做到這點,虛擬機器給每個物件定義了一個物件年齡計數器。如果物件在 Eden 出生並經過第一次 Minor GC 後仍然存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並且物件年齡設為 1 。物件在 Survivor 區中每“熬過”一次 Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設15歲),就會被晉升到老年代中。物件晉升老年代的年齡閾值,可以通過引數 -XX:MaxTenuringThreshold 設定。

2.4 動態物件年齡判定

為了能更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件的年齡必須達到了 MaxTenuringThreshold 才能晉升老年代,如果在 Survivor 空間中相同年齡所有物件大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到 MaxTenuringThreshold 中的要求的年齡。

2.5 空間分配擔保機制

  • 在發生 Minor GC 之前,虛擬機器會先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果這個條件成立,那麼 Minor GC 可以確保是安全的。如果不成立,則虛擬機器會檢視 HandlePromotionFailure 設定值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次 Minor GC ,儘管這次 Minor GC 是有風險的;如果小於,或者 HandlePromotionFailure 設定不允許冒險,那這次也要改為進行一次 Full GC。
  • 上面提到的“冒險”指的是,由於新生代使用複製收集演算法,但為了記憶體利用率,只使用其中一個 Survivor 空間來作為輪換備份,因此當出現大量物件在 Minor GC 後仍然存活的情況,把 Survivor 無法容納的物件直接進入老年代。老年代要進行這樣的擔保,前提是老年代本身還有容納這些物件的剩餘空間,一共有多少物件會活下來在實際完成記憶體回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代物件容量的平均大小值作為經驗值,與老年代的剩餘空間進行比較,決定是否進行 Full GC 來讓老年代騰出更多空間。
  • 取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次 Minor GC 存活後的物件突增,遠遠高於平均值的話,依然會導致擔保失敗。
  • 如果出現了HandlePromotionFailure 失敗,那就只好在失敗後重新發起一次 Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將 HandlePromotionFailure 開關開啟,避免 Full GC 過於頻繁。
  • 但在 JDK 6 Update 24 之後,HandlePromotionFailure 引數不會再影響到虛擬機器的控制元件分配擔保策略,只要老年代的連續空間大於新生代物件總大小或者歷次晉升的平均大小就會進行 Minor GC ,否則將進行 Full GC。

三、記憶體回收策略

  • 新生代 GC(Minor GC) :指發生在新生代的垃圾收集動作,因為 Java 物件大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
  • 老年代 GC(Major GC / Full GC):值發生在老年代的 GC,出現了 Major GC,經常會伴隨至少一次的 Minor GC(但非絕對)。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。

3.1 記憶體回收關注的區域

  • 上面已經介紹 Java 記憶體執行時區域的各個部分,其中程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅。
  • 棧中的棧幀隨著方法的進入和退出而有條不紊地執行者出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的。
  • 因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。
  • 而 Java 堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,垃圾收集器所關注的是這部分記憶體。

3.2 物件存活判斷

3.2.1 引用計數演算法
  • 給物件新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為 0 的物件就是不可能再被使用的。
  • 這種演算法的實現簡單,判定效率也很高,在大部分情況下它都是一個不錯的演算法,但它很難解決物件之間相互迴圈引用的問題。
  • 舉個例子,物件 objA 和 objB 都有欄位 instance,賦值令 objA.instance = objB 及 objB.instance = objA ,除此之外,這兩個物件再無任何引用,實際上,這兩個物件已經不可能再被訪問,但是它們因為相互引用著對方,導致它們的引用計數都不為 0,於是引用計數演算法無法通知 GC 收集器回收它們。
3.2.2 可達性分析演算法
  • 這個演算法的基本思路就是通過一系列額稱為“GC Roots” 的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到 GC Roots 沒有任何引用鏈相連或者說這個物件不可達時,則證明此物件是不可用的。
  • 在 Java 語言中,可作為 GC Roots 的物件包括以下:
  1. 虛擬機器棧(棧幀中的本地變數表)中引用的物件
  2. 方法區中類靜態屬性引用的物件
  3. 方法區中常量引用的物件
  4. 本地方法棧中 JNI 引用的物件

請看下圖:

可達性分析演算法

3.3 方法區的回收

  • 方法區(HotSpot 虛擬機器中的永久代)的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。回收廢棄常量與回收 Java 堆的物件非常類似。
  • 判定一個類是否是“無用的類”需要同時滿足下面3個條件:
  1. 該類的所有的例項都已經被回收,也就是 Java 堆中不存在該類的任何例項。
  2. 載入該類的 ClassLoader 已經被回收。
  3. 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
  • 虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣,不使用了就必然回收。

3.4 垃圾收集演算法

3.4.1 標記—清除演算法
  • 演算法分為 “標記” 和 “清除” 兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
  • 它主要有兩個不足的地方:一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而得不到提前觸發另一次垃圾收集動作。
  • 這是最基礎的收集演算法,後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。

“標記—清除”演算法示意圖

3.4.2 複製演算法
  • 為了解決效率問題,“複製”演算法應運而生,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中一塊。
  • 當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。
  • 這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。
  • 不足之處是,將記憶體縮小為原來的一半,代價太高。

複製演算法示意圖

舉個優化例子:新生代中的物件98%是“朝生夕死”的,所以並不需要按照 1:1 的比例來劃分記憶體空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活著的物件一次性地複製到另一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間。

再舉個優化例子:將 Eden 和 Survivor 的大小比例設為 8:1 ,也就是每次新生代中可用記憶體空間為整個新生代容器的 90%,只有10% 的記憶體作為保留區域。當然 98% 的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於 10% 的物件存活,當 Survivor 空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(空間分配擔保機制在上面,瞭解一下)。

3.4.3 標記—整理演算法

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。所以在老年代一般不能直接選用複製收集演算法。

  • 根據老年代的特點,“標記—整理” 演算法應運而生。
  • 標記過程仍然與 “標記—清除” 演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

“標記—整理”演算法示意圖

3.4.4 分代收集演算法
  • 根據物件存活週期的不同將記憶體劃分為幾塊,一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。
  • 在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。
  • 而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用 “標記—清除” 或者 “標記—整理” 演算法來進行回收。
  • 當前商業虛擬機器的垃圾收集都採用 “分代收集” 演算法。

四、程式設計中的記憶體優化

相信大家在程式設計中都會注意到記憶體使用的問題,下面我就簡單列一下在實際操作當中需要注意的地方。

4.1 減小物件的記憶體佔用

  • 使用更加輕量的資料結構

我們可以考慮使用 ArrayMap / SparseArray 而不是 HashMap 等傳統資料結構。(我在老專案中,根據 Lint 提示,將 HashMap 替換成 ArrayMap / SparseArray 之後,在 Android Profiler 中顯示執行時記憶體比之前直接少了幾M,還是挺可觀的。)

  • 避免使用 Enum
  • 減小 Bitmap 物件的記憶體佔用
  1. inSampleSize :縮放比例,在把圖片載入記憶體之前,我們需要先計算出一個合適的縮放比例,避免不必要的大圖載入。
  2. decode format:解碼格式,選擇 ARGB_8888 / RBG_565 / ARGB_4444 / ALPHA_8,存在很大差異。
  • 使用更小的圖片:儘量使用更小的圖片不僅僅可以減少記憶體的使用,還可以避免出現大量的 InflationException。

4.2 記憶體物件的重複利用

  • 複用系統自帶的資源:Android系統本身內建了很多的資源,例如字串/顏色/圖片/動畫/樣式以及簡單佈局等等,這些資源都可以在應用程式中直接引用。
  • 注意在 ListView / GridView 等出現大量重複子元件的檢視裡面對 ConvertView 的複用
  • Bitmap 物件的複用
  • 避免在 onDraw 方法裡面執行物件的建立:類似 onDraw() 等頻繁呼叫的方法,一定需要注意避免在這裡做建立物件的操作,因為他會迅速增加記憶體的使用,而且很容易引起頻繁的 GC,甚至是記憶體抖動。
  • StringBuilder:在有些時候,程式碼中會需要使用到大量的字串拼接的操作,這種時候有必要考慮使用 StringBuilder 來替代頻繁的 “+” 。

4.3 避免物件的記憶體洩露

  • 注意 Activity 的洩漏
  1. 內部類引用導致 Activity 的洩漏
  2. Activity Context 被傳遞到其他例項中,這可能導致自身被引用而發生洩漏。
  • 考慮使用 Application Context 而不是 Activity Context :對於大部分非必須使用 Activity Context 的情況(Dialog 的 Context 就必須是 Activity Context),我們都可以考慮使用 Application Context 而不是 Activity 的 Context,這樣可以避免不經意的 Activity 洩露。
  • 注意臨時 Bitmap 物件的及時回收:例如臨時建立的某個相對比較大的 bitmap 物件,在經過變換得到新的 bitmap 物件之後,應該儘快回收原始的 bitmap,這樣能夠更快釋放原始 bitmap 所佔用的空間。
  • 注意監聽器的登出:在 Android 程式裡面存在很多需要 register 與 unregister 的監聽器,我們需要確保在合適的時候及時 unregister 那些監聽器。自己手動 add 的 listener,需要記得及時 remove 這個 listener。
  • 注意快取容器中的物件洩漏:我們為了提高物件的複用性把某些物件放到快取容器中,可是如果這些物件沒有及時從容器中清除,也是有可能導致記憶體洩漏的。
  • 注意 WebView 的洩漏:通常根治這個問題的辦法是為 WebView 開啟另外一個程式,通過 AIDL 與主程式進行通訊,WebView 所在的程式可以根據業務的需要選擇合適的時機進行銷燬,從而達到記憶體的完整釋放。
  • 注意 Cursor 物件是否及時關閉

4.4 記憶體使用策略優化

  • 資原始檔需要選擇合適的資料夾進行存放
  • Try catch 某些大記憶體分配的操作:在某些情況下,我們需要事先評估那些可能發生 OOM 的程式碼,對於這些可能發生 OOM 的程式碼,加入 catch 機制,可以考慮在 catch 裡面嘗試一次降級的記憶體分配操作。例如 decode bitmap 的時候,catch 到 OOM,可以嘗試把取樣比例再增加一倍之後,再次嘗試 decode。
  • 謹慎使用 static 物件:因為static的生命週期過長,和應用的程式保持一致,使用不當很可能導致物件洩漏。
  • 特別留意單例物件中不合理的持有:因為單例的生命週期和應用保持一致,使用不合理很容易出現持有物件的洩漏。
  • 珍惜Services資源:建議使用 IntentService
  • 優化佈局層次,減少記憶體消耗:越扁平化的檢視佈局,佔用的記憶體就越少,效率越高。我們需要儘量保證佈局足夠扁平化,當使用系統提供的 View 無法實現足夠扁平的時候考慮使用自定義 View 來達到目的。
  • 謹慎使用 “抽象” 程式設計
  • 使用 nano protobufs 序列化資料
  • 謹慎使用依賴注入框架
  • 謹慎使用多程式
  • 使用 ProGuard 來剔除不需要的程式碼
  • 謹慎使用第三方 libraries
  • 考慮不同的實現方式來優化記憶體佔用

五、記憶體檢測工具

最後給推薦幾個記憶體檢測的工具,具體使用方法,可以自行搜尋。當然除了下面這些工具,應該還有更多更好用的工具,只是我還沒有發現,如有建議,可以在文章下面評論留言,大家一起學習分享一下。

  • Systrace
  • Traceview
  • Android Studio 3.0 的 Android Profiler 分析器
  • LeakCanary

後續

學習資料
  • 《深入理解Java虛擬機器:JVM高階特性與最佳實踐》
  • Android效能優化典範

相關文章