查看原文
其他

JVMTI 在手淘 Profiler 中的应用

程超(伝逸) 阿里巴巴终端技术 2023-04-06

Android JVMTI

JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 Native 编程接口。通过这些接口,开发人员不仅可以调试在虚拟机上运行的 Java 程序,还能查看它们运行的状态、控制环境变量,甚至修改代码逻辑,从而帮助开发人员监控和优化程序性能。

Android 的 JVMTI 功能是从 Android 8.0(API 26)开始支持的,官方的叫法是 ART Tooling Interface (ART TI) 。提供的重要功能主要有:

  • 运行状态监控
  • 重定义 Class
  • 跟踪对象分配和垃圾回收过程
  • 遵循对象的引用树,遍历堆中的所有对象
  • 检查 Java 调用堆栈
  • 暂停和恢复所有线程

要使用 JVMTI 的能力,需要提供一个 Agent,利用 ART TI 和 Runtime 进行通信。JVMTI 支持在 JVM 启动时和运行时加载这个 Agent。

dalvikvm -Xplugin:libopenjdkjvmti.so -agentpath:/path/to/agent/libagent.so …

VM 启动时加载不适用于 Android 应用,因为 Android 应用的进程都是从已运行的 zygote 进程 fork 出来的。所以只能在运行时加载。系统提供了 am 命令和 Debug 接口两种加载方式。

adb shell 'am attach-agent com.example.android.displayingbitmaps
\'
/data/data/com.example.android.displayingbitmaps/code_cache/libfieldnulls.so=Ljava/lang/Class;.name:Ljava/lang/String;\''

Debug 接口是通过attachJvmtiAgent加载,这个是 Android9.0 才开始提供。

/**
 * Attach a library as a jvmti agent to the current runtime, with the given classloader
 * determining the library search path.
 * Note: agents may only be attached to debuggable apps. Otherwise, this function will
 * throw a SecurityException.
 *
 * @param library the library containing the agent.
 * @param options the options passed to the agent.
 * @param classLoader the classloader determining the library search path.
 *
 * @throws IOException if the agent could not be attached.
 * @throws a SecurityException if the app is not debuggable.
 */
public static void attachJvmtiAgent(@NonNull String library, @Nullable String options,
        @Nullable ClassLoader classLoader) throws IOException

Android8 上需要通过反射调用,但是反射的接口没有classLoader参数,这样会因为 namespace 问题导致 agent.so 无法调用第三方 so。一个简单的办法就是用一个空的 agent.so,加载它的作用只是用来初始化应用中的 JVMTI 环境。然后在通过System.load加载一个使用了 JVMTI 接口的 so 就可以了。

因为 JVMTI 提供了代码重定义的能力,所以 Android 上对 JVMTI 的功能进行了限制:

  • 只能在可调式的 APP 包中使用 (android:debuggable = true)
  • 未提供代码重定义相关的 Java 接口
  • 对于 APP 进程来说,无法在进程启动时加载 Agent
  • 尚未实现全部的 JVMTI 规范中的能力

突破限制

JVMTI 是一个强有力的监控工具,如果只能在 Debug 包上运行,那么实用性将大大降低。而且 Debug 包得到的数据本身就和实际线上环境相差很大,所以要想利用好 JVMTI,第一步就是要突破 Debug 包的限制。

限制原理

Agent 的加载主要包括两步:

  • EnsureJvmtiPlugin: 加载 libopenjdkjvmti.so, 初始化进程的 JVMTI 运行环境
  • AgentSpec.Attach: 加载 agent.so, 使用 JVMTI 的能力
static bool EnsureJvmtiPlugin(Runtime* runtime,
                              std::string* error_msg) {
  // TODO Rename Dbg::IsJdwpAllowed is IsDebuggingAllowed.
  DCHECK(Dbg::IsJdwpAllowed() || !runtime->IsJavaDebuggable())
      << "Being debuggable requires that jdwp (i.e. debugging) is allowed.";
  // Is the process debuggable? Otherwise, do not attempt to load the plugin unless we are
  // specifically allowed.
  if (!Dbg::IsJdwpAllowed()) {
    *error_msg = "Process is not allowed to load openjdkjvmti plugin. Process must be debuggable";
    return false;
  }

  constexpr const char* plugin_name = kIsDebugBuild ? "libopenjdkjvmtid.so" : "libopenjdkjvmti.so";
  return runtime->EnsurePluginLoaded(plugin_name, error_msg);
}

