近3个月,抖音 Android 版面临一个多次触发线上报警的崩溃问题,全量版本和灰度版本的异常数据激增,该问题不仅容易触发报警,更成为了 Java Top 1 崩溃问题,带来巨大困扰,急需攻坚解决。
本文展现了具体的分析过程、优化思路和解决方案,同时提供了已集成该方案的实用工具。
我们以某发版期间数据为例进行分析:
samsung sm-s9180 占比 top1
。只有 10 以上版本,12/13/10 占比 top3
。通过对多维特征分析,我们得出结论是:存在 OS 高版本和三星机型聚集特征。
从下图我们看到,崩溃类型 TransactionTooLargeException,崩溃现场在 BinderProxy.transactNative 方法附近,崩溃提示消息 data parcel size xxx bytes
,崩溃时 Activity 在向 AMS 传递 stop 信息。初步分析这些信息,我们得出结论:
除了崩溃堆栈,我们也要分析崩溃日志,这样往往能够从中获得一些有价值的线索。以下,我们分别从聚合 logcat 日志和单次 logcat 日志进行分析。通过 应用性能监控全链路版(APMPlus)App端监控 的 logcat 词云,看到聚合日志 top1 是 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **
。这个是系统 Binder 传输数据失败打印的日志。
我们从源码里面找到打印日志的地方,发现是在 Native 层 Binder.cpp 里面,接收到 Binder 驱动的错误码 FAILED_TRANSACTION 之后,先打印错误日志,然后通过 jniThrowException 函数从 JNI 层进入 Java 层抛出异常。
通过对聚合 logcat 日志分析,我们得到结论:只有系统 Binder 传输失败日志,无业务相关日志,不确定是哪个业务不合理导致的崩溃。根据前面聚合 locat 日志关键字,我们翻看了几十个上报日志,发现在 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **
的后面紧接着出现 Tag 为 ActivityStopInfo
的 Bundle stats:
日志,其中一个如图所示。因此这时我们知道 Binder transaction 传输的 parcel 内容很大可能跟 Bundle 有关。
从打印的 Bundle stats:
树形结构看,只打印了系统的 view 和 fragment 占用 size,没有 App 业务相关占用 size,无法细分是哪里不合理。另外在日志 JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = **
的前面我们终于发现了跟业务相关的 Activity 是 com.ss.android.ugc.aweme.detail.ui.DetailActivity
。翻看了几十个上报日志,并不是每个日志都是这个 com.ss.android.ugc.aweme.detail.ui.DetailActivity
,还有 com.ss.android.ugc.aweme.shortvideo.ui.VideoPublishActivity
等。
经过对单次 logcat 日志分析,能够从单次 logcat 日志中知道是哪个 Activity stop 时出错,但是目前分析不出这些处于 stop 状态的 Activity 占比情况。
综合聚合和单次 logcat 日志看,无明显 App 业务逻辑聚合,只有系统日志,无法简单定位到某个业务解决。
经过前面的分析,我们得出初步结论:
因此,需要进行深入分析,一方面要从监控归因着手排查其中不合理的因素,另一方面也要从底层通用解决方案尝试突破限制彻底解决问题。
下面将主要从崩溃堆栈进行深入分析,翻了几十个崩溃堆栈,我们发现并不是只有 Activity stop 调用栈,还有 Activity start 调用栈,主要包含两类,一个是 activityStopped 关键字的崩溃,占比约 97.25%;另一个是 handleStartActivity 关键字的崩溃,占比约 2.75%,这些崩溃主要与三星兼容性问题有关。
因此,我们判断在有限的时间和精力下,先解决 top 的 activityStopped 关键字的崩溃,以下主要是对 activityStopped 分析。如图所示,我们接下来从底层向上层对核心逻辑崩溃堆栈进行逐步分析。
根据关键字 data parcel size
和相关源码分析,我们得出 data parcel size
附近的数据处理流程:
"data parcel size %d bytes"
。"Transaction failed on small parcel; remote process probably died, but this could also be caused by running out of binder buffer space"
,意思是 Binder 传输小的数据量情况下也可能失败,原因有两个:第一是远程进程可能已经死亡,Binder 驱动找不到远程进程对应的 mmap 虚拟内存,也就无法完成 Binder 数据传输;第二是远程进程还存活,但是它的 Binder mmap 接收缓冲区已经被用完,无法分配空闲的 mmap 虚拟内存,也就无法完成 Binder 数据传输。通过对错误消息分析,我们得出结论:
前面我们在 signalExceptionForError 函数中读取这个 FAILED_TRANSACTION 错误码,接下来我们要知道其写入的地方在哪里,以便理顺数据处理流程。通过关键字查找,我们发现这个 FAILED_TRANSACTION 错误码只在 IPCThreadState::waitForResponse 函数中写入。对应有两种情况会写入:BR_FAILED_REPLY 回复失败,BR_FROZEN_REPLY 冻结回复。
这两个 cmd 对应的值是从 mIn.readInt32
读取出来,也就是 Parcel 中读取出来的命令值。
我们依然循着错误码传递路径来到 Binder 驱动层。
BR_FAILED_REPLY 错误码在 binder 驱动中有 40 个引用,过于宽泛,没有唯一性,导致继续使用这个排查方向的 ROI 不高,因此暂停这个排查方向。
http://aospxref.com/kernel-android13-5.10-lts/more/drivers/android/binder.c?full=BR_FAILED_REPLY
BR_FROZEN_REPLY 错误码在 binder 驱动中有 1 个引用,指进程/线程被冻结情况,主要用于区分进程死亡时 Binder 无法回复 BR_DEAD_REPLY。我们这个 Activity stop 场景用户大概率还在前台,App 和 System Server 不太可能出现进程被冻结,因此暂停这个排查方向。
http://aospxref.com/kernel-android13-5.10-lts/xref/drivers/android/binder.c#6298
除了错误码之外,我们结合源码得出 BinderProxy.transactNative 执行流程如下:
前面我们分析了崩溃类型 TransactionTooLargeException、崩溃消息 data parcel size xx bytes、和堆栈最底层函数 BinderProxy.transactNative 数据处理流程之后,得出的结论是:该崩溃是Binder 传输数据超过 200 * 1024 时出现的崩溃。
由于 BinderProxy.transactNative 是底层通用函数,它的调用方有很多,我们需要快速确定这个崩溃场景最大的 Context 是在哪个里面,所以我们直接跳到分析主线程消息处理里面业务根消息PendingTransactionActions$StopInfo.run 的分析。
通过下图源码,我们发现 App 进程通过 Binder IPC 通知 System Server 进程的 AMS Activity Stop 信息时,有个 catch RemoteException 异常捕获逻辑。
通过先打印 Bundle 统计数据,后进行异化逻辑处理,我们看到 targetSdk < 24 情况下,程序打印了一行日志 App sent too much data in instance state, so it was ignored
,然后 return 返回,并没有执行 throw 异常。所以推测系统在这种情况下其实不崩溃。
使用 App 监控统计分析到,6/7/10系统没有崩溃,这个和源码逻辑可以匹配上。在 targetSdk >= 24 之后,把捕获的异常重新通过 throw 关键字抛出,这种情况下会出现崩溃。经过回溯 StopInfo 中的 Bundle 来自Activity.onSaveInstanceState :
因此,我们得出崩溃场景是:
前面我们分析崩溃场景 Binder 传输数据和 Activity.onSaveInstance 的 Bundle 数据有关,接下来我们分析这个 Bundle 数据恢复处理流程。
经过前面分析,我们知道 Binder 传输数据量超限是 Activity.onSaveInstance 保存的 Bundle 过大导致。但是我们并不知道 Bundle 超过多大会崩溃,比如 Bundle 超过 200K、500K、1M 会崩溃吗?
为了分析崩溃执行过程,在 demo App 中 Activity.onSaveInstanceState 保存大数据,并使用 root 机器分析崩溃场景日志:
// 4692 是发送方 app 进程 pid,1838 是接收方 system server 进程 pid。接收方接收数据时,遇到分配 4198824 虚拟内存失败,没有足够的虚拟地址空间
4692 4692 E binder_alloc: 1838: binder_alloc_buf size 4198824 failed, no address space
4692 4692 E binder_alloc: allocated: 2240 (num: 8 largest: 2080), free: 1038144 (num: 4 largest: 1036672)
4692 4692 I binder : 4692:4692 transaction failed 29201/-28, size 4198812-8 line 3317
4692 4692 E JavaBinder: !!! FAILED BINDER TRANSACTION !!! (parcel size = 4198812)
结合源码,我们得出 BinderProxy.transactNative 执行流程是这样:
int
reply
识别区分是否是发送数据还是接收数据。因此,TransactionTooLargeException 直接原因是接收方进程 Binder mmap 接收缓冲区无法分配足够大的空闲内存。
前面我们只传输了约 500K 大小的数据就遇到了 TransactionTooLargeException 问题。那么
通过 binder 驱动底层源码分析,单进程 Binder mmap 分析最大值 4MB,但是还受到参数 vma 控制。
而从整个 Binder 架构初始化流程分析,这个参数来自 ProcessState 里面,最大值是 1M - 8K。
这两个值里面取最小值作为 Binder 传输数据最大值。
因此,一个进程 Binder 接收缓冲区最大是 1M-8K。
经过前面的深入分析,我们对这个问题得出更加清晰的结论:
前面我们分析得知崩溃场景是 Activity stop 和 start 时和 AMS 传递的 Bundle 过大流程:
按照这个数据处理流程,一旦 Bundle 超过进程 Binder 接收缓冲区大小,就会触发 Binder 传输失败。
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
// 需要 hook 拦截处理 outState
}@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
// 需要 hook 拦截处理 savedInstanceState
super.onRestoreInstanceState(savedInstanceState);
}
因此,结合问题场景和现状,我们的优化思路是:
通过阅读源码和断点调试,我们发现一个合适的 hook 类 ActivityLifecycleCallbacks,其能满足我们的诉求:
onActivityPreCreated 正好是在 onRestoreInstanceState 之前执行,而且能拿到 savedInstanceState 参数
所以我们通过 Application.registerActivityLifecycleCallbacks 能够拿到合适的执行时机。
为了让 Binder 读取到的 Bundle 数据量小从而避免崩溃,我们需要思考小到什么程度?比如:
为了尽量不影响系统本身的 save 和 restore 逻辑,我们选择传输 KEY 数据缩小 Bundle 数据。其中依赖的 KEY 我们使用 UUID 生成方式。
public void onActivityPostSaveInstanceState(Activity activity, Bundle outState)
outState.clear();
String uuid = UUID.randomUUID().toString();
sKey2ContentMap.put(uuid, outState);
outState.putString("TransactionTooLargeOptKey", uuid);
}
前面我们有了 KEY 和 Map 结构,现在我们只需要根据 KEY 查找对应的 Bundle 数据进行还原即可:
public void onActivityPreCreated(Activity activity, Bundle savedInstanceState) {
if (savedInstanceState != null) {
Object uuid = savedInstanceState.get("TransactionTooLargeOptKey");
Bundle bundle = sKey2ContentMap.get(uuid);
if (bundle != null) {
savedInstanceState.clear();
savedInstanceState.putAll(bundle);
}
}
}
有了整体稳定性优化数据处理流程之后,我们发现维护的 Map 结构需要一定的空间开销,可能包括内存或者外存。
一方面,假如我们需要跟系统 save 和 restore 行为保持一致,即冷启动之后还可以恢复 Activity 状态,那么需要把 Bundle 信息保存在外存。而外存一般只能保存文本/二进制数据,这就需要把 Bundle 进行转换成二进制,并且涉及到一些 io 操作,很可能影响性能。
另一方面,我们对抖音主要 Activity 进行销毁重建,发现 Activity 并不能很完美的支持恢复能力。
所以,基于以上两点,我们决定 Map 结构不涉及的 io,只做内存级别。
前面我们的流程中针对所有 Bundle 都进行了优化,可能它就没有超过 Binder 的阈值。因此,为了精准识别问题场景,我们希望对超过一定大小的 Bundle 才进行优化。所以我们使用 Parcel 对 Bundle 进行了序列化量化:
private static byte[] bundle2Bytes(Bundle outState) {
Parcel parcel = Parcel.obtain();
try {
parcel.writeBundle(outState);
return parcel.marshall();
} catch (Throwable t) {
t.printStackTrace();
} finally {
parcel.recycle();
}
return null;
}
把 Bundle 序列化为 byte[] 之后,我们限制大小超过 1024 Bytes 才进行优化。
假如用户不断触发 save 操作,那么很可能会挤爆 Map 内存。所以我们需要有个 Map 容量上限和清理逻辑。这里初步设置容量 64,超限之后清理掉最久不用的记录:
if (sKey2ContentMap.size() > 64) {
Set<Map.Entry<String, byte[]>> entries = sKey2ContentMap.entrySet();
List<String> removeKeys = new ArrayList<>();
// 清理掉前 20%,留下后面 80%
final int removeCount = (int) (64 * 0.2);
for (Map.Entry<String, byte[]> entry : entries) {
removeKeys.add(entry.getKey());
if (removeKeys.size() >= removeCount) {
break;
}
}
for (String firstKey : removeKeys) {
sKey2ContentMap.remove(firstKey);
}
}
在 demo App 中,我们尝试往 Bundle 中写入超过 Binder 传输阈值的 16 MB 数据,预期在 restore 中能读取到相同数据:
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
byte[] bytes = new byte[16 * 1024 * 1024]; // 16MB
outState.putByteArray("key", bytes);
System.out.println("onSaveInstanceState bytes.length = " + bytes.length);
}@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
byte[] bytes = savedInstanceState.getByteArray("key");
System.out.println("onRestoreInstanceState bytes.length = " + bytes.length);
}
在机器中开启开发者选项中不保留活动选项,测试结果中两行日志 bytes 数组长度一致,说明方案可行。
经过前面层层分析和逐步优化,我们的最终方案是:
单个 issue 修复效果:优化方案已经解决 97% 的问题。
剩余三星 startActivity 兼容性问题,已经不是 Top 问题。由于涉及启动 Activity 多进程数据共享问题,故此次未做优化
目前该方案已经在抖音全量上线,并集成到火山引擎 APMPlus ,供各产品快速接入(由于不同产品的复杂度不同,建议接入时做好观察验证)。接入方式如下:
MonitorCrash.Config config = MonitorCrash.Config.app(APM_AID)
.enableOptimizer(true);//打开稳定性优化开关
.build();
只需要初始化时打开优化开关即可,欢迎各产品接入优化异常问题,该能力开箱即用,具体接入方式详见文档(https://www.volcengine.com/docs/6431/68852)。
APMPlus 是火山引擎下的应用性能监控产品,通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。基于海量数据的聚合分析,平台可帮助客户发现多类异常问题,并及时报警,做分配处理。目前,APMPlus 已服务了抖音、今日头条等多个大规模移动 App,以及作业帮、Keep、德邦等众多外部业务方。
APMPlus APP端监控方案提供了 Java 崩溃、Native 崩溃、ANR 等不同的异常类别监控,以及启动、页面、卡顿等流畅性监控,还包括内存、CPU、电量、流量、磁盘等资源消耗问题的监控。此外,APMPlus 提供网络耗时和异常监控,拥有强大的单点分析和日志回捞能力。在数据采集方面,提供采样和开关配置,以满足客户对数据量和成本控制的需求,并支持实时报警能力。针对跨平台方案,其提供了 WebView 页面的监控。丰富的能力满足客户对 App 全面性能监控的诉求。
方案亮点
扫码申请免费试用
👇 点击阅读原文,了解更多