【Android】重读 MMKV:同样的代码,不同的感悟

MMKV 是腾讯于 2018 年 9 月 20 日开源的一个 K-V 存储库,它与 SharedPreferences 相似但又解决了它存在的一些问题。大概一年前听说了 MMKV 的名字,对当时的自己来说 mmap 还是个很新鲜的东西,因此对它非常感兴趣写下了一篇 【Android】 MMKV 源码浅析 。不过由于当时还是大二,知识的储备还不够丰富,因此整体的分析在某些细节上还比较稚嫩。由于对这个库很感兴趣,因此尝试重新对它进行一次源码解析,对以前分析不够到位的地方进行补充,并且将以前没有研究的部分细致研究一下。

本文基于 MMKV 1.0.16

关于 MMKV 的编译可以看这篇文档: https://github.com/Tencent/MMKV/wiki/android_setup

初始化

通过 MMKV.initialize 方法可以实现 MMKV 的初始化:

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

它采用了内部存储空间下的 mmkv 文件夹作为根目录,之后调用了 initialize 方法。

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

调用到了 jniInitialize 这个 Native 方法进行 Native 层的初始化:

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);
    }
}

这里通过 MMKV::initializeMMKV 对 MMKV 类进行了初始化:

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 并创建对应的根目录,由于 mkPath 方法创建目录时会修改字符串的内容,因此需要复制一份字符串进行。

获取

获取 MMKV 对象

通过 mmkvWithID 方法可以获取 MMKV 对象,它传入的 mmapID 就对应了 SharedPreferences 中的 name,代表了一个文件对应的 name,而 relativePath 则对应了一个相对根目录的相对路径。

@Nullable
public static MMKV mmkvWithID(String mmapID, String relativePath) {
    if (rootDir == null) {
        throw new IllegalStateException("You should Call MMKV.initialize() first.");
    }
    long handle = getMMKVWithID(mmapID, SINGLE_PROCESS_MODE, null, relativePath);
    if (handle == 0) {
        return null;
    }
    return new MMKV(handle);
}

它调用到了 getMMKVWithId 这个 Native 方法,并获取到了一个 handle 构造了 Java 层的 MMKV 对象返回。这是一种很常见的手法,Java 层通过持有 Native 层对象的地址从而与 Native 对象通信(例如 Android 中的 Surface 就采用了这种方式)。

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;
    // mmapID 为 null 返回空指针
    if (!mmapID) {
        return (jlong) kv;
    }
    string str = jstring2string(env, mmapID);

    bool done = false;
    // 如果需要进行加密,获取用于加密的 key,最后调用 MMKV::mmkvWithID
    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;
        }
    }
    // 如果不需要加密,则调用 mmkvWithID 不传入加密 key,表示不进行加密
    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;
}

这里实际上调用了 MMKV::mmkvWithID 方法,它根据是否传入用于加密的 key 以及是否使用相对路径调用了不同的方法。

MMKV *MMKV::mmkvWithID(
    const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {

    if (mmapID.empty()) {
        return nullptr;
    }
    // 加锁
    SCOPEDLOCK(g_instanceLock);

    // 将 mmapID 与 relativePath 结合生成 mmapKey
    auto mmapKey = mmapedKVKey(mmapID, relativePath);
    // 通过 mmapKey 在 map 中查找对应的 MMKV 对象并返回
    auto itr = g_instanceDic->find(mmapKey);
    if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second;
        return kv;
    }
    // 如果找不到,构建路径后构建 MMKV 对象并加入 map
    if (relativePath) {
        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);
    (*g_instanceDic)[mmapKey] = kv;
    return kv;
}

这里的步骤如下:

  1. 通过 mmapedKVKey 方法对 mmapIDrelativePath 进行结合生成了对应的 mmapKey ,它会将它们两者的结合经过 md5 从而生成对应的 key,主要目的是为了支持不同相对路径下的同名 mmapID
  2. 通过 mmapKeyg_instanceDic 这个 map 中查找对应的 MMKV 对象,如果找到直接返回。
  3. 如果找不到对应的 MMKV 对象,构建一个新的 MMKV 对象,加入 map 后返回。

构造 MMKV 对象

我们可以看看在 MMKV 的构造函数中做了什么:

MMKV::MMKV(
    const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
    : m_mmapID(mmapedKVKey(mmapID, relativePath))
    // ...) {
    // ...

    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;
    }

        // 通过加密 key 构建 AES 加密对象 AESCrypt
    if (cryptKey && cryptKey->length() > 0) {
        m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
    }

        // 赋值操作
    // 加锁后调用 loadFromFile 加载数据
    {
        SCOPEDLOCK(m_sharedProcessLock);
        loadFromFile();
    }
}

