不可思議的OOM

摘要:
?本文發現了一類OOM(OutOfMemoryError),這類OOM的特點是崩潰時java堆內存和設備物理內存都充足,探索并解釋了這類OOM拋出的原因。

關鍵字:
?OutOfMemoryError ,OOM,pthread_create failed , Could not allocate JNI Env

一. 引子

?對于每一個移動開發者,內存是都需要小心使用的資源,而線上出現的OOM(OutOfMemoryError)都會讓開發者抓狂,因為我們通常仰仗的直觀的堆棧信息對于定位這種問題通常幫助不大。
?網上有很多資料教我們如何“緊衣縮食“的利用寶貴的堆內存(比如,使用小圖片,bitmap復用等),可是:

  • 線上的OOM真的全是由于堆內存緊張導致的嗎?
  • 有沒有App堆內存寬裕,設備物理內存也寬裕的情況下發生OOM的可能?

?內存充裕的時候出現OOM崩潰?看似不可思議,然而,最近筆者在調查一個問題的時候,通過自研的APM平臺發現公司的一個產品的大部分OOM確實有這樣的特征,即:

  • OOM崩潰時,java堆內存遠遠低于Android虛擬機設定的上限,并且物理內存充足,SD卡空間充足

?既然內存充足,這時候為什么會有OOM崩潰呢?

二. 問題描述

?在詳細描述問題之前,先弄清楚一個問題:

????什么導致了OOM的產生?

下面是幾個關于Android官方聲明內存限制閾值的API:

ActivityManager.getMemoryClass():     虛擬機java堆大小的上限,分配對象時突破這個大小就會OOM
ActivityManager.getLargeMemoryClass():manifest中設置largeheap=true時虛擬機java堆的上限
Runtime.getRuntime().maxMemory() :    當前虛擬機實例的內存使用上限,為上述兩者之一
Runtime.getRuntime().totalMemory() :  當前已經申請的內存,包括已經使用的和還沒有使用的
Runtime.getRuntime().freeMemory() :   上一條中已經申請但是尚未使用的那部分。那么已經申請并且正在使用的部分used=totalMemory() - freeMemory()
ActivityManager.MemoryInfo.totalMem:   設備總內存
ActivityManager.MemoryInfo.availMem:   設備當前可用內存
/proc/meminfo                                           記錄設備的內存信息

????????圖2-1 Android內存指標

?通常認為OOM發生是由于java堆內存不夠用了,即

Runtime.getRuntime().maxMemory()這個指標滿足不了申請堆內存大小時

????????圖2-2 Java堆OOM產生原因
?這種OOM可以非常方便的驗證(比如: 通過new byte[]的方式嘗試申請超過閾值maxMemory()的堆內存),通常這種OOM的錯誤信息通常如下:

java.lang.OutOfMemoryError: Failed to allocate a XXX byte allocation with XXX free bytes and XXXKB until OOM

????????圖2-3 堆內存不夠導致的OOM的錯誤信息
?而前面已經提到了,本文中發現的OOM案例中堆內存充裕(Runtime.getRuntime().maxMemory()大小的堆內存還剩余很大一部分),設備當前內存也很充裕(ActivityManager.MemoryInfo.availMem還有很多)。這些OOM的錯誤信息大致有下面兩種:

  1. 這種OOM在Android6.0,Android7.0上各個機型均有發生,文中簡稱為OOM一,錯誤信息如下:
java.lang.OutOfMemoryError: Could not allocate JNI Env

????????圖2-4 OOM一的錯誤信息

  1. 集中發生在Android7.0及以上的華為手機(EmotionUI_5.0及以上)的OOM,簡稱為OOM二,對應錯誤信息如下:
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory

????????圖2-5 OOM二的錯誤信息

三. 問題分析及解決

3.1 代碼分析

?Android系統中,OutOfMemoryError這個錯誤是怎么被系統拋出的?下面基于Android6.0的代碼進行簡單分析:

  1. Android虛擬機最終拋出OutOfMemoryError的代碼位于 /art/runtime/thread.cc
