先看结论与判断条件

  • ANR 判断要从 Android 组件与主线程时间线出发,JNI 栈帧只是阻塞位置线索,最初原因可能是锁持有者、Binder、I/O 或上游任务排队。
  • GetPrimitiveArrayCritical 与 Release 之间应保持极短、无阻塞且不跨越复杂调用;critical 持有时间和完整 JNI 调用时间必须分别计量。
  • 预算不能直接照搬系统 ANR 超时或他人毫秒数,应由产品交互目标、基线分布、设备尾部和同路径上游成本共同制定,并保留安全余量。
  • SO/VMP 可能改变指令、分支、锁或调用布局,是否增加时延必须由当前候选测量;高风险平台桥和 critical 区间通常保持透明。
  • 诊断证据至少绑定 APK 与 SO 摘要、线程、callSite、阶段、设备、系统、时间窗、ANR trace 和 ApplicationExitInfo,禁止只保存一张堆栈。
  • 本地 instrumented test、独立进程 Macrobenchmark、ApplicationExitInfo 和 Android vitals 分别覆盖设备语义、可重复基准、退出证据和线上分布,不能互相替代。

ANR 来自主线程或组件超时,不来自 JNI 标签本身

Diagnose Android ANRs 建议按主线程阻塞、锁竞争、Binder、I/O 和组件超时等路径分层定位。主线程进入一个 JNI 方法后不能及时返回,消息循环无法继续处理输入、绘制或生命周期工作,就可能成为 ANR 时间线的一部分。但 trace 顶部显示 Native 方法,只说明采样时线程停在那里,不证明该方法独自消耗了全部时间。

锁竞争是常见的归因误区。主线程可能只在 Native mutex 上等待,真正长时间工作的线程持有该锁并执行 I/O、等待 Binder 或处理大数据。若只优化等待函数的几条指令,ANR 不会消失。报告需要同时记录等待者和持有者、锁获取前后时间、线程名与相关调用,才能把表象堆栈追到最初阻塞源。

组件也有自己的超时上下文,BroadcastReceiver、Service、ContentProvider 或其他系统回调不能用同一预算概括。工程门禁应先标记调用发生在主线程还是工作线程、属于哪个组件和用户路径,再选择预算。一个工作线程调用很慢可能拖延业务,却不一定直接阻塞主线程;如果主线程同步等待它,依赖链仍需纳入。

Native 栈出现时的分层诊断
观察可能原因需要补充禁止结论
主线程在 JNINative 计算或阻塞分阶段时长和线程状态JNI 本身必然错误
等待 mutex其他线程持锁锁持有者时间线等待函数就是根因
Binder 调用远端或线程池阻塞调用双方和事务时长只看客户端栈
文件或网络 I/O同步资源访问路径、线程与取消加速计算即可
组件超时回调未按时完成组件类型和系统 trace统一套主线程预算
SO/VMP 后出现布局或路径变化同候选对照保护一定是根因

critical 数组访问的持有时间必须独立记录

Android JNI tips 讨论 JNI 注册、线程、引用、异常与类加载等边界,也强调减少跨 JNI 的复杂工作。GetPrimitiveArrayCritical 或类似 critical 访问为运行时提供更严格的临界提示,具体实现可能复制或直接访问数组。应用不能假定得到的一定是固定指针,也不能把 critical 区间当作普通长期缓存。

Get 和 Release 之间应只做必要的短小内存操作,不等待锁、不执行 I/O、不调用可能阻塞的服务,也不跨越复杂 Java 回调。正确结构通常是先准备所有外部状态,进入 critical 后完成有界复制或计算,立即 Release,再执行日志、锁、网络或回调。若业务需要长时间处理,应考虑先复制到 Native 自有缓冲区并退出 critical。

