OOM的起點到終點

玩毛線的包子發表於2019-03-12

前言

1.問題及現象

線上日誌反饋記憶體溢位問題。根據使用者反饋,客戶操作一段時間之後,APP 記憶體溢位崩潰。

2.分析過程

(1) 分析線上日誌,發現主要分兩種:

第一種如下,可能是某個死迴圈導致記憶體不停增大導致:

java.lang.OutOfMemoryError: OutOfMemoryError thrown while trying to throw OutOfMemoryError; no stack trace available

第二種如下,壓死駱駝的最後一根稻草:

java.lang.OutOfMemoryError: Failed to allocate a 16 byte allocation with 0 free bytes and 3GB until OOM, max allowed footprint 536872136, growth limit 536870912

這兩種情況下都無法定位問題所在。

通過觀察發生的版本資訊,是從V9.5.6開始的,於是就開始查那期改動的功能(主要是webview優化,其他的是SDK更新),由於沒有線上出問題的機型,就找個類似的華為機器操作webview,發現記憶體增長並不快,操作很長時間記憶體變化不大。

(2) 分析使用者操作路徑

讓工程匯出出現OOM問題使用者最近的操作記錄,埋點,頁面停留,崩潰日誌,觀察進入了哪些頁面,但是照著操作路徑操作,也沒發現崩潰。

(3) 統計出現問題的機型,前五都是華為的,系統版本7.0以上,記憶體3GB以上。

(4) 使用LeakCanary + Android Profiler排查洩漏的地方,檢視線上日誌的時候,偶然發現測試有一部手機出現了問題,華為P8,然後使用工具根據使用者操作路徑查各個模組洩漏問題,發現委託登入有個持續的10M洩漏,比較大的洩漏還有開屏廣告頁和行情登入頁面34M。這個手機給APP分配的可用記憶體最大為256M,操作幾次登入頁面就崩潰了。

OOM的起點到終點

開屏頁:

private ScheduledFuture<?> mTaskFuture = null;

private void gotoMain() {
    ...
    AdCountDownTask adCountDownTask = new AdCountDownTask();
    HexinThreadPool.cancelTaskFutre(mTaskFuture, true);
    mTaskFuture = HexinThreadPool.getThreadPool().sheduleWithFixedDelay(xxx);
    handler.sendEmptyMessageDelayed(xxx, xxx);
}

protected void onDestory() {
    ...
    if (mTaskFuture != null) {
        HexinThreadPool.cancelTaskFutre(mTaskFuture, true);
        mTaskFuture = null;
    }
}

複製程式碼

委託登入

public class AdsCT {
    private Runnable runable = new Runnable {
        // 輪播圖片
        handler.postDelay(runnable, time);
    }
    
    public void onRemove() {
        handler.removeCallbacksAndMessages(null);
    }
}

public class WeituoLogin {
    public void onRemove() {
        adsCt.onRemove();
    }
}
複製程式碼

(5) 修改查出來比較大的記憶體洩漏,券商給之前出問題的兩個使用者單獨安裝,發現還是存在,券商反饋有個使用者安裝9.4.5的一直都沒問題,升級到最新之後就出現問題了,現在懷疑還是9.4.6哪個模組導致的。

一:OOM是什麼?

全稱“Out Of Memory”,翻譯成中文就是“記憶體用完了”,來源於java.lang.OutOfMemoryError。

二:OOM是哪裡報錯的?

Java記憶體模型

OOM的起點到終點
JVM記憶體結構主要有三大塊:堆記憶體、方法區和棧。堆記憶體是JVM中最大的一塊由年輕代和老年代組成,而年輕代記憶體又被分成三部分,Eden空間、From Survivor空間、To Survivor空間,預設情況下年輕代按照8:1:1的比例來分配;

方法區儲存類資訊、常量、靜態變數等資料,是執行緒共享的區域,為與Java堆區分,方法區還有一個別名Non-Heap(非堆);棧又分為java虛擬機器棧和本地方法棧主要用於方法的執行。

三:為什麼會發生OOM?

概念

記憶體洩露:申請使用完的記憶體沒有釋放,導致虛擬機器不能再次使用該記憶體,此時這段記憶體就洩露了,因為申請者不用了,而又不能被虛擬機器分配給別人用。

記憶體溢位:申請的記憶體超出了JVM能提供的記憶體大小,此時稱之為溢位。

