likes
comments
collection
share

内存优化:so 库申请的内存优化

作者站长头像
站长
· 阅读数 17

学完了 Java 堆内存的优化,这一章我们正式进入 Native 内存优化的学习。对于很多开发者来说,进行 Native 内存优化要比 Java 堆内存优化的频率少很多。一是 Native 内存可使用的内存大小理论上是手机设备的所有内存,并不会像 Java 堆内存一样被限制在 512M。二是 Naitve 内存相比 Java 堆内存优化会复杂很多,很多人因为没有相关开发经验就直接放弃了。

但我想说的是,虽然 Native 内存优化的频率较少,但如果 Naitve 内存占用出现了异常,同样会影响应用的稳定性。因此我们不能忽略,而且熟悉 Native 内存优化是 Android 进阶的必经之路。

那在优化 Native 内存之前,和学习 Java 堆内存优化一样,我们要先了解它的组成。Native 内存的组成并没有 Java 堆内存的组成那么复杂,主要有 2 部分。

  1. so 库中通过 malloc 、calloc 、realloc 、mmap 等函数申请函数的内存。

  2. Bitmap 的占用,从 Android 8 开始,Bitmap 的内存都是算在 Native 上的。

这一章节我们先讲解第一部分:so 库申请的内存优化。不过,如果你完全没有 Native 开发经验,学习起来可能会有一定的难度,但只要你耐心多看几遍,肯定能吸收并理解。

so 库内存优化思路

要知道,对于一个 App 的 Native 内存消耗来说,并不是越少越好,而是要确保 Native 内存没有异常,异常指的是占用过大,如超过了手机可使用内存的三分之一。异常情况下,应用进程很容易被系统的 LowMemoryKiller 机制强行杀掉,导致应用不可用。

那我们该如何确保 Native 内存没有异常呢?这就要从异常产生的原因说起了,主要有两种原因:一是在 so 库中申请了非常大的内存;二是 so 库有内存的泄漏,导致 Native 内存一直增长,最后变得非常的大。想要解决这两个问题,我们要先定位出问题,也就是要能准确知道异常是哪个 so 库中,甚至是 so 库中哪个函数中的哪一行代码申请了内存,当我们定位出问题后,优化的方案就很简单了,对于能够修改的 so 库,我们将泄漏的内存及时调用 free 进行释放,减少大内存的申请,对于无法修改的第三方 so 库,可以更换稳定版本。

那怎样才能定位出 so 库中的内存异常使用呢?主要有下面这 3 个步骤:

  1. 通过 Native Hook 技术,hook 住 so 库中申请内存和释放内存的函数。在此基础上,我们就可以统计出一个 so 库一共申请了多少内存,释放了多少内存。并且当 so 库申请了超大的内存时,还能获取 Native 的堆栈,便于定位异常函数。

  2. 对于 so 库申请了超大内存的的情况,我们需要获取 Native 的堆栈用于定位问题。

  3. 直接获取的 Native 堆栈是一个个 16 进制地址的堆栈,无法看出有效信息,所以还需要根据 16 进制的地址堆栈还原出 so 名以及具体的函数和位置

后面我们都会围绕这三个步骤进行详细讲解和实践。为了方便你理解,这里通过一个简单 Demo 来说说,如何通过这 3 个步骤定位到 Native 内存中的异常。在 Demo 中,我会新建一个名 testmalloc 的 so 库,并且在这个 so 库中通过 TestMalloc 函数申请了 88 M 的超大内存。

 内存优化:so 库申请的内存优化 内存优化:so 库申请的内存优化

Native Hook 技术的原理

在步骤一中,我们要通过 Native Hook 技术 hook 住 so 库中申请内存和释放内存的函数。可要怎么才能实现 Native 的 Hook 呢?主要有 2 种技术方案可以实现:

  1. PLT Hook:通过修改 GOT 外部函数跳转表进行hook。
  1. Inline Hook:通过修改目标函数的汇编代码来进行hook。

接下来,我们先看第一种方案。

修改 got 表 hook Native 函数

程序在运行过程中会不断调用函数并执行,而调用函数的过程中只有知道了该函数的地址后才能进行正常的调用。如果是 so 库内部的函数,在编译阶段就能确定函数地址,因为编译器只需计算函数在这个 so 库中的相对偏移地址就可以了,当这个 so 库被加载进内存后并,这个函数的实际地址就是 so 库的基地址 + 该函数的相对偏移地址。

