大厂的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 Thread和new 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问题

相关文章
|
Android开发
mac下配置adb环境变量
在终端中输入adb命令时,会提示 command not found ,这是是因为mac电脑下没有配置Android环境变量或者环境变量配置错误。
|
9月前
|
监控 Java 测试技术
OOM排查之路:一次曲折的线上故障复盘
本文记录了一次Paimon数据湖与RocksDB集成服务线上频繁OOM的排查历程。通过分析线程暴增、堆外内存泄漏,最终定位到SDK中RocksDB的JNI内存未释放问题,并借助Flink重构写入链路彻底解决。分享了MAT、NMT、async-profiler等工具的实战经验与排查思路,为类似技术栈提供借鉴。
OOM排查之路:一次曲折的线上故障复盘
|
监控 Java Linux
大厂的OOM优化和监控方案(二)
大厂的OOM优化和监控方案(二)
大厂的OOM优化和监控方案(二)
|
缓存 算法 测试技术
|
存储 缓存 关系型数据库
图解MySQL【日志】——Redo Log
Redo Log(重做日志)是数据库中用于记录数据页修改的物理日志,确保事务的持久性和一致性。其主要作用包括崩溃恢复、提高性能和保证事务一致性。Redo Log 通过先写日志的方式,在内存中缓存修改操作,并在适当时候刷入磁盘,减少随机写入带来的性能损耗。WAL(Write-Ahead Logging)技术的核心思想是先将修改操作记录到日志文件中,再择机写入磁盘,从而实现高效且安全的数据持久化。Redo Log 的持久化过程涉及 Redo Log Buffer 和不同刷盘时机的控制参数(如 `innodb_flush_log_at_trx_commit`),以平衡性能与数据安全性。
1098 5
图解MySQL【日志】——Redo Log
|
JSON 算法 安全
harmony-utils之RSA,RSA加解密
harmony-utils是一款专为HarmonyOS打造的高效工具库,提供丰富的实用功能,助力开发者快速构建鸿蒙应用。其RSA模块实现非对称加解密、签名验签等功能,支持密钥生成、数据加密解密及分段处理,适用于安全通信与身份验证等场景。
558 0
|
数据可视化 JavaScript 前端开发
代码可视化平台
这是一个代码可视化工具,旨在简化代码理解过程。用户无需额外配置,直接复制代码即可实时观看执行过程,支持前进后退和动画展示。目前支持JavaScript和Python,未来将扩展更多语言。工具提供了数组、链表、栈、队列、二叉树和哈希表的可视化,并包含辅助函数和自定义注释功能。主要局限在于仅支持单段代码,且执行步数限制为500步。[了解更多](https://staying.fun/zh)
1134 20
|
存储 缓存 运维
缓存技术有哪些优缺点呢
【10月更文挑战第19天】缓存技术有哪些优缺点呢
美团面试:Redis锁如何续期?Redis锁超时,任务没完怎么办?
在40岁老架构师尼恩的读者交流群中,近期有小伙伴在面试一线互联网企业时遇到了关于Redis分布式锁过期及自动续期的问题。尼恩对此进行了系统化的梳理,介绍了两种核心解决方案:一是通过增加版本号实现乐观锁,二是利用watch dog自动续期机制。后者通过后台线程定期检查锁的状态并在必要时延长锁的过期时间,确保锁不会因超时而意外释放。尼恩还分享了详细的代码实现和原理分析,帮助读者深入理解并掌握这些技术点,以便在面试中自信应对相关问题。更多技术细节和面试准备资料可在尼恩的技术文章和《尼恩Java面试宝典》中获取。
美团面试:Redis锁如何续期?Redis锁超时,任务没完怎么办?
|
NoSQL 安全 Linux
Redis 从入门到精通之内存和CPU配置优化
Redis 是一种基于内存的数据存储系统,因此内存的规划是非常重要的。在配置 Redis 内存时,应该避免物理内存使用过量导致大量使用 Swap,同时需要考虑内存碎片的问题。根据多年的经验整理了一些建议
1646 112