在上一章中,我们介绍了通过线程和多进程来优化虚拟内存的两种方案,它们已经能解决大部分问题了。但有的时候,这两种方案依然没法彻底优化虚拟内存,解决应用序崩溃,那就需要用到一些非常规的“黑科技”手段了。今天,我们就来介绍两种。第一种是释放 Android 系统为 WebView 保留的虚拟内存
在详细讲解这两种方案之前,我想先讲讲我们是怎样发现这些非常规优化方案的,因为你可能会担心:这些手段只有技术大牛才能想出来,普通开发者很难想到。实际上,“非常规”方案和我们常用的通用方案一样,同样是基于底层原理寻找方法论,并衍生而出一系列的优化方案,然后对这些方案进行可行性验证的。
那么这一章提到的两种方案是如何发现的呢?在《掌握 App 运行时的内存模型》这一章中我们就讲过,应用进程中所有已分配的虚拟内存都会记录在 maps 文件中,那这一点便是底层原理。基于这一原理,如果我们一行一行地去分析应用的 maps 文件,寻找到应用中已经分配但可能用不上的虚拟内存空间,然后将这些空间想办法释放掉,是不是就能优化不少虚拟内存呢? 如果我们从 maps 文件中找到的可释放的点越多,我们的方案也就越多,然后我们将这些方案一一进行可行性验证即可。这一章提到的两个优化方案,都是通过分析 maps 文件后,发现占用了较多虚拟内存,然后尝试是否可以优化,最后验证优化是可行的。
这样的思路不仅适用于内存相关的优化,也适用于其他方面的优化。在后面的章节中还有很多关于优化思路的讲解,我们也都会遵循先原理,后方案,再总结方法论这样的讲解思路,希望你能把这样的思路记下来,尝试应用在工作中,最终形成你自己的优化方法论。
了解了非常规优化方案的寻找思路,我们马上进入实践,看看这两个方案的具体实施过程吧!
释放为 WebView 保留的虚拟内存
通过分析系统“设置”这个应用的 maps 文件,可以发现有一块 anno:libwebview reservation 的虚拟内存空间的分配,通过计算(722d0a6000 - 71ed0a6000 = 1024 M ),这一块内存的大小有 1G,对于设置来说,是没有 WebView 页面的,所以这一块虚拟内存实际上是用不上的。
libwebview reservation 的内存空间不仅“设置”这个应用有,安装在 Android 系统上的每一个应用都会有,即使我们新建一个空 Android 项目,将这个空应用跑起来之后,也能看到这一块虚拟内存的申请。实际上这部分虚拟内存是为了打开 WebView 页面预留的空间,64 位机上是 1G 大小,32 位机上是 130M 大小,其他非 ARM 机器是 190M。通过源码 WebViewLibraryLoader.java 我们可以看到它的逻辑。
这一块空间实际是在 Zygote 进程就已经申请了,Zygote 进行中会加载 webviewchromiun_loader 这个 so 库并申请一定大小的虚拟空间,所有应用的进程都是通过 Zygote 进程 fork 的,所以也都会保留这一块区域。如果我们的应用不需要使用系统 WebView ,或者我们已经把 WebView 的使用场景放到了子进程中,那我们完全可以在主进程上释放这一块空间,节约出 130M 的虚拟内存。
通过前面的学习我们知道,虚拟内存都是通过 mmap 函数来申请的,那要释放虚拟内存,只需要调用 munmap()即可。
int munmap(void *start, size_t length)
所以如果我们想要释放这部分内存,只需要在 Native 层调用如下代码:
// 722d0a6000 是起始地址,1073741824是1G转换成字节的大小
munmap(0x722d0a6000, 1073741824)
但由于 libwebview reservation 这块空间的地址并不是固定的,所以我们并不能将地址写死成 722d0a6000。并且 1G 是 64 位机的大小,64 位上我们并不需要担心虚拟内存不足,所以我们只需要判断是否是 32 位机就可以了。如果是,则读取 maps 文件,解析 libwebview reservation,取出首地址和尾地址,计算 size,然后调用 munmap 函数。maps 文件的读取和解析逻辑,我们在《掌握 App 运行时的内存模型》中其实也见过,不过这里还是再演示一遍:
- 解析 maps,并寻找 libwebview reservation 区域的首尾地址。
static bool FindReservedSpaceByParseMaps(void** start_out, size_t* size_out) {
bool found = false;
IterateMaps([&](uintptr_t start, uintptr_t end, char perms[4], const char* path, void* args) -> bool {
if (perms[0] != '-' || perms[1] != '-' || perms[2] != '-' || perms[3] != 'p') {
return false;
}
//判断是否为anon:libwebview reservation
if (std::strcmp(path, "[anon:libwebview reservation]") == 0) {
*start_out = reinterpret_cast<void*>(start);
*size_out = static_cast<size_t>(static_cast<uint64_t>(end) - static_cast<uint64_t>(start));
found = true;
return true;
}
return false;
});
return found;
}
bool IterateMaps(const MapsEntryCallback& cb, void* args) {
if (cb == nullptr) {
return false;
}
FILE* fp = nullptr;
char line[PATH_MAX] = {};
// 读取maps文件
if ((fp = std::fopen("/proc/self/maps", "r")) == nullptr) {
return false;
}
// 通过行读取和解析maps
while(std::fgets(line, sizeof(line), fp) != nullptr) {
uintptr_t start = 0;
uintptr_t end = 0;
char perm[4] = {};
int pathnamePos = 0;
if (std::sscanf(line, "%" PRIxPTR "-%" PRIxPTR " %4s %*x %*x:%*x %*d%n", &start, &end, perm, &pathnamePos) != 3) {
continue;
}
if (pathnamePos <= 0) {
continue;
}
while (std::isspace(line[pathnamePos]) && pathnamePos <= static_cast<int>(sizeof(line) - 1)) {
++pathnamePos;
}
if (pathnamePos > static_cast<int>(sizeof(line) - 1)) {
continue;
}
size_t pathLen = std::strlen(line + pathnamePos);
if (pathLen == 0 || pathLen > static_cast<int>(sizeof(line) - 1)) {
continue;
}
char* pathname = line + pathnamePos;
while (pathLen >= 0 && pathname[pathLen - 1] == '\n') {
pathname[pathLen - 1] = '\0';
--pathLen;
}
// 将读取到的maps行信息回调
if (cb(start, end, perm, pathname, args)) {
return true;
}
}
return false;
}
- 释放 libwebview reservation 区域的虚拟内存。
void* reservedSpaceStart = nullptr;
size_t reservedSpaceSize = 0;
if (LocateReservedSpaceByParsingMaps(&reservedSpaceStart, &reservedSpaceSize)) {
result = true;
}
if (result) {
//释放虚拟内存
munmap(reservedSpaceStart , reservedSpaceSize)
}
看到这,你可能想说,这很简单嘛,哪称得上黑科技呢!如果你觉得这个方案很简单,那可就错了,上面只是实现了这个优化方案的一半而已。实际上,Android 10 的系统才开始将这一块区域命名为 libwebview reservation,通过对比 9.0 和 10.0 的源码可以看到,10 开始才会调用 prctl 函数将这块区域进行命名。
对于 Android 10 以下的机型,这块区域在 maps 文件中是匿名的,我们没法根据 libwebview reservation 这个字段来找出这块区域,所以解析 maps 的方案便不生效了,如何找到这块区域的地址,便是整个方案的难点。
通过上面的源码我们可以发现,通过 mmap 申请这块内存后,将地址和大小分别赋值给了 gReservedAddress 和 gReservedSize 这两个静态变量,所以我们只需要想办法知道 gReservedAddress 的值就能解决这个难点了。获取 gReservedAddress 的值的方案有多种,这里我主要介绍微信的方案,下面就详细介绍一下这个方案的实现思路以及细节。
我们首先通过在 Android 源码中全局搜索 gReservedAddress,发现它仅被 loader.cpp 这个对象中的方法使用到。
分别是 DoReserveAddressSpace 、DoCreateRelroFile 和 DoLoadWithRelroFile 这三个方法。
在 DoCreateRelroFile 和 DoLoadWithRelroFile 方法中,我们可发现 gReservedAddress 和 gReservedSize 会被封装在 extinfo 结构体中,然后作为入参,调用 android_dlopen_ext 函数,这个函数是 libdl.so 库中的函数。
我们已经了解了 plt hook 技术,它专门用来 hook 外部库的调用函数。对于 webviewchromiun_loader 这个库来说,android_dlopen_ext 刚好是一个外部函数,所以我们只需要 通过 plt hook 技术 hook 住 webviewchromiun_loader 这个 so 中的 android_dlopen_ext 函数,就能拿到 extinfo 数据,进而拿到 gReservedAddress 和 gReservedSize 的值了。这里还是以 bhook 作为工具来演示具体的代码实现:
//1. 通过bhook,hook住webviewchromiun_loader 这个 so库中的android_dlopen_ext函数
bytehook_stub_t bytehook_hook_single(
null,
"libwebviewchromium_loader.so",
reinterpret_cast<void*>(android_dlopen_ext),
reinterpret_cast<void*>(android_dlopen_ext_hook),
bytehook_hooked_t hooked,
void *hooked_arg);
/*2. extinfo实际是一个android_dlextinfo结构体,
但是因为在我们的hook函数中无法直接使用这个结构体,
所以我们按照原来结构体的数据结构构造一个*/
typedef struct {
uint64_t flags;
void* reserved_addr;
size_t reserved_size;
int relro_fd;
int library_fd;
off64_t library_fd_offset;
struct android_namespace_t* library_namespace;
} android_dlextinfo;
//3. 在hook函数中获取gReservedAddress和gReservedSize的值
static void* android_dlopen_ext_hook(const char* filepath, int flags, void* extinfo) {
//将extinfo强制转换成android_dlextinfo结构体
auto android_extinfo = reinterpret_cast<android_dlextinfo*>(extinfo);
//然后就能直接拿到reserved_addr和reserved_size的值了
sReservedSpaceStart = android_extinfo->reserved_addr;
sReservedSpaceSize = android_extinfo->reserved_size;
//调用原函数
BYTEHOOK_CALL_PREV();
}
//4. 释放对于的虚拟内存空间
munmap(sReservedSpaceStart ,sReservedSpaceSize )
当我们做完上面一系列操作后,还有一个问题需要解决,那就是执行 DoCreateRelroFile 或者 DoLoadWithRelroFile 这两个方法,如果这两个方法都不执行的话,android_dlopen_ext 根本就不会被调用到,我们自然也拿不到想要的数据。这两个方法是在 zygote 进程中被执行的,在我们自己的应用中,如果不启动 webview 的话,这两个方法也不会被执行,所以我们需要在应用中通过代码主动执行这两个函数中的一个。
怎么才能执行这两个函数呢?通过分析源码后会发现,想要主动调用这两个函数其实很简单,因为这两个函数其实可以通过 CreateRelroFile 和 LoadWithRelroFile 这两个 JNI 方法来调用,所以我们是否只需要直接在 Java 层调用 System.loadLibrary("webviewchromiun_loader "),然后调用这其中一个 JNI 方法就可以实现目的了呢?
答案是不行的。上面的流程看起来似乎没啥问题,但当我们实际去操作时却无法跑通。首先,webviewchromiun_loader 这个 so 实际已经被加载进我们应用的进程了,因为这个 so 是被 zygote 进程加载的,而通过 zygote fork 出来的应用进程会共享父进程的虚拟内存中的数据,自然就加载了这个 so。并且,即使我们想在 Java 层调用 System.loadLibrary 重新加载一次这个 so,也没法做到。这是因为从 Android7.0 版本开始,便不允许应用加载系统的 so 库了,而 webviewchromiun_loader 是一个系统的 so 库,自然无法正常加载。
其次,我们直接在 Java层调用 nativeLoadWithRelroFile 或者 CreateRelroFile 这两个 native 函数也行不通,因为系统会自动将这个 native 方法加上包名前缀,导致我们找不到这个 native 方法。
那么如何才能执行这两个函数呢?幸运的是,我们在 native 层就能调用这两个方法了,这里以调用 nativeLoadWithRelroFile 函数为例,我们只需要通过 env->FindClass 拿到这个函数对应的 Java 对象就能执行这个方法了。下面是代码实现流程,虽然很长,但大部分只是为了兼容不同操作系统版本而写的兼容性代码,所以实现起来其实并不难。
//1. 在 jni 层调用nativeLoadWithRelroFile函数
static bool LocateReservedSpaceByProbing(JNIEnv* env,
jint sdk_ver, jobject class_loader) {
jclass loaderClazz = env->FindClass("android/webkit/WebViewLibraryLoader");
const char* probeMethodName = "nativeLoadWithRelroFile";
const char* LS = "java/lang/String";
const char* LC = "java/lang/ClassLoader";
jstring jProbeTag = env->NewStringUTF(PROBE_REQ_TAG);
jstring jFakeRelRoPath = env->NewStringUTF(FAKE_RELRO_PATH);
jmethodID probeMethodID = nullptr;
jint probeMethodRet = 0;
//不同的Android系统版本中,nativeLoadWithRelroFile 函数的入参是不一样的,所以这里通过遍历,把所有可能的入参情况都调一遍
for (int i = 1; probeMethodID == nullptr && i <= 4; ++i) {
switch (i) {
case 1: {
const char* typeNameList[] = {LS, LS, LC};
// 拿到nativeLoadWithRelroFile方法的methodId
probeMethodID = GetMethodIDByMetaReflect(env, loaderClazz, probeMethodName,
typeNameList, NELEM(typeNameList));
if (probeMethodID != nullptr) {
// 执行nativeLoadWithRelroFile方法
probeMethodRet = env->CallStaticIntMethod(loaderClazz, probeMethodID,
jProbeTag, jFakeRelRoPath, class_loader);
env->ExceptionClear();
}
break;
}
case 2: {
const char* typeNameList[] = {LS, LS, LS, LC};
probeMethodID = GetMethodIDByMetaReflect(env, loaderClazz, probeMethodName,
typeNameList, NELEM(typeNameList));
if (probeMethodID != nullptr) {
probeMethodRet = env->CallStaticIntMethod(loaderClazz, probeMethodID,
jProbeTag, jFakeRelRoPath, jFakeRelRoPath, class_loader);
env->ExceptionClear();
}
break;
}
case 3: {
const char* typeNameList[] = {LS, LS, LS, LS, LC};
probeMethodID = GetMethodIDByMetaReflect(env, loaderClazz, probeMethodName,
typeNameList, NELEM(typeNameList));
if (probeMethodID != nullptr) {
probeMethodRet = env->CallStaticIntMethod(loaderClazz, probeMethodID,
jProbeTag, jProbeTag, jFakeRelRoPath, jFakeRelRoPath, class_loader);
env->ExceptionClear();
}
break;
}
case 4: {
const char* typeNameList[] = {LS, LS, LS, LS};
probeMethodID = GetMethodIDByMetaReflect(env, loaderClazz, probeMethodName,
typeNameList, NELEM(typeNameList));
if (probeMethodID != nullptr) {
if (LIKELY(sdk_ver >= 23)) {
probeMethodRet = env->CallStaticIntMethod(loaderClazz, probeMethodID,
jProbeTag, jProbeTag, jFakeRelRoPath, jFakeRelRoPath);
} else {
probeMethodRet = env->CallStaticBooleanMethod(loaderClazz, probeMethodID,
jProbeTag, jProbeTag, jFakeRelRoPath, jFakeRelRoPath)
? LIBLOAD_SUCCESS : LIBLOAD_FAILURE;
}
env->ExceptionClear();
}
break;
}
default: {
break;
}
}
}
if (probeMethodID == nullptr) {
return false;
}
if (probeMethodRet == LIBLOAD_SUCCESS) {
return true;
} else {
return false;
}
}
通过上面的流程,应用就多出了 130M 的可用虚拟内存。
除了上面提到的方案,我们还有其他的方案能实现同样的目的。比如 gReservedAddress 作为一个未初始化的全局变量,会存放在 bss 段。所以我可以解析 maps 文件,找到 webviewchromiun_loader 这个 so 库的地址并转换成 ELF 格式之后,通过寻找和遍历 bss 段就能获取到 gReservedAddress 的值了。并且 webviewchromiun_loader 这个 so 库只有 7 个全局变量,所以 bss 段中只有 7 条数据,很容易就可以找到。前面介绍 PLT Hook 的实现原理,其实也和这有点类似,通过拿到 so 的地址并转换成 ELF 格式,之后遍历 dynamic 段修改 got 表的值。
小结
一般只有常规的优化方案都使用完,依然还有很多因为虚拟内存导致的程序崩溃时,我们才会使用这些非常规的方案,但是作为一名技术人员,对技术的追求应该是无止境的,所以即使我们用上这些方案的机会不多,但是依然需要知道这些方案的技术点及原理,并能从这些方案中吸收优化的思路,举一反三,扩展出更多的用法。