但如果我们调用的是一个外部库的函数时,比如 malloc 函数,它位于 libc.so 这个库中,在编译期间就没法确定这个函数的地址了,只有运行时才能知道。这样一来,当程序运行且执行这个外部函数时,由 Linker (动态连接器) 这个系统程序将目标函数的地址写进 got 表中。什么是 got 表呢?

当我们想要 hook 某个函数时,比如上面 Demo 中的 Malloc 函数,只需要修改 testmalloc.so 文件中存放在 got 表中的 malloc 函数地址,改成自己的函数地址即可。后续这个 so 库每次调用 malloc 函数都会调用到我们自己的函数。那为什么这种方案不叫 GOT Hook,要叫 PLT Hook呢?

实际上函数在调用的时候,会先跳转到 plt 表,plt 表是位于 .text 段中的一张表,plt 表中记录着目标函数 .got 表的地址,.got 表中又记录着目标函数的地址。它们的调用关系如下。

 内存优化:so 库申请的内存优化

所以 plt 表的作用相当于是一个跳板,之所以有一个 plt 表,而不直接使用 got 表,是因为这样可以实现延迟绑定,也就是说只有当真正调用目标函数时,再去绑定 got 表中的真实地址。接下来我们就看看如何修改 so 库 got 表中 malloc 函数的地址吧!其实也不难,只需要遍历 ELF 文件中的段,找到 .dynamic 段,然后在 .dynamic 段的 plt 表中找到目标函数后修改地址即可,具体实现主要通过下面这 5 个步骤。

  1. 获取动态库的基地址。这里我们可以通过读取并解析 maps 文件来找到 so 的基地址,我们也可以通过 dl_iterate_phdr 这个 Linux 提供的函数找到目标 so 库的基地址。
FILE *fp = fopen("/proc/self/maps", "r")
while(fgets(line, sizeof(line), fp))
{
    // “PRIxPTR”是将数据转换成 16 进制地址格式的标志,然后赋值给 base_addr 
    if(NULL != strstr(line, "libtestmalloc.so") &&
       sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &base_addr) == 1)
        break;
}
fclose(fp);
  1. 计算 so 库中程序头部表的地址,头部表中记录了 ELF 文件中各个段的地址:
//将 base_addr 强制转换成Elf32_Ehdr格式,即32位 ELF header的结构体,如果是 64 位需要转换成 Elf64_Ehdr
Elf32_Ehdr *header = (Elf32_Ehdr *) (base_addr);
  
Elf32_Phdr *phdr_table = (Elf32_Phdr *) (base_addr + header->e_phoff);  // 程序头部表的地址
if (phdr_table == 0) {
    return 0;
}
size_t phr_count = header->e_phnum;  // 程序头表项个数

//ELF_Ehdr 的数据结构如下
typedef struct elf_hdr{
    unsigned char e_ident[EI_NIDENT];     /* 魔数和相关信息 */
    Elf_Half    e_type;                 /* 目标文件类型 */
    Elf_Half    e_machine;              /* 硬件体系 */
    Elf_Word    e_version;              /* 目标文件版本 */
    Elf_Addr    e_entry;                /* 程序进入点 */
    Elf_Off     e_phoff;                /* 程序头部偏移量 */
    Elf_Off     e_shoff;                /* 节头部偏移量 */
    Elf_Word    e_flags;                /* 处理器特定标志 */
    Elf_Half    e_ehsize;               /* ELF头部长度 */
    Elf_Half    e_phentsize;            /* 程序头部中一个条目的长度 */
    Elf_Half    e_phnum;                /* 程序头部条目个数  */
    Elf_Half    e_shentsize;            /* 节头部中一个条目的长度 */
    Elf_Half    e_shnum;                /* 节头部条目个数 */
    Elf_Half    e_shstrndx;             /* 节头部字符表索引 */
} Elf_Ehdr;
  1. 遍历程序头部表,获取 .dynamic 段的地址:
unsigned long dynamicAddr;  
unsigned int dynamicSize;  
for (int i = 0; i < phr_count; i++) {
    if (phdr_table[i].p_type == PT_DYNAMIC) {
        //so基地址加dynamic段的偏移地址,就是dynamic段的实际地址
        dynamicAddr = phdr_table[i].p_vaddr + base_addr; 
        dynamicSize = phdr_table[i].p_memsz;
        break;
    }
}
  1. 遍历 .dynamic 段,d_tag为 3(不同平台下的 so,这里的序列可能会不一样,所以为了兼容性考虑,我们最好用名称来进行确认) 即为 .got.plt 表地址:
