大厂的OOM优化和监控方案(一)

简介: 大厂的OOM优化和监控方案(一)
  • 一、前言
  • 二、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 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能。

项目地址:https://github.com/YunaiV/ruoyi-vue-pro

二、OOM问题分类

很多人对于OOM的理解就是Java虚拟机内存不足,但通过线上OOM问题分析,OOM可以大致归为以下3类:

  1. 线程数太多
  2. 打开太多文件
  3. 内存不足

接下来将分别围绕这三类问题进行展开分析~

基于微服务的思想,构建在 B2C 电商场景下的项目实战。核心技术栈,是 Spring Boot + Dubbo 。未来,会重构成 Spring Cloud Alibaba 。

项目地址:https://github.com/YunaiV/onemall

三、线程数太多

3.1 报错信息

pthread_create (1040KB stack) failed: Out of memory

这个是典型的创建新线程触发的OOM问题

微信图片_20220906140159.jpg

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

微信图片_20220906140226.jpg

线程数限制

不同设备的threads-max限制是不一样的,有些厂商的低端机型threads-max比较小,容易出现此类OOM问题。

查看当前进程运行的线程数

cat proc/{pid}/status

微信图片_20220906140302.jpg

当线程数超过/proc/sys/kernel/threads-max中规定的上限时就会触发OOM。

既然系统对每个进程的线程数有限制,那么解决这个问题的关键就是尽可能降低线程数的峰值。

线程优化

回看两年前我写过一篇文章《面试官:今日头条启动很快,你觉得可能是做了哪些优化?》,虽然里面的内容有些已经过时,不过分析问题的思路还是可以借鉴的,记得当时对于线程优化只是一句话描述,今天这篇文章刚好可以做一个补充。

3.3.1 禁用 new Thread

解决线程过多问题,传统的方案是禁止使用new Thread,统一使用线程池,但是一般很难人为控制, 可以在代码提交之后触发自动检测,有问题则通过邮件通知对应开发人员。

不过这种方式存在两个问题:

  1. 无法解决老代码的new Thread
  2. 对于第三方库无法控制。

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 Threadnew ShadowThread对应字节码差异,可以安装一个ASM Bytecode Viewer插件,如下所示

微信图片_20220906140334.jpg

通过字节码修改,你可以简单理解为做如下替换:

微信图片_20220906140338.jpg

3、由于将任务放到线程池去执行,假如线程奔溃了,我们不知道是哪个线程出问题,所以自定义ShadowThread中的内部类MyRunnable 的作用是:在线程出现异常的时候,将异常捕获,还原它的名字,重新抛出一个信息更全的异常。

测试代码

private fun testThreadCrash() {
        Thread {
            val i = 9 / 0
        }.apply {
            name = "testThreadCrash"
        }.start()
    }

开启一个线程,然后触发奔溃,堆栈信息如下:

微信图片_20220906140411.jpg

可以看到原本的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);
    }
  1. corePoolSize :核心线程数量。核心线程默认情况下即使空闲也不会释放,除非设置allowCoreThreadTimeOut为true。
  2. maximumPoolSize :最大线程数量。任务数量超过核心线程数,就会将任务放到队列中,队列满了,就会启动非核心线程执行任务,线程数超过这个限制就会走拒绝策略;
  3. keepAliveTime :空闲线程存活时间
  4. unit:时间单位
  5. workQueue:队列。任务数量超过核心线程数,就会将任务放到这个队列中,直到队列满,就开启新线程,执行队列第一个任务。
  6. threadFactory:线程工厂。实现new Thread方法创建线程
通过线程池参数,我们可以找到优化点如下:
  1. 限制空闲线程存活时间,keepAliveTime 设置小一点,例如1-3s;
  2. 允许核心线程在空闲时自动销毁
executor.allowCoreThreadTimeOut(true)

如何做呢?为了做到无侵入性,依然采用ASM操作字节码,跟new Thread的替换基本同理

在编译期,通过ASM,做如下几个操作:
  1. 将调用 Executors 类的静态方法替换为自定义 ShadowExecutors 的静态方法,设置executor.allowCoreThreadTimeOut(true)
  2. 将调用 ThreadPoolExecutor 类的构造方法替换为自定义 ShadowThreadPoolExecutor 的静态方法,设置executor.allowCoreThreadTimeOut(true)
  3. 可以在 Application 类的() 中调用我们自定义的静态方法 ShadowAsyncTask.optimizeAsyncTaskExecutor() 来修改 AsyncTask 的线程池参数,调用executor.allowCoreThreadTimeOut(true)

你可以简单理解为做如下替换:

微信图片_20220906140506.jpg

详细代码可以参考 booster。

3.4 线程监控

假如线程优化后还存在创建线程OOM问题,那我们就需要监控是否存在线程泄漏的情况。

3.4.1 线程泄漏监控

主要监控native线程的几个生命周期方法:pthread_create、pthread_detach、pthread_join、pthread_exit

  1. hook 以上几个方法,用于记录线程的生命周期和堆栈,名称等信息;
  2. 当发现一个joinable的线程在没有detach或者join的情况下,执行了pthread_exit,则记录下泄露线程信息;
  3. 在合适的时机,上报线程泄露信息。

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问题

相关文章
|
运维 监控 Java
内存溢出+CPU占用过高:问题排查+解决方案+复盘(超详细分析教程)
全网最全的内存溢出CPU占用过高排查文章,包含:问题出现现象+临时解决方案+复现问题+定位问题发生原因+优化代码+优化后进行压测,上线+复盘
2317 5
|
监控 Java
【JVM线上调优】
【JVM线上调优】
|
6月前
|
监控 数据可视化 Java
jvm性能调优实战 - 31从测试到上线_如何分析JVM运行状况及合理优化
jvm性能调优实战 - 31从测试到上线_如何分析JVM运行状况及合理优化
95 1
|
1月前
|
监控 Java API
如何通过监控工具来诊断G1垃圾回收器的性能问题
如何通过监控工具来诊断G1垃圾回收器的性能问题
|
3月前
|
监控 Java API
如何通过监控工具来诊断G1垃圾回收器的性能问题?
如何通过监控工具来诊断G1垃圾回收器的性能问题?
|
6月前
|
SQL 运维 NoSQL
【Redis 故障排查】「连接失败问题排查和解决」带你总体分析CPU及内存的使用率高问题排查指南及方案
【Redis 故障排查】「连接失败问题排查和解决」带你总体分析CPU及内存的使用率高问题排查指南及方案
208 0
|
监控 Java Linux
大厂的OOM优化和监控方案(二)
大厂的OOM优化和监控方案(二)
大厂的OOM优化和监控方案(二)
|
SQL 缓存 监控
监控指标解读和JVM 分析&调优
监控指标解读和JVM 分析&调优
监控指标解读和JVM 分析&调优
|
存储 运维 Java
【JVM性能优化】服务发生OOM故障定位方案
【JVM性能优化】服务发生OOM故障定位方案
316 0
【JVM性能优化】服务发生OOM故障定位方案
|
存储 JSON 监控
大厂的OOM优化和监控方案(三)
大厂的OOM优化和监控方案(三)
大厂的OOM优化和监控方案(三)
下一篇
无影云桌面