计时要区分四个节点:进入 JNI、开始等待或准备、成功获得 critical 指针、Release 完成和 JNI 返回。完整调用超预算但 critical 很短,问题可能位于锁或计算;critical 本身超预算,则要继续分解临界区内部。只记录整个 Native 方法会让修复人员不知道应该缩短数组持有、移动工作线程还是消除同步依赖。

JNI 调用的阶段计时
阶段开始与结束主要风险建议标签
jni-total入口到返回主线程总体阻塞callSite 与 threadClass
lock-wait请求锁到获得锁竞争与反转lockClass
critical-holdGet 成功到 Release运行时受限区间过长arrayKind
native-compute纯计算区间CPU 尾部时延algorithmVersion
binder-io外部调用前后远端或存储阻塞dependencyClass
java-callback回调准备到返回异常和线程切换callbackId

主线程、锁等待和回调会把短函数串成长停顿

一个 JNI 方法的每个局部步骤都可能看起来很短,但同步串联后会超过交互预算。主线程等待工作线程锁,获得锁后进入 critical 数组区,退出后发起 Binder,再回调 Java 触发同步布局,累计成本才是用户实际等待。门禁需要保存同一 traceId 下的阶段关系,而不是只看单个函数平均值。

锁设计要记录持有范围和顺序。不要在持有共享 mutex 时调用 Java、Binder、文件系统或未知第三方函数,因为它们可能重入或等待其他资源。主线程能访问的锁应有明确上限和失败策略,工作线程长任务尽量在锁外处理,再以小型不可变结果提交。VMP 变换前后若锁边界变化,必须专项回归。

Java 回调需要先检查是否存在待处理异常,并明确线程。Native 代码不能假定回调总在主线程,也不能在 critical 区间中调用复杂 Java 逻辑。若回调需要 UI 更新,应先退出临界区和锁,再调度到合适线程。错误路径也必须 Release 引用、释放锁并返回一致异常;只测成功路径容易在异常时留下更长阻塞。

长停顿的组合链
节点输入风险可观测字段重构方向
主线程入口同步调用looper 与 callSite异步或切分
锁等待持有者未知wait 与 hold 时长缩小锁范围
critical 数组长操作或阻塞hold 时长复制后退出
Native 计算数据规模尾部输入类别与时长分块或工作线程
Binder/I/O外部延迟依赖类别与超时移出主线程
Java 回调重入和线程切换异常与返回时长锁外调度

预算来自项目基线和交互目标,不是一个通用毫秒数

系统 ANR 超时是最终故障边界,不适合作为日常函数预算。等到单个 JNI 调用接近系统超时才报警,已经没有留给同一用户路径中的布局、Binder、I/O、GC 和调度抖动的空间。团队应从产品交互目标出发,将总路径预算分配到 JNI 总调用、锁等待、critical 持有、计算和回调阶段,并保留设备尾部余量。

预算需要版本化。每个 callSite 记录 threadClass、phase、budget、统计窗口、最小样本、设备类别和适用输入规模。主线程 critical-hold 往往比工作线程批处理更严格,但本文不提供通用阈值,因为算法、设备、数据规模和组件上下文不同。没有基线和产品目标时,先采集分布再设停线值,不能用任意数字制造通过。

门禁同时看单次超限、尾部分位和超限比例。平均值会掩盖少量长尾,而最大值可能受不可重复环境噪声影响。持续集成可对稳定设备和输入运行多次,生产监控再观察真实分布。每项统计都只记录脱敏 callSite 和阶段,不采集业务数据、数组内容、令牌或文件路径。

预算记录的最小字段
字段含义来源不合格情况
callSiteId稳定脱敏调用点代码清单包含用户数据
phasetotal、lock、critical 等插桩定义阶段混用
threadClassmain 或 worker运行时采样线程未知
budget项目阶段预算交互目标与基线照搬通用数值
sampleWindow迭代和设备范围测试计划单次外推
candidateHashAPK 与 SO 身份构建流水线旧结果复用

SO/VMP 应避开 critical 和平台胶水,优先保护有界纯计算

