1. 程式人生 > >Android快取機制

Android快取機制

Android快取分為記憶體快取和檔案快取(磁碟快取)。在早期,各大圖片快取框架流行之前,常用的記憶體快取方式是軟引用(SoftReference)和弱引用(WeakReference),如大部分的使用方式:HashMap> imageCache;這種形式。從Android 2.3(Level 9)開始,垃圾回收器更傾向於回收SoftReference或WeakReference物件,這使得SoftReference和WeakReference變得不是那麼實用有效。同時,到了Android 3.0(Level 11)之後,圖片資料Bitmap被放置到了記憶體的堆區域,而堆區域的記憶體是由GC管理的,開發者也就不需要進行圖片資源的釋放工作,但這也使得圖片資料的釋放無法預知,增加了造成OOM的可能。因此,在Android3.1以後,Android推出了LruCache這個記憶體快取類,LruCache中的物件是強引用的。  

2.1 記憶體快取——LruCache原始碼分析

2.1.1 LRU

LRU,全稱Least Rencetly Used,即最近最少使用,是一種非常常用的置換演算法,也即淘汰最長時間未使用的物件。LRU在操作系統中的頁面置換演算法中廣泛使用,我們的記憶體或快取空間是有限的,當新加入一個物件時,造成我們的快取空間不足了,此時就需要根據某種演算法對快取中原有資料進行淘汰貨刪除,而LRU選擇的是將最長時間未使用的物件進行淘汰。  

2.1.2 LruCache實現原理

根據LRU演算法的思想,要實現LRU最核心的是要有一種資料結構能夠基於訪問順序來儲存快取中的物件,這樣我們就能夠很方便的知道哪個物件是最近訪問的,哪個物件是最長時間未訪問的。LruCache選擇的是LinkedHashMap這個資料結構,LinkedHashMap是一個雙向迴圈連結串列,在構造LinkedHashMap時,通過一個boolean值來指定LinkedHashMap中儲存資料的方式,LinkedHashMap的一個構造方法如下:
?
1 2 3 4 5 6 7 8 9 10 11 /* * 初始化LinkedHashMap * 第一個引數:initialCapacity,初始大小 * 第二個引數:loadFactor,負載因子=0.75f * 第三個引數:accessOrder=true,基於訪問順序;accessOrder=false,基於插入順序 */ public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); init(); this.accessOrder = accessOrder;
}
顯然,在LruCache中選擇的是accessOrder = true;此時,當accessOrder 設定為 true時,每當我們更新(即呼叫put方法)或訪問(即呼叫get方法)map中的結點時,LinkedHashMap內部都會將這個結點移動到連結串列的尾部,因此,在連結串列的尾部是最近剛剛使用的結點,在連結串列的頭部是是最近最少使用的結點,當我們的快取空間不足時,就應該持續把連結串列頭部結點移除掉,直到有剩餘空間放置新結點。 可以看到,LinkedHashMap完成了LruCache中的核心功能,那LruCache中剩下要做的就是定義快取空間總容量,當前儲存資料已使用的容量,對外提供put、get方法。  

2.1.3 LruCache原始碼分析

