【性能優(yōu)化】大廠OOM優(yōu)化和監(jiān)控方案

一磨确、前言

隨著項目不斷壯大吨些,OOM(Out Of Memory)成為奔潰統(tǒng)計平臺上的疑難雜癥之一气筋,大部分業(yè)務開發(fā)人員對于線上OOM問題一般都是暫不處理宫屠,一方面是因為OOM問題沒有足夠的log澡绩,無法在短期內(nèi)分析解決稽揭,另一方面可能是忙于業(yè)務迭代、身心疲憊肥卡,沒有精力去研究OOM的解決方案溪掀。

這篇文章將以線上OOM問題作為切入點,介紹常見的OOM類型步鉴、OOM的原理揪胃、大廠OOM優(yōu)化黑科技、以及主流的OOM監(jiān)控方案氛琢。

文章較長喊递,請備好小板凳~

二、OOM問題分類

很多人對于OOM的理解就是Java虛擬機內(nèi)存不足阳似,但通過線上OOM問題分析骚勘,OOM可以大致歸為以下3類:

  1. 線程數(shù)太多
  2. 打開太多文件
  3. 內(nèi)存不足

接下來將分別圍繞這三類問題進行展開分析~

三、線程數(shù)太多

3.1 報錯信息

pthread_create (1040KB stack) failed: Out of memory

這個是典型的創(chuàng)建新線程觸發(fā)的OOM問題

[圖片上傳失敗...(image-aaf4a3-1696662793994)]

3.2 源碼分析

