咱们知道,充沛且合理地运用 CPU 资源是提高速度的实质要素之一。提高 CPU 利用率,除了前面说到的优化计划外,还有许多其他的计划,比方咱们还能够经过剖析 CPU 的运用情况寻觅优化点。

Android 官方供给了完善的剖析 CPU 运用的东西,如抓 Trace 或许 AndroidStudio 中自带的 Profile 东西,假如不了解运用的能够参阅官方文档,解说十分详细,这儿就不过多介绍了。

在经过 Profile 剖析 CPU 运用时, 咱们 常常会发现 HeapTaskDaemon 线程 占用了较高 CPU 时刻,这个线程实践是虚拟机用来履行 GC 操作的。 下图是 Demo 中的 CPU 运用剖析,能够看到 HeapTaskDaemon 线程有大块处于 Running 状况的时刻。

速度优化:GC抑制

从 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 方针中。

速度优化:GC抑制

剖析源码能够发现,HeapTaskDaemon 承继自 Daemon 方针。Daemon 方针实践是一个 Runnale,而且内部会创立一个线程,用于履行当时这个 Daemon Runnable,这个内部线程的线程名就叫 HeapTaskDaemon。到这儿,咱们就知道了这个线程的来源。

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 这个类中。

static void VMRuntime_runHeapTasks(JNIEnv* env, jobject) {
  Runtime::Current()->GetHeap()->GetTaskProcessor()->RunAllTasks(ThreadForEnv(env));
}

经过源码一路跟踪下来,能够看到 HeapTaskDaemon 线程的 run 办法中实在做的工作,实践仅仅在无限循环的调用 GetTask 函数获取 HeapTask 并履行。GetTask 中会不断从 tasks 调集中取出 HeapTask 来履行,而且关于需求延时的 HeapTask ,会阻塞到方针时刻。

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 的子类。

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 的子类有下面这些。

速度优化:GC抑制

下面大致介绍一下每一个 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 办法。

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 了,这归于虚拟机模块的常识,就不打开讲了。

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) 等数据便是办法对应的符号,以及符号对应办法的地址。假如对内容记不清了,能够再回头看一下这章。

速度优化:GC抑制

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

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

符号查找

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

cp /system/lib/libart.so /sdcard/libart.so

符号信息都是共同放在符号表(.symtab)中的,和 .bss,.text 这些段相同,符号表 .symtab 也归于 ELF 文件中的一个段。咱们经过 readelf 东西的 -S 指令来读取 libart 库的段信息。能够看到 so 中是包含了 .symtab 这个段的。

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 段。
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 段,寻觅方针符号,并获取符号对应函数的地址。
//确认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 重新组织了一次,然后加载进内存中,这样能节约更多的内存空间。

速度优化:GC抑制

咱们能够经过 readelf -l 指令,检查 libart.so 依照 Program 段的组织方式,能够看到 Program Headers 只要 9 个,而 Section Headers 有 31 个,这 31 个 Section 会依照 Type 的差异,整合到这 9 个 Program 中。

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 库和符号的查找就很简略,经过下面两个函数就能快速完结功用。

//翻开 so
ndk_dlopen()
//依据符号查找函数地址
ndk_dlsym()

当然, 除了 ndk_dlopen 这个开源库,你能够找一些其他的老练的开源结构来完结上面的逻辑,GitHub 都有许多。

获取 ConcurrentGCTask 的 Run 函数地址

了解了怎么经过符号查找函数地址,咱们再来看一下 ConcurrentGCTask 方针的 Run 函数的符号是什么。咱们经过 readelf -s libart.so 指令来读取 libart 中所有的符号,能够看到 libart.so 的符号十分多,有 2 万多个。

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 开源东西做演示:

//初始化 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 表中寻觅函数的实在地址。

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 两个虚函数,那么编译器就会为这个类生成一张虚函数表,而且将虚函数表的地址放在方针实例的首地址的内存中。同一个类的不同实例,都是共用一张虚函数表的。

速度优化:GC抑制

这儿只大致介绍虚函数和虚函数表的机制,关于虚函数更多的常识,就不再这儿打开介绍了,有爱好的能够自己查阅相关材料。

关于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。
//经过符号拿到ConcurrentGCTask方针地址
void *taskAddress = ndk_dlsym(handle,"_ZTVN3art2gc4Heap16ConcurrentGCTaskE");
  1. 因为虚函数放在方针头部内存数据中,所以方针首地址中的数据便是虚函数表的地址。
/*因为 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 函数。
// 将虚函数表中的值替换成咱们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抑制

当咱们把握本章的常识点后,咱们的优化手法就大大扩展了。除了 GC 线程,在最初的图片中,咱们也能够看到 Jit thread pool 线程占有了较多的 CPU 时刻,这个线程咱们相同能够用本章学到的常识点来优化,而且本章的常识点在逆向、安全、外挂等领域都会被常常运用,期望你能把握好。

到这儿,你是不是觉得自己迈入了高手之路呢!切记不要眼高手低,只要当你了解、吸收本章的内容,并能根据它们触类旁通,扩展出更多的优化计划时,你才实在迈进了高手之路!