快速缓解 32 位 Android 环境下虚拟内存地址空间不足的“黑科技”

4390次阅读  |  发布于2年以前

背景

随着微信新业务的不断增长,因 32 位设备上虚拟内存地址空间限制导致的内存分配失败问题也越来越突出。目前市场上的存量 32 位设备数量仍然较多,且预计还会继续存在一段时间。为了保障微信在这些设备上的可用性,我们尝试了一些常规优化手段,前期收效是显著的,但随着业务规模和数量的增长,虚拟内存的消耗速度越来越快,常规优化手段的收益也越来越低。要在这种趋势下继续缓解内存不足的问题,在常规优化手段的基础上就还需要一些能快速见效的办法,即标题里提到的“黑科技”

预研与构想

为了找到优化方案的切入点,我们通过输出进程的 /proc/self/maps 梳理了除业务代码外还有哪些地方在占用虚拟内存。根据占用者的来源我们将进程中被占用的虚拟内存分为了以下几部分:

App 自身占用的区域通过常规方案已经很难显著节省空间了,但通过对微信的线程逻辑进行抽样分析我们发现其实大部分线程执行的逻辑看起来并不需要多少栈空间,而一个 Native 线程默认的栈空间大小为 1M 左右,显然对于这些逻辑简单的线程我们是可以想办法减小它们的栈空间大小节省出一部分虚拟内存的,这就是本文要介绍的第一项“黑科技”——线程默认栈空间减半。

除了 App 自身占用的区域之外,经过在系统预分配区域的一番挖掘,我们发现了一个大小为 130M 的[anon:libwebview reservation]区域。如果我们能想办法安全地释放掉这段预分配的空间,可用的虚拟内存地址空间就能立刻增加 130M。这就是本文要介绍的第二项“黑科技”——释放 WebView 预分配的内存。

除此之外系统预分配区域还有没有能释放的空间占用呢?本来我们也没有更多想法了,但 simsun 经过一番大胆尝试后提出虚拟机的堆空间在一定条件下是可以减半的。这就是本文要介绍的第三项“黑科技”——虚拟机堆空间缩减。

实现

拦截系统 API

由于上述优化方案可能涉及到修改系统 API 的行为,因此拦截系统 API 是实现这些方案的基础。目前在 Android 上拦截 Native 系统 API 主要有两种方法:

Linux 中的动态库是通过 PLT + GOT 的方式完成对外部函数的调用的。具体过程简单概括就是作为调用方的库调外部函数的时候不会直接跳转到目标,而是先跳转到对应的 PLT 表项,PLT 表项中的指令再从对应的 GOT 表项读出目标函数的真实地址然后跳转过去。由此可知只要修改调用方 Native 库里的目标函数对应的 GOT 表项为我们准备的处理函数即可完成拦截。

优点

缺点

不管通过何种方式调用,目标函数总是要被执行的。因此直接修改目标函数的头几条指令使控制流转到我们准备的处理函数即可完成拦截。

优点

缺点

这里顺便介绍一下 “导出表” Hook 。尽管 Linux 中的 ELF 格式并没有导出表一说,但 Linker 在查找外部符号的时候是会通过定义这个符号的 Native 库的符号表来查找符号地址的,因此只要在其他库加载之前把被拦截的函数的符号值改成拦截处理函数地址,Linker 在加载其他库的时候就会自动把拦截处理函数的地址填到其他库的 GOT 表里。相比 PLT/GOT Hook,这种拦截方式除了具有 PLT/GOT Hook 的优点外,在需要拦截多个调用点的场景下不需要处理所有调用了被拦截符号的库,性能开销更低。但在 App 入口执行之前系统肯定已经加载了一些 Native 库,如果要拦截这些库里的函数,“导出表”Hook就无能为力了。不过配合 PLT/GOT Hook 使用就能规避掉这个缺点,需要全局拦截时可以结合使用这两种 Hook 方案。

