美文网首页
腾讯开源轻量级缓存 MMKV 源码解析

腾讯开源轻量级缓存 MMKV 源码解析

作者: N0tExpectErr0r | 来源:发表于2019-08-08 10:52 被阅读0次

    MMKV 是腾讯于 2018 年 9 月 20 日开源的一个 K-V 组件,下面是官方对它的介绍:

    MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。近期也已移植到 Android / macOS / Windows 平台,一并开源。

    从上面的介绍,可以发现它与 Android 中的 SharedPreferences 是极其相似的,但是它的性能却远超于 SharedPreferences。根据官方的宣传,写入随机 int 1000次,下面是它们两者的性能对比:

    image

    可以发现,它相比 SP 的性能提升不是一点半点,接近了 100 倍!那么它是如何达到这么高的效率的呢?相信很多人已经从上面官方对它的介绍中找到了原因——mmap

    没错,又是 mmap,Binder 的底层原理用到了它,腾讯和美团的日志库用到了它,如今腾讯的 K-V 组件也用到了它,关于 mmap 的介绍及基本使用可以看我之前的一篇笔记:内存映射—— mmap() 函数的使用

    那么今天就让我们来研究一下 MMKV 的具体实现。

    注意:

    1. MMKV 的核心内容使用 C++ 编写,同时涉及了很多 JNI 的使用,因此这篇文章并不是纯 Java。

    2. 由于笔者还是大二学生,C++ 功底一般,本文纯属因兴趣开始的研究,因此只能尽量以理解具体流程为主,如果有误请路过的大佬指出

    如果你还不知道什么是 JNI 和 NDK,可以看我之前的另一篇博客:NDK的基本使用,这一篇就够了

    初始化

    MMKV 在使用前,需要在 Application 中进行初始化:

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        String rootDir = MMKV.initialize(this);
    }
    

    那么我们先进入 initialize 方法看看具体做了什么:

    public static String initialize(Context context) {
        String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
        return initialize(root);
    }
    

    可以看到,这里指定 rootDir 为 context.getFilesDir().getAbsolutePath() + "/mmkv";,然后调用了 initialize(rootDir)

    下面我们看到 initialize(rootDir):

    public static String initialize(String rootDir) {
        MMKV.rootDir = rootDir;
        jniInitialize(MMKV.rootDir);
        return rootDir;
    }
    

    它调用了一个 Native 方法 jniInitialize,下面我们看到它的具体实现:

    extern "C" JNIEXPORT JNICALL void
    Java_com_tencent_mmkv_MMKV_jniInitialize(JNIEnv *env, jobject obj, jstring rootDir) {
        if (!rootDir) {
            return;
        }
        const char *kstr = env->GetStringUTFChars(rootDir, nullptr);
        if (kstr) {
            MMKV::initializeMMKV(kstr);
            env->ReleaseStringUTFChars(rootDir, kstr);
        }
    }
    

    可以看到,首先它将 Java 中的 rootDir 转换为了一个 char* kstr,在不为 null 的情况下调用了 MMKV 类的 static 方法 initializeMMKV。下面来到 initializeMMKV:

    void MMKV::initializeMMKV(const std::string &rootDir) {
        static pthread_once_t once_control = PTHREAD_ONCE_INIT;
        pthread_once(&once_control, initialize);
    
        g_rootDir = rootDir;
        char *path = strdup(g_rootDir.c_str());
        mkPath(path);
        free(path);
    
        MMKVInfo("root dir: %s", g_rootDir.c_str());
    }
    

    这里可以看到,这里首先将 rootDir 赋值给了这个 static 变量 g_rootDir,之后将 rootDir 复制了一份 path,通过 path 来将 rootDir 这个目录在硬盘中创建出来。

    为什么要复制一份而不直接将 rootDir 传进去呢?我们不妨看看 mkPath 的代码:

    bool mkPath(char *path) {
        struct stat sb = {};
        bool done = false;
        char *slash = path;
    
        while (!done) {
            slash += strspn(slash, "/");
            slash += strcspn(slash, "/");
    
            done = (*slash == '\0');
            *slash = '\0';
    
            if (stat(path, &sb) != 0) {
                if (errno != ENOENT || mkdir(path, 0777) != 0) {
                    MMKVWarning("%s : %s", path, strerror(errno));
                    return false;
                }
            } else if (!S_ISDIR(sb.st_mode)) {
                MMKVWarning("%s: %s", path, strerror(ENOTDIR));
                return false;
            }
    
            *slash = '/';
        }
    
        return true;
    }
    

    可以看到,它从前向后一层层进行判断是否目录是否存在,若不存在则调用 mkdir 函数来进行目录的创建,在这个过程中会将传入的路径的一些字段置为 '\0' ,因此为了保证原来的 rootDir 不会被改变,因此复制了一份 char * 传入。

    那么到这里,初始化的过程就结束了,主要的步骤就是一些变量的赋值以及目录的创建。

    MMKV 对象的获取

    MMKV 中有一种叫做 mmapID 的 String 用于区别存储,类似于 SP 中的 name。通过 MMKV.mmkvWithId 即可获取对应 MMKV 对象,下面我们可以看一下其具体实现:

    通过 mmapID 获取

    这里以 mmkvWithID(String mmapID, int mode) 进行举例,其实其他的重载都是类似的,都调用了 getMMKVWithID 这个 Native 方法,只是传入的参数不同。

    private native static long
    getMMKVWithID(String mmapID, int mode, String cryptKey, String relativePath);
    

    上面是它的 Java 定义,下面我们看到它的具体实现:

    extern "C" JNIEXPORT JNICALL jlong Java_com_tencent_mmkv_MMKV_getMMKVWithID(
        JNIEnv *env, jobject obj, jstring mmapID, jint mode, jstring cryptKey, jstring relativePath) {
        MMKV *kv = nullptr;
        if (!mmapID) {
            return (jlong) kv;
        }
        string str = jstring2string(env, mmapID);
        bool done = false;
        if (cryptKey) {
            string crypt = jstring2string(env, cryptKey);
            if (crypt.length() > 0) {
                if (relativePath) {
                    string path = jstring2string(env, relativePath);
                    kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt, &path);
                } else {
                    kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt, nullptr);
                }
                done = true;
            }
        }
        if (!done) {
            if (relativePath) {
                string path = jstring2string(env, relativePath);
                kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr, &path);
            } else {
                kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr, nullptr);
            }
        }
        return (jlong) kv;
    }
    

    可以看到,首先是进行了 mmapID 的判空,为空则返回 nullptr,之后则根据 cryptKey 及 relativePath 为空与否分别传入了不同的参数调用 MMKV 类的 mmkvWithID 这一 static 方法。

    细心的你可能会发现,这里返回的是一个 jlong 类型的值,MMKV 对象的地址被强转为了 Java 中的 long 类型,这个返回值对应了 Java 层 MMKV 类中的 nativeHandle 这个 long 值,其他的对该 MMKV 对象的操作都需要传入该 long 值,然后在该方法内进行强转即可得到这个对象的地址

    下面我们看到 mmkvWithID 方法:

    MMKV *MMKV::mmkvWithID(
        const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {
        if (mmapID.empty()) {
            return nullptr;
        }
        SCOPEDLOCK(g_instanceLock);
        auto mmapKey = mmapedKVKey(mmapID, relativePath);   // 1
        auto itr = g_instanceDic->find(mmapKey);
        if (itr != g_instanceDic->end()) {
            MMKV *kv = itr->second;
            return kv;
        }
        if (relativePath) { // 2
            auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);
            if (!isFileExist(filePath)) {
                if (!createFile(filePath)) {
                    return nullptr;
                }
            }
            MMKVInfo("prepare to load %s (id %s) from relativePath %s", mmapID.c_str(), mmapKey.c_str(),
                     relativePath->c_str());
        }
        auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath); // 3
        (*g_instanceDic)[mmapKey] = kv;
        return kv;
    }
    

    其中, g_instanceDic 为一个 unordered_map 对象,它的声明如下:

    unordered_map<std::string, MMKV *> *g_instanceDic;

    这里可以看到,首先在注释 1 处通过 mmapedKVKey 方法传入 mmapID 及 relativePath 获取到了对应的 mmapKey,之后通过 mmapKey 在 map 中获取对应的 MMKV 对象,如果找到则直接返回。

    在注释 2 处在 relativePath 不为空的情况下,调用了 mappedKVPathWithID 方法传入 mmapID、mode、relativePath 这三个参数获取到了对应的 filePath,并创建了相应的文件夹。

    可能有人会问,这个 relativePath 是什么呢?既然是相对路径,那么我在这里猜测可能是存储的文件相对与 rootDir 的路径吧。

    之后在注释 3 处调用了 MMKV 的构造函数,创建完后将其放入 map 中并返回。

    mmapKey 的生成

    下面我们先看看 mmapKey 是如何获取的,来到 mmapedKVKey 方法:

    static string mmapedKVKey(const string &mmapID, string *relativePath) {
        if (relativePath && g_rootDir != (*relativePath)) {
            return md5(*relativePath + "/" + mmapID);
        }
        return mmapID;
    }
    

    可以看到,在 relativePath 不为空且 rootDir 与 relativePath 不相同的情况下,mmapKey 是对 relativePath/mmapID 进行 MD5 加密后的字符串

    而在 relativePath 为空的情况下, mmapKey 就是 mmapID。

    这样的生成方法的主要目的我猜测是区分存储不同 relativePath 下相同 mmapID 的数据。

    relativePath 对应的 filePath

    下面我们看到 mappedKVPathWithID,来看看 relativePath 和 mmapID 所生成的目录:

    static string mappedKVPathWithID(const string &mmapID, MMKVMode mode, string *relativePath) {
        if (mode & MMKV_ASHMEM) {
            return string(ASHMEM_NAME_DEF) + "/" + encodeFilePath(mmapID);
        } else if (relativePath) {
            return *relativePath + "/" + encodeFilePath(mmapID);
        }
        return g_rootDir + "/" + encodeFilePath(mmapID);
    }
    

    可以看到,这和方法里面主要是根据不同的情况返回不同的目录。

    MMKV 的构造函数

    下面我们来看看创建 MMKV 对象的 MMKV 构造函数:

    MMKV::MMKV(
        const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
        :  m_mmapID(mmapedKVKey(mmapID, relativePath))
        , m_path(mappedKVPathWithID(m_mmapID, mode, relativePath))  // 1
        , m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
        , m_metaFile(m_crcPath, DEFAULT_MMAP_SIZE, (mode & MMKV_ASHMEM) ? MMAP_ASHMEM : MMAP_FILE)
        , m_crypter(nullptr)
        , m_fileLock(m_metaFile.getFd())
        , m_sharedProcessLock(&m_fileLock, SharedLockType)
        , m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType)
        , m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0)
        , m_isAshmem((mode & MMKV_ASHMEM) != 0) {
        m_fd = -1;
        m_ptr = nullptr;
        m_size = 0;
        m_actualSize = 0;
        m_output = nullptr;
    
        if (m_isAshmem) {
            m_ashmemFile = new MmapedFile(m_mmapID, static_cast<size_t>(size), MMAP_ASHMEM);
            m_fd = m_ashmemFile->getFd();
        } else {
            m_ashmemFile = nullptr;
        }
    
        if (cryptKey && cryptKey->length() > 0) {
            m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
        }
    
        m_needLoadFromFile = true;
        m_hasFullWriteback = false;
    
        m_crcDigest = 0;
    
        m_sharedProcessLock.m_enable = m_isInterProcess;
        m_exclusiveProcessLock.m_enable = m_isInterProcess;
    
        // sensitive zone
        {
            SCOPEDLOCK(m_sharedProcessLock);
            loadFromFile();
        }
    }
    

    它的构造函数里面主要是一些赋值操作,赋值过后调用了 loadFromFile 方法,其中可以看到注释 1 处是调用了mappedKVPathWithID 来获取文件存放的目录。也就是说文件存放目录由 mappedKVPathWithID 得到

    从文件读取数据

    下面让我们看到 loadFromFile 方法:

    void MMKV::loadFromFile() {
        ...
        m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);     // 1
        if (m_fd < 0) {
            MMKVError("fail to open:%s, %s", m_path.c_str(), strerror(errno));
        } else {
            m_size = 0;
            struct stat st = {0};
            if (fstat(m_fd, &st) != -1) {
                m_size = static_cast<size_t>(st.st_size);   // 2
            }
            // round up to (n * pagesize)
            if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
                size_t oldSize = m_size;        // 3
                m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
                if (ftruncate(m_fd, m_size) != 0) {
                    MMKVError("fail to truncate [%s] to size %zu, %s", m_mmapID.c_str(), m_size,
                              strerror(errno));
                    m_size = static_cast<size_t>(st.st_size);
                }
                zeroFillFile(m_fd, oldSize, m_size - oldSize);
            }
            m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);    // 5
            if (m_ptr == MAP_FAILED) {
                MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
            } else {
                memcpy(&m_actualSize, m_ptr, Fixed32Size);  // 6
                MMKVInfo("loading [%s] with %zu size in total, file size is %zu", m_mmapID.c_str(),
                         m_actualSize, m_size);
                bool loadFromFile = false, needFullWriteback = false;
                if (m_actualSize > 0) {
                    if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) {
                        if (checkFileCRCValid()) {  // 7
                            loadFromFile = true;
                        } else {
                            auto strategic = onMMKVCRCCheckFail(m_mmapID);
                            if (strategic == OnErrorRecover) {
                                loadFromFile = true;
                                needFullWriteback = true;
                            }
                        }
                    } else {
                        auto strategic = onMMKVFileLengthError(m_mmapID);
                        if (strategic == OnErrorRecover) {
                            loadFromFile = true;
                            needFullWriteback = true;
                        }
                    }
                }
                if (loadFromFile) {
                    MMKVInfo("loading [%s] with crc %u sequence %u", m_mmapID.c_str(),
                             m_metaInfo.m_crcDigest, m_metaInfo.m_sequence);
                    MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);    // 8
                    if (m_crypter) {
                        decryptBuffer(*m_crypter, inputBuffer);
                    }
                    m_dic.clear();
                    MiniPBCoder::decodeMap(m_dic, inputBuffer);     // 9
                    m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
                                                   m_size - Fixed32Size - m_actualSize);
                    if (needFullWriteback) {
                        fullWriteback();
                    }
                } else {
                    SCOPEDLOCK(m_exclusiveProcessLock);
                    if (m_actualSize > 0) {
                        writeAcutalSize(0);
                    }
                    m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
                    recaculateCRCDigest();
                }
                MMKVInfo("loaded [%s] with %zu values", m_mmapID.c_str(), m_dic.size());
            }
        }
        if (!isFileValid()) {
            MMKVWarning("[%s] file not valid", m_mmapID.c_str());
        }
        m_needLoadFromFile = false;
    }
    

    这里代码非常长,不过问题不大,我们慢慢分析

    先看到注释 1 处,通过 open 函数打开了对应的要写入的文件

    之后看到注释 2 处,通过 fstat 方法获取到了文件对应的大小

    然后再看到注释 3 处,取了一片足够存放该文件原信息的 DEFAULT_MMAP_SIZE 整数倍的文件大小作为该文件的新容量。

    之后在注释 4 处,调用了 zeroFillFile(m_fd, oldSize, m_size - oldSize) 方法来将文件以 0 字符填充到新容量的大小(这里有点类似我 mmap 博客中分析的 Logan mmap 工具库中打开文件后的处理)。

    注释 5 处,则是 MMKV 的核心,它调用了 mmap 函数来将该文件映射到了 m_ptr 指向的内存为起点的内存中。

    (下面的这一部分参考了 微信MMKV源码分析(二) | mmap映射 这篇博客)

    之后,在注释 6 处读取了文件中数据长度,并在注释 7 处验证了数据的有效性。

    之后,又在注释 8 处将数据读取到 MMBuffer 这一缓冲区中。若之前标记为加密,则在此处进行解密。

    然后,在注释 9 处对数据进行了反序列化操作,放入了 m_dic 这一 Map 中,通过官方文档知道使用的是 protobuf 协议,这里不深究序列化和反序列化过程。

    MMKV 的修改操作

    修改数据

    写入数据通常通过 encode 方法,根据不同的重载可以写入不同类型的数据。下面我们以 encode(String, int) 为例来分析。

    public boolean encode(String key, int value) {
        return encodeInt(nativeHandle, key, value);
    }
    

    可以看到,这里调用了 Native 方法 encodeInt,我们看看它的具体实现:

    extern "C" JNIEXPORT JNICALL jboolean Java_com_tencent_mmkv_MMKV_encodeInt(
        JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint value) {
        MMKV *kv = reinterpret_cast<MMKV *>(handle);
        if (kv && oKey) {
            string key = jstring2string(env, oKey);
            return (jboolean) kv->setInt32(value, key);
        }
        return (jboolean) false;
    }
    

    可以看到,首先将 nativeHandle 强转回了我们的 MMKV 指针,之后调用了这个对象的 setInt32 方法。

    我们看到 setInt32 方法:

    bool MMKV::setInt32(int32_t value, const std::string &key) {
        if (key.empty()) {
            return false;
        }
        size_t size = pbInt32Size(value);
        MMBuffer data(size);
        CodedOutputData output(data.getPtr(), size);
        output.writeInt32(value);
    
        return setDataForKey(std::move(data), key);
    }
    

    可以看到,这里首先创建了一个足够放下 value 的 Buffer,之后又通过该 Buffer 创建了一个 CodedOutputData 对象用于写入,最后通过其 writeInt32 方法将数据写入。写入之后又调用了 setDataForKey 方法为其设置 key。

    将数据写入

    我们先看到 CodedOutputData 的 writeInt32 方法:

    void CodedOutputData::writeInt32(int32_t value) {
        if (value >= 0) {
            this->writeRawVarint32(value);
        } else {
            this->writeRawVarint64(value);
        }
    }
    

    可以看到,在 value >= 0 时调用的是 writeRawVarint32 方法,否则调用了 writeRawVarint64 方法。

    其实进入这些方法会发现它们所做的事都是将数据按字节写入到 m_ptr 中,而 m_ptr 则是之前 data 的 getPtr 所返回的 ptr。也就是说,这里实际上是将数据按字节写入了我们的 MMBuffer 中,CodedOutputData 类的作用是为其提供写入。

    为 data 设置 Key

    那么我们再看看 MMKV 是如何将 key 和 data 放入 map 的:

    bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
        if (data.length() == 0 || key.empty()) {
            return false;
        }
        SCOPEDLOCK(m_lock);
        SCOPEDLOCK(m_exclusiveProcessLock);
        checkLoadData();    // 1
    
        // m_dic[key] = std::move(data);
        auto itr = m_dic.find(key); // 2
        if (itr == m_dic.end()) {
            itr = m_dic.emplace(key, std::move(data)).first;
        } else {
            itr->second = std::move(data);
        }
        m_hasFullWriteback = false;
    
        return appendDataWithKey(itr->second, key);
    }
    

    这里为了优化用到了 C++ 11 中的一些特性,有兴趣的可以去了解一下,这里不详细介绍,只介绍大体流程。

    首先,在注释 1 处检查了读取的操作是否完成。

    之后开始在 Map 中寻找对应 key 的元素,如果没有找到则创建一个新的元素。否则将对应元素的 value 替换为我们之前写入的 data。

    写入文件

    之后则调用了 appendDataWithKey 方法,让我们继续进去看看:

    bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
        size_t keyLength = key.length();
        // size needed to encode the key
        size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);   // 1
        // size needed to encode the value
        size += data.length() + pbRawVarint32Size((int32_t) data.length());
    
        SCOPEDLOCK(m_exclusiveProcessLock);
    
        bool hasEnoughSize = ensureMemorySize(size);
    
        if (!hasEnoughSize || !isFileValid()) { // 2
            return false;
        }
        if (m_actualSize == 0) {    // 3
            auto allData = MiniPBCoder::encodeDataWithObject(m_dic);    // 4
            if (allData.length() > 0) {
                if (m_crypter) {
                    m_crypter->reset();
                    auto ptr = (unsigned char *) allData.getPtr();
                    m_crypter->encrypt(ptr, ptr, allData.length());
                }
                writeAcutalSize(allData.length());
                m_output->writeRawData(allData); // note: don't write size of data
                recaculateCRCDigest();
                return true;
            }
            return false;
        } else {
            writeAcutalSize(m_actualSize + size);   // 5
            m_output->writeString(key);
            m_output->writeData(data); // note: write size of data
    
            auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
            if (m_crypter) {
                m_crypter->encrypt(ptr, ptr, size);
            }
            updateCRCDigest(ptr, size, KeepSequence);
    
            return true;
        }
    }
    

    代码比较长,跟着我的思路来看。

    首先在注释 1 处进行了需要的 size 的计算。

    之后在注释 2 处核验了空间是否足够以及文件的有效性。

    然后在注释 3 处判断了当前文件是否为空。

    若还没写入数据则在注释 4 处将数据转换为 protobuf 的文本,之后进行了一些加密,加密后调用了 writeAcutalSize 方法重新计算了 m_actualSize,然后调用了 m_output 的 writeRawData 方法来将数据写入文件的映射区。之后计算了 CRC 校验码。关于 CRC 校验码我们后面再谈。

    若已经写入过数据,则在注释 5 处改变了 m_actualSize,然后将 key 和 data 写入文件。最后重新计算了 CRC 校验码。

    我们可以以 writeRawData 为例看看它具体是如何写入文件的:

    void CodedOutputData::writeRawData(const MMBuffer &data) {
        size_t numberOfBytes = data.length();
        memcpy(m_ptr + m_position, data.getPtr(), numberOfBytes);
        m_position += numberOfBytes;
    }
    

    可以看到,实际上就是通过 memcpy 的方式将数据复制到了 m_ptr 所指向的那块内存中,由于这块内存是与文件形成了映射的,所以文件的内容也会被系统自动回写。

    写入优化与文件重整

    其实据官方的描述,protobuf 这种文件虽然具有占用的空间小的特点,但是是不支持增量更新的,那么它们的解决方法是如何呢?

    标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。

    可以看到,官方的处理是在写入时不断添加到末尾,然后在读入数据时,不断用后读入的值替换新的值。

    但是仔细一想,这样不断 append 的话,那么会带来一个问题:文件的大小在不断增大。因此 MMKV 的开发者使用了下面这种解决方式:

    我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

    那么这个文件重整机制是在哪里体现的呢?

    其实我们之前在写入文件时没有去研究这个函数——ensureMemorySize,它就实现了这个排重机制。

    bool MMKV::ensureMemorySize(size_t newSize) {
        if (!isFileValid()) {   // 1
            MMKVWarning("[%s] file not valid", m_mmapID.c_str());
            return false;
        }
        
        if (newSize >= m_output->spaceLeft()) { // 2
            // try a full rewrite to make space
            static const int offset = pbFixed32Size(0);
            MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);
            size_t lenNeeded = data.length() + offset + newSize;
            if (m_isAshmem) {
                if (lenNeeded > m_size) {
                    MMKVWarning("ashmem %s reach size limit:%zu, consider configure with larger size",
                                m_mmapID.c_str(), m_size);
                    return false;
                }
            } else {
                size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size());
                size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2);
                // 1. no space for a full rewrite, double it
                // 2. or space is not large enough for future usage, double it to avoid frequently full rewrite
                if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {   // 1
                    size_t oldSize = m_size;
                    do {    // 3
                        m_size *= 2;
                    } while (lenNeeded + futureUsage >= m_size);
                    MMKVInfo(
                        "extending [%s] file size from %zu to %zu, incoming size:%zu, futrue usage:%zu",
                        m_mmapID.c_str(), oldSize, m_size, newSize, futureUsage);
    
                    // if we can't extend size, rollback to old state
                    if (ftruncate(m_fd, m_size) != 0) {
                        MMKVError("fail to truncate [%s] to size %zu, %s", m_mmapID.c_str(), m_size,
                                  strerror(errno));
                        m_size = oldSize;
                        return false;
                    }
                    if (!zeroFillFile(m_fd, oldSize, m_size - oldSize)) {
                        MMKVError("fail to zeroFile [%s] to size %zu, %s", m_mmapID.c_str(), m_size,
                                  strerror(errno));
                        m_size = oldSize;
                        return false;
                    }
    
                    if (munmap(m_ptr, oldSize) != 0) {
                        MMKVError("fail to munmap [%s], %s", m_mmapID.c_str(), strerror(errno));
                    }
                    m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);  // 4
                    if (m_ptr == MAP_FAILED) {
                        MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
                    }
    
                    // check if we fail to make more space
                    if (!isFileValid()) {
                        MMKVWarning("[%s] file not valid", m_mmapID.c_str());
                        return false;
                    }
                }
            }
    
            if (m_crypter) {    // 5
                m_crypter->reset();
                auto ptr = (unsigned char *) data.getPtr();
                m_crypter->encrypt(ptr, ptr, data.length());
            }
    
            writeAcutalSize(data.length());
    
            delete m_output;    
            m_output = new CodedOutputData(m_ptr + offset, m_size - offset);
            m_output->writeRawData(data);
            recaculateCRCDigest();
            m_hasFullWriteback = true;
        }
        return true;
    }
    

    这里代码特别特别长...

    首先在注释 1 处校验了文件的合法性

    之后在注释 2 处,若空间不足够,则开始尝试一次文件重整,将所有数据序列化后进行了一次计算。

    此时会出现两种情况:

    1. 重整后空间足够,则将文件清空重新写入
    2. 重整后空间仍不足,则不断将文件扩容至 2 倍的大小,直到足够将数据放入。

    现在逻辑很清晰了,那么我们看看具体实现。

    看到注释 3 处,在内存不足的情况下,执行了一个 do-while 循环,不断将 m_size 乘 2,直到满足我们的需要。

    之后执行了用 0 填充新文件、释放之前的映射的操作后,又在注释 4 处进行了内存的重新映射。最后检验了文件的完整性。

    之后在注释 5 处,首先在需要加密的情形下进行了加密。之后将数据清空并重新写入了映射的内存。

    这样就完成了一次文件重整。

    MMKV 的查询操作

    下面我们看看 MMKV 提供的查询操作,仍然以 Int 为例:

    public int decodeInt(String key, int defaultValue) {
        return decodeInt(nativeHandle, key, defaultValue);
    }
    

    它调用了 native 方法 decodeInt(nativeHandle, key, defaultValue),我们看看它的具体实现:

    extern "C" JNIEXPORT JNICALL jint Java_com_tencent_mmkv_MMKV_decodeInt(
        JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint defaultValue) {
        MMKV *kv = reinterpret_cast<MMKV *>(handle);
        if (kv && oKey) {
            string key = jstring2string(env, oKey);
            return (jint) kv->getInt32ForKey(key, defaultValue);
        }
        return defaultValue;
    }
    

    可以看到,调用了 MMKV 对象的 getInt32ForKey 方法,在无法找到的情况下则会返回默认值。

    我们看看 getInt32ForKey 方法:

    int32_t MMKV::getInt32ForKey(const std::string &key, int32_t defaultValue) {
        if (key.empty()) {
            return defaultValue;
        }
        SCOPEDLOCK(m_lock);
        auto &data = getDataForKey(key);
        if (data.length() > 0) {
            CodedInputData input(data.getPtr(), data.length());
            return input.readInt32();
        }
        return defaultValue;
    }
    

    可以看到,先通过 getDataForKey 方法获取到对应的 MMBuffer,然后从中通过 CodedInputData 来按字节获取对应的数据,获取不到时则会返回默认值。

    我们看看 getDataForKey 的实现:

    const MMBuffer &MMKV::getDataForKey(const std::string &key) {
        checkLoadData();
        auto itr = m_dic.find(key);
        if (itr != m_dic.end()) {
            return itr->second;
        }
        static MMBuffer nan(0);
        return nan;
    }
    

    可以看到,其实就是从 m_dic 中取出对应 key 的 MMBuffer,找不到则会返回 NULL。

    到这里查询操作就结束了,可以看到是十分简单的

    MMKV 的删除操作

    下面我们看看 MMKV 的删除操作,来到 removeValueForKey 方法,它调用了 removeValueForKey(nativeHandle, key) 这个 native 方法。

    extern "C" JNIEXPORT JNICALL void Java_com_tencent_mmkv_MMKV_removeValueForKey(JNIEnv *env,
                                                                                   jobject instance,
                                                                                   jlong handle,
                                                                                   jstring oKey) {
        MMKV *kv = reinterpret_cast<MMKV *>(handle);
        if (kv && oKey) {
            string key = jstring2string(env, oKey);
            kv->removeValueForKey(key);
        }
    }
    

    可以看到,这里调用了 removeValueForKey 方法,我们进去看看:

    void MMKV::removeValueForKey(const std::string &key) {
        if (key.empty()) {
            return;
        }
        SCOPEDLOCK(m_lock);
        SCOPEDLOCK(m_exclusiveProcessLock);
        checkLoadData();
    
        removeDataForKey(key);
    }
    

    首先,这里进行了是否加载数据的检查,在数据已经加载的情况下会执行 removeDataForKey 方法:

    bool MMKV::removeDataForKey(const std::string &key) {
        if (key.empty()) {
            return false;
        }
    
        auto deleteCount = m_dic.erase(key);
        if (deleteCount > 0) {
            m_hasFullWriteback = false;
            static MMBuffer nan(0);
            return appendDataWithKey(nan, key);
        }
    
        return false;
    }
    

    这里很简单,其实就是将 key 从 map 中删除,之后将该 key 写入空的 value 到文件。

    总结

    MMKV 是一种基于 mmap 的 K-V 存储库,与 SharedPreferences 的定位类似,但它的效率比 SharedPreferences 高了近百倍,原因是它使用了 mmap 这种内存映射技术,使得相比 SharedPreferences 减少了拷贝及提交的时间。

    它是可以通过 mmkvWithID 方法来根据一个 mmapID 获取到对应的 MMKV 对象的,在获取 MMKV 对象时会从本地读取数据到内存中的一个 map。

    在写入数据时,不论什么类型的数据都会在内存中以 MMBuffer 的形式存在,这些数据都以一个个字节的形式存放于 Buffer 中,在写入数据时会将数据同时写入文件,由于 protobuf 协议无法做到增量更新,因此其实是通过不断向文件后 append 新的 value 来实现的。然后在读取时不断的以最后的 value 替换之前的 value。

    当写入空间不足时,会进行文件重整,将所有数据重新序列化一次,若文件重整后内存仍然不足,则会先将文件进行 double 的扩容。最后,会将文件清空并重新写入重整后的数据。

    在查询数据时,会从 map 中取出 Buffer,再将 Buffer 中的数据转换为对应的真实类型并返回。

    在删除数据时,会找到对应的 key 并从 map 中删除,之后将 key 在文件中对应的 value 置为 0。

    CodedInputData 及 CodedOutputData 类其实就是一个 Buffer 与真实数据的中介,将真实数据写入 Buffer 需要通过 CodedOutputData,而将 Buffer 中的数据转换为真实数据则需要 CodedInputData。

    相关文章

      网友评论

          本文标题:腾讯开源轻量级缓存 MMKV 源码解析

          本文链接:https://www.haomeiwen.com/subject/kibtjctx.html