Ahab's Studio.

细读《深入理解 Android 内核设计思想》(二)内存管理

字数统计: 4.7k阅读时长: 18 min
2020/04/06 Share

对冗余挑拣重点,对重点深入补充,输出结构清晰的精简版

  1. 操作系统内存管理基础
    1. 虚拟内存
    2. 内存分配与回收
    3. mmap
    4. Copy on Write
  2. Android 内存管理
    1. Low Memory Killer
    2. Ashmem 驱动
    3. MemoryFile 原理
  3. 总结

操作系统内存管理基础

不论什么操作系统,内存管理都是绝对的重点和难点。内存管理旨在为系统中所有 Task 提供稳定可靠的内存分配、释放和保护机制。你可能会疑问,学习 Android 系统有必要了解 Linux Kernel 的内存管理机制吗?

是的!不论是 Android 的音频系统、GUI 系统,还是 Binder 的实现机理等,都是和内存管理息息相关的。

虚拟内存

虚拟内存就是当内存资源不足时,借用硬盘中的一部分的空间,充当内存使用。系统会挑选优先级低的内存数据放入硬盘,后续若要用到硬盘中的数据,系统会产生一次缺页中断,然后把数据交换回内存中。

要理解虚拟内存机制,就要理解三种地址空间,分别是逻辑地址、线性地址和物理地址:

1.逻辑地址(Logical Address)
逻辑地址是程序编译后产生的地址,也称为相对地址,由两部分组成:

  • 段选择子(Segment Selector):描述逻辑地址所处的段
  • Offset:描述所在段内的偏移值

2.线性地址(Linear Address)
线性地址是由逻辑地址经过分段机制转换后得到的。

大致转换过程为:通过段选择子确定段的基地址,然后结合 Offset 得到线性地址。

3.物理地址(Physical Address)
物理地址就是指机器真实的物理内存地址,任何操作系统,最终都要通过物理地址来访问内存。

若系统开启了分页机制,则在得到线性地址后需要通过分页机制转换后,才能得到物理地址。

简单来说,由逻辑地址得到物理地址过程如下:

  • 逻辑地址 -> 分段机制转换 -> 线性地址 -> 分页机制转换 -> 物理地址

内存分配与回收

内存的分配与回收是操作系统的重要组成部分,需要解决的核心问题包括:

  • 操作系统应保证应用程序的硬件无关性,硬件差异不能体现在应用程序上
  • 内存划分的区域、分配粒度、最小单位,管理区分已使用和未使用的内存,回收等等
  • 优化内存碎片,考虑整体机制的高效性

mmap

mmap(Memory Map) 可以将某个设备或文件映射到应用进程的内存空间中,这样应用程序访问这块内存,相当于直接对设备/文件读写,不再需要 read、write 等 IO 操作。

mmap 函数如下:

1
2
//映射成功返回0,否则返回错误码
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);

  • addr:指文件/设备应该映射到进程空间的哪个起始地址
  • len:指被映射到进程空间的内存块大小
  • prot:指定被映射内存的访问权限,包括 PROT_READ(可读)、PROT_WRITE(可写) 等
  • flags:指定程序对内存块所做改变造成的影响,包括 MAP_SHARED(保存到文件) 等
  • fd:被映射到进程空间的文件描述符
  • offset:指定从文件的哪一部分开始映射

源码见 http://androidxref.com/9.0.0_r3/xref/bionic/libc/bionic/mmap.cpp 。mmap 可用于跨进程通信,Linux Kernel 和 Android 中就频繁的用到了这个函数,比如 Android 的 Binder 驱动,下面分析 MemoryFile 原理时还会提到这个函数。

Copy on Write

Copy on Write(写时拷贝) 是指如果有多个调用者要请求同一资源,他们会获取到相同的指向这一资源的指针,直到某个调用者需修改资源时,系统才会复制一份副本给该调用者,而其他调用者仍使用最初的资源。

