先看结论与判断条件

  • AttachCurrentThread 只为当前原生线程取得 JNIEnv,不会自动建立应用 ClassLoader 上下文,因此线程附加成功与业务类可见是两件事。
  • 应用线程可以把正确 ClassLoader 传给 Native 并保存全局引用,原生线程通过 loadClass 调用;稳定核心类也可在 JNI_OnLoad 中查找后缓存全局类引用。
  • JNIEnv 只能在所属线程使用,JavaVM 可以跨线程保存;原生创建的线程退出前要按责任正确 detach。
  • FindClass、CallObjectMethod 和 GetMethodID 后都要检查异常,失败路径必须清理局部引用并返回明确状态,不能带挂起异常继续调用 JNI。
  • R8 对通过字符串、JNI 或反射访问的类和成员需要精确可达性规则,宽泛 keep 不能代替类加载器、签名和注册检查。
  • 设备验收要覆盖 Java 线程与原生线程入口、进程重建、不同 ABI、混淆候选和加固候选,并区分类加载、链接和 API level 故障。

根因通常是 ClassLoader 上下文而不是线程未附加

原生代码创建 pthread 后,通常先通过 JavaVM 的 AttachCurrentThread 为当前线程取得 JNIEnv。这个步骤让线程可以调用 JNI,却不会凭空生成一个应用 Java 调用栈。FindClass 会根据当前 JNI 调用上下文选择类加载路径;在应用 Java 方法调用下进入 Native 时,它通常能够关联应用类加载器,而纯 Native 线程没有同样上下文,查找应用业务类就可能失败。

因此排查不能只打印“AttachCurrentThread 返回成功”。需要同时记录查找入口来自 Java 回调、JNI_OnLoad 还是原生线程,类名采用哪种形式,预期 ClassLoader 从哪里取得,FindClass 或 loadClass 后是否产生异常,以及结果引用属于局部还是全局。线程已经附加但加载器错误,会稳定得到 ClassNotFoundException 或空结果;盲目重复 Attach 不会改变加载器选择。

本文只回答原生线程为什么找不到 App 业务类,以及如何建立可诊断加载边界,不扩写成一般 JNI 教程或 Native 诊断构建指南。没有最终 APK、R8 配置和设备回执时,不能断言某次 ClassNotFound 已被修复。类加载失败、Native 符号缺失、ABI 不兼容和高版本 API 调用是不同故障,必须先分类再处置。

不同 JNI 入口的类加载上下文
入口JNIEnv 来源应用加载器可见性建议
Java 调用 NativeVM 传入当前线程通常跟随调用类上下文可直接查找并验证
JNI_OnLoadJavaVM 取得有加载该库的特殊上下文缓存稳定类与方法
Native 创建线程AttachCurrentThread不自动继承应用调用栈使用缓存加载器或类引用
线程池回调视创建与入口而定不能凭线程名推断显式记录来源
跨线程复用 JNIEnv错误复用未定义且不安全每线程单独取得
未附加线程没有有效 JNIEnv不能调用 JNI先附加并管理退出

JavaVM 可以缓存,JNIEnv 不能跨线程共享

JNIEnv 是线程局部接口,不能从 Java 主线程保存后交给 pthread 使用。Native 层可以在 JNI_OnLoad 缓存 JavaVM 指针,每个进入 JNI 的原生线程先调用 GetEnv 判断是否已经附加;未附加时再 AttachCurrentThread,并记录本次附加责任。线程退出前只对自己执行的附加调用 DetachCurrentThread,避免把由 VM 管理的 Java 线程错误分离。

线程生命周期要用 RAII 或统一包装器管理。异常返回、取消、pthread 退出和初始化失败都必须经过同一清理路径,不能只在正常结尾 detach。长生命周期线程可以保持附加,但要明确它何时退出;短任务如果反复创建线程,需要评估附加成本和局部引用表。本文不提供性能数字,具体线程模型应由目标设备基线和真实任务量决定。

