比 SharedPreferences 更高效?微信 MMKV 源码解析

6,740 阅读27分钟

你好,我是 N0tExpectErr0r,一名热爱技术的 Android 开发

我的个人博客:blog.N0tExpectErr0r.cn

本文基于 MMKV 1.0.16,关于 MMKV 的编译可以阅读这篇文档:github.com/Tencent/MMK…

MMKV 是微信于 2018 年 9 月 20 日开源的一个 K-V 存储库,它与 SharedPreferences 相似,但又在更高的效率下解决了其不支持跨进程读写等弊端。

一年前的自己因对它非常感兴趣写下了一篇 【Android】 MMKV 源码浅析。不过由于当时还是大二,知识的储备还不够丰富,因此整体的分析在某些细节上还比较稚嫩。由于对这个库很感兴趣,因此尝试重新对它进行一次源码解析,对以前分析不够到位的地方进行补充,并且将以前没有研究的部分细致研究一下。

初始化

通过 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 的内存重整进行了实现,步骤如下:

  1. 当剩余映射空间不足以写入需要写入的内容,尝试进行内存重整
  2. 内存重整会将文件清空,将 map 中的数据重新写入文件,从而去除冗余数据
  3. 若内存重整后剩余映射空间仍然不足,不断将映射空间 double 直到足够,并用 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 的内容回写到文件。

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

  1. 通过 MMKV.reKey 方法修改加密的 key。
  2. 删除一系列的 key 时(通过 removeValuesForKeys 方法)
  3. 读取文件时文件校验或 CRC 校验失败。
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 从而进行内存重整与扩容。

Protobuf 处理

Protobuf 编码

在我们开始对 Protobuf 部分代码进行研究前,让我们先研究一下 Protobuf 编码的格式。

Protobuf 采用了一种 TLV(Tag-Length-Value)的格式进行编码,其格式如下:

可以看到,每条字段都由 Tag、Length、Value 三部分组成,其中Length 是可选的

Tag

image-20200229101120808

Tag 由 field_numberwire_type 两部分组成,其中:

  • field_number:字段编号
  • wire_type:protobuf 编码类型

并且 Tag 采用了 Varints 编码,它是一种可变长的 int 编码(类似 dex 文件的 LEB128)。

wire_type 共有 3 位,可以存放 8 种编码格式,目前已经实现了如下 6 种:

含义 用途
0 Varint 可变整型
1 64-bit 固定 64 位
2 Length-delimited string、bytes 等
3 Start group(已废弃) group 开始
4 End group(已废弃) group 结束
5 32-bit 固定 32 位

可以发现,Start group 与 End group 已经废弃,对于 Length 这个字段,只有 Length-delimited 用到,其余的 Varint64-bit32-bit 等都不需要 Length 字段。

Varints 编码

Varints 编码是一种可变长的 int 编码,它的编码规则如下:

  1. 第一位标明了是否需要读取下一字节
  2. 存储了数值的补码,且低位在前高位在后。

解码过程

可以简单模拟一下解码的过程,我们接收到一串二进制数据,我们可以先读取一个 Varints 编码块,其后面 3 位为 wire_type,而前面的表示 field_number。之后它会根据 wire_type 来决定是根据 Length 读取固定大小的 Value 还是采用 Varint 等方式读取后面的 Value。

Protobuf 实现

在 MMKV 中通过 MiniPBCoder 完成了 Protobuf 的序列化及反序列化。我们可以通过 MiniPBCoder::decodeMap 将 MMKV 存储的 protobuf 文件反序列化为对应的 Map,可以通过 MiniPBCoder::encodeDataWithObject 将 Map 序列化为对应存储的字节流。

序列化

我们先看看它是如何完成序列化的过程的:

static MMBuffer encodeDataWithObject(const T &obj) {
    MiniPBCoder pbcoder;
    return pbcoder.getEncodeData(obj);
}

它调用到了 getEncodeData 方法,并传入了对应的 Map:

MMBuffer MiniPBCoder::getEncodeData(const unordered_map<string, MMBuffer> &map) {
    m_encodeItems = new vector<PBEncodeItem>();
    // 准备 PBEncodeItem 数组
    size_t index = prepareObjectForEncode(map);
    PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
    if (oItem && oItem->compiledSize > 0) {
        m_outputBuffer = new MMBuffer(oItem->compiledSize);
        m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());

        writeRootObject();
    }

    return std::move(*m_outputBuffer);
}