int symbolTableAddr = 0;  
Elf32_Dyn *dynamic_table = (Elf32_Dyn *) dynamicAddr;
for (i = 0; i < dynamicSize; i++) {
    int val = dynamic_table[i].d_un.d_val;
    if (dynamic_table[i].d_tag == 3) {
        symbolTableAddr = val + base_addr;
        break;
    }
}
  1. 修改内存属性为可写,并遍历 .got.plt 表,找到 Malloc 函数的地址后,将 Malloc 函数地址替换成我们自己的 Malloc_hook 函数地址:
//读写权限改为可写
mprotect((void *)PAGE_START(symbolTableAddr), PAGE_SIZE, PROT_READ | PROT_WRITE);

int oldFunc = &malloc- (int) base_addr; // 目标函数偏移地址
int newFunc = &malloc_hook ;  // 替换的hook函数的偏移地址
 while (1) {
    if (symbolTableAddr[i].st_value == oldFunc) {
        symbolTableAddr[i].st_value = newFunc;  
        break;
    }
    i++;
}

可以看到通过修改 got 表来达到 hook Native 函数的技术原理并不复杂,原理就是遍历 ELF 文件然后寻找目标数据并进行修改。如果对 ELF 文件格式不太清晰的,可以等14章更新后一起结合着来看,里面会继续讲解 ELF 文件相关的知识。虽然上面的代码实现看起来比较简单,但是一个能在 Android 项目中使用的 PLT Hook 库,需要考虑不同系统版本的兼容、性能、稳定性等多方面因素,所以一个能用于线上使用的稳定版本的 PLT Hook 的库还是比较复杂的。

Inline 方式 hook Native 函数

修改 got 表的 Hook 方案只有对调用外部的库的函数时才有效,如果是调用当前 so 库内的函数,如何进行 hook 呢?这时,Inline Hook 就派上用场了,但 Inline Hook 的技术较复杂,兼容性较差,还需要较深的汇编基础,所以我就不深入讲解代码实现了,只介绍实现原理。

Inline Hook 是 通过在程序运行时动态修改内存中的汇编指令,来改变程序执行流程的一种 Hook 方式,基本思路就是在已有的代码段中插入跳转指令,把代码的执行流程转向我们自己的函数中

比如 Demo 中的 TestMalloc 函数,它的汇编指令如下。当我们将第一行 (地址为 62c)和第二行(地址为 630)的指令覆盖成跳转到我们自己的函数的指令时,那执行这个函数就会先跳转到我们自己的函数中(需要覆盖第一和第二行是因为一个全地址范围跳转指令:LDR 指令,它至少需要 8 位才能实现,第一行和第二行指令加起来刚好 8 位)。

 内存优化:so 库申请的内存优化

当我们的函数执行完成,就会继续回到 TestMalloc 函数中,从 634 的地址开始执行。这个时候,我们还需要恢复执行 62c 和 630 这两个地址中被覆盖的指令。整个流程如下图。

 内存优化:so 库申请的内存优化

通过上面的例子,我们可以将 inline hook 的实现步骤总结下:

  1. 拷贝原函数的头部两条汇编指令,并覆盖成跳转到 Hook 函数的指令;

  2. Hook 函数执行完成,再执行前面备份的已被覆盖的两条指令。不过这里直接执行可能会出现问题,比如有的指令如果用到了 PC 寄存器 (保存的是当前正在取指的指令的地址),但此时的 PC 已经改变了,所以我们还需要进行指令修复,修复的方案是将 PC 相关的指令替换成 PC 无关的指令。

虽然上面的原理看起来比较容易理解,但是实际实现起来还是比较复杂的,需要对汇编有较深的基础,由于是直接修改的汇编指令,因此会有很多兼容性问题,不太稳定,最后的指令修复方案也很容易出问题,你如果感兴趣,可以参考相关的开源库深入分析。

讲完了 Native Hook 技术的两种方案,我们来对比下它们的优缺点。

方案优点缺点
PLT Hook稳定- 只能Hook 外部 so 函数调用- hook过程比较费时
Inline Hook可以 hook so 内部调用- 不稳定- 只能hook大于8字节长度的方法

Naitve hook 是一项很成熟的技术,GitHub 上有很多相关的开源库,我们在这里掌握原理即可,并不需要自己重复造轮子再去实现一遍。这里推荐几个大厂开源的 Native Hook 库:

PLT Hook

Inline hook

除了 PLT Hook 和 Inline Hook 两种 Naitve Hook 方案,还有一种虚函数表 Hook 方案,但是这种方案只有在虚函数中才能使用,在《通过 GC 抑制来提升速度》文章中会详细讲解这一种方案,就不在这里展开讲了。

总的来说,我更推荐你在 hook so 库的内存申请这一场景中,使用修改 got 表的 Native Hook 方案,因为这个方案更稳定也更简单。但并不是说 Inline Hook 就不能用,Inline Hook 在很多场景下,往往是首选,比如需要 hook so 内部的函数调用时,但是需要我们有一定的技术功底,才能掌控的住 Inline Hook。

所以这里我以 bhook 这个开源的 PLT Hook 库为例,来 hook demo 中 testmalloc.so 库的 Malloc 函数。我们新建一个 hookmalloc 的 so 库,按照 bhook 提供的 bytehook_hook_all 接口,来实现对所有 so 库中的 malloc 函数的 hook,并在 hook 函数 malloc_hook 中,打印申请的内存大小。

 内存优化:so 库申请的内存优化 内存优化:so 库申请的内存优化

运行后,通过 log 日志可以看到,我们成功检测到了这一笔 88 M ( 92274688 k)的内存申请。

 内存优化:so 库申请的内存优化

Demo 中 hook 了malloc 函数,但如果我们想要统计全面,还需要把其他的所有内存申请相关的函数 hook 住,内存释放的函数也 hook 住。

Native 堆栈获取原理

当我们检测到这笔异常的内存申请后,就需要获取堆栈来帮助我们定位问题了。在 Java 代码中,我们只需要调用 Debug.DumpHeap 方法就能获取当前 Java 函数的堆栈,Naitve 中没有直接获取堆栈的方法,需要我们自己去实现,主要的方式有 2 种:

  1. 通过 FP 栈帧寄存器获取 Native 堆栈;

  2. 通过栈信息 CFI (Call Frame Information) 获取 Native 堆栈。

通过 FP 寄存器获取堆栈

在讲如何通过 FP 寄存器获取堆栈之前,先介绍几个 Android 设备上常用的寄存器。

  • PC(ProgramCounter,程序计数器) :保存的是当前正在取指的指令的地址。

  • LR (LinkRegister) :在进行函数调用时,会将函数返回后要执行的下一条指令放入 lr 寄存器中。

  • FP(FramePointer,帧指针):通常指向一个函数的栈帧底部,表示一个函数栈的开始位置。

  • SP(StackPointer,栈顶指针):指向当前栈空间的顶部位置,当函数执行流程进行 push 和 pop 时会一起移动。

在前面的章节中,我们已经知道栈在虚拟内存中是从高地址向底地址扩展的,所以从下图中可以看到,函数栈中 FP 和 SP 寄存器分别指向栈底和栈顶,通过 FP 寄存器就可以找到存储在栈中的 LR 寄存器的数据,这个数据就是函数返回地址。同时也可以找到保存在函数栈中的上一级函数的 FP 寄存器的数据,这个数据指向了上一级函数的栈底。接下来就可以按照同样的方法找出上一级函数栈中存储的 LR 和 FP 数据,因此也知道了上上一级函数以及它的栈底地址,这样循环起来就构成了一个栈回溯过程。整个流程以 FP 为核心,依次找出每个函数栈中存储的 LR 和 FP 数据,计算出函数返回地址和上一级函数栈底地址,从而找出每一级函数调用关系。

 内存优化:so 库申请的内存优化

这种栈回溯方式的实现简单且非常快速,但它在运行时需要占用一个通用寄存器,也就是 FP 会占用寄存器,而 ARM32 的寄存器又比较紧缺,所以默认情况下编译器会进行优化,也就是关闭 FP 寄存器,这可以优化程序的性能和执行速度。如果想要使用 FP 这种方式来回溯,需要在编译 so 库时,显式地添加 -fno-omit-frame-pointer 标志来告诉编译器不要关闭 FP 寄存器,达到快速回溯的目的

我们使用的第三方库的 so,很多都关闭了 FP 寄存器,兼容性不高,所以这里就不继续介绍实现方案了,只需要了解大致原理作为知识储备,等我们需要这种方案的那时候再深入调研即可。

通过栈信息 CFI 获取堆栈