考虑到我们的“黑科技”会同时用到全局拦截和对特定库的拦截,在综合了性能开销和稳定性因素之后我们决定采用 PLT/GOT Hook + “导出表” Hook 的方式来拦截相关的系统函数。实现上因为爱奇艺的 xhook 工具已经过了多个项目的线上验证,我们就不再重复造轮子直接接入使用了,“导出表” Hook 的实现也是在 xhook 的基础上修改得到的。

线程默认栈空间减半

有了拦截系统 API 的方法后,如何将线程的默认栈空间减半就非常简单了。除了极个别特殊需求外,Android 系统的线程都是调pthread_create这个 API 创建的。其函数原型如下:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

这里我们关注attr这个参数,Linux Man Page 对它的描述如下:

The attr argument points to a pthread_attr_t structure whose contents are used at thread creation time to determine attributes for the new thread; this structure is initialized using pthread_attr_init(3) and related functions. If attr is NULL, then the thread is created with default attributes.

可见在attrNULL时新线程的属性将采用默认值,否则新线程的属性将使用attr中指定的值。进一步查询 Man Page 可知操作attr参数的系列函数中有一组函数pthread_attr_getstacksize()pthread_attr_setstacksize()函数分别能获取、修改attr结构体中保存的栈大小。于是在拦截了对pthread_create函数的调用后只需判断attr参数是否为null,是则构造一个pthread_attr_t结构体并设置其中的stacksize为默认值的一半作为新attr,否则判断attr中的stacksize是否为默认值,是则将其减半。然后以新attr为参数调用原pthread_create函数即可。

那么为什么只对默认栈空间大小进行减半操作呢?因为如果线程创建者自行调整了栈大小,我们就可以假设创建者认为 1M 的默认大小不够或者太多所以才指定了新的栈大小,在这种情况下贸然把创建者指定的大小减半就很容易导致栈溢出。

另外减半是个非常粗略的操作,而有些线程可能需要大于 1M / 2 = 512K 的栈空间,因此对于栈空间减半后出现栈溢出的线程我们还需要对其进行加白。那用什么信息作为标识来加白呢?理想情况下按每次运行均保持一致的能唯一标识一个线程的特征加白是最精准的,但我们能获取到的满足要求的特征暂时只有线程名一项,而绝大部分线程在创建的时候都不会特别指定一个各线程唯一的名称,所以最终我们只好牺牲一些优化效果,通过 Native 库的路径对某个 Native 库创建的所有线程进行加白。

释放 WebView 预分配的内存

既然预研阶段已经知道这片区域在 maps 中有个libwebview reservation的特征字符串,那么直接通过搜索 maps 读取这片区域的地址范围,然后调munmap释放是否就可以了呢?答案是否定的。显然如果我们直接释放了这片区域,对永远不会用到 WebView 的进程还好,但对于可能用到 WebView 的进程,一旦 WebView 被加载了,其背后的逻辑不知道我们已经释放了这片保留区域,于是直接将 WebView 的资源加载进去,这样肯定会加载失败导致 WebView 不可用。因此我们还需要拦截加载 WebView 资源的相关函数以确保在释放了这片预分配区域之后 WebView 还能正常加载。

libwebview reservation作为特征搜索系统源码,可在frameworks/base/native/webview/loader/loader.cpp文件中找到一个 DoReserveAddressSpace 函数:

jboolean DoReserveAddressSpace(jlong size) {
 size_t vsize = static_cast<size_t>(size);

 void* addr = mmap(NULL, vsize, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
 if (addr == MAP_FAILED) {
   ALOGE("Failed to reserve %zd bytes of address space for future load of "
         "libwebviewchromium.so: %s",
         vsize, strerror(errno));
   return JNI_FALSE;
 }
 prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, addr, vsize, "libwebview reservation");
 gReservedAddress = addr;
 gReservedSize = vsize;
 ALOGV("Reserved %zd bytes at %p", vsize, addr);
 return JNI_TRUE;
}

