Android 创建线程源码与OOM分析

https://cloud.tencent.com/developer/article/1071770

有两种栈:

出现次数最多的一种,称之为堆栈A

java.lang.OutOfMemoryError:pthread_create(1040KB stack)failed:Outofmemory    java.lang.Thread.nativeCreate(Native Method)java.lang.Thread.start(Thread.java:745)...

另一种,出现次数较少,称之为堆栈B

java.lang.OutOfMemoryError:Could not allocate JNI Envjava.lang.Thread.nativeCreate(Native Method)java.lang.Thread.start(Thread.java:729)...

针对上面两种crash,分析一下Android/Linux中线程的创建过程,以及该OOM出现的原因。

1. 从java到native

我们看到最靠近栈顶的java方法调用的Thread::start, 该方法内部调用了 native 方法Thread::nativeCreate。如下:

publicsynchronizedvoidstart(){...nativeCreate(this,stackSize,daemon);...}

这里我们主要关注传入的两个参数

1.this: 即Thread对象自身

2.stackSize: 这个比较关键,指定了新创建的线程的栈大小,单位是字节(Byte)

Thread 类其中一个构造函数,接受stackSize参数

设置为0表示忽略之

文档提到:提高stackSize会减少StackOverFlow的发生,而降低stackSize会减少OutOfMemory的发生

另外:该参数是平台相关的,在一些平台上可能会直接被无视(有点类似Syste::gc的描述,然而目前来看gc在绝大多数平台都生效)

3.daemon: 表明新创建的线程是否是Daemon线程

2. 从native到ART

native层的代码分析的是Android 8.0的ART虚拟机源码,相关文件会给出全路径。

首先我们看一下 Thread::nativeCreate 的native实现。在art/runtime/native/java_lang_thread.cc 中。其主要逻辑会调用到 art/runtime/thread.cc 的 art::Thread::CreateNativeThread 函数来。

其主要逻辑如下:

voidThread::CreateNativeThread(JNIEnv*env,jobject java_peer,size_t stack_size,bool is_daemon){// 代码1Thread*child_thread=newThread(is_daemon);// 代码2std::unique_ptr<JNIEnvExt>child_jni_env_ext(JNIEnvExt::Create(child_thread,Runtime::Current()->GetJavaVM(),&error_msg));if(child_jni_env_ext.get()!=nullptr){// 代码片段3if(success)return;}// Either JNIEnvExt::Create or pthread_create(3) failed, so clean up.// 代码片段4}

代码1

创建了java.lang.Thread相对应的 native 层C++对象。

代码2

有JNI基础的同学知道,java中每一个 java线程 对应一个 JniEnv 结构。这里的JniEnvExt 就是ART 中的 JniEnv。这里源码中有一段注释

Try to allocate a JNIEnvExt for the thread. We do this here as we might be out of memory and do not have a good way to report this on the child’s side.

代码片段4

3是创建线程的主要逻辑,4是执行创建流程失败的收尾逻辑。我们先跳过3,看一下4的逻辑。

std::stringmsg(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)));ScopedObjectAccesssoa(env);soa.Self()->ThrowOutOfMemoryError(msg.c_str());

可以看到最后一句就是抛出我们熟悉的OOM异常的地方了。而且msg刚好和我们遇到的两种堆栈吻合。

child_jni_env_ext.get() == nullptr 对应的是堆栈B

pthread_create 调用失败对应的是堆栈A

所以这里我们可以得出堆栈B发生的原因:JNIEnvExt::Create调用失败。

跟进去看一下为什么JNIEnvExt::Create会return nullptr:

JNIEnvExt*JNIEnvExt::Create(Thread*self_in,JavaVMExt*vm_in,std::string*error_msg){std::unique_ptr<JNIEnvExt>ret(newJNIEnvExt(self_in,vm_in,error_msg));if(CheckLocalsValid(ret.get())){returnret.release();}returnnullptr;}

直接原因是CheckLocalsValidreturnfalse,再进一步是JniEnvExt::table_mem_map_是nullptr。

调用链是JniEnvExt::Create() -> JNIEnvExt::JNIEnvExt()(构造函数) ->IndirectReferenceTable::IndirectReferenceTable()

