开幕雷击:GC 抑制是一种非常规技术手段,可以用来丰富一下我们的技术武器库。但成本高,收益低。不过它涉及的各种技术思路及方案实现,个人认为是值得学习的。
Android 2.3 引入了并发 GC。并发 GC 的好处,是减少了 GC 的停顿时间,减轻 GC 停顿导致用户感受到的卡顿。
但是,并发 GC,有时候还是会抢占 CPU,导致主线程、RenderThread 等被迫切换到小核上,或者抢不到 CPU 时间片,导致页面卡顿。由此,为了优化一些核心场景的体验,坊间开始流行起 "GC 抑制"。
GC 抑制的核心思想,是短时间内干扰,甚至停止并发 GC,避免并发 GC 带来的抢占 CPU 问题。而短时间内的 GC 抑制,通常不会导致 OOM 等问题。
本文主要探讨 Android 5.0 以后的 GC 抑制。分三个方面:
-
GC 抑制的收益
-
GC 抑制的两种实现方案
- 挂起 GC 线程
- 调大 heap 触发 GC 的阈值
-
GC 抑制的应用场景
GC 抑制的收益
Android 10 以后,在应用启动 2 秒内,会通过调整 heap 参数,减少 GC 的发生,达到抑制 GC 的目的。相关代码变动及效果,可查看该 commit:
应用 | 平均启动速度(抑制前/抑制后) | 堆大小(抑制前/抑制后) | GC 次数(抑制前/抑制后) |
---|---|---|---|
Camera | 588m / 567ms | 2.7MB / 4.3MB | 4 / 2 |
Chrome | 394ms / 350ms | 1.5MB / 2.5MB | 2 / 0 |
Photos | 516ms / 447ms | 4MB / 6MB | 3 / 0 |
Maps | 1440ms / 1419ms | 11MB / 19.5MB | 5 / 0 |
Gmail | 156ms / 148ms | 2.5MB / 3.5MB | 1 / 0 |
Youtube | 761ms / 721ms | 4.5MB / 8MB | 3 / 1 |
从上面的表格,我们可以看到 GC 抑制的收益:
- 启动时间减少 10 - 70ms 左右,不同应用效果不一。(直接改系统源码,才有这样的收益,如果是后面的 hook 实现,启动过程中如果不常出现 GC,容易造成负优化)
- 堆的大小比抑制前增长了 1-10 MB,但堆大小均远未超过堆的上限。所以基本不需要担心 OOM。
GC 抑制的两种实现方案
主要基于 Android platform 分支 android-7.0.0_r1 解析。
当我们通过 new 关键字创建对象时,会通过 Heap::AllocObjectWithAllocator() 来为对象分配内存,这就有可能触发下面的调用链路,触发 GC:
GC 抑制的两种方案的原理,就藏在 AllocateInternalWithGc() 和 CheckConcurrentGC() 里。
让我们先来看看挂起 GC 线程的实现方案。
方案一:挂起 GC 线程
当需要 GC 的时候,会通过 CheckConcurrentGC(),最终添加一个并发 GC 任务(即 ConcurrentGCTask)到队列中:
c++
void Heap::RequestConcurrentGC(Thread* self, bool force_full) {
task_processor_->AddTask(self, new ConcurrentGCTask(NanoTime(), // Start straight away.
force_full));
}
ConcurrentGCTask 什么时候会被处理呢?
它是由 HeapTaskDaemon 这个线程处理的。该线程在是在 Zygote fork 生成应用进程后,由应用进程启动。
HeapTaskDaemon 线程启动后,会通过下面的函数调用,开始轮询处理任务队列的任务:
c++
void TaskProcessor::RunAllTasks(Thread* self) {
while (true) {
// Wait and get a task, may be interrupted.
HeapTask* task = GetTask(self);
if (task != nullptr) {
task->Run(self);
task->Finalize();
} else if (!IsRunning()) {
break;
}
}
}
GetTask 会去任务队列里获取任务。获取到任务后,就会执行 task->Run()。所以,挂起 GC 线程的办法就找到了:
- 使用 inline hook,将 ConcurrentGCTask 的 Run() 作为一个 hook 点,当 ConcurrentGCTask 试图运行的时候,首先会运行我们的 hook 函数。
- 在 hook 函数里,我们只要休眠 HeapTaskDaemon 线程一段时间,再重新调用 ConcurrentGCTask 的 Run() 就可以达到抑制 GC 的目的。
该方案的实现,在网上能找到几篇文章。这里就不重复造轮子了。
- 速度优化:GC抑制:提供了一个虚函数 hook 的实现。但存在一些问题。尝试了一下,在高版本 Android 还会存在 "/system/lib/libart.so not found in my userspace" 异常。
- GC/JIT 抑制:指出了上文的一些问题,并提供了一个 inline hook 实现。
方案二:调大 heap 触发 GC 的阈值
该方案源自《Android性能优化之虚拟机调优》。该文中直接写死了目标变量的偏移量,更容易受系统版本影响。这里主要分析原理,并给出一个相对健壮的编码实现。
前面我们已经知道,当 Java 层创建对象时,有可能会调用到 AllocateInternalWithGc()。AllocateInternalWithGc() 会触发 GC,并评估下一次 GC 发生的条件。
关键调用如下:
GrowForUtilization() 的简化代码如下:
c++
void Heap::GrowForUtilization(collector::GarbageCollector* collector_ran,
uint64_t bytes_allocated_before_gc) {
// 获取本次 GC 后,堆已分配的字节数
const uint64_t bytes_allocated = GetBytesAllocated();
// 如果是后台应用,multiplier 是 1.0,如果是前台应用,根据系统版本,可能是 2.0 或 3.0
const double multiplier = HeapGrowthMultiplier();
const uint64_t adjusted_min_free = static_cast<uint64_t>(min_free_ * multiplier);
const uint64_t adjusted_max_free = static_cast<uint64_t>(max_free_ * multiplier);
// targetHeapUtilization 默认是 0.75,表示堆内存的利用率
// delta 相当于根据 targetHeapUtilization 和 bytes_allocated 计算出来的堆内存未分配的字节数
ssize_t delta = bytes_allocated / GetTargetHeapUtilization() - bytes_allocated;
// target_size 相当于当前 heap 内存大小
target_size = bytes_allocated + delta * multiplier;
// 注意,这两行代码,相当于限制堆内存的未分配的字节数,在 [adjusted_min_free,adjusted_max_free]
target_size = std::min(target_size, bytes_allocated + adjusted_max_free);
target_size = std::max(target_size, bytes_allocated + adjusted_min_free);
// 将 max_allowed_footprint_ 设置为 target_size
SetIdealFootprint(target_size);
// 计算本次 GC 释放的字节数
const uint64_t freed_bytes = current_gc_iteration_.GetFreedBytes() +
current_gc_iteration_.GetFreedLargeObjectBytes() +
current_gc_iteration_.GetFreedRevokeBytes();
// 计算在本次并发 GC 期间,堆上分配的字节数
const uint64_t bytes_allocated_during_gc = bytes_allocated + freed_bytes -
bytes_allocated_before_gc;
// 计算本次并发 GC 的时间
const double gc_duration_seconds = NsToMs(current_gc_iteration_.GetDurationNs()) / 1000.0;
// 估计当我们需要启动下一个 GC 时,我们堆上至少需要有多少剩余的未分配字节
// 目的是确保下次并发 GC 时,堆上还有未分配的内存供给用户线程创建对象时使用
size_t remaining_bytes = bytes_allocated_during_gc * gc_duration_seconds;
// 计算下一次 GC 触发的堆内存阈值
concurrent_start_bytes_ = std::max(max_allowed_footprint_ - remaining_bytes,
static_cast<size_t>(bytes_allocated));
}
void Heap::SetIdealFootprint(size_t max_allowed_footprint) {
if (max_allowed_footprint > GetMaxMemory()) {
max_allowed_footprint = GetMaxMemory();
}
max_allowed_footprint_ = max_allowed_footprint;
}
上面的代码揭示了一次并发 GC 后,堆内存调整,以及下一次 GC 触发的堆内存阈值的计算。有几个关键的变量:
- multiplier:如果是后台应用,multiplier 是 1.0,如果是前台应用,根据系统版本,可能是 2.0 或 3.0
- min_free_:min_free_ 在 Android 7.0 上是 512 KB。min_free_ * multiplier 决定堆最小未分配字节数,也就是堆上的最小空闲内存
- max_free_:min_free_ 在 Android 7.0 上是 2 MB。max_free_ * multiplier 决定堆最大未分配字节数,也就是堆上的最大空闲内存
- target_size:调整后的堆大小。target_size - bytes_allocated 的取值,必须落在区间 [min_free_ * multiplier,max_free_ * multiplier] 上
- concurrent_start_bytes_:下一次 GC 触发的堆内存阈值。堆上已分配的字节数,大于等于它时,将会触发并发 GC。在 CheckConcurrentGC() 里,可以看到相关判断:
if (UNLIKELY(new_num_bytes_allocated >= concurrent_start_bytes_))
显然,只要我们调大 concurrent_start_bytes_ 的值,必然会导致 GC 的次数减少。Android 10 以后的代码变动,就是直接调整 max_allowed_footprint_ 和 concurrent_start_bytes_ 来达成 GC 抑制的目的。
此外,我们还可以调大 min_free_ 和 max_free_ 的值,导致 target_size 变大,最终导致 concurrent_start_bytes_ 变大。
调大 min_free_ 和 max_free_
该部分代码,仅在 Android 7.0 的 32 位模拟器上测试过。
要调大 min_free_ 和 max_free_。我们需要解决下面两个问题:
1. 如何拿到 heap 对象的指针?
如果你熟悉 Zygote 进程的启动,就会知道在 Zygote 创建本地 Socket 监听来自 AMS 的创建应用进程的消息之前,它会先加载 libart.so,进行 Java 堆的创建。这样,通过 Zygote fork 产生的应用进程,都会有对应的堆。
堆创建的代码,在 Runtime::Init() 里:
c++
bool Runtime::Init(RuntimeArgumentMap&& runtime_options_in) {
// 创建 Heap 实例
heap_ = new gc::Heap(runtime_options.GetOrDefault(Opt::MemoryInitialSize),
runtime_options.GetOrDefault(Opt::HeapGrowthLimit),
runtime_options.GetOrDefault(Opt::HeapMinFree),
runtime_options.GetOrDefault(Opt::HeapMaxFree),
runtime_options.GetOrDefault(Opt::HeapTargetUtilization),
...);
// 创建 JavaVMExt 实例,它会持有 Runtime 的指针
java_vm_ = new JavaVMExt(this, runtime_options);
// 创建一个 Thread 对象,并绑定到当前的 Linux 线程上。这个应该就是我们常说的主线程
Thread* self = Thread::Attach("main", false, nullptr, false);
return true;
}
接着看看 Thread::Attach() 和 Thread::init(),还有最终会调用的 JNIEnvExt::Create():
c++
Thread* Thread::Attach(const char* thread_name, bool as_daemon, jobject thread_group,
bool create_peer) {
Runtime* runtime = Runtime::Current();
Thread* self;
// Thread::Attach() 会调用 Thread::init(),为当前线程创建一个 JNIEnvExt 实例。
bool init_success = self->Init(runtime->GetThreadList(), runtime->GetJavaVM());
}
bool Thread::Init(ThreadList* thread_list, JavaVMExt* java_vm, JNIEnvExt* jni_env_ext) {
// JNIEnvExt 就是我们在 JNI 中常见的 JNIEnv 的实现类。所以,有一种说法:the JNIEnv* is per-thread
tlsPtr_.jni_env = JNIEnvExt::Create(this, java_vm);
}
JNIEnvExt* JNIEnvExt::Create(Thread* self_in, JavaVMExt* vm_in) {
// 可以看到 JNIEnvExt 在创建的时候,会持有 JavaVMExt*
std::unique_ptr<JNIEnvExt> ret(new JNIEnvExt(self_in, vm_in));
if (CheckLocalsValid(ret.get())) {
return ret.release();
}
return nullptr;
}
所以,我们可以从 JNI 调用里的 JNIEnvExt 获取到 JavaVMExt,进一步获取到 Runtime,最后获取到 heap。
2. 如何计算字段的偏移量?
在 JNI 里,JavaVMExt 这个类是不暴露给普通的应用开发的,暴露出来的只是它的父类 JavaVM,该类没有提供直接获取 Runtime 的方法。
我们获取到 JavaVMExt 的指针,也就是这个对象的起始位置,如果通过硬编码写死偏移量,去访问 Runtime 字段,显然不是很明智的选择。
我们可以定义一个和 JavaVMExt 类似的结构体,将 JavaVM* 强转为该结构体的指针,用该结构体去访问 Runtime:
c++
struct JavaVMExtShadow {
void *functions; // 父类 JavaVM 的字段
void *runtime_;
};
所以获取 Runtime 的编码可以如下:
c++
JavaVM *vm;
env->GetJavaVM(&vm);
JavaVMExtShadow *vmExt = (JavaVMExtShadow *) vm;
void *runtime = vmExt->runtime_;
访问在 Runtime 中的 heap 的时候,思路也是类似的。我们不必完整定义一个和 Runtime 一样的结构体,只需要定义一个局部一样的结构体:
c++
// 定义一个和 Runtime 局部一样的结构体
struct RuntimeShadow {
void *heap;
void *jit_arena_pool_;
void *arena_pool_;
void *low_4gb_arena_pool_;
void *linear_alloc_;
size_t max_spins_before_thin_lock_inflation_;
void *monitor_list_;
void *monitor_pool_;
void *thread_list_;
void *intern_table_;
void *class_linker_;
void *signal_catcher_;
std::string stack_trace_file_;
void *java_vm_;
};
然后通过已知的字段作为锚点,比如 java_vm_,巧妙计算 heap 的偏移量:
c++
// 先搜索到已知的 java_vm_ 在 Runtime 结构体的偏移量
int vm_offset = findOffset(runtime, 0, 2000, (void *) vmExt);
RuntimeShadow shadow;
// 计算 java_vm_ 和 heap 的偏移量
int offset = (char *) &(shadow.java_vm_) - (char *) &(shadow.heap);
// 获取到 heap 的指针
void **heap = (void **) ((char *) runtime + (vm_offset - offset));
搜索偏移量代码:
c++
template<typename T>
int findOffset(void *start, int regionStart, int regionEnd, T value) {
if (NULL == start || regionEnd <= 0 || regionStart < 0) {
return -1;
}
char *c_start = (char *) start;
for (int i = regionStart; i < regionEnd; i += 4) {
T *current_value = (T *) (c_start + i);
if (value == *current_value) {
LOG("found offset: %d", i);
return i;
}
}
return -2;
}
同样,我们可以在查看 Heap 的定义后,就发现,可以用 target_utilization_ 作为锚点:
c++
class Heap {
private:
...
size_t min_free_;
size_t max_free_;
double target_utilization_;
...
}
target_utilization_ 的值,通常是 0.75,我们可以通过 VMRuntime.getTargetHeapUtilization() 获取:
kotlin
val vmRuntimeClass = Class.forName("dalvik.system.VMRuntime")
val getMethod = vmRuntimeClass.getDeclaredMethod("getTargetHeapUtilization")
val getRuntime = vmRuntimeClass.getDeclaredMethod("getRuntime")
val runtime = getRuntime.invoke(null)
val targetHeapUtilization = getMethod.invoke(runtime) as Float
我们根据 target_utilization_ 的值,根据 heap 指针,先搜索到 target_utilization_ 的偏移量,然后就不难找到 min_free_ 和 max_free_:
c++
int utilization_offset = findOffset(*heap, 0, 2000, (double) targetHeapUtilization);
if (utilization_offset < 0) return;
size_t *max_free_ = (size_t *) ((char *) *heap + utilization_offset - sizeof(size_t));
size_t *min_free_ = (size_t *) ((char *) *heap + utilization_offset - 2 * sizeof(size_t));
// 修改 max_free_、min_free 的值
*max_free_ = *max_free_ * 2;
*min_free_ = *min_free_ * 2;
这里其实有一个很明显的问题:如果 target_utilization_ 不是 0.75,而是 1 这些很容易出现的值,findOffset() 就很容易出错。所以,这也是不推荐普通的应用开发做这种操作的原因之一。当然,也可以通过多个锚点,确保准确性。
该方案的性能损耗是低于 inline hook,只需要微秒级别的耗时即可完成,算上 Java 的反射调用、JNI 调用,也只需要 1ms 出头的时间即可完成。但很显然,它更容易受到相关类的字段内存布局的影响。可移植性和适配性更低。
另外,min_free_ 和 max_free_ 调整为多大,跟手机的物理内存、应用使用的内存也有关联,也需要经过测试。
完整示例代码:GCSuppress
GC 抑制的应用
应用场景
GC 抑制常见的应用场景是:
- 应用启动
- 页面初始化
- 列表滑动,尤其是图片列表。但应该优先考虑,能否通过减少内存占用,来避免卡顿。
结合实际
对不同类型的开发者而言,是否采用 GC 抑制,都需要结合实际,综合考虑收益和风险。
对于系统开发者,修改 heap 参数并不困难。所以可以考虑:
- 对于应用启动的场景:在 Android 5.0 - 9.0,可以模仿 Android 10 的代码调整 heap 参数,在应用启动时,进行 GC 抑制。
- 对于其它场景:直接修改系统源码,并通过 SDK 的形式,为有需要的上层开发者提供 GC 抑制的接口。
对于普通的应用开发者,实现 GC 抑制是比较困难的。常见的两种 GC 抑制的实现方案各有各的不足:
- 挂起 GC 线程:
- 原理:从 GC 流程中,寻找到 hook 点,直接挂起 GC 线程。
- 缺点:无论是 inline hook 还是虚函数 hook,耗时都要几毫秒,甚至十几毫秒。而在应用启动的场景中,不一定会发生 GC。容易造成负优化。
- 调大 heap 触发 GC 的阈值:
- 原理:拿到 heap 对象的指针,直接通过偏移量,修改一些会影响 GC 触发的参数,达到 GC 抑制的目的。
- 缺点:虽然比 inline hook 耗时更短,但是更容易受到相关类的字段内存布局的影响。可移植性和适配性更低。系统一旦调整字段位置,就可能导致失效。
所以,对于普通的应用开发者,应该:
- 优先考虑优化应用的内存分配,减少不必要的 GC
- 通过 Systrace 确定是 GC 抢占 CPU 导致的卡顿,不得不采用 GC 抑制时:
- 优先尝试考虑联系厂商协助处理(如果是 to B 项目,适配机型少的话)
- 选择合理的 GC 抑制方案(推荐使用 android-inline-hook 进行 inline hook),并做好兼容性测试、灰度测试。
参考资料:
无业游民求各大公司的内推码...
6 年 Android 应用开发经验,熟悉 Java/C++,熟悉 Android 应用开发,熟悉 Android Framework,有 NDK 开发经验,熟悉热更新、插件化、性能优化等等。