其中可以看到通过mmap分配的内存区域的起始地址和大小分别被保存到了gReservedAddressgReservedSize这两个变量中。继续在这个文件中搜索这两个变量,可以找到两个函数引用了它们:DoCreateRelroFileDoLoadWithRelroFile。这里我们随便选择一个函数继续分析,可以看到以下调用:

 // ......
 android_dlextinfo extinfo;
 extinfo.flags = ANDROID_DLEXT_RESERVED_ADDRESS | ANDROID_DLEXT_USE_RELRO |
                 ANDROID_DLEXT_USE_NAMESPACE |
                 ANDROID_DLEXT_RESERVED_ADDRESS_RECURSIVE;
 extinfo.reserved_addr = gReservedAddress;
 extinfo.reserved_size = gReservedSize;
 extinfo.relro_fd = relro_fd;
 extinfo.library_namespace = ns;
 void* handle = android_dlopen_ext(lib, RTLD_NOW, &extinfo);
 // ......

注意到android_dlopen_ext函数,如果我们拦截它,并且做点修改让它不使用gReservedAddressgReservedSize指定的内存区域,就可以安全地释放这片预分配的区域了。于是我们继续全工程搜索ANDROID_DLEXT_RESERVED_ADDRESS这个常量,可以在定义它的代码附近找到下面这个常量和注释:

 /**
  * Like `ANDROID_DLEXT_RESERVED_ADDRESS`, but if the reserved region is not large enough,
  * the linker will choose an available address instead.
  */
 ANDROID_DLEXT_RESERVED_ADDRESS_HINT = 0x2

这正好就是我们想要的效果。将ANDROID_DLEXT_RESERVED_ADDRESS替换成上面这个常量后只要我们将extinfo.reserved_size改成0,就必然会命中“保留区域不够大”这个条件,这样android_dlopen_ext函数就会帮我们另外找个可用的区域了。通过frameworks/base/native/webview/loader/Android.bp可知以上对android_dlopen_ext的调用发生在libwebviewchromium_loader.so中,于是拦截这个 so 对android_dlopen_ext的调用并在截获调用后将extinct.flag中的ANDROID_DLEXT_RESERVED_ADDRESS替换为ANDROID_DLEXT_RESERVED_ADDRESS_HINT,就可以安全地调munmap释放预分配的内存了。

按以上步骤写好 Demo 之后在 WeTest 上跑一趟 monkey 测试,发现 Android P 及更旧的系统上全部报告失败。随后查阅对应的系统源码才发现在这些系统上我们要释放的内存区域根本就没设置名称,所以在这些系统上通过特征字符串定位目标区域才会失败。

难道这个黑科技就只能在 Android Q 及更新的系统上生效了吗?回顾一下上面提到的DoCreateRelroFileDoLoadWithRelroFile这两个函数,其中都有这样一段:

// ......
extinfo.reserved_addr = gReservedAddress;
extinfo.reserved_size = gReservedSize;
// ......
void* handle = android_dlopen_ext(lib, RTLD_NOW, &extinfo);
// ......

看起来是不是只要我们先拦截android_dlopen_ext,然后主动调这两个函数中的其中一个,就能在android_dlopen_ext的拦截处理函数中通过extinfo参数读到我们想要的信息了呢?是的,这就是第二种获取目标内存区域的方案,其中还需要解决下面几个问题:

由于DoCreateRelroFile会产生临时文件,且根据其实现如果临时文件创建失败则不会走到调android_dlopen_ext的代码,因此我们选择调用更稳妥更环保的DoLoadWithRelroFile完成任务。

虚拟机堆空间缩减