void Thread::ThrowOutOfMemoryError(const char* msg)
參數msg攜帶了OOM時的錯誤信息

????????圖3-1 ART Runtime拋出的位置

  1. 搜索代碼可以發現以下幾個地方調用了上述方法拋出OutOfMemoryError錯誤
  • 第一個地方是堆操作時
系統源碼文件:
    /art/runtime/gc/heap.cc
函數:
    void Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type)
拋出時的錯誤信息:
    oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free  << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM";

????????圖3-2 Java堆OOM
?這種拋出的其實就是堆內存不夠用的時候,即前面提到的申請堆內存大小超過了Runtime.getRuntime().maxMemory()

  • 第二個地方是創建線程時
系統源碼文件:
    /art/runtime/thread.cc
函數:
    void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon)
拋出時的錯誤信息:
    "Could not allocate JNI Env"
  或者
    StringPrintf("pthread_create (%s stack) failed: %s", PrettySize(stack_size).c_str(), strerror(pthread_create_result)));

????????圖3-3 線程創建時OOM
?對比錯誤信息,可以知道我們遇到的OOM崩潰就是這個時機,即創建線程的時候(Thread::CreateNativeThread)產生的。

  • 還有其他的一些錯誤信息如”[XXXClassName] of length XXX would overflow“是系統限制String/Array的長度所致,不在本文討論之列。

那么,我們關心的就是Thread::CreateNativeThread時拋出的OOM錯誤,創建線程為什么會導致OOM呢?

3.2 推斷

?既然拋出來OOM,一定是線程創建過程中觸發了某些我們不知道的限制,既然不是Art虛擬機為我們設置的堆上限,那么可能是更底層的限制。
?Android系統基于linux,所以linux的限制對于Android同樣適用,這些限制有:

  1. /proc/pid/limits 描述著linux系統對對應進程的限制,下面是一個樣例:
Limit                     Soft Limit           Hard Limit           Units     
Max cpu time              unlimited            unlimited            seconds   
Max file size             unlimited            unlimited            bytes     
Max data size             unlimited            unlimited            bytes     
Max stack size            8388608              unlimited            bytes     
Max core file size        0                    unlimited            bytes     
Max resident set          unlimited            unlimited            bytes     
Max processes             13419                13419                processes 
Max open files            1024                 4096                 files     
Max locked memory         67108864             67108864             bytes     
Max address space         unlimited            unlimited            bytes     
Max file locks            unlimited            unlimited            locks     
Max pending signals       13419                13419                signals   
Max msgqueue size         819200               819200               bytes     
Max nice priority         40                   40                   
Max realtime priority     0                    0                    
Max realtime timeout      unlimited            unlimited            us 

????????圖3-4 Linux進程限制示例
?用排除法篩選上面樣例中的limits:

  • Max stack size,Max processes的限制是整個系統的,不是針對某個進程的,排除
  • Max locked memory ,排除,后面會分析,線程創建過程中分配線程私有stack使用的mmap調用沒有設置MAP_LOCKED,所以這個限制與線程創建過程無關
  • Max pending signals,c層信號個數閾值,無關,排除
  • Max msgqueue size,Android IPC機制不支持消息隊列,排除

?剩下的limits項中,Max open files這一項限制最可疑
?Max open files表示每個進程最大打開文件的數目,進程每打開一個文件就會產生一個文件描述符fd(記錄在/proc/pid/fd下面),這個限制表明fd的數目不能超過Max open files規定的數目
?后面分析線程創建過程中會發現過程中涉有及到文件描述符。

  1. /proc/sys/kernel中描述的限制

?這些限制中與線程相關的是/proc/sys/kernel/threads-max,規定了每個進程創建線程數目的上限,所以線程創建導致OOM的原因也有可能與這個限制相關。

3.3 驗證

