比 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