SharedPreference
特点 | 说明 |
---|---|
数据格式 | xml格式保存 |
初始化 | 子线程使用IO读区整个文件,进行xml解析,存入内存map集合 |
保存 | commit同步提交,阻塞主线程,apply异步提交,无法获取结果且可能数据丢失 |
更新 | 把map中的数据,全部序列化维xml,覆盖文件保存(全量更新) |
优化方向
- 比xml更精简的数据格式
- 非耗时操作文件(0拷贝技术)
- 不阻塞主线程的同时避免数据丢失
- 支持局部更新
传统IO
虚拟内存被操作系统分为两块:
-
用户空间
用户程序代码运行的地方
-
内核空间
内核代码运行的地方放,内核空间由所有进程共享
用户空间和内核空间是隔离的,即时用户的程序崩溃了,内核也不受影响
写文件的流程
- 调用write,告诉内核需要写入数据的开始地址和长度
- 内核将数据拷贝到内核页缓存
- 由操作系统调用,将数据拷贝到磁盘,完成写入
mmap
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,初始化这个虚拟内存区域的内容 ,这个过程成为内存映射(memory mapping)
对文件进行mmap,会在进程的虚拟内存分配地址空间,创建映射关系
实现这样的映射关系后,就可以采用指针的方式读写操作内存,而系统会自动回写到对应的文件磁盘上
mmap的优势
- MMAP对文件的读写操作只需要从磁盘到用户主存的一次数据拷贝过程,减少了数据拷贝次数,提高了文件操作效率
- MMAP使用逻辑内存对磁盘文件进行映射,操作内存就相当于操作文件,不需要开启线程,操作MMAP的速度和操作内存的速度一样
- MMAP提供一段可供随时写入的内存块,app只需要往里面写入数据,由操作系统在内存不足,进程退出等时候负责将内存回写到文件(也可以主动回写到文件中)
mmap的劣势
- mmap必须映射整页的内存,可能会造成内存的浪费,所以mmap的适用场景是大文件的频繁读写
- 虽然写回文件的工作由系统负责,但是并不是实时的,是定期写回到磁盘的,中间如果发生内核崩溃、断电等,还是会丢失数据,不过可以通过msync将数据同步回磁盘
什么是protobuf协议
protobuf 是google开源的一个序列化框架,类似xml,json,最大的特点是基于二进制,比传统的XML表示同样一段内容要短小得多。
MMKV正式基于protobuf协议进行数据存储,存储方式为增量更新,也就是不需要每次修改数据都要重新将所有数据写入文件了
MMKV数据格式
总长度 ->是有效字节数
mmap是一页管理,一页大概是4096字节
更新方式
-
增量写入
不管key是否重复,直接将数据追加在后面。
-
全量写入
文件大小满足写入的数据大小,则直接更新全量写入。
追加内容越来越多,当文件超出限制的时候会做两件事,先去除重复的key值,如果去除之后可以满足写入,则全量写入,如果不满足的话,则会扩容。
多进程实现细节
MMKV本质上是将文件 mmap 到内存块中,将新增的 key-value 统统 添加 到内存中;到达边界后,进行重整回写以腾出空间,空间还是不够的话,就扩容内存空间;对于内存文件中可能存在的重复键值,MMKV 只选用最后写入的作为有效键值。
但首先还得解决一个问题:怎么让其他进程感知这三种情况
状态同步:
- 写指针的同步
我们可以在每个进程内部缓存自己的写指针,然后在写入键值的同时,还要把最新的写指针位置也写到 mmap 内存中;这样每个进程只需要对比一下缓存的指针与 mmap 内存的写指针,如果不一样,就说明其他进程进行了写操作。事实上 MMKV 原本就在文件头部保存了有效内存的大小,这个数值刚好就是写指针的内存偏移量,我们可以重用这个数值来校对写指针。 - 内存重整的感知
考虑使用一个单调递增的序列号,每次发生内存重整,就将序列号递增。将这个序列号也放到 mmap 内存中,每个进程内部也缓存一份,只需要对比序列号是否一致,就能够知道其他进程是否触发了内存重整。 - 内存增长的感知
事实上 MMKV 在内存增长之前,会先尝试通过内存重整来腾出空间,重整后还不够空间才申请新的内存。所以内存增长可以跟内存重整一样处理。至于新的内存大小,可以通过查询文件大小来获得,无需在 mmap 内存另外存放。
其他进程为了保持数据一致,就需要处理这三种情况:
-
写指针增长
当一个进程发现 mmap 写指针增长,就意味着其他进程写入了新键值。这些新的键值都 append 在原有写指针后面,可能跟前面的 key 重复,也可能是全新的 key,而原写指针前面的键值都是有效的。那么我们就要把这些新键值都读出来,插入或替换原有键值,并将写指针同步到最新位置。
-
内存重整
当一个进程发现内存被重整了,就意味着原写指针前面的键值全部失效,那么最简单的做法是全部抛弃掉,从头开始重新加载一遍。
-
内存增长。
发生内存增长的时候,必然已经先发生了内存重整,那么原写指针前面的键值也是统统失效,处理逻辑跟内存重整一样。
文件锁
- 递归锁
意思是如果一个进程/线程已经拥有了锁,那么后续的加锁操作不会导致卡死,并且解锁也不会导致外层的锁被解掉。对于文件锁来说,前者是满足的,后者则不然。因为文件锁是状态锁,没有计数器,无论加了多少次锁,一个解锁操作就全解掉。只要用到子函数,就非常需要递归锁。 - 锁升级/降级
锁升级是指将已经持有的共享锁,升级为互斥锁,亦即将读锁升级为写锁;锁降级则是反过来。文件锁支持锁升级,但是容易死锁:假如 A、B 进程都持有了读锁,现在都想升级到写锁,就会陷入相互等待的困境,发生死锁。另外,由于文件锁不支持递归锁,也导致了锁降级无法进行,一降就降到没有锁。
需要注意的地方有两点:
- 加写锁时,如果当前已经持有读锁,那么先尝试加写锁,try_lock 失败说明其他进程持有了读锁,我们需要先将自己的读锁释放掉,再进行加写锁操作,以避免死锁的发生。
- 解写锁时,假如之前曾经持有读锁,那么我们不能直接释放掉写锁,这样会导致读锁也解了。我们应该加一个读锁,将锁降级。
小结
MMKV作为一种高性能大量数据的存储组件,对比Android传统的存储方式SharedPreferences确实有不少优势。核心是使用mmap内存映射文件,对比传统IO,在性能上有很大优势,并且将读写文件的操作变得和操作内存一样简单。但它的缺点是可能造成内存的浪费,因为必须映射内存页的整数倍,如果只存储很少量的数据,则显得大材小用。因此,可以作为一种数据存储的选择方案,在一些需要大量存储数据场景时,替代SharedPreferences。