先看结论与判断条件
- 死锁结论需要同时看到线程持有什么锁、正在等待什么锁以及等待边能否闭合,仅凭一个 BLOCKED 或 Native 栈不能定性。
- 跨语言锁顺序必须把 Java monitor、Native mutex、条件变量和可能回调 Java 的路径放在同一张等待图中,不能由两个团队分别排序。
- 持有 Native mutex 时回调 Java 是高风险重入点,因为被调用方法可能取得 monitor、进入 Binder 或再次穿过 JNI,形成隐藏的反向边。
- ANR trace、ApplicationExitInfo、符号化 Native 栈和脱敏阶段事件要绑定同一候选、时间窗与用户路径,单份材料只提供局部观察。
- 修复优先建立全局锁等级并缩短临界区,在锁外做跨语言回调和可能阻塞的工作;不能只靠扩大超时或增加重试掩盖闭环。
- SO/VMP 后的放行结论必须覆盖同一 APK、SO、符号、设备和交叉路径,保护前通过或单一设备未复现都不能代替当前证据。
先区分死锁、长等待与主线程慢调用
Java 线程停在 synchronized 入口、Object.wait 或 JNI 调用里,只能说明它此刻没有继续执行,不能单独证明发生死锁。另一线程可能很快释放 monitor,也可能在 Native 里做长计算、同步 I/O 或等待 Binder。真正的死锁要求存在一个不可自行解除的等待环:线程甲持有 Java monitor 并等待 Native mutex,线程乙持有该 mutex 又等待线程甲持有的 monitor,或者经由第三条回调路径完成闭环。诊断第一步是把观察写成等待关系,而不是把最醒目的栈帧直接命名为根因。
长等待与死锁的处理动作不同。若锁持有者仍在推进,只是临界区过大,应分解耗时并搬出阻塞工作;若等待图存在闭环,继续加线程、重试或提高组件超时不会解除互相占有。主线程慢 JNI 还可能没有任何锁,只是 Native 调用超过交互预算。团队需要分别记录主线程总调用时长、锁等待、持锁区间、回调和 I/O,让每条结论对应一类证据。没有时间线时,不能从一次 ANR 推出锁顺序错误。
可执行的分诊表应从线程状态、等待对象和持有者开始。对 Java monitor,记录对象类别的脱敏标识和 owner 线程;对 Native mutex,使用稳定 lockId、取得与释放事件;对 JNI 边界,记录 callSiteId、进入与返回。日志不包含对象内容、用户 ID 或业务参数。若只能取得系统 trace,就先标记缺失边并安排受控复现,而不是伪造一个完整闭环。这样即使最终发现是长临界区而非死锁,采集材料仍可用于预算治理。
| 观察 | 可能解释 | 必须补充 | 不能直接下的结论 |
|---|---|---|---|
| 主线程停在 JNI | Native 计算、锁等待或 I/O | Native 子阶段与线程状态 | JNI 一定死锁 |
| 线程为 BLOCKED | 等待 Java monitor | monitor owner 与其当前等待 | 被锁对象就是根因 |
| Native 栈在 mutex_lock | 等待 Native mutex | mutex owner 和取得时间 | mutex 实现有缺陷 |
| 多个线程长时间不动 | 闭环、长阻塞或采样静止 | 连续时间线与等待图 | 已经形成闭环 |
| SO/VMP 后首次出现 | 路径或时序改变 | 同候选对照与符号 | 保护变换必然导致 |
| 线上 ANR 上升 | 真实回归或样本变化 | 版本、设备和安装来源 | 全部由同一锁引起 |
把 Java monitor 与 Native mutex 画进同一张等待图
等待图的节点不是函数名,而是线程和锁。线程指向它正在等待的锁,锁再指向当前持有线程;若线程在等待另一个线程完成回调或条件,则建立对应的依赖边。Java monitor、ReentrantLock、Native mutex、条件变量和线程 join 都应进入同一模型。跨 JNI 的函数调用只标记路径,不自动等同于等待边。这样可以避免把“调用了 Native”误写成“等待 Native 锁”,也能让 Java 与 C++ 维护者讨论同一组对象。
建立图时必须使用同一时间窗。线程甲在采样一持有 monitor,线程乙在采样二持有 mutex,并不代表两者曾经同时占有;拼接不同时刻的 owner 会制造假闭环。较可靠的方法是结合 trace 中的锁信息、Native 打点的取得释放事件和一次受控复现的时间线。每条边记录观察来源、开始时间、是否仍存在和置信等级。缺失 owner 时保留未知节点,不应根据函数命名猜测持有者。
一个典型闭环可以描述为:UI 线程进入 synchronized update,持有 Java monitor 后调用 JNI save;Native 工作线程持有 stateMutex,准备把结果回调 Java listener;listener 的 synchronized 方法需要 UI 线程仍持有的 monitor,而 UI 线程又在 save 内等待 stateMutex。问题不是某一把锁本身,而是 Java 到 Native 的顺序与 Native 到 Java 的回调顺序相反。把链条写成边列表后,修复位置和验证断言都会更清楚。
| 元素 | 记录字段 | 有效证据 | 常见误判 |
|---|---|---|---|
| Java monitor | lockId、owner、waiter | trace 与受控事件 | 把对象内容写入日志 |
| Native mutex | lockId、owner、取得时刻 | 插桩与符号化栈 | 只记录等待者 |
| JNI 边界 | callSiteId、方向、线程 | 进入与返回事件 | 把调用边当等待边 |
| 回调依赖 | 目标线程、同步方式 | 调用路径与完成回执 | 忽略重入 monitor |
| 条件等待 | 条件标识、唤醒责任 | wait 与 signal 时间线 | 把正常等待当闭环 |
| 未知 owner | 缺失原因、待补材料 | 明确未知 | 按函数名猜测线程 |
回调重入是最容易隐藏的反向取锁路径
持有 Native mutex 时调用 Java 方法,会把 Java 侧的所有同步、类加载和异常处理带入 Native 临界区。目标方法看似只是通知 listener,却可能进入 synchronized getter、投递后等待主线程、访问 Binder,或者再次调用另一个 JNI 方法。此时 Native 代码评审只看到一次 callback,Java 评审也只看到本地 monitor,两边都可能遗漏闭环。JNI 桥接审计必须标出每个回调是否在持锁状态下发生,以及回调可能取得的最高锁等级。
回调还可能重入原对象。Java 方法收到 Native 通知后,为读取状态再次调用 JNI;Native 入口尝试获取当前线程已持有但不可递归的 mutex,形成同线程自锁。另一种情况是回调切换到主线程并同步等待,主线程正阻塞在最初 JNI 调用里,双方没有第二把显式锁也会互等。诊断表必须同时覆盖直接同步回调、跨线程同步派发和 Java 到 Native 的重入,不能只搜索 mutex 的两线程竞争。
优先修复是把需要交给 Java 的数据在锁内复制成不可变快照,释放 Native mutex 后再执行回调。若业务必须保证状态与通知原子一致,应重新设计状态机和消息序号,而不是把跨语言调用留在锁内。Java 侧也应避免持有高层 monitor 进入可能阻塞的 JNI;可以在 monitor 内读取最小状态,退出后调用 Native,再用版本号验证结果仍适用。任何调整都要检查异常路径,保证抛出或回调失败不会跳过 unlock。
| 路径 | 隐藏依赖 | 推荐改造 | 验证断言 |
|---|---|---|---|
| 持 mutex 直接回调 | Java 方法再取 monitor | 锁内复制、锁外回调 | 回调事件发生在 unlock 之后 |
| 回调同步切主线程 | 主线程等待原 JNI | 异步投递并返回 | 工作线程不等待 UI 完成 |
| Java 回调再次进 JNI | 同线程重取非递归 mutex | 拆分只读快照接口 | 重入路径不取得同级锁 |
| 回调访问 Binder | 远端等待延长临界区 | 锁外执行远端调用 | mutex 持有区无 Binder |
| 回调触发类加载 | 加载器锁与初始化 | 预热或锁外解析类 | 关键路径无首次加载 |
| 回调抛出异常 | 清理路径被跳过 | RAII 与异常检查 | 失败后锁可再次取得 |
用 ANR、退出信息与符号化栈还原同一时间窗
ANR trace 的价值是提供组件、线程栈和部分锁等待线索,但顶部栈帧可能只是最终等待位置。应先确认 ANR 类型和主线程当时在做什么,再沿等待对象寻找 owner。若主线程停在 Native 方法,需要同一构建的符号和 ABI 才能把地址还原成可讨论的函数。没有匹配符号时保留原始地址、buildId 与架构,不要套用另一版本符号得到看似完整但错误的调用链。
ApplicationExitInfo 可以补充进程退出原因和相关 trace,但记录必须与同一版本、发生时间、进程名和用户路径关联。一次退出可能发生在后台组件,不能只按包名与最近时间猜测对应关系。采集流程应保留候选摘要、APK 版本、SO buildId、设备、系统版本和测试步骤,再附上退出记录的脱敏元数据。若平台版本或权限条件不提供所需内容,就明确标为未取得,并回到设备端复现补齐。
Native 调试材料应分为可公开方法和项目私有证据。公开文章只说明保留未剥离符号、buildId、ABI 映射和受控 tombstone 的必要性,不展示业务函数、内存内容或可用于攻击复现的细节。项目内的符号化结果要能追溯到精确 SO,不可在加固前后混用。最终等待图中的每条 Native 边都应引用一份可复核回执,否则只能作为待验证假设。
| 材料 | 必须绑定 | 可以证明 | 不能证明 |
|---|---|---|---|
| ANR trace | 组件、时间、候选版本 | 采样时线程状态 | 最初阻塞源 |
| ApplicationExitInfo | 进程、原因、时间窗 | 系统记录的退出上下文 | 某把锁必然根因 |
| Native tombstone | ABI、buildId、设备 | Native 线程与故障上下文 | 跨语言完整闭环 |
| 符号文件 | 精确 SO 摘要 | 地址到函数的还原 | 另一候选的行为 |
| 锁事件 | 线程、lockId、单调时间 | 取得释放和等待顺序 | 未记录锁的状态 |
| 复现步骤 | 输入类别与动作序列 | 路径可重复性 | 全部真实用户路径 |
线上信号用于定范围,不能替代闭环证据
Android vitals 可以帮助观察版本、设备与 ANR 分布,但它回答的是线上质量信号,不会自动给出 Java monitor 与 Native mutex 的等待环。样本还受安装来源、用户同意、活跃设备和统计口径影响。正确用法是先判断问题是否集中在某版本、ABI、设备族或路径,再回到对应候选收集 trace 与锁时间线。没有接入真实控制台数据时,只记录尚未接入,不能编写趋势、比例或收录结论。
分布数据能改变复现优先级。例如问题只出现在特定 ABI,可优先核对该架构 SO 与符号;只在某系统版本集中,则检查线程调度、组件时限和运行时差异;跨版本持续存在,则更像长期锁顺序缺陷。但这些都是工程假设,仍要由等待图验证。将线上相关性直接写成因果,会导致团队围绕设备或系统做规避,却保留真正的逆序取锁。
发布后的监控要区分 ANR 总体、特定路径回执和锁门禁结果。总体信号变好不证明死锁已修复,可能只是路径使用下降;总体不变也不说明修复无效,可能还有其他 ANR 类型。为当前问题建立脱敏事件,只统计 callSiteId、lockId、线程类别、等待阶段和候选标识,并设置留存与访问边界。具体用户输入、对象地址和业务数据不进入公开或通用遥测。
| 问题 | 线上信号 | 项目证据 | 决策边界 |
|---|---|---|---|
| 是否集中在新版本 | 版本分布 | 同候选复现 | 相关不等于因果 |
| 是否集中在设备族 | 设备维度 | 目标设备 trace | 不能外推全部设备 |
| 是否存在锁闭环 | 不能直接回答 | 等待图与时间线 | 必须看到闭合依赖 |
| 修复是否有效 | 趋势作为观察 | 旧路径断言与负向用例 | 总体变化不是唯一回执 |
| SO/VMP 是否相关 | 版本前后信号 | 同构建材料对照 | 不混用符号与候选 |
| 是否可以扩大发布 | 风险范围参考 | 设备矩阵门禁 | 只覆盖实测组合 |
用全局锁等级消除跨语言的逆序获取
锁等级规则必须覆盖 Java 与 Native 两侧,不能分别维护两份互不相知的顺序。给每类锁分配稳定等级,只允许线程从低等级向高等级取得;释放按相反顺序进行。Java monitor 也要有等级,即使语言运行时不会读取这个值。评审表把类或模块映射到脱敏 lockId 和 level,JNI callSite 再声明进入时允许持有哪些等级。新增锁或回调时,检查器和代码评审都能发现反向边。
等级不是把所有路径串成一把大锁。对于没有嵌套关系的锁,可以放在不同域并禁止同时持有;对于必须组合的状态,明确唯一顺序。tryLock、条件变量和读写锁仍要纳入规则,因为尝试失败、释放后等待以及读锁升级都可能改变实际边。若某段代码无法遵守等级,优先拆分临界区或传递不可变快照,而不是为它增加例外。例外会让检查结果逐渐失去约束力。
修复完成后要删除旧闭环的每一条必要边。只改其中一个调用点可能留下异常、超时或回调分支。评审清单应覆盖正常返回、Java 异常、Native 错误、取消、线程销毁和重复回调,确认每条路径都先释放高等级锁再进入低等级资源。锁取得失败必须返回明确错误,不能悄悄换序重试。若现有架构只能通过同步回调维持一致性,就应先重构状态协议,再谈保护或性能优化。
| 规则 | 允许 | 拒绝 | 门禁记录 |
|---|---|---|---|
| 取得顺序 | 低等级到高等级 | 高等级再取低等级 | 线程栈与目标 level |
| 释放顺序 | 后取得先释放 | 非栈顶释放 | release 与当前栈顶 |
| 跨语言调用 | 声明持锁集合 | 未知锁状态进入 JNI | callSite 与 held levels |
| Native 回调 | 释放 mutex 后回调 | 持锁同步回调 Java | unlock 与 callback 顺序 |
| 条件等待 | 按协议释放并重检条件 | 假定唤醒即成立 | wait、signal 与状态版本 |
| 规则例外 | 重构后消除 | 永久白名单 | 负责人和移除条件 |
设备复现要验证旧闭环被打断且没有新等待边
Android instrumented test 适合驱动真实组件、线程和 JNI 路径,但测试目标不是等待超时后宣布未复现。用例应设置受控屏障,让线程甲先取得 Java monitor,线程乙先取得 Native mutex,再按旧调用顺序推进,从而稳定触发原闭环条件。修复后,相同屏障应观察到某一条边不再出现,并且两个线程都在明确预算内进入终态。测试记录保存候选、设备、API、ABI、SO buildId、事件序列和断言。
负向用例要覆盖回调重入、异常清理和生命周期交叉。可以让 Java 回调抛出受控异常,确认 Native 锁仍释放;让页面销毁发生在工作线程准备回调时,确认不会同步等待主线程;让条件等待被取消,确认锁栈恢复一致。测试代码只使用假对象与脱敏 lockId,不连接真实账号或业务后台。一次通过只证明该候选在该组合下满足断言,不能外推全部厂商设备。
SO/VMP 之后必须重新执行同一套用例,因为代码布局、调用封装和时序可能变化。对照时固定 APK 逻辑、测试输入和设备条件,分别绑定加固前后精确 SO 与符号,不把旧报告贴到新候选。若出现新的等待边,先确认符号和构建身份,再判断是保护引入、时序放大还是原有缺陷被暴露。没有项目实测时,公开页面只给出方法与边界,不宣称兼容通过或 ANR 已消失。
- 用受控屏障稳定建立旧闭环所需的前置持锁状态
- 修复后验证至少一条必要等待边不再产生
- Java 异常与 Native 错误路径均能释放锁并返回终态
- 页面销毁和取消不会引入同步等待主线程的新边
- 每次结果绑定 APK、SO、符号、设备、API 与 ABI
- SO/VMP 后重跑同一候选矩阵,不复用旧通过回执
用脱敏锁事件在构建阶段拒绝逆序获取
下面的 Python 检查器读取 TSV 锁事件,每行仅包含 threadId、action、lockId 和 level。它为每个线程维护已取得锁栈,acquire 只能进入更高等级,release 必须与栈顶一致;逆序获取、重复持有、错误释放、非法标识和未释放锁都会返回非零状态。示例不需要业务符号、对象地址、用户数据、凭据或内网地址,适合处理测试插桩导出的脱敏事件。
检查器只能证明输入事件遵守声明的等级,不能证明所有锁都被插桩,也不能证明没有条件变量、join 或同步回调形成的其他等待边。生产接入应把 Java monitor 包装、Native RAII 锁和 JNI callSite 映射到同一规则表,并由测试覆盖异常与取消路径。若某些系统锁无法分配等级,应在等待图中单独建模,而不是伪造 level 让门禁通过。
准备跨语言死锁评估时,可整理最终 APK 与 SO、JNI 入口和回调清单、Java 与 Native 锁表、等级规则、脱敏事件、ANR trace、ApplicationExitInfo、符号映射和设备复现步骤,再从御盾中央平台提交申请。可同时参阅本站关于 JNI 线程生命周期与 Native 临界区预算的技术说明,分别处理线程归属和长等待问题。没有当前候选与真实回执时,不写死锁已消除、性能改善或保护兼容结论。
- 规则表为 Java monitor 与 Native mutex 分配同一等级空间
- 事件只包含稳定 lockId、线程标识、动作与等级
- 逆序、重复取得和非栈顶释放均使构建门禁失败
- 异常、取消与生命周期路径也产出完整释放事件
- 等待图继续覆盖条件变量、join 和同步回调依赖
- 通过结果只绑定当前规则、插桩覆盖和候选身份
from pathlib import Path
import re
import sys
if len(sys.argv) != 2:
raise SystemExit("usage: lock_order_gate.py events.tsv")
event_path = Path(sys.argv[1])
if not event_path.is_file():
raise SystemExit("event file is missing")
safe_id = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
stacks = {}
for line_number, raw in enumerate(event_path.read_text(encoding="utf-8").splitlines(), 1):
if not raw.strip():
continue
fields = raw.split("\t")
if len(fields) != 4:
raise SystemExit(f"line {line_number}: expected four fields")
thread_id, action, lock_id, level_text = fields
if not safe_id.fullmatch(thread_id) or not safe_id.fullmatch(lock_id):
raise SystemExit(f"line {line_number}: unsafe identifier")
if action not in {"acquire", "release"}:
raise SystemExit(f"line {line_number}: unsupported action")
try:
level = int(level_text)
except ValueError as exc:
raise SystemExit(f"line {line_number}: level is not an integer") from exc
if level < 1:
raise SystemExit(f"line {line_number}: level must be positive")
stack = stacks.setdefault(thread_id, [])
if action == "acquire":
if any(item[0] == lock_id for item in stack):
raise SystemExit(f"line {line_number}: duplicate acquire")
if stack and level <= stack[-1][1]:
raise SystemExit(f"line {line_number}: reverse lock order")
stack.append((lock_id, level))
continue
if not stack:
raise SystemExit(f"line {line_number}: release without acquire")
if stack[-1] != (lock_id, level):
raise SystemExit(f"line {line_number}: release is not stack ordered")
stack.pop()
remaining = {thread: stack for thread, stack in stacks.items() if stack}
if remaining:
raise SystemExit("event stream ends with held locks")
print(f"validated_threads={len(stacks)}")事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JNI 的线程、引用、异常和类加载边界会改变跨语言回调与清理路径的稳定性。 | Android JNI tips 说明 JNI 线程、引用、异常检查、类查找和桥接实践。 | 这些建议不证明某个 Java monitor 与 Native mutex 已形成闭环,也不证明 SO 变换保持全部路径。 |
| ANR 诊断需要区分主线程阻塞、锁竞争、Binder、I/O 与组件超时,再沿等待关系寻找持有者。 | Diagnose Android ANRs 给出 ANR 类型、线程阻塞和常见原因的分层诊断方法。 | 一次 trace 的顶部栈帧可能只是最终等待位置,不能单独确定最初阻塞源。 |
| 进程退出记录可为同一候选补充退出原因和相关诊断内容。 | Android ApplicationExitInfo 描述历史退出原因、trace 获取以及部分平台版本的 Native 诊断信息。 | 退出记录必须关联版本、进程、时间窗和用户路径,不能按包名猜测对应死锁。 |
| 线上 ANR 与设备分布适合限定调查范围和发布风险,而不是直接证明锁闭环。 | Android vitals 描述崩溃、ANR、启动和设备维度的线上质量信号。 | 样本受安装来源、用户同意与统计口径影响,且当前项目数据未接入本文。 |
| Native 地址还原需要与候选匹配的符号、架构和构建产物。 | Debug Android native code 说明 Android Native 调试、符号与相关诊断材料的使用边界。 | 符号化只能还原已记录栈,不能自动补齐 Java 侧 owner、回调依赖或未插桩锁。 |
| 依赖真实 Android 组件、线程调度与 JNI 的死锁路径应在设备端执行受控测试。 | Android instrumented tests 说明在 Android 设备或模拟设备上运行依赖平台行为的测试。 | 单一设备和单次通过不能代表全部 API、ABI、厂商设备与持续负载。 |
| 把 Java monitor 与 Native mutex 放进同一等级空间,可以在取得锁时发现明确的逆序路径。 | 工程判断:统一等级、栈式释放和跨语言 callSite 持锁声明让顺序约束可被检查。 | 门禁只覆盖已声明并产出事件的锁,不能替代条件变量、同步派发和 join 的等待图分析。 |
| 持有 Native mutex 时同步回调 Java 应优先改为锁内快照、锁外通知。 | 工程判断:释放 Native 锁后回调可移除回调重入 Java monitor 的必要等待边。 | 状态与通知的一致性仍需版本号或状态机保证,不能仅移动调用就宣称业务语义正确。 |
工程常见问题
线程停在 pthread_mutex_lock 就能确定是死锁吗?
不能。它只说明采样时线程等待 mutex;还要找到 owner、owner 正在等待的资源和同一时间窗,只有等待边形成无法自行解除的闭环才可定性。
Java synchronized 和 Native mutex 应该分别制定锁顺序吗?
不应该。跨 JNI 的调用与回调会把两侧连接起来,应使用同一等级空间或同一等待图,否则两个局部正确的顺序仍可能组合成全局闭环。
为什么持有 Native 锁时回调 Java 风险高?
被调用方法可能取得 monitor、同步切主线程、访问 Binder 或再次进入 JNI,这些隐藏依赖会延长临界区并产生反向取锁。优先在锁内复制快照,释放后回调。
只有 ANR trace,没有 Native 符号还能排查吗?
可以先确认组件、Java 线程和可见等待对象,但 Native 地址不能可靠归属函数。应保留原始地址、ABI 与 buildId,取得匹配符号后再补全等待边。
锁等级检查器通过是否代表不存在死锁?
不代表。它只验证输入事件中的取得与释放顺序,还可能遗漏未插桩锁、条件变量、线程 join、同步派发和运行时内部锁,需要结合等待图与设备复现。
SO/VMP 前已通过死锁测试,保护后是否需要重跑?
需要。保护可能改变代码布局、封装和时序,必须绑定新的 APK、SO、符号与设备矩阵执行同一交叉路径;旧回执不能证明当前候选兼容。