Android Native内存泄漏检测方案详解

1,593 阅读16分钟

在Android Native层开发过程中,内存泄漏是一个常见的问题。内存泄漏不仅会导致应用程序占用越来越多的内存,还可能引发性能问题和崩溃。因此,检测和解决内存泄漏问题对于保证应用程序的稳定性和性能至关重要。本文将详细介绍四种在Android Native层检测内存泄漏的方案,并分析它们的优缺点及适用场景。

一、在Android Native层检测内存泄漏的方案

1.1 AddressSanitizer (ASan)

1.1.1 原理介绍

AddressSanitizer(简称ASan)是一种内存错误检测器,它可以检测出各种内存相关的错误,包括内存泄漏。在Android NDK中,我们可以通过在编译选项中添加-fsanitize=address来启用ASan。ASan会在程序运行时监控内存操作,当检测到内存泄漏时,会打印出详细的错误信息,包括泄漏的大小、位置和堆栈信息。

AddressSanitizer的原理

  1. 内存布局变换:ASan在编译时改变程序的内存布局,使得程序中的每个对象(变量、数组等)周围都有一些额外的“红色区域”(redzones)。这些红色区域用于检测内存访问越界。例如,如果一个数组的访问越过了它的边界并访问了红色区域,ASan就会报告一个缓冲区溢出错误。

  2. 影子内存:ASan使用影子内存(shadow memory)来跟踪程序中的每个内存字节的状态。影子内存是程序内存的一个映射,用于存储有关内存状态的元数据,如内存是否已分配、是否已初始化等。当程序访问内存时,ASan会检查对应的影子内存,以确定访问是否合法。

  3. 编译器插桩:ASan通过编译器插桩(instrumentation)在程序中插入检查代码。这些检查代码在内存访问发生时执行,以检测潜在的内存错误。例如,ASan会在堆分配和释放函数(如mallocfree)中插入代码,以检测内存泄漏和使用已释放的内存。

官方文档

developer.android.google.cn/ndk/guides/…

github.com/google/sani…

1.1.2 优缺点和使用场景

优点

  • 检测速度较快,运行时性能开销较小。
  • 能检测出各种内存错误,包括内存泄漏、越界读写等。
  • 提供详细的错误信息,包括泄漏的大小、位置和堆栈信息。

缺点

  • 需要重新编译程序,可能导致编译时间增加。
  • 可能会导致程序占用更多的内存。

使用场景:适合在开发和测试阶段使用,不适合在线上环境使用。

1.2 LeakSanitizer (LSan)

1.2.1 原理介绍

LeakSanitizer(简称LSan)是专门用于检测内存泄漏的工具,它可以检测出程序中未释放的内存。与ASan类似,我们可以通过在编译选项中添加-fsanitize=leak来启用LSan。LSan会在程序退出时检查所有未释放的内存,如果检测到内存泄漏,会打印出详细的错误信息。

1.2.2 优缺点和使用场景

优点

  • 专门用于检测内存泄漏,准确性较高。
  • 运行时性能开销较小。

缺点

  • 需要重新编译程序。
  • 只能检测内存泄漏,不能检测其他内存错误。

使用场景:适合在开发和测试阶段使用,不适合在线上环境使用。

1.3 Valgrind

1.3.1 原理介绍

Valgrind是一款强大的内存调试工具,它可以检测出各种内存相关的错误,如内存泄漏、使用未初始化的内存、内存访问越界等。但是,Valgrind的运行速度较慢,因此通常只在开发和调试阶段使用。

Valgrind使用一种称为动态二进制仪器(Dynamic Binary Instrumentation,DBI)的技术来检测内存错误。具体来说,Valgrind会在运行时将程序的机器代码翻译成一个中间表示(Intermediate Representation,IR),然后在IR上插入检查代码,最后将IR翻译回机器代码并执行。

1.3.2 优缺点和使用场景

优点

  • 能检测出各种内存错误,包括内存泄漏、越界读写等。
  • 不需要重新编译程序。

缺点

  • 运行速度较慢,性能开销较大。
  • 对于Android平台的支持不如ASan和LSan完善。

使用场景:适合在开发和调试阶段使用,不适合在线上环境使用。

1.4 手动检测

1.4.1 原理介绍

除了使用工具外,我们还可以通过手动检测来发现内存泄漏。例如,我们可以在每次分配和释放内存时,记录下相关信息,然后定期检查这些信息,找出没有被释放的内存。

