SharedPreference与mmkv

一、持久化数据Key-Values存储方案,如何设计?

1、新建磁盘文件:涉及IO读写 (效率问题)
2、选取数据格式:xml,json,protocol (增删改查问题)
3、映射到内存:map集合(内存占用问题)
4、提供get put 方法,修改内存,修改文件,(数据一致性问题)

SharedPreference原理

1、初始化

  • 1.1、新建子线程,使用传统IO,读取xml格式keyVelues,映射成map集合
    SharedPreferencesImpl 源码
private void loadFromDisk() {
    synchronized (mLock) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
   ...
}
  • 1.2、get方法
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

问题:如果没有初始化完成,出现线程阻塞问题

  • 1.3、put方法
public void apply() {
    final long startTime = System.currentTimeMillis();

    // 1、先更新内存数据
    final MemoryCommitResult mcr = commitToMemory();
    // 2、更新磁盘数据
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }

                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };
        ....
        }
@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
   ...
    try {
        FileOutputStream str = createFileOutputStream(mFile);
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
                ...
        try {
            final StructStat stat = Os.stat(mFile.getPath());
            synchronized (mLock) {
                mStatTimestamp = stat.st_mtim;
                mStatSize = stat.st_size;
            }
        } catch (ErrnoException e) {
            // Do nothing
        }
                ...
        return;
    } catch (XmlPullParserException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    } catch (IOException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    }
    
}

问题:

1、写磁盘存在失败情况,出现磁盘数据和内存数据不一致的情况
2、出现ANR风险
anr原因分析:
QueuedWork.addFinisher(awaitCommit);
QueuedWork.waitToFinish();
查看QueuedWork 以及 ActivityThread 源码

总结:
SharedPreference的问题

SharedPreference 问题 解决方案
IO读写 传统IO读写,效率慢
数据格式xml 不支持局部更新
内存映射 饿汉模式,初始化就创建map
可靠性 存在数据不一致性,存在ANR风险
多进程 不支持多进程数据共享 ?

二、文件拷贝相关知识补充

应用运行时,存在用户空间内存,和内核空间,两个空间的内存是隔离的,互相不影响。下图所示:


image.png

应用层的任何代码执行都是在用户空间执行的,如果要进行系统调用,比如IO操作,那么就需要进入内核空间,在内核空间实现系统调用。

那么,用户空间和内核空间如何实现数据共享呢?答案就是通过CPU拷贝来实现的。

下图显示了,一次传统IO操作的过程发生的内存拷贝操作:

    File source = new File("/Users/dw/applogs.zip");
        File dest = new File("/Users/dw/applogs_io.zip");
        java.io.InputStream input = null;
        java.io.OutputStream output = null;
         
        input = new FileInputStream(source);
        output = new FileOutputStream(dest);
        byte[] buf = new byte[1024];
        int bytes;
        while ((bytes = input.read(buf)) > 0) {
             output.write(buf, 0, bytes);
        }
image.png

1、read系统调用,DMA执行了一次数据拷贝,从磁盘拷贝到内核空间
2、read结束后,发生了第二次数据拷贝,由CPU将数据从内核空间拷贝到用户空间
3、write系统调用,CPU将用户空间的数据拷贝到内核空间
4、write结束,DMA执行了一次数据拷贝,从磁盘拷贝到内核空间

以上过程,发生了4次数据拷贝,两次上下文切换。

  • DMA(Direct Memory Access,直接[存储器]),DMA 传输将数据从一个地址空间复制到另外一个地址空间。当CPU 初始化这个传输动作,传输动作本身是由 DMA 控制器来实行和完成。

三、零拷贝技术

  • 零拷贝,不是真正的不拷贝,而是减少CPU的拷贝操作,避免了CPU将数据从一块内存区拷贝到另一块内存区,用户程序绕过操作系统,直接操作系统资源,数据传输使用DMA传输,让数据拷贝不需要经过用户空间。

  • 操作内存,就等于操作磁盘

  • java自带的零拷贝实现类:

    java.nio.channels.FileChannel 和 java.io.InputStream 拷贝文件效率对比演示

四、mmap

  • mmap操作提供了一种机制,让用户程序直接访问设备内存,这种机制,相比较在用户空间和内核空间互相拷贝数据,效率更高。在要求高性能的应用中比较常用。mmap映射内存必须是页面大小的整数倍,面向流的设备不能进行mmap,mmap的实现和硬件有关。
  • mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图所示:


    image.png

4.1 mmap的write

1.进程(用户态)将需要写入的数据直接copy到对应的mmap地址(内存copy)
2.若mmap地址未对应物理内存,则产生缺页异常,由内核处理
3.若已对应,则直接copy到对应的物理内存
4.由操作系统调用,将脏页回写到磁盘(通常是异步的)

4.2 mmap的read

image.png

4.3 mmap函数

mmap函数
映射文件

 void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);

解除映射

int munmap(void *addr, size_t length);

函数参数解析

4.3 mmap总结

