由單例模式引起的內存泄漏

1、背景

項目中部署了leakcanary,用於檢測app的內存泄漏情況,不知道leakcanary的同學可以自行百度。其中一處泄漏讓人印象深刻,提筆記錄一下。

2、場景復現

從主界面MainActivity點擊進入收藏界面FavoriteFragment,按一次返回鍵返回主界面MainActivity後再按一次返回鍵退出app,leakcanary捕捉到內存泄漏。(補充說明:FavoriteFragment裏面有個listview用以顯示收藏,這個listview就是FavoriteList)

public FavoriteList(Context context, ListView list, FavoriteDatabaseListener listener,
            FileIconHelper iconHelper,Handler handler) {
        mContext = context;
        mFavoriteDatabase = new FavoriteDatabaseHelper(context, this);
        mFavoriteDatabase.setListener(listener);
        ……
    }

leakcanary提示mFavoriteDatabase持有了MainActivity的context,導致內存泄漏。通常來說,有關數據庫的操作我們都會用單例模式來維護以節省開銷。在這個場景下,mFavoriteDatabase是單例模式維護,它的生命週期和application的生命週期一致,退出app後,系統會回收MainActivity此時application/mFavoriteDatabase沒有被回收。而mFavoriteDatabase持有MainActivity的引用,導致MainActivity不能被GC回收——內存泄漏。

3、解決方案

1、手動設置對象爲null

如果是由於mFavoriteDatabase持有MainActivity的引用,那最簡單的改法就是在MainActivity的onDestory()方法裏手動置FavoriteList爲null,這樣mFavoriteDatabase和MainActivity就不會存在引用關係了:

@Override
    protected void onDestroy() {
        super.onDestroy();
        mFavoriteList = null;
    }

這樣改有效嗎?
再次場景復現時,leakcanary提示依然捕捉到了內存泄漏。
難道mFavoriteList = null這句話沒有生效還是什麼原因?
有關手動設置null這個問題能否觸發GC回收在查閱了周志明老師《深入理解jvm虛擬機》一書,得到答案:這樣做並不能觸發GC回收。以下是書中摘錄:
賦null值的操作在某些情況下確實是有用的,但筆者的觀點是不應當對賦null值的操作有過多的依賴,更沒有必要把它當做一個普 遍的編碼規則來推廣。原因有兩點,從編碼角度講,以恰當的變量作用域來控制變量回收時間纔是最優雅的解決方法,如代碼清單8-3那樣的場景並不多見。更關鍵的是,從執行角度講,使用賦null值的操作來優化內存回收是建立在對字節碼執行引擎概念模型的理解之上 的,在第6章介紹完字節碼後,筆者專門增加了一個6.5節“公有設計、私有實現”來強調概念模型與實際執行過程是外部看起來等效,內部看上去則可以完全不同。在虛擬機使用解釋器執行時,通常與概念模型還比較接近,但經過JIT編譯器後,纔是虛擬機執行代碼的主要方 式,賦null值的操作在經過JIT編譯優化後就會被消除掉,這時候將變量設置爲null就是沒有意義的。字節碼被編譯爲本地代碼後,對GC Roots的枚舉也與解釋執行時期有巨大差別,以前面例子來看,代碼清單8-2在經過JIT編譯後,System.gc()執行時就可以正確地回收掉內存,無須寫成代碼清單8-3的樣子。
即:手動置null並不能保證GC一定會將該對象回收。那一個對象在什麼時候纔會被回收呢?依然引用書中的原話:
如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”。如果這個對象被判定爲有必要執行finalize()方法,那麼這個對象將會放置在一個叫做F-Queue的隊列之中,並在稍後由一個由虛擬機自動建立的、低優先級的Finalizer線程去執行它。這裏所謂的“執行”是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束,這樣做的原因是,如果一個對象在finalize()方法中執行緩慢,或者發生了死循環(更極端的情況),將很可能會導致F-Queue隊列中其他對象永久處於等待,甚至導致整個內存回收系統崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移除出“即將回收”的集合;如果對象這時候還沒有逃 脫,那基本上它就真的被回收了。
可以非常簡單地理解(不一定正確):一個對象在爲null且沒有任何對象持有它的引用的時候,下一個回收週期到來時GC會回收。

