随着微信新业务的不断增长,因 32 位设备上虚拟内存地址空间限制导致的内存分配失败问题也越来越突出。目前市场上的存量 32 位设备数量仍然较多,且预计还会继续存在一段时间。为了保障微信在这些设备上的可用性,我们尝试了一些常规优化手段,前期收效是显著的,但随着业务规模和数量的增长,虚拟内存的消耗速度越来越快,常规优化手段的收益也越来越低。要在这种趋势下继续缓解内存不足的问题,在常规优化手段的基础上就还需要一些能快速见效的办法,即标题里提到的“黑科技”。
为了找到优化方案的切入点,我们通过输出进程的 /proc/self/maps 梳理了除业务代码外还有哪些地方在占用虚拟内存。根据占用者的来源我们将进程中被占用的虚拟内存分为了以下几部分:
App 自身占用的区域通过常规方案已经很难显著节省空间了,但通过对微信的线程逻辑进行抽样分析我们发现其实大部分线程执行的逻辑看起来并不需要多少栈空间,而一个 Native 线程默认的栈空间大小为 1M 左右,显然对于这些逻辑简单的线程我们是可以想办法减小它们的栈空间大小节省出一部分虚拟内存的,这就是本文要介绍的第一项“黑科技”——线程默认栈空间减半。
除了 App 自身占用的区域之外,经过在系统预分配区域的一番挖掘,我们发现了一个大小为 130M 的[anon:libwebview reservation]
区域。如果我们能想办法安全地释放掉这段预分配的空间,可用的虚拟内存地址空间就能立刻增加 130M。这就是本文要介绍的第二项“黑科技”——释放 WebView 预分配的内存。
除此之外系统预分配区域还有没有能释放的空间占用呢?本来我们也没有更多想法了,但 simsun 经过一番大胆尝试后提出虚拟机的堆空间在一定条件下是可以减半的。这就是本文要介绍的第三项“黑科技”——虚拟机堆空间缩减。
由于上述优化方案可能涉及到修改系统 API 的行为,因此拦截系统 API 是实现这些方案的基础。目前在 Android 上拦截 Native 系统 API 主要有两种方法:
Linux 中的动态库是通过 PLT + GOT 的方式完成对外部函数的调用的。具体过程简单概括就是作为调用方的库调外部函数的时候不会直接跳转到目标,而是先跳转到对应的 PLT 表项,PLT 表项中的指令再从对应的 GOT 表项读出目标函数的真实地址然后跳转过去。由此可知只要修改调用方 Native 库里的目标函数对应的 GOT 表项为我们准备的处理函数即可完成拦截。
优点
缺点
无法拦截通过 dlsym 等方式绕过 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.
可见在attr
为NULL
时新线程的属性将采用默认值,否则新线程的属性将使用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 库创建的所有线程进行加白。
既然预研阶段已经知道这片区域在 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
分配的内存区域的起始地址和大小分别被保存到了gReservedAddress
和gReservedSize
这两个变量中。继续在这个文件中搜索这两个变量,可以找到两个函数引用了它们:DoCreateRelroFile
和DoLoadWithRelroFile
。这里我们随便选择一个函数继续分析,可以看到以下调用:
// ......
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
函数,如果我们拦截它,并且做点修改让它不使用gReservedAddress
和gReservedSize
指定的内存区域,就可以安全地释放这片预分配的区域了。于是我们继续全工程搜索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 及更新的系统上生效了吗?回顾一下上面提到的DoCreateRelroFile
和DoLoadWithRelroFile
这两个函数,其中都有这样一段:
// ......
extinfo.reserved_addr = gReservedAddress;
extinfo.reserved_size = gReservedSize;
// ......
void* handle = android_dlopen_ext(lib, RTLD_NOW, &extinfo);
// ......
看起来是不是只要我们先拦截android_dlopen_ext
,然后主动调这两个函数中的其中一个,就能在android_dlopen_ext
的拦截处理函数中通过extinfo
参数读到我们想要的信息了呢?是的,这就是第二种获取目标内存区域的方案,其中还需要解决下面几个问题:
android.webkit.WebViewFactory
这个类的nativeCreateRelroFile
和 nativeLoadWithRelroFile
方法上,因此可以通过反射对应的 Java 方法来调用。android_dlopen_ext
函数,并不想完整执行它们的功能以免带来副作用。观察这两个函数的实现可知,如果android_dlopen_ext
返回NULL
,这两个函数都会提前返回。因此我们可以在主动调用这两个函数的时候在第一个参数里传入一个特殊值,这样在android_dlopen_ext
的拦截处理函数中只要发现第一个参数为我们定义的特殊值即可判断出当前调用是我们主动触发的,随后在拿到想要的信息之后直接返回NULL
即可。由于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 版本的系统上定期判断当前的虚拟内存占用大小,当虚拟内存紧张时释放掉其中一片来腾出内存空间。为此我们需要解决下面几个问题:
main space
和main space 1
,以这两个字符串为特征搜索maps
即可读取到所需的信息。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 函数的,怎么办?把整套操作放到一个独立的线程里跑,并且让这个线程永远阻塞在结束之前就可以了。
上面提到的方案只能在 5.0 到 7.1 版本的系统上生效,因为从 8.0 开始 ART 又引入了 Concurrent Copying GC 这个新的实现,对应地堆空间也变成了一个叫 RegionSpace 的新实现了,而 RegionSpace 的压缩算法并不是靠把已分配对象在两片空间之间来回倒腾来实现的,所以无法直接释放掉其中的一半空间。微信在 8.0 及以上的系统里的 32 位用户还很多,看着这批用户无法通过堆空间缩减来缓解虚拟内存不足的问题实在是心有不甘,就在我们打算放弃的时候,组里一位同事发现了阿里巴巴团队的开源库 Patrons ,一看说明发现正好补足了我们的堆空间缩减方案里缺失的部分。这里也简单介绍一下它的原理,供打算接入的读者作为参考。
Patrons 库的核心操作是想办法在虚拟内存占用超过一定阈值时调用RegionSpace
中的ClampGrowthLimit
方法来缩减 RegionSpace 的大小。为此它实现了以下几个步骤:
RegionSpace
实例的地址。Patrons 先通过libart.so
导出的符号获得了Runtime
实例,然后通过Runtime
实例中的heap_
成员变量的值获取Heap
实例,最后通过Heap
实例中的region_space_
成员变量获得RegionSpace
实例。ClampGrowthLimit
方法的地址。在 Android P 及之后的系统里ClampGrowthLimit
方法是导出的符号,直接从libart.so
中查找即可。但在 Android P 之前不存在这个方法,所以 Patrons 又额外获取了RegionSpace
实例中的begin_
、end_
、limit_
成员变量值及MemMap::SetSize
方法和ContinuousSpaceBitmap::SetHeapSize
方法手动实现了ClampGrowthLimit
的逻辑。RegionSpace::num_regions_
成员变量的值,并将其与通过先前获取的begin_
、limit_
成员变量的值计算出来的结果作比较,相等才认为前面获取到的值是正确的。总体上看 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
先加上了左边Begin
和BaseBegin
的差值,再按页大小向上对齐后即得到new_base_size_
。如果new_base_size_
不等于base_size_
,则执行 Unmap,且 Unmap 的大小为base_size_
和new_base_size_
的差值;否则说明new_size
和size
的差值小于一页的大小,此时只更新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 = ®ions_[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 |
操作 | 耗时或耗时增量 |
---|---|
初始化 + 定位并释放目标内存 | 解析 Maps 成功时:5 ms解析 Maps 失败,通过反射 Java 方法探测时:7 ms |
加载空白 WebView | +2 us (相比于未使用此方案时的耗时增量) |
操作 | 耗时或耗时增量 |
---|---|
定位目标内存区域 | 1 ms |
使用后由于 Compact / Moving GC 被阻止,理论上反而会降低频繁触发 GC 的逻辑的执行耗时。但实测中由于没有刻意构造这种用例,因此暂未发现运行时性能有明显变化。
操作 | 耗时或耗时增量 |
---|---|
初始化 | 8 ms |
大家在分析 Maps 的时候可能会发现一些匿名内存区域因为没有名称而无法定位来源,最后额外介绍一个函数帮助解决这个问题。在上文的源码片段里有这样一个函数:
prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, addr, vsize, "libwebview reservation");
从效果上看它的功能是给addr
和vsize
指定的内存区域命名,调用后 Maps 中的效果如下:
d40f3000-dc2f3000 ---p 00000000 00:00 0 [anon:libwebview reservation]
这样一来只需全局拦截mmap
函数,然后在其拦截处理函数中调用原函数后再按上面的参数调用一次prctl
就能给所有的匿名内存区域命名了。经过几轮尝试后我们发现这种命名方法存在以下限制:
传入的内存区域只能是MMAP_ANON
类型的,即匿名内存区域。其他如文件映射、具名共享内存、设备保留区域等类型的区域是无法通过这种方式改名的。
prctl
之后不能再发生变化。否则结果是未定义的。如果只是用来暴露匿名虚拟内存,这几条限制基本上是可以忽略的。目前我们在mmap
的拦截处理函数中获取了调用者的路径,并用获取到的结果来命名所有的匿名内存区域。实践中这个函数帮助我们排查出了一些不合理的 Native 库长驻行为,配合本文开头提到的常规手段也减少了一部分虚拟内存空间的占用。
最后,本文中提到的除 Patrons 外的所有“黑科技”的实现代码现均已加入 Matrix 全家桶(https://github.com/Tencent/matrix),希望能帮助到大家解决平时遇到的内存优化问题。
扫一扫
在手机上阅读