SO/VMP 可能改变指令布局、分支、间接调用和局部资源使用,是否增加时延必须在当前候选测量,不能预设一定变慢或无影响。JNI 导出入口、Get 或 Release、锁获取、异常桥和 Java 回调属于平台胶水,通常应保持薄且透明,便于 trace 和异常诊断。将整个 JNI 方法包入复杂变换会掩盖阶段边界。

更适合评估的对象是锁外、critical 外、输入有界、无系统回调的高价值纯 Native 计算或业务状态转换。调用方先复制并验证输入,退出 critical 后在工作线程执行候选函数,再将最小结果提交给 Java 或服务端。即使函数适合保护,也要限制输入规模和取消语义,不能因为离开主线程就允许无限运行。

保护清单要精确到符号与构建摘要,并记录 threadPolicy、lockPolicy、criticalPolicy 和 budgetId。变换报告列出成功、跳过和失败目标,设备回执比较保护前后分阶段分布。没有相同 APK、SO、设备和输入条件时,不把时延差异归因于 VMP,也不宣称任何兼容或防护强度。

Native 函数的 SO/VMP 选择矩阵
函数类型临界/平台耦合默认建议必需回执
JNI 导出入口保持薄线程与异常
critical 获取释放排除hold 计时
锁管理排除wait/hold 图
Java/Binder 回调排除线程和超时
有界纯计算优先评估保护前后分布
业务状态转换低到中选择性保护输入契约和设备测试

ANR trace、ApplicationExitInfo 和线上 vitals 要绑定同一候选

Android ApplicationExitInfo 可以提供进程退出原因、ANR trace,并在新版本中提供 Native tombstone 等信息。读取记录时要匹配 package、版本、时间窗、进程和用户路径,再与 APK、SO 摘要和 callSite 采样关联。退出记录来自另一个版本或另一次启动,就不能解释当前保护候选。

ANR trace 是时间点证据。主线程堆栈要结合其他线程、锁持有者、Binder 和 I/O 状态阅读,表象位置不一定是初始阻塞源。若 Native 符号被剥离或变换,仍需同批符号、构建 ID 和 VMP 映射进行还原。Java mapping 不能替代 Native 符号,Native tombstone 也不能自动说明业务路径。

Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号,适合观察发布后的真实长尾和设备聚类。但 Play 样本受安装来源、用户同意和统计口径限制,不能替代实验室重现。报告应区分本地、测试轨道和生产来源,并避免把样本变化直接写成 SO/VMP 因果。

ANR 证据的用途与边界
证据回答的问题必须绑定不能证明
阶段采样哪段耗时callSite、线程和候选系统判定 ANR
ANR trace卡住时线程状态时间窗和版本最初根因
ApplicationExitInfo退出原因与历史记录进程、版本和路径所有用户受影响
Native tombstoneNative 退出细节符号和 build ID业务授权错误
Android vitals线上分布趋势版本和设备维度单一技术因果
VMP 报告哪些函数被变换SO 与 APK 摘要运行兼容通过

设备测试与 Macrobenchmark 分别验证语义和可重复分布

Android instrumented tests 适合验证 JNI、组件、线程与系统 API 的真实语义。用例应覆盖主线程误用拒绝、工作线程正常路径、锁竞争、异常数组、超大输入类别、进程恢复和 Java 回调异常,并确认所有 Get 都在错误路径 Release。单一设备通过不能代表 API、ABI 和厂商矩阵。

Android Macrobenchmark 建议在独立测试进程和可重复设备条件下测量启动与关键路径,并记录多次迭代。对于 JNI 预算,可以让测试应用走真实入口,使用固定输入类别,比较未保护、R8-only、SO/VMP 和最终签名候选。基准框架不会提供预设结论,设备温度、后台负载和采样迭代仍要控制与记录。

