欢迎使用服装制定模板!
0898-08980898
网站首页
关于华体会
华体会动态
华体会注册
华体会登录
华体会平台
华体会代理加盟
客户留言
联系我们

华体会动态

当前位置: 首页 > 华体会动态

抑制 GC 执行的方案

发布时间:2024-06-10 05:35:27

我们知道,充分且合理地使用 CPU 资源是提升速度的本质因素之一。提升 CPU 利用率,除了前面提到的优化方案外,还有很多其他的方案,比如我们还可以通过分析 CPU 的使用情况寻找优化点。

Android 官方提供了完善的分析 CPU 使用的工具,如抓 Trace 或者 AndroidStudio 中自带的 Profile 工具,如果不熟悉使用的可以参考官方文档,讲解非常详细,这里就不过多介绍了。

在通过 Profile 分析 CPU 使用时, 我们 经常会发现 HeapTaskDaemon 线程 占用了较高 CPU 时间,这个线程实际是虚拟机用来执行 GC 操作的。 下图是 Demo 中的 CPU 使用分析,可以看到 HeapTaskDaemon 线程有大块处于 Running 状态的时间。

从 Android 5 开始,Dalvik 虚拟机被替换成了 ART 虚拟机,ART 虚拟机在进行 GC 的时候,虽然不再执行 Stop The World 逻辑来停止一切其他任务,但并不意味着 GC 操作便不会再导致卡顿。ART虚拟机上, GC 操作依然会导致卡顿,主要原因是该操作会抢占很多 CPU 资源,从而导致核心线程无法获得足够的 CPU 时间片而卡顿或者变慢。HeapTaskDaemon 线程除了抢占 CPU 时间片,还会因为有较多内存操作而持有内存相关的锁,其他任务无法得到锁自然就变慢了。

所以当我们执行核心场景,比如启动,打开页面或者滑动 List 时,如果能抑制 GC 的执行,就能让核心任务获得更多的 CPU 时间,表现出更好的性能。

这一章,我们就来学习如何对 GC 进行抑制。因为涉及比较多复杂的知识点,内容上会有一定的难度,希望通过今天的学习我们能一起弄懂它们,踏上进阶之路。

想要抑制 GC 执行,我们首先要熟悉 GC 的执行流程,然后从流程中寻找突破点,在前面学习通过“黑科技”手段优化虚拟内存时,我们也是这样的思路。既然 HeapTaskDaemon 线程抢占了较多的 CPU,我们就直接从 HeapTaskDaemon 这个线程来分析,看看这个线程到底是做什么的。

通过全局搜索 HeapTaskDaemon 关键字,发现它是在 Java 层创建的线程,并位于 Daemons.java 对象中。

分析源码可以发现,HeapTaskDaemon 继承自 Daemon 对象。Daemon 对象实际是一个 Runnale,并且内部会创建一个线程,用于执行当前这个 Daemon Runnable,这个内部线程的线程名就叫 HeapTaskDaemon。到这里,我们就知道了这个线程的起源。


知道了 HeapTaskDaemon 线程的起源,我们接着看看它是干什么的。

HeapTaskDaemon 是一个守护线程,随着 Zygote 进程启动便会启动,该线程的 run 方法也比较简单,就是执行 runInternal 这个抽象函数,该抽象函数的实现方法中会执行 VMRuntime.getRuntime().runHeapTasks() 方法,runHeapTasks() 函数会执行 RunAllTasks 这个 Native 函数,它位于 task_processor.cc 这个类中。


通过源码一路跟踪下来,可以看到 HeapTaskDaemon 线程的 run 方法中真正做的事情,实际只是在无限循环的调用 GetTask 函数获取 HeapTask 并执行。GetTask 中会不断从 tasks 集合中取出 HeapTask 来执行,并且对于需要延时的 HeapTask ,会阻塞到目标时间。


