Android 存储之MMKV

1,407 阅读7分钟

SharedPreference

特点说明
数据格式xml格式保存
初始化子线程使用IO读区整个文件,进行xml解析,存入内存map集合
保存commit同步提交,阻塞主线程,apply异步提交,无法获取结果且可能数据丢失
更新把map中的数据,全部序列化维xml,覆盖文件保存(全量更新)

优化方向

  • 比xml更精简的数据格式
  • 非耗时操作文件(0拷贝技术)
  • 不阻塞主线程的同时避免数据丢失
  • 支持局部更新

传统IO

虚拟内存被操作系统分为两块:

  • 用户空间

    用户程序代码运行的地方

  • 内核空间

    内核代码运行的地方放,内核空间由所有进程共享

用户空间和内核空间是隔离的,即时用户的程序崩溃了,内核也不受影响 image.png

写文件的流程
  1. 调用write,告诉内核需要写入数据的开始地址和长度
  2. 内核将数据拷贝到内核页缓存
  3. 由操作系统调用,将数据拷贝到磁盘,完成写入

mmap

Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,初始化这个虚拟内存区域的内容 ,这个过程成为内存映射(memory mapping)

image.png

对文件进行mmap,会在进程的虚拟内存分配地址空间,创建映射关系

实现这样的映射关系后,就可以采用指针的方式读写操作内存,而系统会自动回写到对应的文件磁盘上

mmap的优势

  • MMAP对文件的读写操作只需要从磁盘到用户主存的一次数据拷贝过程,减少了数据拷贝次数,提高了文件操作效率
  • MMAP使用逻辑内存对磁盘文件进行映射,操作内存就相当于操作文件,不需要开启线程,操作MMAP的速度和操作内存的速度一样
  • MMAP提供一段可供随时写入的内存块,app只需要往里面写入数据,由操作系统在内存不足,进程退出等时候负责将内存回写到文件(也可以主动回写到文件中)

mmap的劣势

  • mmap必须映射整页的内存,可能会造成内存的浪费,所以mmap的适用场景是大文件的频繁读写
  • 虽然写回文件的工作由系统负责,但是并不是实时的,是定期写回到磁盘的,中间如果发生内核崩溃、断电等,还是会丢失数据,不过可以通过msync将数据同步回磁盘

什么是protobuf协议

protobuf 是google开源的一个序列化框架,类似xml,json,最大的特点是基于二进制,比传统的XML表示同样一段内容要短小得多。

MMKV正式基于protobuf协议进行数据存储,存储方式为增量更新,也就是不需要每次修改数据都要重新将所有数据写入文件了

MMKV数据格式

image.png

总长度 ->是有效字节数

mmap是一页管理,一页大概是4096字节

更新方式

  • 增量写入

    不管key是否重复,直接将数据追加在后面。

  • 全量写入

    文件大小满足写入的数据大小,则直接更新全量写入。

    追加内容越来越多,当文件超出限制的时候会做两件事,先去除重复的key值,如果去除之后可以满足写入,则全量写入,如果不满足的话,则会扩容。

多进程实现细节

MMKV本质上是将文件 mmap 到内存块中,将新增的 key-value 统统 添加 到内存中;到达边界后,进行重整回写以腾出空间,空间还是不够的话,就扩容内存空间;对于内存文件中可能存在的重复键值,MMKV 只选用最后写入的作为有效键值。

但首先还得解决一个问题:怎么让其他进程感知这三种情况

状态同步:
  • 写指针的同步
    我们可以在每个进程内部缓存自己的写指针,然后在写入键值的同时,还要把最新的写指针位置也写到 mmap 内存中;这样每个进程只需要对比一下缓存的指针与 mmap 内存的写指针,如果不一样,就说明其他进程进行了写操作。事实上 MMKV 原本就在文件头部保存了有效内存的大小,这个数值刚好就是写指针的内存偏移量,我们可以重用这个数值来校对写指针。
  • 内存重整的感知
    考虑使用一个单调递增的序列号,每次发生内存重整,就将序列号递增。将这个序列号也放到 mmap 内存中,每个进程内部也缓存一份,只需要对比序列号是否一致,就能够知道其他进程是否触发了内存重整。
  • 内存增长的感知
    事实上 MMKV 在内存增长之前,会先尝试通过内存重整来腾出空间,重整后还不够空间才申请新的内存。所以内存增长可以跟内存重整一样处理。至于新的内存大小,可以通过查询文件大小来获得,无需在 mmap 内存另外存放。
其他进程为了保持数据一致,就需要处理这三种情况:
  • 写指针增长

    当一个进程发现 mmap 写指针增长,就意味着其他进程写入了新键值。这些新的键值都 append 在原有写指针后面,可能跟前面的 key 重复,也可能是全新的 key,而原写指针前面的键值都是有效的。那么我们就要把这些新键值都读出来,插入或替换原有键值,并将写指针同步到最新位置。

  • 内存重整

    当一个进程发现内存被重整了,就意味着原写指针前面的键值全部失效,那么最简单的做法是全部抛弃掉,从头开始重新加载一遍。

  • 内存增长。

    发生内存增长的时候,必然已经先发生了内存重整,那么原写指针前面的键值也是统统失效,处理逻辑跟内存重整一样。

文件锁
  • 递归锁
    意思是如果一个进程/线程已经拥有了锁,那么后续的加锁操作不会导致卡死,并且解锁也不会导致外层的锁被解掉。对于文件锁来说,前者是满足的,后者则不然。因为文件锁是状态锁,没有计数器,无论加了多少次锁,一个解锁操作就全解掉。只要用到子函数,就非常需要递归锁。
  • 锁升级/降级
    锁升级是指将已经持有的共享锁,升级为互斥锁,亦即将读锁升级为写锁;锁降级则是反过来。文件锁支持锁升级,但是容易死锁:假如 A、B 进程都持有了读锁,现在都想升级到写锁,就会陷入相互等待的困境,发生死锁。另外,由于文件锁不支持递归锁,也导致了锁降级无法进行,一降就降到没有锁。

image.png

需要注意的地方有两点:

  • 加写锁时,如果当前已经持有读锁,那么先尝试加写锁,try_lock 失败说明其他进程持有了读锁,我们需要先将自己的读锁释放掉,再进行加写锁操作,以避免死锁的发生。
  • 解写锁时,假如之前曾经持有读锁,那么我们不能直接释放掉写锁,这样会导致读锁也解了。我们应该加一个读锁,将锁降级

小结

MMKV作为一种高性能大量数据的存储组件,对比Android传统的存储方式SharedPreferences确实有不少优势。核心是使用mmap内存映射文件,对比传统IO,在性能上有很大优势,并且将读写文件的操作变得和操作内存一样简单。但它的缺点是可能造成内存的浪费,因为必须映射内存页的整数倍,如果只存储很少量的数据,则显得大材小用。因此,可以作为一种数据存储的选择方案,在一些需要大量存储数据场景时,替代SharedPreferences。