从源码中我们发现了限制 Debug 包的地方主要有两个接口:

  • Dbg::IsJdwpAllowed()
  • Runtime::IsJavaDebuggable()

这两个接口中返回的变量是在进程从ZygoteHooks_nativePostForkChild中设置的

static uint32_t EnableDebugFeatures(uint32_t runtime_flags) {

  Runtime* const runtime = Runtime::Current();

  // 设置JDWP
  Dbg::SetJdwpAllowed((runtime_flags & DEBUG_ENABLE_JDWP) != 0);
  if ((runtime_flags & DEBUG_ENABLE_JDWP) != 0) {
    EnableDebugger();
  }
  runtime_flags &= ~DEBUG_ENABLE_JDWP;

  // 设置Runtime的DEBUG_JAVA_DEBUGGABLE
  bool needs_non_debuggable_classes = false;
  if ((runtime_flags & DEBUG_JAVA_DEBUGGABLE) != 0) {
    runtime->AddCompilerOption("--debuggable");
    runtime_flags |= DEBUG_GENERATE_MINI_DEBUG_INFO;
    runtime->SetJavaDebuggable(true);
    // Deoptimize the boot image as it may be non-debuggable.
    runtime->DeoptimizeBootImage();
    runtime_flags &= ~DEBUG_JAVA_DEBUGGABLE;
    needs_non_debuggable_classes = true;
  }

  // 还有native debug相关,对JVMTI影响不大
  ...
  return runtime_flags;
}

这两个 flag 实际是由android:debuggable决定的,但是线上包设置为 true 开启 debug 是不可行的。

修改 JDWP

//Source: platform/art/runtime/debugger.cc

// JDWP is allowed unless the Zygote forbids it.
static bool gJdwpAllowed = true;

bool Dbg::IsJdwpAllowed() {
  return gJdwpAllowed;
}

void Dbg::SetJdwpAllowed(bool allowed) {
  gJdwpAllowed = allowed;
}

从源码看到,gJdwpAllowed是一个静态变量。我们可以通过dlsym的方式获取到SetJdwpAllowed方法符号来修改他的状态。需要注意的是,这个符号在libart.so中,Android 7.0 对加载系统库进行了限制。不过目前绕过的方式都比较成熟,我们采用了读取/proc/self/maps查找 libart.so 和解析 ELF 的方式获取到了此方法的符号。

修改 Runtime

//Source: platform/art/runtime/runtime.h

// Whether Java code needs to be debuggable.
bool is_java_debuggable_;

bool IsJavaDebuggable() const {
    return is_java_debuggable_;
}

void Runtime::SetJavaDebuggable(bool value) {
  is_java_debuggable_ = value;
  // Do not call DeoptimizeBootImage just yet, the runtime may still be starting up.
}

is_java_debuggable_Runtime中的一个成员变量,没办法采用和 JDWP 一样的方式进行修改。但是在 C 层我们通JavaVM对象可以拿到唯一Runtime的对象

class JavaVMExt : public JavaVM {
private:
  Runtime* const runtime_;
}

然后通过内存布局找到is_java_debuggable_地址进行修改。虽然我们针对不同版本进行适配, 但是手机厂商可能会对 Runtime 结构进行修改,这就会导致一旦修改了错误的地址,可能会引发 Crash。

class Runtime {

  // Specifies target SDK version to allow workarounds for certain API levels.
  int32_t target_sdk_version_;
  CompatFramework compat_framework_;
  bool implicit_null_checks_;       // NullPointer checks are implicit.
  bool implicit_so_checks_;         // StackOverflow checks are implicit.
  bool implicit_suspend_checks_;    // Thread suspension checks are implicit.
  bool no_sig_chain_;
  bool force_native_bridge_;
  bool is_native_bridge_loaded_;
  bool is_native_debuggable_;
  bool async_exceptions_thrown_;
  bool non_standard_exits_enabled_;
  // Whether Java code needs to be debuggable.
  bool is_java_debuggable_;

}