到这里,抑制 GC 的思路其实已经出来,我们有 2 种做法:

  1. 添加一个自己的 HeapTask 到 tasks 集合中,并且在我们自己的 HeapTask 中进行休眠,此时便会阻塞 HeapTaskDaemon 线程 ,达到抑制该线程执行的目的
  1. 获取到系统的 HeapTask,并让这个 HeapTask 休眠,同样能达到抑制 HeapTaskDaemon 线程 执行的目的。

这两种方案都需要 HeapTask 进行操作,为了让方案顺利实施,我们需要继续分析 HeapTask 是干什么的。

通过源码分析可以发现,HeapTask 实际上依次继承自 SelfDeletingTask 、Task 和 Closure 这三个类,Task 类定义了 Finalize 这个虚函数,Closure 类定义了 Run 这个虚函数。什么是虚函数呢?我们可以先把它理解成 Java 的抽象函数,virtual 关键字就类似于 Java 的 abstract 关键字,虚函数在后面有很重要的作用,是实现 Hook 的关键之一,这里先有个印象。 既然是抽象函数,就需要子类来实现,SelfDeletingTask 实现了 Finalize 这个虚函数,用于对象析构使用。Run 函数的实现,则会交给 HeapTask 的子类。


还是通过全局搜索,发现 Android 系统中继承自 HeapTask 的子类有下面这些。

下面大致介绍一下每一个 HeapTask 的作用。

  • ConcurrentGCTask:当 Java 内存到达阈值时,便会执行这个 Task,用于执行并发 GC。
  • CollectorTransitionTask:前后台切换时,便会执行这个 Task,用于切换 GC 的类型,比如到后台时,便会切换成拷贝回收这种 GC 机制。
  • HeapTrimTask:GC 完成之后,如果需要将堆中空闲的内存归还给内核,则会执行这个 Task 来处理。
  • TriggerPostForkCCGcTask:Android8 开始,系统为了在启动时避免 GC 操作,会执行这个 Task,将 HeapTaskDaemon 线程阻塞 2 秒。
  • ReduceTargetFootprintTask:和 TriggerPostForkCCGcTask 配合使用。
  • ClearedReferenceTask:在对象回收时,会执行该 Task,Task 中调用 Java 层的ReferenceQueue.add 方法, 将被回收对象引用添加到 ReferenceQueue 队列中。LeakCanary 便是用 ReferenceQueue 队列来判断内存泄漏。
  • NotifyStartupCompletedTask:启动完成后执行的一个 Task,用于校验使用。

因为 Task 比较多,我们就不每一个都去分析它的实现了,这里仅以 ConcurrentGCTask 这一个 Task 为例子讲解它的原理和机制。

《Java 堆内存优化》中讲到过,当我们创建对象时,最终虚拟机会调用 AllocObjectWithAllocator 方法,到 Java 堆中为这个对象申请内存空间。申请空间的操作就不重复讲了,我们主要看触发 ConcurrentGCTask 的流程。

通过源码可以看到,如果判断是并发 GC,或者堆内存达到 concurrent_start_bytes_ (这个值是一个动态值,系统会根据当前条件,动态调整这个值的大小)阈值时,就会调用 RequestConcurrentGCAndSaveObject 方法。


RequestConcurrentGCAndSaveObject 方法中实际上就是创建 ConcurrentGCTask,并调用 task_processor_ 对象的 AddTask 方法,将该 Task 添加到 tasks 集合里去。ConcurrentGCTask 里面具体做的事情,就是执行并发 GC 了,这属于虚拟机模块的知识,就不展开讲了。


如果你对 GC 机制比较有兴趣,可以将其他的 HeapTask 都分析一下,这样能加深你对 ART GC 机制的了解。了解了 HeapTaskDaemon 线程以及相关的流程,下面我们进入实战,看看如何抑制 GC 的执行。

在上面的分析过程中,已经提到了 2 种方案:

  1. 添加一个自己的 HeapTask 到 tasks 集合中,并且在我们自己的 HeapTask 中进行休眠,此时便会阻塞 HeapTaskDaemon 线程,达到抑制该线程的目的;
  1. 获取到系统的 HeapTask,并让这个 HeapTask 休眠,同样能达到抑制 HeapTaskDaemon 线程执行的目的。

