
1. 从被用户骂“卡死了”说起ANR 分析到底在分析什么先抛一个我自己的真实经历。早年负责一款日活过千万的资讯类 App某次灰度版本上线后应用市场评论区突然涌进大量“一打开就白屏”“点一下就没反应”的反馈。当时第一反应是查崩溃日志结果 Crash 率曲线稳得一批完全没炸。后来才意识到用户说的“卡死”绝大部分不是 Crash而是 ANR——Application Not Responding应用无响应。那段时间我们团队最大的困惑是ANR 不像崩溃那样有明确的堆栈和异常类型它是一个系统层面的“裁决结果”留给开发者的只有一份晦涩的 traces 文件和几条系统日志。光靠肉眼翻文件很难快速定位到底是哪行代码拖垮了主线程。后来我逐步建立了一套相对固定的 ANR Analysis Flow也就是从日志采集、现场还原、根因判定到线上治理的完整链路。这套流程帮我处理过十几类不同根因的 ANR 问题包括主线程 IO、锁竞争、Binder 阻塞、系统负载过高等。这篇文章就把这套方法完整拆开来讲适合正在被 ANR 困扰的 Android 开发、性能优化工程师以及刚接触系统稳定性方向的初级开发者。我不会只给结论会连同判断逻辑、日志特征、坑点一起说清楚争取让你下次拿到一份 traces 就能自己完成初步定性。先说明一点这里讨论的 ANR 分析不是指单次偶发问题的临时排查而是一个可以复用的、面向线上和线下场景的完整分析流程。流程的核心不是某个工具而是一套判断思维——拿到任何一份 ANR 现场数据都能沿着固定的路径去定位根因。2. 理解 ANR 的触发链路才能读懂日志在说什么2.1 系统的“无响应”判定机制很多人以为 ANR 是“主线程卡了 5 秒就报”这个说法不准确。系统判定 ANR 有一套独立于应用进程的超时机制不同组件类型的超时阈值完全不同。输入事件分发超时Input dispatching timed out最典型场景按键、触摸事件在 5 秒内没有被处理完系统就会弹出“xxx 未响应”对话框。广播超时Broadcast of intent timeout前台广播 10 秒、后台广播 60 秒内没有执行完 onReceive。服务超时Service timeout前台服务 20 秒、后台服务 200 秒内没有完成 onCreate、onStartCommand 等关键回调。ContentProvider 超时Provider 发布或加载超过 20 秒左右会触发 ANR。关键点在于这些超时检测是系统侧的 watchdog 机制在做的不是应用自身报出来的。所以 ANR 发生时应用进程可能还活着甚至主线程 Looper 还在正常轮转只是某个系统消息没有被及时消费。理解了这一点后面读 traces 时就不会被“主线程明明在 running”这种假象迷惑。2.2 从用户可感知异常到系统日志沉淀当系统判定 ANR 后会做三件关键事情在 ActivityManager 中记录 ANR 事件并写入 event log。收集各进程的线程堆栈快照写入 /data/anr/ 目录下的 traces 文件。将部分信息同步到 dropboxdata_app_anr方便开发者通过官方工具拉取。这三类数据就是 Analysis Flow 最核心的输入。很多开发者只盯 traces忽略了 event log 和 dropbox 里的补充信息导致一些“主线程看起来没卡”的 ANR 迟迟无法定位。我会在后面的实操环节专门讲怎么把它们结合起来看。2.3 为什么必须有一套固定的分析流程我自己早期处理 ANR 的方式是拿到 traces 后先看“main”线程如果是 sleep 就直接猜是和锁相关如果是 IO 就去看文件操作完全靠直觉碰运气。这种方式对典型的死锁问题有效但遇到系统负载高、主线程被调度器饿死这类问题时会浪费大量时间在错误方向。后来发现所有 ANR 的根因最终都可以归到三类主线程自身执行了耗时操作、主线程被其他线程的锁或 Binder 调用阻塞、系统整体资源不足导致主线程无法被调度。固定的分析流程能帮你快速判断当前 ANR 属于哪一大类再逐层递减排查空间。这就像医生看病先分科再检查而不是一上来就做全身 CT。3. 核心日志与现场数据先知道该捞什么3.1 一份 ANR 现场涉及的四类数据展开分析前先把数据源理清楚。完整的 ANR Analysis Flow 至少应该覆盖以下四类信息数据来源典型文件/通道主要作用线程堆栈/data/anr/traces.txt 或 /data/anr/ 下各进程文件定位线程状态、锁关系、执行位置系统事件event log 中的 am_anr 条目确认 ANR 类型、发生时间、进程名应用日志logcat 中应用进程相关输出还原 ANR 前的消息执行序列系统状态dropbox 中的 data_app_anr 条目、CPU 负载信息判断是否存在系统资源瓶颈实际抓取时我通常建议先执行adb shell ls /data/anr/看看有多少 traces 文件。多进程应用可能同时产生多个 trace 文件比如主进程和渲染进程各自一份。不要只看名字带主进程的那个有些 ANR 的根因线索藏在关联进程里——比如主进程在等待渲染进程的 Binder 返回此时渲染进程的 traces 才是关键。3.2 traces 文件的信息结构一份标准 traces 文件会包含多个进程的堆栈快照每个进程块有固定的信息骨架第一个关键字段是 “Cmd line”也就是进程的命令行名称。它用来确认这是不是你要分析的进程多进程应用尤其要注意。接着是 “DALVIK THREADS” 或 “Threads” 段里面列出该进程所有线程的快照。每个线程块包含线程名和线程 ID如 main prio5 tid1当前线程状态如 Runnable、Sleeping、Monitor、Native正在执行的 Java 方法调用栈如果是 Native 状态还会有一长串 native 栈这里要提醒一点traces 是系统在 ANR 那一刻通过信号机制强行停住所有线程抓取的快照所以看到的状态都是瞬时的。很多“看起来主线程在正常执行某段代码”的堆栈其实只代表它在那一刻刚好执行到哪里并不代表它已经卡了很久。要判断是“卡了很久”还是“偶发快照”必须结合 event log 里的超时时间和 trace 文件头部记录的时间戳来推理。3.3 获取现场数据时容易忽略的细节第一条经验是一定要保留 ANR 发生前后的完整 logcat。系统日志是环形缓冲区默认大小可能只有几百 KB 到 1MB如果人工复现 ANR 后再去抓关键日志可能已经被冲掉。我的习惯是在复现前先执行adb logcat -G 16M adb logcat -c把缓冲区调大并清空复现后再完整导出。如果你处理的是线上问题没有提前准备的机会那就依赖 dropbox 和 event log。第二条经验是别忽略 CPU 负载信息。traces 文件头部通常有一段 “----- pid x at 2024-xx-xx xx:xx:xx -----” 之类的信息附近可能附带系统 load average 和 CPU 占用排名。这段信息对区分“应用自身卡顿”和“系统整体繁忙”至关重要。很多线上 ANR 的根因是 CPU 被跑满应用主线程根本没有机会执行这种现象在 traces 里往往表现为主线程处于 Runnable 状态却不在执行关键方法。4. 实操从一份 traces 到根因定位的完整路径接下来进入正题。我会用三个带示例特征的现场一步步拆解分析过程。这些案例综合了我实际处理过的多类问题你可以直接套用同样的思路去分析自己的现场。4.1 场景一主线程 Runnable 但卡在文件读写先看第一份 traces 中的主线程快照简化后大致长这样main prio5 tid1 Runnable at java.io.FileInputStream.read(Native Method) at java.io.FileInputStream.read(FileInputStream.java:298) at okio.BufferedSource.readFrom(BufferedSource.kt:183) at okio.RealBufferedSource.read(RealBufferedSource.kt:196) at okio.RealBufferedSource.readByteString(RealBufferedSource.kt:15) at okhttp3.ResponseBody.string(ResponseBody.kt:253) at com.example.app.network.ApiClient.getUserInfo(ApiClient.java:88) at com.example.app.ui.MainActivity.refreshUserInfo(MainActivity.java:215) at com.example.app.ui.MainActivity.onResume(MainActivity.java:180) ...第一眼看上去这是一次很典型的“主线程做网络 IO”问题。ResponseBody.string()会把整个响应体读入内存而readByteString底层是阻塞式文件流读取。如果接口响应慢或数据量大主线程就会卡在这里。但这里有个容易误判的点主线程状态显示 Runnable并不代表它真的在“忙”。Runnable 表示线程可被调度但如果底层阻塞在 Native 的 read 调用上线程在 Java 层的状态依然是 Runnable。所以遇到 Runnable 文件/网络读写栈的组合优先怀疑阻塞式 IO。这类问题在旧版本上尤其常见因为早期很多网络库的execute()方法直接在调用线程执行回调。解决方向有两个一是把网络请求切到子线程或使用异步接口二是对必须同步执行的调用加超时控制避免无限期阻塞。从线上治理角度比较有效的拦截手段是在主线程 Looper 的消息分发处做监控统计每条消息的执行耗时任何超过 500ms 的消息都单独上报堆栈。这类监控能帮你在用户投诉前先发现问题。4.2 场景二主线程 Monitor 状态等待锁第二份例子是主线程处于 Monitor 状态main prio5 tid1 Monitor - waiting to lock 0x0b6a2d10 (a java.lang.Object) at com.example.app.manager.CacheManager.readCache(CacheManager.java:58) - waiting to lock 0x0b6a2d10 (a java.lang.Object) held by thread pool-2-thread-1 (tid27) at com.example.app.manager.CacheManager.getData(CacheManager.java:102) at com.example.app.ui.DetailActivity.loadData(DetailActivity.java:66) ...这一看就非常清晰了主线程在等待一把被pool-2-thread-1持有的锁。下一步判断的唯一重点就是持有锁的线程在做什么继续往下翻 traces找到 tid27 对应的线程块pool-2-thread-1 prio5 tid27 Native at android.os.MessageQueue.nativePollOnce(Native Method) at android.os.MessageQueue.next(MessageQueue.java:332) at android.os.Looper.loop(Looper.java:183) ...这是一个典型的“锁被闲线程持有”的场景。持有锁的线程本身没有在做耗时任务它只是进入 Looper 空闲等待但因为代码逻辑没有在 finally 中释放锁或者锁的作用范围过大导致主线程需要这把锁时被无限期阻塞。这类问题的修复比较直接检查synchronized块是否覆盖了不必要的范围把锁粒度缩小如果锁确实需要长期持有考虑用ReentrantLock加超时tryLock避免永久等待再一个就是确认是否有线程在持有锁时进入 Looper 轮询这种情况几乎都是代码结构问题。这里有一个我踩过的坑只看到主线程 waiting to lock 就兴奋地以为是死锁然后去全文件找两个线程互相持有的证据。但实际上很多“主线程等锁”的 ANR 根本不是死锁而是锁被一个长期空闲的线程占着不放。死锁需要两个及以上的线程循环等待而这里只有一个方向的等待。定位时先区分死锁和单方向阻塞能省下大量时间。4.3 场景三主线程阻塞在 Binder 调用第三种典型的 traces 形态主线程停在 Binder 通信上main prio5 tid1 Native at android.os.BinderProxy.transactNative(Native Method) at android.os.BinderProxy.transact(BinderProxy.java:542) at android.app.IActivityManager$Stub$Proxy.getContentProvider(IActivityManager.java:4651) at android.app.ActivityThread.acquireProvider(ActivityThread.java:5681) at android.app.ContextImpl.getContentResolver(ContextImpl.java:2347) ...这类 ANR 的逻辑链路比较长主线程通过 Binder 向 system_server 请求某个 ContentProvider而 system_server 那边可能因为 Provider 所在的进程没有启动、正在冷启动或者 Provider 的onCreate超时导致这个 Binder 请求长时间得不到返回。处理这类问题不能只盯着应用自身代码需要把视线扩大到系统侧。我常用的排查路径是先确认目标 Provider 属于哪个进程用adb shell ps -A | grep 包名看该进程是否存在。如果进程不存在看 Provider 所在进程的启动日志确认是否存在onCreate中的耗时初始化比如建库、做网络请求。如果进程存在但响应慢用adb shell dumpsys activity providers查看 Provider 的连接状态和时间。这类问题的一个典型修复案例某 App 的ContentProvider的onCreate中做了 SharedPreferences 写入和数据库升级冷启动路径上主线程首次访问 Provider 时被长期阻塞。后来把重量级初始化移到子线程并用启动后的第一个空闲消息去预加载 ProviderANR 直接清零。4.4 用 event log 还原 ANR 时间线光靠堆栈快照还不够最好配合 event log 来还原完整时间线。导出事件日志的命令adb logcat -b events -d | grep -i anr典型输出会包含类似这样的条目am_anr: [0,12345,com.example.app,123456789,Input dispatching timed out, ...]这条记录会明确告诉你 ANR 类型是 Input dispatching timed out 还是 Broadcast timeout以及 ANR 发生的精确进程和时间。拿到这个时间后再回到应用的 logcat 里看主线程在这个时间点之前最后执行了什么。很多主线程耗时操作不会在 traces 里留下“卡住”的痕迹但它会在前面的 log 里暴露出最后一次业务操作比如一条网络请求日志、一次数据库访问日志。这些日志和 ANR 时间点之间的间隔往往就是主线程被卡住的真实时长。我的习惯是把 event log 里的 ANR 时间、traces 文件头部的时间戳、logcat 里应用最后一条业务日志的时间戳放在一起画一条简单的线性时间轴不用工具纸上画都行。如果最后一条业务日志离 ANR 时间很近说明主线程在 ANR 前刚执行过某个操作线索就在那个操作附近如果差距很大说明主线程早就空闲了可能是输入事件没被及时分发重点要去查 InputDispatcher 相关的系统状态。4.5 从“卡住”到“为什么卡住”需要补的一层信息很多时候traces 只告诉我们主线程停在哪里但没说为什么停在那里。比如主线程停在Looper.loop的nativePollOnce说明它此刻是空闲的那 ANR 是怎么触发的答案往往在消息队列里积压了尚未处理的消息。这种情况下需要开启 Looper 消息日志来辅助分析。可以在应用里通过Looper.setMessageLogging()打印每条消息的分发耗时或者使用官方提供的Debug.startMethodTracing()在 ANR 复现时抓取方法调用轨迹。前者能看到“哪条消息耗时多少”后者能看到“耗时期间代码在做什么”。建议在测试包中默认开启耗时长消息的堆栈采样线上包再通过配置开关控制。毕竟全量开启方法跟踪会严重影响性能不适合直接上线上。实操经验是遇到主线程空闲但系统报 ANR 的案件优先去查主线程消息队列里是否有延迟消息或高频任务占用了 Looper 的分发能力。比如大量postDelayed任务、while循环中不断post消息会撑爆消息队列导致正常输入事件排不上号。5. 常见问题与专项场景排查技巧5.1 拿到 traces 但主线程看起来无害怎么办这是最高频的困惑。有些 traces 主线程停在nativePollOnce旁边也找不到明显异常线程甚至DALVIK THREADS段短得可怜。这种情况大概率是抓取时机偏晚或系统裁剪了不相关的线程信息。排查方向有两条看 traces 文件头部的 CPU 负载信息。如果 load average 已经很高比如 8 核设备 load 超过 12说明系统整体过载CPU 调度不过来主线程即使没被执行耗时操作也拿不到时间片。这类 ANR 的根因不在应用代码而在全局资源竞争。看其他关联进程的 traces。一个应用可能同时有多个进程服务进程的时时任务可能拖垮整机 CPU间接导致前台进程的输入事件超时。这种情况常发生在多进程架构的 App 中主进程看起来冤枉但锅确实在同一个应用的兄弟进程。5.2 常见 ANR 类型速查表ANR 类型典型超时traces 里常见主线程状态优先排查方向Input dispatching timed out5 秒Runnable / Native / 任意输入事件处理链路、主线程消息阻塞Broadcast of intent timeout前台 10 秒 / 后台 60 秒onReceive 执行栈onReceive 中的耗时操作、串行广播排队Service timeout前台 20 秒 / 后台 200 秒onCreate / onStart 执行栈服务启动初始化耗时ContentProvider timeout约 20 秒BinderProxy.transact 或 Provider 相关栈Provider 所在进程启动速度、onCreate 耗时CPU 抢占型 ANR不固定主线程 Runnable 但不在关键代码系统负载、其他进程 CPU 占用这个表格不是让你背的而是指导排查方向。看到 ANR 类型先对应到表格里的优先排查方向通常能节约一半以上的排查时间。5.3 线上 ANR 的监控与治理落地线下复现 ANR 只是基本功真正考验团队的是线上治理。业内比较成熟的方案基本都围绕两个思路展开第一个思路是主线程消息监控。通过自定义Printer注入 Looper统计单条消息执行耗时超过阈值就抓取当前堆栈并上报。这套方案的优点是成本低能覆盖所有主线程耗时问题缺点是抓到的堆栈只是“超时那一刻”的采样不一定能命中耗时最深处。第二个思路是文件 IO 与锁监控。对常见的文件读写、SharedPreferences 操作做插桩统计单次调用耗时对锁竞争则通过synchronized监控或ReentrantLock的带超时能力来及时发现。这类方案通常需要接入插桩工具链维护成本高一些但定位精度高。实操建议是不要一上来就搞全量插桩先在线上开启 Looper 消息监控和 ANR 现场的 traces 自动上报把这两条基础通道跑通再逐步针对高频场景做更深层的插桩。稳定性治理最忌讳一次性铺太大既难保证稳定性又难评估效果。5.4 另一个常被忽略的细节ANR 与内存压力的关系有一种特殊的 ANR 场景看起来和代码无关但频繁出现在低内存设备上系统可用内存不足触发 LMK 或频繁 GC主线程虽然没在干活却被 GC 或内存分配拖慢。这类问题在 traces 里有时能看到主线程停在art::gc相关的 Native 栈上或者大量线程处于Waiting状态等待 GC 完成。处理方向不能只盯着 ANR 本身还需要看内存占用状况adb shell dumpsys meminfo 包名查看进程内存构成。关注 Bitmap 缓存、WebView 内存、LeakCanary 检测等是否存在异常。检查是否有频繁的大对象分配导致的 Alloc 锁竞争。这里有个我实际遇到过的例子一个图片加载库在低内存设备上频繁触发OutOfMemoryError导致进程崩溃但崩溃前会有大量 GC 引发主线程长时间停顿最终先报了 ANR。修复图片压缩策略和缓存上限后ANR 和 Crash 一起下降了。所以做 ANR 分析时备一份进程内存快照能避免走弯路。6. 一些用血泪换来的经验处理 ANR 分析这么长时间有几条经验始终排在第一位。第一永远先看系统负载再看线程状态。我见过太多人一上来就死磕主线程栈分析半天发现系统 load 已经高到连 system_server 都在排队这根本不是应用能控制的。先花 10 秒看 CPU 信息能过滤掉至少三成“假 ANR”。第二多进程应用一定要收集全所有进程的 traces。很多和 Binder 相关的 ANR问题堆栈不在应用主进程而在服务进程或者渲染进程。只拉主进程 traces 会让你永远找不到真凶。第三不要试图在一次分析里解决所有问题。ANR 往往是多种因素叠加的结果比如内存高、GC 频繁、主线程恰好在做一次大文件读取。这种情况很难用“把读取切到子线程”一键解决需要制定一个优先级列表按影响面从大到小逐步改进每轮只验证一个变量。第四线上 ANR 一定要有现场上报通道。系统原生生成的 traces 只能保存在设备本地普通用户不会帮你导出。成熟的方案是在应用层监听ApplicationExitInfoANR 被杀死后自动把 traces 相关数据和 dropbox 信息回传到后台。这套通道越早建立后续的治理越有数据支撑。最后再分享一个我一直保留的调优小工具给测试机开启Settings Developer options Show all ANRs同时在 adb 端保持 longcat 日志输出到本地文件。偶发问题复现时这个组合能让你第一时间拿到完整现场而不是等问题过去了再翻各种残留日志。做 ANR 分析如同治慢性病不可能一蹴而就。把 Analysis Flow 沉淀成团队的标准动作每一次线上事故都会变成一次能力积累。如果你手里正攒着一份看不懂的 traces不妨按这篇文章的思路重新过一遍大概率会有新的发现。