JNIEnv 有效也不意味着此前异常已经清除。某次 FindClass 失败会留下挂起的 Java 异常,在异常未检查和处理前继续调用其他 JNI 方法,后续结果可能失去解释力。诊断包装器应在每个可能抛出异常的调用后立即检查,记录非敏感阶段码,将异常传播到受控 Java 边界或转换成明确错误状态,并清理本次创建的局部引用。

  • JavaVM 在 JNI_OnLoad 保存且不跨线程保存 JNIEnv
  • 每个原生线程用 GetEnv 判断当前附加状态
  • 只分离由当前 Native 代码主动附加的线程
  • 异常、取消和线程退出共享统一清理路径
  • 每次类查找和方法调用后立即检查异常
  • 失败状态包含入口与阶段但不输出业务秘密

在 Java 上下文安装正确 ClassLoader

通用方案是在已知应用 Java 上下文中取得业务锚点类的 ClassLoader,将它传入 Native,并通过 NewGlobalRef 保存。Native 同时缓存 ClassLoader.loadClass 的 method ID。原生线程附加后,不再对业务类直接调用 FindClass,而是用缓存加载器的 loadClass 查找点分隔二进制类名。安装过程必须在业务线程启动前完成,并在重复安装、进程重建和卸载时有清晰生命周期。

另一种方案是在 JNI_OnLoad 中查找数量很少且稳定的锚点类,随后把 jclass 转成全局引用并缓存所需 method ID。Android JNI 指南对 JNI_OnLoad 的 FindClass 有特殊类加载语境,这使它适合做集中注册和缓存。但它不适合枚举大量业务类,也不能绕过 R8 的名称变化。缓存范围越大,类卸载、动态特性和版本变更的管理成本越高。

ClassLoader 全局引用属于长期资源,必须在明确卸载或进程结束路径释放。若应用使用动态 feature、插件或多个加载器,一个全局默认加载器可能不覆盖全部类;记录中要写清加载器来源、锚点模块和适用类集合。不能因为一次 base 模块类加载成功,就推断按需模块或插件类也可见。加载器选择是架构事实,需要与模块交付和生命周期一起设计。

业务类加载方案的选择
方案适用范围优势主要限制
Java 传入 ClassLoader多类与原生线程来源明确且可诊断需要安装时序和全局引用管理
JNI_OnLoad 缓存 jclass少量稳定锚点集中注册和快速复用不适合动态模块与大规模缓存
每次 Native FindClass系统类或有 Java 上下文代码简单纯 Native 线程可能看不到业务类
缓存类名字符串仅记录意图不持有引用仍需正确加载器和 R8 规则
传入 Class 对象调用方已知目标类避免二次名称查找接口耦合和生命周期需管理
动态模块加载器feature 或插件类匹配模块边界交付、卸载和并发更复杂

引用、方法签名和异常要作为同一契约

FindClass 或 loadClass 返回的类对象在当前 JNI 调用中通常是局部引用,不能直接保存到另一个线程或下一次异步回调。需要长期使用时,用 NewGlobalRef 创建全局引用,并在生命周期结束时 DeleteGlobalRef。大量循环查找还要及时 DeleteLocalRef,防止局部引用表累积。弱全局引用适用于允许类被回收的场景,但使用前必须重新确认对象仍存活。

类找到后,GetMethodID 或 GetStaticMethodID 还依赖精确 JNI 签名。R8、Kotlin 生成签名、装箱类型、数组和内部类名称变化都可能让查找失败。每个方法 ID 应与对应 jclass 的全局引用、签名版本和候选 buildId 一起登记。调用前保证参数类型、线程和异常状态正确,调用后立即检查异常;不能把“类已找到”当成完整桥接通过。

失败路径要避免 ExceptionDescribe 在生产日志中泄露内部类名或用户数据。受控诊断构建可以输出经过脱敏的异常类别和入口阶段,生产版本则返回稳定错误码,并在 Java 或上层业务决定重试、降级或报告。若选择 ExceptionClear,必须先将错误转换为可追踪状态,不能静默清除后继续使用空引用。挂起异常、空 jclass 和错误 method ID 都应触发明确失败。

  • 跨调用或跨线程缓存使用 GlobalRef 而非 LocalRef
  • 全局引用有明确创建、替换和释放时点
  • 循环中的局部引用及时删除
  • 方法 ID 与 jclass、签名和 buildId 共同绑定
  • GetMethodID 和 Call 方法后均检查异常
  • 清除异常前转换成稳定且脱敏的诊断状态