pthread_create觸發(fā)的OOM異常撮奏,源碼(Android 9)位置如下: androidxref.com/9.0.0_r3/xr…

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
  ...
  pthread_create_result = pthread_create(...)
  //創(chuàng)建線程成功
  if (pthread_create_result == 0) {
      return;
  }
  //創(chuàng)建線程失敗
  ...
  {
    std::string msg(child_jni_env_ext.get() == nullptr ?
        StringPrintf("Could not allocate JNI Env: %s", error_msg.c_str()) :
        StringPrintf("pthread_create (%s stack) failed: %s",
                                 PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
    ScopedObjectAccess soa(env);
    soa.Self()->ThrowOutOfMemoryError(msg.c_str());
  }
}

pthread_create里面會調(diào)用Linux內(nèi)核創(chuàng)建線程俏讹,那什么情況下會創(chuàng)建線程失敗呢?

查看系統(tǒng)對每個進程的線程數(shù)限制

cat /proc/sys/kernel/threads-max

[圖片上傳失敗...(image-e36ab4-1696662793994)]

不同設(shè)備的threads-max限制是不一樣的畜吊,有些廠商的低端機型threads-max比較小泽疆,容易出現(xiàn)此類OOM問題。

查看當前進程運行的線程數(shù)

cat proc/{pid}/status

[圖片上傳失敗...(image-e53c8-1696662793994)]

當線程數(shù)超過/proc/sys/kernel/threads-max中規(guī)定的上限時就會觸發(fā)OOM玲献。

既然系統(tǒng)對每個進程的線程數(shù)有限制殉疼,那么解決這個問題的關(guān)鍵就是盡可能降低線程數(shù)的峰值梯浪。

3.3 線程優(yōu)化

3.3.1 禁用 new Thread

解決線程過多問題,傳統(tǒng)的方案是禁止使用new Thread瓢娜,統(tǒng)一使用線程池挂洛,但是一般很難人為控制, 可以在代碼提交之后觸發(fā)自動檢測恋腕,有問題則通過郵件通知對應開發(fā)人員抹锄。

不過這種方式存在兩個問題:

  1. 無法解決老代碼的new Thread
  2. 對于第三方庫無法控制荠藤。

3.3.2 無侵入性的new Thread 優(yōu)化

Java層的Thread只是一個普通的對象,只有調(diào)用了start方法获高,才會調(diào)用native 層去創(chuàng)建線程哈肖,

所以理論上我們可以自定義Thread,重寫start方法念秧,不去啟動線程淤井,而是將任務放到線程池中去執(zhí)行,為了做到無侵入性摊趾,需要在編譯期通過字節(jié)碼插樁的方式币狠,將所有new Thread字節(jié)碼都替換成new 自定義Thread

步驟如下:

1砾层、創(chuàng)建一個Thread的子類叫ShadowThread吧漩绵,重寫start方法,調(diào)用自定義的線程池CustomThreadPool來執(zhí)行任務肛炮;

public class ShadowThread extends Thread {

    @Override
    public synchronized void start() {
        Log.i("ShadowThread", "start,name="+ getName());
        CustomThreadPool.THREAD_POOL_EXECUTOR.execute(new MyRunnable(getName()));
    }

    class MyRunnable implements Runnable {

        String name;
        public MyRunnable(String name){
            this.name = name;
        }

        @Override
        public void run() {
            try {
                ShadowThread.this.run();
                Log.d("ShadowThread","run name="+name);
            } catch (Exception e) {
                Log.w("ShadowThread","name="+name+",exception:"+ e.getMessage());
                RuntimeException exception = new RuntimeException("threadName="+name+",exception:"+ e.getMessage());
                exception.setStackTrace(e.getStackTrace());
                throw exception;
            }
        }
    }
}

2止吐、在編譯期,hook 所有new Thread字節(jié)碼侨糟,全部替換成我們自定義的ShadowThread碍扔,這個難度應該不大,按部就班秕重,

我們先確認new Threadnew ShadowThread對應字節(jié)碼差異不同,可以安裝一個ASM Bytecode Viewer插件,如下所示

[圖片上傳失敗...(image-2436bd-1696662793994)]

通過字節(jié)碼修改溶耘,你可以簡單理解為做如下替換:

[圖片上傳失敗...(image-656532-1696662793994)]

3二拐、由于將任務放到線程池去執(zhí)行,假如線程奔潰了汰具,我們不知道是哪個線程出問題卓鹿,所以自定義ShadowThread中的內(nèi)部類MyRunnable 的作用是:在線程出現(xiàn)異常的時候,將異常捕獲留荔,還原它的名字吟孙,重新拋出一個信息更全的異常澜倦。

測試代碼

    private fun testThreadCrash() {
        Thread {
            val i = 9 / 0
        }.apply {
            name = "testThreadCrash"
        }.start()
    }

開啟一個線程,然后觸發(fā)奔潰杰妓,堆棧信息如下:

[圖片上傳失敗...(image-f85983-1696662793994)]

可以看到原本的new Thread已經(jīng)被優(yōu)化成了CustomThreadPool線程池調(diào)用藻治,并且奔潰的時候不用擔心找不到線程是哪里創(chuàng)建的,會還原線程名巷挥。

當然這種方式有一個小問題桩卵,應用正常運行的情況下,如果你想要收集所有線程信息倍宾,那么線程名可能不太準確雏节,因為通過new Thread 去創(chuàng)建線程,已經(jīng)被替換成線程池調(diào)用了高职,獲取到的線程名是線程池中的線程的名字

數(shù)據(jù)對比

同個場景簡單測試了一下new Thread優(yōu)化前后線程數(shù)峰值對比:

線程數(shù)峰值(優(yōu)化前) 線程數(shù)峰值(優(yōu)化后) 降低最大線程數(shù)
337 314 23

對于不同App钩乍,優(yōu)化效果會有一些不同,不過可以看到這個優(yōu)化確實是有效的怔锌。

3.3.3 無侵入的線程池優(yōu)化

隨著項目引入的SDK越來越多寥粹,絕大部分SDK內(nèi)部都會使用自己的線程池做異步操作,

線程池的參數(shù)如果設(shè)置不對埃元,核心線程空閑的時候沒有釋放涝涤,會使整體的線程數(shù)量處于較高位置。

線程池幾個參數(shù):
    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             threadFactory, defaultHandler);
    }

  1. corePoolSize:核心線程數(shù)量岛杀。核心線程默認情況下即使空閑也不會釋放阔拳,除非設(shè)置allowCoreThreadTimeOut為true。
  2. maximumPoolSize:最大線程數(shù)量楞件。任務數(shù)量超過核心線程數(shù)衫生,就會將任務放到隊列中,隊列滿了土浸,就會啟動非核心線程執(zhí)行任務罪针,線程數(shù)超過這個限制就會走拒絕策略;
  3. keepAliveTime:空閑線程存活時間
  4. unit:時間單位
  5. workQueue:隊列黄伊。任務數(shù)量超過核心線程數(shù)泪酱,就會將任務放到這個隊列中,直到隊列滿还最,就開啟新線程墓阀,執(zhí)行隊列第一個任務。
  6. threadFactory:線程工廠拓轻。實現(xiàn)new Thread方法創(chuàng)建線程
通過線程池參數(shù)斯撮,我們可以找到優(yōu)化點如下:
  1. 限制空閑線程存活時間,keepAliveTime 設(shè)置小一點扶叉,例如1-3s勿锅;
  2. 允許核心線程在空閑時自動銷毀
executor.allowCoreThreadTimeOut(true)

如何做呢帕膜?為了做到無侵入性,依然采用ASM操作字節(jié)碼溢十,跟new Thread的替換基本同理