接下来,我们重点说说怎么通过栈信息 CFI 获取堆栈。目前 Android 中 Naitve 的堆栈获取,基本都是通过 CFI 来实现的,包括 Native Carash 时输出的堆栈,以及 Android 官方的一些 Naitve 调试工具等都是采用的这种方案。那什么是 CFI 呢?

CFI (Call Frame Information)是帧调用信息的缩写,在程序运行时,当 Native 函数执行进入栈指令后,就会将对应指令的信息,如地址等(即 CFI ) 写入 .eh_frame 和 .eh_frame_hdr 段中,这两个段也属于 so 这个 ELF文件中的段组成之一。因此,想要获取 Naitve 的堆栈,只需要通过这两个段中的数据就可以了。

Android 系统可以直接使用 libunwind 这个库来获取 Naitve 的堆栈信息,libunwind 的底层原理实际上就是通过读取 CFI 来实现的。unwind 的用法也比较简单,代码实现如下:

#include <unwind.h> //引入 unwind 库

struct backtrace_stack
{
    void** current;
    void** end;
};
static _Unwind_Reason_Code _unwind_callback(struct _Unwind_Context* context, void* data)
{
    struct backtrace_stack* state = (struct backtrace_stack*)(data);
    uintptr_t pc = _Unwind_GetIP(context);  // 获取 pc 值,即绝对地址
    if (pc) {
        if (state->current == state->end) {
            return _URC_END_OF_STACK;
        } else {
            *state->current++ = (void*)(pc);
        }
    }
    return _URC_NO_REASON;
}
static size_t _fill_backtraces_buffer(void** buffer, size_t max)
{
    struct backtrace_stack stack = {buffer, buffer + max};
    _Unwind_Backtrace(_unwind_callback, &stack);
    return stack.current - buffer;
}
//Usage 
void* buffer[30];
int count = _fill_backtraces_buffer(buffer, 30);

上面的代码中,_Unwind_Backtrace(_unwind_callback, &stack) 函数就是 libunwind 库提供,用于栈遍历的,回调函数 _unwind_callback 中不断将每个栈的 PC 值写到 current 变量,直到所有的栈遍历完,或者达到 end 才停止。

Native 堆栈还原函数详细信息

我们通过上面的方式获取的 Native 堆栈后只是 16 进制的地址,根据这些地址是没法查看到有效信息的,所以我们还需要将地址还原成对应的函数详细信息。

 内存优化:so 库申请的内存优化

将 16 进制的地址堆栈还原成带有效信息的堆栈,需要经过下面 3 个步骤 :

  1. 确认 so 名;

  2. 计算的偏移地址;

  3. 根据带符号表(ELF 文件中的一张表,存放了函数,方法,变量等名称符号信息)的 so 文件,还原指针对应的函数名和行数。

确认 so 库

确认 so 库名有多种方法,常见的是通过解析 maps 文件,确认每个 so 文件的基地址和结束地址,然后对比堆栈中 16 进制地址,就能确认是哪个 so 文件了。但这里,我们要介绍一种更简单的方式,也就是使用 dladdr() 函数。

int  dladdr ( void * addr , Dl_info * info ) ;

typedef struct {
    const char *dli_fname;   //地址对应的 so 名
    void       *dli_fbase;   //对应so库的基地址
    const char *dli_sname;   //如果so库有符号表,这会显示离地址最近的函数名
    void       *dli_saddr;   //符号表中,离地址最近的函数的地址
} Dl_info;

dladdr 函数的用法也很简单,传入一个地址和 Dl_info 结构体指针,便能在结构体中获取该函数的 so 名及 so库的基地址。

 内存优化:so 库申请的内存优化

该函数的 log 日志如下:

 内存优化:so 库申请的内存优化

通过日志我们可以看到,对于 libhookmalloc 带了符号表,那么 dli_sname 和 dli_saddr 字段就能显示出正确的值。对于 libart ,已经移除了符号表,则显示为 null ,地址也为 0 。如果是 Release 包,打包的途中,libhookmalloc 的符号表也会自动被移除。

计算地址

还原函数信息后的堆栈 log 日志,已经比只有 16 进制地址的堆栈多了很多有用数据了,我们可以看到日志中第四行就是分配内存异常的地方:

D/MallocHook: # 3 : 0x70329d164c : /data/app/~~HAIFZZBf0QGqnGoZmT_ixA==/com.example.hooktest-6c6SbIWGaQmXST7N_NH0xw==/lib/arm64/libtestmalloc.so(Java_com_example_hooktest_MainActivity_TestMalloc)(0x70329d162c)