从 Android8 开始,应用启动时使用第 1 种方案,将 GC 延后 2 秒才执行。对于系统来说,这种方案非常简单,因为系统能直接拿到 TaskProcessor 对象,往里面添加自定义 task 就行。 但是对于应用来说,这种方案相对复杂,复杂的原因在后面会讲到,所以本章中介绍的是第二种方案,下面以系统的 ConcurrentGCTask 为例,我们看看如何让这个 Task 休眠 。

当我们想要调用某个方法时,需要在代码中持有方法的对象,然后才能进行方法的调用,当代码被编译时,编译器会将这个对象编译成内存中的一个地址。但是当我们在代码中拿不到目标对象时,就没法使用这个对象了,即使这个对象会被加载到进程的虚拟内存中。

如果我们想要在自己的 native 方法中,执行 libart 这个 so 库中 ConcurrentGCTask 对象的 Run 方法 ,常规手段办不到,因为我们拿不到 ConcurrentGCTask 对象,更别说执行对象里面的方法了 。

此时,只能使用非常规手段了。libart.so 这个库实际上是已经加载进我们应用的虚拟内存中了,这个方法也被存放在应用用户空间的某一块内存地址上。这时,我们只需要找到这个 Run 方法的地址,就可以操作它了。那怎么才能找到 Run 方法在内存中的地址呢?我们需要用到这个方法的符号,并通过符号在 libart 这个 so 库的内存范围中去寻找其对应的符号表,这样我们就能获取符号对应方法的内存地址了。那么什么是符号呢?

编译器在将 C++ 源代码编译成目标文件时,会将函数和变量的名字进行修饰,生成符号名,所以符号是相应的函数和变量修饰后的名称。编译器不同,生成的符号也不一样,比如通过 GCC 编译器来编译下面这几个函数,对应的符号则如下:

函数符号
int func(int)_Z4funci
float func(float)_Z4funcf
int Test::func(int)_ZN4Test4funcEi

以 int Test::func(int) 这个函数为例,GCC 在生成方法的符号时,都以 _Z 开头,对于嵌套的名字,后面紧跟 N,然后跟着各个名称空间和类的名称长度及名称,所以是 4Test4func,再以 E 结尾(非嵌套的方法名不需要 E ),最后跟着入参类型,那么这个函数的符号连起来就是 _ZN4Test4funcEi。我们不需要去熟悉这些规则,大致了解就行。

《Native内存优化》这篇文章中,讲到了通过 dladdr 函数获取到的 dli_sname 和 dli_saddr 字段,就是方法的符号和这个符号对应的方法地址。下图中的 (Z16CaptureBacktracePPVM)(0x7032a1145c) 、(Z16printNativeStackV)(0X7032a11640) 等数据就是方法对应的符号,以及符号对应方法的地址。如果对内容记不清了,可以再回头看一下这章。

为了包体积和安全考虑,我们一般会将 so 去符号,这样我们在 dladdr 函数中就没法根据符号定位到方法名以及地址了,从上图也可以看到,去符号后的数据为 (null)(0x0)。

幸运的是,在 libart.so 中,很多对象和方法都是有符号的,之所以保留这些符号,可能是需要用于调试或者异常定位使用。通过符号,我们就能找到对应的函数地址了。话说回来,我们为什么不介绍第一种方案呢?也是因为 TaskProcessor 这个对象没有符号,我们无法拿到这个对象,但在第二种方案中,各种 HeapTask 的子类符号是有保留的,所以我们就能拿到这些 Task 的对象和函数的内存地址。有了地址,就有了操作的可行性。下面就来看一下要怎么做吧!

为了便于分析,我们先从 root 手机中拉取一份 libart.so 到本地,在设备的 shell 窗口中执行下面指令即可。libart 这个 so 库一般存放在 /system/lib/ 目录中。