2、延長對象的生命週期

既然手動置對象爲null不能解決問題,那麼延長對象的生命週期呢?嘗試以下改動:

public FavoriteList(Context context, ListView list, FavoriteDatabaseListener listener,
            FileIconHelper iconHelper,Handler handler) {
        mContext = context;
        mFavoriteDatabase = new FavoriteDatabaseHelper(context.getApplicationContext(), this);
        mFavoriteDatabase.setListener(listener);
        ……
    }

將mFavoriteDatabase的生命週期設置和application的生命週期一致。這樣改的出發點是既然單例模式FavoriteDatabaseHelper的實例mFavoriteDatabase持有了一個比它生命週期還短的對象MainActivity的引用,導致GC無法回收,那我把它的生命週期延遲,和application保持一致,理論上應該不會再有內存泄露了吧。改好之後編譯運行,leakcanary依然報了內存泄露。這次報泄露的地方在mFavoriteDatabase.setListener(listener)這句,還是說單例模式FavoriteDatabaseHelper的實例mFavoriteDatabase持有了MainActivity的引用,導致內存泄露。分析代碼發現,FavoriteList的構造函數有一個FavoriteDatabaseListener類型的形參listener,這個listener是MainActivity的匿名內部類。我們都知道匿名內部類會默認持有外部類的引用,結果就是在執行mFavoriteDatabase.setListener(listener)這句時,mFavoriteDatabase持有了MainActivity的引用。這個listener的作用是什麼呢?就是一個回調接口,用於通知UI數據庫變化。那這個泄露其實還是單例模式引起,仔細分析代碼發現這個接口比較重要,無法通過其他方法修改。那怎麼在保存這個接口的情況下不導致內存泄露呢?

3、使用弱引用WeakReference

弱引用WeakReference提供了若於強引用和軟引用之後的引用關係,一個對象被Weakreference修飾而沒有任何其他strong reference指向的時候, 如果GC運行, 那麼這個對象就會被回收。修改之前的代碼:

……
private FavoriteDatabaseListener mListener;

public FavoriteDatabaseHelper(Context context, FavoriteDatabaseListener listener) {
    super(context, DATABASE_NAME, null, DATABASE_VERSION);
    mListener = listener;
}
……

修改之後的代碼:

……
private WeakReference<FavoriteDatabaseListener> mListener;

public FavoriteDatabaseHelper(Context context, FavoriteDatabaseListener listener) {
	super(context, DATABASE_NAME, null, DATABASE_VERSION);
 	mListener = new WeakReference<>(listener);
}
……

使用mListener之前需先判空:

mListener.get().

編譯運行,這次不在有內存泄露,問題解決。
使用以下代碼查看mFavoriteDatabase的引用情況,發現mListener不再持有MainActivity的引用:

Field[] dataBaseHelperField = mFavoriteDatabase.getClass().getDeclaredFields();

相關參考:https://medium.com/freenet-engineering/memory-leaks-in-android-identify-treat-and-avoid-d0b1233acc8
相關參考:https://stackoverflow.com/questions/34621640/when-an-anonymous-class-with-no-references-to-its-enclosing-class-is-returned-fr
相關參考:https://www.nowcoder.com/questionTerminal/fbef4d5971ce4009aa720aecf7d83f3c?pos=81&mutiTagIds=570&orderByHotValue=1
相關參考:https://blog.csdn.net/cao_dayong/article/details/64447191?locationNum=14&fps=1
相關參考:https://blog.csdn.net/xwx617/article/details/81193102

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章