在編譯期垮刹,通過ASM,做如下幾個操作:
  1. 將調(diào)用 Executors 類的靜態(tài)方法替換為自定義 ShadowExecutors 的靜態(tài)方法张弛,設(shè)置executor.allowCoreThreadTimeOut(true)荒典;
  2. 將調(diào)用 ThreadPoolExecutor 類的構(gòu)造方法替換為自定義 ShadowThreadPoolExecutor 的靜態(tài)方法,設(shè)置executor.allowCoreThreadTimeOut(true)吞鸭;
  3. 可以在 Application 類的 <clinit>() 中調(diào)用我們自定義的靜態(tài)方法 ShadowAsyncTask.optimizeAsyncTaskExecutor() 來修改 AsyncTask 的線程池參數(shù)寺董,調(diào)用executor.allowCoreThreadTimeOut(true)

3.4 線程監(jiān)控

假如線程優(yōu)化后還存在創(chuàng)建線程OOM問題刻剥,那我們就需要監(jiān)控是否存在線程泄漏的情況螃征。

3.4.1 線程泄漏監(jiān)控

主要監(jiān)控native線程的幾個生命周期方法:pthread_create、pthread_detach透敌、pthread_join、pthread_exit踢械。

  1. hook 以上幾個方法酗电,用于記錄線程的生命周期和堆棧,名稱等信息内列;
  2. 當發(fā)現(xiàn)一個joinable的線程在沒有detach或者join的情況下撵术,執(zhí)行了pthread_exit,則記錄下泄露線程信息话瞧;
  3. 在合適的時機嫩与,上報線程泄露信息。

linux線程中交排,pthread有兩種狀態(tài)joinable狀態(tài)unjoinable狀態(tài)划滋。joinable狀態(tài)下,當線程函數(shù)自己返回退出時或pthread_exit時都不會釋放線程所占用堆棧和線程描述符埃篓。只有當你調(diào)用了pthread_join之后這些資源才會被釋放处坪,需要main函數(shù)或者其他線程去調(diào)用pthread_join函數(shù)。

3.4.2 線程上報

當監(jiān)控到線程有異常的時候架专,我們可以收集線程信息同窘,上報到后臺進行分析。

收集線程信息代碼如下:

    private fun dumpThreadIfNeed() {

        val threadNames = runCatching { File("/proc/self/task").listFiles() }
            .getOrElse {
                return@getOrElse emptyArray()
            }
            ?.map {
                runCatching { File(it, "comm").readText() }.getOrElse { "failed to read $it/comm" }
            }
            ?.map {
                if (it.endsWith("\n")) it.substring(0, it.length - 1) else it
            }
            ?: emptyList()

        Log.d("TAG", "dumpThread = " + threadNames.joinToString(separator = ","))
    }

接下來介紹打開太多文件導致的OOM問題

四部脚、打開太多文件

4.1 錯誤信息

E/art: ashmem_create_region failed for 'indirect ref table': Too many open files
Java.lang.OutOfMemoryError: Could not allocate JNI Env

這個問題跟系統(tǒng)想邦、廠商關(guān)系比較大

4.2 系統(tǒng)限制

Android是基于Linux內(nèi)核,/proc/pid/limits 描述著linux系統(tǒng)對每個進程的一些資源限制委刘,

如下圖是一臺Android 6.0的設(shè)備丧没,Max open files的限制是1024

如果沒有root權(quán)限鹰椒,可以通過ulimit -n命令查看Max open files,結(jié)果是一樣的

ulimit -n

Linux 系統(tǒng)一切皆文件骂铁,進程每打開一個文件就會產(chǎn)生一個文件描述符fd(記錄在/proc/pid/fd下面)

cd /proc/10654/fd

ls

這些fd文件都是鏈接文件吹零,通過 ls -l可以查看其對應的真實文件路徑

當fd的數(shù)目達到Max open files規(guī)定的數(shù)目,就會觸發(fā)Too many open files的奔潰拉庵,這種奔潰在低端機上比較容易復現(xiàn)灿椅。

知道了文件描述符這玩意后,看看怎么優(yōu)化~

4.2 文件描述符優(yōu)化

對于打開文件數(shù)太多的問題钞支,盲目優(yōu)化其實無從下手茫蛹,總體的方案是監(jiān)控為主。

通過如下代碼可以查看當前進程的fd信息

    private fun dumpFd() {
        val fdNames = runCatching { File("/proc/self/fd").listFiles() }
            .getOrElse {
                return@getOrElse emptyArray()
            }
            ?.map { file ->
                runCatching { Os.readlink(file.path) }.getOrElse { "failed to read link ${file.path}" }
            }
            ?: emptyList()

        Log.d("TAG", "dumpFd: size=${fdNames.size},fdNames=$fdNames")

    }

4.3 文件描述符監(jiān)控

監(jiān)控策略: 當fd數(shù)大于1000個烁挟,或者fd連續(xù)遞增超過50個婴洼,就觸發(fā)fd收集,將fd對應的文件路徑上報到后臺撼嗓。

這里模擬一個bug柬采,打開一個文件多次不關(guān)閉,通過dumpFd且警,可以看到很多重復的文件名粉捻,進而大致定位到問題。

[圖片上傳失敗...(image-b8da22-1696662793994)]

當懷疑某個文件有問題之后斑芜,我們還需要知道這個文件在哪創(chuàng)建肩刃,是誰創(chuàng)建的,這個就涉及到IO監(jiān)控~

4.4 IO監(jiān)控

4.4.1 監(jiān)控內(nèi)容

監(jiān)控完整的IO操作杏头,包括open盈包、read、write醇王、close

open:獲取文件名呢燥、fd、文件大小厦画、堆棧疮茄、線程

read/write:獲取文件類型、讀寫次數(shù)根暑、總大小力试,使用buffer大小、讀寫總耗時

close:打開文件總耗時排嫌、最大連續(xù)讀寫時間

4.4.2 Java監(jiān)控方案:

以Android 6.0 源碼為例畸裳,FileInputStream 的調(diào)用鏈如下

java : FileInputStream -> IoBridge.open -> Libcore.os.open ->  
 BlockGuardOs.open -> Posix.open

Libcore.java是一個不錯的hook點

package libcore.io;
public final class Libcore {
    private Libcore() { }

    public static Os os = new BlockGuardOs(new Posix());
}


我們可以通過反射獲取到這個Os變量,它是一個接口類型淳地,里面定義了open怖糊、read帅容、write、close方法伍伤,具體實現(xiàn)在BlockGuardOs里面并徘。

// 反射獲得靜態(tài)變量
Class<?> clibcore = Class.forName("libcore.io.Libcore");
Field fos = clibcore.getDeclaredField("os");

通過動態(tài)代理的方式,在它所有IO方法前后加入插樁代碼來統(tǒng)計IO信息

// 動態(tài)代理對象
Proxy.newProxyInstance(cPosix.getClassLoader(), getAllInterfaces(cPosix), this);

beforeInvoke(method, args, throwable);
result = method.invoke(mPosixOs, args);
afterInvoke(method, args, result);

此方案缺點如下:

  • 性能差扰魂,IO調(diào)用頻繁麦乞,使用動態(tài)代理和Java的字符串操作,導致性能較差劝评,無法達到線上使用標準
  • 無法監(jiān)控Native代碼姐直,這個也是比較重要的
  • 兼容性差:需要根據(jù)Android 版本做適配,特別是Android P的非公開API限制

4.4.3 Native監(jiān)控方案

Native Hook方案的核心從 libc.so 中的這幾個函數(shù)中選定 Hook 的目標函數(shù)

int open(const char *pathname, int flags, mode_t mode);
ssize_t read(int fd, void *buf, size_t size);
ssize_t write(int fd, const void *buf, size_t size); write_cuk
int close(int fd);

我們需要選擇一些有調(diào)用上面幾個方法的 library蒋畜,例如選擇libjavacore.so声畏、libopenjdkjvm.so、libopenjdkjvm.so姻成,可以覆蓋到所有的 Java 層的 I/O 調(diào)用插龄。

不同版本的 Android 系統(tǒng)實現(xiàn)有所不同,在 Android 7.0 之后科展,我們還需要替換下面這三個方法辫狼。

open64
__read_chk
__write_chk

native hook 框架目前使用比較廣泛的是愛奇藝的xhook ,以及它的改進版辛润,字節(jié)跳動的bhook

具體的native IO監(jiān)控代碼见秤,可以參考 Matrix-IOCanary砂竖,內(nèi)部使用的是xhook框架。

關(guān)于IO涉及到的知識非常多鹃答,后面有時間可以單獨整理一篇文章乎澄。

接下來看看最后一種OOM類型~

五、內(nèi)存不足

5.1 堆棧信息

[圖片上傳失敗...(image-ee4a61-1696662793994)]

這種是最常見的OOM测摔,Java堆內(nèi)存不足置济,512M都不夠玩~

發(fā)生此問題的大部分設(shè)備都是Android 7.0,高版本也有锋八,不過相對較少浙于。

5.2 重溫JVM內(nèi)存結(jié)構(gòu)

