尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android FD 泄露问题总结:从排查到修复的完整实践

Android FD 泄露问题总结:从排查到修复的完整实践 1. Android FD 泄露问题到底难在哪从崩溃堆栈到排查思路Android FD 泄露问题总结这件事真正让人头疼的地方不在于修复而在于定位。文件描述符File Descriptor在 Linux 里是一个非负整数索引指向内核为进程维护的打开文件表。Android 沿用了这套机制每个进程默认上限是 1024 个 FD。一旦某个进程持续打开文件、Socket、Cursor、HandlerThread 的 eventfd 却不释放FD 数量就会缓慢爬升直到触顶后进程直接崩溃。它和内存泄漏最大的区别是FD 泄露往往不会触发 GC也不会立刻表现为内存不足。你可能跑几个小时甚至几天才复现一次crash 堆栈还每次都不一样——有时是Too many open files有时是Could not allocate JNI Env有时是Could not read input channel file descriptors from parcel。这些堆栈只是最后一根稻草真正泄露的代码可能在完全不相干的地方。我试过在稳定性测试里追一个 FD 泄露堆栈指向 WifiNative查了半天发现是另一个模块反复 new FileOutputStream 没关。所以排查 FD 泄露的核心思路是不要盯着崩溃堆栈要盯着 FD 的增长曲线和类型分布。这篇会从常见泄露源头讲起给出可复制的监控脚本、adb 排查命令以及验证修复是否生效的完整步骤适合做 Android 稳定性、性能优化的同学直接跟做。FD 泄露的典型场景包括输入输出流未关闭、Cursor 未 close、HandlerThread 反复创建、Java 线程生命周期失控、InputChannel 随 Window 异常增长、Bitmap 通过 RemoteViews 频繁 IPC 等。下面逐个拆开讲每个都给出可复现的代码和对应的排查命令。2. 常见 FD 泄露源头与 adb 排查命令实战2.1 输入输出流未关闭导致的 FD 泄露最常见也最容易被忽视的就是流没关。每次new FileInputStream、new FileOutputStream、FileReader、FileWriter内核都会分配一个 FD 指向打开的文件。如果反复执行却不 closeFD 会持续增长。// 错误示范反复执行会持续泄露 FD String fileName prefix temp; File file new File(getCacheDir(), fileName); try { file.createNewFile(); FileOutputStream out new FileOutputStream(file); // 没有 close异常路径下更不会关 } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }正确做法是用 try-with-resources无论中途是否异常都会关闭// 正确示范try-with-resources 自动关闭 String fileName prefix temp; File file new File(getCacheDir(), fileName); try (FileOutputStream out new FileOutputStream(file)) { file.createNewFile(); out.write(data); } catch (IOException e) { e.printStackTrace(); }排查时用这条命令看进程打开的 FD 列表# 查看指定进程的所有 FD-l 显示软链接指向 adb shell ls -l /proc/pid/fd/如果看到大量指向/data/data/pkg/cache/xxx_temp的 FD基本可以确认是流没关。注意即使指向同一个文件每次 new 也会创建新的 FD所以列表里会出现多个指向同一路径的条目。2.2 Cursor 未关闭导致的 FD 与内存双重泄露通过 ContentResolver 查询数据库返回的 Cursor底层在 native 层用 CursorWindow 保存数据而 CursorWindow 本质是共享内存的文件映射会占用 FD。CursorWindow 默认大小 2MB用不好不仅泄露 FD还会泄露内存。// 错误示范Cursor 用完不关 Cursor cursor getContentResolver().query(uri, null, null, null, null); if (cursor ! null cursor.moveToFirst()) { // 处理数据 } // 忘记 cursor.close()正确写法// 正确示范finally 中关闭 Cursor Cursor cursor null; try { cursor getContentResolver().query(uri, null, null, null, null); if (cursor ! null cursor.moveToFirst()) { // 处理数据 } } finally { if (cursor ! null) { cursor.close(); } }当 logcat 里出现Could not allocate CursorWindow或Cant open DB due to too many open files时优先怀疑 Cursor 泄露。2.3 HandlerThread 反复创建导致的 eventfd 泄露HandlerThread 自带 Looper而 Looper 初始化时需要 FD 资源。每创建一个 HandlerThread会消耗一对 FDeventFd和epollFd用于线程间通信。如果某个函数被反复调用导致 HandlerThread 反复创建FD 会成对增长。// 错误示范每次调用都新建 HandlerThread public void doWork() { HandlerThread handlerThread new HandlerThread(worker); handlerThread.start(); Handler handler new Handler(handlerThread.getLooper()); handler.post(() - { /* ... */ }); // 没有 quit线程和 FD 都留着 }正确做法是在不需要时调用quitSafely()// 正确示范用完销毁 Looper HandlerThread handlerThread new HandlerThread(worker); handlerThread.start(); Handler handler new Handler(handlerThread.getLooper()); handler.post(() - { /* ... */ }); // 任务结束后 handlerThread.quitSafely();排查时看 FD 列表里anon_inode:[eventpoll]和anon_inode:[eventfd]是否异常多# 统计 eventfd / eventpoll 类型 FD 的数量 adb shell ls -l /proc/pid/fd/ | grep -E eventfd|eventpoll | wc -l如果这个数字达到几百基本可以确认是 HandlerThread 或 Looper 相关泄露。再配合ps -T pid看线程数量如果某个线程名重复出现几百次就能定位到具体代码。2.4 Java 线程与 InputChannel 相关的 FD 泄露Java 线程创建时也需要 FD 资源。线程生命周期管理不当起得太多同样会耗尽 FD。典型报错是java.lang.OutOfMemoryError: Could not allocate JNI Env或pthread_create (1040KB stack) failed。InputChannel 是另一个高频泄露点。应用的输入事件由 WindowManagerService 管理WMS 内部通过 InputChannel 与 InputManager 通信每个 InputChannel 底层是一对 socket 文件read 端和 write 端各占一个 FD。每次 addWindow 都会创建 InputChannel如果 Window 异常添加却不销毁socket FD 就会堆积。复现代码很简单点击按钮不断弹 AlertDialog// 反复弹 Dialog 会不断创建 Window 和 InputChannel findViewById(R.id.btn).setOnClickListener(v - { new AlertDialog.Builder(MainActivity.this) .setTitle(test) .setMessage(fd leak demo) .show(); });弹几十次后就会看到Could not read input channel file descriptors from parcel。排查用# 查看 Window 列表找异常重复的 Window adb shell dumpsys window windows | grep -E Window #|mOwnerUid # 统计 socket 类型 FD 数量 adb shell ls -l /proc/pid/fd/ | grep socket | wc -l如果 dumpsys window 里同一个 Activity 出现几十个 Window 条目问题就明确了。2.5 Bitmap 通过 RemoteViews 更新通知导致的跨进程 FD 泄露Bitmap 在 IPC 传递时实际传递的是指向匿名共享内存ashmem的 FD而不是整个 Bitmap 对象。用 RemoteViews 频繁更新带 Bitmap 的通知时app 把通知 IPC 给 system_serversystem_server 再转给 SystemUI中间涉及多次 FD 传递。更新太频繁会导致本进程、system_server、SystemUI 三方的 FD 都增长严重时 system_server 崩溃重启。典型报错是Could not copy bitmap to parcel blob。排查时看 FD 列表里/dev/ashmem是否异常多# 统计 ashmem 类型 FD 数量 adb shell ls -l /proc/pid/fd/ | grep ashmem | wc -l再配合 logcat 搜 notification 相关日志如果发现大量通知更新基本可以定位。阅读类、计步类 App 频繁更新通知栏的嫌疑最大。3. 可复制的 FD 监控脚本与配置片段光靠手动敲命令效率太低稳定性测试需要自动化监控。下面给一个可复制的 shell 脚本定时采样指定进程的 FD 数量超过阈值就抓取现场信息。#!/system/bin/sh # fd_monitor.sh - 定时监控 FD 数量超阈值抓现场 # 用法: sh fd_monitor.sh package_name threshold interval_sec PKG$1 THRESHOLD${2:-900} INTERVAL${3:-10} OUTDIR/sdcard/fd_monitor mkdir -p $OUTDIR while true; do PID$(pidof $PKG) if [ -z $PID ]; then echo [$(date)] $PKG not running $OUTDIR/monitor.log sleep $INTERVAL continue fi FD_COUNT$(ls /proc/$PID/fd/ 2/dev/null | wc -l) echo [$(date)] pid$PID fd_count$FD_COUNT $OUTDIR/monitor.log if [ $FD_COUNT -gt $THRESHOLD ]; then TS$(date %Y%m%d_%H%M%S) # 抓 FD 列表 ls -l /proc/$PID/fd/ $OUTDIR/fd_$TS.txt 21 # 抓线程信息 ps -T $PID $OUTDIR/threads_$TS.txt 21 # 抓 Window 信息 dumpsys window windows $OUTDIR/window_$TS.txt 21 # 抓 logcat 快照 logcat -d -t 2000 $OUTDIR/logcat_$TS.txt 21 echo [$(date)] threshold exceeded, dumped to $OUTDIR/*_$TS.txt $OUTDIR/monitor.log fi sleep $INTERVAL done推送到设备并运行adb push fd_monitor.sh /data/local/tmp/ adb shell chmod x /data/local/tmp/fd_monitor.sh adb shell sh /data/local/tmp/fd_monitor.sh com.example.app 900 10除了脚本还可以在应用层做 FD 数量自监控。下面这段代码可以在 Debug 包或稳定性测试包里定期打印 FD 数量// FdWatcher.java - 应用内 FD 数量监控 public class FdWatcher { private static final String TAG FdWatcher; private static final int WARN_THRESHOLD 800; public static int getFdCount() { File fdDir new File(/proc/self/fd); File[] files fdDir.listFiles(); return files null ? -1 : files.length; } public static void startWatch(final long intervalMs) { new Thread(() - { while (true) { int count getFdCount(); if (count WARN_THRESHOLD) { Log.w(TAG, FD count high: count); dumpFdInfo(); } else { Log.d(TAG, FD count: count); } try { Thread.sleep(intervalMs); } catch (InterruptedException e) { break; } } }, FdWatcher).start(); } private static void dumpFdInfo() { try { File fdDir new File(/proc/self/fd); File[] files fdDir.listFiles(); if (files null) return; for (File f : files) { try { String target android.system.Os.readlink(f.getAbsolutePath()); Log.w(TAG, fd f.getName() - target); } catch (Exception ignored) {} } } catch (Exception e) { Log.e(TAG, dumpFdInfo failed, e); } } }在 Application 的 onCreate 里启动// 仅在 Debug 或稳定性测试包中启用 if (BuildConfig.DEBUG) { FdWatcher.startWatch(30_000); }如果你在排查过程中需要借助大模型辅助分析堆栈和日志可以把抓到的 FD 列表、logcat 片段整理成文本通过 TaoToken 的模型对话能力做归类分析快速判断是哪种类型的 FD 在增长。它的 API 地址是https://taotoken.net/api模型对话入口在https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel_chat适合把杂乱的 FD 输出丢进去做模式识别。对于需要长期跑稳定性测试、反复分析日志的团队可以考虑用 Coding Plan 把监控脚本、日志解析、报告生成串成自动化流程入口在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_plan。4. 验证 FD 泄露是否修复的完整操作步骤改完代码不能只看感觉好了要有可量化的验证流程。下面这套步骤可以直接跟做。第一步在修复前先建立基线。跑一轮稳定性测试用第 3 节的脚本记录 FD 增长曲线# 启动监控 adb shell sh /data/local/tmp/fd_monitor.sh com.example.app 900 10 # 另开终端持续观察日志 adb shell tail -f /sdcard/fd_monitor/monitor.log记录下从启动到崩溃或到测试结束的 FD 数量变化。如果 FD 从 200 涨到 1024 然后崩溃说明泄露稳定复现。第二步应用修复代码后用同样的测试用例、同样的时长再跑一轮。对比两次的 FD 曲线# 拉取监控日志到本地对比 adb pull /sdcard/fd_monitor/monitor.log ./after_fix.log如果修复有效FD 数量应该在一个区间内波动比如 200 到 350 之间来回而不是单调递增。这是判断修复是否生效的核心指标。第三步针对具体泄露类型做定向验证。如果是流没关反复触发相关业务逻辑 500 次然后检查 FD# 触发业务后检查 FD 数量 adb shell ls /proc/$(pidof com.example.app)/fd/ | wc -l如果数量稳定不涨说明流关闭正确。如果是 HandlerThread 泄露反复调用相关函数后检查 eventfd 数量adb shell ls -l /proc/$(pidof com.example.app)/fd/ | grep -c eventfd如果是 InputChannel 泄露反复弹 Dialog 后检查 socket 数量和 Window 数量adb shell ls -l /proc/$(pidof com.example.app)/fd/ | grep -c socket adb shell dumpsys window windows | grep -c com.example.app第四步做长时间压测。FD 泄露有时增长很慢短时间测试看不出来。建议至少跑 2 小时以上的 Monkey 或稳定性测试期间每 10 秒采样一次 FD 数量最后用脚本分析趋势# 分析 FD 增长趋势提取 fd_count 列 grep fd_count /sdcard/fd_monitor/monitor.log | \ awk -Ffd_count {print $2} | \ awk NR1{first$1} {last$1} END{print firstfirst lastlast deltalast-first}如果 delta 接近 0 或为负说明没有持续泄露。如果 delta 是几百说明还有泄露点没找到。第五步验证 system_server 是否受影响。如果是 Bitmap 通知类泄露还要检查 system_server 的 FDadb shell ls /proc/$(pidof system_server)/fd/ | wc -l正常情况下 system_server 的 FD 数量应该在几百到一千多之间波动如果持续增长到接近上限说明跨进程泄露还在。5. 本篇常见报错与排查对照排查 FD 泄露时logcat 里的报错信息是最重要的线索。下面把常见报错和对应的排查方向整理成对照表遇到时可以直接查。报错信息可能原因排查命令Too many open files通用 FD 耗尽ls -l /proc/pid/fd/ | wc -lCould not allocate JNI Env线程创建时 FD 不足ps -T pid看线程数Could not read input channel file descriptors from parcelInputChannel/Window 泄露dumpsys window windowsCould not open input channel pair. status-24socket FD 耗尽ls -l /proc/pid/fd/ | grep socketCould not allocate CursorWindowCursor 未关闭检查 ContentResolver 查询代码Could not copy bitmap to parcel blobBitmap IPC 频繁ls -l /proc/pid/fd/ | grep ashmempthread_create failed: Try again线程栈内存或 FD 不足ps -T pid FD 列表dup failed in Parcel::readFD 已满无法复制立即抓 FD 列表和 logcat几个容易踩的坑第一个坑是只看崩溃堆栈。FD 泄露的堆栈往往指向最后一个申请 FD 失败的地方而不是泄露源头。比如堆栈指向 WifiNative.startHal但实际泄露可能在某个图片加载库。正确做法是结合 FD 类型分布和业务日志一起看。第二个坑是 FD 满了之后读不到 FD 列表。当进程 FD 达到上限时ls /proc/pid/fd/本身也需要 FD可能读失败。这时可以先提高进程的 FD rlimit或者用readlink逐个读# 提高 FD 上限需要 root 或 debuggable 包 adb shell ulimit -n 4096第三个坑是忽略 ashmem 和 dmabuf。这两个类型的 FD 增长往往和图形、Bitmap、Surface 相关容易被当成正常现象。如果 ashmem 数量达到几百一定要查是不是 Bitmap 或 Surface 没释放。第四个坑是 Android O 之后 NE 的 Tombstone 文件。Native 崩溃时 Tombstone 里会记录 open files可以直接看到崩溃瞬间打开的 FDadb shell ls /data/tombstones/ adb shell cat /data/tombstones/tombstone_00 | grep -A 50 open files第五个坑是复现困难时没有提前埋点。建议在 Application 里复写UncaughtExceptionHandler在 crash 前把 FD 列表、线程信息、Window 信息 dump 到文件// 在 crash 前抓取 FD 现场 Thread.setDefaultUncaughtExceptionHandler((t, e) - { FdWatcher.dumpFdInfo(); // 再交给默认 handler });这样即使问题难复现也能在崩溃时留下现场。如果日志量太大不好人工分析可以把 dump 出来的 FD 列表和堆栈整理后通过 TaoToken 的模型对话做批量归类快速识别出重复出现的 FD 类型和可疑调用链。API Keys 在https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi_keys申请接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc里面有完整的请求示例和参数说明。6. 把 FD 监控接入日常稳定性流程FD 泄露排查最有效的方式不是等崩溃了再查而是把它变成稳定性测试的常规监控项。具体做法是在 Monkey 或自动化测试启动时同步拉起 FD 监控脚本测试结束后自动生成 FD 趋势报告。一个实用的流程是测试开始前记录基线 FD 数量测试过程中每 10 秒采样测试结束后对比首尾差值。如果差值超过 100就标记为可疑自动拉取 FD 列表和 logcat 供人工分析。这套流程可以完全脚本化不需要人工盯着。对于需要长期维护的项目建议把 FD 监控和日志分析做成自动化管道。抓到的 FD 列表、线程信息、Window 信息可以结构化后交给大模型做初步分类把明显正常的 FD比如固定的几个 socket、标准输入输出过滤掉只保留可疑的增长项。这样能把人工分析的工作量降低一个数量级。Coding Plan 适合把这类重复性的日志解析和报告生成任务固化下来入口在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_plan。最后提醒一点FD 泄露的修复往往不是一劳永逸的。随着业务迭代新的流、新的 Cursor、新的线程会不断引入。把监控脚本和验证步骤固化到 CI 或稳定性测试流程里比每次出问题再临时排查要高效得多。真正踩过的坑是修完一个泄露点以为万事大吉结果两周后另一个模块又引入了新的泄露。持续监控才是根本解法。
返回列表