Android 定位 Native 内存问题
我在前面的文章 通过 dump 虚拟机线程方法栈和堆内存来分析 Android 卡顿和 OOM 问题介绍了如何定位 Java
内存的问题,今天就是介绍如何定位 Native
内存问题,相对于 Java
的内存问题,Native
的内存问题相对就更加隐蔽,这篇文章就是要介绍定位 Native
内存问题的工具。
ASan
大家也可以直接看官方文档,ASan
的全称是 Address Sanitizer
,它是用来检测 C/C++
中内存问题的工具库。在 C/C++
中就算方法调用当时访问了错误的内存地址,也不一定会抛出异常,当你对这段内存读写操作后会影响未来的某个方法调用然后造成崩溃,这样的话就比较难定位真正造成内存问题的代码。ASan
可以在你第一次访问错误的内存时就会抛出异常,然后帮助我们定位问题。ASan
可以探测到以下的内存问题:
- 堆栈的 Buffer 溢出
- 使用已经释放的堆内存
- 栈地址超出范围
- 多次释放内存
接入 ASan
ASan
只支持 Android 8
及其以上的版本,而且 ASan
只能用于测试包,不能用于线上环境。
- 修改 build.gradle 打包文件配置
KotlinScript
android {
// ...
defaultConfig {
// ...
externalNativeBuild {
cmake {
arguments.add("-DANDROID_ARM_MODE=arm")
arguments.add("-DANDROID_STL=c++_shared")
}
}
}
// ...
packaging {
jniLibs {
// ..
useLegacyPackaging = true
}
}
// ...
}
我的构建使用的是 Kotlin Script
,如果你是用的 Groovy
自己转换一下,首先在打包中添加 -DANDROID_ARM_MODE=arm
和 -DANDROID_STL=c++_shared
参数;修改 useLegacyPackaging
参数为 true
。
- 修改
CMakeLists.txt
中的配置
CMake
// ...
target_compile_options(${CMAKE_PROJECT_NAME} PUBLIC -fsanitize=address -fno-omit-frame-pointer)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES LINK_FLAGS -fsanitize=address)
// ...
需要为每个你生成的 .so
库添加上面的配置。
- 在
Manifest
文件中添加可debug
xml
<application
// ...
android:debuggable="true"
// ...
>
// ...
</application>
- 添加
ASan
库
库在 ndk
中有现成编译好的,直接复制到项目的 jniLibs
目录下就好了。
我用的 MacBook
是在下面的目录:
text
**/Library/Android/sdk/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/11.0.5/lib/linux
我们只需要复制下面的这些 .so
库,分别对应 Android
中支持的四种 CPU
架构,你也可以根据你们项目的实际情况选择复制。
text
libclang_rt.asan-aarch64-android.so
libclang_rt.asan-arm-android.so
libclang_rt.asan-i686-android.so
libclang_rt.asan-x86_64-android.so
在 jniLibs
同级的目录下创建 resources
目录,在 resources
目录下也添加上 lib
目录,然后再添加上CPU
架构目录,然后在每个 CPU
架构目录下都添加一个 wrap.sh
脚本文件,脚本文件的内容如下:
shell
#!/system/bin/sh
HERE="$(cd "$(dirname "$0")" && pwd)"
export ASAN_OPTIONS=log_to_syslog=false,allow_user_segv_handler=1
ASAN_LIB=$(ls $HERE/libclang_rt.asan-*-android.so)
if [ -f "$HERE/libc++_shared.so" ]; then
# Workaround for https://github.com/android-ndk/ndk/issues/988.
export LD_PRELOAD="$ASAN_LIB $HERE/libc++_shared.so"
else
export LD_PRELOAD="$ASAN_LIB"
fi
"$@"
最终的目录结构就如下:
text
<project root>
└── app
└── src
└── main
├── jniLibs
│ ├── arm64-v8a
│ │ └── libclang_rt.asan-aarch64-android.so
│ ├── armeabi-v7a
│ │ └── libclang_rt.asan-arm-android.so
│ ├── x86
│ │ └── libclang_rt.asan-i686-android.so
│ └── x86_64
│ └── libclang_rt.asan-x86_64-android.so
└── resources
└── lib
├── arm64-v8a
│ └── wrap.sh
├── armeabi-v7a
│ └── wrap.sh
├── x86
│ └── wrap.sh
└── x86_64
└── wrap.sh
如果你的步骤没有问题就可以直接打包测试了,这里是我自己写的Demo(注意切换 asan
分支)。
测试 ASan
我的测试异常的代码如下:
C
extern "C" JNIEXPORT void JNICALL
Java_com_tans_stacktrace_MainActivity_testVisitOutBoundArray(
JNIEnv* env,
jobject /* this */) {
int size = sizeof(int) * 5;
int *array = static_cast<int *>(malloc(size));
memset(array, 1, size);
int a = array[5];
free(array);
}
我创建了一个大小为 5
的数组,然后我去访问它的第 6
个元素,也就是访问堆地址越界了。
当我调用上面的测试方法就可以得到以下错误:
text
heap-buffer-overflow on address 0x003a70a5d9a4 at pc 0x0072080d5fa8 bp 0x007ff0d27da0 sp 0x007ff0d27d98
READ of size 4 at 0x003a70a5d9a4 thread T0 (tans.stacktrace)
#0 0x72080d5fa4 (/data/app/~~yRbBu2IUM8fNLLDRScE-XQ==/com.tans.stacktrace-XiTk9ASPoWB26xlzi3X_7A==/lib/arm64/libmemalloctest.so+0xfa4)
#1 0x7271604ed4 (/apex/com.android.art/lib64/libart.so+0x13ced4)
#2 0x72715fb564 (/apex/com.android.art/lib64/libart.so+0x133564)
#3 0x7271670a78 (/apex/com.android.art/lib64/libart.so+0x1a8a78)
#4 0x72717e0330 (/apex/com.android.art/lib64/libart.so+0x318330)
#5 0x72717d665c (/apex/com.android.art/lib64/libart.so+0x30e65c)
#6 0x7271b46b70 (/apex/com.android.art/lib64/libart.so+0x67eb70)
#7 0x72715f5914 (/apex/com.android.art/lib64/libart.so+0x12d914)
#8 0x7271b476f0 (/apex/com.android.art/lib64/libart.so+0x67f6f0)
#9 0x72715f5994 (/apex/com.android.art/lib64/libart.so+0x12d994)
#10 0x7271b476f0 (/apex/com.android.art/lib64/libart.so+0x67f6f0)
#11 0x72715f5994 (/apex/com.android.art/lib64/libart.so+0x12d994)
#12 0x72717cdc58 (/apex/com.android.art/lib64/libart.so+0x305c58)
#13 0x7271b331fc (/apex/com.android.art/lib64/libart.so+0x66b1fc)
#14 0x7271604ff8 (/apex/com.android.art/lib64/libart.so+0x13cff8)
#15 0x720069e0 (/system/framework/arm64/boot-framework.oat+0x9209e0)
我们看到错误信息 heap-buffer-overflow
也就是堆缓存溢出,不过我们看到的栈信息没有通过符号表翻译成方法,正好我在手把手教你如何 Dump Native 线程栈和监听崩溃信号文章中介绍了如何监听崩溃信号和获取崩溃栈,代码可以直接拿过来用。
然后我自己写的崩溃信号检测的代码拿到的崩溃栈如下:
less
Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 13897 (tans.stacktrace), pid 13897 (com.tans.stacktrace)
#00 pc 000000000000ad4c /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libstacktrace.so (_Z9dumpStackP15DumpStackResulti+720)
#01 pc 000000000000d6fc /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libstacktrace.so
#02 pc 00000000000005bc [vdso]
#03 pc 000000000007caa0 /apex/com.android.runtime/lib64/bionic/libc.so (abort+176)
#04 pc 000000000004f020 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
#05 pc 000000000004dfe0 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
#06 pc 00000000000a6a28 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
#07 pc 00000000000a854c /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
#08 pc 00000000000a8df8 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so (__asan_report_load4+44)
#09 pc 0000000000000fa4 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libmemalloctest.so (Java_com_tans_stacktrace_MainActivity_testVisitOutBoundArray+168)
#10 pc 000000000021a354 /apex/com.android.art/lib64/libart.so
#11 pc 000000000020a2b0 /apex/com.android.art/lib64/libart.so
#12 pc 0000000000209334 /apex/com.android.art/lib64/libart.so
#13 pc 0000000000209334 /apex/com.android.art/lib64/libart.so
at com.tans.stacktrace.MainActivity.testVisitOutBoundArray(Native Method)
at com.tans.stacktrace.MainActivity.onCreate$lambda$10(MainActivity.kt:84)
at com.tans.stacktrace.MainActivity.$r8$lambda$L6XZ7NlHHvoljHJjxVv09sxciGM(Unknown Source:0)
at com.tans.stacktrace.MainActivity$$ExternalSyntheticLambda7.onClick(Unknown Source:2)
at android.view.View.performClick(View.java:7558)
at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1213)
at android.view.View.performClickInternal(View.java:7534)
at android.view.View.-$$Nest$mperformClickInternal(Unknown Source:0)
at android.view.View$PerformClick.run(View.java:29661)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:240)
at android.os.Looper.loop(Looper.java:351)
at android.app.ActivityThread.main(ActivityThread.java:8377)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:584)
at com.android.internal.os.WrapperInit.main(WrapperInit.java:94)
at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:391)
上面的栈很容易就能够看出异常的方法了,Java
栈都有的,最终触发异常的方法就是 #09 pc 0000000000000fa4 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libmemalloctest.so (Java_com_tans_stacktrace_MainActivity_testVisitOutBoundArray+168)
也就是我们上面写的测试方法(地址也是和 ASan
报告的一样,都是 0x0fa4
地址)。
Tips: 如果你没有添加 ASan
上面的测试方法是不会崩溃的哦。
使用 Profiler 监控 Native 内存分配
在通过 dump 虚拟机线程方法栈和堆内存来分析 Android 卡顿和 OOM 问题文章中介绍了如何通过 Profiler
如何 Dump
Java
内存和监控 Java
内存的分配。这里就再介绍一下监控 Native
内存的分配。
我这里先给出一下我测试内存使用和回收的方法:
C
static void **testMemArray = nullptr;
static int memArraySize = 0;
extern "C" JNIEXPORT void JNICALL
Java_com_tans_stacktrace_MainActivity_testAllocMem(
JNIEnv* env,
jobject /* this */,
jlong size) {
if (size <= 0) return;
testMemArray = static_cast<void **>(realloc(testMemArray, (++memArraySize) * sizeof(void *)));
auto data = malloc(size);
memset(data, 0, size);
testMemArray[memArraySize - 1] = data;
}
extern "C" JNIEXPORT void JNICALL
Java_com_tans_stacktrace_MainActivity_freeAllTestMem(
JNIEnv* env,
jobject /* this */) {
if (memArraySize <= 0) return;
for (int i = 0; i < memArraySize; i ++) {
auto data = testMemArray[i];
testMemArray[i] = nullptr;
free(data);
}
free(testMemArray);
testMemArray = nullptr;
memArraySize = 0;
}
我分配内存用的 malloc
方法,然后分配好的内存还用 memset
方法把它的内存中的值设置为 0
,这里为什么要写一下这段内存呢???哈哈哈,这也算是一个知识点吧,通过 malloc
分配的内存只是申请的虚拟内存,并没有映射到物理内存,只有当真正读写这块内存的时候才会执行物理内存的映射分配,Android
中的 Bitmap
也是一样的当创建 Bitmap
实例后也是分配的虚拟内存,只有真实的读写后才会分配物理内存,如果对虚拟内存不熟悉的同学可以看看我之前的文章:聊聊虚拟内存。
这里要注意一点,使用了 ASan
是无法使用 Profiler
的,如果你用我的代码测试记得从 asan
分支切换到 master
分支重新打包。
然后一切准备好后打开 Profiler
:
然后选中 Record native allocations
然后点击 Record
这时就开始了监控 Native
的内存分配,当你需要结束的时候点击大红按钮就好了。 结束后我们就能看到如下的截图:
Profiler
是监控 malloc
,calloc
和 realloc
这几个方法分配的内存的方法,上面的截图表示了不同的方法申请了多少内存,回收了多少内存。
我们再选中 Visualization
tab,我们还能够看到每次分配的方法栈是什么样的:
我是分配了两次,每次分配内存 10MB
。
读取 smaps
文件
我们可以通过 adb shell dumpsys [pid]
去查询内存使用的整体情况,结果大致如下:
text
Applications Memory Usage (in Kilobytes):
Uptime: 76374295 Realtime: 369954849
** MEMINFO in pid 15901 [com.phx.waha.dev] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
Native Heap 77711 77684 0 23 79112 105984 88719 17264
Dalvik Heap 13888 13788 0 77 15364 17353 8677 8676
Dalvik Other 3702 2980 0 0 5416
Stack 1908 1908 0 0 1920
Ashmem 55 12 0 0 504
Other dev 48 0 44 0 632
.so mmap 43724 2460 35820 15 79656
.jar mmap 6071 0 3344 0 37364
.apk mmap 698 0 232 0 2288
.ttf mmap 1047 0 264 0 2640
.dex mmap 12702 512 12180 0 13428
.oat mmap 52 0 4 0 1744
.art mmap 8602 7948 92 43 19540
Other mmap 112 40 64 0 908
GL mtrack 67368 67368 0 0 67368
Unknown 1277 1256 8 0 1688
TOTAL 239123 175956 52052 158 329572 123337 97396 25940
App Summary
Pss(KB) Rss(KB)
------ ------
Java Heap: 21828 34904
Native Heap: 77684 79112
Code: 54836 138068
Stack: 1908 1920
Graphics: 67368 67368
Private Other: 4384
System: 11115
Unknown: 8200
TOTAL PSS: 239123 TOTAL RSS: 329572
// ...
如果不知道里面的参数什么意思,可以参考我之前的文章如何解读 dumpsys meminfo 的信息,通过 dumpsys
的方法拿到的是一个内存的整体的使用情况,分不同的类型统计。
我们还可以通过 showmap -a [pid]
来查看每段虚拟内存地址的内存使用情况:
text
emulator_arm64:/ # showmap -a 2543
start end virtual shared shared private private
addr addr size RSS PSS clean dirty clean dirty swap swapPSS object
-------- -------- -------- -------- -------- -------- -------- -------- -------- -------- -------- ------------------------------
12c00000 2ac00000 393216 8304 8304 0 0 0 8304 0 0 [anon:dalvik-main space (region space)]
70262000 704f1000 2620 2620 1229 0 1452 0 1168 0 0 [anon:dalvik-/apex/com.android.art/javalib/boot.art]
704f1000 7054c000 364 364 229 0 140 0 224 0 0 [anon:dalvik-/apex/com.android.art/javalib/boot-core-libart.art]
7054c000 70618000 816 816 379 0 456 0 360 0 0 [anon:dalvik-/apex/com.android.art/javalib/boot-core-icu4j.art]
70618000 7064f000 220 220 74 0 152 0 68 0 0 [anon:dalvik-/apex/com.android.art/javalib/boot-okhttp.art]
// ...
showmap
又比 dumpsys meminfo
的信息更加详细了,包含每段地址,也包含内存对应的库 (大部分都是系统的,我们自己的库我不知道为什么无法显示出来)。
然后最最最详细的内存使用信息可能就是 proc/[pid]/smaps
文件中的内容了,我猜测 showmap
和 dumpsys meminfo
中的数据也是来自这个文件。
text
12c00000-2ac00000 rw-p 00000000 00:00 0 [anon:dalvik-main space (region space)]
Name: [anon:dalvik-main space (region space)]
Size: 393216 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 2136 kB
Pss: 2136 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 2136 kB
Referenced: 2136 kB
Anonymous: 2136 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
VmFlags: rd wr mr mw me ac
6f706000-6f995000 rw-p 00000000 00:00 0 [anon:dalvik-/apex/com.android.art/javalib/boot.art]
Name: [anon:dalvik-/apex/com.android.art/javalib/boot.art]
Size: 2620 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 2620 kB
Pss: 1151 kB
Shared_Clean: 0 kB
Shared_Dirty: 1512 kB
Private_Clean: 0 kB
Private_Dirty: 1108 kB
Referenced: 2200 kB
Anonymous: 2620 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
VmFlags: rd wr mr mw me ac
// ...
这个文件相对大一点,我给出一部分,开始就指出了该段虚拟内存的地址,然后对应的模块名字,然后 Size
是指虚拟内存地址大小,Pss
是实际的占用物理内存大小,我们看到 Java
虚拟机占用 393216 kB
的虚拟内存,实际占用内存为 2136 kB
,我们通常看 Pss
,Rss
和 Private_Dirty
等等参数,其他的参数数据可以参考一下。
然后我会回到我们自己的测试内存分配的代码,然后我在 smaps
中找到了我们测试代码的内存记录:
text
// ...
6fd34d7000-6fd3ed8000 rw-p 00000000 00:00 0 [anon:scudo:secondary]
Name: [anon:scudo:secondary]
Size: 10244 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 10244 kB
Pss: 10244 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 10244 kB
Referenced: 10244 kB
Anonymous: 10244 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
VmFlags: rd wr mr mw me ac
// ...
我们看到这段内存并没有指出来是我们的库分配的,是一段匿名的内存,由 scudo
内存分配器分配,我发现大部分的系统库会指出名字,我也测试了 ffmpeg
也是能够指出名字,我们能够知道的就是一段虚拟内存地址,通过这段地址讲道理应该是可以找到对应的 .so
库才对,不过我现在还没有找到方法,如果能够有方法把这个地址翻译成对应的 .so
库,我认为对我们分析线上的 OOM
有挺大的帮助。
最后
希望通过我自己的一些实践对大家去处理 Native
内存问题有一些帮助。