Android 在 ART 虚拟机中引入了 Semi-Space GC 和 Generational Semi-Space GC 两种 Compact GC 实现以消除堆中的碎片,在 8.0 版本引入 Concurrent Copying GC 之前这两种 GC 是 Background GC 的默认实现方式。关于这两种 GC 方式的实现原理和区别可以参考老罗的这篇文章(https://blog.csdn.net/luoshengyang/article/details/45017207) 。这里我们考虑它们的共同点,即存在两片大小和堆大小一样的内存空间分别作为 From Space 和 To Space,在 GC 时将 From Space 里的对象复制到 To Space 里,在复制对象的过程中 From Space 中的碎片就会被消除,然后交换两片空间的角色,下次 GC 时重复这套操作。显然我们可以在 5.0 至 7.1 版本的系统上定期判断当前的虚拟内存占用大小,当虚拟内存紧张时释放掉其中一片来腾出内存空间。为此我们需要解决下面几个问题:

static jobject NewObjectV(JNIEnv* env, jclass java_class, jmethodID mid, va_list args) {
 // ...
 ObjPtr<mirror::Object> result = c->AllocObject(soa.Self());
 // ...
 jobject local_result = soa.AddLocalReference<jobject>(result);
 // ...
 return local_result;
}

它返回的只是个 local reference,并不是堆上的地址。虽然再经过一次反射调用 Unsafe API 就能获取到对象的真实地址,但众所周知用 JNI 反射调用 Java 方法写起来很长很麻烦,相比之下创建一个基本类型数组,然后通过 GetPrimitiveArrayCritical来获取它在堆上的地址会更方便一些。

最初我们尝试通过调用Heap::DisableMovingGc方法来实现目的,但因为Runtime::heap_字段不是导出符号,且没有导出的 Getter 函数能够获取,所以只能靠 hardcode 偏移来获取这个字段的值。而这里又缺少可供校验正确性的特征,所以 hardcode 偏移的风险略大。就在我对如何安全地阻止 Compact GC 一筹莫展的时候,simsun 根据自己的实验结果表示GetPrimitiveArrayCritical这个函数就能阻止 Moving GC,我一看源码才发现确实是这样,绕了半天原来答案一直就在眼前。

那也就是说前面的步骤里调完GetPrimitiveArrayCritical之后其实只要不调ReleasePrimitiveArrayCritical就可以了?是的,但不完全是。因为如果不调ReleasePrimitiveArrayCritical,在 Debug 包或 CheckJNI 被开启的情况下,调了GetPrimitiveArrayCritical方法但没有 Release 的线程在下次调用 JNI 函数时会被 CheckJNI 里的检查逻辑发现而触发 Abort。我们肯定是没法保证任何一个线程在我们这番操作之后不再调其他 JNI 函数的,怎么办?把整套操作放到一个独立的线程里跑,并且让这个线程永远阻塞在结束之前就可以了。

虚拟机堆空间缩减 II - Patrons

上面提到的方案只能在 5.0 到 7.1 版本的系统上生效,因为从 8.0 开始 ART 又引入了 Concurrent Copying GC 这个新的实现,对应地堆空间也变成了一个叫 RegionSpace 的新实现了,而 RegionSpace 的压缩算法并不是靠把已分配对象在两片空间之间来回倒腾来实现的,所以无法直接释放掉其中的一半空间。微信在 8.0 及以上的系统里的 32 位用户还很多,看着这批用户无法通过堆空间缩减来缓解虚拟内存不足的问题实在是心有不甘,就在我们打算放弃的时候,组里一位同事发现了阿里巴巴团队的开源库 Patrons ,一看说明发现正好补足了我们的堆空间缩减方案里缺失的部分。这里也简单介绍一下它的原理,供打算接入的读者作为参考。

Patrons 库的核心操作是想办法在虚拟内存占用超过一定阈值时调用RegionSpace中的ClampGrowthLimit方法来缩减 RegionSpace 的大小。为此它实现了以下几个步骤:

总体上看 Patrons 的方案思路非常清晰,虽然 hardcode 偏移量的地方有点多,但也针对不同的厂商做了适配,在获取到所需字段的值之后还做了力所能及的校验,实现是比较严谨的。另外对于ClampGrowthLimit方法是否真的安全有效,我们也简单地做了以下两点分析:

确实能,不过传入的new_size需要满足一定的条件。分析ClampGrowthLimit的实现可以发现这个方法调用了 MemMap::SetSize 方法。MemMap::SetSize方法实现如下:

void MemMap::SetSize(size_t new_size) {
 CHECK_LE(new_size, size_);
 size_t new_base_size = RoundUp(new_size + static_cast<size_t>(PointerDiff(Begin(), BaseBegin())),
                                kPageSize);
 if (new_base_size == base_size_) {
   size_ = new_size;
   return;
 }
 CHECK_LT(new_base_size, base_size_);
 MEMORY_TOOL_MAKE_UNDEFINED(
     reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(BaseBegin()) +
                             new_base_size),
     base_size_ - new_base_size);
 CHECK_EQ(TargetMUnmap(reinterpret_cast<void*>(
                       reinterpret_cast<uintptr_t>(BaseBegin()) + new_base_size),
                       base_size_ - new_base_size), 0)
                       << new_base_size << " " << base_size_;
 base_size_ = new_base_size;
 size_ = new_size;
}

