《Android 创建线程源码与OOM分析》
| 导语 企鹅FM近几个版本的外网Crash出现很多OutOfMemory(以下简称OOM)问题,Crash的堆栈都在Thread::start方法上。该文详细分析了发生原因。
有两种栈:
出现次数最多的一种,称之为 堆栈A。
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory
java.lang.Thread.nativeCreate(Native Method)
java.lang.Thread.start(Thread.java:745)
...
另一种,出现次数较少,称之为 堆栈B。
java.lang.OutOfMemoryError: Could not allocate JNI Env
java.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
。如下:
public synchronized void start() {
...
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 函数来。
其主要逻辑如下:
void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) { // 代码1
Thread* child_thread = new Thread(is_daemon); // 代码2
std::unique_ptr<JNIEnvExt> child_jni_env_ext(
JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM(), &error_msg));
if (child_jni_env_ext.get() != nullptr) { // 代码片段3
if (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::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());
可以看到最后一句就是抛出我们熟悉的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(new JNIEnvExt(self_in, vm_in, error_msg));
if (CheckLocalsValid(ret.get())) {
return ret.release();
}
return nullptr;
}
直接原因是CheckLocalsValid
return false
,再进一步是 JniEnvExt::table_mem_map_
是nullptr
。
调用链是 JniEnvExt::Create() -> JNIEnvExt::JNIEnvExt()
(构造函数) -> IndirectReferenceTable::IndirectReferenceTable()
我们一步到位,直接看一下IndirectReferenceTable::IndirectReferenceTable()
的实现
const size_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:
int pthread_create(pthread_t* thread_out, pthread_attr_t const* 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) {
return result;
}
... // 2. linux 系统调用 clone,执行真正的创建动作。
int rc = clone(__pthread_start, child_stack, flags, thread, &(thread->tid), tls, &(thread->tid));
if (rc == -1) {
return errno;
}
... return 0;
}
步骤2先按下不表,我们看看步骤1的逻辑:
static int __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) {
return EAGAIN;
}
...
}
再看一下__create_thread_mapped_space
干了什么:
static void* __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) {
... return NULL;
} // 代码片段1
return space;
}
主体逻辑再简单不过,即:调用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层有这样一段代码。
static size_t FixStackSize(size_t stack_size) {
if (stack_size == 0) {
// GetDefaultStackSize 是启动art时命令行的 "-Xss=" 参数
// Android 中没有该参数,因此为0.
stack_size = Runtime::Current()->GetDefaultStackSize();
} // bionic pthread 默认栈大小是 1M
stack_size += 1 * MB;
...
if (Runtime::Current()->ExplicitStackOverflowChecks()) { // 8K
stack_size += GetStackOverflowReservedBytes(kRuntimeISA);
} else { // 8K + 8K
stack_size += Thread::kStackOverflowImplicitCheckSize +
GetStackOverflowReservedBytes(kRuntimeISA);
}
... return stack_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);
return NULL;
}
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: Out of memory11-06 12:27:00.256 30775 31188 W 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-06 06:25:54.193 3725 8575 E art : ashmem_create_region failed for 'indirect ref table': Too many open files11-06 06:25:54.193 3725 8575 W art : Throwing OutOfMemoryError "Could not allocate JNI Env"
企鹅FM中的堆栈B场景属于 FileDescriptor 耗尽
如果您觉得我们的内容还不错,就请转发到朋友圈,和小伙伴一起分享吧~
- 【Python3-API】情感倾向分析示例代码
- SpringMVC+Hibernate +MySql+ EasyUI实现CRUD(一)
- 【Python3-API】通用文字识别示例代码
- Python入门教程之安装MyEclipse插件和安装Python环境
- AutoFlowLayout-多功能流式布局与网格布局控件
- RBAC新解:基于资源的权限管理(Resource-Based Access Control)
- 基于开源项目搭建属于自己的技术堆栈
- Redis整合Spring项目搭建实例
- SpringMVC+Hibernate +MySql+ EasyUI实现POI导出Excel(二)
- Nginx+Tomcat+Redis负载均衡Session共享实现超级简单(CentOS6.9系统 Java版本)
- Apache Ignite——新一代数据库缓存系统
- 微信JSSDK接入Java版--步骤及问题处理和解决
- 微信企业号回调模式配置讲解 Java Servlet+Struts2版本 echostr校验失败解决
- Android Material Design系列之RecyclerView和CardView
- java教程
- Java快速入门
- Java 开发环境配置
- Java基本语法
- Java 对象和类
- Java 基本数据类型
- Java 变量类型
- Java 修饰符
- Java 运算符
- Java 循环结构
- Java 分支结构
- Java Number类
- Java Character类
- Java String类
- Java StringBuffer和StringBuilder类
- Java 数组
- Java 日期时间
- Java 正则表达式
- Java 方法
- Java 流(Stream)、文件(File)和IO
- Java 异常处理
- Java 继承
- Java 重写(Override)与重载(Overload)
- Java 多态
- Java 抽象类
- Java 封装
- Java 接口
- Java 包(package)
- Java 数据结构
- Java 集合框架
- Java 泛型
- Java 序列化
- Java 网络编程
- Java 发送邮件
- Java 多线程编程
- Java Applet基础
- Java 文档注释
- Redis集群方案对比:Codis、Twemproxy、Redis Cluster
- 这就是你日日夜夜想要的docker!!!---------Docker镜像制作与私有仓库建立
- 排障集锦:九九八十一难之第十八难!-----System has not been booted with systemd as init system (PID 1). Can‘t operat
- 深入了解 Flex 属性
- 如何设计一个安全的短信接口?
- ERROR Shell:396 - Failed to locate the winutils binary in the hadoop binary path java.io.IOE...
- Windows 安装配置 PySpark 开发环境(详细步骤+原理分析)
- 安利三个关于Python字符串格式化进阶知识
- TCP/IP学习笔记1——协议分层
- 用Python爬取淘宝4403条大裤衩数据进行分析,终于找到可以入手的那一条
- Python 微信机器人:属于自己的微信机器人制作,简单易懂。图灵机器人接口api调用。
- 最全总结:把模块当做脚本来执行的 7 种案例及其原理
- 经典八种排序算法总结(带动画演示)
- bokeh作图过程报错解决方法兼Pycharm如何升级安装包的方法
- 一、html 基础