全民K歌内存篇2——虚拟内存浅析

2021-03-04 18:40:06 浏览数 (1)

《全民K歌内存篇1——线上监控与综合治理》 《全民K歌内存篇2——虚拟内存浅析》 《全民K歌内存篇3——native内存分析与监控》

一、简介

在多任务操作系统中,每个进程都拥有独立的虚拟地址空间,通过虚拟地址进行内存访问主要具备以下几点优势:

  1. 进程可使用连续的地址空间来访问不连续的物理内存,内存管理方面得到了简化。
  2. 实现进程与物理内存的隔离,对各个进程的内存数据起到了保护的作用。
  3. 程序可使用远大于可用物理内存的地址空间,虚拟地址在读写前不占用实际的物理内存,并为内存与磁盘的交换提供了便利。

Android系统历经dlmallocjemallocscudo等不同的内存分配器,其差异主要在内存管理的策略方面,而实际的分配与释放都是通过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_3GVMSPLIT_2GVMSPLIT_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共支持4KB16KB64KB三种页面大小,在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的内核文档也给出了这样的一些数据:

代码语言:javascript复制
/**  * 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的高地址,如下:

代码语言:javascript复制
-------------------->  -------------------------- 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以上,虽然非进程独占,但系统可通过LowMemoryKillerZRAM进行管理,满足应用使用过程中的需要,问题不大。这就是32位应用的内存问题集中在虚拟内存不足的原因。

当应用升级到64位后,虚拟内存的问题得以缓解,物理内存将可能成为新的短板。笔者在多台机器上分别自测了大量分配虚拟内存大量分配物理内存的情况:

代码语言:javascript复制
//  大量分配虚拟内存,每次调用分配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)时,被系统杀掉了。

代码语言:javascript复制
<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,其布局概况如下:

代码语言:javascript复制
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 spacezygote进程启动后加载的一些类及其它其它资源,实现在在不同进程间共享,不参与GC
  • 其它: 还有其它一些类型的内存受ASLR策略的影响,分布较为分散

笔者通过AS创建了一个简单的demo,应用启动后,从虚拟内存大小的角度看,排名前20的内容为(单位KB):

代码语言:javascript复制
 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可获取到的数据是这样的(篇幅有限,只选取了部分内容进行展示):

代码语言:javascript复制
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指示了这一块内存的去向,通过文件映射的内存都能在这里找到详细的记录,比如加载的artapksottf等。举个例子,当我们通过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命令获取进房前及退房后的虚拟内存映射并保存下来

代码语言:javascript复制
//    进房前的内存映射,命名为 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.txtmerge2.txt,如步骤1的map1.txt展示了4条[anon:dalvik-main space (region space)],合并后是这样的:

代码语言:javascript复制
// 通过mapping合并后的数据示意Kbytes     PSS   Dirty    Swap  Mode  Mapping 53504   10588   10588       0 rw---  [anon:dalvik-main space (region space)]

3. 对merge1.txtmerge2.txt中相同的mapping内容进行增量计算,并分类汇总。

代码语言:javascript复制
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

0 人点赞