速度优化:GC抑制

我们知道,充分且合理地使用 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 执行,我们首先要熟悉 GC 的执行流程,然后从流程中寻找突破点,在前面学习通过"黑科技"手段优化虚拟内存时,我们也是这样的思路。既然 HeapTaskDaemon 线程抢占了较多的 CPU,我们就直接从 HeapTaskDaemon 这个线程来分析,看看这个线程到底是做什么的。

HeapTaskDaemon 线程的起源

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

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

Java 复制代码
private static class HeapTaskDaemon extends Daemon {
    private static final HeapTaskDaemon INSTANCE = new HeapTaskDaemon();

    HeapTaskDaemon() {
        super("HeapTaskDaemon");
    }

   public void runInternal() {
        ......
        VMRuntime.getRuntime().runHeapTasks();
    }
}
    
private static abstract class Daemon implements Runnable {
    @UnsupportedAppUsage
    private Thread thread;
    private String name;
    private boolean postZygoteFork;

    protected Daemon(String name) {
        this.name = name;
    }

    @UnsupportedAppUsage
    public synchronized void start() {
        startInternal();
    }

    public synchronized void startPostZygoteFork() {
        postZygoteFork = true;
        startInternal();
    }

    // zygote 进程启动就会启动当前线程
    public void startInternal() {
        if (thread != null) {
            throw new IllegalStateException("already running");
        }
        thread = new Thread(ThreadGroup.systemThreadGroup, this, name);
        thread.setDaemon(true);
        thread.setSystemDaemon(true);
        thread.start();
    }

    public final void run() {
        ......
        try {
            runInternal();
        } catch (Throwable ex) {
           ......
            throw ex;
        }
    }

    public abstract void runInternal();

    ......
    
}

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

HeapTaskDaemon 线程的作用

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

c++ 复制代码
static void VMRuntime_runHeapTasks(JNIEnv* env, jobject) {
  Runtime::Current()->GetHeap()->GetTaskProcessor()->RunAllTasks(ThreadForEnv(env));
}

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

c++ 复制代码
void TaskProcessor::RunAllTasks(Thread* self) {
  while (true) {
    HeapTask* task = GetTask(self);
    if (task != nullptr) {
      task->Run(self);
      task->Finalize();
    } else if (!IsRunning()) {
      break;
    }
  }
}

std::multiset<HeapTask*, CompareByTargetRunTime> tasks_ ;

HeapTask* TaskProcessor::GetTask(Thread* self) {
  ......
  while (true) {
    if (tasks_.empty()) {
      //如果 tasks 集合为空,则休眠线程
      cond_.Wait(self);  
    } else {
      // 如果 task是集合不会空,则取出第一个 HeapTask
      const uint64_t current_time = NanoTime();
      HeapTask* task = *tasks_.begin();
      
      uint64_t target_time = task->GetTargetRunTime();
      if (!is_running_ || target_time <= current_time) {
        tasks_.erase(tasks_.begin());
        return task;
      }
      // 对于延时执行的 HeapTask,这里会进行等待,直到目标时间  
      const uint64_t delta_time = target_time - current_time;
      const uint64_t ms_delta = NsToMs(delta_time);
      const uint64_t ns_delta = delta_time - MsToNs(ms_delta);
      cond_.TimedWait(self, static_cast<int64_t>(ms_delta), static_cast<int32_t>(ns_delta));
    }
  }
  UNREACHABLE();
}

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

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

HeapTask 分析

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

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

c++ 复制代码
class HeapTask : public SelfDeletingTask {
 public:
  explicit HeapTask(uint64_t target_run_time) : target_run_time_(target_run_time) {
  }
  uint64_t GetTargetRunTime() const {
    return target_run_time_;
  }

 private:
  void SetTargetRunTime(uint64_t new_target_run_time) { //延时时间设置接口
    target_run_time_ = new_target_run_time;
  }

  uint64_t target_run_time_;
  friend class TaskProcessor;
};