从源码看到,在这个字段之前,有一些字段的值是相对固定的,我们可以通过这些字段作为参考点,来更准确的找到is_java_debuggable_。这里我们主要选取了target_sdk_version_作为结构起始的参考点,而后面几个 bool 值变量,则成为我们检验的字段,因为他们的值也是相对固定的。

Android12 之后多了一个compat_framework_字段,使用偏移量进行计算容易出错,所以我们自己定义了MockRuntime对象,以target_sdk_version_地址为起点进行对象转换。这里又涉及到CompatFramework 内部的结构以及内存对齐的问题,这里就不展开了。

对 JDWP 和 Rumtime 修改之后,Agent 就可以成功的在 Release 环境加载了,我们在加载完成后会恢复原有的值,保证不影响 APP 的正常运行。针对线上加载失败的情况,我们把 Runtime 的内存 Dump 后进行了线下分析,发现基本都是在一些双开模拟器中获取不到target_sdk_version_导致失败。所以我们也增加了多开设备的开关。

使用受限版本

通过对 ART 中 JVMTI 源码的深入分析,我们发现源码中提供给了一个kArtTiVersion的版本号。

// A special version that we use to identify special tooling interface versions which mostly matches
// the jvmti spec but everything is best effort. This is used to implement the userdebug
// 'debug-anything' behavior.
//
// This is the value 0x70010200.
static constexpr jint kArtTiVersion = JVMTI_VERSION_1_2 | 0x40000000;

当 JVMTI 以这个版本号运行时,他只提供了部分功能,而使用JVMTI_VERSION_1_2版本时则提供了完整功能。从代码可以看到,如果运行在 Debug 环境或者完全解释执行的环境时,所有功能可用。也就是说这个受限版本可以在 Release 包使用。

// Returns whether we are able to use all jvmti features.
static bool IsFullJvmtiAvailable() {
  art::Runtime* runtime = art::Runtime::Current();
  return runtime->GetInstrumentation()->IsForcedInterpretOnly() || runtime->IsJavaDebuggable();
}

通过源码分析,我们发现使用受限版本只是无法使用调试和热修复的能力,但是监控和获取运行信息的能力基本不受影响。也从另一个方面说明 JVMTI 进行线上监控的可行性。

// These are capabilities that are disabled if we were loaded without being debuggable.
//
// This includes the following capabilities:
//   can_retransform_any_class:
//   can_retransform_classes:
//   can_redefine_any_class:
//   can_redefine_classes:
//   can_pop_frame:
//   can_force_early_return:
//     We need to ensure that inlined code is either not present or can always be deoptimized. This
//     is not guaranteed for non-debuggable processes since we might have inlined bootclasspath code
//     on a threads stack.

考虑到后续可能会使用到更多的能力,所以我们最终没有使用受限版本,还是通过 Mock 方式开启了全功能版本。但是要注意的是,即便使用了全功能版本,要使用全部的能力,还有许多事情需要解决。

TBProfiler

JVMTI 的功能比较强大,但是使用不当可能对应用性能产生严重的影响。为了合理方便的使用 JVMTI 的能力,我们对 JVMTI 进行了封装成了 Android 的 Profiler 工具--“TBProfiler”,下面就介绍一下手淘中主要使用的一些能力。

运行监控

为了支持 JVMTI 功能,Android Runtime 从 8.0 开始引入了RuntimeCallbacks,用于统一管理运行时信息的回调通知。对于Heap和其他一些功能模块则是提供了对应的Listerner。JVMTI 则通过EventHandler统一进行管理,把相关接口暴露给 Agnet 使用。

方法调用监控

JVMTI 提供了MethodEntryMethodExit回调来监听方法的执行和退出。包括应用和系统方法。所以打开之后对性能的影响还是很大的。线下可以用来监控方法调用,生成类似 SystemTrace 的信息。但更合适的场景是获取特定的一个或一类方法的调用频率、调用时长、调用堆栈等信息,帮助问题排查。这样可以大幅减少对性能的影响,在高端设备上的性能影响并不明显。

线程和类监控

当线程创建或销毁、Class 加载时可以通过回调通知我们。这 2 个监控能力目前只在 C 层进行处理,并没有抛给 Java 层。因为在 Java 层处理可能存在嵌套的线程调用以及嵌套的 Class 加载。对于类加载我们目前主要是在线下使用,比如用来检查应用启动过程中加载的类和加载的顺序,配合方法调用的监控,辅助进行 Dex 重排、PGO 文件生成,来提升启动阶段的性能。

