引言
大量的使用者每天在Android裝置上使用Facebook,滾動新聞Feed流頁面,包括個人資料,活動,頁面和組,與他們關心的人員和資訊進行互動等一系列行為。 所有這些不同的Feed型別都由Android Feed Platform小組建立的平臺提供支援,因此我們對Feed平臺進行的任何優化都可能提高我們的應用程式的效能。 我們專注於頁面的滾動效能,因為我們希望使用者在滾動他們的Feed流頁面時有一個平滑的體驗。
為了幫助我們實現這一點,我們有幾種自動化工具,可以跨不同的場景和不同的裝置在Feed平臺上執行效能測試,測量程式碼在執行時記憶體使用,幀速率等方面的執行情況。 其中一個工具Traceview顯示了我們的程式對Long.valueOf()函式的呼叫次數相對較多,這導致物件在記憶體中累積並導致應用程式卡頓停止等。 這篇文章描述這個問題,我們權衡了各種潛在解決方案之後,對改進Feed流平臺而進行的一系列優化。
便利性帶來的缺點
我們從Traceview的一個方法分析報告中注意到:facebook的app對Long.valueOf()函式的大量呼叫。之後,我們進行了又進一步的測試,證實了當我們滾動新聞列表時,Long.valueOf()方法的呼叫會意外升高。
當我們檢視堆疊時,我們發現這個函式沒有被直接的從Facebook的程式碼呼叫,而是隱式地由編譯器插入的程式碼。 在分配長整型物件的原始長值時呼叫此函式。 Java支援物件和原始的簡單型別(例如,整數,字元),並提供了一種在它們之間無縫轉換的方式。 這種方式稱為自動裝箱,因為它將基本型別裝箱為相應的型別的物件型別。 雖然這是一個方便的開發功能,但是它同時也建立了開發人員不知道的新物件。
在對一個示例應用程式的堆疊中發現Long物件有大量的存在; 雖然每個物件本身都不大,但是存在的大量的Long物件佔據了應用程式在堆中的大部分記憶體。 對於執行Dalvik的裝置來說,會有很大的影響。 與Android的ART執行時環境不同,Dalvik沒有一代間垃圾回收機制,造成很多小物件的垃圾回收效率很低。 當我們滾動新聞Feed流,會造成Long物件數量增加,垃圾收集將導致應用程式卡頓來從記憶體中清除未使用的物件。 積累的物件越多,垃圾收集器將越來越頻繁地暫停應用程式,導致卡頓使得戶體驗不佳。
幸運的是,Traceview和Allocation Tracker等工具可以幫助我們找到這些函式呼叫的位置。 在檢視了這些自動裝箱事件的根源之後,我們發現大多數成因都是:將Long型別的基本型別資料插入HashSet 資料結構中造成。 (我們使用這個資料結構儲存新聞Feed的雜湊值,稍後檢查某個雜湊是否已經在Set中。)HashSet提供對具體feed的快速訪問。 由於雜湊計算並儲存在一個原始的長變數中,然而我們的HashSet僅適用於物件,所以當呼叫set.put(Hash)時,我們會得到不可避免的自動裝箱。
作為一個解決方案,可以使用基本資料型別而不是物件型別的Set實現,但是結果並不像我們預期的那麼簡單。
目前的解決方案
有幾個現有的Java庫為原始資料型別提供了Set實現。 幾乎所有這些類庫都是10多年前建立的,當時在移動裝置上執行的唯一的Java是J2ME。為了確定可行性,我們需要在Dalvik / ART下進行測試,並確保它們在資源更受限的移動裝置上表現良好。 我們建立了一個小型測試框架來幫助將這些庫與現有的HashSet進行比較。 結果表明,這些庫中的一些庫具有比HashSet更快的執行時間,並且具有較少的Long物件,但是它們仍然在內部分配了很多Long物件。 例如,Troow庫中的一部分TLongHashSet在測試時分配了大約2 MB的物件,共有1,000個item
對其他的類庫進行測試,包括PCJ和Colt, 顯示了類似的結果。
現有的解決方案不符合我們的需求。 我們考慮是否可以建立一個新的Set實現,並針對Android進行優化。 在Java的HashSet中,使用單個HashMap來實現一個相對簡單的實現。
public class HashSet<E> extends AbstractSet<E> implements Set<E>, ... {
transient HashMap<E, HashSet<E>> backingMap;
...
@Override public boolean add(E object) {
return backingMap.put(object, this) == null;
}
@Override public boolean contains(Object object) {
return backingMap.containsKey(object);
}
...
}
複製程式碼
向HashSet新增新item意味著將其新增到內部HashMap,其中物件是關鍵字,而HashSet的例項是該值。 要檢查物件成員身份,HashSet將檢查其內部HashMap是否包含物件作為鍵。 可以使用Android優化的map和相同的原則來實現HashSet的替代方案。
引進LongArraySet
你可能已經熟悉了LongSparseArray,它是Android支援類庫中的一個類,用作使用long型別作為key的map。 使用示例
LongSparseArray<String> longSparseArray = new LongSparseArray<>();
longSparseArray.put(3L, "Data");
String data = longSparseArray.get(3L); // the value of data is "Data"
複製程式碼
LongSparseArray的工作方式與HashMap不同。 當呼叫mapHashmap.get(KEY5)時,下圖說明了如何在HashMap中找到該值:
當使用HashMap上的鍵檢索值時,它使用金鑰的雜湊值作為索引訪問陣列中的值,即O(1)時間複雜度的的直接訪問。 對LongSparseArray進行相同的呼叫如下所示:
LongSparseArray使用二分搜尋,執行時間為O(log N)的時間複雜度操作搜尋排序金鑰陣列的金鑰值。 陣列中的鍵的索引值用於查詢values陣列中的值。
HashMap分配一個大陣列,以避免hash衝突,但是這樣導致搜尋速度較慢。 LongSparseArray分配兩個小陣列,使其記憶體佔用更小。 但是為了支援其搜尋演算法,LongSparseArray需要在連續的記憶體塊中分配其內部陣列。 新增更多的item將需要在當前空間不足的情況下分配新的陣列。 LongSparseArray的工作原理使得它在儲存超過1,000個專案時效率下降,這些差異對效能有更重要的影響。 (您可以在官方文件中瞭解有關LongSparseArray的更多資訊,並通過觀看Google的簡短視訊。)
由於LongSparseArray的鍵是原始long型別,所以我們可以使用與HashSet相同的方法建立一個資料結構,使用LongSparseArray作為內部對映而不是HashMap。
建立LongArraySet
新的資料結構更加合理。通過使用與之前相同的測試框架,我們將新的資料結構與HashSet進行了比較。 每個資料結構都通過新增X個item進行測試,檢查每個item的存在,然後刪除所有item。 我們使用不同數量的item(X = 10,X = 100,X = 1,000 ...)執行測試,並平均每個item完成每個操作所花費的時間。
執行時結果(時間顯示為納秒):
我們看到使用新資料結構的contains和delete方法的執行時效率改進。 另外,隨著陣列中item數的增加,新增新item花費更多時間。 這與我們已經知道的LongSparseArray是一致的 ,當item數量超過1,000時,它與HashMap的表現不一樣。 在我們的用例中,我們只處理了數百個item,所以這是一個我們願意做的權衡。我們也看到了記憶體使用有很大的改善。 在檢視堆轉儲和分配跟蹤報告時,我們注意到物件分配的減少。 下面是當新增1,000個item進行20次迭代時,HashSet和LongArraySet實現的並行分配報告:
除了避免所有Long物件分配之外,LongSparseArray更具有記憶體效率,在這種情況下的分配減少了約30%。
結論
通過了解其他資料結構如何工作,我們能夠為我們的需求建立一個更優化的資料結構。 垃圾收集器必須工作的越少,這樣丟幀的可能性就越低。 使用新的LongArraySet類和類似的IntArraySet作為原始int資料型別,我們能夠在整個應用程式中減少大量的物件記憶體分配。
這個案例研究表明了我們選擇資料結構的重要性。雖然這種解決方案對於所有用例來說並不完美,因為這種實現對於非常大的資料集來說較慢,但是還可以繼續對我們的程式碼進行優化。
你可以在下面網址找到兩個資料結構的原始碼。 我們很高興繼續努力應對挑戰,優化我們的Feed平臺,並與社群分享我們的解決方案。
https://code.facebook.com/posts/973222319439596