class SelfDeletingTask : public Task {
 public:
  virtual ~SelfDeletingTask() { }
  virtual void Finalize() {
    delete this;
  }
};

class Task : public Closure {
 public:
  // 定义 Finalize 虚函数
  virtual void Finalize() { }
};

class Closure {
 public:
  virtual ~Closure() { }
  // 定义 Run 虚函数
  virtual void Run(Thread* self) = 0;
};

还是通过全局搜索,发现 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 为例子讲解它的原理和机制。

ConcurrentGCTask 分析

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

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

c++ 复制代码
inline mirror::Object* Heap::AllocObjectWithAllocator(Thread* self,
                                                      ObjPtr<mirror::Class> klass,
                                                      size_t byte_count,
                                                      AllocatorType allocator,
                                                      const PreFenceVisitor& pre_fence_visitor) {
  ......
  bool need_gc = false;
  uint32_t starting_gc_num;  // o.w. GC number at which we observed need for GC.
  {
    ......
    if (bytes_tl_bulk_allocated > 0) {
      ......
      // 如果是并发 GC ,或者达到了阈值,则need_gc为true
      if (IsGcConcurrent() && UNLIKELY(ShouldConcurrentGCForJava(new_num_bytes_allocated))) {
        need_gc = true;
      }
      ......
    }
  }
  ......
  if (need_gc) {
    // Do this only once thread suspension is allowed again, and we're done with kInstrumented.
    RequestConcurrentGCAndSaveObject(self, /*force_full=*/ false, starting_gc_num, &obj);
  }
  ......
  return obj.Ptr();
}

inline bool Heap::ShouldConcurrentGCForJava(size_t new_num_bytes_allocated) {
  return new_num_bytes_allocated >= concurrent_start_bytes_;
}

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

c++ 复制代码
void Heap::RequestConcurrentGCAndSaveObject(Thread* self,
                                            bool force_full,
                                            uint32_t observed_gc_num,
                                            ObjPtr<mirror::Object>* obj) {
  RequestConcurrentGC(self, kGcCauseBackground, force_full, observed_gc_num);
}

bool Heap::RequestConcurrentGC(Thread* self,
                               GcCause cause,
                               bool force_full,
                               uint32_t observed_gc_num) {
  uint32_t max_gc_requested = max_gc_requested_.load(std::memory_order_relaxed);
  if (!GCNumberLt(observed_gc_num, max_gc_requested)) {
    if (CanAddHeapTask(self)) {
      if (max_gc_requested_.CompareAndSetStrongRelaxed(max_gc_requested, observed_gc_num + 1)) {
        task_processor_->AddTask(self, new ConcurrentGCTask(NanoTime(),  // Start straight away.
                                                            cause,
                                                            force_full,
                                                            observed_gc_num + 1));
      }
      ......
      return true;
    }
    return false;
  }
  return true;  
}

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

抑制 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/ 目录中。

Shell 复制代码
cp /system/lib/libart.so /sdcard/libart.so

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

