文章目录
- 前言
- 一、MMKV简介
- 1.mmap
- 2.protobuf
- 二、MMKV 源码详解
- 1.MMKV初始化
- 2.MMKV对象获取
- 3.文件摘要的映射
- 4.loadFromFile 从文件加载数据
- 5.数据写入
- 6.内存重整
- 7.数据读取
- 8.数据删除
- 9.文件回写
- 10.Protobuf 实现
- 1.序列化
- 2.反序列化
- 12.文件锁
- 1.加锁
- 2.解锁
- 13.状态同步
- 总结
- 参考文献
前言
谈到轻量级的数据持久化,在 Android 开发过程中,大家首先想到的应该就是 SharedPreferences(以下简称 SP),其存储数据是以 key-value 键值对的形式保存在 data/data/<package name>/shared_prefs 路径下的 xml 文件中,使用 I/O 流 进行文件的读写。通常用来保存应用中的一些简单的配置信息,如用户名、密码、自定义参数的设置等。
需要注意的是:SP 中的 value 值只能是 int、boolean、float、long、String、StringSet 这些类型的数据。
作为 Android 原生库中自带的轻量级存储类,SP 在使用方式上还是很便捷的,但是也存在以下的一些问题:
- 通过 getSharedPreferences() 方法获取 SP 实例对象,从首次初始化到读到数据会存在延迟,因为读文件操作需阻塞调用的线程直到文件读取完毕,因此不要在主线程调用,可能会对 UI 界面的流畅度造成影响。(线程阻塞)
- SP 在跨进程共享方面无法保证线程安全,因此在 Android 7.0 之后便不再对跨进程模式进行支持。(跨进程共享)
- 将数据写入到 xml 文件需要经过两次数据拷贝,如果数据量过大,将会有很大的性能损耗,效率不高。(两次拷贝)
为了解决上述问题,腾讯的微信团队基于 MMAP 研发了 MMKV 来代替 SP。
一、MMKV简介
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。支持通过 AES 算法对 protobuf 文件进行加密,并且引入 循环冗余校验码(CRC) 对文件的完整性进行校验。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。近期也已移植到 Android / macOS / Windows/ POSIX 平台,并且开源。
1.mmap
mmap 是 memory map 的缩写,也就是内存映射或地址映射,是 Linux 操作系统中的一种系统调用,它的作用是将一个文件或者其它对象映射到进程的地址空间,实现磁盘地址和进程虚拟地址空间一段虚拟地址的一一对应关系。通过 mmap 这个系统调用我们可以让进程之间通过映射到同一个普通文件实现共享内存,普通文件被映射到进程地址空间当中后,进程可以像访问普通内存一样对文件进行一系列操作,而不需要通过 I/O 系统调用来读取或写入。
mmap 函数 声明如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
函数各个参数的含义如下:
- addr:待映射的虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),通常设置成 NULL,意思就是完全交由内核来帮我们决定虚拟映射区的起始地址(要按照 PAGE_SIZE(4K) 对齐)。
- length:待申请映射的内存区域的大小,如果是匿名映射,则是要映射的匿名物理内存有多大,如果是文件映射,则是要映射的文件区域有多大(要按照 PAGE_SIZE(4K) 对齐)。
- prot:映射区域的保护模式。有 PROT_READ、PROT_WRITE、PROT_EXEC等。
- flags:标志位,可以控制映射区域的特性。常见的有 MAP_SHARED 和 MAP_PRIVATE 等。
- fd:文件描述符,用于指定映射的文件 (由 open( ) 函数返回)。
- offset:映射的起始位置,表示被映射对象 (即文件) 从那里开始对映,通常设置为 0,该值应该为大小为PAGE_SIZE(4K)的整数倍。
mmap 函数会将一个文件或其他对象映射到进程的地址空间中,并返回一个指向映射区域的指针,进程可以使用指针来访问映射区域的数据,就像访问内存一样。关于 mmap 的映射原理及源码分析,有兴趣的同学可看一下这篇文章。
2.protobuf
Protocol Buffers 简称:Protobuf,是 Google 提供的一个具有高效的协议数据交换格式工具库,用于高效地序列化和反序列化结构化数据,通常用于网络通信、数据存储等场景。
Protobuf 和 Xml、Json 序列化的方式不同,采用了二进制字节的序列化方式,用字段索引和字段类型通过算法计算得到字段之间的关系映射,从而达到更高的时间效率和空间效率,特别适合对数据大小和传输速率比较敏感的场合使用。
Protobuf 采用了一种 TLV (Tag-Length-Value) 的格式进行编码,其格式如下:
由图可知,每条字段都由 Tag、Length、Value 三部分组成,其中 Length 是可选的。Tag 字段又由 field_number 和 wire_type 两部分组成,其中:
- field_number:message 定义字段时指定的字段编号;
- wire_type:Protobuf 编码类型,由三位 bit 构成,故能表示 8 种类型的编码方案,目前已定义 6 种,其中两种已被废弃。
并且 Tag 采用了 Varints 编码,这是一种可变长的 int 编码(类似 dex 文件的 LEB128),其编码规则如下:
- 第一位标明了是否需要读取下一字节;
- 存储了数值的补码,且低位在前高位在后。
Protobuf 的主要优点包括:
- 高效性:Protobuf 序列化后的二进制数据通常比其他序列化格式(比如常用的 JSON)更小,并且序列化和反序列化的速度更快,这对于性能敏感的应用非常有益;
- 简洁性:Protobuf 使用一种定义消息格式的语法,它允许定义字段类型、顺序和规则(消息结构更加清晰和简洁);
- 版本兼容性:Protobuf 支持向前和向后兼容的版本控制,使得在消息格式发生变化时可以更容易地处理不同版本的通信;
- 语言无关性:Protobuf 定义的消息格式可以在多种编程语言中使用,这有助于跨语言的通信和数据交换(截至本文发布目前官方支持的有C++/C#/Dart/Go/Java/Kotlin/python);
- 自动生成代码:Protobuf 通常与相应的工具一起使用,可以自动生成代码,包括序列化/反序列化代码和相关的类(减少了手动编写代码的工作量,提高效率)。
在 MMKV 中通过 MiniPBCoder 完成了 Protobuf 的序列化及反序列化。可以通过 MiniPBCoder::decodeMap 将 MMKV 存储的 Protobuf 文件反序列化为对应的 map,也可以通过 MiniPBCoder::encodeDataWithObject 将 map 序列化为对应存储的字节流。
二、MMKV 源码详解
1.MMKV初始化
通过 MMKV.initialize 方法可以实现 MMKV 的初始化:
public class MMKV implements SharedPreferences, SharedPreferences.Editor {// call on program start 程序启动时调用public static String initialize(Context context) {// 使用内部存储空间下的 mmkv 文件夹作为根目录String root = context.getFilesDir().getAbsolutePath() + "/mmkv";// 继续调用 initialize 方法传入根目录 root 进行初始化return initialize(root, null);}// 记录 mmkv 存储使用的根目录static private String rootDir = null; public static String initialize(String rootDir, LibLoader loader) {...... // 省略MMKV.rootDir = rootDir; // 保存根目录// Native 层初始化jniInitialize(MMKV.rootDir);return rootDir;}// JNI 调用到 Native 层继续初始化private static native void jniInitialize(String rootDir);
}
MMKV 的初始化,主要是将存储根目录通过 jniInitialize 传入到 Native 层,接下来看看 Native 层的初始化操作:
// native-bridge.cpp
namespace mmkv { // mmkv 命名空间MMKV_JNI void jniInitialize(JNIEnv *env, jobject obj, jstring rootDir) {if (!rootDir) { // 如果根目录为空则直接返回return;}// 将 jstring 类型转化为 c 中的 const char * 类型const char *kstr = env->GetStringUTFChars(rootDir, nullptr);if (kstr) {// 调用 MMKV::initializeMMKV 对 MMKV 进行初始化MMKV::initializeMMKV(kstr);// c 和 c++ 与 Java 不同,用完需主动释放掉env->ReleaseStringUTFChars(rootDir, kstr);}
}
}// MMKV.cpp
static unordered_map<std::string, MMKV *> *g_instanceDic;
static ThreadLock g_instanceLock;
static std::string g_rootDir;void initialize() {// 获取一个 unordered_map, 类似于 Java 中的 HashMapg_instanceDic = new unordered_map<std::string, MMKV *>;// 初始化线程锁g_instanceLock = ThreadLock();g_instanceLock->initialize();......
}void MMKV::initializeMMKV(const std::string &rootDir) {// 由 Linux Thread 互斥锁和条件变量保证 initialize 函数在一个进程内只会执行一次static pthread_once_t once_control = PTHREAD_ONCE_INIT;// 回调 initialize() 方法进行初始化操作pthread_once(&once_control, initialize);// 将根目录保存到全局变量g_rootDir = rootDir;// 字符串拷贝库函数,这里是防止根目录被修改字符串的内容,因此拷贝副本使用char *path = strdup(g_rootDir.c_str());if (path) {// 根据路径, 生成目标地址的目录mkPath(path);free(path); // 释放内存}
}
可以看到 initializeMMKV 的主要任务是初始化数据,以及创建根目录:
- 创建 MMKV 对象的缓存散列表 g_instanceDic;
- 创建一个线程锁 g_instanceLock;
- mkPath 根据字符串创建文件目录。
pthread_once_t: 类似于 Java 的单例,其 initialize 方法在进程内只会执行一次。
// MmapedFile.cpp
bool mkPath(char *path) {// 定义 stat 结构体用于描述文件的属性struct stat sb = {};bool done = false;// 指向字符串起始地址char *slash = path;while (!done) {// 移动到第一个非 "/" 的下标处slash += strspn(slash, "/");// 移动到第一个 "/" 下标出处slash += strcspn(slash, "/");done = (*slash == '\0');*slash = '\0';if (stat(path, &sb) != 0) {// 执行创建文件夹的操作, C 中无 mkdirs 的操作, 需要一个一个文件夹的创建if (errno != ENOENT || mkdir(path, 0777) != 0) {MMKVWarning("%s : %s", path, strerror(errno));return false;}}// 若非文件夹, 则说明为非法路径else if (!S_ISDIR(sb.st_mode)) {MMKVWarning("%s: %s", path, strerror(ENOTDIR));return false;}*slash = '/';}return true;
}
mkPath 根据字符串创建好文件目录之后,Native 层的初始化操作便结束了,接下来看看 MMKV 实例构建的过程。
2.MMKV对象获取
通过 mmkvWithID 方法可以获取 MMKV 对象,传入的 mmapID 就对应了 SharedPreferences 中的 name,代表了一个文件对应的 name,而 relativePath 则对应了一个相对根目录的相对路径:
public class MMKV implements SharedPreferences, SharedPreferences.Editor {@Nullablepublic static MMKV mmkvWithID(String mmapID, int mode, String cryptKey, String relativePath) {if (rootDir == null) { throw new IllegalStateException("You should Call MMKV.initialize() first."); } // Native 层 getMMKVWithID 方法,执行完 Native 层初始化, 返回句柄值long handle = getMMKVWithID(mmapID, mode, cryptKey, relativePath);if (handle == 0) {return null;}// 构建一个 Java 的壳对象return new MMKV(handle);}private native static longgetMMKVWithID(String mmapID, int mode, String cryptKey, String relativePath);// jniprivate long nativeHandle; // Java 层持有 Native 层对象的地址从而与 Native 对象通信private MMKV(long handle) {nativeHandle = handle; // 并不是真正的 new 出 Java 层的一个实例对象}
}
调用到 Native 层的 getMMKVWithId 方法,并获取到了一个 handle 构造了 Java 层的 MMKV 对象返回,Java 层通过持有 Native 层对象的地址从而与 Native 对象通信。
// native-bridge.cpp
namespace mmkv {
MMKV_JNI jlong getMMKVWithID(JNIEnv *env, jobject, jstring mmapID, jint mode, jstring cryptKey, jstring relativePath) {MMKV *kv = nullptr;if (!mmapID) { // mmapID 为 null 返回空指针 return (jlong) kv;}// 获取独立存储 mmapIDstring str = jstring2string(env, mmapID); // jstring类型的值转化为c++中的string类型 bool done = false;if (cryptKey) { // 如果需要进行加密,获取用于加密的 key,最后调用 MMKV::mmkvWithIDstring crypt = jstring2string(env, cryptKey); // 获取秘钥if (crypt.length() > 0) {if (relativePath) {// 获取相对路径string path = jstring2string(env, relativePath);// 通过 mmkvWithID 函数获取一个 MMKV 的对象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); } } // 强转成句柄, 返回到 Javareturn (jlong) kv;
}
}
其内部继续调用了 MMKV::mmkvWithID 方法,根据是否传入用于加密的 key 以及是否使用相对路径调用了不同的方法来获取到 MMKV 的对象。
// MMKV.cpp
MMKV *MMKV::mmkvWithID(const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {if (mmapID.empty()) { // mmapID 为 null 返回空指针 return nullptr;}SCOPEDLOCK(g_instanceLock); // 加锁 // 通过 mmapID 和 relativePath, 组成最终的 mmap 文件路径的 mmapKeyauto 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 对象并加入 mapif (relativePath) {// 根据 mappedKVPathWithID 获取 mmap 的最终文件路径// mmapID 使用 md5 加密auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);if (!isFileExist(filePath)) { // 不存在则创建一个文件if (!createFile(filePath)) {return nullptr; // 创建不成功则返回空指针}}...}// 创建实例对象auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);// 缓存这个 mmapKey(*g_instanceDic)[mmapKey] = kv;return kv;
}
MMKV::mmkvWithID 方法的执行流程如下:
- 通过 mmapedKVKey 方法对 mmapID 及 relativePath 进行结合生成对应的 mmapKey,它会将它们两者的结合经过 md5 加密从而生成对应的 key,主要目的是为了支持不同相对路径下的同名 mmapID;
- 通过 mmapKey 在 g_instanceDic 这个 map 中查找对应的 MMKV 对象,如果找到直接返回;
- 如果找不到对应的 MMKV 对象,在构建路径后,构建一个新的 MMKV 对象,加入 map 后返回。
接下来重点关注 MMKV 的构造函数:
// MMKV.cpp
MMKV::MMKV(const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath): m_mmapID(mmapedKVKey(mmapID, relativePath)) // 通过 mmapID 和 relativePath 组成最终的 mmap 文件路径的 mmapKey 赋值给 m_mmapID// 拼装文件的路径, m_path(mappedKVPathWithID(m_mmapID, mode, relativePath))// 拼装 .crc 文件路径, m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))// 将文件摘要信息映射到内存, 4 kb 大小, m_metaFile(m_crcPath, DEFAULT_MMAP_SIZE, (mode & MMKV_ASHMEM) ? MMAP_ASHMEM : MMAP_FILE)......, m_sharedProcessLock(&m_fileLock, SharedLockType)......, m_isAshmem((mode & MMKV_ASHMEM) != 0) {......// 判断是否为 Ashmem 跨进程匿名共享内存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;}// 根据 cryptKey 创建 AES 加解密的引擎 AESCryptif (cryptKey && cryptKey->length() > 0) {m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());}......// sensitive zone{ // 加锁后调用 loadFromFile 根据 m_mmapID 来加载文件中的数据SCOPEDLOCK(m_sharedProcessLock);loadFromFile();}
}
MMKV 构造函数:
- 进行了一些赋值操作,之后如果需要加密则根据用于加密的 cryptKey 生成对应的 AESCrypt 对象用于 AES 加密;
- 加锁后通过 loadFromFile 方法根据 m_mmapID 从文件中读取数据,这里的锁是一个跨进程的文件共享锁。
MMKV 的构造函数可以看出,MMKV 是支持 Ashmem 共享内存的,当我们不想将文件写入磁盘,但是又想进行跨进程通信,就可以使用 MMKV 提供的 MMAP_ASHMEM。
3.文件摘要的映射
// MmapedFile.cpp
MmapedFile::MmapedFile(const std::string &path, size_t size, bool fileType): m_name(path), m_fd(-1), m_segmentPtr(nullptr), m_segmentSize(0), m_fileType(fileType) {if (m_fileType == MMAP_FILE) { // 用于内存映射的文件// open 方法打开文件m_fd = open(m_name.c_str(), O_RDWR | O_CREAT, S_IRWXU);if (m_fd < 0) {MMKVError("fail to open:%s, %s", m_name.c_str(), strerror(errno));} else {FileLock fileLock(m_fd); // 创建文件锁InterProcessLock lock(&fileLock, ExclusiveLockType);SCOPEDLOCK(lock);struct stat st = {}; // 获取文件的信息if (fstat(m_fd, &st) != -1) {m_segmentSize = static_cast<size_t>(st.st_size); // 获取文件大小}// 验证文件的大小是否小于一个内存页, 一般为 4kbif (m_segmentSize < DEFAULT_MMAP_SIZE) {m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE);// 通过 ftruncate 将文件大小对其到内存页// 通过 zeroFillFile 将文件对其后的空白部分用 0 填充if (ftruncate(m_fd, m_segmentSize) != 0 || !zeroFillFile(m_fd, 0, m_segmentSize)) {close(m_fd);m_fd = -1;removeFile(m_name); // 文件拓展失败, 关闭并移除这个文件return;}}// 通过 mmap 函数将文件映射到内存, 获取内存首地址m_segmentPtr =(char *) mmap(nullptr, m_segmentSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);if (m_segmentPtr == MAP_FAILED) {MMKVError("fail to mmap [%s], %s", m_name.c_str(), strerror(errno));close(m_fd);m_fd = -1;m_segmentPtr = nullptr;}}}// 用于共享内存的文件else {......}
}
MmapedFile 构造函数:
- open 方法打开指定的文件;
- fileLock 方法创建文件锁;
- 修正文件大小,最小为 4kb,前 4kb 用于统计数据总大小;
- mmap 函数将文件映射到内存。
通过 MmapedFile 的构造函数, 我们便能够获取到映射后的内存首地址,操作这块内存时 Linux 内核会负责将内存中的数据同步到文件中。 即使进程意外死亡,也能够通过 Linux 内核的保护机制,将映射后文件的内存数据刷入到文件中,提升了数据写入的可靠性。
4.loadFromFile 从文件加载数据
// MMKV.cpp
void MMKV::loadFromFile() {......// 忽略匿名共享内存相关代码// 若已经进行了文件映射if (m_metaFile.isFileValid()) {m_metaInfo.read(m_metaFile.getMemory()); // 则获取元文件的数据}// 打开对应的文件,获取文件描述符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)) {......}// 通过 mmap 将文件映射到内存,获取映射后的内存地址m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);if (m_ptr == MAP_FAILED) {......} else {// 读取内存文件的前 32 位, 获取存储数据的真实大小memcpy(&m_actualSize, m_ptr, Fixed32Size);......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 校验失败,则回调 CRC 异常auto strategic = mmkv::onMMKVCRCCheckFail(m_mmapID);if (strategic == OnErrorRecover) {loadFromFile = true;needFullWriteback = true;}}} else {// 文件大小有误,回调文件长度异常auto strategic = mmkv::onMMKVFileLengthError(m_mmapID);if (strategic == OnErrorRecover) {writeAcutalSize(m_size - Fixed32Size);loadFromFile = true;needFullWriteback = true;}}}// 需要从文件获取数据if (loadFromFile) {......// 构建输入缓存 MMBufferMMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);if (m_crypter) {// 如果需要解密,对文件进行解密 decryptBuffer(*m_crypter, inputBuffer);}// 通过 MiniPBCoder 将 MMBuffer 转换为 mapm_dic.clear();MiniPBCoder::decodeMap(m_dic, inputBuffer);// 构建输出数据的 CodedOutputDatam_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);// 重新计算 CRCrecaculateCRCDigest();}......}}......m_needLoadFromFile = false;
}
loadFromFile 函数的执行流程如下:
- 通过 m_metaInfo.read 方法读取元文件的数据信息,内部使用 void * memcpy ( void * dest, const void * src, size_t num ) 函数,该函数的作用为:复制 src 所指的内存内容的前 num 个字节到 dest 所指的内存地址上。
- 打开 m_path 路径文件并获取文件大小,将文件的大小对齐到内存页的整数倍,不足则补 0(与内存映射的原理有关,内存映射是基于页的换入换出机制实现的);
- 通过 mmap 函数将文件映射到内存中,得到指向该内存区域的指针 m_ptr;
- 从文件中读取前 4 个字节 Fixed32Size,得到存储的数据实际占用的空间 。
- 对文件进行长度校验及 CRC 校验(循环冗余校验,可以校验文件完整性),在失败的情况下会根据当前策略进行抉择,如果策略是失败时恢复,则继续读取,并且在最后将 map 中的内容回写到文件;
- 由指向内存区域的指针 m_ptr 构造出一块用于管理 MMKV 映射内存的 MMBuffer 对象,如果需要解密,通过之前构造的 AESCrypt 对象进行解密;
- 由于 MMKV 使用了 Protobuf 进行序列化,通过 MiniPBCoder::decodeMap 方法将 Protobuf 转换成对应的 map;
- 构造用于输出的 CodedOutputData 类实例,如果需要回写 (CRC 校验或文件长度校验失败),则调用 fullWriteback 方法将 map 中的数据回写到文件。
5.数据写入
Java 层的 MMKV 类继承了 SharedPreferences 及 SharedPreferences.Editor 接口并实现了一系列如 putInt、putLong、putString 等方法,同时也有 encode 的很多重载方法,用于对存储的数据进行修改,下面以 putInt 为例:
public class MMKV implements SharedPreferences, SharedPreferences.Editor {...// 省略部分代码 public boolean encode(String key, int value) {// 调用 Native 层的 encodeInt 方法对数据进行写入操作return encodeInt(nativeHandle, key, value);}@Overridepublic Editor putInt(String key, int value) {// 调用 Native 层的 encodeInt 方法对数据进行写入操作encodeInt(nativeHandle, key, value);return this;}private native boolean encodeInt(long handle, String key, int value);
}
putInt 方法和 encode 方法,都是调用 Native 层的 encodeInt 方法对数据进行写入操作。
// native-bridge.cpp
namespace mmkv {MMKV_JNI jboolean encodeInt(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint value) {// 将 Java 层持有的 NativeHandle 转为对应的 MMKV 对象MMKV *kv = reinterpret_cast<MMKV *>(handle);if (kv && oKey) {string key = jstring2string(env, oKey);return (jboolean) kv->setInt32(value, key);}return (jboolean) false;
}
}
继续调用 MMKV::setInt32 函数将数据写入:
// MMKV.cpp
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 中所占据的大小 size,之后为其构造了对应的 MMBuffer 并将数据写入了这段 Buffer,最后调用 setDataForKey 方法;
CodedOutputData:是与 Buffer 交互的桥梁,可以通过它实现向 MMBuffer 中写入数据。
// MMKV.cpp
bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {if (data.length() == 0 || key.empty()) {return false;}SCOPEDLOCK(m_lock); // 获取写锁SCOPEDLOCK(m_exclusiveProcessLock);checkLoadData(); // 确保数据已正确读入内存 // 将键值对写入 mmap 文件映射的内存中auto ret = appendDataWithKey(data, key);if (ret) { // 写入成功, 更新散列数据m_dic[key] = std::move(data);m_hasFullWriteback = false;}return ret;
}bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {size_t keyLength = key.length();// size needed to encode the key -- 计算 key 的 ProtocolBuffer 编码后的长度size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);// size needed to encode the value -- 计算 key + value 的 ProtocolBuffer 编码后的长度size += data.length() + pbRawVarint32Size((int32_t) data.length());// 要写入,获取写锁 SCOPEDLOCK(m_exclusiveProcessLock);// 验证是否有足够的空间, 不足则进行数据重整与扩容操作bool hasEnoughSize = ensureMemorySize(size);if (!hasEnoughSize || !isFileValid()) {return false;}// m_actualSize 是位于文件的首部,保存当前有效内存的大小// 由于新增数据,需调用 writeAcutalSize 函数更新 m_actualSize 值writeAcutalSize(m_actualSize + size);// 将编码后的 key 和 value 写入到文件映射的内存m_output->writeString(key);m_output->writeData(data); // note: write size of data// 获取文件映射内存当前 <key, value> 的起始位置auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;if (m_crypter) { // 需加密,则加密这块区域m_crypter->encrypt(ptr, ptr, size);}updateCRCDigest(ptr, size, KeepSequence); // 更新 CRC 校验码return true;
}
- 首先计算准备写入到映射空间的内容大小,随后调用 ensureMemorySize 方法验证是否有足够的映射空间,不足则进行数据重整与扩容操作。
- 由于新增数据,因此需调用 writeAcutalSize 函数更新 m_actualSize 值,然后通过 CodedOutputData 写入编码后的数据,最后更新 CRC 校验码。
注意:由于 Protobuf 不支持增量更新,为了避免全量写入带来的性能问题,MMKV 在文件中的写入并不是通过修改文件对应的位置,而是直接在后面 append 新增一条新的数据,即使是修改了已存在的 key。而读取时只记录最后一条对应 key 的数据,这样会导致在文件中存在冗余的数据。这样设计的原因我认为是出于性能的考量,MMKV 中存在着一套内存重整机制用于对冗余的 key-value 数据进行处理,其正是在确保内存充足时实现的。
6.内存重整
// MMKV.cpp
bool MMKV::ensureMemorySize(size_t newSize) {if (!isFileValid()) {MMKVWarning("[%s] file not valid", m_mmapID.c_str());return false;}// make some room for placeholder -- 为占位符留出一些空间constexpr size_t ItemSizeHolderSize = 4; if (m_dic.empty()) {newSize += ItemSizeHolderSize;}// 如果文件剩余空闲空间少于新的键值对,或存储的散列表为空if (newSize >= m_output->spaceLeft() || m_dic.empty()) {// try a full rewrite to make space --- 尝试完全重写以腾出空间static const int offset = pbFixed32Size(0);// 通过 MiniPBCoder::encodeDataWithObject 将整个 map 转换为对应的 MMBufferMMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);// 计算所需的空间大小size_t lenNeeded = data.length() + offset + newSize;if (m_isAshmem) {if (lenNeeded > m_size) {MMKVError("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);// 1. no space for a full rewrite, double it -- 所需空间大小 >= 当前可映射文件总大小// 2. or space is not large enough for future usage, double it to avoid frequently full rewrite// 如果内存重整后仍不足以写入,则将大小不断乘2直至足够写入,最后通过 mmap 重新映射文件 if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {size_t oldSize = m_size;do {m_size *= 2; // double 空间直至足够} while (lenNeeded + futureUsage >= m_size);MMKVInfo("extending [%s] file size from %zu to %zu, incoming size:%zu, future usage:%zu",m_mmapID.c_str(), oldSize, m_size, newSize, futureUsage);// if we can't extend size, rollback to old state -- 如果扩展后大小还是不够,则回到之前的状态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;}if (munmap(m_ptr, oldSize) != 0) { // 如果扩容后满足所需,则先 munmap 解除之前的映射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 spaceif (!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;
}
- 当剩余映射空间不足以写入新的待写入内容时,尝试进行内存重整;
- 内存重整会将文件清空,将 map 中的数据重新写入文件,从而去除冗余数据;
- 若内存重整后的映射空间仍然不足,则不断将映射空间 double 直到足够,然后 munmap 解除之前的映射,调用 mmap 重新映射;
7.数据读取
类似上面数据存入,这里通过 getInt 函数对数据进行读取:
public class MMKV implements SharedPreferences, SharedPreferences.Editor {...// 省略部分代码 public int decodeInt(String key, int defaultValue) {// 调用 Native 层的 decodeInt 方法对数据进行读取操作return decodeInt(nativeHandle, key, defaultValue);}@Overridepublic int getInt(String key, int defValue) {// 调用 Native 层的 decodeInt 方法对数据进行读取操作return decodeInt(nativeHandle, key, defValue);}private native int decodeInt(long handle, String key, int defaultValue);
}
getInt 方法和 decodeInt 方法,都是调用 Native 层的 decodeInt 方法来读取数据。
// native-bridge.cpp
namespace mmkv {MMKV_JNI jint decodeInt(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint defaultValue) {// 将 Java 层持有的 NativeHandle 转为对应的 MMKV 对象MMKV *kv = reinterpret_cast<MMKV *>(handle);if (kv && oKey) {string key = jstring2string(env, oKey);return (jint) kv->getInt32ForKey(key, defaultValue);}return defaultValue;
}
}
继续调用 MMKV::getInt32ForKey 函数读取数据:
// MMKV.cpp
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;
}const MMBuffer &MMKV::getDataForKey(const std::string &key) {checkLoadData(); // 确保数据已正确读入内存 auto itr = m_dic.find(key); // 从散列表 map 中寻找获取 key 对应的 valueif (itr != m_dic.end()) {return itr->second;}static MMBuffer nan(0);return nan;
}
MMKV::getInt32ForKey 函数继续调用 getDataForKey 方法获取 key 对应的 MMBuffer,如读取到数据,则调用 CodedInputData 函数将数据读出并返回,如长度为 0 则视为不存在对应的数据,返回默认值。
getDataForKey 函数通过在散列表 map 中寻找获取 key 对应的 value,找不到会返回 size 为 0 的 MMBuffer。
注意:MMKV 读写是直接读写到 mmap 文件映射的内存上,绕开了普通读写 io 需要进入内核,再写到磁盘的过程。
8.数据删除
通过 Java 层 MMKV 的 remove、removeValueForKey 方法传入指定的 key 值来实现删除操作:
public class MMKV implements SharedPreferences, SharedPreferences.Editor {...// 省略部分代码 public void removeValueForKey(String key) {// 调用 Native 层的 removeValueForKey 方法对数据进行删除操作removeValueForKey(nativeHandle, key);}@Overridepublic Editor remove(String key) {// 调用 Native 层的 removeValueForKey 方法对数据进行删除操作removeValueForKey(key);return this;}private native void removeValueForKey(long handle, String key);
}
remove 方法和 removeValueForKey 方法,都是调用 Native 层的 removeValueForKey 方法来删除数据。
// native-bridge.cpp
namespace mmkv {MMKV_JNI void removeValueForKey(JNIEnv *env, jobject instance, jlong handle, jstring oKey) {// 将 Java 层持有的 NativeHandle 转为对应的 MMKV 对象MMKV *kv = reinterpret_cast<MMKV *>(handle);if (kv && oKey) {string key = jstring2string(env, oKey);kv->removeValueForKey(key);}
}
}
继续调用 MMKV::removeValueForKey 函数删除数据:
// MMKV.cpp
void MMKV::removeValueForKey(const std::string &key) {if (key.empty()) {return;}SCOPEDLOCK(m_lock);SCOPEDLOCK(m_exclusiveProcessLock);checkLoadData(); // 确保数据已正确读入内存removeDataForKey(key);
}bool MMKV::removeDataForKey(const std::string &key) {if (key.empty()) {return false;}// 从散列表 map 中删除 key 对应的 valueauto deleteCount = m_dic.erase(key);if (deleteCount > 0) {m_hasFullWriteback = false;static MMBuffer nan(0);return appendDataWithKey(nan, key);}// 读取时发现它的 size 为 0,则会认为这条数据已经删除;return false;
}
removeDataForKey 函数从散列表 map 中删除 key 对应的 value,然后构造一条 size 为 0 的 MMBuffer 并调用 appendDataWithKey 函数将其 append 到 Protobuf 文件中。
9.文件回写
MMKV 中,在一些特定的情景下,会通过 fullWriteback 方法立即将散列表 map 中的数据回写到文件。如:
- 通过 MMKV.reKey 方法修改加密的 key;
- 通过 MMKV.removeValuesForKeys 删除一系列 key;
- 读取文件时文件校验或 CRC 冗余校验失败。
// MMKV.cpp
bool MMKV::fullWriteback() {if (m_hasFullWriteback) { // 如果已经回写完毕,则返回 truereturn true;}if (m_needLoadFromFile) { // 需要从文件中加载数据是,则返回 true,即此时暂时不回写return true;}if (!isFileValid()) { // 文件不可用,即没有文件可供回写,则返回 falseMMKVWarning("[%s] file not valid", m_mmapID.c_str());return false;}if (m_dic.empty()) { // 如果存储所用的散列表 m_dic 是空的,直接清空文件clearAll();return true;}// 将 m_dic 转换为对应的 MMBufferauto 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;// 通过 CodedOutputData 写入编码后的数据m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);m_output->writeRawData(allData); // note: don't write size of datarecaculateCRCDigest(); // 更新 CRC 校验码m_hasFullWriteback = true; // 回写完毕,标记 m_hasFullWriteback 为 truereturn true;} else {// 如果剩余空间不够写入,则调用 ensureMemorySize 函数进行内存重整与扩容return ensureMemorySize(allData.length() + Fixed32Size - m_size);}}return false;
}
- 如果存储所用的散列表 m_dic 是空的,表示所有的数据已被删除,通过 clearAll 函数清除文件与数据,然后重新从文件中加载数据;
- 如果当前映射空间足够写入散列表 m_dic 中回写的数据,则直接通过 CodedOutputData 写入编码后的数据,否者调用 ensureMemorySize 函数进行内存重整与扩容。
10.Protobuf 实现
在 MMKV 中通过 MiniPBCoder 完成 Protobuf 的序列化及反序列化。通过 MiniPBCoder::decodeMap 将 MMKV 存储的 Protobuf 文件反序列化为对应的散列表 map,通过 MiniPBCoder::encodeDataWithObject 将 map 序列化为对应存储的字节流 MMBuffer。
1.序列化
// MiniPBCoder.h
class MiniPBCoder {const MMBuffer *m_inputBuffer;CodedInputData *m_inputData;MMBuffer *m_outputBuffer;CodedOutputData *m_outputData;std::vector<PBEncodeItem> *m_encodeItems;private:MiniPBCoder();MiniPBCoder(const MMBuffer *inputBuffer);......
public:template <typename T>static MMBuffer encodeDataWithObject(const T &obj) {MiniPBCoder pbcoder;return pbcoder.getEncodeData(obj);}
};
继续调用到 MiniPBCoder::getEncodeData 方法,并传入待序列化的 map:
// MiniPBCoder.cpp
MMBuffer MiniPBCoder::getEncodeData(const unordered_map<string, MMBuffer> &map) {m_encodeItems = new vector<PBEncodeItem>(); // 新建 PBEncodeItem 数组// 调用 prepareObjectForEncode 方法将 map 中的键值对分别构建成 PBEncodeItem 并添加到 m_encodeItems 中size_t index = prepareObjectForEncode(map);PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;if (oItem && oItem->compiledSize > 0) {// 新建输出缓存 MMBuffer,然后通过新建的 CodedOutputData 将数据写到 MMBuffer 中m_outputBuffer = new MMBuffer(oItem->compiledSize);m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());writeRootObject();}return std::move(*m_outputBuffer);
}
- 调用 MiniPBCoder::prepareObjectForEncode 方法将 map 中的键值对转为对应的 PBEncodeItem 对象数组;
- 随后构建对应的用于写出的 CodedOutputData,以及输出缓存 MMBuffer,最后调用 writeRootObject 方法将数据通过 CodedOutputData 写到 MMBuffer 缓存中。
首先来看 MiniPBCoder::prepareObjectForEncode 方法是如何序列化 map 的:
// MiniPBCoder.cpp
size_t MiniPBCoder::prepareObjectForEncode(const unordered_map<string, MMBuffer> &map) {// 新建 PBEncodeItem 放到 m_encodeItems 的尾部m_encodeItems->push_back(PBEncodeItem());// 获取 m_encodeItems 尾部刚刚新建的 PBEncodeItem 的引用,以及其对应的 indexPBEncodeItem *encodeItem = &(m_encodeItems->back());size_t index = m_encodeItems->size() - 1; { // 将该 PBEncodeItem 作为一个 ContainerencodeItem->type = PBEncodeItemType_Container;encodeItem->value.strValue = nullptr;for (const auto &itr : map) { // 遍历 mapconst auto &key = itr.first;const auto &value = itr.second;if (key.length() <= 0) {continue; // 如果 key 为空,则跳过本次循环}// 将 key 作为一个 String 类型的 EncodeItem 放入数组size_t keyIndex = prepareObjectForEncode(key);if (keyIndex < m_encodeItems->size()) {// 将 value 作为一个 Data 类型存储 MMBuffer 的 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 中的最后一个元素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 并添加到 m_encodeItems 的尾部,并且将它们的 size 计算入 Container 的 valueSize,最后返回该 Container 在 m_encodeItems 中的 index。
接下来看 MiniPBCoder::writeRootObject 方法是如何将数据写入到缓存 MMBuffer 的:
// MiniPBCoder.cpp
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;}}}
}
根据 MiniPBCoder::prepareObjectForEncode 构建的不同类型的 PBEncodeItem,分别调用 CodedOutputData 对应的写入函数进行写入操作。其中 PBEncodeItemType_Container 类型写入的就是后面数据的长度 size。
数据写入到文件后,最终的格式如下:
2.反序列化
// MiniPBCoder.cpp
void MiniPBCoder::decodeMap(unordered_map<string, MMBuffer> &dic,const MMBuffer &oData,size_t size) {MiniPBCoder oCoder(&oData); // 使用 MMBuffer 缓存构建 MiniPBCoderoCoder.decodeOneMap(dic, size); // 调用 decodeOneMap 方法进行反序列化
}void MiniPBCoder::decodeOneMap(unordered_map<string, MMBuffer> &dic, size_t size) {if (size == 0) {// 通过 CodedInputData 读取 Varint32 的 valueSize 值auto length = m_inputData->readInt32();}while (!m_inputData->isAtEnd()) {// 通过 CodedInputData 读取 key 值const auto &key = m_inputData->readString();if (key.length() > 0) {// 通过 CodedInputData 读取 value 值auto value = m_inputData->readData();if (value.length() > 0) {dic[key] = move(value);} else { // 如果 value 值为0,删除 key 对应的项dic.erase(key);}}}
}
相比于序列化来说,反序列化的逻辑相对简单,通过 CodedInputData 读取 Varint32 的 valueSize 值,随后不断循环通过 CodedInputData 分别读取 key 值和 value 值。
12.文件锁
SharedPreferences 在 Android 7.0 之后便不再对跨进程模式进行支持,原因是跨进程无法保证线程安全,而 MMKV 则通过文件锁解决了这个问题。
其实本来是可以采用在共享内存中创建 pthread_mutex 互斥锁来实现两端线程的读写同步,但由于 Android 对 Linux 的部分同步互斥机制进行了阉割,使得它无法保证当持有锁的进程意外死亡时,并不会释放其拥有的锁,此时若多进程之间存在竞争,那么阻塞的进程将不会被唤醒,导致等待锁的进程饿死。
文件锁是一种用来保证多个进程对同一个文件的安全访问的机制。文件锁可以分为两种类型:建议性锁和强制性锁:
- 建议性锁是一种协作式的锁,它只有在所有参与的进程都遵守锁的规则时才有效;
- 强制性锁是一种强制式的锁,它由内核或文件系统来强制执行,不需要进程的配合。
flock 函数是一种使用文件描述符来实现文件锁的方法。该函数的功能是对一个已打开的文件描述符 fd 进行锁定或解锁操作,它的函数原型如下:
#include <sys/file.h>
int flock(int fd, int operation);
函数的参数如下:
- fd:已打开的文件描述符,必须是可读或可写的,不能是只执行的;
- operation:表示锁类型的整数,可以是 LOCK_SH、LOCK_EX、LOCK_UN 或 LOCK_NB 的组合。
锁类型如下:
- LOCK_SH:共享锁,允许多个进程同时对文件进行读操作,但不允许写操作;
- LOCK_EX:独占锁,只允许一个进程对文件进行读写操作,其他进程都不能访问文件;
- LOCK_UN:解锁,释放之前的锁定,允许其他进程访问文件;
- LOCK_NB:非阻塞,如果不能立即获得锁,不会等待,而是返回错误。
函数的用法:
- 打开一个文件,获得一个文件描述符fd;
- 调用 flock 函数,传入 fd 和想要的锁类型,例如 LOCK_EX。如果成功,返回 0,表示获得了锁,可以对文件进行读写操作。如果失败,返回 -1,并设置 errno,表示没有获得锁,可能是因为文件已经被其他进程锁定,或者其他错误发生;
- 完成文件操作后,调用 flock 函数,传入 fd 和 LOCK_UN,释放锁,关闭文件。
文件锁存在着一定缺点:
- 不支持递归加锁(重入锁):如果我们重复加锁会导致阻塞,如果解锁会把所有的锁都给解除;
- 死锁问题:如果我们两个进程同时将读锁升级为死锁,可能会陷入互相等待从而发生死锁。
MMKV 采用了文件锁的设计,并对文件锁的递归锁和锁升级/降级机制进行了实现。
- 递归锁(可重入):若一个进程/线程已经拥有了锁,那么后续的加锁操作不会导致卡死,并且解锁也不会导致外层的锁被解掉。而由于文件锁是基于状态的,没有计数器,因此在解锁时会导致外层的锁也被解掉;
- 锁升级/降级:锁升级是指将已经持有的共享锁,升级为互斥锁,也就是将读锁升级为写锁;锁降级则是反过来。文件锁支持锁升级,但是容易死锁:假如 A、B 进程都持有了读锁,现在都想升级到写锁,就会发生死锁。另外,由于文件锁不支持递归锁,也导致了锁降级一降就降到没有锁。
1.加锁
MMKV 中调用 FileLock.lock 或 FileLock.try_lock 方法进行文件加锁,他们两者的区别是前者是阻塞式获取锁,会等待到锁的释放,后者则是非阻塞式获取锁,其最终都会调用 FileLock.doLock 方法完成锁的获取:
// InterProcessLock.cpp
bool FileLock::lock(LockType lockType) {// 阻塞式,需等待return doLock(lockType, true);
}bool FileLock::try_lock(LockType lockType) {// 非阻塞式,不需等待return doLock(lockType, false);
}bool FileLock::doLock(LockType lockType, bool wait) {if (!isFileLockValid()) {return false; // 文件锁不可用,返回 false}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;}}// 加读锁或写锁获取到的锁类型:LOCK_SH 或 LOCK_EXint realLockType = LockType2FlockType(lockType);int cmd = wait ? realLockType : (realLockType | LOCK_NB);if (unLockFirstIfNeeded) {// 如果已经存在读锁,先看看能否获取写锁,成功直接返回,否者需先解读锁,再加写锁auto ret = flock(m_fd, realLockType | LOCK_NB);if (ret == 0) {return true;}// 解除共享锁以防止死锁ret = flock(m_fd, LOCK_UN);if (ret != 0) {MMKVError("fail to try unlock first fd=%d, ret=%d, error:%s", m_fd, ret,strerror(errno));}}// 执行对应的加锁(读锁或写锁)auto ret = flock(m_fd, cmd);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 从而实现递归加锁,如果存在其他锁时,就不再需要真正第二次加锁。
2.解锁
MMKV 通过 FileLock.unlock 方法完成文件解锁:
// InterProcessLock.cpp
bool FileLock::unlock(LockType lockType) {if (!isFileLockValid()) {return false; // 文件锁不可用,返回 false}bool unlockToSharedLock = false; // 是否解锁到共享锁(读锁)if (lockType == SharedLockType) {if (m_sharedLockCount == 0) { // 共享锁,只是还未加锁,无须解锁return false;}m_sharedLockCount--; // 解读锁,只需减少 m_sharedLockCount 即可// 此时,如果存在其它的锁,则不需要真正的解锁if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) {return true;}} else {if (m_exclusiveLockCount == 0) {return false; // 如果是写锁,只是还未加锁,无须解锁}m_exclusiveLockCount--; // 解读锁,只需减少 m_exclusiveLockCount 即可if (m_exclusiveLockCount > 0) {return true;}// 当所有排他锁,都解锁后,恢复共享锁// 如果之前存在写锁,则只是降级为读锁,因为之前是将读锁升级为了写锁,此时只需降回即可if (m_sharedLockCount > 0) {unlockToSharedLock = true;}}int cmd = unlockToSharedLock ? LOCK_SH : LOCK_UN;// 执行对应的操作(加读锁或解锁)auto ret = flock(m_fd, cmd);if (ret != 0) {MMKVError("fail to unlock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));return false;} else {return true;}
}
在解锁时,对于解写锁时,如果我们的写锁是由读锁升级而来,则不会真的进行解锁,而是改为加读锁,从而实现将写锁降级为读锁(因为读锁还没解除)。
13.状态同步
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;}if (!m_metaFile.isFileValid()) {return; // 文件不可用直接返回}// TODO: atomic lock m_metaFile? 原子锁 m_metaFileMMKVMetaInfo 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);// 序列号 m_sequence 不同,说明发生了内存重整,清除后重新加载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 相同,说明需要进行写指针同步,只需要部分进行 loadFilepartialLoadFromFile();}}
}
除了写指针同步的情况,其余情况都是清除后重新读取文件实现同步。
总结
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;
- 通过 MMKV.removeValuesForKeys 删除一系列 key;
- 读取文件时文件校验或 CRC 冗余校验失败。
- MMKV 支持跨进程读写,通过文件锁实现跨进程加锁,并且通过对文件锁引入读锁和写锁的计数,从而解决了其存在的不支持递归锁和锁升级/降级问题。不使用 pthread_mutex 通过共享内存加锁的原因是 Android 对 Linux 进行了阉割,如果持有锁的进程被杀死无法保证清除锁的信息,可能导致等待锁的其他进程饿死。
- MMKV 解决了写指针同步、内存重整同步以及内存增长同步 等问题,写指针同步通过在文件的起始处添加一个写指针值,在 checkLoadData 中会对它进行比较,从而获取最新的写指针 m_actualSize;内存重整同步通过一个序号 m_sequence 来实现,每当发生一次内存重整对其 +1,通过比较值即可确定;内存增长同步则通过比较文件大小实现。
MMKV使用时的注意事项:
- 保证每一个文件存储的数据都比较小,也就说需要把数据根据业务线存储分散,避免虚拟内存消耗过快;
- 适当的时候释放一部分内存数据,比如在 App 中监听 onTrimMemory 方法,在 Java 内存吃紧的情况下进行 MMKV 的 trim 操作;
- 不需要使用的时候,最好把 MMKV 给 close 掉,甚至调用 onExit 方法退出。
参考文献
- github.com/Tencent/MMKV