ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android多线程与线程池实战:从主线程卡顿到ThreadPoolExecutor参数详解

Android多线程与线程池实战:从主线程卡顿到ThreadPoolExecutor参数详解 1. 主线程卡顿的本质为什么Android应用离不开多线程把《水文》这个系列写到第20篇的时候后台收到一条私信问的是Android多线程和线程池到底怎么用。这个问题看着基础实际上能聊的东西特别多。Android开发入门三件套之一就是“主线程卡了要放子线程”——可是为什么放、放几个、用new Thread还是线程池、参数该怎么配很多人其实没完全想透。前阵子帮朋友看一个线上崩溃现象是应用所有接口请求全部超时点按钮半天没反应紧接着ANR弹窗排着队跳出来。最后定位到原因就是主线程里直接执行了一次全表数据库查询拿到结果才更新UI。这个案例听起来很幼稚但在真实项目里并不少见尤其是界面逻辑越叠越多、业务方急着上线的时候耗时操作往主线程里一塞就是图个省事。卡顿、ANR、掉帧根源其实是同一件事。1.1 ANR和掉帧背后是同一件事Android的主线程也叫UI线程它并不是一个能随意并行执行多段代码的线程而是一个消息循环线程。Looper从MessageQueue里不停取消息交给Handler去执行。你在主线程里写的代码不管是onCreate、onClick还是onDraw本质上都是被塞进消息队列的一条一条消息。一旦某条消息的处理函数里出现了耗时操作后续所有消息都得排队等着。等的结果有两种一是界面掉帧因为下一帧的绘制消息迟迟没被执行二是ANR因为输入事件5秒内没被处理掉系统弹窗让你选择“等待”或者“关闭”。不管是哪一种都是因为“主线程忙不过来”。掉帧比ANR更容易被忽略。你滑动列表时偶尔觉得不跟手大多数时候不是系统性能差而是主线程里存在一个耗时的“老鼠屎”靠着多线程把它挪出去流畅度立刻就能上来。1.2 Handler和Looper主线程到底在忙什么很多人一开始接触Handler只知道“子线程不能更新UI”但对为什么不能更新UI说得不太清楚。因为View和WindowManager内部的线程检查规则要求任何UI更新操作必须在Looper所在的主线程执行。更准确地说UI操作要走主线程的Looper队列而不是直接改内存里的某个属性。子线程想更新UI标准动作有两个一是调用View.post把更新操作post到主线程的消息队列里二是通过Handler在主线程里处理Message。这两个动作的底层都是同一个消息循环机制。理解了这一点你再看线程池里的任务回调就会非常自然地想到任务完成之后不能在池线程里直接改View必须回到主线程去更新UI。1.3 从Thread到线程池真正要解决的问题新手遇到耗时操作的常规反应是new Thread().start()。这个操作本身没有错但它只解决了一个问题让耗时代码不阻塞主线程。它解决不了另外三个问题第一线程的创建和销毁是有开销的频繁短时间任务会让CPU一直在做线程调度而不是做正事第二并发数量没法控制列表快速滚动时可能会瞬间创建几十个线程每个线程默认栈空间在手机上是几MB级别内存很快就扛不住了第三线程的生命周期没有统一管理项目里散落着无数个裸奔的Thread想停停不掉想查查不清。线程池解决的就是这三件事复用既有线程限制并发上限统一管理任务队列和饱和策略。Android开发里可以直接用java.util.concurrent包下的ThreadPoolExecutor不用自己造轮子。2. ThreadPoolExecutor参数拆解五个参数和一套执行规则ThreadPoolExecutor是Java并发包里最实用的一个类也是Android多线程绕不开的核心API。你要能读懂它的行为而不是只会照着别人的配置抄。val executor ThreadPoolExecutor( corePoolSize 2, maximumPoolSize 4, keepAliveTime 30L, TimeUnit.SECONDS, workQueue LinkedBlockingQueue(64) )这段代码只是一个基础示例。实际上想要配好线程池你得把下面几个参数之间的关系彻底搞清楚。2.1 五个参数的职责分工用一个餐饮店的例子来类比corePoolSize是店里的正式员工不管忙不忙都养着maximumPoolSize是最忙的时候能拉来的临时工总数上限workQueue是店门口的等位区没座位的客人在那里排队keepAliveTime是非核心线程空闲多久可以被辞退rejectedExecutionHandler是店里实在坐不下时怎么处理新来的客人。你可能会问corePoolSize和maximumPoolSize都只是“人数”那什么时候拉临时工什么时候拒绝后续任务规则很明确按顺序判断如果当前线程数小于corePoolSize来一个任务就创建一个新线程直到达到核心线程数。如果当前线程数达到corePoolSize新任务会被放进workQueue排队而不是立刻创建新线程。如果队列已经满了同时当前线程数小于maximumPoolSize才会创建新线程最多扩到maximumPoolSize。如果队列满了线程数也到上限了新任务就会交给rejectedExecutionHandler处理。这个顺序是面试题常客也是实际配置线程池时最容易误解的地方。太多人以为“任务一多就应该立刻创建新线程”实际上线程池是先排队队列塞不下才扩容。2.2 提交任务之后的执行流转搞清楚这个流程你再看任何一篇讲线程池的文章都会通透很多。举个具体例子假设corePoolSize是2maximumPoolSize是4队列容量是8。你连续提交10个任务前面2个任务会分别占用两个核心线程。第3到第10个任务会全部排进队列因为队列容量不小此时并不会创建第3、第4个线程。如果你再提交第11个任务队列满了线程池才会创建第3个线程来执行第11个任务然后是第12个任务创建第4个线程。如果此时还有第13个任务进来队列满、线程数到上限只能走拒绝策略。这里有个隐藏知识点keepAliveTime只对非核心线程生效核心线程默认即使空闲也不会被回收。如果你希望核心线程也能被回收可以调用allowCoreThreadTimeOut(true)但一般业务场景用不上。2.3 自定义ThreadFactory给线程起名字不是仪式感很多项目里线程池的配置是直接从网上复制的ThreadFactory从来不写。结果就是线程池运行时你在Android Studio的Dump Java Thread里看到的线程名是pool-2-thread-1这种毫无信息量的名字。线上排查问题的时候你只知道有线程在跑但根本分不清是哪块业务提交的任务。建议所有线程池都配一个带上业务前缀的ThreadFactoryval threadFactory ThreadFactory { r - Thread(r, image-download-pool).apply { priority Thread.NORM_PRIORITY } } val executor ThreadPoolExecutor( 2, 4, 30L, TimeUnit.SECONDS, LinkedBlockingQueue(16), threadFactory )线程名虽然看起来只是调试信息但在实际排障中价值巨大。线程泄漏排查、CPU占用分析、看主线程是否有异常耗时首先要从线程名里判断“这是哪来的任务”。3. 三种阻塞队列的取舍排队长度就是线程池的缓冲防线workQueue是线程池里最容易忽略的参数。很多人把Executors工具类里的newCachedThreadPool当成救星其实它背后用的就是SynchronousQueue移动端完全不推荐。要理解为什么先要把队列这个角色看明白。3.1 队列是缓冲不是仓库队列的容量决定了线程池能排队多少任务。如果队列是无界的核心线程数就是实际干活的最大线程数因为队列永远不会满maximumPoolSize和拒绝策略都不会生效。这本身不一定是坏事但有个很大的隐患任务提交速度超过消费速度时无界队列里的任务会无限堆积每个Runnable都持有内存引用最后App可能直接OOM。移动端的内存本来就比服务端紧我的建议是优先使用有界队列。给队列一个明确容量让“排队”这件事变得可控这是线程池的第一道防线。3.2 LinkedBlockingQueue、ArrayBlockingQueue和SynchronousQueue的行为差异这三种队列都应该掌握它们决定了线程池在遇到突发流量时的表现。LinkedBlockingQueue默认无界也可以传入容量变成有界队列。底层是链表结构FIFO顺序出队。Executors.newFixedThreadPool用的就是默认无界的它。当任务量比较均匀时使用体验很顺滑。ArrayBlockingQueue数组结构必须有界严格FIFO可以构造为公平模式。因为有界它天然会触发后续的扩容和拒绝逻辑适合用来限制积压任务数量。SynchronousQueue内部没有缓冲容量提交任务时要求另一个线程立刻接手否则提交操作就会阻塞。这是newCachedThreadPool的默认队列配合“最大线程数无限”就会导致每一个新任务都可能创建一个新线程。队列类型容量核心特点适合场景LinkedBlockingQueue默认无界可设置容量FIFO链式存储使用灵活固定线程池默认场景任务量可控ArrayBlockingQueue必须有界数组存储严格FIFO需要限制任务堆积控制内存SynchronousQueue无缓冲直接交接不排队不限制线程数的高频小任务移动端慎用3.3 Android场景下怎么选队列一个比较保守的配置是ArrayBlockingQueue(32)或者LinkedBlockingQueue(64)。这样即使某个业务突然在短时间内提交了几百个任务队列最多囤积几十个剩下的要么被拒绝要么走自定义策略不会把内存打爆。如果你是做下载任务一个任务可能会消耗比较长的时间核心线程数不建议设得太大否则网络慢的时候一堆线程都在等IO系统线程调度压力也不小。一个实际可用的组合是核心线程2最大线程4队列容量32。这个配置在多数Android真机上都够用也不会给主线程造成额外负担。如果任务是有优先级概念的可以考虑PriorityBlockingQueue。但需要注意ThreadPoolExecutor提交Callable时会被包装成FutureTask直接按Runnable写优先级比较器经常会不生效需要手动写FutureTask的比较器这里面的坑不少。非必要不建议在Android里用队列做优先级调度老老实实按批次提交更省心。4. 配置线程池规模手机上的线程数不是服务端那套算法网上最常见的线程池数量公式是CPU密集型任务用N1IO密集型任务用2N其中N是CPU核心数。这个公式对服务端来说大致方向是对的但直接搬到Android上很危险因为手机不是只跑你一个应用。4.1 CPU密集与IO密集的判断先明确什么是CPU密集型图片压缩、视频转码、复杂计算这类任务绝大多数时间都在占用CPU开启的线程数超过CPU核心数之后多出来的线程只会增加上下文切换的开销反而更慢。IO密集型则相反比如网络请求、文件读写线程大部分时间在等待IO返回等待期间CPU是空闲的所以可以多开线程让更多请求同时在路上跑。Android场景下网络 IO和数据库 IO 是最常见的耗时点理论上可以开比CPU核心数更多的线程。但你要知道手机上不是只有你App在运行主线程、渲染线程、系统服务都在抢CPU。如果业务线程开太多主线程的位置就会被挤掉得不偿失。4.2 手机上的线程数怎么算我给一个偏保守也很实用的经验值网络请求并发核心线程2最大线程4队列32左右。图片解码、压缩这类CPU密集任务核心线程Math.max(1, Runtime.getRuntime().availableProcessors() - 1)最大线程可以和核心线程一致队列设置成16以内。数据库读写不要为了快而开线程池。SQLite本身的写入操作就是串行化的多线程同时写不仅不会更快还会频繁出现database disk I/O error或者database is locked。把数据库操作收敛到单线程池反而最稳。availableProcessors()在部分手机上不是特别可信有些八核CPU在同一时刻只开放了大核心返回值不等于你真正能使用的并发能力。这套数据够用来做粗略上限但别把它当成精确计算依据。4.3 Executors工具的边界Executors工具类在Java学习中很常用newSingleThreadExecutor、newFixedThreadPool、newCachedThreadPool的区别也是面试高频题。从工程角度说线上代码我建议直接new ThreadPoolExecutor自己传参数原因很简单newFixedThreadPool用的是无界的LinkedBlockingQueue任务积压过多时会悄悄堆内存。newCachedThreadPool最大线程数是Integer.MAX_VALUE移动端稍微一个抖动就能创建几十上百个线程OOM只是一瞬间的事。直接new可以自己控制队列长度、线程名、拒绝策略只比工具类多几行代码但可控性高了不是一点。5. 实战落地用线程池完成12张图片的并发下载与进度回调理论聊完来看一个真实的落地场景。假设用户打开一个商品详情页页面上有12张商品图需要分批发出来要求App启动后并发下载下载过程中进度条要实时更新下载完成才能展示图片。5.1 需求拆解与线程池配置这个需求包含两个特点存在网络等待时间属于IO密集型12个任务不算多但也不能一次性全开。所以线程池可以设为核心线程2、最大线程4、队列容量16。为什么队列要设成16这么大因为12个任务即使全部进入队列也不会触发拒绝策略加上最大线程数是4实际执行速度可控。考虑页面销毁的场景线程池不能直接放在Activity成员变量里带着跑更好的方式是用一个全局单例Application级别持有。这样页面退出时已经提交的任务可以继续执行而不是被页面销毁连带销毁。5.2 代码实现与回调设计val doneCount AtomicInteger(0) val total urls.size for (url in urls) { imageThreadPool.execute { try { val bitmap HttpClient.downloadBitmap(url) val done doneCount.incrementAndGet() uiHandler.post { progressBar.progress done * 100 / total imageView.setImageBitmap(bitmap) } } catch (e: Exception) { Log.e(ImageDownloader, download failed: $url, e) } } }这里的doneCount用的是AtomicInteger因为池线程是并发的直接用Int做累加会有竞态问题。进度更新不能直接在池线程里做必须通过uiHandler.post回到主线程本质原因就是前面提到的主线程消息循环机制。HttpClient.downloadBitmap只是一个示意真实项目里网络请求可能是OkHttp、Retrofit重点在于请求要放在池线程里执行。回调里做的UI更新最终走主线程这个链路不能断。5.3 关闭、取消与生命周期页面退出时如果提交的任务已经不需要了可以用submit拿到Future对象然后调用cancel(true)。但要注意cancel(true)只是给正在执行的线程发一个中断信号任务代码里必须自己响应中断否则网络请求还是会继续跑完。一个相对稳妥的做法是在写任务逻辑时注意判断Thread.currentThread().isInterrupted把取消当成正常退出路径。如果你使用的是全局单例线程池就不要在页面onDestroy里调用shutdown()否则页面销毁一次线程池就关一次后续页面再也无法复用。shutdown只适合那些“用完就扔”的局部线程池。全局线程池要做的是“不shutdown但控制任务提交数量”以及确保Runnable里没有直接持有关键组件的强引用。6. 踩坑实录线程池在真实场景里最容易炸掉的五个地方理论和示例代码都看懂了不代表线上不会炸。下面这几个坑是我在项目里实际遇到过的每次都是排查半天才发现问题出在线程池配置上。6.1 队列满了拒绝策略怎么选有界队列配合有限的线程数一旦任务提交速度超过处理速度RejectedExecutionException就会抛出来。这是线上很常见的一种崩溃表面看是“队列满了”实际是任务提交方没有做好节流。应对方案是选择rejectedExecutionHandlerAbortPolicy是默认策略直接抛异常不够温和但至少问题会暴露出来。CallerRunsPolicy会把这个任务交给提交任务的线程执行如果提交方是主线程那就等于在主线程里跑耗时任务卡顿风险很大但好处是不会丢任务。DiscardPolicy直接丢弃适合一些允许丢失的埋点日志。DiscardOldestPolicy丢弃队列里最老的任务适合时效性强的刷新场景。我的习惯是尽量用CallerRunsPolicy因为任务不会无缘无故丢。但如果任务是重量级操作提交方又是主线程宁可自定义一个策略把被拒绝的任务重新提交到后台而不是让主线程去顶班。6.2 线程复用导致ThreadLocal状态残留这是我最想强调的一个坑。线程池里的线程是会复用的同一个线程执行完任务A紧接着就会去执行任务B。如果你在任务里使用了ThreadLocal又没有在结束时remove任务B就会读取到任务A留下的旧数据。我踩过的具体场景是给网络请求加TraceId每个任务开始前把requestId放进ThreadLocal方便日志链路追踪。结果排查线上问题时发现下一批任务的日志里全部出现了上一批任务的requestId整整查了一下午才发现是线程复用导致ThreadLocal没有清理。正确做法是在finally里调用remove()这一点很多文章都不会提醒你。6.3 忘了shutdown引起的线程泄漏界面上创建线程池用完后不关这是内存泄漏的经典写法。线程池本身有存活的核心线程核心线程不会被GC回收于是线程池对象也被线程引用着。如果提交给线程池的Runnable里又持有了Activity的引用Activity就会跟着泄漏一整条链。解决办法有两个方向一是把线程池的生命周期提升到Application级别让它成为全局单例二是在页面创建局部线程池时务必在onDestroy里shutdown。最怕的是项目里既有全局池又有局部池混乱使用出问题很难查。6.4 父子任务互相等待导致死锁单线程池或者核心线程数很小的线程池里如果线程池中的一个任务又去往同一个线程池提交子任务并且等待子任务返回结果就会死锁。因为父任务占着唯一的执行线程子任务在队列里永远排不到。这个坑在“一个任务里再做二次异步处理”的场景特别容易发生。解决思路也比较简单不要让父任务同步等待子任务改成回调驱动或者父子任务使用不同的线程池再或者干脆把最大线程数放宽。总之同一个线程池里的任务之间不能存在同步依赖。6.5 吞掉的异常会让你的排查从下午开始线程池里的Runnable如果没有捕获异常异常会直接打进线程的未捕获异常处理器最终可能导致线程被终止然后线程池重新创建一个新线程。这个“静默重启”的过程会把问题隐藏起来你只看到线程池线程数来回跳却看不到异常本身。不管任务逻辑多简单都一定要在Runnable里try-catch并打日志。尤其是网络请求、文件读写这类IO操作异常率远比你想象的高。加个日志不会让代码变难看但能让你在出问题的时候少花三小时。我个人在实际项目里偏爱的做法是把线程池封装成一个小工具类对外只暴露execute和submit所有业务线程池都带上专属线程名前缀。排查问题时打开线程Dump一眼就能看出是哪块业务在“搞事”。并发这个领域晦涩的规则很多但理解底层逻辑之后再动手踩坑的概率会小很多。
返回列表