做Android开发这几年线程池是我最常用也最常被问到的组件。Android中线程池的背后是Java的Executor框架但真正把它用好涉及参数调优、队列选型、任务取消和内存管理一堆细节。今天这篇文章不聊教科书定义而是把我在实际项目里踩过的坑、做过取舍的点和可以直接抄走的配置方案一次性说清楚。不少刚接触Android的开发者会先学会new Thread(...).start()然后在某个页面刷新列表、上传图片、解析大JSON时“开一个线程就完事”。表面看确实省事但等项目体量上来这种写法会让人很痛苦。文章适合想系统梳理线程池用法的人也适合正在排查自己项目里“线程莫名变多”“任务被丢弃”之类问题的朋友。1. 与其每次“现开线程”不如先搞懂线程池替我们扛了什么1.1 new Thread看起来省事代价却很高我见过很多代码里到处写着new Thread。每个地方看起来都很简单可一旦业务放量问题就出来了。首先是资源开销。Java线程最终要映射到操作系统线程创建线程需要申请内核资源线程退出还得做清理。Android设备的内存本身比PC紧张如果每个并发请求都临时建一个线程100个并发任务就是100个线程它们各自占栈空间再加上线程切换的上下文开销性能损耗是实打实的。其次是失控。谁创建了线程、谁该销毁线程、线程卡死在哪里散落在各个业务模块里根本没法统一观测和治理。有一次我们线上出现“线程数持续上涨但不下降”排查半天最后发现就是某个组件在用户反复进入页面时不断new Thread页面退出后线程还在空转等回调。这种问题在线程池模式下至少会有一个集中的线程名和生命周期管理入口可以顺着日志很快查到。1.2 池化思想的本质线程是“工人”任务是“订单”线程池的核心思想说起来很直白不重复招聘、辞退工人而是让一组固定工人在后厨盯着订单。把线程看作工人任务看作订单。线程池预先留几个核心工人订单来了直接分配。订单变多核心工人不够就先把多出来的订单放进等候区排队。排队也满了临时请几个零工来帮忙。零工空闲一阵子就辞退核心工人一直保留。如果订单量大到连零工都忙不过来餐厅只能执行某种“拒客策略”要么让下单的人自己等、自己动手要么干脆把订单丢掉。对应到Android场景线程复用减少了创建销毁的开销任务队列帮我们削峰填谷线程数量上限避免了无节制抢资源。这套机制解决的不只是性能问题更重要的是让“并发任务”这个变量变得可控、可预测。1.3 Android没有另起炉灶用的就是Java的Executor框架Android虽然是移动操作系统但在多线程这块没有发明一套新东西而是沿用了Java的并发框架。ThreadPoolExecutor、ExecutorService、FutureTask这些类在Android上都能直接用底层调度逻辑和标准Java一致。为什么还要单独聊Android里的线程池因为Android有一个UI主线程所有界面刷新必须在主线程完成。线程池里的后台线程跑完任务后需要把结果切回主线程或者通过回调暴露给UI层。再加上Android组件有明确的生命周期不能像后端服务那样“进程活着线程就一直在”。所以在Android里用线程池重点不是会不会调API而是怎么避免内存泄漏、怎么避免低端机被压垮、怎么和Activity/Fragment的销毁时机对齐。这些就是本文后面要展开的部分。2. ThreadPoolExecutor七个参数逐一说透才算入门ThreadPoolExecutor的完整构造方法包含七个核心参数很多人背过八股文但真到了配置的时候还是一脸懵。我建议先把这个构造方法本身写明白new ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 空闲线程存活时间 TimeUnit unit, // 存活时间单位 BlockingQueueRunnable workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 )这七个参数不是孤立的它们共同决定了一个线程池的“脾气”什么情况下扩容、什么情况下排队、什么情况下拒绝任务。2.1 corePoolSize与maximumPoolSize线程数量的两条水位线核心线程数corePoolSize是线程池平时维持的工人数量即使空闲也不会随便回收除非开启了allowCoreThreadTimeOut(true)。最大线程数maximumPoolSize是线程池在忙疯时最多能拥有的工人数量。这两条水位线之间有一段缓冲地带当核心线程都在忙新任务不会立刻创建新线程而是先丢进任务队列队列一旦填满线程池才会在核心线程数之上继续创建线程一直涨到maximumPoolSize为止。所以“队列长度”是个关键变量。很多人误以为corePoolSize4、maxPoolSize8就一定会创建8个线程实际上若队列很长线程数可能永远停在4。反过来如果队列是个容量为0的队列那任务进来就要求立刻有线程处理线程数会迅速冲到上限。设定核心线程数时我会先估算任务性质。CPU密集型任务合理值大约等于CPU核心数 1IO密集型任务比如网络请求、数据库读写线程可以多一些通常按CPU核心数 * 2起步具体再根据实际压测调整。CPU核心数可以通过Runtime.getRuntime().availableProcessors()拿到。2.2 三种阻塞队列的脾气队列选型直接影响线程池行为。三种常见队列各有个性。LinkedBlockingQueue如果不设置容量就是无界队列可以无限堆积任务。它最大的诱惑是“永远不会拒绝任务”但副作用是内存可能被持续堆积的任务吃光这在内存吃紧的Android上非常危险。ArrayBlockingQueue是固定容量的有界队列初始化必须给一个大小比如128、256。容量有限满了之后触发扩容或者拒绝是生产环境里我优先考虑的队列。SynchronousQueue比较特殊它队列容量为0不存储任务而是直接把任务交给线程处理。没有空闲线程就立刻新建线程。配合很大的maximumPoolSize使用时等于把线程数完全交给并发流量决定容易出现线程爆炸。还有一个容易被忽略的点:队列上的任务优先级。如果希望某些任务先执行可以用PriorityBlockingQueue但要注意进入该队列的任务要么实现Comparable接口要么在构造时传入Comparator。优先级队列会打乱提交顺序不符合所有业务场景。2.3 四种拒绝策略什么时候用哪个当队列满了线程数也到了maximumPoolSize再提交任务就会触发拒绝策略。JDK给了四种现成策略策略行为适用场景AbortPolicy默认直接抛出RejectedExecutionException必须要立刻暴露问题的场景DiscardPolicy悄悄丢弃任务无任何反馈允许丢任务且不需要感知的场景DiscardOldestPolicy丢掉队列中最老的任务再尝试提交更看重“新数据”的场景CallerRunsPolicy直接在提交者的线程里执行任务任务可以降级变慢、不能丢的场景我这里专门说下CallerRunsPolicy。它在拒绝时返回调用线程执行任务有点像“餐厅拒绝进店后让客人在门口排队等厨师忙完自己端菜”。好处是任务没有被丢弃坏处是如果提交者是UI主线程主线程会被这个任务拖住导致掉帧甚至ANR。所以用这个策略时提交方的线程身份必须想清楚。如果四种策略都不满足业务还可以实现RejectedExecutionHandler接口写自定义逻辑比如记录日志、上报指标、让任务稍后重试。2.4 ThreadFactory命名线程和标记线程是排查问题的第一步很多线上问题排查不下去就是因为线程全叫Thread-12、Thread-38一抓堆栈不知道是哪个业务模块。用ThreadFactory给线程起一个能看懂的名字是我强烈建议做的事。AtomicInteger threadId new AtomicInteger(0); ThreadFactory factory new ThreadFactory() { Override public Thread newThread(Runnable r) { Thread t new Thread(r, app-global-pool- threadId.getAndIncrement()); return t; } };别小看这一步。线程池一旦出问题线程名会直接出现在崩溃日志和抓取的ANR堆栈里。有了可读的线程名你能一眼判断是哪个线程池在作怪。后续做线程优先级统一、线程状态监控也都是从这里开始的。3. 在Android里配置一套真正能用的全局线程池3.1 Executors快捷方法看着省心用起来揪心Executors.newFixedThreadPool(4)、newCachedThreadPool()这类方法确实方便但它们默认的参数设计不完全适合Android。newFixedThreadPool背后用的是无界队列意味着任务可以无限堆积。低峰期看不出问题高峰期如果业务侧有Bug任务积压能把内存拖垮。newCachedThreadPool更激进核心线程数是0最大线程数是Integer.MAX_VALUE。它的意图是任务一到就创建线程空闲60秒就回收线程。可一旦提交任务的速度持续高于处理速度线程数会一路狂涨最终拖垮整个进程。我并不是说这些快捷方法绝对不能用但在Android这种资源受限的平台上我更愿意显式配置有界队列和合理上限让风险处于可控范围。3.2 一套可直接抄作业的线程池配置下面是我在多数业务App里使用的一套全局线程池配置模板你可以直接粘过去再按业务微调。public class AppThreadPool { private static final int CPU_COUNT Runtime.getRuntime().availableProcessors(); // 核心线程数CPU核心数1保证CPU密集型任务能跑满 private static final int CORE_POOL_SIZE Math.max(2, CPU_COUNT 1); // 最大线程数留一部分余量给IO等待型任务 private static final int MAX_POOL_SIZE Math.max(4, CPU_COUNT * 2 1); private static final int KEEP_ALIVE_SECONDS 30; private static final int QUEUE_CAPACITY 256; private static final AtomicInteger THREAD_ID new AtomicInteger(0); private static final ThreadFactory THREAD_FACTORY new ThreadFactory() { Override public Thread newThread(Runnable r) { Thread t new Thread(r, app-task- THREAD_ID.getAndIncrement()); // 新线程统一设为后台优先级见第4章 t.setPriority(Thread.NORM_PRIORITY); return t; } }; public static final ExecutorService GLOBAL new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, new ArrayBlockingQueue(QUEUE_CAPACITY), THREAD_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy() ); private AppThreadPool() { } }这里的几个选择都有原因。ArrayBlockingQueue容量设256防止任务无限堆积拒绝策略用CallerRunsPolicy保证任务不丢但我也在4.2里特别说明它可能引发主线程执行的坑。如果你的任务允许丢可以换成自记录的Discard策略。AppThreadPool.GLOBAL建议做成进程级单例。最忌讳的是每个页面或者每个业务模块自己建一个线程池那样线程池数量一多线程总数照样失控池化也失去了意义。3.3 线程池的关闭与任务追踪“正在跑的任务”比线程数量更重要很多人以为关闭线程池就是调一下shutdown或者shutdownNow。这两个方法的差别值得注意。shutdown()会让线程池停止接收新任务但已提交且排队中的任务会继续执行。shutdownNow()除了停止接收新任务还会尝试中断正在执行的任务并返回队列中未执行的任务列表。自行实现InterruptedException时Future.cancel(true)能尝试中断但中断只是给线程发一个信号真正能不能停下来取决于任务内部的耗时逻辑是否响应中断。比如一个循环里没有检查Thread.currentThread().isInterrupted()中断后任务还会继续跑。所以我认为可靠的“关闭”不只是调API而是要在业务层做好任务放行控制。比如页面销毁时用一个volatile boolean cancelled标志位任务执行前先检查这个标志一旦取消就立即返回不要继续更新回调。否则即便线程池没有关闭后台任务也会拿着一个已经销毁的页面引用去做无用功。4. Android特有场景下线程池的四个典型坑4.1 线程池里拿着Activity/Context不放销毁后依然执行线程池本身是长生命周期的任务里如果直接持有Activity页面在任务执行完之前被销毁Activity就无法被回收这就是内存泄漏。这个问题常见的错误写法是后台加载完图片后直接调用imageView.setImageBitmap(...)。哪怕imageView是局部变量它内部的Context引用链也会顺着任务对象活到任务结束。我现在的做法很简单回调统一用弱引用包装或者尽量不让任务持有UI控件引用。在任务执行完成后需要切主线程更新UI时只用ApplicationContext去发消息UI层在订阅方自行判断是否还存活。配合Lifecycle组件的观察者机制页面销毁时把任务标志位置为取消双重保险。这样线程池的生命周期和页面生命周期彻底解耦页面销毁不会拖住线程池线程池执行完也不会回头操作死页面。4.2 RejectedExecutionException队列打满后的雪崩有界队列加最大线程上限意味着总容量是有极限的。如果短时间内提交的任务远超处理能力默认的AbortPolicy会抛RejectedExecutionException。这个异常如果是在主线程提交任务时抛出直接就是一次崩溃。我曾经在一个活动页踩过这个坑用户快速下拉刷新每次都提交一批联网任务上一批还没跑完下一批又进来队列瞬间打满。之后线上崩溃日志里反复出现RejectedExecutionException。后来我们的处理方式是自定义拒绝策略拒绝时先把异常和线程池状态打到日志里再根据返回值决定是重试还是丢弃。如果你希望在拒绝时不丢失任务使用CallerRunsPolicy但要评估提交方线程。若任务的提交入口在主线程一旦进入拒绝路径主线程会去执行耗时任务用户能明显感受到卡顿。更好的做法是业务侧在提交前做“流量控制”例如限制同时进行的任务数或者对重复请求做合并而不是把希望全押在拒绝策略上。4.3 线程优先级被忽略后台任务照样抢主线程Android的主线程承担按键事件、绘制、动画等关键工作。线程池新建的后台线程默认优先级和普通线程差不多在任务密集时容易与主线程争抢CPU资源。这里有两个层面的设置。第一层是Java层的Thread.setPriority()它影响Java虚拟机中的调度权重。第二层是Android系统里的Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)这是Android特有的线程优先级配置对系统的调度影响更直接。我的习惯是在每个任务入口处都写上Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);让后台任务主动把自己放到后台调度组尽可能避免干扰主线程。尤其是在图片处理、JSON解析这类CPU密集型任务里这个设置的价值很明显任务还在跑但界面滑动不会像以前那样一顿一顿。4.4 任务可以无限堆积无界队列是Android上最温柔的OOM后台线程加上无界队列在Android上就是个慢性的内存杀手。单个任务对象也许只有几十字节可当任务被几十万条地堆积时任务对象本身、闭包捕获的外部变量、任务执行后要写入的集合或文件句柄都会一直留在内存里。我曾经接手过一个老模块里面用了newFixedThreadPool所有任务都往里丢。表面看线程数很稳定内存却像漏了一样涨。一查原来某个任务在异常分支里把自己又提交到同一个队列形成了无限循环堆积。那其实不是彻底的内存泄漏而是“队列积压型内存占用”但后果和泄漏一样严重。所以Android上我几乎不用无界队列。队列容量必须设上限拒绝策略必须明确。你宁可丢一个次要任务也不能让整个App因为内存暴涨被系统回收。5. 真实项目里线程池的选型与落地经验5.1 先给任务分类再决定线程池形态不少开发者是“一个线程池走天下”这也不是不行但要清楚代价。我会先按任务特征分类再决定是复用全局池还是单独建池。任务类型典型例子建议CPU密集型图片压缩、大JSON解析核心线程数≈CPU数1队列不宜太长IO密集型网络请求、数据库读写、文件操作核心线程数可设大一些队列给一定缓冲短时高频心跳上报、打点上报核心线程数和最大线程数都不宜太大防止浪费低频长任务启动时初始化、预加载单独串行队列或定时线程池避免互相阻塞比如启动预加载这类任务我通常不会丢进全局池因为其他高优先级任务可能把它排在后面。我会单独建一个容量很小的池专门处理启动阶段的初始化等预加载任务完成后就停掉。反过来像打点上报这种“丢了可惜但不至于致命”的任务我宁可放在一个独立池里使用有界队列满了直接丢弃绝不让它干扰业务核心线程。5.2 和HandlerThread、WorkManager的分工不是所有异步任务都必须用线程池。Android本身有更适合特定场景的组件。HandlerThread本质是一个自带Looper的单线程适合串行执行任务比如按顺序处理待上传队列里的文件。这时候如果硬用线程池反而要额外处理并发导致的顺序问题。WorkManager适合需要保证“任务最终会执行”的场景例如数据同步、本地通知定时触发。它可以利用系统的调度窗口结合省电策略在合适时机运行任务比自己在后台开线程池更省电、更稳定。写业务代码时我会先问自己任务是否需要立即执行是否能容忍延迟执行结果是否需要时刻更新UI如果答案适合交给系统统一调度就不要自己去造一个后台线程池的轮子。线程池真正擅长的是那些你明确知道要在当前App进程里立刻处理并且想自己掌控并发度的任务。5.3 线上线程池监控和问题定位线程池不是配好就万事大吉线上还是要能监控。ThreadPoolExecutor自身提供了一些统计方法getPoolSize()看当前线程数getActiveCount()看活动线程数getQueue().size()看积压任务数getTaskCount()看累计提交任务数getCompletedTaskCount()看已完成任务数。我常用的方法是写一个简单的采样任务定时把这些指标打到日志里private static String poolStatus(ThreadPoolExecutor executor) { return poolSize executor.getPoolSize() , active executor.getActiveCount() , queued executor.getQueue().size() , completed executor.getCompletedTaskCount(); }如果发现queued持续高位说明任务提交速度远大于处理速度优先去看业务层是不是有重复提交、死循环提交。如果发现poolSize长期等于maximumPoolSize说明线程池已经被打满需要考虑扩容还是加流量控制。另一个很有效的排查手段是监控任务执行的耗时。对每个提交的任务包一层统计实际执行时间设置阈值。超过阈值就单独上报并打印当时的线程池状态。很多“看不到、改不动”的隐性性能问题都是靠这种监控暴露出来的。最后分享一个小经验给线程池里的线程命名、设置后台优先级、统一队列容量这些事越早做越好。老项目里线程满天飞的时候统一线程池和离线采集线程池状态往往比对单个业务做性能优化见效更快。线程池本身不是银弹但一份清晰可控的并发模型确实能让Android项目省掉很多夜里的告警电话。