JVM在運行時,將內(nèi)存劃分為以下5個部分

  1. 方法區(qū):存放靜態(tài)變量挟纱、常量羞酗、即時編譯代碼;
  2. 程序計數(shù)器:線程私有紊服,記錄當前執(zhí)行的代碼行數(shù)檀轨,方便在cpu切換到其它線程再回來的時候能夠不迷路胸竞;
  3. Java虛擬機棧:線程私有,一個Java方法開始和結(jié)束参萄,對應一個棧幀的入棧和出棧卫枝,棧幀里面有局部變量表、操作數(shù)棧讹挎、返回地址校赤、符號引用等信息;
  4. 本地方法棧:線程私有淤袜,跟Java虛擬機棧的區(qū)別在于 這個是針對native方法痒谴;
  5. 堆:絕大部分對象創(chuàng)建都在堆分配內(nèi)存

內(nèi)存不足導致的OOM,一般都是由于Java堆內(nèi)存不足铡羡,絕大部分對象都是在堆中分配內(nèi)存积蔚,除此之外,大數(shù)組烦周、以及Android3.0-7.0的Bitmap像素數(shù)據(jù)尽爆,都是存放在堆中。

基于這個結(jié)論读慎,關(guān)于Java堆內(nèi)存不足導致的OOM問題漱贱,優(yōu)化方案主要是圖片加載優(yōu)化、內(nèi)存泄漏監(jiān)控夭委。

5.3 圖片加載優(yōu)化

5.3.1 常規(guī)的圖片優(yōu)化方式

  1. 分析了主流圖片庫Glide和Fresco的優(yōu)缺點幅狮,以及使用場景;
  2. 分析了設(shè)計一個圖片加載框架需要考慮的問題株灸;
  3. 防止圖片占用內(nèi)存過多導致OOM的三個方式:軟引用崇摄、onLowMemory、Bitmap 像素存儲位置

這篇文章現(xiàn)在來看還是有點意義的慌烧,其中的原理部分還沒過時逐抑,不過技術(shù)更新迭代,常規(guī)的優(yōu)化方式已經(jīng)不太夠了屹蚊,長遠考慮厕氨,可以做圖片自動壓縮、大圖自動檢測和告警汹粤。

5.3.2 無侵入性自動壓縮圖片

針對圖片資源命斧,設(shè)計師往往會追求高清效果,忽略圖片大小嘱兼,一般的做法是拿到圖后手動壓縮一下冯丙,這種手動的操作完全看個人修養(yǎng)。

無侵入性自動壓縮圖片,主流的方案是利用Gradle 的Task原理胃惜,在編譯過程中泞莉,mergeResourcesTask 這個任務是將所以aar、module的資源進行合并船殉,我們可以在mergeResourcesTask 之后可以拿到所有資源文件鲫趁,具體做法:

  1. mergeResourcesTask這個任務后面,增加一個圖片處理的Task利虫,拿到所有資源文件挨厚;
  2. 拿到所有資源文件后,判斷如果是圖片文件糠惫,則通過壓縮工具進行壓縮疫剃,壓縮后如果圖片有變小,就將壓縮過的圖片替換掉原圖硼讽。

可以簡單理解如下: [圖片上傳失敗...(image-48a940-1696662793994)]

5.4 大圖監(jiān)控

5.3.2 自動壓縮圖片只是針對本地資源巢价,而對于網(wǎng)絡圖片,如果加載的時候沒有壓縮固阁,那么內(nèi)存占用會比較大壤躲,這種情況就需要監(jiān)控了。

5.4.1 從圖片框架側(cè)監(jiān)控

很多App內(nèi)部可能使用了多個圖片庫备燃,例如Glide碉克、Picasso、Fresco并齐、ImageLoader漏麦、Coil,如果想監(jiān)控某個圖片框架况褪, 那么我們需要熟讀源碼唁奢,找到hook點。

對于Glide窝剖,可以通過hook SingleRequest,它里面有個requestListeners酥夭,我們可以注冊一個自己的監(jiān)聽赐纱,圖片加載完做一個大圖檢測。

其它圖片框架熬北,同理也是先找到hook點疙描,然后進行類似的hook操作就可以,

5.4.2 從ImageView側(cè)監(jiān)控

5.4.1 是從圖片加載框架側(cè)監(jiān)控大圖讶隐,假如項目中使用到的圖片加載框架太多起胰,有些第三方SDK內(nèi)部可能自己搞了圖片加載,

這種情況下我們可以從ImageView控件側(cè)做監(jiān)控巫延,監(jiān)聽setImageDrawable等方法效五,計算圖片大小如果大于控件本身大小地消,debug包可以彈窗提示需要修改。