我们一步到位,直接看一下IndirectReferenceTable::IndirectReferenceTable()的实现

constsize_t table_bytes=max_count*sizeof(IrtEntry);table_mem_map_.reset(MemMap::MapAnonymous(...,table_bytes,...));

这里的max_count是常量art::kLocalsInitial == 512。而笔者自己计算了一下sizeof(IrtEntry) == 8。所以table_bytes = 512 * 8 = 4096 = 4k,刚好是一个内存页的大小。

因此是调用MemMap::MapAnonymous()失败了。

核心代码摘要如下, art/runtime/mem_map.cc:

// 1. 创建 ashmemfd.reset(ashmem_create_region(debug_friendly_name.c_str(),page_aligned_byte_count));// 2. 调用mmap映射到用户态内存地址空间void* actual = MapInternal(..., fd.get(), ...);

需要注意的是如果步骤1失败的话,fd.get()返回-1,步骤2仍然会正常执行,只不过其行为有所不同。

如果步骤1成功的话,两个步骤则是:

1.通过Andorid的匿名共享内存(Anonymous Shared Memory)分配 4KB(一个page)内核态内存

2.再通过 Linux 的 mmap 调用映射到用户态虚拟内存地址空间。

如果步骤1失败的话,步骤2则是:

通过 Linux 的 mmap 调用创建一段虚拟内存。

注意是分配虚拟内存失败了,区分一下虚拟内存和物理内存的概念。

考察失败的场景:

步骤1 失败的情况一般是内核分配内存失败,这种情况下,整个设备/OS的内存应该都处于非常紧张的状态。

步骤2 失败的情况一般是 进程虚拟内存地址空间耗尽。

另外,8.0的代码中可以看到,在mmap失败之后,会整理一串错误信息出来,而外网的crash中没看到相关信息,猜测是新版本加入的。错误信息如下:”Failed anonymous mmap(%p, %zd, 0x%x, 0x%x, %d, 0): %s. See process maps in the log.”

显然,此处是因为步骤2 失败。

PS: 关于Android 的ashmem可以阅读