发布门禁先运行静态清单和阶段预算,再运行设备语义与基准,最后核对 ApplicationExitInfo 和测试轨道信号。任何超预算条目都必须定位到阶段和候选;修复后生成新 APK、SO 和回执。不能删除异常样本、提高预算或改为工作线程后就宣称 ANR 已解决,仍需验证用户路径和组件时限。

  • JNI Get 与 Release 在正常和异常路径成对验证
  • 主线程、工作线程、锁等待和回调分别采样
  • 未保护、SO/VMP 和最终签名候选保持可比较
  • Macrobenchmark 使用独立进程和重复迭代
  • ApplicationExitInfo 与同版本同时间窗关联
  • 超预算修复后生成新候选并重跑设备门禁

用脱敏时延样本阻止超预算 Native 候选

下面的 Python 示例读取一个 TSV 文件,每行只包含 callSiteId、phase、budgetMicros、elapsedMicros 和 mainThread。它校验标识格式与正数时长,按 callSite 和阶段聚合 count、maximum、p95 和 overBudget,并在任一条超过该行项目预算时返回非零状态。样本不包含数组内容、用户 ID、令牌、文件路径或业务参数。

代码消费的是 Native 插桩导出的脱敏结果,不负责在 JNI 内部打点,也不提供通用预算。生产插桩应使用单调时钟,在 JNI total、lock wait、critical hold、compute 和 callback 边界生成数据,并绑定 APK、SO、设备和测试轮次。callSiteId 使用稳定内部编号或哈希,不能把原始业务对象拼进标签。

申请 JNI 或 SO/VMP 的商业加固评估时,可准备最终 APK 与 SO、JNI 注册表、critical 与锁清单、阶段预算、脱敏样本、ANR trace、ApplicationExitInfo、VMP 报告和设备矩阵,再从御盾中央平台提交申请。没有当前候选和回执时,不宣称 ANR 已消除、性能改善或保护兼容通过。

  • 样本只含脱敏 callSite、阶段、预算、耗时和线程
  • 预算来自项目基线与交互目标而非通用常数
  • JNI total、lock、critical、compute、callback 分开采集
  • 任何单条超预算都产生非零门禁结果
  • 采样绑定 APK、SO、设备和测试轮次
  • 门禁通过后仍核对 ANR trace 与真实设备语义
汇总脱敏 Native 调用样本并执行预算门禁
from collections import defaultdict
from pathlib import Path
import math
import re
import sys

if len(sys.argv) != 2:
    raise SystemExit("usage: budget_gate.py samples.tsv")
input_path = Path(sys.argv[1])
if not input_path.is_file():
    raise SystemExit("input file is missing")

safe_id = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
groups = defaultdict(lambda: {"budget": None, "durations": []})
for line_number, raw in enumerate(input_path.read_text(encoding="utf-8").splitlines(), 1):
    if not raw.strip():
        continue
    fields = raw.split("\t")
    if len(fields) != 5:
        raise SystemExit(f"line {line_number}: expected five fields")
    call_site, phase, budget_text, elapsed_text, thread_name = fields
    if not safe_id.fullmatch(call_site) or not safe_id.fullmatch(phase):
        raise SystemExit(f"line {line_number}: unsafe identifier")
    if thread_name not in {"main", "worker"}:
        raise SystemExit(f"line {line_number}: invalid thread name")
    try:
        budget = int(budget_text)
        elapsed = int(elapsed_text)
    except ValueError as exc:
        raise SystemExit(f"line {line_number}: duration is not an integer") from exc
    if budget <= 0 or elapsed <= 0:
        raise SystemExit(f"line {line_number}: duration must be positive")
    key = (call_site, phase, thread_name)
    group = groups[key]
    if group["budget"] is not None and group["budget"] != budget:
        raise SystemExit(f"line {line_number}: inconsistent budget")
    group["budget"] = budget
    group["durations"].append(elapsed)

if not groups:
    raise SystemExit("input contains no samples")
