Thread也会OOM吗?

4,851 阅读7分钟

OOM其实是一个比较常见的异常了,但是不知道各位老哥有没有见过这个异常。

java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
	at java.lang.Thread.nativeCreate(Thread.java)
	at java.lang.Thread.start(Thread.java:1076)
	at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:920)
	at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1338)
...

由于国内手机厂商的奇奇怪怪的优化,特别是华为,其对于线程的构建有特别严苛的要求,当进程内总线程数量达到一定的量级的情况下就会发生线程OOM问题。

这个问题其实有人专门做过分析,我这个人还是不喜欢直接复制别人的文章,但是读书人吗借书怎么能叫偷呢。不可思议的OOM

在Android7.0及以上的华为手机(EmotionUI_5.0及以上)的手机产生OOM,这些手机的线程数限制都很小(应该是华为rom特意修改的limits),每个进程只允许最大同时开500个线程,因此很容易复现了。

  for (i in 0 until 3000) {
            Thread {
                while (true) {
                    Thread.sleep(1000)
                }
            }.start()
        }

这个是作者做的一个实验,当华为手机的线程创建超过500的时候就会发生崩溃的问题了。但是我自己写了个demo,发现也不是所有的华为手机都这样,我用NOVA7测试出来的结果大概是3000个线程才会出现崩溃的问题。

线上真的会有超过500个线程的情况出现吗?

如何查看当前线程数量?

Android Profiler 工具非常强大,里面就有当前进程启动的线程数量,以及其cpu调度情况的。

图上可以看出来THREADS 后面的就是当前的线程使用数量。一个只含有少量代码的安卓项目执行的时候其实也有大概30条左右的线程存在,而OKHttp,Glide,第三方框架,Socket以及启动任务栈等等第三方框架接入后,线程数量更是会出现一个井喷式增长。

线上问题原因分析?

我观察了下我们的项目的线程使用情况,发现当项目完成简单的初始化之后就会构建出大概300条左右的线程,其实还是比较感人的。而线上的使用情况很复杂,而且报错日志上的错误并不是oom的真实原因,而是压死骆驼的最后一根稻草。

我其实在上家公司的时候就发生过这个问题,当时我们跟踪源代码,发现在使用rxjava的Schedulers.io()导致的这个问题。

  static final class CachedWorkerPool implements Runnable {
        private final long keepAliveTime;
        private final ConcurrentLinkedQueue<ThreadWorker> expiringWorkerQueue;
        final CompositeDisposable allWorkers;
        private final ScheduledExecutorService evictorService;
        private final Future<?> evictorTask;
        private final ThreadFactory threadFactory;

        CachedWorkerPool(long keepAliveTime, TimeUnit unit, ThreadFactory threadFactory) {
            this.keepAliveTime = unit != null ? unit.toNanos(keepAliveTime) : 0L;
            this.expiringWorkerQueue = new ConcurrentLinkedQueue<ThreadWorker>();
            this.allWorkers = new CompositeDisposable();
            this.threadFactory = threadFactory;

            ScheduledExecutorService evictor = null;
            Future<?> task = null;
            if (unit != null) {
                evictor = Executors.newScheduledThreadPool(1, EVICTOR_THREAD_FACTORY);
                task = evictor.scheduleWithFixedDelay(this, this.keepAliveTime, this.keepAliveTime, TimeUnit.NANOSECONDS);
            }
            evictorService = evictor;
            evictorTask = task;
        }

        @Override
        public void run() {
            evictExpiredWorkers();
        }

        ThreadWorker get() {
            if (allWorkers.isDisposed()) {
                return SHUTDOWN_THREAD_WORKER;
            }
            while (!expiringWorkerQueue.isEmpty()) {
                ThreadWorker threadWorker = expiringWorkerQueue.poll();
                if (threadWorker != null) {
                    return threadWorker;
                }
            }

            // No cached worker found, so create a new one.
            ThreadWorker w = new ThreadWorker(threadFactory);
            allWorkers.add(w);
            return w;
        }

        void release(ThreadWorker threadWorker) {
            // Refresh expire time before putting worker back in pool
            threadWorker.setExpirationTime(now() + keepAliveTime);

            expiringWorkerQueue.offer(threadWorker);
        }

        void evictExpiredWorkers() {
            if (!expiringWorkerQueue.isEmpty()) {
                long currentTimestamp = now();

                for (ThreadWorker threadWorker : expiringWorkerQueue) {
                    if (threadWorker.getExpirationTime() <= currentTimestamp) {
                        if (expiringWorkerQueue.remove(threadWorker)) {
                            allWorkers.remove(threadWorker);
                        }
                    } else {
                        // Queue is ordered with the worker that will expire first in the beginning, so when we
                        // find a non-expired worker we can stop evicting.
                        break;
                    }
                }
            }
        }

        long now() {
            return System.nanoTime();
        }

        void shutdown() {
            allWorkers.dispose();
            if (evictorTask != null) {
                evictorTask.cancel(true);
            }
            if (evictorService != null) {
                evictorService.shutdownNow();
            }
        }
    }
    
    public ScheduledThreadPoolExecutor(int corePoolSize,
                                       ThreadFactory threadFactory) {
        super(corePoolSize, Integer.MAX_VALUE,
              DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
              new DelayedWorkQueue(), threadFactory);
    }