符号信息都是统一放在符号表(.symtab)中的,和 .bss,.text 这些段一样,符号表 .symtab 也属于 ELF 文件中的一个段。我们通过 readelf 工具的 -S 命令来读取 libart 库的段信息。可以看到 so 中是包含了 .symtab 这个段的。


既然符号表是 so 库中的一个段,那么查找这个符号就不难了,和之前 plt hook 方案中查找 dynamic 段中 got 表的函数一样,也是 2 步。

  1. 找到 so 库的首地址,并转换成 ELF 格式。
  1. 找到 ELF 文件中的 .symtab 段,并遍历该段,找到我们想要的符号信息,并取出地址。

解析 maps 文件可以找到 so 库地址的方法我们就不再讲了,这里重点看看第 2 个步骤。

  1. 遍历 ELF 文件中的 Section 段,并寻找 symtab 段。

  1. 遍历 symtab 段,寻找目标符号,并获取符号对应函数的地址。

可以看到,通过符号寻找地址的逻辑并不复杂。我们也可以回头再看看《Native 内存优化:so 库申请的内存优化》这篇文章中 plt hook 的方案实现,会发现寻找 .dynamic 段时的操作和这里寻找 .symtab 段是有区别的。plt hook 方案中,我们 遍历 的是 Program 段,这里遍历的是 Section 段。Program 实际只是按照 Section 的读写权限和属性特征,将 Section 重新组织了一次,然后加载进内存中,这样能节约更多的内存空间。

我们可以通过 readelf -l 命令,查看 libart.so 按照 Program 段的组织方式,可以看到 Program Headers 只有 9 个,而 Section Headers 有 31 个,这 31 个 Section 会按照 Type 的区别,整合到这 9 个 Program 中。


不管是遍历 Section 段 ,还是遍历 Program 段,都能实现在 ELF 文件中查找数据的目的。通过这两种在 ELF 文件中查找数据的方案,可以让我们对 ELF 文件有一个更全面的了解。

虽然已经反复演示过了如何查找 ELF 文件中数据的操作,但是这里还是建议大家用成熟的开源工具来做这个事情,因为真正在线上应用中使用时,我们需要考虑到查找的性能,版本的兼容等各种因素,一不小心可能就出问题了。比如用 ndk_dlopen 这个开源库来实现 so 库和符号的查找就很简单,通过下面两个函数就能快速实现功能。


当然, 除了 ndk_dlopen 这个开源库,你可以找一些其他的成熟的开源框架来完成上面的逻辑,GitHub 都有很多。

了解了如何通过符号查找函数地址,我们再来看一下 ConcurrentGCTask 对象的 Run 函数的符号是什么。我们通过 readelf -s libart.so 指令来读取 libart 中所有的符号,可以看到 libart.so 的符号非常多,有 2 万多个。


当我们稍微了解一下 libart 中符号的生成规则,就能找到 ConcurrentGCTask 对象的 Run 函数的符号,它位于 16846 行,即 _ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE。

有了 Run 函数的符号,我们就很容易拿到地址了,这里以 ndk_dlopen 开源工具做演示:


简单的两行代码,我们就成功拿到了 ConcurrentGCTask 的 Run 函数的地址,这个时候只需要插入我们自己的代码,修改这个函数让它休眠就能成功阻塞 HeapTaskDaemon 线程了。修改这个函数可以用《Native内存优化》这篇文章中提到的 inline hook 方式,我们直接使用文中提到的开源的 inline hook 工具即可,使用起来也很简单,这里就当做课后作业留给你自己去实现了。

inline hook 会直接修改汇编代码,不太稳定,所以这里介绍一种更简单稳定的方案:虚函数 Hook 。通过这种方案,我们能稳定且高效地实现对 Run 方法的 Hook 操作。

C++ 中的虚函数和 Java 中的抽象函数在目的上是类似的,都是留给子类去扩展,实现多态的。虚函数和外部库函数一样都没法直接执行,需要在表中去查找函数的真实地址。当编译器将代码编译成目标代码时,如果发现代码逻辑中执行的是虚函数时,编译器实际上会生成去虚函数表中寻找目标函数的地址的代码,如果不生成这些代码,这个函数是无法执行的,这和我们调用外部库函数也是类似的道理。调用外部函数时,实际的代码逻辑会去 plt 和 got 表中寻找函数的真实地址。