failed = False
for key in sorted(groups):
    group = groups[key]
    durations = sorted(group["durations"])
    p95_index = max(0, math.ceil(len(durations) * 0.95) - 1)
    over_budget = sum(value > group["budget"] for value in durations)
    if over_budget > 0:
        failed = True
    print(*key, len(durations), durations[p95_index], durations[-1], over_budget, sep="\t")

raise SystemExit(1 if failed else 0)

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
JNI 注册、线程、引用、异常和类加载边界会直接影响 Native 桥接稳定性。Android JNI tips 描述 Android JNI 设计与性能稳定性注意事项。JNI 建议不证明某个 SO/VMP 变换保持 ABI、线程语义或项目性能预算。
ANR 需要按主线程阻塞、锁竞争、Binder、I/O 和组件超时分层定位。Diagnose Android ANRs 描述常见 ANR 类型、trace 分析和修复方向。trace 中显示的 Native 栈可能只是等待位置,不一定是最初阻塞源。
ApplicationExitInfo 可以提供进程退出原因、ANR trace,并在新版本提供 Native tombstone。Android ApplicationExitInfo 描述历史进程退出信息与 trace 获取能力。退出记录仍需与同一应用版本、进程、时间窗和用户路径关联。
Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号。Android vitals 描述 Play 控制台中的应用质量指标和维度。Play 样本受安装来源、用户同意和统计口径限制,不能单独证明 SO/VMP 因果。
依赖真实 Android 运行时、组件和 JNI 系统语义的行为需要设备端测试。Android instrumented tests 说明设备测试用于验证依赖 Android 运行环境的行为。单一设备通过不能代表完整 API、ABI、厂商和 Native 工具链矩阵。
启动与关键路径对比应在独立测试进程和可重复设备条件下执行并记录迭代。Android Macrobenchmark 描述独立进程的启动和关键路径基准方法。基准框架不提供加固前后的预设性能结论,也不定义项目预算。
JNI 总调用、锁等待、critical 持有、计算和 Java 回调应分别计量。工程判断:分阶段采样才能区分主线程停顿、竞争、数组持有和外部依赖。阶段数据需要同候选时间线关联,单独平均值不能证明 ANR 根因。
critical 与平台胶水通常应排除在宽 SO/VMP 边界之外,优先评估锁外有界纯计算。工程判断:薄 JNI 边界更易保持运行时约束和诊断能力,纯函数更适合独立回归。最终保护范围与性能影响必须由当前工具、SO、设备和真实回执确认。

工程常见问题

GetPrimitiveArrayCritical 调用是否一定会导致 ANR?

不会自动导致。风险取决于线程、持有时间和区间内工作。主线程长时间持有、等待锁、执行 I/O 或复杂回调会扩大停顿,应单独计量并尽快 Release。

ANR trace 顶部是 Native 方法,能否认定它是根因?

不能直接认定。主线程可能在等待其他线程持有的锁、Binder 或 I/O。需要同时查看相关线程、分阶段计时、组件上下文和同一版本的退出记录。

JNI 临界区应该设置多少毫秒预算?

没有适用于所有应用的统一数值。预算应由交互目标、基线分布、设备尾部、输入规模和同路径其他成本共同制定,且要明显早于最终 ANR 边界。

把 JNI 方法移到工作线程是否就解决问题?

不一定。主线程若同步等待工作结果仍会阻塞,组件也可能有自己的时限。还需检查锁、回调、取消、超时和用户路径,并重新测量端到端时长。

哪些 Native 函数更适合做 SO/VMP?

优先评估 critical 外、锁外、无系统回调、输入有界的高价值纯计算或业务状态转换。JNI 入口、锁管理、Get/Release 和 Java 回调通常保持薄且透明。

提交 JNI ANR 与 SO/VMP 评估需要哪些资料?

准备最终 APK 与 SO、JNI 注册表、critical 和锁清单、阶段预算、脱敏样本、ANR trace、ApplicationExitInfo、VMP 报告和设备矩阵,再通过御盾中央平台提交申请。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单