如果调用者不需要修改资源,就不会建立副本,多个调用者共享读取同一份资源。

Linux 的 fork() 函数就是 Copy on Write 的,实际开销很小,主要是给子进程创建进程描述符等,并且推迟甚至免除了数据拷贝操作。比如 fork() 后子进程需立即调用 exec() 装载新程序到进程的内存空间,即不需要父进程的任何数据,这种情况 Copy on Write 技术就避免了不必要的数据拷贝,从而提升了运行速度。

Android 内存管理

Low Memory Killer

Linux Kernel 有自己的内存监控机制,即 OOMKiller。当系统的可用内存达到临界值时,OOMKiller 就会按照优先级从低到高杀掉进程。优先级该如何衡量呢?OOMKiller 会综合进程当前消耗内存、进程占用 CPU 时间、进程类型等因素,对进程实时评分。分值存储在 /proc/{PID}/oom_score 中,可通过 cat 命令查看。分值越低的进程,优先级越高,被杀死的概率越小。

基于 Linux 内核 OOMKiller 的核心思想,Android 系统拓展出了自己的内存监控体系,相比 Linux 达到临界值才触发,Android 实现了不同梯级的 Killer。Android 系统为此开发了专门的驱动,名为 Low Memory Killer,源码在内核的 /drivers/staging/android/Lowmemorykiller.c 中。

Lowmemorykiller.c 中有如下定义:

1
2
3
4
5
6
7
8
static int lowmem_adj[6] = {0, 1, 6, 12};
static int lowmem_adj_size = 4; //页大小
static size_t lowmem_minfree[6] = { //元素使用时以 lowmem_adj_size 为单位
3 * 512, //6MB
2 * 1024, //8MB
4 * 1024, //16MB
16 * 1024//64MB
};

lowmem_minfree 定义了可用内存容量对应的不同梯级。lowmem_adj 与 lowmem_minfree 中的梯级一一对应,表示处于某梯级时需要被处理的 adj 值。adj 值用来描述进程的优先级,取值范围为 -17~15,数字越小表示进程优先级越高,被杀死的概率越小。

比如当可用内存低于 64MB 时,即 lowmem_minfree 第 4 梯级,对应于 lowmem_adj 的 12,那就会清理掉优先级低于 12(即 adj>12)的进程。

上面这两个数组中梯级的定义只是系统的预定义值,Android 系统还提供了相应的文件供我们修改这两组值,路径为:

1
2
/sys/module/lowmemorykiller/parameters/adj
/sys/module/lowmemorykiller/parameters/minfree

可以在 init.rc(系统启动时由 init 进程解析的一个脚本) 中,这样修改:

1
2
write /sys/module/lowmemorykiller/parameters/adj        0, 8
write /sys/module/lowmemorykiller/parameters/minfree 1024, 4096

另外 ActivityManagerService 中有一个 updateOomLevels 方法也是通过修改这两个文件来实现的,AMS 在运行时会根据当前的系统配置自动调整 adj 和 minfree,以尽可能适配不同的硬件设备。

了解了 Low Memory Killer 的梯级规则后,来看下 Android 进程的 adj 值含义:

ADJ 说明
HIDDEN_APP_MAX_AD = 15 只运行了不可见 Activity 的进程
HIDDEN_APP_MIN_ADJ = 9 只运行了不可见 Activity 的进程
SERVICE_B_ADJ = 8 B list of Service
PREVIOUS_APP_ADJ = 7 用户的上一个产生交互的进程
HOME_APP_ADJ = 6 Launcher 进程
SERVICE_ADJ = 5 当前运行了 application service 的进程
BACKUP_APP_ADJ = 4 用于承载 backup 相关操作的进程
HEAVY_WEIGHT_APP_ADJ = 3 重量级应用程序进程
PERCEPTIBLE_APP_ADJ = 2 能被用户感觉但不可见,如后台运行的音乐播放器
VISIBLE_APP_ADJ = 1 有前台可见的 Activity
FOREGROUND_APP_ADJ = 0 当前正在前台运行与用户交互的进程
PERSISTENT_PROC_ADJ = -12 Persistent 性质的进程,如 telephony
SYSTEM_ADJ = -16 系统进程

