先看结论与判断条件

  • 死锁结论需要同时看到线程持有什么锁、正在等待什么锁以及等待边能否闭合,仅凭一个 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,就先标记缺失边并安排受控复现,而不是伪造一个完整闭环。这样即使最终发现是长临界区而非死锁,采集材料仍可用于预算治理。

Java 与 Native 阻塞的第一轮分诊
观察可能解释必须补充不能直接下的结论
主线程停在 JNINative 计算、锁等待或 I/ONative 子阶段与线程状态JNI 一定死锁
线程为 BLOCKED等待 Java monitormonitor owner 与其当前等待被锁对象就是根因
Native 栈在 mutex_lock等待 Native mutexmutex 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 monitorlockId、owner、waitertrace 与受控事件把对象内容写入日志
Native mutexlockId、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。

Native 到 Java 回调的重入审计
路径隐藏依赖推荐改造验证断言
持 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 tombstoneABI、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 与当前栈顶
跨语言调用声明持锁集合未知锁状态进入 JNIcallSite 与 held levels
Native 回调释放 mutex 后回调持锁同步回调 Javaunlock 与 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 和同步回调依赖
  • 通过结果只绑定当前规则、插桩覆盖和候选身份
跨 Java 与 Native 的锁等级事件检查器
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、符号与设备矩阵执行同一交叉路径;旧回执不能证明当前候选兼容。

想用自己的 App 验证?

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

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