异常捕获

JVMTI 提供了EventExceptionEventExceptionCatch两个回调来监听应用中发生的被捕获和未捕获的异常。很多时候我们写代码都是使用try-catch代替了条件检查,这样虽然代码不会崩溃,但是可能会导致一部分功能异常,却又难以发现。通过EventExceptionCatch回调,可以获取到被捕获的异常信息。

这里有几点注意:

  • 系统自身有大量的捕获异常,可以根据异常方法所属的类进行过滤,过滤掉系统的异常
  • 因为系统异常量很大,需要做采样提高性能
  • 需要处理异常回调中递归的问题
  • 开启后性能有严重影响,仅线下使用

主线程锁监控

目前手淘线上的一部分 ANR、卡顿问题可能都是由于死锁、主线程长时间等锁导致的。如果能在这些情况拿到持有锁线程的堆栈信息,可以帮助我们解决一部分问题。JVMTI 中提供了相应的回调事件以及获取运行信息的接口,利用这些能力我们在不需要 Hook 系统方法的情况下,实现了线上的主线程长等锁的监控。在平台上通过堆栈聚合,可以清楚的显示出那些操作导致了主线程的卡顿。

目前 Android 中锁的实现主要有两种:

  • ART 实现:Monitor
  • JDK 实现:AQS+CAS

Monitor

在 Java 层使用Synchronized,或者是Objectwaitnotify方法进行线程间的同步。在 ART 内是通过Monitor实现的,最终系统层面是通过自旋锁 + mutex 的方式来实现。

所有的Synchronized锁都有一个等待的Object对象,我们在主线程开始等锁的时候,获取到这个jobject,并启动一个线程设置超时时间。如果达到阈值时主线程还没有获取到锁,我们就会去获取持有这个锁的线程的堆栈信息。当主线程获取到这个锁时,我们把收集到的信息进行上报。我们在达到阈值而不是主线程获取到锁时去抓堆栈,主要是因为主线程一旦获取到锁,我们就拿不到之前持有锁的线程的堆栈信息了。

这里我们看到主线程在加载动画时,因为触发调用了系统的getResourceValue方法,等待了Synchronized锁 917ms。而另一个线程也在操作AssetManager并持有了这个锁。

查看系统源码,他们都把AssetManager对象作为锁。

public final class AssetManager implements AutoCloseable {
    @UnsupportedAppUsage
    boolean getResourceValue(@AnyRes int resId, int densityDpi, @NonNull TypedValue outValue,
            boolean resolveRefs) {
        Objects.requireNonNull(outValue, "outValue");
        synchronized (this) {
            ensureValidLocked();
            final int cookie = nativeGetResourceValue(
                    mObject, resId, (short) densityDpi, outValue, resolveRefs);
            if (cookie <= 0) {
                return false;
            }

            // Convert the changing configurations flags populated by native code.
            outValue.changingConfigurations = ActivityInfo.activityInfoConfigNativeToJava(
                    outValue.changingConfigurations);

            if (outValue.type == TypedValue.TYPE_STRING) {
                if ((outValue.string = getPooledStringForCookie(cookie, outValue.data)) == null) {
                    return false;
                }
            }
            return true;
        }
    }


    public @Nullable String[] list(@NonNull String path) throws IOException {
        Objects.requireNonNull(path, "path");
        synchronized (this) {
            ensureValidLocked();
            return nativeList(mObject, path);
        }
    }
}

目前线上大部分都是Synchroized锁,有了明确的堆栈信息,我们就可以结合实际场景来分析和优化,减少主线程的等待时间。当然可能并不是所有的 case 都可以直接进行解决和优化,但是有了这些信息可以帮助我们查找可能存在问题和可以优化的点。

AQS

Monitor不同,这个是 Java 实现,内部使用CLH队列 + CAS来实现线程同步。简单说就有一个state标识资源使用状态。可以独占也可以共享。线程请求资源时会定义为一个Node,获取到资源的通过CAS修改state,没有获取到锁的Node被添加到CLH队列中,然后挂起。当资源被释放时会唤起等待的Node,AQS 中通过LockSupport这个类实现线程的挂起和唤醒。

