ARTICLE DETAIL

资讯详情

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

Android进程与线程:从优先级到Binder及ANR实战解析

Android进程与线程:从优先级到Binder及ANR实战解析 做 Android 开发头两年我最怕面试被问到进程和线程总觉得这属于大学操作系统课的内容和写界面、搭业务没多大关系。后来线上用户反馈首页卡死拿到的 ANR 日志里 main 线程堵在数据库查询上花了大半宿才想明白进程与线程这两块基础如果不补扎实后面写多少业务代码都是在给自己埋雷。这篇文章想把 Android 开发里绕不开的进程与线程知识完整串一遍包括进程优先级怎么影响应用存活、主线程与工作线程如何协同、线程池参数怎么配、跨进程通信 Binder 到底做了什么以及线上问题排查时的实际思路。适合刚入门搞不懂 UI 卡顿原因的初级开发者也适合从其他技术栈转 Android、想补底层细节的同事。1. 先把进程和线程这两个概念掰扯清楚很多新手会把进程和线程混着说原因不难理解日常开发中见到的 Activity、Service、ContentProvider 全都跑在同一个进程里而到处 new 出来的 Thread 又好像天然属于这个进程。真要问一句“两者区别在哪”经常就卡住了。1.1 进程独立的内存房间进程是操作系统分配内存和资源的最小单位。每个进程都有一块相对独立的地址空间、自己的文件描述符表还有一个独立的虚拟机实例。你可以把它理解成一栋楼里的某个房间房间里配了独立的水电表、独立的钥匙房间之间不能随意乱串。在 Android 里系统启动应用时并不是直接 new 一个进程而是由 Zygote 进程 fork 出来的。这种 fork 方式可以让子进程直接继承虚拟机和共享库的预加载状态所以应用启动速度比传统 Linux fork 后重新初始化要快得多。默认情况下一个应用只有一个进程进程名通常就是包名。但开发者在 AndroidManifest.xml 里可以用 android:process 属性给四大组件单独分配进程比如写成 :remote 或 push.xxx。带冒号的进程名表示该应用的私有进程而普通的全局进程名可以被多个同 UID 的应用共享。进程内部如果出现 OOM 或者空指针导致崩溃只是这个进程挂掉系统不会因为一个普通应用进程崩溃而整体死机。这就是隔离的意义某个房间着火至少不会直接烧穿整栋楼。1.2 线程房间里的调度员工线程是 CPU 调度的最小单位。一个进程至少有一个主线程也可以有多个子线程它们共享进程堆内存、静态变量和大部分对象但各自拥有独立的栈、寄存器和局部变量表。继续用房间来类比线程就是房间里分工干活的员工。进程提供了工作环境员工们干活的细节互不干扰但员工之间如果要协作就得读同一张工作台。这张工作台就是进程共享的那块内存。正因为共享内存才引出了线程安全问题两个员工同时改同一个单据结果很容易乱。从系统调度角度看线程才是真正被 CPU 执行的东西。多核手机上几个线程可以同时跑但线程数一旦远超 CPU 核心数就陷入频繁切换上下文切换消耗反而超过业务本身的计算消耗这也是为什么线程池里的线程数不能拍脑袋随便定的原因之一。1.3 为什么这一组概念在 Android 里格外重要Linux 系统本身就有进程和线程但 Android 把事情做得更“特殊”应用程序的 Activity、Service、BroadcastReceiver 生命周期回调默认全部运行在主线程上。换句话说主线程不只是画 UI 的线程它还承载了大量组件生命周期逻辑。一旦主线程被某个耗时操作堵住界面就无法响应系统就会弹出 ANR 对话框。同时Android 管理进程也不是靠开发者意志而是靠一套优先级回收策略。系统内存紧张时会从低优先级进程开始杀。你如果不理解这套优先级规则很容易出现“我的应用明明还在运行怎么一回到桌面就被杀了”的困惑。理解了这两层再去看 Handler、线程池、Binder 这些具体机制思路会顺很多。2. Android 进程优先级系统决定“杀”还是“留”的潜规则Android 不是一个可以让你随意常驻后台的系统进程的生死权基本掌握在 ActivityManager 手里。当系统内存不足时AMS 会按进程优先级从低到高逐个清理这个过程完全不可逆普通应用也拦截不了。2.1 五级进程优先级从前台到空壳从高到低可以大致分为五类优先级进程类型典型场景被清理概率1前台进程正在交互的 Activity、正在执行 onReceive 的 Receiver、已 startForeground 的服务极低2可见进程界面被部分遮挡但仍可见例如弹了非全屏对话框、处于 onPause 状态低3服务进程通过 startService 启动且仍在运行的 Service中4后台进程Activity 已 onStop且没有服务进程引用高5空进程没有任何组件只留下进程壳最先被杀前台进程并不只包含用户正在操作的 Activity还包含正在执行生命周期回调的组件。系统为了保证生命周期回调能及时完成也会把这类进程放到最高优先级。所谓“可见但不可交互”常见于被另一个透明 Activity 盖住的情况系统认为用户还能看到当前界面所以不会轻易杀。这里有一个常见的误判很多人以为启动一个普通 Service 就能让应用“活着”其实服务进程只比后台进程高一级系统内存吃紧时照样会回收。想让服务尽量稳定存活正确做法是调用 startForegroundService 并尽快让服务转入前台状态这需要弹一个常驻通知。2.2 前台服务与进程常驻的边界在哪里前台服务是 Android 里少数能提高进程优先级的手段但代价也很明确通知栏必须有一条用户可见的通知。Android 8 以后需要创建通知渠道Android 12 以后对后台启动前台服务还做了限制只允许在特定场景下启动。很多早期项目喜欢用双进程守护互相拉起这种设计后来基本被系统限制死了后台限制和电池优化会让这种方案变成耗电黑洞还会被应用商店直接拒审。现在做进程常驻合规思路只有一条确定业务真的需要。例如音乐播放、地图导航、运动记录这类需要长期在后台给用户提供服务的场景用前台服务是合理的单纯为了“保活”而常驻既没有用户价值也扛不住系统回收。2.3 实战里怎样观察进程生命周期在开发阶段可以用 adb 命令查看进程优先级和当前存活情况adb shell ps -A | grep com.example.app adb shell dumpsys activity processes | grep -A 5 com.example.appdumpsys 输出里会包含 oom adj 值这个值直接反映了进程当前优先级。数值越小优先级越高例如前台进程通常接近 0后台进程可能到几百。出现“adj 变高说明你回到了后台系统正在准备回收”这类状态时不要恐慌这是正常 Linux 内存回收策略。真的需要保住某些状态应该考虑把数据持久化而不是依赖进程不灭。3. 主线程与工作线程从 Looper 到协程的调度协同Android 的主线程也叫 UI 线程但它不是简单的“画界面的线程”它背后运行着一个 Looper 循环不断从 MessageQueue 里取出消息并执行UI 绘制、输入事件、生命周期回调都是消息的一部分。如果消息执行时间过长或者队列里堆积太多消息界面就会卡顿甚至触发 ANR。3.1 主线程为什么不能做耗时操作主线程一旦阻塞后果是直接面向用户的。系统规定了许多超时阈值页面无法响应输入、BroadcastReceiver 处理超时、Service 执行超时都会触发 ANR。简单说网络请求、数据库操作、Bitmap 压缩、JSON 解析、文件读写这些常见代码都绝对不该直接写进主线程。但有一点需要说清楚耗时操作并不是唯一的主线程杀手。有时候一段代码执行速度本身不慢但它在主线程里被连续触发例如滑动列表时每帧都做大量对象分配或者某个高频回调里加了日志打印和反射调用累计时间超过垂直同步的 16ms 预算就会让用户感到掉帧。所以关注主线程不只是关注“是否做耗时操作”还要关注“单位时间内有没有做太多不必要的事”。3.2 从 Thread、Handler 到 Kotlin 协程最朴素的方式是直接起一个 Thread得到结果后再用 runOnUiThread 切回主线程更新界面new Thread(() - { String result loadData(); runOnUiThread(() - binding.tvResult.setText(result)); }).start();但线程一旦多起来频繁创建和销毁的代价就很明显而且代码里到处乱飞线程会很难维护。Android 提供的 Handler 机制是更稳妥的基础设施子线程通过 Handler 向主线程的消息队列发送任务主线程通过 Handler 接收并执行天然实现跨线程通信。Handler mainHandler new Handler(Looper.getMainLooper()); new Thread(() - { String result loadData(); mainHandler.post(() - binding.tvResult.setText(result)); }).start();这里值得停下来理解一下底层原理Looper 是一个消息循环对象每个线程只能绑定一个 Looper主线程的 Looper 在应用启动时就准备好了。子线程如果也想有自己的消息队列需要手动调用 Looper.prepare 和 Looper.loop。所谓 Handler 发送消息本质上是往目标 Looper 的队列里插一条 Message而这个 View 的 post 方法最终也是通过主线程 Handler 实现的。如果项目已经使用 Kotlin协程是目前最合适的异步方案它比 Thread 更轻量不需要大量创建内核级线程。在 ViewModel 里可以直接用 viewModelScope 发起异步任务viewModelScope.launch { val result withContext(Dispatchers.IO) { repository.loadData() } tvResult.text result }这段代码看起来像同步顺序执行但 loadData 被切到了 IO 线程池执行执行完自动回主线程。协程并不是魔法它底层仍然依赖线程调度只是把线程切换和回调封装成了挂起与恢复从而把大量并发代码写得像普通顺序逻辑一样清晰。3.3 给线程起好名字排查少走弯路多线程项目最怕的就是抓取线程 dump 时看到一堆 Thread-12、Thread-28 这种毫无意义的名称完全不知道它属于哪块业务。建议每一处自建线程都设置可读名称Thread worker new Thread(runnable, image-loader- index); worker.start();线程池里也可以用自定义 ThreadFactory 统一命名方便匹配业务模块。排查 ANR 或死锁时线程名称会直接出现在 traces 文件里清楚的命名能让定位问题的时间从小时级降到分钟级。这是投入产出比极高的好习惯。4. 线程安全与同步共享状态的排雷手册多线程带来的最大问题不是“并发跑不快”而是共享数据不一致。进程内所有线程共享堆内存所以多个线程同时读写同一个对象就可能出现数据竞争、脏读、死锁等一系列问题。4.1 竞争、可见性与共享状态的坑最常见的一个坑是计数器问题。两个线程同时执行 count底层其实分成了读取、加一、写回三步。线程 A 读到了 100还没写回线程 B 也读到了 100两边都加一后再写回结果仍是 101而不是 102。这种现象就是数据竞争。还有更隐蔽的可见性问题Java 内存模型并不保证一个线程修改变量后另一个线程能立刻看到最新值。CPU 缓存、指令重排都可能导致另一个线程读到旧值。解决方案通常有两条路要么用 volatile 保证可见性和有序性要么用 synchronized 或锁保证原子性和互斥。对于简单的状态标记volatile 够用对于复合操作必须上锁。日常开发的另一个隐患是使用非线程安全的容器例如 ArrayList 被多个线程同时 add。稍不注意就会抛 ConcurrentModificationException或者把内部数组结构弄坏。这种问题通常不是必现的线上偶发时特别难查所以团队里要达成约定跨线程共享的集合必须用 CopyOnWriteArrayList、ConcurrentHashMap 这类并发容器。4.2 锁、wait/notify 与死锁定位synchronized 是乱象之源。它既支持对象锁也支持类锁可以修饰方法、代码块。内部原理是监视器锁持有锁的线程进入临界区其他线程需要阻塞等待锁的释放。理论虽然简单实际使用却容易踩到两个问题锁粒度太大会造成性能浪费锁顺序不一致会造成死锁。wait 和 notify 这两个方法也必须配合 synchronized 使用否则会抛 IllegalMonitorStateException。它们的作用是让线程在某个条件不满足时主动释放锁并等待而不是抱着锁死等。正确写法通常是一个循环里检查条件synchronized (lock) { while (ready false) { lock.wait(); } // 条件满足执行后续逻辑 }改用 while 而不是 if是为了防止线程被唤醒后条件又被其他线程篡改这也是经典 Producer-Consumer 模型里必考的细节。死锁是大家最爱聊的话题两个线程各持有一把锁又互相等待对方释放就会永久卡死。例如线程 A 持有锁 L1 等待 L2线程 B 持有锁 L2 等待 L1。此时线程转储里能看到典型的“waiting to lock”片段。定位方法很直接抓取线程 dump找到互相等候的线程看它们各自“locked”和“waiting to lock”的锁地址是否重复。预防手段也很简单多把锁时保证统一的加锁顺序或者用 ReentrantLock 的 tryLock 设置超时时间避免无限期等下去。4.3 并发容器与协程的“伪线程安全”很多人把 Kotlin 协程自带一种“线程安全”的错觉其实协程只是切换线程变得更方便了共享内存的同步问题一个都没少。在协程里如果你让多个协程同时操作同一个可变对象数据竞争依然存在。哪怕用 suspend 函数也只是挂起不代表数据被锁住。协程里应对互斥有挂起友好的 Mutex它和传统 synchronized 不一样协程拿到锁之后可以安全地让出线程因为锁的状态会被恢复出来。逻辑上更像是一个里程碑用多个线程各自执行独立任务没问题但只要涉及共享资源还是要靠并发容器、原子变量或锁来兜底。实际开发中还有一个更务实的技巧如果共享逻辑可以串行化就尽量串行化。与其多线程并发去改同一个对象不如把它放入单线程的调度队列里逐个执行这样整个模块天然安全还省去理解复杂锁逻辑的成本。5. 进程间通信Binder、AIDL 与跨进程“传话”Android 的进程隔离做得比桌面 Linux 更严格每个普通应用进程都跑在自己独立的虚拟机里A 进程不能直接读写 B 进程内存。但很多场景又确实需要跨进程沟通例如系统应用需要管理各个应用的状态应用 Service 需要运行在独立进程里时这时就需要 IPC而 Android 家族里的核心 IPC 机制就是 Binder。5.1 Binder跨进程通信的快递通道Binder 可以理解成一套快递服务。快递员不直接把包裹扔进你家客厅而是把它放到你门口由你确认签收。Binder 驱动在 Linux 内核里做好了权限校验和一次数据拷贝比传统 Socket 或管道更安全、性能也更高。Android 四大组件底层的跨进程通信包括 ActivityManager、PackageManager、WindowManager全部依赖 Binder。开发者层面最直接的 Binder 使用体验就是 AIDL。你定义了一个 .aidl 接口编译时系统会生成 Stub 和 Proxy。Stub 是服务端接收调用的实现骨架Proxy 是客户端发起调用的代理对象。客户端调用方法时参数会被序列化经过 Binder 驱动到达服务端服务端执行完再把结果序列化回来。这个过程会有跨进程的数据拷贝所以频繁调用和传递大对象都不合适。5.2 AIDL、Messenger、ContentProvider 怎么选选择 IPC 方案时要先认清复杂度边界不是所有跨进程通信都值得上 AIDL。方案特点适合场景AIDL强类型接口、支持复杂参数、同步或异步调用自定义方法多、参数结构固定的跨进程服务Messenger基于 Handler 消息包装本质还是 Binder串行处理只需要传递 Message、消息频率不高ContentProvider以 URI 为操作对象适合数据共享和跨进程 CRUD对外开放数据、文件共享、跨应用读取文件共享简单直接但并发写文件会产生锁冲突只在自控进程间且数据量极小时使用ContentProvider 在开发中经常被忽视但它是系统很多机制的基础。应用 A 要向应用 B 暴露数据定义 Provider 的 authorities 和 URIB 通过 ContentResolver 查询本质上就是一次 Binder 调用。FileProvider 也是 ContentProvider 的子类它在 Android 7 以后为了解决 file:// 跨应用不安全的问题而出现向其他应用分享文件时系统会生成一个带授权的 content:// URI。5.3 跨进程通信的性能禁忌和频率控制使用 AIDL 和 Binder 时最需要记住的一句经验不要在同步调用里传大对象不要在主线程里发起远程同步调用。一次 Binder 调用可能涉及序列化、内核拷贝、线程调度一个 2MB 的 Bitmap 跨进程传递会非常吃力还容易触发 TransactionTooLargeException。实际项目里常见的合理做法是跨进程传大文件时先把文件写入共享目录再传 URI传图片时先压缩高频状态变化尽量批量上报而不是每变化一次就发起一次 IPC。另外定义 AIDL 接口时如果该方法执行逻辑比较重服务端不要直接在主线程执行最好内部切到工作线程避免拖垮服务端自身。6. 线程池与异步架构给任务一个合理的执行环境每次异步都直接 new Thread 的做法在小型 demo 里没问题但放到正式项目就是灾难。线程创建本身虽然不慢可线程数量一旦膨胀调度压力、内存占用、上下文切换都会拖垮应用。线程池正是为了解决“复用线程、控制数量、管理生命周期”这三个问题而存在的。6.1 先搞明白 ThreadPoolExecutor 的五个决定性参数Android 开发者大多见过 ThreadPoolExecutor但未必真正理解它参数之间的联动关系。我常用一个有界队列的线程池配置ThreadPoolExecutor executor new ThreadPoolExecutor( 4, // corePoolSize 8, // maximumPoolSize 60L, // keepAliveTime TimeUnit.SECONDS, new ArrayBlockingQueue(32), r - new Thread(r, app-work- counter.incrementAndGet()), new ThreadPoolExecutor.AbortPolicy() );这里有几个核心过程要记住新建线程并不是无脑的当线程数小于核心线程数时即使有空闲线程也倾向于新建线程执行任务。当线程数达到核心线程数新任务先进队列排队而不是立刻开新线程。当队列也满了系统才会创建非核心线程直到最大线程数。超出最大线程数后任务会交给拒绝策略处理。所以“最大线程数”本身不是灵活性上限它取决于队列是否满了。很多人以为设置了 maximumPoolSize 8 就会在任务繁忙时自动扩容这是误解。如果 workQueue 用的是无界 LinkedBlockingQueue任务根本不会触碰到最大线程数线程池永远最多只有 4 个线程在处理。不要忽视 threadFactory 的命名作用我之前收到线上 dump 时最痛苦的就是看到 Thread-5 这种没有业务含义的名字。给线程池统一命名等于提前给排查工具铺好了路。6.2 阻塞队列怎么选直接决定反压行为workQueue 的选择决定了线程池的“反压策略”也就是任务堆积时系统会如何反应队列类型特点推荐场景LinkedBlockingQueue默认无界任务可无限排队maxPoolSize 形同虚设任务量稳定、队列内存可控ArrayBlockingQueue有界队列满后触发扩容和拒绝策略对外部任务量没有信心、需要保护后端资源SynchronousQueue不缓存任务直接把任务交给新线程希望以最大吞吐执行短小任务无界队列看起来简单实际上存在一个隐患如果生产任务的速度长期大于消费速度队列就会无限增长最后内存被拖垮。有界队列虽然会增加拒绝概率但至少拒绝过程是明面上的你可以通过拒绝策略去记录日志或做降级。正常情况下我宁愿让任务被拒绝也不愿意让全应用一起内存暴涨。拒绝策略里 AbortPolicy 会抛异常CallerRunsPolicy 会把任务退回调用线程执行DiscardOldestPolicy 会丢弃最老的任务。选哪种要结合业务背景下载任务丢一条没关系但资金交易相关的任务绝不能静默丢弃。最好的策略一般是自定义一个既要打点日志又要走降级逻辑而不是依赖默认行为蒙混过关。6.3 协程里的 Dispatchers 和线程池的关系很多 Kotlin 协程使用者对 Dispatchers 的理解停留在“IO 就是慢操作专用、Default 就是计算专用”但更准确地说它们背后都是线程调度器。Dispatchers.Default 使用的是 JVM 共享线程池线程数一般和 CPU 核心数相关适合 CPU 密集计算Dispatchers.IO 则是一个具备更大吞吐量的线程池适合阻塞式磁盘和网络操作。实际项目里建议优先使用带生命周期作用的 viewModelScope 或 lifecycleScope而不是 GlobalScope避免协程失去作用域后泄漏。同时注意 whenContext(I/O) 不是免费的频繁切换调度器也会有损耗。如果代码里出现十几个 withContext(Dispatchers.IO)往往意味着数据设计出了问题而不是调度器不够用。还有一点要特别警惕在协程里启用多个子任务并发时必须显式比如用 async 或者 launch。如果一个协程内只是连续写了两个挂起点它们并不会自动并行执行而是按顺序执行直到遇到真正的挂起操作。另外“进程池”这三字需要多说一句。Android 开发中通常讲线程池很少手工维护“进程池”。进程的创建和销毁成本极高而且四大组件的路由由系统管理把任务分发给多个进程的成本远高于使用线程池。如果真想多进程并行一般是通过 Service 派驻进程或 WorkManager 调度而不是自己管理一组进程。7. 进程与线程问题排查实录理论和设计说再多终究要落到排查问题上。这里把我平时处理线上卡顿、ANR、死锁问题时的一套流程分享出来。7.1 从 ANR 日志定位主线程阻塞出现 ANR 时系统会把进程的线程栈写到 traces 文件里旧版本一般位于 /data/anr/ 目录新版本或某些厂商 ROM 可能需要通过 bugreport 拉取。拿到 traces 后第一步不是通读全文而是找到 “Cmd line: 你的包名” 这一段然后定位名为 main 的线程。main 线程当前栈如果停在 SQLite 查询、Binder 调用、类加载、I/O 读写这些位置基本就能判断问题方向。我踩过的一个真实案例是用户反馈某页面点击无反应traces 里 main 线程卡在 ContentResolver.query。表面上只是访问本地数据库实际数据源是另一个应用进程通过 ContentProvider 提供的对方进程内部的 Binder 调用线程满了我们的 query 就一直排队。根因不在自家代码而在依赖的另一个进程性能退化但没 traces 的话我根本想不到这条链路上还藏着一个跨进程等待。所以遇到奇奇怪怪的“主线程卡死”别忘了展开调用栈里所有 Binder 调用看看是不是在等别的进程。7.2 用线程转储排查死锁与 CPU 占用怀疑死锁时第一步是抓取线程转储。Android Studio 自带 Profiler 可以抓 Java Heap 和线程状态也可以使用 adb 导出 traces。对比两个线程的状态一个处于 locked另一个 waiting to lock 同一个锁对象那就基本锁定死锁现场。死锁之外还有一类常见问题叫 CPU 占用异常。某个线程疯狂空转虽然不会立即阻塞主线程但会让整个进程的 CPU 占用飙到 100%严重影响动画流畅度。这类问题排查思路是先找出哪个线程“runnable”状态持续时间最长再通过 Method Trace 或 Profiler 记录该线程的调用热点。很多 CPU 峰值其实是业务里不合理的轮询逻辑造成的例如一个自定义 inotify 监听频繁被触发或者while (true)里没有 wait 和 sleep 的耗资源循环。7.3 避坑心得先统一线程命名和共享数据最后分享一条我后来写进团队规范里的经验也是被线上事故打出来的教训所有新创建的线程和线程池都必须有清晰命名所有跨线程共享的对象必须明确是并发容器或加锁对象。倒不是说这样做能消灭所有并发问题但它能极大缩短从系统现象到根因之间的推理距离。如果排查时你发现手头的线程 dump 全是 Thread-15 这种名字第一反应不应该是“我猜它可能是干上传的”而是应该先去找对应代码确认这段逻辑是否真的在自己掌控之内。很多时候多线程问题的难度不是锁本身而是你根本不知道还有其他线程在改同一个数据。每次线程问题告一段落后我都会再做一次简单复盘把改动涉及的对象是否是共享可变对象、是否有人在没有锁的情况下做写入、线程切换是否都走了统一调度器这三个问题分别过一遍。这个动作看起来繁琐但确实是降低并发 Bug 最快的方式。毕竟真等到线上线上客户来反馈“应用偶尔卡一下”的时候你能拿到的线索往往只有一段平淡无奇的线程栈。
返回列表