其他
快速缓解 32 位 Android 环境下虚拟内存地址空间不足的“黑科技”
背景
预研与构想
内核保留区域这片区域包括内核映像、已加载的内核模块和特殊用途的保留地址范围, App 代码无法直接操作它们,思考优化方案时可以直接忽略。 系统预分配区域这片区域包括 Zygote 进程初始化时预加载的系统框架代码和资源,以供其他 App 进程启动后直接使用。从 App 角度来看这片区域通常也没有优化的余地,但好在 App 代码能直接操作这些内容,大胆尝试小心验证还是能找到切入点的。 App 自身占用的区域这片区域包括 App 自身的代码、资源、 App 直接拉起或触发系统功能间接拉起的线程消耗的栈空间和 App 申请的共享内存、内存文件映射等内容。由这片区域的内容容易想到很多常规优化方案,如减少 App 的 Dex 数量,懒加载非必须的资源、Native 库,通过线程池、队列等手段减少 App 拉起的线程数量等等。
[anon:libwebview reservation]
区域。如果我们能想办法安全地释放掉这段预分配的空间,可用的虚拟内存地址空间就能立刻增加 130M。这就是本文要介绍的第二项“黑科技”——释放 WebView 预分配的内存。实现
拦截系统 API
GOT/PLT Hook Linux 中的动态库是通过 PLT + GOT 的方式完成对外部函数的调用的。具体过程简单概括就是作为调用方的库调外部函数的时候不会直接跳转到目标,而是先跳转到对应的 PLT 表项,PLT 表项中的指令再从对应的 GOT 表项读出目标函数的真实地址然后跳转过去。由此可知只要修改调用方 Native 库里的目标函数对应的 GOT 表项为我们准备的处理函数即可完成拦截。 仅需修改一个32位长的 GOT 表项,产生风险的概率较低,实现也很简单。 全局拦截一个函数需要处理每个调用了该函数的库,开销较大。 无法拦截通过 dlsym 等方式绕过 GOT 调用目标函数的情况。 Inline Hook 不管通过何种方式调用,目标函数总是要被执行的。因此直接修改目标函数的头几条指令使控制流转到我们准备的处理函数即可完成拦截。 优点 全局拦截只需修改目标函数所在的库,修改开销相对较低。 无论是通过 PLT/GOT 调用还是 dlsym 方式调用都能被拦截。 目标函数的头几条指令被修改后若需要重新调用目标函数,则需要备份这些指令并对相对寻址的指令进行修正,开发难度较大。 修改的范围较大,产生风险的概率较高,即容易遇到执行流恰好位于被修改的指令中导致 Crash 的情况。 如果目标函数短到无法容纳需要在函数开头插入的跳转指令,则无法使用 Inline Hook 来拦截。 如果需要单独拦截来自某个库的调用,则每次拦截到调用后都需要额外判断调用者,使 App 运行性能下降。
线程默认栈空间减半
pthread_create
这个 API 创建的。其函数原型如下: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
函数即可。释放 WebView 预分配的内存
libwebview reservation
的特征字符串,那么直接通过搜索 maps 读取这片区域的地址范围,然后调munmap
释放是否就可以了呢?答案是否定的。显然如果我们直接释放了这片区域,对永远不会用到 WebView 的进程还好,但对于可能用到 WebView 的进程,一旦 WebView 被加载了,其背后的逻辑不知道我们已经释放了这片保留区域,于是直接将 WebView 的资源加载进去,这样肯定会加载失败导致 WebView 不可用。因此我们还需要拦截加载 WebView 资源的相关函数以确保在释放了这片预分配区域之后 WebView 还能正常加载。libwebview reservation
作为特征搜索系统源码,可在frameworks/base/native/webview/loader/loader.cpp
文件中找到一个 DoReserveAddressSpace
函数: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
释放预分配的内存了。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
参数读到我们想要的信息了呢?是的,这就是第二种获取目标内存区域的方案,其中还需要解决下面几个问题:这两个函数都没有导出符号,所以无法直接从 Native 调用。 但从其所在源文件的 RegisterWebViewFactory 函数可得知这两个函数分别被绑定到了 Java 层 android.webkit.WebViewFactory
这个类的nativeCreateRelroFile
和nativeLoadWithRelroFile
方法上,因此可以通过反射对应的 Java 方法来调用。我们只想借用这两个函数中的 android_dlopen_ext
函数,并不想完整执行它们的功能以免带来副作用。观察这两个函数的实现可知,如果 android_dlopen_ext
返回NULL
,这两个函数都会提前返回。因此我们可以在主动调用这两个函数的时候在第一个参数里传入一个特殊值,这样在android_dlopen_ext
的拦截处理函数中只要发现第一个参数为我们定义的特殊值即可判断出当前调用是我们主动触发的,随后在拿到想要的信息之后直接返回NULL
即可。
DoCreateRelroFile
会产生临时文件,且根据其实现如果临时文件创建失败则不会走到调android_dlopen_ext
的代码,因此我们选择调用更稳妥更环保的DoLoadWithRelroFile
完成任务。虚拟机堆空间缩减
定位这两片空间并获取其地址和大小。 通过阅读 heap.cc 里的代码可知,这两片区域分别叫 main space
和main space 1
,以这两个字符串为特征搜索maps
即可读取到所需的信息。确定哪片空间是 From Space。 现在虽然我们知道了两片空间的名称,但是哪片是 From Space 还得再想办法判断。对此一种非常直接的想法是先创建一个对象,然后拿到对象在堆中的地址,看这个地址落在哪片空间里,哪片空间就是 From Space,也就是当前充当堆的空间。但不是任何对象都能很方便地获取它在堆上的地址的,比方说通过 NewObect 创建的对象,阅读源码可知: 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
来获取它在堆上的地址会更方便一些。阻止后续的 Compact GC(源码里也叫 Moving GC) 现在我们知道哪片空间是 From Space 了,但我们还不能马上把 To Space 释放掉,因为后续虚拟机再执行 Compact GC 的时候如果 To Space 被释放掉了,那 From Space 里的对象就会被复制到一块未分配或已分配作它用的区域里,从而引起 Crash 或其他预料之外的行为。所以在释放 To Space 之前要先阻止 Compact GC 后续继续执行。 最初我们尝试通过调用 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
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
的逻辑。上述逻辑的正确性校验 由于上述逻辑中的成员变量值几乎都是通过 hardcode 偏移量的方式获取的,为了保证正确性,Patrons 还获取了 RegionSpace::num_regions_
成员变量的值,并将其与通过先前获取的begin_
、limit_
成员变量的值计算出来的结果作比较,相等才认为前面获取到的值是正确的。
ClampGrowthLimit
方法是否真的安全有效,我们也简单地做了以下两点分析:ClampGrowthLimit 真的能释放 RegionSpace 占用的内存吗? 确实能,不过传入的 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
的差值必须大于等于一页的大小,否则就无法起到释放虚拟内存的作用了。Heap 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
不会释放掉已分配的对象。
性能开销
线程默认空间减半
操作 | 耗时或耗时增量 |
---|---|
初始化 + 拦截目标函数 | 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
addr
和vsize
指定的内存区域命名,调用后 Maps 中的效果如下:mmap
函数,然后在其拦截处理函数中调用原函数后再按上面的参数调用一次prctl
就能给所有的匿名内存区域命名了。经过几轮尝试后我们发现这种命名方法存在以下限制:传入的内存区域只能是 MMAP_ANON
类型的,即匿名内存区域。其他如文件映射、具名共享内存、设备保留区域等类型的区域是无法通过这种方式改名的。传入的名称字符串需要是全局常量,即生命周期需要和整个进程的生命周期一致,且传入 prctl
之后不能再发生变化。否则结果是未定义的。传入的名称字符串长度暂未发现代码层面的限制,但不宜太长,否则一些解析 Maps 的库会因为 Maps 中的条目太长产生截断而出现意料之外的行为。
mmap
的拦截处理函数中获取了调用者的路径,并用获取到的结果来命名所有的匿名内存区域。实践中这个函数帮助我们排查出了一些不合理的 Native 库长驻行为,配合本文开头提到的常规手段也减少了一部分虚拟内存空间的占用。