因为 AQS 锁的实现机制,所以无法像Synchroized锁那样直接确定谁持有了锁。但是可以通过抓取到的信息来分析这样的等待是否合理,是否有优化的空间。

内存监控

JVMTI 中另一个比较重要的功能是内存监控。JVMTI 中提供了 4 个内存相关的回调,包括内存分配和回收、GC 开始和结束。

callback_->gcStart = MemoryUtils::HandleGCStart;
callback_->gcFinish = MemoryUtils::HandleGCFinish;
callback_->objectAlloc = MemoryUtils::HandleObjectAlloc;
callback_->objectFree = MemoryUtils::HandleObjectFree;

利用这些能力我们可以监控线上大内存的分配、总的内存分配和回收量、以及准确知道 GC 发生时间和次数。

void MemoryUtils::HandleObjectAlloc(jvmtiEnv *jvmti_env,
                                    JNIEnv *jni_env,
                                    jthread thread,
                                    jobject object,
                                    jclass object_klass,
                                    jlong size) {}

通过回调函数我们可以拿到分配对象的类型、大小以及线程。通过线程我们又可以获取到当前的堆栈信息。比如监控大内存的分配,当分配的大小超过阈值时,收集到的信息抛到 Java 层封装为一个BigMemoryAllocException,通过异常信息进行上报聚合。

这样就很容看到是那个线程在做什么操作分配了大内存。回到大内存监控实现本身,有一些问题是需要注意的:

  • 性能问题:我们主要通过设定内存大小阈值和采样的方式减少对性能的影响。并且线上主要是为了收集数据,可以只针对高端机型采样开启;
  • 数据收集问题:收集信息时我们主要会用到jclassjthread两个对象来获取类型和堆栈。但是因为他们都是 local reference 对象,所以不能传递到其他线程,否则需要NewGlobalReference。所以在 C 层回调中只做必要信息收集,抛到 Java 层线程进行数据加工处理,减少性能影响;
  • 内存分配递归:我们回调过程中处理数据时可能会再次触发内存分配,所以必须要做防递归的处理,我们使用thread_local 来记录每个线程的递归情况,发生递归则退出处理。

内存 Dump

目前处理 Java 内存问题时,hprof 文件是最有效的方式。TBProfiler 中也有线上 Dump Hprof 的能力。虽然我们对 hprof 文件做了裁剪,但是体积还是比较大。从线上数据来看,在内存即将触顶(GC 回收后 HeapSize >= 95%)时,Dump 的 hprof 文件大小基本都在 700M 左右。通过裁剪(ImageSpaceZygoteSpace下的对象、原始数组的值、hprof 协议中使用不到字段等)和 zstd 压缩,最终得到的 hprof 文件在 90M 左右。整个过程对于用户磁盘空间(>1G)、网络条件(WIFI)都有一定要求。主要原因在于 hprof 文件内容大而全,而利用 JVMTI 能力,我们可以定制化的生成内存信息。

对象实例信息

分析 hprof 时,我们可以通过 MAT 工具查看每个类的实例数量、Shallow Size 和 Retained Size 信息。我们可以通过分析对象实例数的方式判断那些组件存在泄露。比如某一个 Activity 具有很多个实例时,我们可以判断他存在泄露。

Hprof 虽然很好用,但是他很大。通过 JVMTI 遍历 Heap 堆上的对象(不包含不可达对象),我们可以实现类似的功能,区别在于无法提供 Retained Size,因为这个需要引用关系。

但是这个的目的是发现泄露,而不是定位泄露 。并且我们可以统计内存触顶时大致分布情况,经过聚合,可以对 Shallow Size 过大的类进行关注和进一步分析。并且生成的文件非常小,手淘内存触顶时,全量 Dump 所有对象实例信息,生成的文件经过压缩后只有不到 150K,可以方便快速的上报。快速的了解 OOM 时内存的分布状况。

JVMTI 生成 Hprof

实例信息只能帮我们发现一小部分问题,要定位和解决内存泄露,最重要的还是要能够拿到对象的之间的引用关系,找到异常实例无法释放的原因,我们还是需要依赖 Hprof 文件。既然 JVMTI 可以遍历 Heap 对象,我们是否可以利用 JVMTI 能力来生成 hprof 文件呢?