我们在前面通过 ndk_dlsym 拿到的 Run 函数的地址,实际上已经直接拿到了该函数的真实地址了,但在 RunAllTasks 的汇编代码逻辑中,需要去虚函数表查找后才能拿到这个函数最终地址。那什么是虚函数表?

一个类中如果存在虚函数,如 ConcurrentGCTask 有 Run 和 Finalize 两个虚函数,那么编译器就会为这个类生成一张虚函数表,并且将虚函数表的地址放在对象实例的首地址的内存中。同一个类的不同实例,都是共用一张虚函数表的。

这里只大致介绍虚函数和虚函数表的机制,关于虚函数更多的知识,就不再这里展开介绍了,有兴趣的可以自己查阅相关资料。

关于c++ 虚函数更详细的资料,也可以参考这几篇文档

zhuanlan.zhihu.com/p/75172640

cloud.tencent.com/developer/a…

可以看到,虚函数表和 plt got 表的功能其实类似。当我们执行函数时,都需要去表中查询目标函数的真实地址,既然 plt hook 可以修改 got 表中目标函数的地址来达到 hook 的目的,虚函数 hook 的方案同样可以修改虚函数表中目标函数的地址,跳转到我们自己的函数中,来实现 hook 的操作。

和 got 表不同的是,got 表是存在 dynamic 段中的,所以我们修改 got 表需要去遍历 dynamic 段,但是虚函数表是存在对象头部的,我们直接在对象头部中就能拿到虚函数表了,相比 plt hook 会简单很多。下面就看下实现步骤。

  1. 通过符号拿到对象的内存地址,这里是 ConcurrentGCTask 这个对象,它的符号是 _ZTVN3art2gc4Heap16ConcurrentGCTaskE。

  1. 因为虚函数放在对象头部内存数据中,所以对象首地址中的数据就是虚函数表的地址。

  1. 拿到 Run 函数在虚函数表中的地址后,将该地址里面的值替换成我们自己的函数就完成了 hook。在我们自己的函数中进行休眠操作就能抑制 GC 的执行,休眠完成后再调用真正的 Run 函数。

到这里,我们就成功抑制 HeapTaskDaemon 线程执行 GC 的逻辑了。但你可能会担心,抑制了 GC 会不会导致 OOM 提升呢?实际上不会,我们不需要长时间的抑制 GC,只需要在启动的时候,List 滑动的时候,页面打开的时候,抑制 2 到 3 秒即可。并且,从Android8 开始,应用启动时系统自己也会抑制 GC 2 秒。

抑制 GC 的方法有很多,比如我们可以一个个去分析 HeapTask 中 Run 函数所执行的逻辑,寻找这些逻辑中是否有回调方法,可以让我们直接进行休眠操作。以前面提到的 ClearedReferenceTask 为例,它会在 Run 函数中执行 ReferenceQueue.add 这个 Java 方法,那么我们能否在这个 add 方法中进行休眠操作来抑制 GC 呢?希望你能自己去想想,这一章只是为了抛砖引玉,讲了一个可行的实现方案,期待你自己能发现更多可行的方案。

这一章我们就讲到这里,你可以通过下面这张导图,以及导图中的几个问题,来自己回顾、总结一下本章的内容。

当我们掌握本章的知识点后,我们的优化手段就大大扩展了。除了 GC 线程,在开头的图片中,我们也可以看到 Jit thread pool 线程占有了较多的 CPU 时间,这个线程我们同样可以用本章学到的知识点来优化,并且本章的知识点在逆向、安全、外挂等领域都会被经常使用,希望你能掌握好。

到这里,你是不是觉得自己迈入了高手之路呢!切记不要眼高手低,只有当你理解、吸收本章的内容,并能基于它们举一反三,扩展出更多的优化方案时,你才真正迈进了高手之路!

平台注册入口