除了表格中系统的评定标准,有没有办法改变某一进程的 adj 值呢?和修改上面的 adj、minfree 梯级类似,进程的 adj 值也可以通过写文件的方式来修改,路径为 /proc/{PID}/oom_adj,比如 init.rc 中:

1
write /proc/1/oom_adj -16

另外还可以在 AndroidManifest.xml 中给 application 添加 “android:persistent=true” 属性。

Ashmem 驱动

Anonymous Shared Memory 匿名共享内存是 Android 特有的内存共享机制,它可以将指定的物理内存分别映射到各个进程自己的虚拟地址空间中,从而便捷的实现进程间内存共享,Ashmem 的实现依赖 Ashmem 设备节点。

怎么理解设备节点呢?Linux 抽象了对硬件的处理,所有的硬件设备都可以当作普通文件一样来看待,设备节点文件是设备驱动的逻辑文件,其中对设备的描述包括文件操作函数集合,应用程序可以通过这些函数来访问硬件设备。

除了磁盘等真正的硬件设备,还可以通过内存抽象,使用设备节点文件的方式来描述一个”设备”并使用它,Ashmem、Binder 驱动都是属于这种内存抽象的”设备”。

介绍 Ashmem 设备节点前,先了解下 ueventd 进程。ueventd 就是 Android 中负责创建和管理设备节点的进程,创建设备节点文件有两种方式:
1.静态节点文件:以预先定义的设备信息为基础,当 ueventd 进程启动后,统一创建设备节点文件
2.动态节点文件:即在系统运行中,当有设备插入 USB 端口时,ueventd 进程就会接收到这一事件,为插入的设备动态创建设备节点文件

Ashmem 设备节点就属于静态节点文件,创建过程如下:
1.Android 系统启动,解析 init.rc,启动 ueventd 进程
2.ueventd 进程会去解析 ueventd.rc,读取 ashmem 设备节点信息到系统中

其中 ueventd.rc 文件格式如下:

1
2
3
4
5
/dev/null                 0666   root       root
/dev/zero 0666 root root
/dev/random 0666 root root
/dev/ashmem 0666 root root
/dev/binder 0666 root root

可以看到包括 binder、ashmem 在内的一系列设备节点信息都会在这里读取到系统中。

随后 ashmem 会调用 ashmem.c 文件的 ashmem_init 进行初始化:

1
2
3
4
5
6
7
8
static int _init ashmem_init(void){
int ret;
ashmem_area_cachep = kmem_cache_create("ashmem_area_cache",sizeof(struct ashmem_area),0,0,NULL);
ashmem_range_cachep = kmem_cache_create("ashmem_range_cache",sizeof(struct ashmem_range),0,0,NULL);
ret = misc_register(&ashmem_misc);
...
return 0;
}

通过 kmem_cache_create() 函数创建了两个 cache,后面申请内存时需要用到。对于 kmem_cache_create() 函数,书中提及 Slab、Slub、Slob 三种机制,这里不再延伸,仅理解: kmem_cache_create() 并没有真正的分配内存,后续还要调用 kmem_cache_alloc() 。

由于 ashmem 属于 misc 杂项设备,所以调用 misc_register(&ashmem_misc) 进行设备注册。ashmem_misc 就是 Ashmem 的设备描述,定义如下:

1
2
3
4
5
static struct miscdevice ashmem_misc = {
.minor = MISC_DYNAMIC_MINOR, //自动分配次设备号
.name = "ashmem", //设备节点的名称
.fops = &ashmem_fops, //文件操作集合
};

.fops 就是上面提到的”文件操作函数集合”,即 Ashmem 设备的操作函数集,如下