R8 可达性与 ClassLoader 是两道不同门禁

R8 无法从普通静态调用图中自动理解所有 JNI 字符串、反射名称和间接入口。若 Native 通过类名或方法签名查找业务代码,需要精确 keep 规则保留必要名称与成员,或在构建时生成可追踪注册映射。宽泛 `-keep class **` 可能暂时掩盖规则遗漏,却会削弱优化并让边界失去所有者。正确做法是从每个 JNI 注册点和查找点生成最小规则,并用 release 候选测试。

keep 规则通过只说明 R8 没有移除或改写指定入口,不说明 Native 线程使用了正确 ClassLoader。反过来,正确加载器也无法找到被 R8 改名但 Native 仍使用旧字符串的类。诊断表应把“类是否保留”“最终名称是否匹配”“加载器是否可见”“签名是否匹配”“异常是否挂起”分成独立字段,避免一个失败被宽泛规则或重复查找暂时遮盖。

Kotlin 反射还依赖运行时可发现的类、成员和元数据。若同一路径同时使用 JNI 和 Kotlin reflection,需要分别确认 kotlin-reflect 或平台能力、元数据保留和名称规则。反射文档不能替某个序列化框架或生成代码作保证。不要把反射依赖、JNI 字符串入口和 VMP 保护范围混成一个 keep 包;每类间接入口都要有明确调用者与测试。

类查找失败的分层判定
门禁检查证据失败表现修复方向
R8 可达性mapping、usage 和精确 keep类或成员被移除改名修正规则与注册
加载器来源锚点类和 loader 身份业务类不可见安装正确 ClassLoader
线程附加GetEnv 与 attach 责任没有有效 JNIEnv逐线程获取并清理
JNI 名称斜杠或点分隔规则查找目标格式错误按调用 API 使用正确名称
方法签名最终描述符与 method IDNoSuchMethod 或空 ID绑定精确签名
异常状态ExceptionCheck 和阶段码后续 JNI 调用失真立即处理并停止路径

不要把类加载失败误判成链接或 API 问题

NDK 常见兼容故障还包括 API level、缺失符号、STL、异常类型和错误依赖装载。这些问题可能在 System.loadLibrary、dlopen 或首次符号解析时出现,早于 FindClass,也可能在业务路径中表现为 UnsatisfiedLinkError。诊断应先确定目标 so 已按正确 ABI 加载、依赖符号可解析、JNI_OnLoad 返回成功,再进入类加载检查。重复调整 ClassLoader 不会修复缺失 Native 符号。

高于 minSdk 的 Native API 不能被无条件直接静态调用,必要时需要通过 dlopen 和 dlsym 做版本化处理。即使 API 在某个平台存在,动态链接 namespace 和设备实现仍可能影响可达性。这个边界与 App ClassLoader 不同:前者处理 Native 库与符号,后者处理 Java 类。日志中应使用不同阶段码,例如 library-load、symbol-resolve、thread-attach、class-load 和 method-lookup。

VMP 或 SO 保护可能改变加载顺序、库布局或异常边界,因此保护前后回归要保持相同 ABI、minSdk、依赖和类加载输入。出现问题时先用阶段码确认失败在哪一层,再比较最终 ELF 依赖、JNI 注册、R8 mapping 和 ClassLoader 来源。本文不宣称某种保护一定保持 ABI,实际结论必须来自同候选设备运行和故障回执。

  • 目标 ABI 的 so 和依赖在类查找前已成功加载
  • JNI_OnLoad 返回值与注册结果完整记录
  • Native 符号解析和 Java 类加载使用不同阶段码
  • 高版本 API 按 minSdk 和动态加载策略审查
  • 保护前后保持相同 ABI、依赖和类加载输入
  • ClassLoader 调整不会用于掩盖链接失败

用受控加载器包装器记录失败阶段

下面的 Java 示例展示加载器桥的应用侧实现。应用在受控 Java 上下文中用锚点类安装 ClassLoader,Native 在 JNI_OnLoad 缓存这个桥类的全局引用和 probe 方法 ID,原生线程附加后调用 probe,而不是再次 FindClass 业务类。示例接收诊断输入并返回稳定状态,不含真实业务类、令牌、地址或内部服务信息。