GC機制

可達性分析(Reachability Analysis):從GC Roots開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。不可達物件。

申請一塊記憶體時,如果當前可用記憶體不足,則會出發一次GC,然後再次申請,此時如果記憶體還不足,則會丟擲OOM。

四:OOM常見例項和解決方案

1.內部類持有外部引用-Handler,Context

    // 錯誤示例,內部類持有外部物件導致洩漏
    private class MyHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
        }
    }
    
    // 修改後,在activity onDestory()的時候,呼叫myHandler.removeCallbacksAndMessages(null);
    private static class MyHandler extends Handler {
        private WeakReference<MainActivity> mainActivityWeakReference;

        public MyHandler(MainActivity mainActivity) {
            mainActivityWeakReference = new WeakReference<MainActivity>(mainActivity);
        }

        @Override
        public void handleMessage(Message msg) {
            MainActivity mainActivity = mainActivityWeakReference.get();
            if (mainActivity == null) {
                return;
            }
            mainActivity.jump(null);
            super.handleMessage(msg);
        }
    }
    
    // context被執行緒持有
    private void registerException(final Context context) {
        Thread.UncaughtExceptionHandler uncaughtExceptionHandler = new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                String appName = context.getResources().getString(R.string.app_name);
            }
        };
    }
複製程式碼

jvm四種引用

strong soft weak phantom(其實還有一種FinalReference,這個由jvm自己使用,外部無法呼叫到),主要的區別體現在gc上的處理,如下:

Strong型別,也就是正常使用的型別,不需要顯示定義,只要沒有任何引用就可以回收

SoftReference型別,如果一個物件只剩下一個soft引用,在jvm記憶體不足的時候會將這個物件進行回收

WeakReference型別,如果物件只剩下一個weak引用,那gc的時候就會回收。和SoftReference都可以用來實現cache

PhantomReference型別,

2.資源沒有關閉-資料庫,流

    private void testStream() {
        InputStream in = null;
        try {
            in = new FileInputStream("xxx.xml");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                in = null;
            }
        }
    }

複製程式碼

3.靜態變數持有物件

單例持有Context,監聽器等

五:OOM檢查工具-LeakCanary原理

1.監聽物件

Activity的監聽,是在Activity onDestory()執行之後再看是否被回收,在Application建立的時候,註冊了監聽

Application.ActivityLifecycleCallbacks lifecycleCallbacks = new..{
    ...
    @Override public void onActivityDestroyed(Activity activity) {
          ActivityRefWatcher.this.onActivityDestroyed(activity);
        }
}
複製程式碼

工程中檢視Page是否被釋放,可以在onRemove()的時候使用

RefWatcher watch(Object obj)
複製程式碼

2.如何判斷物件是否被釋放?

使用的WeakReference+ReferenceQueue實現

final KeyedWeakReference reference =
        new KeyedWeakReference(watchedReference, key, referenceName, queue);
複製程式碼

如果軟引用或弱引用所引用的物件被垃圾回收器回收,Java虛擬機器就會把這個軟引用或弱引用加入到與之關聯的引用佇列中,由此可判斷物件是否被回收。

3.怎麼讓系統回收物件?

    @Override public void runGc() {
      Runtime.getRuntime().gc();
      enqueueReferences();
      System.runFinalization(); //強制呼叫已經失去引用的物件的finalize方法,確保釋放例項佔用的全部資源。
    }
    
    private void enqueueReferences() {
      try {
        Thread.sleep(100);
      } catch (InterruptedException e) {
        throw new AssertionError();
      }
    }
  };
複製程式碼

4.如何分析物件洩漏?

(1) 對記憶體檔案進行分析,由於這個過程比較耗時,因此最終會把這個工作交給執行在另外一個程式中的HeapAnalyzerService來執行。

(2) 利用HAHA將之前dump出來的記憶體檔案解析成Snapshot物件,解析得到的Snapshot物件直觀上和我們使用MAT進行記憶體分析時候羅列出記憶體中各個物件的結構很相似,它通過物件之間的引用鏈關係構成了一棵樹,我們可以在這個樹種查詢到各個物件的資訊,包括它的Class物件資訊、記憶體地址、持有的引用及被持有的引用關係等。

(3) 為了能夠準確找到被洩漏物件,LeakCanary通過被洩漏物件的弱引用來在Snapshot中定位它。因為,如果一個物件被洩漏,一定也可以在記憶體中找到這個物件的弱引用,再通過弱引用物件的reference就可以直接定位被洩漏物件。

