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

公眾號回復: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;  }

這裡的步驟如下:

  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

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,通過比較即可確定。而記憶體增長同步則通過比較文件大小實現。

參考資料

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