《全民K歌内存篇1——线上监控与综合治理》 《全民K歌内存篇2——虚拟内存浅析》 《全民K歌内存篇3——native内存分析与监控》
一、简介
在多任务操作系统中,每个进程都拥有独立的虚拟地址空间,通过虚拟地址进行内存访问主要具备以下几点优势:
- 进程可使用连续的地址空间来访问不连续的物理内存,内存管理方面得到了简化。
- 实现进程与物理内存的隔离,对各个进程的内存数据起到了保护的作用。
- 程序可使用远大于可用物理内存的地址空间,虚拟地址在读写前不占用实际的物理内存,并为内存与磁盘的交换提供了便利。
Android系统历经dlmalloc
、jemalloc
、scudo
等不同的内存分配器,其差异主要在内存管理的策略方面,而实际的分配与释放都是通过brk
/sbrk
来改变数据段大小,又或通过mmap
/munmap
进行匿名映射。通过malloc
调用,应用程序分配到的是虚拟地址,当访问这一内存块并发生缺页中断时,才会去分配物理内存。
分页也是内存管理的一种手段,把内存划分一小块一小块的连续空间,每一块我们称之为页,页面是内存分配的最小单位,其大小一般为4KB。对于虚拟内存来说,页面共有以下几个状态:
代码语言:javascript复制Not Present:页面分配后未映射到物理内存;又或是作为干净页即将被内核清除
Resident:当页面映射到物理内存后,需常驻于内存中,根据其内容是否存在文件备份,可划分为两种类型:
Clean(干净页):仅适用于文件映射,加载到内存后不曾被更改,当内存不足时可由内核进行清除
Dirty(脏页):匿名映射(不存在文件备份)或页面内容与磁盘不同。这种情况下无法由内核进行清除,因为会导致数据丢失,但可由Swapped机制进行交换处理
Swapped:脏页可被交换到磁盘上,当再次发生缺页中断时才被重新加载到内存;在Android中表示通过ZRAM进行了压缩,但仍会占用部分内存
二、地址空间大小
2.1 32位的地址空间
ARM 32位的CPU架构可使用的地址空间大小为2^32=4GB
,并需要保留一部分给内核使用。在Linux实现里,提供了三种虚拟地址空间分配的参数:VMSPLIT_3G
、VMSPLIT_2G
、 VMSPLIT_1G
,代表用户态可访问的虚拟地址空间大小,如下:
其中,默认的参数为VMSPLIT_3G
,也就是用户态可使用3GB的低地址,剩下的1GB高地址分配给内核。
2.2 64位的地址空间
ARM 64位的CPU架构虽然拥有64位的地址空间,但并不代表我们可以全部使用。一方面是因为64位可提供地址空间实在是太大了,在未来的一段时间内都用不上那么多的地址空间;另一方面,更多的地址空间同时也意味着需要通过更加复杂的页表来进行管理。于是,ARM采取了折中的策略:在指令集方面允许使用完整的64位地址空间,为后期的扩展提供了充分的支持,而当前的CPU只使用部分地址。
通过ARM架构的官方文档,可以看到所有的Armv8-A实现都支持了48位的虚拟地址空间,即2^48=256TB
,对52位的支持则只是一个可选项,大多芯片都未支持且受页面大小限制。Armv8-A共支持4KB
、16KB
和64KB
三种页面大小,在64KB
页面大小的前提下才可以使用52位地址,其它情况下则依然只有48位。
Android默认的页面大小配置为CONFIG_ARM64_4K_PAGES
,即4KB,使用48位的地址空间需要4级页表来支持。默认的虚拟地址的长度配置为CONFIG_ARM64_VA_BITS=39
,并使用3级页表CONFIG_PGTABLE_LEVELS=3
进行管理,故Android的64位应用可使用的地址空间一般为2^39=512GB
。
Linux的内核文档也给出了这样的一些数据:
/** * Armv8-A的虚拟地址空间分布 **/ Start End Size Use ------------------------------------------------------------------- // 4KB页大小 4级页表 0000000000000000 0000ffffffffffff 256TB user ffff000000000000 ffffffffffffffff 256TB kernel
// 4KB页大小 3级页表 0000000000000000 0000007fffffffff 512GB user ffffff8000000000 ffffffffffffffff 512GB kernel
2.3 64位架构上运行的32位应用
上面分别讨论了32位及64位架构下可用的虚拟地址空间,而当32位应用在64位的构架上运行时,情况会略有不同,用户态可独占低地址的32位空间,共4GB,而内核依然可以使用512GB的高地址,如下:
--------------------> -------------------------- ffff:ffff:ffff:ffff | | | | | Kernel (512G) | | |ffff:ff80:0000:0000 | |--------------------> -------------------------- ffff:ff7f:ffff:ffff |//////////////////////////| |//////////////////////////| |//////////////////////////| z //////////////////////// z |//////////////////////////| |//////////////////////////|0000:0001:0000:0000 |//////////////////////////|--------------------> -------------------------- 0000:0000:ffff:ffff | | | User (4G) |0000:0000:0000:0000 | |--------------------> --------------------------
总的来说,大多数据情况下,32位应用可独享4GB虚拟地址空间,64位应用则有512GB。
2.4 内存分配的短板
进程的内存分配受虚拟内存及物理内存大小的限制,除此之外,系统对虚拟内存的区域数量也有所限制(虚线圈中的部分),可通过/proc/sys/vm/max_map_count
进行查看,默认值为65536
进程使用的虚拟内存往往远大于物理内存,在32位应用中,受虚拟内存4GB的限制。而物理内存方面,绝大多数Android手机都达到了4GB甚至是8GB以上,虽然非进程独占,但系统可通过LowMemoryKiller
及ZRAM
进行管理,满足应用使用过程中的需要,问题不大。这就是32位应用的内存问题集中在虚拟内存不足的原因。
当应用升级到64位后,虚拟内存的问题得以缓解,物理内存将可能成为新的短板。笔者在多台机器上分别自测了大量分配虚拟内存
及大量分配物理内存
的情况:
// 大量分配虚拟内存,每次调用分配20Gvoid virtualMemoryTest(){ int sizeVm = 1 * 1024 * 1024 * 1024; // 1GB for(int i = 0; i < 20; i ){ // 1GB * 20 void* block = mmap(NULL, sizeVm, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0); memset(block, 1, 1); list[index ] = block; }}
// 大量分配物理内存,每次调用申请160Mvoid physicalMemoryTest(){ int size = 8 * 1024 * 1024; // 8M for (int i = 0; i < 20; i ) { // 8M * 20 void *block = malloc(size); memset(block, 1, size); list[index ] = block; }}
得到两个自测的结论:
1:虚拟内存确实可以使用到接近512GB
;
2:大量申请物理内存导致内存紧张时,后台应用及服务会逐一被LowMemoryKiller
杀死,继续申请时,前台应用亦不能幸免,表现为闪退,但无法被Bugly捕获。
以下是一个4GB
物理内存的机器,前台应用申请到3864316KB(约3.7GB)
时,被系统杀掉了。
<6>[122969.200223] lowmemorykiller: Killing 'raoke.memoryApp' (21760) (tgid 21760), adj 0,<6>[122969.200223] to free 3864316kB on behalf of 'UsbFfs-worker' (25160) because<6>[122969.200223] cache 72088kB is below limit 73728kB for oom score 0<6>[122969.200223] Free memory is -35644kB above reserved.
至于区块数量,以全民K歌为例,启动并覆盖录歌、直播、歌房等主场景后,不超过8000条,估计还没那么容易超出默认的限制。
三、地址空间分布
Android的应用程序都是从zygote
进程fork
而来,由Copy On Write
的特性可知,应用启动时拥有与zygote
进程一样的虚拟地址空间。此时的地址空间主要包括zygote
进程启动时为ART虚拟机分配的各种space,其布局概况如下:
ffffffff --------------------------- | | --------------------------- | stack | --------------------------- | | | | z z | | | | --------------------------- | no moving space (62 M) | --------------------------- | zygote space (1 M) | --------------------------- | image space | --------------------------- | |52C00000 --------------------------- | | | main space 2 (512M) | | |32C00000 --------------------------- | | | main space 1 (512M) | | |12C00000 --------------------------- | | | |00000000 ---------------------------
- main space:
从
0x12c00000
开始分配共1GB的空间,用于虚拟机内部对象的管理,我们在Java层分配的内存就是使用了这部分的空间。 - image space: 主要是系统启动相关的一些镜像文件。
- zygote space/non moving space:
zygote
进程启动后加载的一些类及其它其它资源,实现在在不同进程间共享,不参与GC - 其它: 还有其它一些类型的内存受ASLR策略的影响,分布较为分散
笔者通过AS创建了一个简单的demo,应用启动后,从虚拟内存大小的角度看,排名前20的内容为(单位KB):
VmSize PSS Dirty Swap Mapping ------ ------ ----- ----- ------1048576 380 380 0 [anon:dalvik-main space (region space)] 汇总 133120 0 0 0 [anon:libwebview reservation] 汇总 110144 216 216 16 [anon] 汇总 98304 16 8 0 jit-cache (deleted) 汇总 63760 92 92 0 [anon:dalvik-non moving space] 汇总 47552 0 0 0 icudt63l.dat 汇总 32768 0 0 4 [anon:dalvik-zygote-data-code-cache] 汇总 32768 0 0 8 [anon:dalvik-zygote-jit-code-cache] 汇总 29324 1232 0 0 framework.jar 汇总 24196 0 0 0 NotoSerifCJK-Regular.ttc 汇总 19856 0 0 0 NotoSansCJK-Regular.ttc 汇总 16384 0 0 0 [anon:dalvik-concurrent copying gc mark stack] 汇总 16384 8 8 0 [anon:dalvik-region space live bitmap] 汇总 16384 0 0 0 [anon:dalvik-region-space inter region ref bitmap] 汇总 16104 70 0 0 oppo-framework-res.apk 汇总 11628 20 0 4 boot-framework.oat 汇总 11264 4776 4708 1372 [anon:libc_malloc] 汇总 10496 3601 12 28 libllvm-glnext.so 汇总 10052 0 0 0 SysSans-Hans-Regular.ttf 汇总 10048 89 0 0 framework-res.apk 汇总
由此可见,应用所使用的虚拟内存大小要远大于实际使用的物理内存,主要的消耗对象为:Java堆、虚拟机相关缓存、webview及一些系统资源。在32位的应用上,内存使用不合理时,会比较容易引发因虚拟内存不足而导致的白屏或OOM等问题。
四、虚拟内存分析
4.1 smaps介绍
对于当前虚拟内存的使用状态,可通过读取/process/pid/status
中的VmSize
字段来得到。如果希望进一步分析,则可读取/process/pid/smaps
,这一文件记录了进程中所有的虚拟内存分配情况。通过一些命令行工具还可整理提取出其中的关键信息,比如pmap
可获取到的数据是这样的(篇幅有限,只选取了部分内容进行展示):
Address Kbytes PSS Dirty Swap Mode Mapping0000000012c00000 256 4 4 0 rw--- [anon:dalvik-main space (region space)]000000001fb40000 836352 0 0 0 rw--- [anon:dalvik-main space (region space)]0000000070b9c000 1964 771 744 56 rw--- boot.art0000000071b2f000 8 0 0 0 r--s- boot.vdex0000000071b31000 4 0 0 0 r---- boot.oat0000000072ae4000 1776 616 588 792 rw--- [anon:dalvik-zygote space]0000000072ca0000 12 12 12 0 rw--- [anon:dalvik-non moving space]00000000bde80000 512 136 136 0 rw--- [anon:libc_malloc]00000000d00a8000 260 43 0 0 r--s- base.apk00000000e30dd000 888 187 0 0 r---- framework.jar00000000e5df4000 4 4 4 0 rw-s- kgsl-3d000000000e6f62000 168 2 0 0 r---- libmedia.so00000000e7130000 32 0 0 0 r--s- NotoSerifLao-Bold.ttf00000000f571b000 4 4 4 0 rw--- linker00000000f5729000 32768 4 0 0 r--s- jit-cache (deleted)00000000ff37d000 8188 44 44 0 rw--- [stack]00000000ffff0000 4 0 0 0 r-x-- [vectors]---------------- ------ ------ ------ ------total 1988112 22318 11600 7032
主要包括Kbytes(虚拟内存)
、PSS(物理内存含公摊)
、Dirty(脏页)
及Mapping(映射文件或匿名内存)
等字段。mapping
指示了这一块内存的去向,通过文件映射的内存都能在这里找到详细的记录,比如加载的art
、apk
、so
及ttf
等。举个例子,当我们通过adb shell dumpsys meminfo pid
发现某个场景so类型的内存增量不合理时,只要对比场景前后的/proc/pid/smaps
文件就可以轻易地知道是加载了那些so库,它们分别占用了多大的内存空间。
而另一部分以anon
开头的则代表匿名映射,不具备文件备份,包括由java虚拟机或内存分配器来管理的空间,mapping
中只能看到[anon:dalvik-xxx]
或[anon:libc_malloc]
这样的信息,无法具体分析,但可结合Java内存快照或native hook的工具进行进一步的分析。
4.2 内存增量分析
全民K歌的直播间业务中,观众首次进退房后会存在一定的内存增量,我们需要分析这一增量是否合理。在此,我们以PSS为例(分析VSS增量也可使用同样的办法),通过以下几个步骤进行分析:
1. 通过pmap
命令获取进房前及退房后的虚拟内存映射并保存下来
// 进房前的内存映射,命名为 map1.txtAddress Kbytes PSS Dirty Swap Mode Mapping0000000012c00000 5888 2248 2248 0 rw--- [anon:dalvik-main space (region space)]00000000131c0000 7936 0 0 0 ----- [anon:dalvik-main space (region space)]0000000013980000 9728 8340 8340 0 rw--- [anon:dalvik-main space (region space)]0000000014300000 29952 0 0 0 ----- [anon:dalvik-main space (region space)]......00000000ff014000 4 0 0 0 ----- [anon]00000000ff015000 8188 84 84 0 rw--- [stack]00000000ffff0000 4 0 0 0 r-x-- [vectors]---------------- ------ ------ ------ ------total 2444128 259868 182884 11240
// 退房后的内存映射,命名为 map2.txtAddress Kbytes PSS Dirty Swap Mode Mapping0000000012c00000 4864 1988 1988 0 rw--- [anon:dalvik-main space (region space)]00000000130c0000 10496 0 0 0 ----- [anon:dalvik-main space (region space)]0000000013b00000 7936 7400 7400 0 rw--- [anon:dalvik-main space (region space)]00000000142c0000 256 0 0 0 ----- [anon:dalvik-main space (region space)]0000000014300000 3840 3772 3772 0 rw--- [anon:dalvik-main space (region space)]......00000000effc4000 8 8 8 0 rw--- [anon]00000000ff014000 4 0 0 0 ----- [anon]00000000ff015000 8188 84 84 0 rw--- [stack]00000000ffff0000 4 0 0 0 r-x-- [vectors]---------------- ------ ------ ------ ------total 2495076 289399 197128 10928
通过最后一行total
的对比,可知PSS的增量为289399-259868 ≈ 30M
2. 两个文件存在大量的重复内容,先通过脚本删除重复项,再将同一文件内相同mapping
的内容合并为一条,分别得到文件merge1.txt
和merge2.txt
,如步骤1的map1.txt
展示了4条[anon:dalvik-main space (region space)]
,合并后是这样的:
// 通过mapping合并后的数据示意Kbytes PSS Dirty Swap Mode Mapping 53504 10588 10588 0 rw--- [anon:dalvik-main space (region space)]
3. 对merge1.txt
和merge2.txt
中相同的mapping
内容进行增量计算,并分类汇总。
private fun analysisLineType(lines: ArrayList<Line>){ lines.forEach { it.type = when { it.mapping.startsWith("[anon:dalvik-alloc space") || it.mapping.startsWith("[anon:dalvik-main space") || it.mapping.startsWith("[anon:dalvik-large object space") || it.mapping.startsWith("[anon:dalvik-non moving space") || it.mapping.startsWith("[anon:dalvik-zygote space") || it.mapping.startsWith("[anon:dalvik-free list large object space") -> { "[1]dalvik" } it.mapping.endsWith(".so") -> { "[8].so" } it.mapping.endsWith(".art") || it.mapping.endsWith(".art]") -> { "[e].art" } it.mapping == "[anon]" -> { "[f]other-mmap" } ...... // 省略其它分支 } }}
4. 经过以上各个步骤的处理后,得到的结果是这样的:
各个数据代表着内存的变化,大于0代表内存增加,小于0则是释放了部分的内存。这一结果可分析各个类型的内存增量并罗列变化的具体内容。
以截图为例,加载libYTCommon.so
使用了304KB的虚拟内存,且已全部映射到物理内存,其中的288KB是干净页,没有修改过的。在这个案例中,这一加载是不合理的,因为它是优图用于图像处理的SO库,观看直播其实是不需要用到的。
带有dalvik-
关键字的则与JAVA虚拟机有关,如dalvik-main space(region space)
的虚拟内存增量为0,这是因为应用启动时就预分配了大量的空间为其所用,当我们在JAVA层分配对象时,会反映在PSS上,需要结合Java dump进一步分析。
五、总结
虚拟内存为应用开发及系统的进程管理提供了极大的便利。但在32位应用中,因受地址空间的限制,容易成为内存分配的短板,从而引发Crash或白屏。本文主要对虚拟内存的大小、布局及内容进行了简要的分析,望抛砖引玉。
QQ音乐/全民K歌招聘Android/ios客户端开发,投递简历发送至邮箱:tmezp@tencent.com