我们还知道了这个异常的 so 名为 libtestmalloc.so,异常函数为Java_com_example_hooktest_MainActivity_TestMalloc。基于此,我们还能进一步定位到这个函数中哪一行出了问题吗?当然是可以的。

我们可以使用 addr2line 工具,根据函数偏移地址,获取地址对应的函数名、行号等信息。

addr2line -C -f -e xxx.so 函数偏移地址

-C:将低级别的符号名解码为用户级别的名字。
-e:指定需要转换地址的可执行文件名
-f:在显示文件名、行号信息的同时显示函数名。

这里我们需要知道函数的偏移地址,那什么是函数的偏移地址呢?堆栈中的 16 进制是函数的绝对地址,偏移地址是相对 so 库的偏移地址,在前面讲修改 got 表 hook Native 函数也讲过,所以只需要用绝对地址减去 so 库的相对地址就能得到偏移地址。

 内存优化:so 库申请的内存优化

补充了偏移地址,再看 Log 日志,我们就能确定出问题的函数的偏移地址是 0X64C。

 内存优化:so 库申请的内存优化

还原函数名及行数

通过上面的方式,我们已经知道了函数的偏移地址,接下来就通过 addr2line 工具试验一下吧!Android 的 NDK 中已经提供了这个工具了,位于 /ndk/xxx/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin 目录中。因为我的电脑是 M1,所以选择 aarch64 目录,你可以根据自己的电脑选择对应的目录,如 arm 或者 x86 等。

arm-linux-androideabi-addr2line   -C -f -e libionativehook.so  0x64c

这里的 so 文件需要选择带符号表的 so ,我们可以在编译产物的 native_libs 中去寻找 Debug 版本的 so。编译产物文件中有一个 stripped_native_libs 的文件,里面的 so 都是去符号表的,记得不要选择这里面的 so。

 内存优化:so 库申请的内存优化 内存优化:so 库申请的内存优化

运行后的结果如下。可以看到,结果显示了地址对应函数名位于 15 行,根据这个行数,我们可以准确定位出问题的地方。

 内存优化:so 库申请的内存优化 内存优化:so 库申请的内存优化

对于第三方 sdk,都是已经去符号表的,是没法通过 add2line 查看到对应行数的,如下用去符号表之后的 libtestmalloc.so 查询的结果。

 内存优化:so 库申请的内存优化

不过,即使第三方 sdk 的 so 即使没去符号表,我们在没有源码的情况下也无法修改。因此,我们只需要知道是否有异常就行了,我们可以将分配的和已经释放记录下来,看是否有申请大量内存而没释放的情况,对于这些内存使用异常的第三方so,我们最好的做法是替换成稳定正常的版本。

当然,如果是线上监控,需要考虑到性能,那么只需要抓取到 16 进制的堆栈就行了,然后将 16 进制的堆栈和 maps 文件上传到服务端,服务端通过 maps 文件的方案,确认对应的 so 名和函数名,如果服务端有带符号表的 so ,还能进一步确定行号。

工具介绍

分析和治理 so 库中内存异常的全流程还是挺长的,而且知识点也比较多,这里建议大家能够自己操作一遍,可以帮助我们更深刻地理解和认知整个流程。前面也提到过,开发一款线上可用、稳定性又高的工具是需要付出很多精力的,如果你没有这样的精力去开发一套 so 库的异常内存检测工具,我们也有现成的工具,这里介绍 2 款:

  1. Malloc_Debug

malloc_debug 是谷歌 Google 官方提供的 Native 分析工具,malloc_debug 的技术原理和上面讲到的流程是一致,但是它是 hook 整个 zygote 进程中的内存申请相关的函数,并且需要在 Root 后的手机上才能使用,使用起来不太灵活,性能也较差,只能作为线下的工作使用。

  1. Memory-leak-detector

MemoryLeakDetector 是字节开源的一款 Native 内存泄漏监控工具,具有接入简单、监控范围广、性能优良、 稳定性好的特点,并且经过了众多字节 App 线上的验证。

这两款工具我也建议你都用一用,把流程跑通,有兴趣也可以对着两个库的源码阅读,在前面基础原理的加持下,应该也是读得懂。

小结

这一章虽然只讲解了 so 库的内存优化这一部分,但涉及 Native 知识还是挺多的,这里做个导图进一步总结下:  内存优化:so 库申请的内存优化 你可以反复阅读并进行实操,直到把里面的知识点都吸收。