资讯详情

资讯详情

Android线程池详解:从ThreadPoolExecutor参数到协程调度

1. 从一次卡顿说起为什么Android里离不开线程池我接手过一个已经上线的信息流项目用户反馈在弱网环境下刷新列表时界面掉帧特别明显。拉了代码一看崩溃倒是没有但每来一个请求代码里就new一个Thread去处理图片压缩、再new一个Thread去请求接口高峰期同时存在二十多个线程在抢CPU。结果就是主线程拿不到足够的调度时间滑动列表时卡得像PPT。这个案例不是个例很多从新手期走过来的项目都有这种“线程随用随扔”的习惯。后来我做的第一件事就是把所有零散线程替换成统一的线程池管理卡顿问题立刻缓解了一大半。线程池这个东西说高深一点就是一个“线程缓存 任务队列”的调度器说直白一点就是一张“服务员调度表”。它把我们创建的线程统一管理起来有任务过来优先用空闲线程没有空闲线程先排队等排队也排不下再触发兜底策略。它解决的问题很具体减少线程创建销毁的开销、限制并发线程数量、解耦任务提交和执行机制。这篇文章适合在Android项目里被线程问题折磨过的开发者也适合那些刚接触并发没多久、想知道线程池到底应该怎么配置的新手。我会把参数、配置、封装、踩坑和与协程的关系全部讲透。2. 四个参数加一个队列ThreadPoolExecutor的完整拼图2.1 corePoolSize和maximumPoolSize不是拍脑袋定的ThreadPoolExecutor最核心的构造参数是核心线程数corePoolSize、最大线程数maximumPoolSize、空闲存活时间keepAliveTime、工作队列workQueue和拒绝策略。很多人背得出参数名却不知道怎么取值最后照抄网上的代码线程池变成摆设。核心线程数和最大线程数之间的差决定了这个池子的“弹性空间”。核心线程数可以理解为正式工最大线程数是正式工加临时工的上限。平时任务少正式工处理就够了任务突然变多正式工忙不过来任务进队列排队队列也满了才会去把线程数从corePoolSize往上加到maximumPoolSize。这里有个关键逻辑线程数的增长不是任务一来就立刻扩到最大而是先填队列。这个顺序很多人搞反了后面会被坑。在Android上怎么取值我见过有人直接用Runtime.getRuntime().availableProcessors()乘2这是参照服务端CPU密集型的算法在手机端并不完全适用。手机上的任务大多数是IO型比如网络请求、数据库读写、图片压缩这些任务真正占用的不是CPU计算而是等待结果。CPU密集型任务的核心线程数可以设为CPU核数1IO密集型则可以设得更大比如CPU核数的2到4倍。但手机端还有一个隐形约束内存。线程本身要占栈空间默认的线程栈只有8KB吗不对Java线程默认栈在Android上通常是1MB左右虽然实际映射会按需分配但开太多线程依然会显著增加内存压力。所以我在手机项目里更倾向于保守核心线程数不要超过CPU核数最大线程数控制在CPU核数的2倍上下再依赖有界队列去缓冲突发流量。2.2 workQueue和拒绝策略排队还是拒绝工作队列的选择直接决定了线程池在压力下的表现。常用的有SynchronousQueue、LinkedBlockingQueue和ArrayBlockingQueue。SynchronousQueue不存任务来了就必须有线程马上接手所以它配合的线程池在线程数达到最大之前会疯狂创建新线程适合短平快的任务。LinkedBlockingQueue如果没设置容量就是无界队列任务永远往里塞最大线程数形同虚设。ArrayBlockingQueue是有界队列需要一个容量这个容量才是线程池真正承受压力的缓冲区。我建议在Android里使用有界队列容量根据任务属性来定。比如一个图片加载线程池队列容量设128甚至更小如果队列里积压了大量任务说明当前负载已经太高再堆积下去只会让内存和延迟双双恶化。有界队列必须搭配拒绝策略否则满的时候会直接抛RejectedExecutionException。拒绝策略有四种AbortPolicy默认策略直接抛异常适合及时暴露问题CallerRunsPolicy非常实用任务提交线程自己执行相当于变相限流DiscardPolicy默默丢弃不推荐会让业务“静默失败”DiscardOldestPolicy丢弃最老的任务适合允许丢旧任务换取新任务的场景。在业务正常的Android应用里我倾向用CallerRunsPolicy兜底至少不会让这个任务凭空消失。2.3 ThreadFactory和keepAliveTime容易被忽略的细节ThreadFactory是唯一一个看起来不起眼但直接影响排查效率的参数。如果你不自定义线程池里的线程名就是类似“pool-1-thread-1”这种线上日志里根本看不出是哪个模块发出的请求。我习惯在ThreadFactory里给线程名加上业务前缀比如image-load-thread-1、db-write-thread-1。一旦线程数异常飙高看日志就能立刻定位是哪个线程池在搞事。ThreadFactory里还可以设置是否daemon但Android里进程的生命周期和Java标准环境不一样设置daemon并不能让应用更安全地退出反而可能让人误解线程状态所以这个名字前缀和优先级设置才是更值得花心思的。keepAliveTime是指非核心线程空闲多久后被回收。这个很容易理解但有个坑如果设置了allowCoreThreadTimeOut(true)核心线程也会在空闲后被回收默认是false。在Android上我不建议让核心线程也超时回收因为频繁创建线程同样有开销。核心线程保持存活等下一次任务进来直接用这是线程池的意义所在。3. Executors快捷方法为什么危险3.1 看似方便的Executors到底封装了什么Java自带的Executors提供了很多快捷工厂方法Android早期的教科书和博客也特别喜欢推荐因为它们几行代码就能拿到一个线程池。但“方便”背后藏着两个知名隐患无界队列和无界线程数。Executors.newFixedThreadPool(4)用的是LinkedBlockingQueue队列是整型的最大值理论上是无界的。当任务提交速度超过了处理速度积压的任务会让内存慢慢被吃干净OOM不是不可能。Executors.newCachedThreadPool()走的是SynchronousQueue线程数是Integer.MAX_VALUE任务一多线程数也会跟着涨如果任务是长期存在的耗时操作线程数会一直膨胀最后把系统资源耗光。Executors.newSingleThreadExecutor()本质上也是无界队列好处是串行执行但同样有任务堆积风险。这里不是说Executors完全不能用而是它的默认参数并不是为移动端这种资源受限环境设计的。服务端可以用无界队列等待任务慢慢执行手机上内存紧张无界队列一出事就是OOM。而且Executors提供的线程工厂没有业务标识排查问题也比较费劲。3.2 自建ThreadPoolExecutor的正确配置方式自建线程池并不复杂关键是知道自己要什么。下面是我在Android项目里常用的一个基础模板核心思想是有界队列 自定义ThreadFactory CallerRuns拒绝策略。ThreadPoolExecutor executor new ThreadPoolExecutor( 2, // 核心线程数 4, // 最大线程数结合CPU核数和任务类型 60, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(128), // 有界队列 new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, business-executor- count.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() );有人会问为什么核心线程数只有2最大线程数才4因为在大多数App的常规业务里真正需要并行执行的任务并不多。你可以想一下自己的使用场景同时发起两个网络请求加一个数据库操作3个并发已经很高了。把线程数设置成一个大数字并不代表性能好反而意味着CPU为了让这些线程轮流执行会不断进行上下文切换每次切换都有开销。线程池的价值在于控制而不是无脑增加并发。4. Android项目里的线程池封装实践4.1 全局线程池长什么样在项目里我用一个类统一管理线程池而不是在Activity或Fragment里直接new。原因很简单线程池是全局资源应该属于应用进程而不是某个界面。如果每个界面都自己建一个页面销毁时如果忘了关闭线程就一直挂着内存泄漏就是这么积累出来的。我一般会写一个ThreadPoolManager单例内部持有几个不同用途的线程池。比如网络请求用NetworkExecutor数据库操作用DbExecutor文件处理用FileExecutor。每个线程池的配置可以独立调整这样某个池子出问题不会影响全局。对外暴露submit和execute方法业务方不需要关心底层参数。封装之后还有个附带好处可以统一做监控。在execute里给任务包一层包装类任务开始和结束时记录当前队列大小、线程数量、完成任务数并定期打日志。线程池的负载情况不再是黑盒性能排查会轻松很多。4.2 怎么处理任务优先级和线程名线程池默认是先进先出但有些业务需要让某些任务“插队”比如下拉刷新比后台预加载更紧迫。这时候可以给任务加上优先级然后让队列使用PriorityBlockingQueue。但要注意直接用PriorityBlockingQueue配合ThreadPoolExecutor有个坑一旦队列里的任务开始被取出执行新提交的更高优先级任务可能会插入到队列位置但已经在等待执行的线程是感知不到的因为PriorityBlockingQueue只对队列内排队元素排序。这种实现比较复杂如果你的场景大部分任务都没有优先级要求建议不要引入否则会引入更多莫名其妙的并发问题。线程名前面已经说过是排查问题的关键。我见过的很多线上日志里崩溃栈能看到“pool-2-thread-1”但你根本不知道pool-2是哪一类业务。自定义ThreadFactory之后日志里会出现“image-load-thread-3”这种名字再配合时间戳很快能还原现场。这是花两分钟设置就能省下几小时排查成本的动作。4.3 线程池和UI通信从Handler到LiveData线程池跑在子线程更新UI必须切回主线程这个基本规则谁都知道。实操中怎么切最不容易出错我早期用Handler.post后来用runOnUiThread再后来使用LiveData。其实三者都可以关键是生命周期管理。Handler如果持有Activity的Context消息没处理完就不能及时释放LiveData和ViewModel配合时能自动感知生命周期线程池执行完往LiveData里postValue界面在onDestroy后不会收到更新。有一个很容易被忽略的细节线程池执行的结果如果要回调给界面尽量不要在回调里直接强引用View。我习惯定义回调接口底层线程池只负责把结果传给回调由ViewModel或者Presenter层决定要不要更新UI。这样即使界面销毁了线程池里的任务还在跑也不会因为更新已销毁的View而闪退。5. 线上踩过的坑和排查思路5.1 任务堆积导致OOM有一个真实案例某个模块每隔几秒就提交一批日志上传任务线程池线程数4队列用的是无界LinkedBlockingQueue。某个版本网络日志服务异常每个任务都长时间不返回线程池里的任务越积越多最终内存被撑爆。崩溃栈显示在LinkedBlockingQueue的offer或节点创建处这也是无界队列OOM的典型特征。解决思路有两步第一步把无界队列改成有界队列并设置拒绝策略第二步增加监控队列积压超过阈值时降级处理比如放弃非关键日志。经过这个事故后我给自己定了一个规矩所有线程池的队列必须有容量上限除非你能证明这个池子的任务数量绝对可控否则无界队列就是埋雷。5.2 线程池没关闭导致Activity泄漏另一个常见问题是线程池和Activity生命周期绑定。比如某个页面里用了Executors.newFixedThreadPool来执行分页加载页面销毁时线程池一直没关即使任务执行完了线程池本身还活着。线程池持有任务RunnableRunnable可能持有Activity的引用这就形成了一条引用链Application - ThreadPoolManager - ThreadPoolExecutor - Thread - Runnable - Activity。只要线程池不关Activity永远无法被回收。解决这个问题的思路不是引导大家在每个页面都去关线程池而是全局复用线程池页面不持有它的强引用。任务里如果要传Context必须用ApplicationContext不能在Runnable里放Activity对象。如果确实需要临时线程池一定要在onDestroy时调用shutdown()并确认所有任务都已结束。5.3 并发下的重复执行和幂等线程池提交的任务如果因为网络超时被业务层重试同一份数据可能被多个线程同时处理造成重复记账、重复插入数据库等问题。这不是线程池本身的错而是使用者在并发环境下没有考虑幂等。我在项目里处理这个问题时会给每个任务分配一个唯一ID处理前先检查这个ID是否已经处理过比如保存到数据库或内存缓存里处理完成后标记完成。这个习惯在引入线程池以后变得更加重要因为并发任务的执行顺序不再可控同一个逻辑很可能被并发跑两遍。5.4 线程池内部相互等待导致死锁有一种很隐蔽的死锁线程池A的任务内部又往线程池A里提交了一个任务然后当前任务等待这个新任务的结果比如用Future的get()阻塞等待。当线程池线程数已经被当前任务占满时新任务永远没有机会执行当前任务又一直等所有线程卡死。我见过一个项目在图片加载链路里加了线程池缓存判断逻辑结果在同一个池子里嵌套提交任务线上接口全部超时最后抓线程栈才发现大量线程WAITING在FutureTask上。解决这类问题的原则很简单不要在同一个线程池的任务里继续向它提交任务并等待结果。如果确实需要异步嵌套应该使用不同的线程池或者干脆在任务内部创建子线程去执行。排查死锁问题时抓取线程dump看状态是WAITING还是BLOCKED然后看它的栈顶在等什么锁十有八九能找到现场。6. 协程时代还需要线程池吗6.1 协程调度器的底层也是线程池Kotlin协程出现以后很多开发者觉得线程池是不是要过时了。其实协程的Dispatchers.IO底层就是一个线程池Dispatchers.Default底层也是线程池只是它们在外面包了一层协程调度器。协程的价值不在于替代线程池而是把异步代码写得更像同步代码让我们不用再手动管理线程切换也不容易写出回调地狱。协程的挂起和恢复并不需要占用新线程一个IO调度器线程池里可以同时跑很多个挂起中的任务线程只是用来执行非挂起段的代码。这就是为什么协程看起来“轻量”因为挂起的协程不会阻塞线程。所以线程池没有消失只是被协程框架封装得更优雅了。6.2 什么场景仍用线程池什么场景直接协程我的建议是新项目优先用协程但依然要去理解线程池。协程让你不用关心“线程什么时候创建、什么时候回收”但当你需要限制某一类任务的并发数、自定义线程名、监控队列堆积时最终还是要和线程池打交道。协程的CoroutineScope配合自定义线程池可以实现精确控制比如Executor.asCoroutineDispatcher()把线程池转换成协程调度器。在老项目里如果你暂时不能大面积改造协程线程池依然是稳定可靠的方案。我在一个维护了三年的项目里网络层和数据库层都是线程池后来引入协程也只是在上层去做串联底层的线程池没有动。两者并不互斥理解线程池的运行机制能帮你在协程的调度器出现诡异问题时依然能看穿问题的本质。最后再分享一个我个人的习惯任何线程池上线前我都会自己写一个小脚本压一把把任务数量、线程数、队列容量、拒绝策略全部打出来看一遍。线程池参数不是一次定死而是要跟着业务形态调。当你发现自己花了几个小时在排查一个协程死锁问题时想起线程池的基础知识你会感谢当初花时间把它的原理啃透。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →