虚拟内存优化:“黑科技”优化手段

3,715 阅读12分钟

在上一章中,我们介绍了通过线程和多进程来优化虚拟内存的两种方案,它们已经能解决大部分问题了。但有的时候,这两种方案依然没法彻底优化虚拟内存,解决应用序崩溃,那就需要用到一些非常规的“黑科技”手段了。今天,我们就来介绍两种。第一种是释放 Android 系统为 WebView 保留的虚拟内存

在详细讲解这两种方案之前,我想先讲讲我们是怎样发现这些非常规优化方案的,因为你可能会担心:这些手段只有技术大牛才能想出来,普通开发者很难想到。实际上,“非常规”方案和我们常用的通用方案一样,同样是基于底层原理寻找方法论,并衍生而出一系列的优化方案,然后对这些方案进行可行性验证的。

那么这一章提到的两种方案是如何发现的呢?在《掌握 App 运行时的内存模型》这一章中我们就讲过,应用进程中所有已分配的虚拟内存都会记录在 maps 文件中,那这一点便是底层原理。基于这一原理,如果我们一行一行地去分析应用的 maps 文件,寻找到应用中已经分配但可能用不上的虚拟内存空间,然后将这些空间想办法释放掉,是不是就能优化不少虚拟内存呢? 如果我们从 maps 文件中找到的可释放的点越多,我们的方案也就越多,然后我们将这些方案一一进行可行性验证即可。这一章提到的两个优化方案,都是通过分析 maps 文件后,发现占用了较多虚拟内存,然后尝试是否可以优化,最后验证优化是可行的。

这样的思路不仅适用于内存相关的优化,也适用于其他方面的优化。在后面的章节中还有很多关于优化思路的讲解,我们也都会遵循先原理,后方案,再总结方法论这样的讲解思路,希望你能把这样的思路记下来,尝试应用在工作中,最终形成你自己的优化方法论。

了解了非常规优化方案的寻找思路,我们马上进入实践,看看这两个方案的具体实施过程吧!

释放为 WebView 保留的虚拟内存

通过分析系统“设置”这个应用的 maps 文件,可以发现有一块 anno:libwebview reservation 的虚拟内存空间的分配,通过计算(722d0a6000 - 71ed0a6000 = 1024 M ),这一块内存的大小有 1G,对于设置来说,是没有 WebView 页面的,所以这一块虚拟内存实际上是用不上的。

image.png

libwebview reservation 的内存空间不仅“设置”这个应用有,安装在 Android 系统上的每一个应用都会有,即使我们新建一个空 Android 项目,将这个空应用跑起来之后,也能看到这一块虚拟内存的申请。实际上这部分虚拟内存是为了打开 WebView 页面预留的空间,64 位机上是 1G 大小,32 位机上是 130M 大小,其他非 ARM 机器是 190M。通过源码 WebViewLibraryLoader.java 我们可以看到它的逻辑。

image.png

这一块空间实际是在 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 运行时的内存模型》中其实也见过,不过这里还是再演示一遍:

  1. 解析 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;
}
  1. 释放 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 函数将这块区域进行命名。

image.pngimage.png

对于 Android 10 以下的机型,这块区域在 maps 文件中是匿名的,我们没法根据 libwebview reservation 这个字段来找出这块区域,所以解析 maps 的方案便不生效了,如何找到这块区域的地址,便是整个方案的难点

通过上面的源码我们可以发现,通过 mmap 申请这块内存后,将地址和大小分别赋值给了 gReservedAddress 和 gReservedSize 这两个静态变量,所以我们只需要想办法知道 gReservedAddress 的值就能解决这个难点了。获取 gReservedAddress 的值的方案有多种,这里我主要介绍微信的方案,下面就详细介绍一下这个方案的实现思路以及细节。

我们首先通过在 Android 源码中全局搜索 gReservedAddress,发现它仅被 loader.cpp 这个对象中的方法使用到。

image.png

分别是 DoReserveAddressSpace 、DoCreateRelroFile 和 DoLoadWithRelroFile 这三个方法。

image.pngimage.pngimage.png

在 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 方法就可以实现目的了呢?

image.png

答案是不行的。上面的流程看起来似乎没啥问题,但当我们实际去操作时却无法跑通。首先,webviewchromiun_loader 这个 so 实际已经被加载进我们应用的进程了,因为这个 so 是被 zygote 进程加载的,而通过 zygote fork 出来的应用进程会共享父进程的虚拟内存中的数据,自然就加载了这个 so。并且,即使我们想在 Java 层调用 System.loadLibrary 重新加载一次这个 so,也没法做到。这是因为从 Android7.0 版本开始,便不允许应用加载系统的 so 库了,而 webviewchromiun_loader 是一个系统的 so 库,自然无法正常加载。

其次,我们直接在 Java层调用 nativeLoadWithRelroFile 或者 CreateRelroFile 这两个 native 函数也行不通,因为系统会自动将这个 native 方法加上包名前缀,导致我们找不到这个 native 方法。

image.png

那么如何才能执行这两个函数呢?幸运的是,我们在 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 表的值。

小结

一般只有常规的优化方案都使用完,依然还有很多因为虚拟内存导致的程序崩溃时,我们才会使用这些非常规的方案,但是作为一名技术人员,对技术的追求应该是无止境的,所以即使我们用上这些方案的机会不多,但是依然需要知道这些方案的技术点及原理,并能从这些方案中吸收优化的思路,举一反三,扩展出更多的用法。