比 SharedPreferences 更高效?微信 MMKV 源码解析
- 2020 年 3 月 9 日
- 筆記
公众号回复:OpenGL ,领取学习资源大礼包
作者:N0tExpectErr0r 原文链接:https://xiaozhuanlan.com/topic/1709584362 本文基于 MMKV 1.0.16,关于 MMKV 的编译可以阅读这篇文档:https://github.com/Tencent/MMKV/wiki/android_setup
新媒体排版
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; }
这里的步骤如下:
- 通过
mmapedKVKey
方法对mmapID
及relativePath
进行结合生成了对应的mmapKey
,它会将它们两者的结合经过 md5 从而生成对应的 key,主要目的是为了支持不同相对路径下的同名mmapID
。 - 通过
mmapKey
在g_instanceDic
这个 map 中查找对应的 MMKV 对象,如果找到直接返回。 - 如果找不到对应的 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; }
这里的代码虽然长,但逻辑还是非常清晰的,步骤如下:
- 打开文件并获取文件大小,将文件的大小对齐到页的整数倍,不足则补 0(与内存映射的原理有关,内存映射是基于页的换入换出机制实现的)
- 通过
mmap
函数将文件映射到内存中,得到指向该区域的指针m_ptr
。 - 对文件进行长度校验及 CRC 校验(循环冗余校验,可以校验文件完整性),在失败的情况下会根据当前策略进行抉择,如果策略是失败时恢复,则继续读取,并且在最后将 map 中的内容回写到文件。
- 通过
m_ptr
构造出一块用于管理 MMKV 映射内存的MMBuffer
对象,如果需要解密,通过之前构造的AESCrypt
进行解密。 - 由于 MMKV 使用了 protobuf 进行序列化,通过
MiniPBCoder::decodeMap
方法将 protobuf 转换成对应的 map。 - 构造用于输出的
CodedOutputData
类,如果需要回写(CRC 校验或文件长度校验失败),则调用fullWriteback
方法将 map 中的数据回写到文件。
修改
数据写入
Java 层的 MMKV 对象继承了 SharedPreferences
及 SharedPreferences.Editor
接口并实现了一系列如 putInt
、putLong
的方法用于对存储的数据进行修改,我们以 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 的内存重整进行了实现,步骤如下:
- 当剩余映射空间不足以写入需要写入的内容,尝试进行内存重整
- 内存重整会将文件清空,将 map 中的数据重新写入文件,从而去除冗余数据
- 若内存重整后剩余映射空间仍然不足,不断将映射空间 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,则会认为这条数据已经删除。
读取
我们通过 getInt
、getLong
等操作可以实现对数据的读取,我们以 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
方法修改加密的 key。 - 删除一系列的 key 时(通过
removeValuesForKeys
方法) - 读取文件时文件校验或 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
Tag 由 field_number
和 wire_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
用到,其余的 Varint
、64-bit
、32-bit
等都不需要 Length 字段。
Varints 编码
Varints 编码是一种可变长的 int 编码,它的编码规则如下:
- 第一位标明了是否需要读取下一字节
- 存储了数值的补码,且低位在前高位在后。
解码过程
可以简单模拟一下解码的过程,我们接收到一串二进制数据,我们可以先读取一个 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:设置文件锁(阻塞),阻塞等到设置成功
文件锁存在着一定缺点:
- 不支持递归加锁(重入锁):如果我们重复加锁会导致阻塞,如果我们解锁会把所有的锁都给解除。
- 存在着死锁问题:如果我们两个进程同时将读锁升级为死锁,可能会陷入互相等待从而发生死锁。
文件锁封装
MMKV 中对文件锁的递归锁和锁升级/降级机制进行了实现。
- 递归锁(可重入) 若一个进程/线程已经拥有了锁,那么后续的加锁操作不会导致卡死,并且解锁也不会导致外层的锁被解掉。由于文件锁是基于状态的,没有计数器,因此在解锁时会导致外层的锁也被解掉。
- 锁升级/降级锁升级是指将已经持有的共享锁,升级为互斥锁,也就是将读锁升级为写锁,锁降级则是反过来。文件锁支持锁升级,但是容易死锁:假如 A、B 进程都持有了读锁,现在都想升级到写锁,就会发生死锁。另外,由于文件锁不支持递归锁,也导致了锁降级一降就降到没有锁。
MMKV 中基于文件锁实现了上述的递归锁以及锁的升级、降级功能。
加锁
调用 FileLock.lock
或 FileLock.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
完成,它会结合传入的mmapId
与relativePath
通过 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 时会将其视为不存在。实际上CodedInputData
与CodedOutputData
就是与MMBuffer
进行交互的桥梁。 - MMKV 还存在着文件回写机制,在以下的时机会将 map 中的数据立即写入文件,空间不足则会进行内存重整:
- 通过
MMKV.reKey
方法修改加密的 key。 - 删除一系列的 key 时(通过
removeValuesForKeys
方法) - 读取文件时文件校验或 CRC 校验失败。
- 通过
- 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