Shell 复制代码
aarch64-linux-android-readelf -S libart.so 
There are 31 section headers, starting at offset 0x5978e8:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .note.android.ide NOTE            0000c154 000154 000018 00   A  0   0  4
  [ 2] .note.gnu.build-i NOTE            0000c16c 00016c 000020 00   A  0   0  4
  [ 3] .dynsym           DYNSYM          0000c18c 00018c 01a800 10   A  4   1  4
  [ 4] .dynstr           STRTAB          0002698c 01a98c 070a56 00   A  0   0  1
  [ 5] .gnu.hash         GNU_HASH        000973e4 08b3e4 00c570 04   A  3   0  4
  [ 6] .gnu.version      VERSYM          000a3954 097954 003500 02   A  3   0  2
  [ 7] .gnu.version_d    VERDEF          000a6e54 09ae54 00001c 00   A  4   1  4
  [ 8] .gnu.version_r    VERNEED         000a6e70 09ae70 000090 00   A  4   3  4
  [ 9] .rel.dyn          LOOS+0x1        000a6f00 09af00 002a80 01   A  0   0  4
  [10] .rel.plt          REL             000a9980 09d980 000bf8 08  AI  3  11  4
  [11] .plt              PROGBITS        000aa578 09e578 001208 00  AX  0   0  4
  [12] .text             PROGBITS        000ab800 09f800 374a20 00  AX  0   0 512
  [13] .ARM.exidx        ARM_EXIDX       00420220 414220 00c2d8 08  AL 12   0  4
  [14] .rodata           PROGBITS        0042c500 420500 027794 00   A  0   0 16
  [15] .ARM.extab        PROGBITS        00453c94 447c94 000858 00   A  0   0  4
  [16] .eh_frame         PROGBITS        004544ec 4484ec 0041c4 00   A  0   0  4
  [17] .eh_frame_hdr     PROGBITS        004586b0 44c6b0 0006fc 00   A  0   0  4
  [18] .fini_array       FINI_ARRAY      0045a410 44d410 000004 00  WA  0   0  4
  [19] .data.rel.ro      PROGBITS        0045a420 44d420 006ab8 00  WA  0   0 16
  [20] .init_array       INIT_ARRAY      00460ed8 453ed8 000058 00  WA  0   0  4
  [21] .dynamic          DYNAMIC         00460f30 453f30 000170 08  WA  4   0  4
  [22] .got              PROGBITS        004610a0 4540a0 000f60 00  WA  0   0  4
  [23] .data             PROGBITS        00462000 455000 001290 00  WA  0   0 16
  [24] .bss              NOBITS          00463290 456290 001fe1 00  WA  0   0 16
  [25] .comment          PROGBITS        00000000 456290 000065 01  MS  0   0  1
  [26] .note.gnu.gold-ve NOTE            00000000 4562f8 00001c 00      0   0  4
  [27] .ARM.attributes   ARM_ATTRIBUTES  00000000 456314 000044 00      0   0  1
  [28] .shstrtab         STRTAB          00000000 456358 000143 00      0   0  1
  [29] .symtab           SYMTAB          00000000 45649c 066a90 10     30 19498  4
  [30] .strtab           STRTAB          00000000 4bcf2c 0da9bc 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  y (noread), p (processor specific)

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

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

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

  1. 遍历 ELF 文件中的 Section 段,并寻找 symtab 段。
c++ 复制代码
unsigned long symbolAddr;  
unsigned int symbolSize;  
//将 so_addr 强制转换成Elf_Ehdr格式
Elf_Ehdr *header = (Elf_Ehdr *) (so_addr);
// 获取段头部表的地址
Elf_Shdr *seg_table = (Elf_Phdr *) (so_addr + header->e_shoff);  

// 段的数量
size_t seg_count = header->e_shnum; 
//遍历段,寻找symtab段地址
for (int i = 0; i < seg_count ; i++) {
    seg_table += header->e_shentsize
    if (seg_table->sh_type == SHT_SYMTAB) {
        //so基地址加symtab段的偏移地址,就是symtab段的实际地址
        symbolAddr = seg_table->sh_offset + so_addr; 
        symbolSize = seg_table->sh_size;
        break;
    }
}
  1. 遍历 symtab 段,寻找目标符号,并获取符号对应函数的地址。
