前言

最近被某个黑乎乎的身影要求写一篇关于内存的文档给业务方(偏Android方向的),于是飞书文档打好草稿之后,顺便脱下敏也发到自己博客上面了。

老实说,讲概念谁都会,但让我这种平时就没怎么写Android的人在讲概念的同时还要附上实际案例就有点费劲了= =


手机的内存是什么?

有cs基础的应该都知道,说到内存,一般就是指下面这些:

  • 寄存器(Register)
  • 静态随机存储器(SRAM: Static Random Access Memory),通常买CPU时提到的一级缓存(L1 Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache)等等就是指这个;另外,单片机也是使用这种多👇🏻
  • image.pngimage.png
  • 动态随机存储器(DRAM:Dynamic Random Access Memory)
  • 同步动态随机存取内存(SDRAM: Synchronous Dynamic Random Access Memory)
  • 双倍数据率同步动态随机存取内存(DDR SDRAM: Double Data Rate Synchronous Dynamic Random Access Memory),通常买电脑内存的DDR3、DDR4就是指这个,SDRAM只是名字缩写缘故没写出来而已👇🏻
  • image.png
  • 低功耗双倍数据率同步动态随机存取内存(LPDDR: Low Power Double Data Rate Synchronous Dynamic Random Access Memory),通常AndRoid等移动端设备就使用这个,虽然商品页不会提到,但实际上我们通过软件检测还是能看到👇🏻:
  • image.png
  • 还有ROM(作用早已不像名字那般简洁)、GDDR、存储原理(电容器)等就不一一介绍了,感兴趣的善用搜索引擎了解一下即可,实际上我们评测的大部分就是LPDDR的占用情况,所以后面的文章再没有特地声明的情况下,说的内存就=LPDDR。

为什么内存不能爆满?

对于操作系统而言,上述的SRAM、LPDDR的分布大致如下:
image.png

image.png

实际上还少了个zRAM,有兴趣的可以额外了解,这里就不展开了

  • SRAM大部分时候都是为了“局部性原理”而服务的,它满了其实也就是相当于LRU的Cache满了,不会发生什么特别的事。
  • 但是LPDDR就不一样了,程序要运行就必须要加载到LPDDR中,如果程序所需要的内存 > LPDDR空闲内存,程序就无法运行,于是就出现各种OOM了,这是我们常说的物理内存OOM。
  • 对于虚存溢出、davlik.vm.heapsize溢出等等情况,在这里就不多展开了,下面会有非常详细的demo+讲解。

APP运行时是什么地方占用了内存?

说到哪些地方占用了内存,就不得不提到进程空间了,32和64位(linux)简略图如下所示👇🏻:

image.png

图片来自网络,以Linux为例,64位核心参考kernel mm

Android的ARM相比上面的x86、x86_64还是有些区别的👇🏻:
image.png

核心参考:ARM32 32位appARM64 32位appARM64 64位app

64位除了空间大了不少、位置挪了挪、多了套寄存器,其它跟32位其实大同小异,为了便于理解,我们把32位展开看看,如下图所示(以Linux x86为例)👇🏻:

image.png

这图是自己画的,老实说不一定对。
另外需要注意的是上面说的地址空间均是指虚拟内存,毕竟贵为操作系统总不能像单片机那样直来直往。

对于.text、.data、.bss、内核空间我们其实不需要太关注,因为这部分数据往往是比较固定的,我们需要重点关注的基本就是堆、栈。

另外,内核空间是所有进程共享的,示意图如下所示👇🏻:
image.png


那么哪些东西会占用堆栈呢?

  • 对于Java(Android)而言,几乎所有new出来的对象 or 数组都是占用堆的;占用栈的通常是基本类型+函数栈帧。
  • 细心的人可能会留意到我上面说的"几乎",这是因为确实有这样的例外,这时候就不得不提起JDK8之后默认开启的 逃逸分析标量替换 了,下面先来看一段代码:
class Person {
    boolean sex;
    int age;
    String name;

    Person(boolean sex, int age, String name) {
        this.sex = sex;
        this.age = age;
        this.name = name;
    }
}

public class Test1 {

    public Person returnPerson() {
        Person person = new Person(true, 23, "chenwenjie.star");
        return person;
    }

