- 一、前言
- 二、OOM问题分类
- 三、线程数太多
- 3.1 报错信息
- 3.2 源码分析
- 线程优化
一、前言
随着项目不断壮大,OOM (Out Of Memory)成为崩溃统计平台上的疑难杂症之一,大部分业务开发人员对于线上OOM问题一般都是暂不处理,一方面是因为OOM问题没有足够的log,无法在短期内分析解决,另一方面可能是忙于业务迭代、身心疲惫,没有精力去研究OOM的解决方案。
这篇文章将以线上OOM问题作为切入点,介绍常见的OOM类型、OOM的原理、大厂OOM优化黑科技、以及主流的OOM监控方案。
文章较长,请备好小板凳~
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能。
二、OOM问题分类
很多人对于OOM的理解就是Java虚拟机内存不足,但通过线上OOM问题分析,OOM可以大致归为以下3类:
- 线程数太多
- 打开太多文件
- 内存不足
接下来将分别围绕这三类问题进行展开分析~
基于微服务的思想,构建在 B2C 电商场景下的项目实战。核心技术栈,是 Spring Boot + Dubbo 。未来,会重构成 Spring Cloud Alibaba 。
三、线程数太多
3.1 报错信息
pthread_create (1040KB stack) failed: Out of memory
这个是典型的创建新线程触发的OOM问题
3.2 源码分析
pthread_create触发的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(...) //创建线程成功 if (pthread_create_result == 0) { return; } //创建线程失败 ... { 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
里面会调用Linux内核创建线程,那什么情况下会创建线程失败呢?
查看系统对每个进程的线程数限制
cat /proc/sys/kernel/threads-max
线程数限制
不同设备的threads-max限制是不一样的,有些厂商的低端机型threads-max比较小,容易出现此类OOM问题。
查看当前进程运行的线程数
cat proc/{pid}/status
当线程数超过/proc/sys/kernel/threads-max
中规定的上限时就会触发OOM。
既然系统对每个进程的线程数有限制,那么解决这个问题的关键就是尽可能降低线程数的峰值。
线程优化
回看两年前我写过一篇文章《面试官:今日头条启动很快,你觉得可能是做了哪些优化?》,虽然里面的内容有些已经过时,不过分析问题的思路还是可以借鉴的,记得当时对于线程优化只是一句话描述,今天这篇文章刚好可以做一个补充。
3.3.1 禁用 new Thread
解决线程过多问题,传统的方案是禁止使用new Thread
,统一使用线程池,但是一般很难人为控制, 可以在代码提交之后触发自动检测,有问题则通过邮件通知对应开发人员。
不过这种方式存在两个问题:
- 无法解决老代码的
new Thread
; - 对于第三方库无法控制。
3.3.2 无侵入性的new Thread 优化
Java层的Thread
只是一个普通的对象,只有调用了start
方法,才会调用native 层去创建线程,
所以理论上我们可以自定义Thread,重写start方法,不去启动线程,而是将任务放到线程池中去执行,为了做到无侵入性,需要在编译期通过字节码插桩的方式,将所有new Thread
字节码都替换成new 自定义Thread
。
对于字节码操作,在上一篇文章《ASM hook隐私方法调用,防止App被下架》已经详细介绍,本文不再过多解释。
步骤如下:
1、创建一个Thread
的子类叫ShadowThread
吧,重写start方法,调用自定义的线程池CustomThreadPool
来执行任务;
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
字节码,全部替换成我们自定义的ShadowThread
,这个难度应该不大,按部就班,
我们先确认new Thread
和new ShadowThread
对应字节码差异,可以安装一个ASM Bytecode Viewer插件,如下所示
通过字节码修改,你可以简单理解为做如下替换:
3、由于将任务放到线程池去执行,假如线程奔溃了,我们不知道是哪个线程出问题,所以自定义ShadowThread
中的内部类MyRunnable
的作用是:在线程出现异常的时候,将异常捕获,还原它的名字,重新抛出一个信息更全的异常。
测试代码
private fun testThreadCrash() { Thread { val i = 9 / 0 }.apply { name = "testThreadCrash" }.start() }
开启一个线程,然后触发奔溃,堆栈信息如下:
可以看到原本的new Thread
已经被优化成了CustomThreadPool
线程池调用,并且奔溃的时候不用担心找不到线程是哪里创建的,会还原线程名。
当然这种方式有一个小问题,应用正常运行的情况下,如果你想要收集所有线程信息,那么线程名可能不太准确,因为通过new Thread 去创建线程,已经被替换成线程池调用了,获取到的线程名是线程池中的线程的名字
数据对比
同个场景简单测试了一下new Thread
优化前后线程数峰值对比:
线程数峰值(优化前) | 线程数峰值(优化后) | 降低最大线程数 |
337 | 314 | 23 |
对于不同App,优化效果会有一些不同,不过可以看到这个优化确实是有效的。
3.3.3 无侵入的线程池优化
随着项目引入的SDK越来越多,绝大部分SDK内部都会使用自己的线程池做异步操作,
线程池的参数如果设置不对,核心线程空闲的时候没有释放,会使整体的线程数量处于较高位置。
线程池几个参数:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory) { this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, defaultHandler); }
- corePoolSize :核心线程数量。核心线程默认情况下即使空闲也不会释放,除非设置
allowCoreThreadTimeOut
为true。 - maximumPoolSize :最大线程数量。任务数量超过核心线程数,就会将任务放到队列中,队列满了,就会启动非核心线程执行任务,线程数超过这个限制就会走拒绝策略;
- keepAliveTime :空闲线程存活时间
- unit:时间单位
- workQueue:队列。任务数量超过核心线程数,就会将任务放到这个队列中,直到队列满,就开启新线程,执行队列第一个任务。
- threadFactory:线程工厂。实现new Thread方法创建线程
通过线程池参数,我们可以找到优化点如下:
- 限制空闲线程存活时间,
keepAliveTime
设置小一点,例如1-3s; - 允许核心线程在空闲时自动销毁
executor.allowCoreThreadTimeOut(true)
如何做呢?为了做到无侵入性,依然采用ASM操作字节码,跟new Thread
的替换基本同理
在编译期,通过ASM,做如下几个操作:
- 将调用
Executors
类的静态方法替换为自定义ShadowExecutors
的静态方法,设置executor.allowCoreThreadTimeOut(true)
; - 将调用
ThreadPoolExecutor
类的构造方法替换为自定义ShadowThreadPoolExecutor
的静态方法,设置executor.allowCoreThreadTimeOut(true)
; - 可以在 Application 类的() 中调用我们自定义的静态方法
ShadowAsyncTask.optimizeAsyncTaskExecutor()
来修改 AsyncTask 的线程池参数,调用executor.allowCoreThreadTimeOut(true)
;
你可以简单理解为做如下替换:
详细代码可以参考 booster。
3.4 线程监控
假如线程优化后还存在创建线程OOM问题,那我们就需要监控是否存在线程泄漏的情况。
3.4.1 线程泄漏监控
主要监控native线程的几个生命周期方法:pthread_create、pthread_detach、pthread_join、pthread_exit
。
- hook 以上几个方法,用于记录线程的生命周期和堆栈,名称等信息;
- 当发现一个joinable的线程在没有detach或者join的情况下,执行了pthread_exit,则记录下泄露线程信息;
- 在合适的时机,上报线程泄露信息。
linux线程中,pthread有两种状态joinable状态 和unjoinable状态 。joinable 状态下,当线程函数自己返回退出时或pthread_exit时 都不会释放线程所占用堆栈和线程描述符。只有当你调用了pthread_join之后 这些资源才会被释放,需要main函数或者其他线程去调用pthread_join函数。
具体代码可以参考:KOOM-thread_holder
3.4.2 线程上报
当监控到线程有异常的时候,我们可以收集线程信息,上报到后台进行分析。
收集线程信息代码如下:
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问题