c++ 复制代码
//确定symtab中符号的数量
size_t symtab_num= (symbolSize / sizeof(Elf_Sym)
//将 sybtal 段地址强制转换成 Elf_Sym 结构体
Elf_Sym *symtab = (Elf_Sym *)symbolAddr;
//遍历 sybtab 中的符号,并进行对比
for (k = 0; k < symtab_num; k++) {
    //如果和想要查找的符号名ratget一致,则返回符号对应函数的地址
    if (strcmp(strtab + symtab->st_name, target) == 0) {
        void *ret = so_addr + symtab->st_value;
        return ret;
    }
    //移动到下一个符号地址上
    symtab++;
}

//Elf_Sym 的数据结构如下
typedef struct elf_sym {
    Elf32_Word        st_name;        //符号名
    Elf32_Addr        st_value;       //符号对应的值的偏移地址
    Elf32_Word        st_size;        //符号的大小
    ......
} Elf_Sym;

可以看到,通过符号寻找地址的逻辑并不复杂。我们也可以回头再看看《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 中。

kotlin 复制代码
aarch64-linux-android-readelf -l libart.so 

Elf file type is DYN (Shared object file)
Entry point 0x0
There are 9 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x0000c034 0x0000c034 0x00120 0x00120 R   0x4
  LOAD           0x000000 0x0000c000 0x0000c000 0x44cdac 0x44cdac R E 0x1000
  LOAD           0x44d410 0x0045a410 0x0045a410 0x08e80 0x0ae61 RW  0x1000
  DYNAMIC        0x453f30 0x00460f30 0x00460f30 0x00170 0x00170 RW  0x4
  NOTE           0x000154 0x0000c154 0x0000c154 0x00038 0x00038 R   0x4
  GNU_EH_FRAME   0x44c6b0 0x004586b0 0x004586b0 0x006fc 0x006fc R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x0
  EXIDX          0x414220 0x00420220 0x00420220 0x0c2d8 0x0c2d8 R   0x4
  GNU_RELRO      0x44d410 0x0045a410 0x0045a410 0x07bf0 0x07bf0 RW  0x10

 Section to Segment mapping:
  Segment Sections...
   00
   01     .note.android.ident .note.gnu.build-id .dynsym .dynstr .gnu.hash .gnu.version .gnu.version_d .gnu.version_r .rel.dyn .rel.plt .plt .text .ARM.exidx .rodata .ARM.extab .eh_frame .eh_frame_hdr
   02     .fini_array .data.rel.ro .init_array .dynamic .got .data .bss
   03     .dynamic
   04     .note.android.ident .note.gnu.build-id
   05     .eh_frame_hdr
   06
   07     .ARM.exidx
   08     .fini_array .data.rel.ro .init_array .dynamic .got
   None   .comment .note.gnu.gold-version .ARM.attributes .shstrtab .symtab .strtab

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

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

c++ 复制代码
//打开 so
ndk_dlopen()
//根据符号查找函数地址
ndk_dlsym()

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

获取 ConcurrentGCTask 的 Run 函数地址

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

Shell 复制代码
Symbol table '.symtab' contains 26281 entries:
   Num:    Value  Size Type    Bind   Vis       Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT   UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT   ABS crtbegin_so.c
     2: 000acf34     0 NOTYPE  LOCAL  DEFAULT    12 $a
     3: 000acf50     0 NOTYPE  LOCAL  DEFAULT    12 $d
     4: 0045a410     0 NOTYPE  LOCAL  DEFAULT    18 $d
     5: 00462000     0 NOTYPE  LOCAL  DEFAULT    23 $d
    ......
 16846: 001b0ff1    36 FUNC    LOCAL  HIDDEN     12 _ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE
    ......

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

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

c++ 复制代码
//初始化 ndk_dlopen
ndk_init(env);

//以RTLD_NOW模式打开动态库libart.so,拿到句柄,RTLD_NOW即解析出每个未定义变量的地址
void *handle = ndk_dlopen("libart.so", RTLD_NOW);

//通过符号拿到地址
void *runAddress = ndk_dlsym(handle," _ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE");

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

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

虚函数 Hook

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

c++ 复制代码
void TaskProcessor::RunAllTasks(Thread* self) {
  while (true) {
    HeapTask* task = GetTask(self);
    if (task != nullptr) {
      //编译器编译成目标代码是,会去虚函数表寻找这个Run函数的地址
      task->Run(self);
      //编译器编译成目标代码是,会去虚函数表寻找这个Finalize函数的地址
      task->Finalize();
    } else if (!IsRunning()) {
      break;
    }
  }
}

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

什么是虚函数表?

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

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

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

zhuanlan.zhihu.com/p/75172640

cloud.tencent.com/developer/a...

如何实现虚函数 Hook?

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

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

  1. 通过符号拿到对象的内存地址,这里是 ConcurrentGCTask 这个对象,它的符号是 _ZTVN3art2gc4Heap16ConcurrentGCTaskE。
c++ 复制代码
//通过符号拿到ConcurrentGCTask对象地址
void *taskAddress = ndk_dlsym(handle,"_ZTVN3art2gc4Heap16ConcurrentGCTaskE");
  1. 因为虚函数放在对象头部内存数据中,所以对象首地址中的数据就是虚函数表的地址。
c++ 复制代码
/*由于 ConcurrentGCTask 只有五个虚函数,所以我们只需要查询前五个地址即可。
  但是在实际开发中,为了稳定性考虑,这里的k设置成 (虚函数表size / sizeof(void *))最稳妥
*/
int k = 5;
void **slot = nullptr;

for (size_t i = 0; i < 5; i++) {
    /*对象头地址中的内容存放的就是是虚函数表的地址,所以这里是指针的指针,即是虚函数表地址
       拿到虚函数表地址后,转换成数组,并遍历获取值
    */
    void *vfunc = ((void **) taskAddress )[i];
    
    // 如果虚函数表中的值是前面拿到的 Run 函数的地址,那么就找到了Run函数在虚函数表中的地址
    if (vfunc == runAddress) {
        //这里需要注意的是,这里 +i 操作拿到的是地址,而不是值,因为这里的值是 Run 函数的真实地址
        slot = (void **) taskAddress + i;
    }
}
  1. 拿到 Run 函数在虚函数表中的地址后,将该地址里面的值替换成我们自己的函数就完成了 hook。在我们自己的函数中进行休眠操作就能抑制 GC 的执行,休眠完成后再调用真正的 Run 函数。
c++ 复制代码
// 将虚函数表中的值替换成我们hook函数的地址
replaceFunc(mSlot,&hookRun)

replaceFunc(void **slot, void *func) {
    //将内存页面设置成为可写
    void *page = (void *) ((size_t) slot & (~(size_t) (PAGE_SIZE - 1)));
    if (mprotect(page, PAGE_SIZE, PROT_READ | PROT_WRITE) != 0) return false;
    //将表中的值替换成我们自己的函数
    *slot = func;
#ifdef __arm__
     //刷新内存缓存,使虚函数表修改生效
    cacheflush((long)page, (long)page + PAGE_SIZE, 0);
#endif
    //将内存页面设置成为只读
    mprotect(page, PAGE_SIZE, PROT_READ);
    return true;
}

 
 //我们的 hook 函数
 void hookRun(void *thread) {
    //休眠2秒
    sleep(2000);
    //将虚函数表中的值还原成原函数,避免每次执行run函数时都会执行hook的方法
    replaceFunc(mSlot, taskAddress);
    //执行原来的Run方法
    ((void (*)(void *)) taskAddress)(thread);
}

到这里,我们就成功抑制 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 时间,这个线程我们同样可以用本章学到的知识点来优化,并且本章的知识点在逆向、安全、外挂等领域都会被经常使用,希望你能掌握好。

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

相关推荐
数据猎手小k2 小时前
AndroidLab:一个系统化的Android代理框架,包含操作环境和可复现的基准测试,支持大型语言模型和多模态模型。
android·人工智能·机器学习·语言模型
你的小103 小时前
JavaWeb项目-----博客系统
android
风和先行3 小时前
adb 命令查看设备存储占用情况
android·adb
无尽的大道4 小时前
Java反射原理及其性能优化
jvm·性能优化
AaVictory.4 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
测试19985 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
似霰5 小时前
安卓智能指针sp、wp、RefBase浅析
android·c++·binder
大风起兮云飞扬丶5 小时前
Android——网络请求
android
干一行,爱一行5 小时前
android camera data -> surface 显示
android
断墨先生5 小时前
uniapp—android原生插件开发(3Android真机调试)
android·uni-app