<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          大廠干掉 OOM 的套路,你知道幾個 !

          共 16112字,需瀏覽 33分鐘

           ·

          2022-05-23 21:54

          點(diǎn)擊上方“碼農(nóng)突圍”,馬上關(guān)注
          這里是碼農(nóng)充電第一站,回復(fù)“666”,獲取一份專屬大禮包

          真愛,請?jiān)O(shè)置“星標(biāo)”或點(diǎn)個“在看”

          文章來源:https://c1n.cn/5ug0H


          目錄
          • 前言

          • OOM 問題分類

          • 線程數(shù)太多

          • 打開太多文件

          • 內(nèi)存不足

          • 總結(jié)


          前言


          隨著項(xiàng)目不斷壯大,OOM(Out Of Memory)成為奔潰統(tǒng)計(jì)平臺上的疑難雜癥之一。


          大部分業(yè)務(wù)開發(fā)人員對于線上 OOM 問題一般都是暫不處理:

          • 一方面是因?yàn)?OOM 問題沒有足夠的 log,無法在短期內(nèi)分析解決。

          • 另一方面可能是忙于業(yè)務(wù)迭代、身心疲憊,沒有精力去研究 OOM 的解決方案。


          這篇文章將以線上 OOM 問題作為切入點(diǎn),介紹常見的 OOM 類型、OOM 的原理、大廠 OOM 優(yōu)化黑科技、以及主流的 OOM 監(jiān)控方案。文章較長,請備好小板凳!


          OOM 問題分類


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

          • 線程數(shù)太多

          • 打開太多文件

          • 內(nèi)存不足


          接下來將分別圍繞這三類問題進(jìn)行展開分析。


          線程數(shù)太多


          | 報(bào)錯信息

          pthread_create?(1040KB?stack)?failed:?Out?of?memory


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

          | 源碼分析

          pthread_create 觸發(fā)的 OOM 異常,源碼(Android 9)位置如下:?
          http://androidxref.com/9.0.0_r3/xref/art/runtime/thread.cc


          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)對每個進(jìn)程的線程數(shù)限制:

          cat?/proc/sys/kernel/threads-max


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


          查看當(dāng)前進(jìn)程運(yùn)行的線程數(shù):

          cat?proc/{pid}/status


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


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


          | 線程優(yōu)化

          ①禁用 new Thread


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


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

          • 無法解決老代碼的 new Thread

          • 對于第三方庫無法控制


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


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


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


          步驟如下:


          創(chuàng)建一個 Thread 的子類叫 ShadowThread 吧,重寫 start 方法,調(diào)用自定義的線程池 CustomThreadPool 來執(zhí)行任務(wù)。
          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;
          ????????????}
          ????????}
          ????}
          }


          在編譯期,hook 所有 new Thread 字節(jié)碼,全部替換成我們自定義的 ShadowThread,這個難度應(yīng)該不大,按部就班,我們先確認(rèn) new Thread 和 new ShadowThread 對應(yīng)字節(jié)碼差異。


          可以安裝一個 ASM Bytecode Viewer 插件,如下所示:
          通過字節(jié)碼修改,你可以簡單理解為做如下替換:

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


          測試代碼:
          ????private?fun?testThreadCrash()?{
          ????????Thread?{
          ????????????val?i?=?9?/?0
          ????????}.apply?{
          ????????????name?=?"testThreadCrash"
          ????????}.start()
          ????}


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

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


          當(dāng)然這種方式有一個小問題,應(yīng)用正常運(yùn)行的情況下,如果你想要收集所有線程信息,那么線程名可能不太準(zhǔn)確,因?yàn)橥ㄟ^ new Thread 去創(chuàng)建線程,已經(jīng)被替換成線程池調(diào)用了,獲取到的線程名是線程池中的線程的名字。


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

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


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


          隨著項(xiàng)目引入的 SDK 越來越多,絕大部分 SDK 內(nèi)部都會使用自己的線程池做異步操作,線程池的參數(shù)如果設(shè)置不對,核心線程空閑的時(shí)候沒有釋放,會使整體的線程數(shù)量處于較高位置。
          ????public?ThreadPoolExecutor(int?corePoolSize,
          ??????????????????????????????int?maximumPoolSize,
          ??????????????????????????????long?keepAliveTime,
          ??????????????????????????????TimeUnit?unit,
          ??????????????????????????????BlockingQueue?workQueue,
          ??????????????????????????????ThreadFactory?threadFactory)
          ?
          {
          ????????this(corePoolSize,?maximumPoolSize,?keepAliveTime,?unit,?workQueue,
          ?????????????threadFactory,?defaultHandler);
          ????}


          線程池幾個參數(shù):
          • corePoolSize:核心線程數(shù)量。核心線程默認(rèn)情況下即使空閑也不會釋放,除非設(shè)置 allowCoreThreadTimeOut 為 true。

          • maximumPoolSize:最大線程數(shù)量。任務(wù)數(shù)量超過核心線程數(shù),就會將任務(wù)放到隊(duì)列中,隊(duì)列滿了,就會啟動非核心線程執(zhí)行任務(wù),線程數(shù)超過這個限制就會走拒絕策略。

          • keepAliveTime:空閑線程存活時(shí)間。

          • unit:時(shí)間單位。

          • workQueue:隊(duì)列。任務(wù)數(shù)量超過核心線程數(shù),就會將任務(wù)放到這個隊(duì)列中,直到隊(duì)列滿,就開啟新線程,執(zhí)行隊(duì)列第一個任務(wù)。

          • threadFactory:線程工廠。實(shí)現(xiàn) new Thread 方法創(chuàng)建線程。


          通過線程池參數(shù),我們可以找到優(yōu)化點(diǎn)如下:
          • 限制空閑線程存活時(shí)間,keepAliveTime?設(shè)置小一點(diǎn),例如 1-3s

          • 允許核心線程在空閑時(shí)自動銷毀
          executor.allowCoreThreadTimeOut(true)


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


          在編譯期,通過 ASM,做如下幾個操作:
          • 將調(diào)用 Executors 類的靜態(tài)方法替換為自定義 ShadowExecutors 的靜態(tài)方法,設(shè)置 executor.allowCoreThreadTimeOut(true);

          • 將調(diào)用 ThreadPoolExecutor 類的構(gòu)造方法替換為自定義 ShadowThreadPoolExecutor 的靜態(tài)方法,設(shè)置 executor.allowCoreThreadTimeOut(true);

          • 可以在 Application 類的?()?中調(diào)用我們自定義的靜態(tài)方法 ShadowAsyncTask.optimizeAsyncTaskExecutor()?來修改 AsyncTask 的線程池參數(shù),調(diào)用 executor.allowCoreThreadTimeOut(true);


          你可以簡單理解為做如下替換:?
          詳細(xì)代碼可以參考 booster。
          https://booster.johnsonlee.io/zh/guide/performance/multithreading-optimization.html#%E7%BA%BF%E7%A8%8B%E7%AE%A1%E7%90%86%E9%9D%A2%E4%B8%B4%E7%9A%84%E6%8C%91%E6%88%98


          | 線程監(jiān)控

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


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


          主要監(jiān)控 native 線程的幾個生命周期方法:

          • pthread_create

          • pthread_detach

          • pthread_join

          • pthread_exit


          hook 以上幾個方法,用于記錄線程的生命周期和堆棧,名稱等信息;當(dāng)發(fā)現(xiàn)一個 joinable 的線程在沒有 detach 或者 join 的情況下,執(zhí)行了 pthread_exit,則記錄下泄露線程信息;在合適的時(shí)機(jī),上報(bào)線程泄露信息。


          linux 線程中,pthread 有兩種狀態(tài) joinable 狀態(tài)和 unjoinable 狀態(tài)。


          joinable 狀態(tài)下,當(dāng)線程函數(shù)自己返回退出時(shí)或 pthread_exit 時(shí)都不會釋放線程所占用堆棧和線程描述符。


          只有當(dāng)你調(diào)用了 pthread_join 之后這些資源才會被釋放,需要 main 函數(shù)或者其他線程去調(diào)用 pthread_join 函數(shù)。


          具體代碼可以參考:KOOM-thread_holder。
          https://github.com/KwaiAppTeam/KOOM/blob/master/koom-thread-leak/src/main/cpp/src/thread/thread_holder.cpp


          ②線程上報(bào)


          當(dāng)監(jiān)控到線程有異常的時(shí)候,我們可以收集線程信息,上報(bào)到后臺進(jì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?=?","))
          ????}


          接下來介紹打開太多文件導(dǎo)致的 OOM 問題。


          打開太多文件


          | 錯誤信息

          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)系比較大。


          | 系統(tǒng)限制

          Android 是基于 Linux 內(nèi)核,/proc/pid/limits 描述著 linux 系統(tǒng)對每個進(jìn)程的一些資源限制,如下圖是一臺 Android 6.0 的設(shè)備,Max open files 的限制是 1024。
          如果沒有 root 權(quán)限,可以通過 ulimit -n 命令查看 Max open files,結(jié)果是一樣的。
          ulimit?-n


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

          cd?/proc/10654/fd
          ls


          這些 fd 文件都是鏈接文件,通過 ls -l 可以查看其對應(yīng)的真實(shí)文件路徑。

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


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


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

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


          通過如下代碼可以查看當(dāng)前進(jì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")

          ????}


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

          監(jiān)控策略:?當(dāng) fd 數(shù)大于 1000 個,或者 fd 連續(xù)遞增超過 50 個,就觸發(fā) fd 收集,將 fd 對應(yīng)的文件路徑上報(bào)到后臺。


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

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


          | IO 監(jiān)控

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


          監(jiān)控完整的 IO 操作,包括:

          • open:獲取文件名、fd、文件大小、堆棧、線程。

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

          • close:打開文件總耗時(shí)、最大連續(xù)讀寫時(shí)間。


          ②Java 監(jiān)控方案


          以 Android 6.0 源碼為例,F(xiàn)ileInputStream 的調(diào)用鏈如下:
          java?:?FileInputStream?->?IoBridge.open?->?Libcore.os.open?->??
          ?BlockGuardOs.open?->?Posix.open


          Libcore.java 是一個不錯的 hook 點(diǎn)。
          http://androidxref.com/6.0.1_r10/xref/libcore/luni/src/main/java/libcore/io/Libcore.java


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

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


          我們可以通過反射獲取到這個 Os 變量,它是一個接口類型,里面定義了 open、read、write、close 方法,具體實(shí)現(xiàn)在 BlockGuardOs 里面。
          http://androidxref.com/6.0.1_r10/xref/libcore/luni/src/main/java/libcore/io/BlockGuardOs.java


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


          通過動態(tài)代理的方式,在它所有 IO 方法前后加入插樁代碼來統(tǒng)計(jì) IO 信息。
          //?動態(tài)代理對象
          Proxy.newProxyInstance(cPosix.getClassLoader(),?getAllInterfaces(cPosix),?this);

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


          此方案缺點(diǎn)如下:

          • 性能差,IO 調(diào)用頻繁,使用動態(tài)代理和 Java 的字符串操作,導(dǎo)致性能較差,無法達(dá)到線上使用標(biāo)準(zhǔn)。

          • 無法監(jiān)控 Native 代碼,這個也是比較重要的。

          • 兼容性差:需要根據(jù) Android 版本做適配,特別是 Android P 的非公開 API 限制。


          ③Native 監(jiān)控方案


          Native Hook 方案的核心從 libc.so 中的這幾個函數(shù)中選定 Hook 的目標(biāo)函數(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)實(shí)現(xiàn)有所不同,在 Android 7.0 之后,我們還需要替換下面這三個方法。
          open64
          __read_chk
          __write_chk


          native hook 框架目前使用比較廣泛的是愛奇藝的 xhook,以及它的改進(jìn)版,字節(jié)跳動的 bhook。
          https://github.com/iqiyi/xHook/blob/master/README.zh-CN.md

          https://github.com/bytedance/bhook/blob/main/doc/overview.zh-CN.md


          具體的 native IO 監(jiān)控代碼,可以參考 Matrix-IOCanary,內(nèi)部使用的是 xhook 框架。
          https://github.com/Tencent/matrix/blob/master/matrix/matrix-android/matrix-io-canary/src/main/cpp/io_canary_jni.cc


          關(guān)于 IO 涉及到的知識非常多,后面有時(shí)間可以單獨(dú)整理一篇文章。接下來看看最后一種 OOM 類型。


          內(nèi)存不足


          | 堆棧信息

          這種是最常見的 OOM,Java 堆內(nèi)存不足,512M 都不夠玩,發(fā)生此問題的大部分設(shè)備都是 Android 7.0,高版本也有,不過相對較少。


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

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

          • 方法區(qū):存放靜態(tài)變量、常量、即時(shí)編譯代碼

          • 程序計(jì)數(shù)器:線程私有,記錄當(dāng)前執(zhí)行的代碼行數(shù),方便在 cpu 切換到其它線程再回來的時(shí)候能夠不迷路

          • Java 虛擬機(jī)棧:線程私有,一個 Java 方法開始和結(jié)束,對應(yīng)一個棧幀的入棧和出棧,棧幀里面有局部變量表、操作數(shù)棧、返回地址、符號引用等信息

          • 本地方法棧:線程私有,跟 Java 虛擬機(jī)棧的區(qū)別在于 這個是針對 native 方法

          • 堆:絕大部分對象創(chuàng)建都在堆分配內(nèi)存


          內(nèi)存不足導(dǎo)致的 OOM,一般都是由于 Java 堆內(nèi)存不足,絕大部分對象都是在堆中分配內(nèi)存,除此之外,大數(shù)組、以及 Android3.0-7.0 的 Bitmap 像素?cái)?shù)據(jù),都是存放在堆中。


          Java 堆內(nèi)存不足導(dǎo)致的 OOM 問題,線上難以復(fù)現(xiàn),往往比較難定位到問題,絕大部分設(shè)備都是 8.0 以下的,主要也是由于 Android 3.0-7.0 Bitmap 像素內(nèi)存是存放在堆中導(dǎo)致的。


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


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

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


          常規(guī)的圖片加載優(yōu)化,可以參考文章《面試官:簡歷上最好不要寫 Glide,不是問源碼那么簡單》
          https://juejin.cn/post/6844903986412126216


          文章核心內(nèi)容大概如下:

          • 分析了主流圖片庫 Glide 和 Fresco 的優(yōu)缺點(diǎn),以及使用場景

          • 分析了設(shè)計(jì)一個圖片加載框架需要考慮的問題

          • 防止圖片占用內(nèi)存過多導(dǎo)致 OOM 的三個方式:軟引用、onLowMemory、Bitmap ?像素存儲位置


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


          ②無侵入性自動壓縮圖片


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


          無侵入性自動壓縮圖片,主流的方案是利用 Gradle 的 Task 原理,在編譯過程中,mergeResourcesTask 這個任務(wù)是將所有 aar、module 的資源進(jìn)行合并,我們可以在 mergeResourcesTask 之后可以拿到所有資源文件。


          具體做法:

          • 在 mergeResourcesTask 這個任務(wù)后面,增加一個圖片處理的 Task,拿到所有資源文件

          • 拿到所有資源文件后,判斷如果是圖片文件,則通過壓縮工具進(jìn)行壓縮,壓縮后如果圖片有變小,就將壓縮過的圖片替換掉原圖


          可以簡單理解如下:?
          具體代碼可以參考 McImage 這個庫。
          https://github.com/smallSohoSolo/McImage


          | 大圖監(jiān)控

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


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


          很多 App 內(nèi)部可能使用了多個圖片庫,例如 Glide、Picasso、Fresco、ImageLoader、Coil,如果想監(jiān)控某個圖片框架, 那么我們需要熟讀源碼,找到 hook 點(diǎn)。


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


          其他圖片框架,同理也是先找到 hook 點(diǎn),然后進(jìn)行類似的 hook 操作就可以,代碼可以參考:dokit-BigImgClassTransformer。
          https://github.com/didi/DoraemonKit/blob/master/Android/buildSrc/src/main/kotlin/com/didichuxing/doraemonkit/plugin/classtransformer/BigImgClassTransformer.kt


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


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


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


          方案如下:

          • 自定義 ImageView,重寫 setImageDrawable、setImageBitmap、setImageResource、setBackground、setBackgroundResource 這幾個方法,在這些方法里面,檢測 Drawable 大小

          • 編譯期,修改字節(jié)碼,將所有 ImageView 的創(chuàng)建都替換成自定義的 ImageView

          • 為了不影響主線程,可以使用 IdleHandler,在主線程空閑的時(shí)候再檢測


          最終是希望當(dāng)檢測到大圖的時(shí)候,debug 環(huán)境能夠彈窗提示開發(fā)進(jìn)行修改,release 環(huán)境可以上報(bào)后臺。


          debug 如下效果:

          當(dāng)然這種方案有個缺點(diǎn):不能獲取到圖片 url。圖片優(yōu)化告一段落,接下來看看內(nèi)存泄漏。


          | 內(nèi)存泄漏監(jiān)控演進(jìn)

          ①LeakCanary


          關(guān)于內(nèi)存泄漏,大家可能都知道 LeakCanary:
          https://github.com/square/leakcanary/


          只要添加一個依賴:
          debugImplementation?'com.squareup.leakcanary:leakcanary-android:2.8.1'


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


          LeakCanary 只能在 debug 環(huán)境使用,因?yàn)樗窃诋?dāng)前進(jìn)程 dump 內(nèi)存快照,Debug.dumpHprofData(path); 會凍結(jié)當(dāng)前進(jìn)程一段時(shí)間,整個 APP 會卡死約 5~15s,低端機(jī)上可能要幾十秒的時(shí)間。


          ②ResourceCanary


          微信對 LeakCanary 做了一些改造,將檢測和分析分離,客戶端只負(fù)責(zé)檢測和 dump 內(nèi)存鏡像文件,文件裁剪后上報(bào)到服務(wù)端進(jìn)行分析。


          具體可以看這篇文章 Matrix ResourceCanary -- Activity 泄漏及 Bitmap 冗余檢測。
          https://mp.weixin.qq.com/s/XL55txToSCJXM8ErwrUGMw


          ③KOOM


          不管是 LeakCanary 還是 ResourceCanary,他們都只能在線下使用,而線上內(nèi)存泄漏監(jiān)控方案,目前 KOOM 的方案比較完善,下面我將基于 KOOM 分析線上內(nèi)存泄漏監(jiān)控方案的核心流程。
          https://github.com/KwaiAppTeam/KOOM/blob/master/README.zh-CN.md


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

          基于 KOOM 源碼分析:


          ①檢測時(shí)機(jī)


          間隔 5s 檢測一次;觸發(fā)內(nèi)存鏡像采集的條件。


          當(dāng)內(nèi)存使用率達(dá)到 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
          ????????}
          ??????}


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


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


          我們知道 LeakCanary 檢測內(nèi)存泄漏,不能用于線上,是因?yàn)樗?dump 內(nèi)存鏡像是在當(dāng)前進(jìn)程進(jìn)行操作,會凍結(jié) App 一段時(shí)間。


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


          整體的策略是:虛擬機(jī) supend→fork 虛擬機(jī)進(jìn)程→虛擬機(jī) resume→dump 內(nèi)存鏡像的策略。


          dump 內(nèi)存鏡像的源碼如下:
          ??//->ForkJvmHeapDumper

          ??public?boolean?dump(String?path)?{
          ????...

          ????boolean?dumpRes?=?false;
          ????try?{
          ??????//1、通過fork函數(shù)創(chuàng)建子進(jìn)程,會返回兩次,通過pid判斷是父進(jìn)程還是子進(jìn)程
          ??????int?pid?=?suspendAndFork();

          ??????MonitorLog.i(TAG,?"suspendAndFork,pid="+pid);
          ??????if?(pid?==?0)?{
          ????????//2、子進(jìn)程返回,dump內(nèi)存操作,dump內(nèi)存完成,退出子進(jìn)程
          ????????Debug.dumpHprofData(path);
          ????????exitProcess();
          ??????}?else?if?(pid?>?0)?{
          ????????//?3、父進(jìn)程返回,恢復(fù)虛擬機(jī),將子進(jìn)程的pid傳過去,阻塞等待子進(jìn)程結(jié)束
          ????????dumpRes?=?resumeAndWait(pid);
          ????????MonitorLog.i(TAG,?"notify?from?pid?"?+?pid);
          ??????}
          ????}
          ????return?dumpRes;
          ??}


          注釋 1:父進(jìn)程調(diào)用 native 方法掛起虛擬機(jī),并且創(chuàng)建子進(jìn)程;


          注釋 2:子進(jìn)程創(chuàng)建成功,執(zhí)行 Debug.dumpHprofData,執(zhí)行完后退出子進(jìn)程;


          注釋 3:得知子進(jìn)程創(chuàng)建成功后,父進(jìn)程恢復(fù)虛擬機(jī),解除凍結(jié),并且當(dāng)前線程等待子進(jìn)程結(jié)束。


          注釋 1 源碼如下:
          //?->native_bridge.cpp

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

          ??//2,fork子進(jìn)程,通過返回值可以判斷是主進(jìn)程還是子進(jìn)程
          ??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、恢復(fù)虛擬機(jī),兼容不同Android版本
          ??if?(android_api_?????resume_vm_fnc_();
          ??}
          ??...
          ??int?status;
          ??for?(;;)?{
          ????//2、waitpid,等待子進(jìn)程結(jié)束
          ????if?(waitpid(pid,?&status,?0)?!=?-1?||?errno?!=?EINTR)?{
          ??????//進(jìn)程異常退出
          ??????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ù),主進(jìn)程可以等待子進(jìn)程 dump 結(jié)束,然后再返回執(zhí)行內(nèi)存鏡像文件分析操作。


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


          前面一步已經(jīng)通過 Debug.dumpHprofData(path) 拿到內(nèi)存鏡像文件,接下來就開啟一個后臺服務(wù)來處理。
          ?//->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是否可達(dá),判斷內(nèi)存泄漏
          ??????findPathsToGcRoot()
          ????}
          ????...

          ????//5、泄漏信息填充到j(luò)son中,然后結(jié)束了
          ????fillJsonFile(jsonFile)


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

          ????//這個服務(wù)是在單獨(dú)進(jìn)程,分析完就退出
          ????System.exit(0);
          ??}


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

          • 通過 shark 這個開源庫將 hprof 文件轉(zhuǎn)換成 HeapGraph 對象。

          • 收集設(shè)備信息,封裝成 json,現(xiàn)場信息很重要。

          • filterLeakingObjects:過濾出泄漏的對象,有一些規(guī)制,例如已經(jīng) destroyed 和 finished 的 activity、fragment manager 為空的 fragment、已經(jīng) destroyed 的 window 等。

          • findPathsToGcRoot:內(nèi)存泄漏的對象,查找其到 GcRoot 的路徑,通過這一步就可以揪出內(nèi)存泄漏的原因。

          • fillJsonFile:格式化輸出內(nèi)存泄漏信息。


          | 小結(jié)

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

          • 掛起當(dāng)前進(jìn)程,然后通過 fork 創(chuàng)建子進(jìn)程

          • fork 會返回兩次,一次是子進(jìn)程,一次是父進(jìn)程,通過返回的 pid 可以判斷是子進(jìn)程還是父進(jìn)程

          • 如果是父進(jìn)程返回,則通過 resumeAndWait 恢復(fù)進(jìn)程,然后當(dāng)前線程阻塞等待子進(jìn)程結(jié)束

          • 如果子進(jìn)程返回,通過 Debug.dumpHprofData(path) 讀取內(nèi)存鏡像信息,這個會比較耗時(shí),執(zhí)行結(jié)束就退出子進(jìn)程

          • 子進(jìn)程退出,父進(jìn)程的 resumeAndWait 就會返回,這時(shí)候就可以開啟一個服務(wù),后臺分析內(nèi)存泄漏情況,這塊跟 LeakCanary 的分析內(nèi)存泄漏原理基本差不多


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


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


          方案如下:

          • 首先要了解 native 層。

          • 申請內(nèi)存的函數(shù):malloc、realloc、calloc、memalign、posix_memalign。

          • 釋放內(nèi)存的函數(shù):free。

          • hook 申請內(nèi)存和釋放內(nèi)存的函數(shù)。
          分配內(nèi)存的時(shí)候,收集堆棧、內(nèi)存大小、地址、線程等信息,存放到 map 中,在釋放內(nèi)存的時(shí)候從 map 中移除。

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

          • 周期性的使用?mark-and-sweep?分析整個進(jìn)程 Native Heap,獲取不可達(dá)的內(nèi)存塊信息「地址、大小

          • 獲取到不可達(dá)的內(nèi)存塊的地址后,可以從我們的 Map 中獲取其堆棧、內(nèi)存大小、地址、線程等信息


          具體實(shí)現(xiàn)可以參考:koom-native-leak。
          https://github.com/KwaiAppTeam/KOOM/blob/master/koom-native-leak/README.zh-CN.md


          總結(jié)


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

          • 對于 pthread_create OOM 問題,介紹了無侵入性的 new Thread 優(yōu)化、無侵入性的線程池優(yōu)化、以及線程泄漏監(jiān)控

          • 對于文件描述符過多問題,介紹了原理以及文件描述符監(jiān)控方案、IO 監(jiān)控方案

          • 對于 Java 內(nèi)存不足導(dǎo)致的 OOM、介紹了無侵入性圖片自動壓縮方案、兩種無侵入性的大圖監(jiān)控方案、Java 內(nèi)存泄漏監(jiān)控的線下方案和線上方案、以及 native 內(nèi)存泄漏監(jiān)控方案。


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


          有問題可以在 github 上找到我:
          https://github.com/lanshifu


          (完)

          碼農(nóng)突圍資料鏈接

          1、臥槽!字節(jié)跳動《算法中文手冊》火了,完整版 PDF 開放下載!
          2、計(jì)算機(jī)基礎(chǔ)知識總結(jié)與操作系統(tǒng) PDF 下載
          3、艾瑪,終于來了!《LeetCode Java版題解》.PDF
          4、Github 10K+,《LeetCode刷題C/C++版答案》出爐.PDF

          歡迎添加魚哥個人微信:smartfish2020,進(jìn)粉絲群或圍觀朋友圈

          瀏覽 48
          點(diǎn)贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評論
          圖片
          表情
          推薦
          點(diǎn)贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  色婷婷导航| 污污污在线免费观看 | 99热在线免费精品 | 国产一级大学生黄色片 | 99曰高清热视频 |