可以看到,它首先通过 prepareObjectForEncode 方法将 Map 中的键值对转为了对应的 PBEncodeItem 对象数组,之后构造了对应的用于写入的 CodedOutputData 以及写入的 m_outputBuffer,然后调用了 writeRootObject 方法将数据通过 CodedOutputData 写入到 m_outputBuffer 中。

PBEncodeItem 数组的准备

我们先看到 prepareObjectForEncode 方法:

size_t MiniPBCoder::prepareObjectForEncode(const unordered_map<string, MMBuffer> &map) {
  	// 放入一个新的 EncodeItem
    m_encodeItems->push_back(PBEncodeItem());
    // 获取刚刚的 Item 以及其对应的 index  
    PBEncodeItem *encodeItem = &(m_encodeItems->back());
  	size_t index = m_encodeItems->size() - 1;
    {
      	// 将该 EncodeItem 作为一个 Container
        encodeItem->type = PBEncodeItemType_Container;
        encodeItem->value.strValue = nullptr;

      	// 遍历 Map
        for (const auto &itr : map) {
            const auto &key = itr.first;
            const auto &value = itr.second;
            if (key.length() <= 0) {
                continue;
            }

         		// 将 key 作为一个 EncodeItem 放入数组
            size_t keyIndex = prepareObjectForEncode(key);
            if (keyIndex < m_encodeItems->size()) {
              	// 将 value 作为一个 EncodeItem 放入数组
                size_t valueIndex = prepareObjectForEncode(value);
                if (valueIndex < m_encodeItems->size()) {
                  	// 计算 container 添加 key 和 value 后的 size
                    (*m_encodeItems)[index].valueSize += (*m_encodeItems)[keyIndex].compiledSize;
                    (*m_encodeItems)[index].valueSize += (*m_encodeItems)[valueIndex].compiledSize;
                } else {
                    m_encodeItems->pop_back(); // pop key
                }
            }
        }

        encodeItem = &(*m_encodeItems)[index];
    }
    encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;

    return index;
}

可以看到,这里实际上会首先在 m_encodeItems 数组中先放入一个作为 Container 的 PBEncodeItem,之后遍历 Map,对每个 Key 和 Value 分别构建对应的 PBEncodeItem 并放入,并且将其 size 计算入 Container 的 valueSize。最后会返回该 Container 的 index。

  • 对于 Key 其会写入一个 String 类型的 PBEncodeItem

  • 对于 Value 其会写入一个 Data 类型存储 MMBuffer 的 PBEncodeItem

将数据写入 MMBuffer

接着我们看看它是如何实现将数据写入的,我们看到 writeRootObject 方法:

void MiniPBCoder::writeRootObject() {
    for (size_t index = 0, total = m_encodeItems->size(); index < total; index++) {
        PBEncodeItem *encodeItem = &(*m_encodeItems)[index];
        switch (encodeItem->type) {
            case PBEncodeItemType_String: {
                m_outputData->writeString(*(encodeItem->value.strValue));
                break;
            }
            case PBEncodeItemType_Data: {
                m_outputData->writeData(*(encodeItem->value.bufferValue));
                break;
            }
            case PBEncodeItemType_Container: {
                m_outputData->writeRawVarint32(encodeItem->valueSize);
                break;
            }
            case PBEncodeItemType_None: {
                MMKVError("%d", encodeItem->type);
                break;
            }
        }
    }
}

这里的实现非常简单,根据是 String 类型还是 Data 类型还是 Container 类型,分别写入 String、MMBuffer 以及 Varint32。其中 Container 写入的就是后面的 size 大小。

因此写入到文件后文件最后的格式如下:

反序列化

我们可以通过 MiniPBCoder.decodeMap 将其反序列化为 Map,我们可以看看它是如何实现的:

void MiniPBCoder::decodeMap(unordered_map<string, MMBuffer> &dic,
                            const MMBuffer &oData,
                            size_t size) {
    MiniPBCoder oCoder(&oData);
    oCoder.decodeOneMap(dic, size);
}