1
2
3
4
5
6
7
8
9
10
static struct file_operations ashmem_fops = {
.owner = THIS_MODULE,
.open = ashmem_open,
.release = ashmem_release,
.read = ashmem_read,
.llseek = ashmem_llseek,
.mmap = ashmem_mmap,
.unlocked_ioctl = ashmem_ioctl,
.compat_ioctl = ashmem_ioctl,
};

其中 ashmem_open、ashmem_mmap 及 ashmem_ioctl 函数比较重要,依次来看:

1.ashmem_open

1
2
3
4
5
6
7
8
9
static int ashmem_open(struct inode *inode, struct file *file){
struct ashmem_area *asma;
...
asma = kmem_cache_zalloc(ashmem_area_cachep, GFP_KERNEL);
...
file->private_data = asma;
...
return 0; //申请成功
}

ashmem_open 主要做了两个工作,1.调用 kmem_cache_zalloc 方法从 ashmem_area_cachep 分配了一块内存,这个方法和 cache 上面都提到过;2.将 ashmem_area 记录在 file 中 。

2.ashmem_mmap

1
2
3
4
5
6
7
8
9
10
11
12
13
static int ashmem_mmap(struct file *file, struct vm_area_struct *vma){
struct ashmem_area *asma = file->private_data;
...
mutex_lock(&ashmem_mutex);
...
if(!asma->file){
shmem_file_setup(name, asma->size, vma->vm_flags);
}
...
shmem_set_file(vma, asma->file);
...
mutex_unlock(&ashmem_mutex);
}

首先拿到在 ashmem_open 函数中创建的 ashmem_area,然后判断如果 asma->file 为空,说明这是第一个访问该共享内存的进程,调用 shmem_file_setup() 函数在 tmpfs 中创建一个临时文件,用于进程间的内存共享;如果 asma->file 不为空,直接调用 shmem_set_file 进行内存映射。

3.ashmem_ioctl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static long ashmem_ioctl(struct file *file, unsigned int cmd, unsigned long arg){
struct ashmem_area * asma = file->private_data;
switch(cmd){
case ASHMEM_SET_NAME://设置名称
set_name(asma, (void __user *) arg);
break;
case ASHMEM_GET_NAME://获取名称
get_name(asma, (void __user *) arg);
break;
case ASHMEM_SET_NAME://设置大小
if(!asma->file){
asma->size = (size_t) arg;
}
break;
...
}
}

ashmem_ioctl 即根据 ioctl 命令做相应的操作,设置或获取 size、名称等。

MemoryFile 原理

书中通过 MemoryDealer 讲解了 Ashmem 示例,触类旁通,我来分析一下 Ashmem 的另一个应用示例:MemoryFile。MemoryFile 是 Java 层对 Ashmem 的一个封装,使用方法大致如下:

进程 A 中申请一块共享内存写入数据,并准备好文件描述符:

1
2
3
4
5
MemoryFile memoryFile = new MemoryFile(name, size);
memoryFile.getOutputStream().write(data);
Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
FileDescriptor des = (FileDescriptor) method.invoke(memoryFile);
ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(des);

进程 B 中通过 binder 拿到 A 进程中准备好的文件描述符,然后直接读取数据:

1
2
3
FileDescriptor descriptor = pfd.getFileDescriptor();
FileInputStream fileInputStream = new FileInputStream(descriptor);
fileInputStream.read(data);

使用起来和文件读写一样很简单,如果不了解 Ashmem 机制,也就只能停留在仅会使用的浅显层面了。现在有了 Ashmem 驱动知识的铺垫,来看 MemoryFile 是怎么从 Java API 调用到 Ashmem 驱动函数的,先来看 MemoryFile 的构造函数:

1
2
3
4
5
6
7
8
public MemoryFile(String name, int length) throws IOException {
try {
mSharedMemory = SharedMemory.create(name, length);
mMapping = mSharedMemory.mapReadWrite();
} catch (ErrnoException ex) {
ex.rethrowAsIOException();
}
}