方案如下:

  1. 自定義ImageView畏妖,重寫setImageDrawable脉执、setImageBitmap、setImageResource戒劫、setBackground半夷、setBackgroundResource這幾個方法,在這些方法里面迅细,檢測Drawable大形组稀;
  2. 編譯期茵典,修改字節(jié)碼湘换,將所有ImageView的創(chuàng)建都替換成自定義的ImageView
  3. 為了不影響主線程敬尺,可以使用IdleHandler枚尼,在主線程空閑的時候再檢測;

最終是希望當檢測到大圖的時候砂吞,debug環(huán)境能夠彈窗提示開發(fā)進行修改署恍,release環(huán)境可以上報后臺蝌以。

debug如下效果:

[外鏈圖片轉(zhuǎn)存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-qyrJo3Ih-1651225474031)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b9133dfddf514d6b8519a21c29044089~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]

當然這種方案有個缺點:不能獲取到圖片url喻喳。

圖片優(yōu)化告一段落,接下來看看內(nèi)存泄漏~

5.5 內(nèi)存泄漏監(jiān)控演進

LeakCanary

關(guān)于內(nèi)存泄漏彪标,大家可能都知道LeakCanary概而,只要添加一個依賴

debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'呼巷,

就能實現(xiàn)自動檢測和分析內(nèi)存泄漏,并發(fā)出一個通知顯示內(nèi)存泄漏詳情信息赎瑰。

LeakCanary只能在debug環(huán)境使用王悍,因為它是在當前進程dump內(nèi)存快照,Debug.dumpHprofData(path);會凍結(jié)當前進程一段時間餐曼,整個 APP 會卡死約5~15s压储,低端機上可能要幾十秒的時間。

ResourceCanary

微信對LeakCanary做了一些改造源譬,將檢測和分析分離集惋,客戶端只負責檢測和dump內(nèi)存鏡像文件,文件裁剪后上報到服務端進行分析踩娘。

具體可以看這篇文章Matrix ResourceCanary -- Activity 泄漏及Bitmap冗余檢測

KOOM

不管是LeakCanary 還是 ResourceCanary刮刑,他們都只能在線下使用,而線上內(nèi)存泄漏監(jiān)控方案,目前KOOM的方案比較完善雷绢,下面我將基于KOOM分析線上內(nèi)存泄漏監(jiān)控方案的核心流程泛烙。

5.6 線上內(nèi)存泄漏監(jiān)控方案

基于KOOM源碼分析

5.6.1 檢測時機

  1. 間隔5s檢測一次
  2. 觸發(fā)內(nèi)存鏡像采集的條件:
  • 當內(nèi)存使用率達到80%以上
      //->OOMMonitorConfig
      
      private val DEFAULT_HEAP_THRESHOLD by lazy {
        val maxMem = SizeUnit.BYTE.toMB(Runtime.getRuntime().maxMemory())
        when {
          maxMem >= 512 - 10 -> 0.8f
          maxMem >= 256 - 10 -> 0.85f
          else -> 0.9f
        }
      }

  • 兩次檢測時間內(nèi)(例如5s內(nèi)),內(nèi)存使用率增加5%

5.6.2 內(nèi)存鏡像采集

我們知道LeakCanary檢測內(nèi)存泄漏习寸,不能用于線上胶惰,是因為它dump內(nèi)存鏡像是在當前進程進行操作,會凍結(jié)App一段時間霞溪。

所以孵滞,作為線上OOM監(jiān)控,dump內(nèi)存鏡像需要單獨開一個進程鸯匹。

整體的策略是:

虛擬機supend->fork虛擬機進程->虛擬機resume->dump內(nèi)存鏡像的策略坊饶。

dump內(nèi)存鏡像的源碼如下:

  //->ForkJvmHeapDumper
  
  public boolean dump(String path) {
    ...

    boolean dumpRes = false;
    try {
      //1、通過fork函數(shù)創(chuàng)建子進程殴蓬,會返回兩次匿级,通過pid判斷是父進程還是子進程
      int pid = suspendAndFork();
      
      MonitorLog.i(TAG, "suspendAndFork,pid="+pid);
      if (pid == 0) {
        //2、子進程返回染厅,dump內(nèi)存操作痘绎,dump內(nèi)存完成,退出子進程
        Debug.dumpHprofData(path);
        exitProcess();
      } else if (pid > 0) {
        // 3肖粮、父進程返回孤页,恢復虛擬機,將子進程的pid傳過去涩馆,阻塞等待子進程結(jié)束
        dumpRes = resumeAndWait(pid);
        MonitorLog.i(TAG, "notify from pid " + pid);
      }
    }
    return dumpRes;
  }