它调用到了 decodeOnMap 方法:

void MiniPBCoder::decodeOneMap(unordered_map<string, MMBuffer> &dic, size_t size) {
    if (size == 0) {
        auto length = m_inputData->readInt32();
    }
    while (!m_inputData->isAtEnd()) {
        const auto &key = m_inputData->readString();
        if (key.length() > 0) {
            auto value = m_inputData->readData();
            if (value.length() > 0) {
                dic[key] = move(value);
            } else {
                dic.erase(key);
            }
        }
    }
}

可以看到,它的实现非常简单,先读取了一个 Varint32 的 valueSize,之后不断通过 CodedInputData 分别读取 key 和 value,这对我们前面的猜想进行了印证,并且当遇到 Length 为 0 的 value 时,会将对应的项删掉。

跨进程锁实现

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

跨进程锁的选择

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

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

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

文件锁

文件锁是 Linux 中基于文件实现的跨进程锁,我们需要维护一个 flock 结构体,它的结构如下:

struct flock {
		short l_type; */\* Type of lock: F_RDLCK, F_WRLCK, F_UNLCK \*/*
		short l_whence; */\* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END \*/*
		off_t l_start; */\* Starting offset for lock \*/*
		off_t l_len; */\* Number of bytes to lock \*/*
		pid_t l_pid; */\* PID of process blocking our lock (F_GETLK only) \*/*
};

其中我们重点关注 l_type,它表达了锁的类型,它有三种状态:

  • F_RDLOCK:也就是读锁,是一种共享锁
  • F_WRLOCK:也就是写锁,是一种互斥锁
  • F_UNLOCK:也就是无锁,代表要对其进行解锁

我们通过 fcntl 函数可以提交对 flock 的修改:

int fcntl(int fd, int cmd, struct flock lock)

其中 fd 也就是文件描述符,cmd 表达了要进行的操作,flock 表示 flock 结构体,它里面包含了对锁进行操作的类型。

cmd 有以下三种取值:

  • F_GETLK:获取文件锁
  • F_SETLK:设置文件锁(非阻塞),设置不成功直接返回
  • F_SETLKW:设置文件锁(阻塞),阻塞等到设置成功

文件锁存在着一定缺点:

  1. 不支持递归加锁(重入锁):如果我们重复加锁会导致阻塞,如果我们解锁会把所有的锁都给解除。
  2. 存在着死锁问题:如果我们两个进程同时将读锁升级为死锁,可能会陷入互相等待从而发生死锁。

文件锁封装

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

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

MMKV 中基于文件锁实现了上述的递归锁以及锁的升级、降级功能。

加锁

调用 FileLock.lockFileLock.try_lock 方法会调用到 FileLock.doLock 方法,他们两者的区别是前者是阻塞式获取锁,会等待到锁的释放,后者则是非阻塞式获取锁。在 FileLock.doLock 中完成了锁的获取:

bool FileLock::doLock(LockType lockType, int cmd) {
    bool unLockFirstIfNeeded = false;

		// 加读锁(共享锁)
    if (lockType == SharedLockType) {
        // 读锁数量++
        m_sharedLockCount++;
        // 有其他锁的情况下,不需要真正再加一次锁
        if (m_sharedLockCount > 1 || m_exclusiveLockCount > 0) {
            return true;
        }
    } else {
        m_exclusiveLockCount++;
        // 之前加过写锁,则不需要再重新加锁
        if (m_exclusiveLockCount > 1) {
            return true;
        }
        // 要加写锁,如果已经存在读锁,可能是其他进程获取的,如果是则需要先将自己的读锁释放掉,再加写锁
        if (m_sharedLockCount > 0) {
            unLockFirstIfNeeded = true;
        }
    }

		// 加读锁或写锁获取到的锁类型 F_RDLCK 或 F_WRLCK
    m_lockInfo.l_type = LockType2FlockType(lockType);
    if (unLockFirstIfNeeded) {
        // 如果已经存在读锁,先看看能否获取写锁
        auto ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
        if (ret == 0) {
            return true;
        }
        // 不能获取写锁说明其他线程获取了读锁,则将自己的读锁释放避免死锁
        auto type = m_lockInfo.l_type;
        // 执行解锁
        m_lockInfo.l_type = F_UNLCK;
        ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
        if (ret != 0) {
            MMKVError("fail to try unlock first fd=%d, ret=%d, error:%s", m_fd, ret,
                      strerror(errno));
        }
        m_lockInfo.l_type = type;
    }
		// 执行对应的加锁(读锁或写锁)
    auto ret = fcntl(m_fd, cmd, &m_lockInfo);
    if (ret != 0) {
        MMKVError("fail to lock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
        return false;
    } else {
        return true;
    }
}

可以看到,上面的步骤对于写锁而言,在加写锁时,如果当前进程持有了读锁,那我们需要尝试加写锁。如果加写锁失败说明其他线程持有了读锁,我们需要将目前的读锁释放掉,再加写锁,从而避免死锁(这种情况说明两个进程的读锁都想升级为写锁)

同时可以发现,MMKV 中通过维护了 m_sharedLockCount 以及 m_exclusiveLockCount 从而实现了递归加锁,如果存在其他锁时,就不再需要真正第二次加锁了。

解锁

通过 FileLock.unlock 可以完成对锁的解锁:

bool FileLock::unlock(LockType lockType) {
    bool unlockToSharedLock = false;
		
    if (lockType == SharedLockType) {
        if (m_sharedLockCount == 0) {
            return false;
        }
      	m_sharedLockCount--;
        // 解读锁,只需要减少 count 即可,如果此时存在其他的锁就不需要真正解锁了
        if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) {
            return true;
        }
    } else {
        if (m_exclusiveLockCount == 0) {
            return false;
        }
      	// 解写锁
        m_exclusiveLockCount--;
        if (m_exclusiveLockCount > 0) {
            return true;
        }
        // 如果之前我们是存在写锁的,则只是降级为读锁,因为我们之前将读锁升级为了写锁
        if (m_sharedLockCount > 0) {
            unlockToSharedLock = true;
        }
    }

    m_lockInfo.l_type = static_cast<short>(unlockToSharedLock ? F_RDLCK : F_UNLCK);
    auto ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
    if (ret != 0) {
        MMKVError("fail to unlock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
        return false;
    } else {
        return true;
    }
}

在解锁时,对于解写锁时,如果我们的写锁是由读锁升级而来,则不会真的进行解锁,而是改为加读锁,从而实现将写锁降级为读锁(因为读锁还没解除)

状态同步

跨进程共享 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 中的数据立即写入文件,空间不足则会进行内存重整:
    1. 通过 MMKV.reKey 方法修改加密的 key。
    2. 删除一系列的 key 时(通过 removeValuesForKeys 方法)
    3. 读取文件时文件校验或 CRC 校验失败。
  • MMKV 对跨进程读写进行了支持,它通过文件锁实现跨进程加锁,并且通过对文件锁引入读锁和写锁的计数,从而解决了其存在的不支持递归锁锁升级/降级问题。不使用 pthread_mutex 通过共享内存加锁的原因是 Android 对 Linux 进行了阉割,如果持有锁的进程被杀死无法保证清除锁的信息,可能导致等待锁的其他进程饿死。
  • 加写锁时,如果当前进程持有了读锁,那我们需要尝试将其升级为写锁。如果升级写锁失败说明其他线程持有了读锁,我们需要将当前进程的读锁释放掉,再加写锁,从而避免死锁(这种情况说明两个进程的读锁都想升级为写锁)。
  • 解写锁时,如果我们的写锁是由读锁升级而来,则不会真的进行解锁,而是改为加读锁,从而实现将写锁降级为读锁(因为读锁还没解除)。
  • MMKV 解决了写指针同步内存重整同步以及内存增长同步问题,写指针同步通过在文件的起始处添加一个写指针值,在 checkLoadData 中会对它进行比较,从而获取最新的写指针 m_actualSize,而内存重整同步通过一个序号 m_sequence 来实现,每当发生一次内存重整对其 + 1,通过比较即可确定。而内存增长同步则通过比较文件大小实现。

参考资料

github.com/Tencent/MMK…

github.com/Tencent/MMK…

github.com/Tencent/MMK…