在Android中,要手动检测Native层的内存泄漏,可以重写malloccallocreallocfree等内存分配和释放函数,以便在每次分配和释放内存时记录相关信息。例如,我们可以创建一个全局的内存分配表,用于存储所有分配的内存块及其元数据(如分配大小、分配位置等)。然后,在释放内存时,从内存分配表中删除相应的条目。定期检查内存分配表,找出没有被释放的内存。

1.4.2 示例

下面代码的主要技术原理是重写内存管理函数并使用弱符号引用原始的内存管理函数,以便在每次分配和释放内存时记录相关信息,并能够在程序运行时动态地查找和调用这些函数。

以下是代码示例:

#include <cstdlib>
#include <cstdio>
#include <map>
#include <mutex>
#include <dlfcn.h>
#include <execinfo.h>
#include <vector>
#include <android/log.h>  

#define TAG "CheckMemoryLeaks" 
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)

// 全局内存分配表,存储分配的内存块及其元数据(如分配大小、调用栈等)
std::map<void*, std::pair<size_t, std::vector<void*>>> g_memoryAllocations;
std::mutex g_memoryAllocationsMutex;

// 定义弱符号引用原始的内存管理函数
extern "C" void* __libc_malloc(size_t size) __attribute__((weak));
extern "C" void  __libc_free(void* ptr) __attribute__((weak));
extern "C" void* __libc_realloc(void *ptr, size_t size) __attribute__((weak));
extern "C" void* __libc_calloc(size_t nmemb, size_t size) __attribute__((weak));

void* (*lt_malloc)(size_t size);
void  (*lt_free)(void* ptr);
void* (*lt_realloc)(void *ptr, size_t size);
void* (*lt_calloc)(size_t nmemb, size_t size);

#define LT_MALLOC  (*lt_malloc)
#define LT_FREE    (*lt_free)
#define LT_REALLOC (*lt_realloc)
#define LT_CALLOC  (*lt_calloc)

// 在分配内存时记录调用栈
std::vector<void*> record_call_stack() {
  //  ...
}

// 初始化原始内存管理函数,如果弱符号未定义,则使用 dlsym 获取函数地址
void init_original_functions() {
  if (!lt_malloc) {
    if (__libc_malloc) {
      lt_malloc = __libc_malloc;
    } else {
      lt_malloc = (void*(*)(size_t))dlsym(RTLD_NEXT, "malloc");
    }
  }
  //calloc realloc free 的实现也类似
  ...
}

// 重写 malloc 函数
extern "C" void* malloc(size_t size) {
  // 初始化原始内存管理函数
  init_original_functions();

  // 调用原始的 malloc 函数
  void* ptr = LT_MALLOC(size);

  // 记录调用栈
  std::vector<void*> call_stack = record_call_stack();

  // 在全局内存分配表中添加新分配的内存块及其元数据
  std::unique_lock<std::mutex> lock(g_memoryAllocationsMutex);
  g_memoryAllocations[ptr] = std::make_pair(size, call_stack);

  return ptr;
}

// 重写 calloc 函数
extern "C" void* calloc(size_t nmemb, size_t size) {
  // 跟 malloc 实现类似
  // ...
}

// 重写 realloc 函数
extern "C" void* realloc(void* ptr, size_t size) {
  // 初始化原始内存管理函数
  init_original_functions();

  // 调用原始的 realloc 函数
  void* newPtr = LT_REALLOC(ptr, size);

  // 记录调用栈
  std::vector<void*> call_stack = record_call_stack();
  
  // 更新全局内存分配表中的内存块及其元数据
  std::unique_lock<std::mutex> lock(g_memoryAllocationsMutex);
  g_memoryAllocations.erase(ptr);
  g_memoryAllocations[newPtr] = std::make_pair(size, call_stack);

  return newPtr;
}

// 重写 free 函数
extern "C" void free(void* ptr) {
  // 初始化原始内存管理函数
  init_original_functions();

  // 从全局内存分配表中删除释放的内存块
  std::unique_lock<std::mutex> lock(g_memoryAllocationsMutex);
  g_memoryAllocations.erase(ptr);

  // 调用原始的 free 函数
  LT_FREE(ptr);
}

// 定义一个函数用于检查内存泄漏
void check_memory_leaks() {
  // 使用互斥锁保护对全局内存分配表的访问,防止在多线程环境下发生数据竞争
  std::unique_lock<std::mutex> lock(g_memoryAllocationsMutex);

  // 如果全局内存分配表为空,说明没有检测到内存泄漏
  if (g_memoryAllocations.empty()) {
    LOGD("No memory leaks detected.");
  } else {
    // 如果全局内存分配表不为空,说明检测到了内存泄漏
    LOGD("Memory leaks detected:");
    // 遍历全局内存分配表,打印出所有未被释放的内存块的地址和大小
    for (const auto& entry : g_memoryAllocations) {
      LOGD("  Address: %p, Size: %zu bytes\n", entry.first, entry.second.first);
      LOGD("  Call stack:");
      for (void* frame : entry.second.second) {
        LOGD("    %p\n", frame);
      }
    }
  }
}