从上述代码可以分析出,IO实现其实就是一个线程池,其核心数为1,最大线程数为Integer.MAX_VALUE,然后线程的销毁时间为60s。这个其实很多文章都有介绍的,也算是一个常规的改点,我们把这个线程池替换了之后的确是对项目线程OOM问题有所下降。

   RxJavaPlugins.setInitIoSchedulerHandler {
            val processors = Runtime.getRuntime().availableProcessors()
            val executor = ThreadPoolExecutor(processors * 2,
                    processors * 10, 1, TimeUnit.SECONDS, LinkedBlockingQueue<Runnable>(processors*10),
                    ThreadPoolExecutor.DiscardPolicy()
            )
            Schedulers.from(executor)
        }

小贴士 这边需要注意一定要在第一次调用rxjava之前执行RxJavaPlugins,否则代码会失效。

Kotlin的协程的IO线程实现机制上也是线程池。之前的文章介绍过,协程的内部的线程调度器的实现其实和rxjava的是一样的,都是一个线程池。我仔细观察了下DefaultScheduler.IO的实现。

open class ExperimentalCoroutineDispatcher(
    private val corePoolSize: Int,
    private val maxPoolSize: Int,
    private val idleWorkerKeepAliveNs: Long,
    private val schedulerName: String = "CoroutineScheduler"
) : ExecutorCoroutineDispatcher() {
    constructor(
        corePoolSize: Int = CORE_POOL_SIZE,
        maxPoolSize: Int = MAX_POOL_SIZE,
        schedulerName: String = DEFAULT_SCHEDULER_NAME
    ) : this(corePoolSize, maxPoolSize, IDLE_WORKER_KEEP_ALIVE_NS, schedulerName)
    
@JvmField
internal val IDLE_WORKER_KEEP_ALIVE_NS = TimeUnit.SECONDS.toNanos(
    systemProp("kotlinx.coroutines.scheduler.keep.alive.sec", 60L)
)

其中线程存活时间为60s,最大线程数则是根据系统配置获取的,我查阅了下stackoverflow发现了这个值的大小为64。那么协程的IO调用的其实也还好,并不会导致线程OOM问题。而且这个值其实也可以由开发去修正,也还是可以限制的。

接下来又可以表现真正的技术了

如果你以为我只有上面这么一点点水平,那么我肯定不会写这篇文章吹牛皮了。

以上只能解决当前项目上可以被修改的一些线程池相关的,那么有没有办法直接修改第三方的线程池构建呢????比如第三方聊天,阿里的一些库等等。

如果我们可以把当前项目内,除了OkHttp,Glide之类的,我们自己定一个一个大的蓄水池,然后把线程池的总数给定义死,之后我们去替换项目内的所有用到线程池的地方。

想想就有点小激动,先想想怎么做,再来决定方法论。

  1. 定义好不需要替换的白名单
  2. 遍历查找所有的类,寻找到线程池的构造函数。
  3. 把构造函数替换成我们的共享线程池。

又是transfrom,为什么老是我

首先我在原先的双击优化的demo上增加了一个小小的功能,就是上面我罗列的那些,通过类查找,然后替换的方式完成线程池构造的替换操作。


public class ThreadPoolMethodVisitor extends MethodVisitor {

   public ThreadPoolMethodVisitor(MethodVisitor mv) {
       super(Opcodes.ASM5, mv);
   }


   @Override
   public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
       boolean isThreadPool = isThreadPool(opcode, owner, name, desc);
       if (isThreadPool) {
           JLog.info("owner:" + owner + " name:" + name + " desc:" + desc);
           mv.visitInsn(Opcodes.POP);
           mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/wallstreetcn/sample/utils/TestIOThreadExecutor",
                   "getTHREAD_POOL_SHARE",
                   "()Lcom/wallstreetcn/sample/utils/TestIOThreadExecutor;", itf);
       } else {
           super.visitMethodInsn(opcode, owner, name, desc, itf);
       }
   }

   @Override
   public void visitInsn(int opcode) {
       super.visitInsn(opcode);
   }

   @Override
   public void visitLineNumber(int line, Label start) {
       super.visitLineNumber(line, start);
   }

   boolean isThreadPool(int opcode, String owner, String name, String desc) {
       List<PoolEntity> list = ThreadPoolCreator.INSTANCE.getPoolList();
       for (PoolEntity poolEntity : list) {
           if (opcode != poolEntity.getCode()) {
               continue;
           }
           if (!owner.equals(poolEntity.getOwner())) {
               continue;
           }
           if (!name.equals(poolEntity.getName())) {
               continue;
           }
           if (!desc.equals(poolEntity.getDesc())) {
               continue;
           }
           return true;
       }
       return false;
   }

}

上面是一个MethodVisitor,任意的一个方法块都会被这个类访问到,然后我们可以根据访问信息,以及方法名,类名等关键信息,对这个方法块进行修改。

我这里生成了一个列表,我会把所有关于线程池构造的实体都放到这个列表中,然后把当前的方法调用拿去其中匹配,当发现是一个线程池的构造函数的时候,我们就对代码进行修改插入,替换成我们的共享线程池。这样我们就能对在编译环节对线程池构造进行替换,约束项目的所有线程池的构建。

除了这个呢?

其实还能通过一部分静态扫描的形势去约束开发人员,你不允许直接new线程的方式去创建一个线程,这样也能对这部分OOM的治理,自己写个lint就行了。

补充下 lint 的demo我也写好了,各位有时间就看看,没时间也就算了https://github.com/Leifzhang/AndroidLint

总结

其实这个方案之前也想了一段时间了,最近要离职了,才抽出时间去写去优化,也算对asm加深了一些理解和使用吧。

其实我看猫眼的官博前几天已经发表过类似的文章了,但是我觉得其实还是有些别的可以改进的,虽然这个问题18年就有人写了,但是我觉得努努力还是可以突破下边界的。