为了方便理解,我们简单画一下执行 Unmap 操作时各变量的关系的示意图: 其中new_size先加上了左边BeginBaseBegin的差值,再按页大小向上对齐后即得到new_base_size_。如果new_base_size_不等于base_size_,则执行 Unmap,且 Unmap 的大小为base_size_new_base_size_的差值;否则说明new_sizesize的差值小于一页的大小,此时只更新size而不执行 Unmap。即传入ClampGrowthLimit方法的new_size与原来的size的差值必须大于等于一页的大小,否则就无法起到释放虚拟内存的作用了。

目前看来是不会有问题的。先看ClampGrowthLimit方法的实现:

void RegionSpace::ClampGrowthLimit(size_t new_capacity) {
 MutexLock mu(Thread::Current(), region_lock_);
 CHECK_LE(new_capacity, NonGrowthLimitCapacity());
 size_t new_num_regions = new_capacity / kRegionSize;
 if (non_free_region_index_limit_ > new_num_regions) {
   LOG(WARNING) << "Couldn't clamp region space as there are regions in use beyond growth limit.";
   return;
 }
 // ...
}

其中if (non_free_region_index_limit_ > new_num_regions)这个判断从字面上理解就是保证了传入的new_capacity不会导致新的 Region 总数量比已分配的 Region 数量还少,从而阻止了 Unmap 掉已分配对象的意外发生。但non_free_region_index_limit_的含义是否真的如此呢?这就要接着分析这个变量是在哪里被更新的了。通过搜索这个变量的引用点可知是RegionSpace::AdjustNonFreeRegionLimit方法更新了non_free_region_index_limit_。继续搜索调用者可以得到以下调用链:

 RegionSpace::Alloc => RegionSpace::AllocNonvirtual => RegionSpace::AllocateRegion => Region::Unfree => Region::MarkAsAllocated => RegionSpace::AdjustNonFreeRegionLimit

先看RegionSpace::AllocateRegion的实现:

RegionSpace::Region* RegionSpace::AllocateRegion(bool for_evac) {
 if (!for_evac && (num_non_free_regions_ + 1) * 2 > num_regions_) {
   return nullptr;
}
 for (size_t i = 0; i < num_regions_; ++i) {
   // ...
   size_t region_index = kCyclicRegionAllocation
       ? ((cyclic_alloc_region_index_ + i) % num_regions_)
      : i;
   Region* r = &regions_[region_index];
   if (r->IsFree()) {
     r->Unfree(this, time_);
     // ...
     return r;
  }
}
 return nullptr;
}

