Android传统的SharedPreferences虽然简单易用,但是也存在一些问题:
全量更新,写入效率低下。
每次都需要将所有的数据加载到内存,如果存储大量数据,会占用很多的内存。
多进程情况下使用安全性问题。没有合适的机制防止多进程更新所造成的冲突,官方因此不推荐多进程模式下使用,已经废弃了MODE_MULTI_PROCESS这个标记位。
而MMKV正是解决了上述的问题。
MMKV的核心原理即是内存映射。mmap是linux提供出来作为应用级内存映射的工具。mmap函数定义如下:
void*mmap(void*__addr,size_t__size,int__prot,int__flags,int__fd,off_t__offset);传入起始地址内存起始地址addr,创建size大小的内存区域,将文件描述符fd关联的文件映射到这个区域,从文件的offset偏移量开始。这里的prot是访问权限,可以指定为PROT_EXEC、PROT-READ、PROT_WRITE、PROT_NONE,分别代表可执行、可读、可写、不能访问。而flags可以设置映射的对象的类型,例如:MAP_SHARED、MAP_PRIVATE、MAP_ANONYMOUS分别代表了共享对象、写时复制的私有对象、不和文件关联的匿名对象。
mmap和传统IO的对比
传统IO的读操作是DMA(DirectMemoryAccess)先复制磁盘数据到内核缓冲区,然后CPU再将内核缓冲区的数据复制到应用程序地址空间。单次读写操作涉及到两次内存拷贝、两次上下文切换。而mmap只需要首次将磁盘数据拷贝到内核缓冲区,然后建立用户空间和内核空间的映射,之后读取这块数据就不需要内存拷贝了,也不需要上下切换。传统IO的写操作同理,也涉及到两次拷贝和两次上下文切换。而mmap只需要建立用户和内存空间的映射,往里面写数据不需要拷贝和上下文切换,系统负责将数据写回到文件中。如此可见相比传统IO,mmap减少了数据的拷贝和上下文切换,提高了IO效率。
MMKV具体的mmap逻辑写在了MemoryFile.cpp中:
boolMemoryFile::mmap(){m_ptr=(char*)::mmap(m_ptr,m_size,PROT_READ|PROT_WRITE,MAP_SHARED,m_fd,0);if(m_ptr==MAP_FAILED){MMKVError("failtommap[%s],%s",m_name.c_str(),strerror(errno));m_ptr=nullptr;returnfalse;}returntrue;}可以看到mmap用的是共享对象的模式,并且赋予了读写的权限。
mmap听上去很完美是不是?但是实际使用中还是有缺陷,例如:mmap必须映射整页的内存,可能会造成内存的浪费,所以mmap的适用场景是大文件的频繁读写,这样就可以节省很多IO的耗时;虽然写回文件的工作由系统负责,但是并不是实时的,是定期写回到磁盘的,中间如果发生内核崩溃、断电等,还是会丢失数据,不过可以通过msync将数据同步回磁盘。
protobuf提升了数据序列化和反序列化的速度,但是由于protobuf不支持增量的更新,那么怎么实现只添加或修改一个key-value呢?这里mmkv的做法是将数据append到内存块的末尾,那么这样就可能产生很多相同的key,而mmkv在第一次从文件加载数据到内存中时,会将后写入的新的key替换之前旧的key,保证数据是最新的。翻看mmkv源码可以看到初始化的时候,用一个map来存放从文件当中加载的键值对,那么如果存在相同的key,前面的key就会被后面的key覆盖,保证这个key的值是最新的。代码片段如下:
KeyValueHolder.h:structKeyValueHolder{uint16_tcomputedKVSize;//internaluseonlyuint16_tkeySize;uint32_tvalueSize;uint32_toffset;KeyValueHolder()=default;KeyValueHolder(uint32_tkeyLength,uint32_tvalueLength,uint32_toffset);MMBuffertoMMBuffer(constvoid*basePtr)const;};可以看到存储了key和value的大小以及在内存中的偏移量,并没有直接存储key和value,到用的时候,凭借偏移量和大小去内存中取,节约了内存。
举例分析String数据是如何存入的。代码片段如下:
再来看看String类型的数据如何取出。片段代码如下:
MMKV的初始容量如果传入的size小于内存页大小,那容量就默认为一个内存页大小了。随着数据不断的插入,会伴随超出容量的情况,这时就需要扩容了。MMKV的扩容逻辑如下:
MMKV内部定义了几种锁来处理不同场景下的同步问题:
mmkv::ThreadLock*m_lock;//线程锁,处理多线程同步mmkv::FileLock*m_fileLock;//文件锁,处理多进程同步,被下面两种锁包装了一下mmkv::InterProcessLock*m_sharedProcessLock;//共享锁,包装了上面的文件锁,读操作之前使用mmkv::InterProcessLock*m_exclusiveProcessLock;//独占锁,包装了上面的文件锁,写操作之前使用先来看看ThreadLock:
MMKV作为一种高性能大量数据的存储组件,对比Android传统的存储方式SharedPreferences和SQLite确实有不少优势。核心是使用mmap内存映射文件,对比传统IO,在性能上有很大优势,并且将读写文件的操作变得和操作内存一样简单。翻看源码,有不少优秀的设计点。比如增量写入,重整内存,通过文件大小校验对多进程操作感知,多进程读写锁等等。但它的缺点是可能造成内存的浪费,因为必须映射内存页的整数倍,如果只存储很少量的数据,则显得大材小用。因此,可以作为一种数据存储的选择方案,在一些需要大量存储数据场景时,替代SharedPreferences。