注釋1:父進程調(diào)用native方法掛起虛擬機行施,并且創(chuàng)建子進程;
注釋2:子進程創(chuàng)建成功魂那,執(zhí)行Debug.dumpHprofData蛾号,執(zhí)行完后退出子進程;
注釋3:得知子進程創(chuàng)建成功后涯雅,父進程恢復虛擬機鲜结,解除凍結(jié),并且當前線程等待子進程結(jié)束活逆。

注釋1源碼如下:

// ->native_bridge.cpp

pid_t HprofDump::SuspendAndFork() {
  //1精刷、暫停VM,不同Android版本兼容
  if (android_api_ < __ANDROID_API_R__) {
    suspend_vm_fnc_();
  }
  ...

  //2划乖,fork子進程,通過返回值可以判斷是主進程還是子進程
  pid_t pid = fork();
  if (pid == 0) {
    // Set timeout for child process
    alarm(60);
    prctl(PR_SET_NAME, "forked-dump-process");
  }
  return pid;
}


注釋3源碼如下:

//->hprof_dump.cpp

bool HprofDump::ResumeAndWait(pid_t pid) {
  //1、恢復虛擬機挤土,兼容不同Android版本
  if (android_api_ < __ANDROID_API_R__) {
    resume_vm_fnc_();
  }
  ...
  int status;
  for (;;) {
    //2琴庵、waitpid,等待子進程結(jié)束
    if (waitpid(pid, &status, 0) != -1 || errno != EINTR) {
      //進程異常退出
      if (!WIFEXITED(status)) {
        ALOGE("Child process %d exited with status %d, terminated by signal %d",
              pid, WEXITSTATUS(status), WTERMSIG(status));
        return false;
      }
      return true;
    }
    return false;
  }
}

這里主要是利用Linux的waitpid函數(shù),主進程可以等待子進程dump結(jié)束,然后再返回執(zhí)行內(nèi)存鏡像文件分析操作迷殿。

5.6.3 內(nèi)存鏡像分析

前面一步已經(jīng)通過Debug.dumpHprofData(path)拿到內(nèi)存鏡像文件儿礼,接下來就開啟一個后臺服務來處理

 //->HeapAnalysisService
 
  override fun onHandleIntent(intent: Intent?) {
    ...
    kotlin.runCatching {
      //1、通過shark將hprof文件轉(zhuǎn)換成HeapGraph對象
      buildIndex(hprofFile)
    }
    ...
    //2庆寺、將設(shè)備信息封裝成json
    buildJson(intent)

    kotlin.runCatching {
      //3蚊夫、過濾泄漏對象,有幾個規(guī)制
      filterLeakingObjects()
    }
    ...
    kotlin.runCatching {
      // 4懦尝、gcRoot是否可達知纷,判斷內(nèi)存泄漏
      findPathsToGcRoot()
    }
    ...

    //5、泄漏信息填充到json中陵霉,然后結(jié)束了
    fillJsonFile(jsonFile)


    //通知主進程內(nèi)存泄漏分析成功
    resultReceiver?.send(AnalysisReceiver.RESULT_CODE_OK, null)

    //這個服務是在單獨進程琅轧,分析完就退出
    System.exit(0);
  }

內(nèi)存鏡像分析的流程如下:

  1. 通過shark這個開源庫將hprof文件轉(zhuǎn)換成HeapGraph對象
  2. 收集設(shè)備信息,封裝成json踊挠,現(xiàn)場信息很重要
  3. filterLeakingObjects:過濾出泄漏的對象乍桂,有一些規(guī)制,例如已經(jīng)destroyed和finished的activity效床、fragment manager為空的fragment睹酌、已經(jīng)destroyed的window等。
  4. findPathsToGcRoot:內(nèi)存泄漏的對象剩檀,查找其到GcRoot的路徑憋沿,通過這一步就可以揪出內(nèi)存泄漏的原因
  5. fillJsonFile:格式化輸出內(nèi)存泄漏信息

小結(jié)

線上Java內(nèi)存泄漏監(jiān)控方案分析,這里小結(jié)一下:

  1. 掛起當前進程谨朝,然后通過fork創(chuàng)建子進程卤妒;
  2. fork會返回兩次,一次是子進程字币,一次是父進程则披,通過返回的pid可以判斷是子進程還是父進程;
  3. 如果是父進程返回洗出,則通過resumeAndWait恢復進程士复,然后當前線程阻塞等待子進程結(jié)束;
  4. 如果子進程返回翩活,通過Debug.dumpHprofData(path)讀取內(nèi)存鏡像信息阱洪,這個會比較耗時,執(zhí)行結(jié)束就退出子進程菠镇;
  5. 子進程退出冗荸,父進程的resumeAndWait就會返回,這時候就可以開啟一個服務利耍,后臺分析內(nèi)存泄漏情況蚌本,這塊跟LeakCanary的分析內(nèi)存泄漏原理基本差不多盔粹。