在瞭解了LruCache的核心原理之後,就可以開始分析LruCache的原始碼了。 (1)關鍵欄位 根據上面的分析,首先要有總容量、已使用容量、linkedHashMap這幾個關鍵欄位,LruCache中提供了下面三個關鍵欄位: ?
1 2 3 4 5 6 //核心資料結構 private final LinkedHashMap<k, v=""> map; // 當前快取資料所佔的大小 private int size; //快取空間總容量 private int maxSize;</k,>
要注意的是size欄位,因為map中可以存放各種型別的資料,這些資料的大小測量方式也是不一樣的,比如Bitmap型別的資料和String型別的資料計算他們的大小方式肯定不同,因此,LruCache中在計算放入資料大小的方法sizeOf中,只是簡單的返回了1,需要我們重寫這個方法,自己去定義資料的測量方式。因此,我們在使用LruCache的時候,經常會看到這種方式: ?
1 2 3 4 5 6 7 private static final int CACHE_SIZE = 4 * 1024 * 1024;//4Mib LruCache<string,bitmap> bitmapCache = new LruCache<string,bitmap>(CACHE_SIZE){ @Override protected int sizeOf(String key, Bitmap value) { return value.getByteCount();//自定義Bitmap資料大小的計算方式 } };</string,bitmap></string,bitmap>
(2)構造方法 ?
1 2 3 4 5 6 7 public LruCache(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } this.maxSize = maxSize; this.map = new LinkedHashMap<k, v="">(0, 0.75f, true); }</k,>
LruCache只有一個唯一的構造方法,在構造方法中,給定了快取空間的總大小,初始化了LinkedHashMap核心資料結構,在LinkedHashMap中的第三個引數指定為true,也就設定了accessOrder=true,表示這個LinkedHashMap將是基於資料的訪問順序進行排序。   (3)sizeOf()和safeSizeOf()方法 根據上面的解釋,由於各種資料型別大小測量的標準不統一,具體測量的方法應該由使用者來實現,如上面給出的一個在實現LruCache時重寫sizeOf的一種常用實現方式。通過多型的性質,再具體呼叫sizeOf時會呼叫我們重寫的方法進行測量,LruCache對sizeOf()的呼叫進行一層封裝,如下: ?
1 2 3 4 5 6 7 private int safeSizeOf(K key, V value) { int result = sizeOf(key, value); if (result < 0) { throw new IllegalStateException("Negative size: " + key + "=" + value); } return result; }
裡面其實就是呼叫sizeOf()方法,返回sizeOf計算的大小。 上面就是LruCache的基本內容,下面就需要提供LruCache的核心功能了。   (4)put方法快取資料 首先看一下它的原始碼實現: ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 <em>/** </em><em>   * 給對應key快取value,並且將該value移動到連結串列的尾部。 </em><em>   */ </em>public final V put(K key, V value) { if (key == null || value == null) { throw new NullPointerException("key == null || value == null"); } V previous; synchronized (this) { // 記錄 put 的次數 putCount++; // 通過鍵值對,計算出要儲存物件value的大小,並更新當前快取大小 size += safeSizeOf(key, value); /* * 如果 之前存在key,用新的value覆蓋原來的資料, 並返回 之前key 的value * 記錄在 previous */ previous = map.put(key, value); // 如果之前存在key,並且之前的value不為null if (previous != null) { // 計算出 之前value的大小,因為前面size已經加上了新的value資料的大小,此時,需要再次更新size,減去原來value的大小 size -= safeSizeOf(key, previous); } } // 如果之前存在key,並且之前的value不為null if (previous != null) { /* * previous值被剔除了,此次新增的 value 已經作為key的 新值 * 告訴 自定義 的 entryRemoved 方法 */ entryRemoved(false, key, previous, value); } //裁剪快取容量(在當前快取資料大小超過了總容量maxSize時,才會真正去執行LRU) trimToSize(maxSize); return previous; }
可以看到,put()方法主要有以下幾步: 1)key和value判空,說明LruCache中不允許key和value為null; 2)通過safeSizeOf()獲取要加入物件資料的大小,並更新當前快取資料的大小; 3)將新的物件資料放入到快取中,即呼叫LinkedHashMap的put方法,如果原來存在該key時,直接替換掉原來的value值,並返回之前的value值,得到之前value的大小,更新當前快取資料的size大小;如果原來不存在該key,則直接加入快取即可; 4)清理快取空間,如下;   (5)trimToSize()清理快取空間 當我們加入一個數據時(put),為了保證當前資料的快取所佔大小沒有超過我們指定的總大小,通過呼叫trimToSize()來對快取空間進行管理控制。如下: ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public void trimToSize(int maxSize) { /* * 迴圈進行LRU,直到當前所佔容量大小沒有超過指定的總容量大小 */ while (true) { K key; V value; synchronized (this) { // 一些異常情況的處理 if (size < 0 || (map.isEmpty() && size != 0)) { throw new IllegalStateException( getClass().getName() + ".sizeOf() is reporting inconsistent results!"); } // 首先判斷當前快取資料大小是否超過了指定的快取空間總大小。如果沒有超過,即快取中還可以存入資料,直接跳出迴圈,清理完畢 if (size <= maxSize || map.isEmpty()) { break; } <em>            /** </em><em>             * 執行到這,表示當前快取資料已超過了總容量,需要執行LRU,即將最近最少使用的資料清除掉,直到資料所佔快取空間沒有超標; </em><em>             * 根據前面的原理分析,知道,在連結串列中,連結串列的頭結點是最近最少使用的資料,因此,最先清除掉連結串列前面的結點 </em><em>             */ </em>            Map.Entry<k, v=""> toEvict = map.entrySet().iterator().next(); key = toEvict.getKey(); value = toEvict.getValue(); map.remove(key); // 移除掉後,更新當前資料快取的大小 size -= safeSizeOf(key, value); // 更新移除的結點數量 evictionCount++; } /* * 通知某個結點被移除,類似於回撥 */ entryRemoved(true, key, value, null); } }</k,>
trimToSize()方法的作用就是為了保證當前資料的快取大小不能超過我們指定的快取總大小,如果超過了,就會開始移除最近最少使用的資料,直到size符合要求。trimToSize()方法在put()的時候一定會呼叫,在get()的時候有可能會呼叫。   (6)get方法獲取快取資料 get方法原始碼如下: ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 <em>/** </em><em> * </em><em>根據</em><em>key</em><em>查詢快取,如果該</em><em>key</em><em>對應的</em><em>value</em><em>存在於快取,直接返回</em><em>value</em><em>; </em><em>* </em><em>訪問到這個結點時,</em><em>LinkHashMap</em><em>會將它移動到雙向迴圈連結串列的的尾部。 </em><em>* </em><em>如果如果沒有快取的值,則返回</em><em>null</em><em>。(如果開發者重寫了</em><em>create()</em><em>的話,返回建立的</em><em>value</em><em>) </em><em>*/ </em>public final V get(K key) { if (key == null) { throw new NullPointerException("key == null"); } V mapValue; synchronized (this) { // LinkHashMap 如果設定按照訪問順序的話,這裡每次get都會重整資料順序 mapValue = map.get(key); // 計算 命中次數 if (mapValue != null) { hitCount++; return mapValue; } // 計算 丟失次數 missCount++; } /* * 官方解釋: * 嘗試建立一個值,這可能需要很長時間,並且Map可能在create()返回的值時有所不同。如果在create()執行的時 * 候,用這個key執行了put方法,那麼此時就發生了衝突,我們在Map中刪除這個建立的值,釋放被建立的值,保留put進去的值。 */ V createdValue = create(key); if (createdValue == null) { return null; } <em>    /*************************** </em><em>     * </em><em>不覆寫</em><em>create</em><em>方法走不到下面 </em><em>* </em><em>     ***************************/</em> /* * 正常情況走不到這裡 * 走到這裡的話 說明 實現了自定義的 create(K key) 邏輯 * 因為預設的 create(K key) 邏輯為null */ synchronized (this) { // 記錄 create 的次數 createCount++; // 將自定義create建立的值,放入LinkedHashMap中,如果key已經存在,會返回 之前相同key 的值 mapValue = map.put(key, createdValue); // 如果之前存在相同key的value,即有衝突。 if (mapValue != null) { /* * 有衝突 * 所以 撤銷 剛才的 操作 * 將 之前相同key 的值 重新放回去 */ map.put(key, mapValue); } else { // 拿到鍵值對,計算出在容量中的相對長度,然後加上 size += safeSizeOf(key, createdValue); } } // 如果上面 判斷出了 將要放入的值發生衝突 if (mapValue != null) { /* * 剛才create的值被刪除了,原來的 之前相同key 的值被重新添加回去了 * 告訴 自定義 的 entryRemoved 方法 */ entryRemoved(false, key, createdValue, mapValue); return mapValue; } else { // 上面 進行了 size += 操作 所以這裡要重整長度 trimToSize(maxSize); return createdValue; } }
get()方法的思路就是:   1)先嚐試從map快取中獲取value,即mapVaule = map.get(key);如果mapVaule != null,說明快取中存在該物件,直接返回即可; 2)如果mapVaule == null,說明快取中不存在該物件,大多數情況下會直接返回null;但是如果我們重寫了create()方法,在快取沒有該資料的時候自己去建立一個,則會繼續往下走,中間可能會出現衝突,看註釋; 3)注意:在我們通過LinkedHashMap進行get(key)或put(key,value)時都會對連結串列進行調整,即將剛剛訪問get或加入put的結點放入到連結串列尾部。   (7)entryRemoved() entryRemoved的原始碼如下: ?
1 2 3 4 5 6 7 8 9 10 <em>/** </em><em> * 1.</em><em>當被回收或者刪掉時呼叫。該方法當</em><em>value</em><em>被回收釋放儲存空間時被</em><em>remove</em><em>呼叫 </em><em>* </em><em>或者替換條目值時</em><em>put</em><em>呼叫,預設實現什麼都沒做。 </em><em>* 2.</em><em>該方法沒用同步呼叫,如果其他執行緒訪問快取時,該方法也會執行。 </em><em>* 3.evicted=true</em><em>:如果該條目被刪除空間 (表示 進行了</em><em>trimToSize or remove</em><em>)  </em><em>evicted=false</em><em>:</em><em>put</em><em>衝突後 或 </em><em>get</em><em>裡成功</em><em>create</em><em>後 </em><em>* </em><em>導致 </em><em>* 4.newValue!=null</em><em>,那麼則被</em><em>put()</em><em>或</em><em>get()</em><em>呼叫。 </em><em>*/ </em>protected void entryRemoved(boolean evicted, K key, V oldValue, V newValue) { }
可以發現entryRemoved方法是一個空方法,說明這個也是讓開發者自己根據需求去重寫的。entryRemoved()主要作用就是在結點資料value需要被刪除或回收的時候,給開發者的回撥。開發者就可以在這個方法裡面實現一些自己的邏輯: (1)可以進行資源的回收; (2)可以實現二級記憶體快取,可以進一步提高效能,思路如下:重寫LruCache的entryRemoved()函式,把刪除掉的item,再次存入另外一個LinkedHashMap>中,這個資料結構當做二級快取,每次獲得圖片的時候,先判斷LruCache中是否快取,沒有的話,再判斷這個二級快取中是否有,如果都沒有再從sdcard上獲取。sdcard上也沒有的話,就從網路伺服器上拉取。 entryRemoved()在LruCache中有四個地方進行了呼叫:put()、get()、trimToSize()、remove()中進行了呼叫。   (8)LruCache的執行緒安全性 LruCache是執行緒安全的,因為在put、get、trimToSize、remove的方法中都加入synchronized進行同步控制。  

2.1.4 LruCache的使用

上面就是整個LruCache中比較核心的的原理和方法,對於LruCache的使用者來說,我們其實主要注意下面幾個點: (1)在構造LruCache時提供一個總的快取大小; (2)重寫sizeOf方法,對存入map的資料大小進行自定義測量; (3)根據需要,決定是否要重寫entryRemoved()方法; (4)使用LruCache提供的put和get方法進行資料的快取   小結:
  • LruCache 自身並沒有釋放記憶體,只是 LinkedHashMap中將資料移除了,如果資料還在別的地方被引用了,還是有洩漏問題,還需要手動釋放記憶體;

  • 覆寫entryRemoved方法能知道 LruCache 資料移除是是否發生了衝突(衝突是指在map.put()的時候,對應的key中是否存在原來的值),也可以去手動釋放資源;

 

2.2磁碟快取(檔案快取)——DiskLruCache分析

LruCache是一種記憶體快取策略,但是當存在大量圖片的時候,我們指定的快取記憶體空間可能很快就會用完,這個時候,LruCache就會頻繁的進行trimToSize()操作,不斷的將最近最少使用的資料移除,當再次需要該資料時,又得從網路上重新載入。為此,Google提供了一種磁碟快取的解決方案——DiskLruCache(DiskLruCache並沒有整合到Android原始碼中,在Android Doc的例子中有講解)。

2.2.1 DiskLruCache實現原理

我們可以先來直觀看一下,使用了DiskLruCache快取策略的APP,快取目錄中是什麼樣子,如下圖: \可以看到,快取目錄中有一堆檔名很長的檔案,這些檔案就是我們快取的一張張圖片資料,在最後有一個檔名journal的檔案,這個journal檔案是DiskLruCache的一個日誌檔案,即儲存著每張快取圖片的操作記錄,journal檔案正是實現DiskLruCache的核心。看到出現了journal檔案,基本可以說明這個APP使用了DiskLruCache快取策略。 根據對LruCache的分析,要實現LRU,最重要的是要有一種資料結構能夠基於訪問順序來儲存快取中的物件,LinkedHashMap是一種非常合適的資料結構,為此,DiskLruCache也選擇了LinkedHashMap作為維護訪問順序的資料結構,但是,對於DiskLruCache來說,單單LinkedHashMap是不夠的,因為我們不能像LruCache一樣,直接將資料放置到LinkedHashMap的value中,也就是處於記憶體當中,在DiskLruCache中,資料是快取到了本地檔案,這裡的LinkedHashMap中的value只是儲存的是value的一些簡要資訊Entry,如唯一的檔名稱、大小、是否可讀等資訊,如: ?
1
?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 private final class Entry { private final String key; <em>/** Lengths of this entry's files. */</em> private final long[] lengths; <em>/** True if this entry has ever been published */</em> private boolean readable; <em>/** The ongoing edit or null if this entry is not being edited. */</em> private Editor currentEditor; <em>/** The sequence number of the most recently committed edit to this entry. */</em> private long sequenceNumber; private Entry(String key) { this.key = key; this.lengths = new long[valueCount]; } public String getLengths() throws IOException { StringBuilder result = new StringBuilder(); for (long size : lengths) { result.append(' ').append(size); } return result.toString(); } <em>/**</em> <em>* Set lengths using decimal numbers like "10123".</em> <em>*/</em> private void setLengths(String[] strings) throws IOException { if (strings.length != valueCount) { throw invalidLengths(strings); } try { for (int i = 0; i < strings.length; i++) { lengths[i] = Long.<em>parseLong</em>(strings[i]); } } catch (NumberFormatException e) { throw invalidLengths(strings); } } private IOException invalidLengths(String[] strings) throws IOException { throw new IOException("unexpected journal line: " + Arrays.<em>toString</em>(strings)); } public File getCleanFile(int i) { return new File(directory, key + "." + i); } public File getDirtyFile(int i) { return new File(directory, key + "." + i + ".tmp"); } }
DiskLruCache中對於LinkedHashMap定義如下: ?
1 2 private final LinkedHashMap<string, entry=""> lruEntries = new LinkedHashMap<string, entry="">(0, 0.75f, true);</string,></string,>
在LruCache中,由於資料是直接快取中記憶體中,map中資料的建立是在使用LruCache快取的過程中逐步建立的,而對於DiskLruCache,由於資料是快取在本地檔案,相當於是持久儲存下來的一個檔