int main() {
  // 初始化原始内存管理函数
  init_original_functions();
  
  // 示例代码
  void* ptr1 = malloc(10);
  void* ptr2 = calloc(10, sizeof(int));
  void* ptr3 = malloc(20);
  ptr3 = realloc(ptr3, 30);
  free(ptr1);
  free(ptr2);
  free(ptr3);

  // 检查内存泄漏
  check_memory_leaks();

  return 0;
}

上面代码的核心逻辑包括:

  1. 重写内存管理函数:重写malloccallocreallocfree,在分配内存时将内存块及其信息添加到全局内存分配表,释放内存时从表中删除相应内存块。

  2. 弱符号引用原始内存管理函数:使用__attribute__((weak))定义四个弱符号引用glibc/eglibc中的内存管理函数。在init_original_functions函数中检查弱符号定义,若未定义则使用dlsym函数查找原始内存管理函数。

  3. 全局内存分配表:定义全局内存分配表存储所有分配的内存块及其信息。表是一个map,键是内存块地址,值是一个pair,包含内存块大小和调用栈。

  4. 调用栈记录:分配内存时记录当前调用栈,有助于检测内存泄漏时找出泄漏来源。

  5. 内存泄漏检测:定义check_memory_leaks函数检查全局内存分配表中仍存在的内存块,表示存在内存泄漏。

1.4.2.1 使用弱符号:防止对dlsym函数的调用导致无限递归

dlsym函数用于查找动态链接库中的符号。但是在glibc和eglibc中,dlsym函数内部可能会调用calloc函数。如果我们正在重定义calloc函数,并且在calloc函数中调用dlsym函数来获取原始的calloc函数,那么就会产生无限递归。

__libc_calloc等函数被声明为弱符号,这是为了避免与glibc或eglibc中对这些函数的强符号定义产生冲突。然后在init_original_functions函数中,我们检查了__libc_calloc等函数是否为nullptr。如果是,那么说明glibc或eglibc没有定义这些函数,那就使用dlsym函数获取这些函数的地址。如果不是,那么说明glibc或eglibc已经定义了这些函数,那就直接使用那些定义。

1.4.2.2 关于RTLD_NEXT的解释

RTLD_NEXT是一个特殊的“伪句柄”,用于在动态链接库函数中查找下一个符号。它常常与dlsym函数一起使用,用于查找和调用原始的(被覆盖或者被截获的)函数。

在Linux系统中,如果一个程序链接了多个动态链接库,而这些库中有多个定义了同名的函数,那么在默认情况下,程序会使用第一个找到的函数。但有时候,我们可能需要在一个库中覆盖另一个库中的函数,同时又需要调用原始的函数。这时候就可以使用RTLD_NEXT

dlsym(RTLD_NEXT, "malloc")会查找下一个名为"malloc"的符号,即原始的malloc函数。然后我们就可以在自定义的malloc函数中调用原始的malloc函数了。

1.4.2.3 使用LD_PRELOAD的方式检测内存泄露

我们除了将上述的示例代码跟自己的源码编译到一起后,替换掉内存管理函数的符号,还可以使用LD_PRELOAD的方式,在不修改源代码的情况下重载内存管理函数。虽然这种方式在Android平台上有很多限制,但是我们也可以稍微了解下相关的原理。

LD_PRELOAD 是一个环境变量,用于在程序运行时预加载动态链接库。通过设置 LD_PRELOAD,我们可以在程序运行时强制加载指定的库,从而在不修改源代码的情况下改变程序的行为。这种方法通常用于调试、性能分析和内存泄漏检测等场景。