Android系统匿名共享内存Ashmem(Anonymous Shared Memory)驱动程序源代码分析(http://blog.csdn.net/luoshengyang/article/details/6664554)

技术内幕:Android对Linux内核的增强 Ashmem(http://www.jmpcrash.com/?p=315)

Android Kernel Features(https://elinux.org/Android_Kernel_Features#ashmem)

至此,代码片段4就分析完了,其只主要功能就是创建子线程相关的数据结构。同事也分析出了Crash堆栈B的出现原因,而Crash堆栈A出现的原因则隐藏在代码片段3中。

3. 从 ART 到 pthread

代码片段3:

if(child_jni_env_ext.get()!=nullptr){pthread_t new_pthread;pthread_attr_t attr;...CHECK_PTHREAD_CALL(pthread_attr_setstacksize,(&attr,stack_size),stack_size);pthread_create_result=pthread_create(&new_pthread,&attr,Thread::CreateCallback,child_thread);if(pthread_create_result==0){...return;}}

可以看到,主要逻辑就是调用了pthread_create,该函数有几个参数:

new_pthread: 新创建的线程的句柄。attr: 指定了新线程的一些属性,其中包括栈大小。Thread::CreateCallback: 新创建的线程的routine函数,即,线程的入口函数。child_thread: callbac的唯一参数,此处是 native 层的 Thread 类。

废话不多少,我们进去pthread_create看一下代码逻辑。

PS:Android的C语言标准库实现是区别于普通GNU/Linux发行版的glic的,因为后者是LGPL协议的,Android重写了一个实现,用的是BSD协议。该lib叫做Bionichttps://www.wikiwand.com/en/Bionic_(software) (意为仿生)。

bionic/lib/bionic/pthread_create.cpp:

intpthread_create(pthread_t*thread_out,pthread_attr_tconst*attr,void*(*start_routine)(void*),void*arg){...// 1. 分配栈。pthread_internal_t*thread=NULL;void*child_stack=NULL;int result=__allocate_thread(&thread_attr,&thread,&child_stack);if(result!=0){returnresult;}...// 2. linux 系统调用 clone,执行真正的创建动作。int rc=clone(__pthread_start,child_stack,flags,thread,&(thread->tid),tls,&(thread->tid));if(rc==-1){returnerrno;}...return0;}

步骤2先按下不表,我们看看步骤1的逻辑:

staticint__allocate_thread(...){mmap_size=BIONIC_ALIGN(attr->stack_size+sizeof(pthread_internal_t),PAGE_SIZE);attr->stack_base=__create_thread_mapped_space(mmap_size,attr->guard_size);if(attr->stack_base==NULL){returnEAGAIN;}...}

再看一下__create_thread_mapped_space干了什么:

staticvoid*__create_thread_mapped_space(size_t mmap_size,size_t stack_guard_size){// Create a new private anonymous map.int prot=PROT_READ|PROT_WRITE;int flags=MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE;void*space=mmap(NULL,mmap_size,prot,flags,-1,0);if(space==MAP_FAILED){...returnNULL;}// 代码片段1returnspace;}

主体逻辑再简单不过,即:调用mmap分配栈内存。这里mmap flag中指定了MAP_ANONYMOUS,即匿名内存映射(mapping anonymous)(https://www.wikiwand.com/en/Mmap#/File-backed_and_anonymous)。这是在Linux中分配大块内存的常用方式。其分配的是虚拟内存,对应页的物理内存并不会立即分配,而是在用到的时候,触发内核的缺页中断,然后中断处理函数再分配物理内存。

我们看一下创建的内存大小是怎么计算的。在pthread的实现中,mmap分配的内存赋值给了stack_base,stack_base不光是线程执行的栈,其中还存储了线程的其他信息(如线程名,ThreadLocal变量等),这些信息定义在pthread_internal_t结构体中。因此实际分配的内存大小是stack_size + sizeof(pthread_internal_t),然后再向上取整,按照内存页大小对齐。

还记得crash堆栈A的异常描述吗

java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory

结论

没错!就是因为这里的mmap失败了。又是虚拟内存分配失败。

默认 StackSize 是多少

另外一个需要考虑的事,如果没有指定stackSize,默认的是多少呢?

Java层的Thread类默认stackSize是0,传给native层也是0,于是在native层有这样一段代码。

staticsize_tFixStackSize(size_t stack_size){if(stack_size==0){// GetDefaultStackSize 是启动art时命令行的 "-Xss=" 参数// Android 中没有该参数,因此为0.stack_size=Runtime::Current()->GetDefaultStackSize();}// bionic pthread 默认栈大小是 1Mstack_size+=1*MB;...if(Runtime::Current()->ExplicitStackOverflowChecks()){// 8Kstack_size+=GetStackOverflowReservedBytes(kRuntimeISA);}else{// 8K + 8Kstack_size+=Thread::kStackOverflowImplicitCheckSize+GetStackOverflowReservedBytes(kRuntimeISA);}...returnstack_size;}

因此 默认的stackSize = 1M + 8K + 8K = 1040K,和crash堆栈完全一致。

Native 层的Stack Overflow检测

另外上面的代码片段1其实也挺有意思的,它优雅的判断了StackOverflow的场景,避免栈内存溢出污染其他内存区域。

PS 代码片段1:

// Stack is at the lower end of mapped space, stack guard region is at the lower end of stack.// Set the stack guard region to PROT_NONE, so we can detect thread stack overflow.if(mprotect(space,stack_guard_size,PROT_NONE)==-1){...munmap(space,mmap_size);returnNULL;}prctl(PR_SET_VMA,PR_SET_VMA_ANON_NAME,space,stack_guard_size,"thread stack guard page");

栈的增长方向是从高地址到低地址,因此把栈最低地址的stack_guard_size字节的内存设置成不可访问。当访问到的时候就会触发系统的异常处理~这段内存有个名字叫做Red Zone

4. 从 pthread 到 Linux 内核调用

这里主要涉及到 linux 的clone系统调用(SystemCall)(http://man7.org/linux/man-pages/man2/clone.2.html)。man page说:

clone() creates a new process, in a manner similar to fork(2).

嗯,“clone创建新进程”?等等,不是线程吗?哈,这里有一个很有趣的地方,Unix里面其实只有进程,而线程是 POSIX标准定义的。因此这里的clone是实现线程的一种手段。

简单来说:

fork:创建新的进程,并把父进程的内存全部copy到子进程,两者的内存不共享。(后来优化出了CopyOnWrite机制,几乎完全优化掉了Copy内存的开销)。

clone:创建新的进程,并且父进程和子进程共享内存。

因此当两个进程的内存共享之后,完全就符合“线程”的定义了。

5. 结论OOM分析

OK,终于分析完了,看了好多代码。最终得出一个结论,不管是堆栈A,还是堆栈B:

创建线程过程中发生OOM是因为进程内的虚拟内存地址空间耗尽了。

所以,什么情况下虚拟内存地址空间才会耗尽呢?我们先研究一下linux的虚拟内存怎么布局的。可以参看这里, 笔者借用另一个PPT 21页(https://events.linuxfoundation.org/sites/events/files/slides/elc_2016_mem.pdf)

可以看到32位系统中,用户空间的内存是3G大,简单起见,我们粗略估计一下,假设

1.可见虚拟内存是3G大(实际值更小)

2.创建一个进程需要1M虚拟内存(实际值更大)

因此再假设有一个进程,除了创建线程什么都不干,那他最多能创建多少个线程?

3G/1M=约3000个

没错,在完全理想的情况下最多是3000个线程。综合其他因素,实际值会明显小于3000。虽然3000的上限看上去很大,而如果有代码逻辑问题,创建很多线程,其实很容易爆掉。

外网上报的crash则属于这种情况,某种corner-case下会导致线程的无节制创建。

6. PS: FileDescriptor超出上限?!

受到《不可思议的OOM》(https://mp.weixin.qq.com/s/AjtzDxwJzyqC95FXgDPS1g) 启发,在此特别感谢作者。

请读者先行阅读上文。

怎么判断虚拟内存用完还是FileDescriptor耗尽呢?

对于堆栈A

我们看到抛出OOM的地方已经保留了错误码信息

pthread_create_result=pthread_create(...);...StringPrintf("pthread_create (%s stack) failed: %s",PrettySize(stack_size).c_str(),strerror(pthread_create_result)));

代码中pthread_create_result是linux标准错误码定义,定义在 bionic/lib/private/bionic_errdefs/bionic_errdefs.h 头文件中,

__BIONIC_ERRDEF(EBADF,9,"Bad file descriptor")__BIONIC_ERRDEF(ECHILD,10,"No child processes")__BIONIC_ERRDEF(EAGAIN,11,"Try again")__BIONIC_ERRDEF(ENOMEM,12,"Out of memory")// <-----...__BIONIC_ERRDEF(EMFILE,24,"Too many open files")// <-----

因此我们可以通过OOM异常的message字段,对应看到错误码。在企鹅FM的异常场景中,属于12,即Out of memory。

同时,在上文提到的linux clone系统调用中,有一处log。

int rc=clone(__pthread_start,child_stack,flags,thread,&(thread->tid),tls,&(thread->tid));if(rc==-1){...__libc_format_log(ANDROID_LOG_WARN,"libc","pthread_create failed: clone failed: %s",strerror(errno));}

因此,在系统log中也能看到蛛丝马迹,例如:

:pthread_create failed:clone failed:Outofmemory11-0612:27:00.2563077531188W art:Throwing OutOfMemoryError"pthread_create (1040KB stack) failed: Out of memory"

对于堆栈B

在上文提到的代码片段中:

fd.Reset(ashmem_create_region(debug_friendly_name.c_str(),page_aligned_byte_count),/* check_usage */false);if(fd.Fd()==-1){*error_msg=StringPrintf("ashmem_create_region failed for '%s': %s",name,strerror(errno));

可以看到会打印出来错误信息,然而Android 8.0 似乎改了代码 https://android.googlesource.com/platform/art/+/a5c61bf479453e7e195888afb4e62a9872d6be7c%5E%21/runtime/mem_map.cc

对应日志中可以看到 errno

11-0606:25:54.19337258575E art:ashmem_create_region failedfor'indirect ref table':Too many open files11-0606:25:54.19337258575W art:Throwing OutOfMemoryError"Could not allocate JNI Env"

企鹅FM中的堆栈B场景属于 FileDescriptor 耗尽

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容