这里进行了一些赋值操作,之后如果需要加密则根据用于加密的 cryptKey 生成对应的 AESCrypt 对象用于 AES 加密。最后,加锁后通过 loadFromFile 方法从文件中读取数据,这里的锁是一个跨进程的文件共享锁。

从文件加载数据

我们都知道,MMKV 是基于 mmap 实现的,通过内存映射在高效率的同时保证了数据的同步写入文件, loadFromFile 中就会真正进行内存映射:

void MMKV::loadFromFile() {
    // ...
    // 打开对应的文件
    m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    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);
        }
        // 将文件大小对齐到页大小的整数倍,用 0 填充不足的部分
        if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
            size_t oldSize = m_size;
            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);
        }
        // 通过 mmap 将文件映射到内存
        m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
        if (m_ptr == MAP_FAILED) {
            MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
        } else {
            memcpy(&m_actualSize, m_ptr, Fixed32Size);
            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) {
                    // 对文件进行 CRC 校验,如果失败根据策略进行不同对处理
                    if (checkFileCRCValid()) {
                        loadFromFile = true;
                    } else {
                        // CRC 校验失败,如果策略是错误时恢复,则继续读取,并且最后需要进行回写
                        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
                MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
                // 如果需要解密,对文件进行解密
                if (m_crypter) {
                    decryptBuffer(*m_crypter, inputBuffer);
                }
                // 通过 MiniPBCoder 将 MMBuffer 转换为 Map
                m_dic.clear();
                MiniPBCoder::decodeMap(m_dic, inputBuffer);
                // 构造用于输出的 CodeOutputData
                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. 打开文件并获取文件大小,将文件的大小对齐到页的整数倍,不足则补 0(与内存映射的原理有关,内存映射是基于页的换入换出机制实现的)
  2. 通过 mmap 函数将文件映射到内存中,得到指向该区域的指针 m_ptr
  3. 对文件进行长度校验及 CRC 校验(循环冗余校验,可以校验文件完整性),在失败的情况下会根据当前策略进行抉择,如果策略是失败时恢复,则继续读取,并且在最后将 map 中的内容回写到文件。
  4. 通过 m_ptr 构造出一块用于管理 MMKV 映射内存的 MMBuffer 对象,如果需要解密,通过之前构造的 AESCrypt 进行解密。
  5. 由于 MMKV 使用了 protobuf 进行序列化 ,通过 MiniPBCoder::decodeMap 方法将 protobuf 转换成对应的 map。
  6. 构造用于输出的 CodedOutputData 类,如果需要回写(CRC 校验或文件长度校验失败),则调用 fullWriteback 方法将 map 中的数据回写到文件。

修改

数据写入

Java 层的 MMKV 对象继承了 SharedPreferencesSharedPreferences.Editor 接口并实现了一系列如 putIntputLong 的方法用于对存储的数据进行修改,我们以 putInt 为例:

@Override
public Editor putInt(String key, int value) {
    encodeInt(nativeHandle, key, value);
    return this;
}

它调用到了 encodeInt 这个 Native 方法:

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;
}

这里将 Java 层持有的 NativeHandle 转为了对应的 MMKV 对象,之后调用了其 setInt32 方法:

bool MMKV::setInt32(int32_t value, const std::string &key) {
    if (key.empty()) {
        return false;
    }
    // 构造值对应的 MMBuffer,通过 CodedOutputData 将其写入 Buffer
    size_t size = pbInt32Size(value);
    MMBuffer data(size);
    CodedOutputData output(data.getPtr(), size);
    output.writeInt32(value);
    return setDataForKey(std::move(data), key);
}

这里首先获取到了写入的 value 在 protobuf 中所占据的大小,之后为其构造了对应的 MMBuffer 并将数据写入了这段 Buffer,最后调用到了 setDataForKey 方法( std::move 是 C++ 11 的特性,我们可以简单理解成赋值,它通过直接移动内存减少了拷贝)。

同时可以发现 CodedOutputData 是与 Buffer 交互的桥梁,可以通过它实现向 MMBuffer 中写入数据。

bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
    if (data.length() == 0 || key.empty()) {
        return false;
    }
    SCOPEDLOCK(m_lock);
    SCOPEDLOCK(m_exclusiveProcessLock);
    // 确保数据已读入内存
    checkLoadData();

    // 将 data 写入 map 中
    auto itr = m_dic.find(key);
    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);
}