安装必须在原生工作线程创建前完成,source 字段只记录非敏感加载器来源。probe 使用 Class.forName 的非初始化模式检查可见性,区分 ClassNotFoundException 与 LinkageError,并拒绝无效类名。main 提供直接输入驱动的失败路径,便于公开示例独立验证;Android 生产代码不会依赖 main,而由受控 Native 桥调用静态方法。

生产实现还要在 Native 侧保存 JavaVM、逐线程取得 JNIEnv、管理桥类 GlobalRef、检查 CallStaticObjectMethod 异常并在主动附加的线程退出前 detach。若目标 jclass 或 method ID 需要缓存,也要绑定生命周期和候选版本。加载器桥只能修复上下文,不会修复错误的 R8 名称、JNI 签名、动态模块时序或 Native 链接问题。

用应用 ClassLoader 检查业务类可见性
import java.util.Objects;
import java.util.regex.Pattern;

public final class NativeClassLoaderProbe {
  private static final Pattern BINARY_NAME = Pattern.compile(
      "[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)+");
  private static volatile ClassLoader appLoader;
  private static volatile String loaderSource;

  private NativeClassLoaderProbe() {}

  public static synchronized void install(Class<?> anchor, String source) {
    Objects.requireNonNull(anchor, "anchor");
    if (source == null || source.isBlank()) {
      throw new IllegalArgumentException("loader source is required");
    }
    ClassLoader loader = anchor.getClassLoader();
    if (loader == null) {
      throw new IllegalStateException("anchor has no application class loader");
    }
    if (appLoader != null && appLoader != loader) {
      throw new IllegalStateException("a different class loader is already installed");
    }
    appLoader = loader;
    loaderSource = source;
  }

  public static Result probe(String dottedName, String entry) {
    if (dottedName == null || !BINARY_NAME.matcher(dottedName).matches()) {
      throw new IllegalArgumentException("invalid binary class name");
    }
    if (entry == null || entry.isBlank()) {
      throw new IllegalArgumentException("entry label is required");
    }
    ClassLoader loader = appLoader;
    if (loader == null) {
      throw new IllegalStateException("application class loader is not installed");
    }
    try {
      Class<?> found = Class.forName(dottedName, false, loader);
      return new Result(true, "ok", entry, loaderSource, found.getName());
    } catch (ClassNotFoundException error) {
      return new Result(false, "class-not-found", entry, loaderSource, dottedName);
    } catch (LinkageError error) {
      return new Result(false, "linkage-error", entry, loaderSource, dottedName);
    }
  }

  public static void main(String[] args) {
    if (args.length != 2) {
      throw new IllegalArgumentException("usage: class-name entry-label");
    }
    String className = args[0];
    if (!BINARY_NAME.matcher(args[0]).matches()) {
      throw new IllegalArgumentException("class-name input is invalid");
    }
    install(NativeClassLoaderProbe.class, "diagnostic-entry");
    Result result = probe(className, args[1]);
    if (!result.found()) {
      throw new IllegalStateException(result.status());
    }
    System.out.println(result.status() + ":" + result.loaderSource());
  }

  public record Result(boolean found, String status, String entry,
                       String loaderSource, String resolvedName) {}
}

在混淆和目标 ABI 设备上完成闭环

类加载语义依赖真实 ART、线程、ClassLoader、R8 输出和 Native ABI,应在设备端 instrumented test 中验证。矩阵至少覆盖 Java 线程直接入口、原生 pthread、重复附加检测、进程重建、加载器未安装、类名错误、方法签名错误、挂起异常、混淆 release 和每个交付 ABI。动态 feature 或多进程存在时,再增加模块安装前后和目标进程。

每条回执要绑定最终 APK 摘要、mapping、keep 规则、so 摘要、ABI、系统版本、加载器锚点、入口阶段和返回状态。单一设备成功不能代表完整 API、ABI 和厂商矩阵;一次 ClassNotFound 消失也不能证明全链路正确,还要调用目标方法、验证异常路径并观察线程退出。保护前后对照每次只改变 VMP 或 SO 保护范围,避免同时修改 R8 规则后无法归因。