优点:

  • 1、用内存读写取代I/O读写,不需要开启子线程,操作mmap的速度和操作内存速度一样快,提高了文件读取效率。
  • 2、用户空间和内核空间高效数据通讯方式
  • 3、多进程可以映射同一个文件,可以实现文件共享
  • 4、内存不足或者应用程序意外崩溃,系统会负责脏数据回写到磁盘,不担心数据丢失

缺点:

  • 1、内存映射必须以页(4096字节)为单位进行映射,如果一个文件大小不足4k,那么映射整个页4096字节映射,造成一些内存浪费

mmap读写数据速度


image.png

五、MMKV实现

mmkv 是腾讯开源的使用mmap原理实现的高效的Key-Values存储方案,在2015年时候开始在微信使用。

mmkv效率演示

 
    private fun spTest() {
        val start = System.currentTimeMillis()
        val sharedPreferences = this.getSharedPreferences("sp_name", Context.MODE_PRIVATE)
        val edit = sharedPreferences.edit()
        for (i in 0..3000) {
            edit.putInt("key$i", i).apply()
        }
        Log.i(TAG, "spTest cost ${System.currentTimeMillis() - start} ms")
    }

    private fun mmkvTest() {
        val start = System.currentTimeMillis()
        val defaultMMKV = MMKV.defaultMMKV()
        for (i in 0..3000) {
            defaultMMKV.putInt("key$i", i).apply()
        }
        Log.i(TAG, "mmkvTest cost ${System.currentTimeMillis() - start} ms")
    }

// 调用方
 Thread {
            mmkvTest()
            spTest()
        }.start()
  

2021-09-25 16:26:25.680 18488-18564/com.douwan.launchdemo I/MainActivity: mmkvTest cost 52 ms
2021-09-25 16:26:27.599 18488-18564/com.douwan.launchdemo I/MainActivity: spTest cost 1919 ms

5.1、mmkv数据结构

数据格式使用 protobuf 协议,数据结构更加精简,key类型是String,values类型统一系列化为buffer。

message KV {
    string key = 1;
    buffer value = 2;
}

mmkv选择的数据结构是Key-Values链表结构


image.png

5.2 mmkv写入方式

  • 写入优化
    protobuf没有提供增量更新能力,每次都是全量写入,所以,需要设计符合增量更新的能力。将kv对象系列化之后,直接append到内存末尾,同一个key可能会存在多份数据,最新的数据在最后,这样,只需要在应用启动后第一次打开mmkv,不断用后读取的values替换前面的值,就保证了数据是最新有效的。

  • 空间增长
    append带来的一个问题就是文件大小会不断的增长,变得不可控,所以解决方案是,当文件空间用尽前,都是append模式,append到文件末尾时候,进行文件重整,key排重,排重之后,如果文件空间还是不够用的话,将文件扩大一倍,直到空间够用。

5.3 mmkv

官方文档

映射完成之后,直接操作内存中的map集合,完成数据读写操作。
对这片映射空间进行了读写操作,会引发缺页异常,系统会自动回写脏页面到对应的文件磁盘上,实现数据持久化存储

//get方法
float MMKV::getFloat(MMKVKey_t key, float defaultValue) {
    if (isKeyEmpty(key)) {
        return defaultValue;
    }
    SCOPED_LOCK(m_lock);
    auto data = getDataForKey(key);
    if (data.length() > 0) {
        try {
            CodedInputData input(data.getPtr(), data.length());
            return input.readFloat();
        } catch (std::exception &exception) {
            MMKVError("%s", exception.what());
        }
    }
    return defaultValue;
}


MMBuffer MMKV::getDataForKey(MMKVKey_t key) {
    checkLoadData();
#ifndef MMKV_DISABLE_CRYPT
    if (m_crypter) { // 有加密
        auto itr = m_dicCrypt->find(key);
        if (itr != m_dicCrypt->end()) {
            auto basePtr = (uint8_t *) (m_file->getMemory()) + Fixed32Size;
            return itr->second.toMMBuffer(basePtr, m_crypter);
        }
    } else
#endif
    { //未加密
        auto itr = m_dic->find(key);
        if (itr != m_dic->end()) {
            auto basePtr = (uint8_t *) (m_file->getMemory()) + Fixed32Size;
            return itr->second.toMMBuffer(basePtr);
        }
    }
    MMBuffer nan;
    return nan;
}

// m_dicCrypt , m_dic 定义:
    mmkv::MMKVMap *m_dic;
    mmkv::MMKVMapCrypt *m_dicCrypt;

// MMKVMap , MMKVMapCrypt 结构体,
// unordered_map: C++定义的map,内部实现了哈希表,其查找速度非常的快
using MMKVMap = std::unordered_map;
using MMKVMapCrypt = std::unordered_map;

六、MMKV vs SharedPreference

SharedPreference 问题 MMKV
IO读写 传统IO读写,效率慢 mmap方式读写磁盘
数据格式xml 不支持局部更新 protocol结构体 Key-Values链表方式
内存映射 饿汉模式,初始化就创建map 初始化只是映射一个内存地址
可靠性 存在数据不一致性,存在ANR风险 直接操作内存,不存在数据一致性问题
多进程 不支持多进程数据共享 支持多进程数据共享

你可能感兴趣的:(SharedPreference与mmkv)