这里在确保数据已读入内存的情况下将 data 写入了对应的 map,之后调用了 appendDataWithKey 方法:

bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
    size_t keyLength = key.length();
    // 计算写入到映射空间中的 size
    size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
    size += data.length() + pbRawVarint32Size((int32_t) data.length());

    SCOPEDLOCK(m_exclusiveProcessLock);

    // 确定剩余映射空间足够
    bool hasEnoughSize = ensureMemorySize(size);

    if (!hasEnoughSize || !isFileValid()) {
        return false;
    }
    if (m_actualSize == 0) {
        auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
        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);
        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;
    }
}

这里首先计算了即将写入到映射空间的内容大小,之后调用了 ensureMemorySize 方法确保剩余映射空间足够。

如果 m_actualSize 为 0,则会通过 MiniPBCoder::encodeDataWithObject 将整个 map 转换为对应的 MMBuffer ,加密后通过 CodedOutputData 写入,最后重新计算 CRC 校验码。否则会将 key 和对应 data 写入,最后更新 CRC 校验码。

m_actualSize 是位于文件的首部的,因此是否为 0 取决于文件对应位置。

同时值得注意的是:由于 protobuf 不支持增量更新,为了避免全量写入带来的性能问题,MMKV 在文件中的写入并不是通过修改文件对应的位置,而是直接在后面 append 一条新的数据,即使是修改了已存在的 key。而读取时只记录最后一条对应 key 的数据,这样显然会在文件中存在冗余的数据。这样设计的原因我认为是出于性能的考量, MMKV 中存在着一套内存重整机制用于对冗余的 key-value 数据进行处理 。它正是在确保内存充足时实现的。

内存重整

我们接下来看看 ensureMemorySize 是如何确保映射空间是否足够的:

bool MMKV::ensureMemorySize(size_t newSize) {
    // ...
    if (newSize >= m_output->spaceLeft()) {
        // 如果内存剩余大小不足以写入,尝试进行内存重整,将 map 中的数据重新写入 protobuf 文件
        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);
            // 如果内存重整后仍不足以写入,则将大小不断乘2直至足够写入,最后通过 mmap 重新映射文件
            if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
                size_t oldSize = m_size;
                do {
                    // double 空间直至足够
                    m_size *= 2;
                } while (lenNeeded + futureUsage >= m_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 = 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;
                }
                                // unmap
                if (munmap(m_ptr, oldSize) != 0) {
                    MMKVError("fail to munmap [%s], %s", m_mmapID.c_str(), strerror(errno));
                }
                                // 重新通过 mmap 映射
                m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
                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) {
            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;
}

这里代码看起来也比较长,它对 MMKV 的内存重整进行了实现,步骤如下:

mmap

删除

通过 Java 层 MMKV 的 remove 方法可以实现删除操作:

@Override
public Editor remove(String key) {
    removeValueForKey(key);
    return this;
}

它调用了 removeValueForKey 这个 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);
    }
}

这里调用了 Native 层 MMKV 的 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;
}

这里实际上是构造了一条 size 为 0 的 MMBuffer 并调用 appendDataWithKey 将其 append 到 protobuf 文件中,并将 key 对应的内容从 map 中删除。读取时发现它的 size 为 0,则会认为这条数据已经删除。

读取

我们通过 getIntgetLong 等操作可以实现对数据的读取,我们以 getInt 为例:

@Override
public int getInt(String key, int defValue) {
    return decodeInt(nativeHandle, key, defValue);
}

它调用到了 decodeInt 这个 Native 方法:

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 方法:

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 方法获取到了 key 对应的 MMBuffer ,之后通过 CodedInputData 将数据读出并返回。可以发现,长度为 0 时会将其视为不存在,返回默认值。

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;
}

这里实际上是通过在 Map 中寻找从而实现,找不到会返回 size 为 0 的 Buffer。

文件回写

MMKV 中,在一些特定的情景下,会通过 fullWriteback 方法立即将 map 的内容回写到文件。

回写时机主要有以下几个:

MMKV.reKey
removeValuesForKeys
bool MMKV::fullWriteback() {
    if (m_hasFullWriteback) {
        return true;
    }
    if (m_needLoadFromFile) {
        return true;
    }
    if (!isFileValid()) {
        MMKVWarning("[%s] file not valid", m_mmapID.c_str());
        return false;
    }

    // 如果 map 空了,直接清空文件
    if (m_dic.empty()) {
        clearAll();
        return true;
    }

    // 将 m_dic 转换为对应的 MMBuffer
    auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
    SCOPEDLOCK(m_exclusiveProcessLock);
    if (allData.length() > 0) {
        if (allData.length() + Fixed32Size <= m_size) {
            // 如果足够写入,直接写入
            if (m_crypter) {
                m_crypter->reset();
                auto ptr = (unsigned char *) allData.getPtr();
                m_crypter->encrypt(ptr, ptr, allData.length());
            }
            writeAcutalSize(allData.length());
            delete m_output;
            m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
            m_output->writeRawData(allData); // note: don't write size of data
            recaculateCRCDigest();
            m_hasFullWriteback = true;
            return true;
        } else {
            // 如果剩余空间不够写入,调用 ensureMemorySize 从而进行内存重整与扩容
            return ensureMemorySize(allData.length() + Fixed32Size - m_size);
        }
    }
    return false;
}

这里首先在 map 为空的情况下,由于代表了所有数据已被删除,因此通过 clearAll 清除了文件与数据。

否则它会对当前映射空间是否足够写入 map 中回写的数据,如果足够则会将数据写入,否则会调用 ensureMemorySize 从而进行内存重整与扩容。

跨进程处理

本部分主要参考自官方文档: MMKV for Android 多进程设计与实现

跨进程锁的选择

SharedPreferences 在 Android 7.0 之后便不再对跨进程模式进行支持,原因是跨进程无法保证线程安全,而 MMKV 则通过了文件锁解决了这个问题。

其实本来是可以采用在共享内存中创建 pthread_mutex 实现两端的线程同步,但由于 Android 对 Linux 的部分机制进行了阉割,它无法保证获取锁的进程被杀死后,系统会对锁的信息进行清理。这就会导致等待锁的进程饿死。

因此 MMKV 采用了文件锁的设计,它的缺点在于不支持递归加锁,不支持锁的升级/降级,因此 MMKV 自行对这两个功能进行了实现。

文件锁封装

MMKV 中对文件锁的 递归锁锁升级/降级 机制进行了实现。

  • 递归锁
    若一个进程/线程已经拥有了锁,那么后续的加锁操作不会导致卡死,并且解锁也不会导致外层的锁被解掉。由于文件锁是基于状态的,没有计数器,因此在解锁时会导致外层的锁也被解掉。
  • 锁升级/降级
    锁升级是指将已经持有的共享锁,升级为互斥锁,也就是将读锁升级为写锁,锁降级则是反过来。文件锁支持锁升级,但是容易死锁:假如 A、B 进程都持有了读锁,现在都想升级到写锁,就会 发生死锁 。另外,由于文件锁不支持递归锁,也导致了锁降级一降就降到没有锁。

MMKV 对文件锁进行了封装,增加了读锁、写锁计数器,其封装位于 InterProcessLock.cpp ,个人没有对文件锁进行了解,这里就不再深究了,如果好奇可以看到上面提到的官方文档。

状态同步

跨进程共享 MMKV 文件面临着状态同步问题: 写指针同步内存重整同步内存增长同步 ,其中对于写指针同步,它通过在文件头部保存了有效内存的大小 m_actualSize ,每次都对其进行比较从而实现。而内存重整同步则是通过使用一个单调递增的序列号 m_sequence 进行比较,每进行一次内存重整将其 + 1从而实现。对于内存增长的同步,是通过文件大小的比较从而实现。

MMKV 中的状态同步通过 checkLoadData 方法实现:

void MMKV::checkLoadData() {
    if (m_needLoadFromFile) {
        SCOPEDLOCK(m_sharedProcessLock);

        m_needLoadFromFile = false;
        loadFromFile();
        return;
    }
    if (!m_isInterProcess) {
        return;
    }

    // TODO: atomic lock m_metaFile?
    MMKVMetaInfo metaInfo;
    metaInfo.read(m_metaFile.getMemory());
    if (m_metaInfo.m_sequence != metaInfo.m_sequence) {
        // 序列号不同,说明发生了内存重整,清空后重新加载
        MMKVInfo("[%s] oldSeq %u, newSeq %u", m_mmapID.c_str(), m_metaInfo.m_sequence,
                 metaInfo.m_sequence);
        SCOPEDLOCK(m_sharedProcessLock);

        clearMemoryState();
        loadFromFile();
    } else if (m_metaInfo.m_crcDigest != metaInfo.m_crcDigest) {
        // CRC 不同,说明发生了改变
        MMKVDebug("[%s] oldCrc %u, newCrc %u", m_mmapID.c_str(), m_metaInfo.m_crcDigest,
                  metaInfo.m_crcDigest);
        SCOPEDLOCK(m_sharedProcessLock);

        size_t fileSize = 0;
        if (m_isAshmem) {
            fileSize = m_size;
        } else {
            struct stat st = {0};
            if (fstat(m_fd, &st) != -1) {
                fileSize = (size_t) st.st_size;
            }
        }
        if (m_size != fileSize) {
            // 如果 size 相同,说明发生了文件增长
            MMKVInfo("file size has changed [%s] from %zu to %zu", m_mmapID.c_str(), m_size,
                     fileSize);
            clearMemoryState();
            loadFromFile();
        } else {
            // size 相同,说明需要进行写指针同步,只需要部分进行loadFile
            partialLoadFromFile();
        }
    }
}

