Android多线程与线程池:从主线程卡顿到实战性能优化

发布时间:2026/9/9 14:43:54
Android多线程与线程池:从主线程卡顿到实战性能优化 先说说我自己的经历。几年前我还在做一个电商类App首页商品列表每次滚动都能感到明显掉帧当时第一反应是图片加载库没做好缓存优化了一圈Bitmap回收和内存缓存问题依旧。后来抓了个全方法耗时才发现罪魁祸首是主线程里直接做了JSON解析和数据库查询——短则几十毫秒长则几百毫秒用户滑动时不断卡顿体感极差。也就是从那次起我认真把Android的多线程方案从头梳理了一遍尤其是线程池的选型和参数调优。这篇文就围绕Android多线程与线程池展开适合正在做性能优化、或者面试前想把线程池原理吃透的Android开发同学内容既有基础概念也有我在真实项目里踩过的坑和最终的解决方案。1. 从主线程卡顿说起Android多线程到底解决了什么问题1.1 主线程的任务边界为什么UI操作必须跑在UI线程Android的UI框架并不是线程安全的View的绘制、事件分发、属性动画这些操作都必须在主线程也叫UI线程完成。系统通过Looper和Handler构建了一个消息循环主线程不断从MessageQueue里取出消息并处理。一旦某个消息的处理时间超过了16ms就会错过下一帧的绘制表现就是掉帧如果超过5秒还没有处理完系统会直接弹ANRApplication Not Responding对话框。这里要强调的是很多人以为ANR只有在“完全没有响应”时才会触发实际上输入事件派发超时、广播处理超时、Service执行超时都会触发ANR。而根因高度集中主线程里做了耗时操作。网络请求、大文件读写、复杂JSON解析、数据库事务、Bitmap压缩这些通通不能直接丢在主线程里跑。记住一个判断标准任何可能超过16ms的操作都要考虑放到子线程。那是不是开个子线程就万事大吉了也不是。子线程里不能直接更新UI你必须通过Handler、runOnUiThread或者LiveData把结果切回主线程。这个“切回来”的机制是Android多线程和纯Java多线程最大的区别之一。1.2 从new Thread到线程池直接开线程的坑在哪里很多初学者会用这种方法new Thread(new Runnable() { Override public void run() { // 耗时操作 } }).start();这个写法在demo里没问题但放到真实项目里就会出问题。第一每执行一个任务就新建一个线程线程创建和销毁本身就有开销包括JVM分配栈空间、操作系统线程调度高频创建销毁对性能和内存都是负担。第二线程数量完全不可控如果某个页面上快速触发几十个任务瞬间就有几十个线程在跑CPU频繁切换上下文App可能比不用多线程还卡。第三线程没有统一管理缺少超时机制、缺少并发限制出问题后很难排查。线程池解决的就是这三个问题复用线程、控制数量、统一管理。它维护一组工作线程把任务放进队列由线程池调度执行。这样避免了反复创建销毁线程的开销也通过核心线程数、最大线程数、队列容量这些参数限定了并发规模。这也是为什么规范的项目里几乎看不到裸的new Thread基本都是用线程池或者基于线程池封装的任务调度框架。2. 线程池的七个参数与核心线程数决策链路2.1 七个参数逐个拆解Java的ThreadPoolExecutor构造函数带七个参数Android里用得最多的也是它。这七个参数是参数作用说明corePoolSize核心线程数即使空闲也保留的线程数量maximumPoolSize最大线程数线程池允许的最大线程数量keepAliveTime空闲存活时间非核心线程空闲多久后被回收unit时间单位与keepAliveTime配合使用workQueue任务队列核心线程满后新任务进入的阻塞队列threadFactory线程工厂创建新线程的工厂可用于命名handler拒绝策略线程数和队列都满时新任务的处理方式很多人背了参数名但不知道怎么配。核心逻辑在于一个决策链路任务来了先看核心线程是否占满没占满就创建新线程执行占满了就放进队列队列也满了才尝试创建非核心线程直到maximumPoolSize如果线程数已经到上限、队列也满就触发拒绝策略。这个链路是线程池工作原理的核心面试里也经常考察。关键点是线程池不是“先开满所有线程再排队”而是“优先复用核心线程核心线程不够了先排队队列满了才扩容”。我见过不少面试者把它讲成“线程满了再排队”顺序反了实际工作逻辑完全不同。2.2 核心线程数到底怎么定这是配置线程池时最先遇到的问题。网上有一种公式化说法CPU密集型任务设为核心线程数 CPU核数 1IO密集型任务设为核心线程数 CPU核数 * 2。这个说法有一定参考价值但实际Android项目里远比这复杂。Android设备CPU核心数差异很大低端机可能是4核旗舰机可能是8核还有大小核架构。跑在同一个线程池里的任务也不会是纯CPU计算或者纯IO等待往往是混合型。我在项目里的做法是分场景配置而不是追求一个万能值图片加载、网络请求这类IO密集型任务核心线程数可以设为Math.max(4, CPU核数 * 2)线程多了阻塞等待IO时可以切换。视频编解码、大Bitmap处理这类CPU密集型任务核心线程数设为CPU核数 1减少上下文切换。后台日志上报、数据预加载这类低优先级任务核心线程数2到4个就够避免和主流程抢CPU。还要注意一个参数allowCoreThreadTimeOut。默认情况下核心线程即使空闲也不会被回收如果你确定某个线程池是突发任务型很久才来一批任务可以调用allowCoreThreadTimeOut(true)让核心线程也超时回收配合keepAliveTime就能避免线程白白占用内存。2.3 拒绝策略四选一还是自定义ThreadPoolExecutor内置了四种拒绝策略AbortPolicy直接抛RejectedExecutionException默认策略。CallerRunsPolicy任务不丢弃由提交任务的线程自己执行。DiscardPolicy静默丢弃。DiscardOldestPolicy丢弃队列中最旧的任务然后重试提交。Android项目里我基本不用默认的AbortPolicy因为一旦任务堆积直接抛异常会导致App崩溃用户体感很差。我推荐CallerRunsPolicy在任务过载时让主线程去执行任务等于给线程池一个“降级”信号同时天然具备背压效果——主线程被占用了后续UI操作自然变慢反而会提醒你排查任务堆积的原因。DiscardPolicy和DiscardOldestPolicy都有丢任务的风险除非业务上明确允许丢否则我不建议用。3. submit和execute不只是返回值的有无3.1 execute的“甩出去就不管”模式ThreadPoolExecutor的execute方法接收Runnable作用是提交任务但不关心结果ExecutorService executorService Executors.newFixedThreadPool(4); executorService.execute(() - { // 执行任务 });execute有几个特点。第一提交的任务没有返回值调用方无法直接拿到任务执行的结果。第二任务内部如果抛出未捕获异常会直接导致该线程退出如果是核心线程退出线程池会创建一个新的核心线程来补充但异常信息可能不会很直观地传递出来。第三execute没有Future对象如果你需要等待任务完成或者取消任务这个方法做不到。3.2 submit的封装与Futuresubmit是ExecutorService接口定义的方法它接收Runnable或者Callable返回一个Future对象FutureInteger future executorService.submit(new CallableInteger() { Override public Integer call() throws Exception { // 模拟耗时 Thread.sleep(1000); return 42; } }); int result future.get();Future的get方法会阻塞等待任务执行完成并返回结果。如果在任务执行过程中抛出异常future.get()会抛出ExecutionException你可以通过cause拿到真正的异常信息。这一点比execute友好至少异常能拿到。但这里有一个常见的坑future.get()是阻塞的如果在主线程里直接调用future.get()等于又把主线程卡住了。正确做法是利用Future的isDone轮询或者使用带有超时参数的get(long timeout, TimeUnit unit)避免无限期阻塞try { Integer result future.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { // 处理超时 future.cancel(true); }我曾经在一次多线程并行请求多个接口的场景里用submit提交了5个任务然后主线程依次get()结果最后一个任务耗时较长整体等待时间和串行几乎一样。后来改成CountDownLatch或者CompletableFuture.allOf并行等待才真正达到并发效果。submit和execute的选择本质上取决于你有没有后置处理需求。没有返回结果、不需要感知异常的用execute更轻量需要拿结果、需要感知异常、需要取消的用submit。4. 阻塞队列选型LinkedBlockingQueue、ArrayBlockingQueue还是SynchronousQueue4.1 三种队列的底层差异线程池的workQueue参数决定了任务排队的方式。Android/Java里最常见的几个阻塞队列是LinkedBlockingQueue、ArrayBlockingQueue和SynchronousQueue。LinkedBlockingQueue是基于链表的阻塞队列默认容量是Integer.MAX_VALUE也可以指定容量。由于链表结构生产和消费各有一把锁吞吐量较高。默认无界容量带来的风险是任务可以无限排队线程池永远达不到maximumPoolSize如果请求量大内存可能被堆积的任务撑爆。ArrayBlockingQueue是基于数组的有界阻塞队列容量必须在构造时指定。它使用一把锁控制生产和消费性能比LinkedBlockingQueue稍低但好处是队列容量可控迫使线程池在任务多时扩容到maximumPoolSize从而限制堆积的任务数量。SynchronousQueue比较特殊它内部不存储任何任务每个插入操作都必须等待一个取出操作。也就是说提交任务时如果核心线程都在忙它不会排队而是直接尝试创建新线程直到maximumPoolSize。超过后就触发拒绝策略。4.2 不同场景下选择哪种队列先说结论再解释日常Android项目里IO密集型的网络加载和数据库操作我通常用指定了容量的LinkedBlockingQueue对内存占用敏感的场景用ArrayBlockingQueue需要最大并发快速处理突发任务、且不怕任务丢失的场景SynchronousQueue配合CallerRunsPolicy可以做到类似Go goroutine的快速调度效果但风险是大量任务被拒绝。之前提过Executors.newFixedThreadPool默认用的是无界LinkedBlockingQueue这是面试里经常被问的坑点使用无界队列时maximumPoolSize形同虚设线程数永远不会超过corePoolSize。真实项目中我对队列的选型标准是队列容量 预期峰值任务数 × 单个任务等待时间 ÷ 单任务执行时间。比如预期峰值1000个任务单任务执行50ms等待可以接受2秒那容量大约在40左右不需要也不可能算得很精确但你要明确个量级而不是随便填一个。5. Android项目里的线程池实战配置与线程命名5.1 创建线程池的常见方式Executors还是手动配置Android开发里看到最多的创建线程池方式是Executors.newCachedThreadPool、newFixedThreadPool、newScheduledThreadPool。这些方式确实方便但Java规范也不推荐在大型应用里直接使用原因和队列问题有关newFixedThreadPool使用无界LinkedBlockingQueue任务堆积时内存风险高。newCachedThreadPool最大线程数是Integer.MAX_VALUE遇到大量短任务时线程数可能飙升。newScheduledThreadPool内部用的是DelayedWorkQueue适合定时任务但并发模型比较特殊。我推荐统一用ThreadPoolExecutor的完整构造函数去创建虽然代码啰嗦一点但每个参数都在你的控制之下。把这个封装成一个线程池管理器类统一提供IO密集型、CPU密集型、定时任务型的线程池实例长期维护起来会轻松很多。5.2 给线程命名排查线上问题的关键一步这是很多人忽略的细节。线上排查问题的时候如果线程名是pool-1-thread-1你根本不知道这个线程是哪个业务模块创建的。但如果你用ThreadFactory设置了业务前缀比如biz_image_upload_thread那在抓ANR日志或者看崩溃堆栈时一眼就能定位到对应的业务模块。ThreadFactory的写法ThreadFactory threadFactory new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, biz_image_upload_thread_ count.getAndIncrement()); } };Java 8之后可以用lambda简化但核心逻辑不变。命名不是锦上添花是排查问题的刚需。5.3 一套可以直接落地的配置参考以我现在的项目为例我维护了三个核心线程池第一个是网络请求线程池核心线程数6最大线程数12队列容量64keepAliveTime 30秒拒绝策略CallerRunsPolicy。网络请求大多在等待响应6个线程基本够用超过12说明流量异常降级处理。第二个是数据库和本地文件操作的IO线程池核心线程数4最大线程数8队列容量32keepAliveTime 30秒拒绝策略DiscardOldestPolicy。数据库操作对顺序有要求堆积时丢弃最旧任务比抛异常合适。第三个是CPU密集计算线程池图像处理、JSON解析核心线程数Math.max(2, Runtime.getRuntime().availableProcessors() - 1)最大线程数和核心线程数相同队列容量16拒绝策略由调用方根据业务决定。这里有个细节Runtime.getRuntime().availableProcessors()在Android上返回的是可用核心数但在某些省电策略下大核可能会休眠这个值并不完全等于实际运行核心数所以我看一般会再手动和固定值取一个最大值避免极低端设备上可用核心数为1时线程池退化成单线程。6. 线程池踩坑实录与排查路径6.1 线程池不关闭导致的内存泄漏早年在项目里写过一个全局单例的线程池没有提供shutdown入口Activity销毁时提交的任务还在继续执行结果持有Activity的引用导致Activity无法被回收。这个问题在LeakCanary上特别明显排查路径也简单泄漏引用链上会看到一个XXXThread对应的Runnable里持有了Activity实例。修复方式有两种一种是保证提交给线程池的任务不持有Activity引用通过静态内部类加WeakReference来引用外部对象另一种是在页面销毁时取消还没有执行的任务。但这里有个现实问题正在执行的任务无法直接打断只能通过中断标志位配合协作式取消。Future.cancel(true)只是发出中断信号如果任务本身不响应中断它还是会继续跑。我的做法是在页面onDestroy时对页面相关的任务项调用future.cancel(true)同时在耗时操作的循环里检查Thread.currentThread().isInterrupted()配合实现可取消的任务。6.2 任务堆积导致的内存膨胀有一次线上反馈内存暴涨排查后发现是一个本地文件上传线程池使用了无界队列。用户在一个网络很差的场景下反复触发上传任务全部堆积在队列里每个任务都持有文件路径和字节流引用内存占用直接拉满。修复方案就是把无界队列改成指定容量的有界队列同时设置队列满后的拒绝策略为CallerRunsPolicy让上传任务在调用线程也就是业务线程里执行相当于告诉业务层线程池已经饱和你先把当前这个任务处理掉再说。配合对业务层的反馈后续又加了一个监测线程池活跃线程数和队列大小的方法在超过阈值时打日志或者上报实现了线程池层面的“监控告警”。6.3 异步任务没切回主线程导致的UI异常这个坑说出来有点基础但确实很容易犯。有一些业务从后台线程池取到数据后直接拿数据改了TextView。在debug模式下可能碰巧跑在主线程线上就不一定了一旦触发ViewRootImpl.CalledFromWrongThreadException崩溃就来了。我的处理策略是线程池只管执行任务最后往主线程切换统一通过一个私有Handler或者协程回调来完成。在Android里推荐用Handler(Looper.getMainLooper())来切线程轻量也没有额外依赖。如果你已经在用Kotlin协程那可以直接用withContext(Dispatchers.Main)切回主线程协程框架天然解决了线程切换的问题和线程池也不冲突可以把线程池理解为协程底层的执行器。6.4 线程池七参数面试题背后真正想考察的东西最后聊一下面试。线程池几乎是Android/Java面试必考题但我发现很多候选人只是背参数实际上不太理解线程池的设计思想。面试官问“线程池的核心线程数工作原理”时真正想考察的是你对资源使用边界的理解线程是稀缺资源创建太多会导致频繁上下文切换创建太少会导致CPU利用率不足。线程池就是一个资源池化技术和数据库连接池、对象池异曲同工核心是复用与限流。面试时如果你能把任务提交决策链路讲清楚把队列选型和拒绝策略在Android场景下的考虑讲明白再结合一个真实项目里调整参数前后的性能数据对比面试官基本会认为你有实战经验而不是背八股。我自己在项目中真的被线程池“教育”过之后就用一个注解ThreadPriority标记任务类型通过动态创建线程池来匹配不同业务场景核心线程数的调节会考虑设备CPU核心数以及任务平均执行耗时。对于应对大流量突发、避免线程数膨胀导致崩溃这套方案到现在运行也比较稳定。

相关新闻