下面對上述的推斷進行驗證,分兩步:本地驗證和線上驗收。

  • 本地驗證:在本地驗證推斷,試圖復現與圖[2-4]OOM一與圖[2-5]OOM二所示錯誤消息一致的OOM
  • 線上驗收:下發插件,驗收線上用戶OOM時確實是由于上面的推斷的原因導致的

本地驗證

實驗一:
?觸發大量網絡連接(每個連接處于獨立的線程中)并保持,每打開一個socket都會增加一個fd(/proc/pid/fd下多一項)
?注:不只有這一種增加fd數的方式,也可以用其他方法,比如打開文件,創建handlerthread等等

  • 實驗預期:
    當進程fd數(可以通過 ls /proc/pid/fd | wc -l 獲得)突破 /proc/pid/limits中規定的Max open files時,產生OOM
  • 實驗結果:
    當fd數目到達 /proc/pid/limits中規定的Max open files時,繼續開線程確實會導致OOM的產生。錯誤信息及堆棧如下:
E/art: ashmem_create_region failed for 'indirect ref table': Too many open files
E/AndroidRuntime: FATAL EXCEPTION: main
                  Process: com.netease.demo.oom, PID: 2435
                  java.lang.OutOfMemoryError: Could not allocate JNI Env
                      at java.lang.Thread.nativeCreate(Native Method)
                      at java.lang.Thread.start(Thread.java:730)
                      ......

????????圖3-5 FD數超限導致OOM的詳細信息
?可以看出,此OOM發生時的錯誤信息確與線上發現的OOM一的“Could not allocate JNI Env”吻合,因此線上上報的OOM一可能就是由FD數超限導致的,不過最終確定需要到線上進行驗證(下一小節).
?此外從ART虛擬機的Log中看出,還有一個關鍵的信息“ art: ashmem_create_region failed for 'indirect ref table': Too many open files”,后面會用于問題定位及解釋。

實驗二:
?創建大量的空線程(不做任何事情,直接sleep)

  • 實驗預期:
    當線程數(可以在/proc/pid/status中的threads項實時查看)超過/proc/sys/kernel/threads-max中規定的上限時產生OOM崩潰

  • 實驗結果:

  1. 在Android7.0及以上的華為手機(EmotionUI_5.0及以上)的手機產生OOM,這些手機的線程數限制都很小(應該是華為rom特意修改的limits),每個進程只允許最大同時開500個線程,因此很容易復現了。OOM時錯誤信息如下:
W libc    : pthread_create failed: clone failed: Out of memory
W art     : Throwing OutOfMemoryError "pthread_create (1040KB stack) failed: Out of memory"
E AndroidRuntime: FATAL EXCEPTION: main
                  Process: com.netease.demo.oom, PID: 4973
                  java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory
                      at java.lang.Thread.nativeCreate(Native Method)
                      at java.lang.Thread.start(Thread.java:745)
                      ......

????????圖3-6 線程數超限導致的OOM詳細信息
?可以看出錯誤信息與我們線上遇到的OOM二吻合:"pthread_create (1040KB stack) failed: Out of memory"
?另外ART虛擬機還有一個關鍵Log:“pthread_create failed: clone failed: Out of memory”,后面會用于問題定位及解釋。

  1. 其他Rom的手機線程數的上限都比較大,不容易復現上述問題。但是,對于32位的系統,當進程的邏輯地址空間不夠的時候也會產生OOM,每個線程通常需要mapp 1MB左右的stack空間(stack大小可以自行設置),32為系統進程邏輯地址4GB,用戶空間少于3GB。邏輯地址空間不夠(已用邏輯空間地址可以查看/proc/pid/status中的VmPeak/VmSize記錄),此時創建線程產生的OOM具有如下信息:
W/libc: pthread_create failed: couldn't allocate 1069056-bytes mapped space: Out of memory
W/art: Throwing OutOfMemoryError "pthread_create (1040KB stack) failed: Try again"
E/AndroidRuntime: FATAL EXCEPTION: main
                  Process: com.netease.demo.oom, PID: 8638
                  java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
                       at java.lang.Thread.nativeCreate(Native Method)
                       at java.lang.Thread.start(Thread.java:1063)
                       ......

????????圖3-7 邏輯地址空間占滿導致的OOM

線上驗收及問題解決

?本地嘗試復現的OOM錯誤信息中圖[3-5]與線上OOM一情況比較吻合,圖[3-6]與線上OOM二的情況比較吻合,但線上的OOM一真的時FD數目超限,OOM二真的是由于華為手機線程數超限的原因導致的嗎?最終確定還需要取線上設備的數據進行驗證.

驗證方法:
?下發插件到線上用戶,當Thread.UncaughtExceptionHandler捕獲到OutOfMemoryError時記錄/proc/pid目錄下的如下信息:

  1. /proc/pid/fd目錄下文件數(fd數)
  2. /proc/pid/status中threads項(當前線程數目)
  3. OOM的日志信息(出了堆棧信息還包含其他的一些warning信息

線上OOM一驗證
?發生OOM一的線上設備中采集到的信息:

  1. /proc/pid/fd目錄下文件數與/proc/pid/limits中的Max open files 數目持平,證明FD數目已經滿了
  2. 崩潰時日志信息與圖[3-5]基本一致

由此,證明線上的OOM一確實是由于FD數目過多導致的OOM,推斷驗證成功

OOM一的定位與解決:
?最終原因是App中使用的長連接庫再某些時候會有瞬時發出大量http請求的bug(導致FD數激增),已修復

線上OOM二驗證
?集中在華為系統的OOM二崩潰時收集到的信息樣例如下,(收集的樣例中包含的devicemodel有VKY-AL00,TRT-AL00A,BLN-AL20,BLN-AL10,DLI-AL10,TRT-TL10,WAS-AL00等):

  1. /proc/pid/status中threads記錄全部到達上限:Threads: 500
  2. 崩潰時日志信息與圖[3-6]基本一致

推斷驗證成功,即線程數受限導致創建線程時clone failed導致了線上的OOM二

OOM二的定位與解決:
?關于App業務代碼中的問題還在定位修復中

3.4 解釋

下面從代碼分析本文描述的OOM是怎么發生的,首先線程創建的簡易版流程圖如下所示:

圖3-8 線程創建流程

上圖中,線程創建大概有兩個關鍵的步驟:

  • 第一列中的創建線程私有的結構體JNIENV(JNI執行環境,用于C層調用Java層代碼)
  • 第二列中的調用posix C庫的函數pthread_create進行線程創建工作

下面對流程圖中關鍵節點(圖中有標號的)進行說明:

  1. 圖中節點①, /art/runtime/thread.cc中的函數Thread:CreateNativeThread部分節選代碼如下:
    std::string msg(child_jni_env_ext.get() == nullptr ?
        "Could not allocate JNI Env" :
        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());

????????圖3-9 Thread:CreateNativeThread節選
可知:

  • JNIENV創建不成功時產生OOM的錯誤信息為"Could not allocate JNI Env",與文中OOM一一致
  • pthread_create失敗時拋出OOM的錯誤信息為"pthread_create (%s stack) failed: %s".其中詳細的錯誤信息由pthread_create的返回值(錯誤碼)給出.錯誤碼與錯誤描述的對應關系可以參見bionic/libc/include/sys/_errdefs.h中的定義.文中OOM二的具體錯誤信息為"Out of memory",就說明pthread_create的返回值為12.
...
__BIONIC_ERRDEF( EAGAIN         ,  11, "Try again" )
__BIONIC_ERRDEF( ENOMEM         ,  12, "Out of memory" )
...
__BIONIC_ERRDEF( EMFILE         ,  24, "Too many open files" )
...

????????圖3-10 系統錯誤定義_errdefs.h

  1. 圖中節點②和③是創建JNIENV過程的關鍵節點,節點②/art/runtime/mem_map.cc中函數MemMap:MapAnonymous的作用是為JNIENV結構體中Indirect_Reference_table(C層用于存儲JNI局部/全局變量)申請內存,申請內存的方法是節點③所示的函數ashmem_create_region(創建一塊ashmen匿名共享內存,并返回一個文件描述符).節點②代碼節選如下:
  if (fd.get() == -1) {
      *error_msg = StringPrintf("ashmem_create_region failed for '%s': %s", name, strerror(errno));
      return nullptr;
  }

????????圖3-11 MemMap:MapAnonymous節選
?我們線上的OOM一的錯誤信息"ashmem_create_region failed for 'indirect ref table': Too many open files",與此處打印的信息吻合."Too many open files"的錯誤描述說明此處的errno(系統全局錯誤標識)為24(見圖[3-10]系統錯誤定義_errdefs.h).
?由此看出我們線上的OOM一是由于文件描述符數目已滿,ashmem_create_region無法返回新的FD而導致的

  1. 圖中節點④和⑤是調用C庫創建線程時的環節,創建線程首先調用__allocate_thread函數申請線程私有的棧內存(stack)等,然后調用clone方法進行線程創建.申請stack采用的時mmap的方式,節點⑤代碼節選如下:
  if (space == MAP_FAILED) {
    __libc_format_log(ANDROID_LOG_WARN,
                      "libc",
                      "pthread_create failed: couldn't allocate %zu-bytes mapped space: %s",
                      mmap_size, strerror(errno));
    return NULL;
  }

????????圖3-12 __create_thread_mapped_space節選
?打印的錯誤信息與圖[3-7]中進程邏輯地址占滿導致的OOM錯誤信息吻合,圖[3-7]中錯誤信息" Try again"說明系統全局錯誤標識errno為11(見圖[3-10]系統錯誤定義_errdefs.h).
?pthread_create過程中,節點4相關代碼如下:

 int rc = clone(__pthread_start, child_stack, flags, thread, &(thread->tid), tls, &(thread->tid));
  if (rc == -1) {
    int clone_errno = errno;
    // We don't have to unlock the mutex at all because clone(2) failed so there's no child waiting to
    // be unblocked, but we're about to unmap the memory the mutex is stored in, so this serves as a
    // reminder that you can't rewrite this function to use a ScopedPthreadMutexLocker.
    pthread_mutex_unlock(&thread->startup_handshake_mutex);
    if (thread->mmap_size != 0) {
      munmap(thread->attr.stack_base, thread->mmap_size);
    }
    __libc_format_log(ANDROID_LOG_WARN, "libc", "pthread_create failed: clone failed: %s", strerror(errno));
    return clone_errno;
  }

????????圖3-13 pthread_create節選
?此處輸出的錯誤日志"pthread_create failed: clone failed: %s"與我們線上發現的OOM二吻合,圖[3-6]中的錯誤描述" Out of memory"說明系統全局錯誤標識errno為12(見圖[3-10]系統錯誤定義_errdefs.h).
?由此線上的OOM二就是由于線程數的限制而在節點5 clone失敗導致OOM.

四. 結論及監控

4.1 導致OOM發生的原因

綜上,可以導致OOM的原因有以下幾種:

  1. 文件描述符(fd)數目超限,即proc/pid/fd下文件數目突破/proc/pid/limits中的限制。可能的發生場景有:
    短時間內大量請求導致socket的fd數激增,大量(重復)打開文件等
  2. 線程數超限,即proc/pid/status中記錄的線程數(threads項)突破/proc/sys/kernel/threads-max中規定的最大線程數。可能的發生場景有:
    app內多線程使用不合理,如多個不共享線程池的OKhttpclient等等
  3. 傳統的java堆內存超限,即申請堆內存大小超過了 Runtime.getRuntime().maxMemory()
  4. (低概率)32為系統進程邏輯空間被占滿導致OOM.
  5. 其他

4.2 監控措施

可以利用linux的inotify機制進行監控:

  • watch /proc/pid/fd來監控app打開文件的情況,
  • watch /proc/pid/task來監控線程使用情況.

五. Demo

POC(Proof of concept) 代碼參見:https://github.com/piece-the-world/OOMDemo

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容