(4) 在Snapshot中找到一條有效的到被洩漏物件之間的引用路徑,從被洩露的物件開始,採用的方法是類似於廣度優先的搜尋策略,將持有它引用的節點(父節點),加入到一個FIFO佇列中,一層一層向上尋找,哪條路徑最先到達GCRoot就表示它應該是一條最短路徑。

OOM的起點到終點

(5) 將之前查詢的最短路徑轉換成最後需要顯示的LeakTrace物件,這個物件中包括了一個由路徑上各個節點LeakTraceElement組成的連結串列,代表了檢查到的最短洩漏路徑。最後一個步驟就是將這些結果封裝成AnalysisResult物件然後交給DisplayLeakService進行處理。這個service主要的工作是將檢查結果寫入檔案,以便之後能夠直接看到最近幾次記憶體洩露的分析結果,同時以notification的方式通知使用者檢測到了一次記憶體洩漏。使用者還可以繼承這個service類來並實現afterDefaultHandling來自定義對檢查結果的處理,比如將結果上傳剛到伺服器等。

六:問題

1.OOM可以被捕獲嗎?

某些情況下是可以的,比如說區域性申請一個很大的記憶體,如果造成了OOM,在catch的地方釋放掉,程式還是可以繼續往下執行的,但是如果捕獲之後釋放不掉,也還是會崩潰。

2.內部類為什麼能訪問外部類成員?

編譯器會預設為成員內部類新增了一個指向外部類物件的引用,那麼這個引用是如何賦初值的呢?

public com.xxx.Outter$Inner(com.cxh.test2.Outter);
複製程式碼

我們在定義的內部類的構造器是無參構造器,編譯器還是會預設新增一個引數,該引數的型別為指向外部類物件的一個引用,所以成員內部類中的Outter this&0 指標便指向了外部類物件,因此可以在成員內部類中隨意訪問外部類的成員。從這裡也間接說明了成員內部類是依賴於外部類的,如果沒有建立外部類的物件,則無法對Outter this&0引用進行初始化賦值,也就無法建立成員內部類的物件了。

3.為什麼區域性內部類和匿名內部類只能訪問區域性final變數?

外部類和內部類編譯後會生成兩個.class檔案,如果區域性變數的值在編譯期間就可以確定,則直接在匿名內部裡面建立一個拷貝。如果區域性變數的值無法在編譯期間確定,則通過構造器傳參的方式來對拷貝進行初始化賦值。 拷貝的話,如果一個值改變了,就會造成資料不一致性,為了解決這個問題,java編譯器就限定必須將變數a限制為final變數,不允許對變數a進行更改(對於引用型別的變數,是不允許指向新的物件),這樣資料不一致性的問題就得以解決了。

4.靜態變數會被回收嗎?finalize用法?

    private static StaticObj staticObj = new StaticObj();

    static class StaticObj {
        @Override
        protected void finalize() throws Throwable {
            staticObj = new StaticObj();
            super.finalize();
        }
    }
複製程式碼

5.System.gc()和Runtime.getRuntime().gc()區別?

    // System.gc()
    /**
     * If we just ran finalization, we might want to do a GC to free the finalized objects.
     * This lets us do gc/runFinlization/gc sequences but prevents back to back System.gc().
     */
    private static boolean justRanFinalization;

    public static void gc() {
        boolean shouldRunGC;
        synchronized (LOCK) {
            shouldRunGC = justRanFinalization;
            if (shouldRunGC) {
                justRanFinalization = false;
            } else {
                runGC = true;
            }
        }
        if (shouldRunGC) {
            Runtime.getRuntime().gc();
        }
    }

    public static void runFinalization() {
        boolean shouldRunGC;
        synchronized (LOCK) {
            shouldRunGC = runGC;
            runGC = false;
        }
        if (shouldRunGC) {
            Runtime.getRuntime().gc();
        }
        Runtime.getRuntime().runFinalization();
        synchronized (LOCK) {
            justRanFinalization = true;
        }
    }
複製程式碼

參考:

Jvm 系列(二):Jvm 記憶體結構

理解StrongReference,SoftReference, WeakReference的區別

Java內部類詳解

效能優化工具(九)-LeakCanary

LeakCanary原理分析

相關文章