完整发布材料应包含 JNI 注册表、类与方法查找清单、加载器安装时序、全局引用生命周期、精确 keep 规则、链接依赖、设备矩阵和失败日志。需要评估实际 SO 时,可通过御盾中央平台提交脱敏的 JNI 入口、虚构化类名映射、加载器来源、最终候选摘要和目标 ABI,先定位 ClassLoader、R8 或链接层,再安排保护边界回归。

  • Java 入口和纯 Native 线程入口分别验证
  • 加载器缺失、类名错误和签名错误均有失败回执
  • 混淆 release 使用对应 mapping 与精确 keep 规则
  • 每个交付 ABI 和最低支持系统都有代表设备
  • 进程重建与动态模块加载时序得到覆盖
  • 保护前后 JNI 异常、引用和线程退出语义一致

事实依据与适用边界

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

本文判断事实或工程依据适用限制
JNI 注册、线程、引用、异常与类加载边界会直接影响 Native 桥接稳定性。Android JNI tips 说明 JNI 线程、引用、异常、FindClass 和注册实践。JNI 建议不能证明某个 SO 加固变换保持 ABI 或类加载语义。
NDK 兼容故障常见于 API level、缺失符号、STL、异常类型和错误依赖装载。Android NDK common problems 汇总常见 Native 构建与运行问题。故障清单不能替代目标 ABI 的实际启动、类加载和异常回归。
反射、JNI 和间接入口需要精确 keep 规则。R8 keep rules best practices 说明间接入口与最小保留规则。keep 规则只描述 R8 可达性,不定义 ClassLoader 上下文或 VMP 可保护范围。
Kotlin 反射依赖运行时可发现的类、成员和元数据。Kotlin reflection 说明 Kotlin 运行时反射能力和依赖。反射文档不覆盖具体序列化框架、生成代码或 JNI 注册的全部行为。
依赖真实 Android 运行时、组件和系统 API 的行为应通过设备端测试验证。Android instrumented tests 说明 instrumented test 适用于真实 Android 环境。单一设备通过不能代表完整 API、ABI 和厂商矩阵。
高于 minSdk 的 Native API 不能被无条件直接静态调用,必要时需版本化动态加载。Android NDK stable APIs 说明 Native API level 和 dlopen、dlsym 使用边界。API 可用性不证明动态链接路径在所有 namespace 中可达,也不涉及 Java ClassLoader。
原生线程取得 JNIEnv 与取得应用 ClassLoader 上下文是两道独立门禁。工程判断:线程附加只建立 JNI 调用能力,业务类可见性仍需缓存加载器、类引用或受控 Java 入口。具体加载器选择仍受模块、进程、动态交付和候选构建影响。
类加载诊断应同时记录入口、加载器来源、最终名称、方法签名和异常状态。工程判断:分层字段可以区分线程、R8、ClassLoader、签名和挂起异常问题。诊断字段不能替代目标方法执行,也不应泄露真实业务类名和用户数据。

工程常见问题

AttachCurrentThread 成功后为什么 FindClass 仍失败?

附加只为当前线程取得 JNIEnv,不会自动继承应用 Java 调用栈和 ClassLoader,上下文不正确时业务类仍不可见。

可以把主线程的 JNIEnv 保存给原生线程吗?

不可以。JNIEnv 属于线程,应缓存 JavaVM,并由每个原生线程通过 GetEnv 或 AttachCurrentThread 取得自己的接口。

为什么推荐在 JNI_OnLoad 缓存少量类?

它有适合库加载的类加载语境,便于集中注册和建立全局引用,但仍需生命周期管理和精确 R8 规则。

加一条宽泛 keep 规则能解决所有 ClassNotFound 吗?

不能。它可能避免类被改名或移除,却不能修复错误 ClassLoader、线程复用、方法签名或挂起异常。

ClassLoader.loadClass 和 FindClass 的类名格式相同吗?

不同。loadClass 通常使用点分隔二进制名,FindClass 使用 JNI 斜杠形式;边界代码应固定一种 API 与格式。

准备 Native 线程类加载排查需要哪些材料?

准备最终 APK、JNI 注册和查找点、加载器锚点、R8 mapping 与 keep、so 和 ABI、异常阶段码、线程生命周期及设备回执。

想用自己的 App 验证?

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

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