可以看到,除了写指针同步的情况,其余情况都是重新读取文件实现同步。

总结

MMKV 是一个基于 mmap 实现的 K-V 存储工具,它的序列化基于 protobuf 实现,引入了 CRC 校验从而对文件完整性进行校验,并且它支持了通过 AES 算法对 protobuf 文件进行加密。

  • MMKV 的 初始化 过程主要完成了对 rootDir 的初始化及创建,它位于应用的内部存储 file 下的 mmkv 文件夹。
  • MMKV 的 获取 需要通过 mmapWithID 完成,它会结合传入的 mmapIdrelativePath 通过 md5 生成一个唯一的 mmapKey ,通过它查找 map 获取对应的 MMKV 实例,若找不到对应的实例会构建一个新的 MMKV 对象。Java 层通过持有 Native 层对象的地址从而实现与 Native 对象进行通信。
  • 在 MMKV 对象创建时,会创建用于 AES 加密的 AESCrypt 对象,并且会调用 loadFromFile 方法将文件的内容通过 mmap 映射到内存中,映射会以页的整数倍进行,若不足的地方会补 0。映射完成后会构造对应的 MMBuffer 对映射区域进行管理并创建对应的 CodedOutputData 对象,之后会通过 MiniPBCoder 将其读入到 m_dic 这个 Map 中,它以 String 为 key, MMBuffer 为 value。
  • MMKV 在 数据写入 前会调用 checkLoadData 方法确保数据已读入并且对跨进程的信息进行同步,之后会将数据转换为 MMBuffer 对象并写入 map 中,然后调用 ensureMemorySize 确保映射空间足够的情况下,通过 构造 MMKV 对象时创建的 CodedOutputData 将数据写入 protobuf 文件。并且 MMKV 的数据更新和写入都是通过在文件后进行 append,会造成存在冗余 key-value 数据。
  • ensureMemorySize 方法在内存不足的情况下首先进行内存重整,它会清空文件,从 map 重新将数据写入文件,从而清理冗余数据,如果仍然不够则会以每次两倍对文件大小进行扩容,并重新通过 mmap 进行映射。

  • MMKV 的 删除 操作实际上是通过在文件中对同样的 key 写入长度为 0 的 MMBuffer 实现,当读取时发现其长度为 0,则将其视为已删除。
  • MMKV 的 读取 是通过 CodedInputData 实现,它在读如的 MMBuffer 长度为 0 时会将其视为不存在。 实际上 CodedInputDataCodedOutputData 就是与 MMBuffer 进行交互的桥梁
  • MMKV 还存在着文件回写机制,在以下的时机会将 map 中的数据立即写入文件,空间不足则会进行内存重整:
    MMKV.reKey
    removeValuesForKeys
    
  • MMKV 对跨进程读写进行了支持,它通过文件锁实现跨进程加锁,并且通过对文件锁引入读锁和写锁的计数,从而解决了其存在的不支持 递归锁锁升级/降级 问题。
  • 不使用 pthread_mutex 通过共享内存加锁的原因是 Android 对 Linux 进行了阉割,如果持有锁的进程被杀死无法保证清除锁的信息,可能导致等待锁的其他进程饿死。
  • MMKV 解决了 写指针同步内存重整同步 以及 内存增长同步 问题,写指针同步通过在文件的起始处添加一个写指针值,在 checkLoadData 中会对它进行比较,从而获取最新的写指针 m_actualSize ,而内存重整同步通过一个序号 m_sequence 来实现,每当发生一次内存重整对其 + 1,通过比较即可确定。而内存增长同步则通过比较文件大小实现。

参考资料

https://github.com/Tencent/MMKV/blob/master/readme_cn.md

https://github.com/Tencent/MMKV/wiki/design

https://github.com/Tencent/MMKV/wiki/android_ipc

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章