比 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