上图就是我们利用 JVMTI 能力生成符合 Android 标准的 Hprof 文件,可以成功了在 Android Studio 中打开。JDK 中就是利用 JVMTI 遍历 Heap 生成的,而 Android 中则是直接读取 Runtime 数据生成的。Android Hprof 文件主要包括:

  • VERSION:JAVA PROFILE 1.0.3。这个不同于 JDK 的 JAVA PROFILE 1.0.2。
  • LOAD CLASS:已加载类的列表,通过GetLoadedClasses 接口可以获取全部加载的类的信息;
  • ROOT: GC Root 对象信息, 通过FollowReferences遍历对象间的引用关系来确定 GC ROOT 对象;
  • CLASS DUMP: 这是类的详细信息,包含了 Super Class、ClassLoader、类的字段名、字段类型、静态字段值等信息。构建 CLASS DUMP 信息是整个过程中最复杂的。这里涉及到GetImplementedInterfacesGetClassSignatureGetClassFieldsGetFieldModifiersGetFieldName 等接口的调用;
  • INSTANCE DUMP:这是实例对象的信息,主要是实例对象的 ID 和字段值。如果是类对象,那么他的值就是引用对象的 ID。这些值通过jvmtiPrimitiveFieldCallbackjvmtiStringPrimitiveValueCallbackjvmtiHeapReferenceCallback 获取。
  • ARRAR DUMP:这个是数组,数组的值通过jvmtiArrayPrimitiveValueCallbackjvmtiHeapReferenceCallback 获取。

具体生成 hprof 过程还有很多需要注意的地方,因为 Android 本身 Dump 会有一些额外的操作,便于在 Android Studio 中显示更多的信息,这里就不介绍具体的生成过程了, 可以参考 Android 和 JDK 中生成 Hprof 的过程。

Mini Hprof

因为 Android 生成 Hprof 是直接在 Rumtime 中读取数据,所以相对于 JVMTI 的生成速度要快很多的,并且能获取更多的信息。通过 JVMTI 来生成 Hprof 在生成速度上有明显劣势,经过剪裁和压缩在文件大小上也没有明显优势。而且还有比较高的实现复杂度,生成的数据也不如系统生成的完备。看起来并没有很大的实用性。但是从中我们知道,通过 JVMTI 我们有能力收集到完整的 Heap 信息。这样我们就不必按照 Hprof 的格式去获取和存储数据,可以自定义数据。

对于排查内存泄漏问题,最重要的是获取到泄露对象到 GC Root 的引用信息。利用 JVMTI 遍历 Heap 的能力,我们可以方便的获取到所有对象之间的引用关系。并且可以区分出引用的类型,一般有 GCRoot 引用、实例字段引用、类静态字段引用、对象数组引用。

这里的数据可以包含对象的 ID、所属 Class、对象大小、引用和被引用对象的 ID 以及类型。加上所有 Class 的信息,我们可以生成一份自定义的 Mini Hprof 文件。虽然在生成时间上要比系统花费的更久,但是我们可以得到更小的数据。

针对ActivityFragment的泄漏, 我们可以在 Java 层通过 WeakReference 获取到关闭后仍然存活的对象类名,利用这个引用关系图,可以在单独的进程中计算出泄露对象的引用链并进行上报。如果不需要上报 RetainedSize,就不需要构建支配树,这样可以大大减轻端侧内存压力和处理速度。

总结和展望

目前在排查线上问题时,主要还是以捞取日志分析为主,但对于内存、卡顿、ANR 等性能相关的问题,日志并不能提供足够的信息进行分析。而 JVMTI 使得我们离 Runtime 更近一步,通过 JVMTI 的能力,我们能方便的获取到更丰富的运行时信息,辅助我们进行问题排查。但线上复杂的情况也不是一个 JVMTI 能完全覆盖的。我们需要类似 Android Studio 中的 Profiler 工具,从各个域收集相关的运行信息。从工具层面,完善工具链,提供高效稳定的线上 Profiler 工具;数据层面,整合工具的数据,生成自定义的 Profile 文件,以统一的方式对异常数据进行上报和分析,提高整体问题的定位排查效率。





您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存