其中kCyclicRegionAllocation的值取决于 ROM 是否为 debug build,因此非工程 ROM 下该变量取值为false。这样一来每次查找空闲 Region 的时候都是从第一个 Region 开始的,先记住这个结论,然后继续看RegionSpace::AdjustNonFreeRegionLimit的实现:

 void AdjustNonFreeRegionLimit(size_t new_non_free_region_index) REQUIRES(region_lock_) {
 DCHECK_LT(new_non_free_region_index, num_regions_);
 non_free_region_index_limit_ = std::max(non_free_region_index_limit_,
                                         new_non_free_region_index + 1);
 VerifyNonFreeRegionLimit();
}

这里new_non_free_region_index是新分配的 Region 的下标。根据上面的代码,如果新分配的 Region 的下标 + 1(即新分配了 Region 之后已分配 Region 的数量)比当前的non_free_region_index_limit_要小,则不更新non_free_region_index_limit_,否则将其更新为新分配的 Region 的下标 + 1。结合RegionSpace::AllocateRegion中总是从第一个 Region 开始查找空闲 Region 的逻辑可知,non_free_region_index_limit_确实是当前已分配 Region 的数量,且不会有新的 Region 出现在>= non_free_region_index_limit_的位置上。于是最开始提到的ClampGrowthLimit中的那个判断条件也就确实能保证调用ClampGrowthLimit不会释放掉已分配的对象。

性能开销

测试环境:Google Pixel 4,Android R,32位进程环境,且所有 CPU 内核的频率都被固定在 1708800 kHz (1.7 GHz)下列数据如无特殊说明均为向上取整后的结果。

线程默认空间减半

操作 耗时或耗时增量
初始化 + 拦截目标函数 65 ms
以默认栈大小创建一条线程 +332 us (相比于未使用此方案时的耗时增量,下同)
以非默认栈大小创建一条线程 +113 us

释放 WebView 预分配的内存

操作 耗时或耗时增量
初始化 + 定位并释放目标内存 解析 Maps 成功时:5 ms解析 Maps 失败,通过反射 Java 方法探测时:7 ms
加载空白 WebView +2 us (相比于未使用此方案时的耗时增量)

虚拟机堆空间缩减

操作 耗时或耗时增量
定位目标内存区域 1 ms

使用后由于 Compact / Moving GC 被阻止,理论上反而会降低频繁触发 GC 的逻辑的执行耗时。但实测中由于没有刻意构造这种用例,因此暂未发现运行时性能有明显变化。

Patrons 库

操作 耗时或耗时增量
初始化 8 ms

One More Thing

大家在分析 Maps 的时候可能会发现一些匿名内存区域因为没有名称而无法定位来源,最后额外介绍一个函数帮助解决这个问题。在上文的源码片段里有这样一个函数:

prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, addr, vsize, "libwebview reservation");

从效果上看它的功能是给addrvsize指定的内存区域命名,调用后 Maps 中的效果如下:

d40f3000-dc2f3000 ---p 00000000 00:00 0                                 [anon:libwebview reservation]

这样一来只需全局拦截mmap函数,然后在其拦截处理函数中调用原函数后再按上面的参数调用一次prctl就能给所有的匿名内存区域命名了。经过几轮尝试后我们发现这种命名方法存在以下限制:

如果只是用来暴露匿名虚拟内存,这几条限制基本上是可以忽略的。目前我们在mmap的拦截处理函数中获取了调用者的路径,并用获取到的结果来命名所有的匿名内存区域。实践中这个函数帮助我们排查出了一些不合理的 Native 库长驻行为,配合本文开头提到的常规手段也减少了一部分虚拟内存空间的占用。

最后,本文中提到的除 Patrons 外的所有“黑科技”的实现代码现均已加入 Matrix 全家桶(https://github.com/Tencent/matrix),希望能帮助到大家解决平时遇到的内存优化问题。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8