可以看到构造 MemoryFile 时通过 SharedMemory create 方法申请了一块匿名共享内存,SharedMemory create 方法中调用了 nCreate native 方法:

1
private static native FileDescriptor nCreate(String name, int size) throws ErrnoException;

对应的 native 实现在 android_os_SharedMemory.cpp 中,源码见 http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/jni/android_os_SharedMemory.cpp ,具体 native 实现如下:

1
2
3
4
5
6
static jobject SharedMemory_create(JNIEnv* env, jobject, jstring jname, jint size) {
const char* name = jname ? env->GetStringUTFChars(jname, nullptr) : nullptr;
int fd = ashmem_create_region(name, size); //创建匿名共享内存
...
return jniCreateFileDescriptor(env, fd);
}

ashmem_create_region 方法的对应实现在 ashmem-dev.cpp 中,源码见 http://androidxref.com/9.0.0_r3/xref/system/core/libcutils/ashmem-dev.cpp#ashmem_create_region ,其中 ashmem_create_region 的后续调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#define ASHMEM_DEVICE "/dev/ashmem" //Ashmem 设备驱动

int ashmem_create_region(const char *name, size_t size){
int ret, save_errno;
int fd = __ashmem_open(); //创建匿名共享内存
if (fd < 0) {
return fd;
}
if (name) {
char buf[ASHMEM_NAME_LEN] = {0};
strlcpy(buf, name, sizeof(buf));
ret = TEMP_FAILURE_RETRY(ioctl(fd, ASHMEM_SET_NAME, buf)); //设置 Ashmem 名字
if (ret < 0) {
goto error;
}
}
}

static int __ashmem_open(){
int fd;

pthread_mutex_lock(&__ashmem_lock);
fd = __ashmem_open_locked(); //创建匿名共享内存
pthread_mutex_unlock(&__ashmem_lock);
return fd;
}

static int __ashmem_open_locked(){
int ret;
struct stat st;
int fd = TEMP_FAILURE_RETRY(open(ASHMEM_DEVICE, O_RDWR | O_CLOEXEC)); //创建匿名共享内存
...
return fd;
}

直到 __ashmem_open_locked 方法中调用到 open(ASHMEM_DEVICE, O_RDWR | O_CLOEXEC) 方法,终于是到 Ashmem 设备驱动函数了,对应于上面的 ashmem_open 函数。另外 ashmem_ioctl 函数也被调用到了,即 ioctl(fd, ASHMEM_SET_NAME, buf)。

通过上面的分析知道 Ashmem 驱动的 ashmem_open 函数是由 SharedMemory 的 create 方法触发一步一步调用到的,那 ashmem_mmap 驱动函数是怎么被调用到的呢?看 MemoryFile 的构造方法,只可能是通过 SharedMemory 的 mapReadWrite 方法触发,下面来分析这个过程:

1
2
3
4
5
6
7
8
9
10
11
//android.os.SharedMemory.java
public @NonNull ByteBuffer mapReadWrite() throws ErrnoException {
return map(OsConstants.PROT_READ | OsConstants.PROT_WRITE, 0, mSize);
}

public @NonNull ByteBuffer map(int prot, int offset, int length) throws ErrnoException {
...
long address = Os.mmap(0, length, prot, OsConstants.MAP_SHARED, mFileDescriptor, offset);
...
return new DirectByteBuffer(length, address, mFileDescriptor, unmapper, readOnly);
}

比较关键的是 mFileDescriptor,它是执行 SharedMemory create 方法申请匿名共享内存后,返回的文件描述符。SharedMemory 中直接调用了系统的通用 mmap 函数,并没有对应的 native 实现,那它最终真的能调用到 ashmem_mmap 函数吗? 继续来跟踪 mmap 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//android.system.Os.java
public static long mmap(long address, long byteCount, int prot, int flags, FileDescriptor fd, long offset) throws ErrnoException {
return Libcore.os.mmap(address, byteCount, prot, flags, fd, offset);
}