使用 LD_PRELOAD 检测内存泄漏的原理和方法如下:

  1. 原理:当设置了 LD_PRELOAD 环境变量时,程序会在加载其他库之前加载指定的库。这使得我们可以在自定义库中重载(override)一些原始库(如 glibc)中的函数。在内存泄漏检测的场景中,我们可以重载内存分配和释放函数(如 malloccallocreallocfree),以便在分配和释放内存时记录相关信息。

  2. 方法

    a. 创建自定义库:首先,我们需要创建一个自定义内存泄露检测库,并在其中重载内存分配和释放函数。在这些重载的函数中,我们可以调用原始的内存管理函数,并在分配内存时将内存块及其相关信息(如分配大小、调用栈等)添加到全局内存分配表中,在释放内存时从全局内存分配表中删除相应的内存块。

    b. 设置 LD_PRELOAD 环境变量:在运行程序之前,我们需要设置 LD_PRELOAD 环境变量,使其指向自定义库的路径。这样,程序在运行时会优先加载自定义库,从而使用重载的内存管理函数。

    c. 运行程序:运行程序时,它将使用重载的内存管理函数,从而记录内存分配和释放的信息。我们可以在程序运行过程中或运行结束后,检查全局内存分配表中仍然存在的内存块,从而检测内存泄漏。

通过使用 LD_PRELOAD 检测内存泄漏,我们可以在不修改程序源代码的情况下,动态地改变程序的行为,记录内存分配和释放的信息,从而检测到内存泄漏并找出内存泄漏的来源。

1.4.2.4 注意事项

手动检测内存泄漏可能会增加程序的运行时开销,并可能导致一些与线程安全相关的问题。在使用这种方法时,你需要确保你的代码是线程安全的,并在不影响程序性能的情况下进行内存泄漏检测。同时,手动检测内存泄漏可能无法发现所有的内存泄漏,因此建议大家还要使用其他工具(如AddressSanitizer、LeakSanitizer或Valgrind)来辅助检测内存泄漏。

1.4.3 优缺点和使用场景

优点

  • 对程序的性能影响较小。
  • 可以根据具体需求定制检测策略。

缺点

  • 准确性和效率可能不如专门的检测工具。
  • 需要手动写和维护检测代码。

使用场景:适合在开发、测试和线上环境中使用,但需要结合其他检测工具来提高检测效果。

二、实践建议

在实际项目中,我们可以结合多种内存泄漏检测方案来提高检测效果。以下是一些建议:

  1. 编码规范:在编写代码时,遵循一定的编码规范和最佳实践,例如使用智能指针、避免循环引用等,可以有效地降低内存泄漏的风险。

  2. 代码审查:在开发过程中,定期进行代码审查,检查代码中是否存在潜在的内存泄漏风险。代码审查可以帮助我们及时发现和修复问题,提高代码质量。

  3. 自动化测试:在项目中引入自动化测试,对关键功能进行内存泄漏检测。可以在持续集成(CI)环境中使用ASan、LSan等工具来检测内存泄漏,确保新提交的代码不会引入新的内存泄漏问题。

  4. 性能监控:在线上环境中,定期监控应用程序的内存使用情况。如果发现内存使用异常,可以使用手动检测方法或者将问题反馈到开发环境,使用其他工具进行进一步分析和处理。

  5. 问题定位:当发现内存泄漏问题时,根据工具提供的错误信息,快速定位问题发生的位置。结合堆栈信息、相对地址等,可以帮助我们更好地理解问题的原因,从而修复问题。

三、总结

在开发和测试阶段,我们可以使用ASan、LSan和Valgrind等工具来检测内存泄漏。而在线上环境中,由于这些工具的性能开销较大,不适合直接使用。在这种情况下,我们可以采用手动检测的方法,结合代码审查和良好的编程习惯,来尽可能地减少内存泄漏的发生。

最后,我们用一张表格来总结本文:

工具/方法原理简介优点缺点使用场景
AddressSanitizer内存布局变换、影子内存、编译器插桩检测速度快,性能开销小;检测多种内存错误;提供详细错误信息需要重新编译程序;可能导致程序占用更多内存开发和测试阶段使用,不适合在线上环境使用
LeakSanitizer在程序退出时检查所有未释放的内存专门检测内存泄漏,准确性高;性能开销小需要重新编译程序;只能检测内存泄漏开发和测试阶段使用,不适合在线上环境使用
Valgrind使用动态二进制仪器(DBI)技术检测内存错误检测多种内存错误;不需要重新编译程序运行速度慢,性能开销大;对Android支持不如ASan和LSan开发和调试阶段使用,不适合在线上环境使用
手动检测重写内存分配和释放函数,记录内存信息,定期检查未释放的内存性能影响小;可定制检测策略准确性和效率可能不如专门的检测工具;需手动维护代码开发、测试和线上环境中使用,结合其他工具提高检测效果

然而,需要注意的是,这些工具并不能保证检测出所有的内存泄漏。内存泄漏的发现和修复,需要我们对代码有深入的理解,以及良好的编程习惯。只有这样,我们才能有效地防止和解决内存泄漏问题,从而提高我们的应用程序的稳定性和性能。