不畫圖了,結(jié)合源碼看應該可以理解程癌。

5.7 native內(nèi)存泄漏監(jiān)控

對于Java內(nèi)存泄漏監(jiān)控舷嗡,線下我們可以使用LeakCanary、線上可以使用KOOM嵌莉,而對于native內(nèi)存泄漏應該如何監(jiān)控呢进萄?

方案如下:

首先要了解native層
申請內(nèi)存的函數(shù):malloc、realloc锐峭、calloc中鼠、memalign、posix_memalign
釋放內(nèi)存的函數(shù):free

  1. hook申請內(nèi)存和釋放內(nèi)存的函數(shù)

分配內(nèi)存的時候只祠,收集堆棧兜蠕、內(nèi)存大小、地址抛寝、線程等信息熊杨,存放到map中,在釋放內(nèi)存的時候從map中移除盗舰。

那怎么判斷native內(nèi)存泄漏呢晶府?

  • 周期性的使用 mark-and-sweep 分析整個進程 Native Heap,獲取不可達的內(nèi)存塊信息「地址钻趋、大小」
  • 獲取到不可達的內(nèi)存塊的地址后川陆,可以從我們的Map中獲取其堆棧、內(nèi)存大小蛮位、地址较沪、線程等信息。

總結(jié)

本文從線上OOM問題入手失仁,介紹了OOM原理尸曼, 以及OOM優(yōu)化方案和監(jiān)控方案,基本上都是大廠開源出來的比較成熟的方案:

  1. 對于pthread_create OOM問題萄焦,介紹了無侵入性的new Thread優(yōu)化控轿、無侵入性的線程池優(yōu)化、以及線程泄漏監(jiān)控拂封;
  2. 對于文件描述符過多問題茬射,介紹了原理以及文件描述符監(jiān)控方案、IO監(jiān)控方案冒签;
  3. 對于Java內(nèi)存不足導致的OOM在抛、介紹了無侵入性圖片自動壓縮方案、兩種無侵入性的大圖監(jiān)控方案萧恕、Java內(nèi)存泄漏監(jiān)控的線下方案和線上方案刚梭、以及native內(nèi)存泄漏監(jiān)控方案档悠。

大廠對外開源的技術(shù)非常多,但不一定最優(yōu)望浩,我們在學習過程中可以多加思考, 例如線程優(yōu)化惰说,booster 對于new Thread的優(yōu)化只是設(shè)置了線程名磨德,有助于分析問題,而經(jīng)過我的猜想和驗證吆视,通過字節(jié)碼插樁典挑,將new Thread無侵入性替換成線程池調(diào)用,才是真正意義上的線程優(yōu)化啦吧。


?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末您觉,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子授滓,更是在濱河造成了極大的恐慌琳水,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件般堆,死亡現(xiàn)場離奇詭異在孝,居然都是意外死亡,警方通過查閱死者的電腦和手機淮摔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門私沮,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人和橙,你說我怎么就攤上這事仔燕。” “怎么了魔招?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵晰搀,是天一觀的道長。 經(jīng)常有香客問我仆百,道長厕隧,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任俄周,我火速辦了婚禮吁讨,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘峦朗。我一直安慰自己建丧,他們只是感情好,可當我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布波势。 她就那樣靜靜地躺著翎朱,像睡著了一般橄维。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上拴曲,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天争舞,我揣著相機與錄音,去河邊找鬼澈灼。 笑死竞川,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的叁熔。 我是一名探鬼主播委乌,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼荣回!你這毒婦竟也來了遭贸?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤心软,失蹤者是張志新(化名)和其女友劉穎壕吹,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體删铃,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡算利,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了泳姐。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片效拭。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖胖秒,靈堂內(nèi)的尸體忽然破棺而出缎患,到底是詐尸還是另有隱情,我是刑警寧澤阎肝,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布挤渔,位于F島的核電站,受9級特大地震影響风题,放射性物質(zhì)發(fā)生泄漏判导。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一沛硅、第九天 我趴在偏房一處隱蔽的房頂上張望眼刃。 院中可真熱鬧,春花似錦摇肌、人聲如沸擂红。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽昵骤。三九已至树碱,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間变秦,已是汗流浹背成榜。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留蹦玫,地道東北人伦连。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像钳垮,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子额港,可洞房花燭夜當晚...
    茶點故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容