    public void test1() {
        Person person = new Person(true, 23, "chenwenjie.star");
        // 标量替换后
        // boolean sex = true;
        // int age = 23;
        // String name = "chenwenjie.star";
    }
}
  • 对于函数returnPerson来说,对象返回后还可能有别的用处,逃逸分析判断返回person的生命周期跟函数不一致,于是不会进行标量替换。
  • 但函数test1就不一样了,person对象是跟着函数同生共死、从头用到尾的,中途也没有出现引用替换等情况,于是逃逸分析后就会觉得这是可以进行标量替换的(如上面注释部分代码所示),那么在这种情况下,内存就是从栈上分配的了(栈空间充足的情况下)。
  • 说白了,我们排查内存泄露、内存溢出等问题,其实大部分都是通过监控堆栈的内存信息排查的,因为RD写的代码主要影响这两个区域。

物理内存和虚拟内存

为什么需要虚拟内存?

一言蔽之:虚拟内存能屏蔽很多硬件的细节,让系统实现、操作起来更简单。

详细原因:

  • 对于使用操作系统的用户而言,使用虚拟内存可以屏蔽底层硬件的差异,比如通过虚拟内存可以操作磁盘用作Swap,而不一定要用"内存条"。
  • 抽象出虚拟内存可以控制进程对物理内存的访问(比如maps文件中就有权限显示)
  • 每个进程都持有自己的虚拟空间,链接的时候无需管不同系统、不同状态下物理内存的差异。

关于最后一点,再举个详细的例子👇🏻:
 
比如,当汇编器遇到最终位置不确定的符号引用时,它会产生一个重定位条目,用来告诉链接器在合成可执行文件时如何修改这个引用;而当链接器处理时,链接器会根据重定位条目来修改成新的引用地址,这个引用地址现在以X86_64举例,则通常是:①R_X86_64_PC32(PC相对地址)、②R_X86_64_32(绝对地址)。
 
假如现在链接器改的是物理地址,那么程序A链接的时候引用的是物理地址xxx,程序B链接的时候引用的也是物理地址xxx,那么这两程序在同一台电脑上就别想同时启动了。
而有了虚拟内存,程序A链接的时候引用的是虚拟地址xxx,程序B链接的时候引用的是虚拟地址xxx,但由于每个进程的虚拟内存都是私有的,最终通过MMU会映射到不同的物理内存上,那么这两个程序物理地址就不会冲突了。
 
正因为有了虚拟内存,每次链接计算出的PC相对地址 or 绝对地址才能通用。

对于我们上面提到的LPDDR,它显然是物理内存;而进程空间的图示,则是虚拟内存。如果你接触过单片机(单片机没有操作系统),你肯定知道它是直接操作物理内存的。

作为经过层层包装后的操作系统,它本身其实是感应不到物理内存的地址的,与物理内存通常是经过MMU(Memory Management Unit: 内存管理单元)打交道的,但MMU就算取到物理内存返回给操作系统,操作系统也是一脸懵逼,于是就需要虚拟内存来交互了。

如果你还不懂,再看一个拟人的例子:假设现在有一个拉丁人看守的仓库,仓库有1,2,3号位,还有一个精通中文、拉丁文的翻译官(兼职搬运工)。现在你想拿仓库的一个位置存取物品,你要:告诉翻译官你想要1个仓库位放东西 -> 翻译官将中文翻译成拉丁文 -> 翻译官转述给拉丁人获取仓库,并把物品放进去 -> 翻译官告诉你他放几号位了。最终你=操作系统,翻译官=MMU,物理内存=拉丁文仓库,虚拟地址(内存)=你说的中文。

简单示意图如下👇🏻:

image.png

至于Android对虚拟内存的管理方式,如分页管理、内存映射、32位机器2级分页、64位4级分页、TLB支持等内容在这里就不一一展开了,感兴趣可以根据官网1官网2顺藤摸瓜。


什么时候会占用物理/虚拟内存?

其实就一句话:只有真正需要的时候才占用物理内存。

这句话听上去有些玄乎,但实际上就是这样的:如果物理内存不够,系统会把其它进程中的「最近没被使⽤」的内存⻚⾯给释放掉(暂时写在硬盘上),这个过程叫换出(Swap Out)。⼀旦需要的时候,再从磁盘加载进物理内存,这个过程叫换⼊(Swap In)。