//libcore.io.Libcore.java
public final class Libcore {
private Libcore() { }
public static Os rawOs = new Linux();
public static Os os = new BlockGuardOs(rawOs);
}

//libcore.io.Linux.java
public native long mmap(long address, long byteCount, int prot, int flags, FileDescriptor fd, long offset) throws ErrnoException;

Libcore 中使用 BlockGuardOs 对 Linux 进行了一层包装,但实际还是通过 Linux 来执行的,最后调用到 Linux 中的 native mmap 方法,native 中对应的实现是 mmap.cpp,源码见 http://androidxref.com/9.0.0_r3/xref/bionic/libc/bionic/mmap.cpp#mmap

1
2
3
void* mmap(void* addr, size_t size, int prot, int flags, int fd, off_t offset) {
return mmap64(addr, size, prot, flags, fd, static_cast<off64_t>(offset));
}

到此为止,由 SharedMemory 的 mapReadWrite 方法调用到 native mmap 函数,传递的关键参数是文件描述符,后续它将这样调用到 ashmem_mmap:
1.通过 fd 可以找到所属设备,也就是 Ashmem 设备
2.调用 Ashmem 设备的 ashmem_mmap 驱动函数

这属于 mmap 函数的内部实现,调用链比较复杂就不再具体展开,关键代码如下:

1
2
3
4
5
if(file){
...
error = file->f_op->mmap(file,vma);
...
}

file 代表文件或设备驱动,这里指的就是 Ashmem 设备,f_op 就是 Ashmem 设备驱动函数集,也就是上文提过的通过 misc_register 注册的 Ashmem 设备描述,至此便是 ashmem_mmap 驱动函数的调用过程。

总结

不知你是否会觉得本文介绍的虚拟内存无用,起初我有这样的想法,作者在原书中是这样描述的:本小节为读者完整地还原了操作系统虚拟地址的概念与转换原理,相信大家会在后续 Android 各子系统的学习中受益匪浅。 这让我想到前几天看到的一个问题 “为什么要分析算法的时间复杂度和空间复杂度,是因为现在的计算机都是冯诺依曼结构吗?”,基于基础知识能有自己的理解和发散才是可贵的。

所以谦虚一点别自以为是,对知识保持敬畏、渴望。技术知识的价值不在于是否会被用到,而在于它能否对你的技术体系建设有帮助,能否让你对本质有更清晰的认知,能否让你的上层建筑更牢固。如果只盯着自己的那一点墨水坐井观天,实力没随年龄上涨,迟早会迎来焦虑的中年危机,被行业淘汰。

那本文涉及的知识点对上层建筑有什么帮助呢?比如学习了 Ashmem 后,再遇到跨进程传输大数据的问题,是不是更有底气了呢?比如学习了 Android 的 Low Memory Killer 机制原理后,才知道应用保活本质到底是在解决什么问题,相比只是知道从网上搜来的几个保活方案,是不是更加胸有成竹呢?比如在阅读本文涉及的源码时发现 mutex_lock 随处可见,是不是很庆幸自己掌握了本系列第一章中的 进程间同步机制 呢?

链接:细读《深入理解 Android 内核设计思想》系列

关注公众号,Get 更多知识点

原文作者:Ahab

原文链接:http://yhaowa.gitee.io/2d9a78ad/

发表日期:April 6th 2020, 2:18:55 pm

更新日期:April 12th 2020, 11:24:38 pm

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 操作系统内存管理基础
    1. 1.1. 虚拟内存
    2. 1.2. 内存分配与回收
    3. 1.3. mmap
    4. 1.4. Copy on Write
  2. 2. Android 内存管理
    1. 2.1. Low Memory Killer
    2. 2.2. Ashmem 驱动
    3. 2.3. MemoryFile 原理
  3. 3. 总结