我知道这玩意说起来其实非常抽象,所以准备了一个demo代码👇🏻:

实验环境linux、win都可,我用的win(mac我是没找到虚存在哪看,放弃)。

//​
// Created by chenwenjie on 2022/2/22.​
//​

#include <malloc.h>​
#include <memory.h>​

int main() {​
    // 6GB,注意不同系统的宏定义,试过有些系统size_t是unsigned int大小,如果遇到这种情况需要自己拆解​
    unsigned long long nGB = 1024*1024*1024*6ull;​
    int *chunk = malloc(nGB); // 第一个断点​
    // 填充一半内存​
    memset(chunk, 1, nGB/2); // 第二个断点​
    // 填充所有内存​
    memset(chunk, 1, nGB); // 第三个断点​
    free(chunk); // 第四个断点​
    return 0;​
}

win我们还要设置一下任务管理器显示虚拟内存,操作如下👇🏻:
image.png
image.png

之后我们debug模式运行代码,先来到第一个断点处,可以发现进程只占了几百kb的物理&虚拟内存👇🏻:
image.png

之后放行来到第二个断点,我们可以观察到虚拟内存暴涨了6G,但是物理内存却完全没动静,任务管理器也没有异常👇🏻:
image.png
image.png

之后我们再放行到第三个断点,终于发现物理内存开始占了3G,这是因为此时我们才真正开始使用内存👇🏻:
image.png
image.png

继续放行到第四个断点,内存占满👇🏻:
image.png
image.png

那到这里可能就有人会问了,malloc申请到的内存默认全是0,那我在Java里面Integer num = new Integer(0)是不是也是一样效果呢?答案是否定的,Java中的原生的对象一般都不是简单的映射就搞定的,虽然它的值虽然是4个字节的0,但对象本身、父类还存储了其它信息就注定了它在初始化之初就要占用物理内存了,但是换成int num = 0可以。

相信通过上面C的demo,你已经对虚拟/物理内存的占用时机有了大概的了解了,而Android的DalvikVM或是Java的JVM,底层都是C/C++实现的,所以这套逻辑可以无缝的搬过去。


其它常见问题

demo app

为了更好的验证,我准备了两个app,功能基本一致,分为32、64位,等下用到可以在这下载:demo-32位.apkdemo-64位.apk

安装命令如:adb install -t demo-32位.apk
查看内存趋势是使用公司内部的diggo工具


为什么app的vss比pss高呢?

对于64位的包&设备来说,这个问题其实尤为明显,拿番茄小说举例,一开始就飞到了25GB👇🏻:
image.png

原因也很简单:

  • 64位的应用理论上有512GB的内核空间+512GB的用户空间,所以空间预申请的虚拟内存可以更挥霍些。
  • Java+系统为了内存安全考虑,也会预申请一定内存。
  • 类似C的结构体对齐也会影响内存大小

虚拟内存是无限申请的吗?

我们可以来实验下,首先是32位的app(可以安装32位的demo实验),可以看到虚存上限基本就是接近4G,就是进程空间的限制👇🏻:

下面这里没到4G,是因为3.8+我单次申请的内存+碎片空间 > 4G,所以没占用到,下个例子(申请虚拟/物理内存超出上限会怎样?)单次占用的内存会更小,所以更接近4G

image.png
超过这部分内存后就会发生OOM,比如有一次活动花屏就是这个原因,物理内存没爆,却因为页面特效太多导致GL打爆了虚存。


再来看看64位的包,实验过多台设备(小米、华为、鸿蒙等),上限均接近506GB👇🏻:

没到512GB,是因为我每次申请10GB,506+10+碎片空间12,所以申请失败了没占用成。

image.png


申请虚拟/物理内存超出上限会怎样?

估计你们看到这个问题,都会想到oom,但实际上oom的条件可能还有多种情况。

我们先来看看32位包,打开32位的demo app,点击物理内存up(非Dalvik VM heap),观察内存趋势👇🏻:

image.png
应用最后闪退了,因为虚存最后触顶了,此时pss约1.8G。

重启应用,点击物理内存up(Dalvik VM heap),观察内存趋势👇🏻:

下面图片突增那段无视就好,估计是软件BUG,实际上就是到512左右就oom了。

image.png

根据上面的图,可以看到这次情况稍微有点儿不一样了,因为虚存没动,物理内存没到上限就oom,oom的日志体现也更明显,就是Java的OOM👇🏻:
image.png


我们再拿64位的包做同样的实验,首先是点击物理内存up(非Dalvik VM heap),内存趋势如下👇🏻:

image.png
可以看到这里跟32位明显不同,消耗完手机物理内存+交换内存才oom的,此时上限不再是虚存。

重启应用,再来看看物理内存up(Dalvik VM heap),内存趋势如下👇🏻:

下面图片突增那段无视就好,估计是软件BUG,实际上就是到512左右就oom了。

image.png
看到上面的内存趋势,这时候就会有小聪明问了,为啥贵为64位包,还是跟32位包一样到512mb就oom了呢?为啥点击物理内存up(非Dalvik VM heap)就不会?下一个问题会回答这个疑问。


触发OOM的条件都有哪些?

OOM的主要原因我知道的就是下面这些:

  • 虚拟内存不足
  • 物理内存不足
  • 向Dakvik VM申请的内存 > dalvik.vm.heapgrowthlimit
  • 向Dakvik VM申请的内存 > dalvik.vm.heapsize

虚拟内存上限:32位设备->3G;64位设备+32位包->4G;64位设备+64位包->512G

对于虚存、物理内存不足的情况很容易理解,但是对于后面两种情况,概念就有些模糊不清了。

我们可以先用adb打印出dalvik.vm.heapgrowthlimitdalvik.vm.heapsize👇🏻:
image.png

没错,我们上面到512MB发生OOM就是因为向Dakvik VM申请的内存超过了heapsize阈值,那什么时候会用到384MB这个阈值呢?其实在官方文档中有描述👇🏻:
image.png

可以看到我们的测试包就声明了android:largeHeap=“true”,所以到512MB才会OOM:
image.png

另外dalvik.vm.heapgrowthlimitdalvik.vm.heapsize的值并不是固定的(尤其国内),一般由定制的厂商决定。比如我换台手机重新获取👇🏻:
image.png

那为啥上面还有两个demo能超出512MB,而且还能把设备剩余可用内存占用完呢?这是因为超过512MB就OOM的demo都是Java层的对象占用的内存,Java内存必定是向Dalvik VM申请的,其它两个demo能逼到极限内存是因为它不是向Dalvik VM申请内存。

对于Android而言,最典型的例子就是BitMap,它的内存不全是向Dalvik VM申请的👇🏻:
image.png
这也是为啥不同app、不同场景出现OOM时PSS都不一样的原因,因为绝大部分时候内存都是混合着来的(音视频、游戏等业务则通常是native占比更高),什么时候OOM完全看Dalvik VM的部分占用了多少。


dumpsys快照中java外的一定是Dalvik VM外的内存吗?

上面的demo为了好说明,对于Dalvik VM产生内存溢出的例子我全部采用Java对象申请内存,于是你看到的就是Java内存涨到512就OOM了;对于占用全部物理内存的例子,则是全都由natiive申请内存。这很容易会给你一个错觉:native的部分就一定不是Dalvik VM的。

为了说明这个问题,我改造了一下demo(只有部分手机能触发),安装命令adb install -t demo_32位_ex.apk👇🏻:demo_32位_ex.apk

我这里能触发的是一台 SEA-AL10 鸿蒙2.0.0版本的手机,另一台同样是鸿蒙2.0.0的LYA-AL00却触发不了,所以实际效果随缘。

多说无益,下面是操作录屏👇🏻:

还有中途dump的文件:demo_32_ex.txt

我们可以看到java内存还没到512MB就OOM了,并且恰好java+native很接近512MB,如下图所示👇🏻:
image.png
事实就是这部分native就是属于Dalvik的,还记得我们在native和java相交的位置dump了一下吗,我们可以从dump出来的txt看到这一点👇🏻:
image.png
像上面红框的内存,恰好约等于最左边的前2条的和,没理解错的话,它就是受dalvik.vm.heapsize约束的部分。

而对于真正Dalvik外的内存,它的分布是像下面这个样子的👇🏻:
image.png