公众号回复:OpenGL ,领取学习资源大礼包
作者:N0tExpectErr0r
原文链接:https://xiaozhuanlan.com/topic/1709584362
本文基于 MMKV 1.0.16,关于 MMKV 的编译可以阅读这篇文档:https://github.com/Tencent/MMKV/wiki/android_setup
新媒体排版
MMKV 是微信于 2018 年 9 月 20 日开源的一个 K-V 存储库,它与 SharedPreferences 相似,但又在更高的效率下解决了其不支持跨进程读写等弊端。
一年前的自己因对它非常感兴趣写下了一篇 【Android】 MMKV 源码浅析。不过由于当时还是大二,知识的储备还不够丰富,因此整体的分析在某些细节上还比较稚嫩。由于对这个库很感兴趣,因此尝试重新对它进行一次源码解析,对以前分析不够到位的地方进行补充,并且将以前没有研究的部分细致研究一下。
初始化
通过 MMKV.initialize
方法可以实现 MMKV 的初始化:
public static String initialize(Context context) {
String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
return initialize(root);
}
它采用了内部存储空间下的 mmkv
文件夹作为根目录,之后调用了 initialize
方法。
public static String initialize(String rootDir) {
MMKV.rootDir = rootDir;
jniInitialize(MMKV.rootDir);
return rootDir;
}
调用到了 jniInitialize
这个 Native 方法进行 Native 层的初始化:
extern "C" JNIEXPORT JNICALL void
Java_com_tencent_mmkv_MMKV_jniInitialize(JNIEnv *env, jobject obj, jstring rootDir) {
if (!rootDir) {
return;
}
const char *kstr = env->GetStringUTFChars(rootDir, nullptr);
if (kstr) {
MMKV::initializeMMKV(kstr);
env->ReleaseStringUTFChars(rootDir, kstr);
}
}
这里通过 MMKV::initializeMMKV
对 MMKV 类进行了初始化:
void MMKV::initializeMMKV(const std::string &rootDir) {
static pthread_once_t once_control = PTHREAD_ONCE_INIT;
pthread_once(&once_control, initialize);
g_rootDir = rootDir;
char *path = strdup(g_rootDir.c_str());
mkPath(path);
free(path);
MMKVInfo("root dir: %s", g_rootDir.c_str());
}
实际上就是记录下了 rootDir
并创建对应的根目录,由于 mkPath
方法创建目录时会修改字符串的内容,因此需要复制一份字符串进行。
获取
获取 MMKV 对象
通过 mmkvWithID
方法可以获取 MMKV 对象,它传入的 mmapID
就对应了 SharedPreferences
中的 name,代表了一个文件对应的 name,而 relativePath
则对应了一个相对根目录的相对路径。
@Nullable
public static MMKV mmkvWithID(String mmapID, String relativePath) {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}
long handle = getMMKVWithID(mmapID, SINGLE_PROCESS_MODE, null, relativePath);
if (handle == 0) {
return null;
}
return new MMKV(handle);
}
它调用到了 getMMKVWithId
这个 Native 方法,并获取到了一个 handle 构造了 Java 层的 MMKV 对象返回。这是一种很常见的手法,Java 层通过持有 Native 层对象的地址从而与 Native 对象通信(例如 Android 中的 Surface 就采用了这种方式)。
extern "C" JNIEXPORT JNICALL jlong Java_com_tencent_mmkv_MMKV_getMMKVWithID(
JNIEnv *env, jobject obj, jstring mmapID, jint mode, jstring cryptKey, jstring relativePath) {
MMKV *kv = nullptr;
// mmapID 为 null 返回空指针
if (!mmapID) {
return (jlong) kv;
}
string str = jstring2string(env, mmapID);
bool done = false;
// 如果需要进行加密,获取用于加密的 key,最后调用 MMKV::mmkvWithID
if (cryptKey) {
string crypt = jstring2string(env, cryptKey);
if (crypt.length() > 0) {
if (relativePath) {
string path = jstring2string(env, relativePath);
kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt, &path);
} else {
kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt, nullptr);
}
done = true;
}
}
// 如果不需要加密,则调用 mmkvWithID 不传入加密 key,表示不进行加密
if (!done) {
if (relativePath) {
string path = jstring2string(env, relativePath);
kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr, &path);
} else {
kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr, nullptr);
}
}
return (jlong) kv;
}
这里实际上调用了 MMKV::mmkvWithID
方法,它根据是否传入用于加密的 key 以及是否使用相对路径调用了不同的方法。
MMKV *MMKV::mmkvWithID(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {
if (mmapID.empty()) {
return nullptr;
}
// 加锁
SCOPEDLOCK(g_instanceLock);
// 将 mmapID 与 relativePath 结合生成 mmapKey
auto mmapKey = mmapedKVKey(mmapID, relativePath);
// 通过 mmapKey 在 map 中查找对应的 MMKV 对象并返回
auto itr = g_instanceDic->find(mmapKey);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
// 如果找不到,构建路径后构建 MMKV 对象并加入 map
if (relativePath) {
auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);
if (!isFileExist(filePath)) {
if (!createFile(filePath)) {
return nullptr;
}
}
MMKVInfo("prepare to load %s (id %s) from relativePath %s", mmapID.c_str(), mmapKey.c_str(),
relativePath->c_str());
}
auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
(*g_instanceDic)[mmapKey] = kv;
return kv;
}
这里的步骤如下:
通过
mmapedKVKey
方法对mmapID
及relativePath
进行结合生成了对应的mmapKey
,它会将它们两者的结合经过 md5 从而生成对应的 key,主要目的是为了支持不同相对路径下的同名mmapID
。通过
mmapKey
在g_instanceDic
这个 map 中查找对应的 MMKV 对象,如果找到直接返回。如果找不到对应的 MMKV 对象,构建一个新的 MMKV 对象,加入 map 后返回。
构造 MMKV 对象
我们可以看看在 MMKV 的构造函数中做了什么:
MMKV::MMKV(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
: m_mmapID(mmapedKVKey(mmapID, relativePath))
// ...) {
// ...
if (m_isAshmem) {
m_ashmemFile = new MmapedFile(m_mmapID, static_cast<size_t>(size), MMAP_ASHMEM);
m_fd = m_ashmemFile->getFd();
} else {
m_ashmemFile = nullptr;
}
// 通过加密 key 构建 AES 加密对象 AESCrypt
if (cryptKey && cryptKey->length() > 0) {
m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
}
// 赋值操作
// 加锁后调用 loadFromFile 加载数据
{
SCOPEDLOCK(m_sharedProcessLock);
loadFromFile();
}
}
这里进行了一些赋值操作,之后如果需要加密则根据用于加密的 cryptKey
生成对应的 AESCrypt
对象用于 AES 加密。最后,加锁后通过 loadFromFile
方法从文件中读取数据,这里的锁是一个跨进程的文件共享锁。
从文件加载数据
我们都知道,MMKV 是基于 mmap 实现的,通过内存映射在高效率的同时保证了数据的同步写入文件,loadFromFile
中就会真正进行内存映射:
void MMKV::loadFromFile() {
// ...
// 打开对应的文件
m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
if (m_fd < 0) {
MMKVError("fail to open:%s, %s", m_path.c_str(), strerror(errno));
} else {
// 获取文件大小
m_size = 0;
struct stat st = {0};
if (fstat(m_fd, &st) != -1) {
m_size = static_cast<size_t>(st.st_size);
}
// 将文件大小对齐到页大小的整数倍,用 0 填充不足的部分
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
size_t oldSize = m_size;
m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
if (ftruncate(m_fd, m_size) != 0) {
MMKVError("fail to truncate [%s] to size %zu, %s", m_mmapID.c_str(), m_size,
strerror(errno));
m_size = static_cast<size_t>(st.st_size);
}
zeroFillFile(m_fd, oldSize, m_size - oldSize);
}
// 通过 mmap 将文件映射到内存
m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
} else {
memcpy(&m_actualSize, m_ptr, Fixed32Size);
MMKVInfo("loading [%s] with %zu size in total, file size is %zu", m_mmapID.c_str(),
m_actualSize, m_size);
bool loadFromFile = false, needFullWriteback = false;
if (m_actualSize > 0) {
if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) {
// 对文件进行 CRC 校验,如果失败根据策略进行不同对处理
if (checkFileCRCValid()) {
loadFromFile = true;
} else {
// CRC 校验失败,如果策略是错误时恢复,则继续读取,并且最后需要进行回写
auto strategic = onMMKVCRCCheckFail(m_mmapID);
if (strategic == OnErrorRecover) {
loadFromFile = true;
needFullWriteback = true;
}
}
} else {
// 文件大小有误,若策略是错误时恢复,则继续读取,并且最后需要进行回写
auto strategic = onMMKVFileLengthError(m_mmapID);
if (strategic == OnErrorRecover) {
loadFromFile = true;
needFullWriteback = true;
}
}
}
// 从文件中读取内容
if (loadFromFile) {
MMKVInfo("loading [%s] with crc %u sequence %u", m_mmapID.c_str(),
m_metaInfo.m_crcDigest, m_metaInfo.m_sequence);
// 读取 MMBuffer
MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
// 如果需要解密,对文件进行解密
if (m_crypter) {
decryptBuffer(*m_crypter, inputBuffer);
}
// 通过 MiniPBCoder 将 MMBuffer 转换为 Map
m_dic.clear();
MiniPBCoder::decodeMap(m_dic, inputBuffer);
// 构造用于输出的 CodeOutputData
m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
m_size - Fixed32Size - m_actualSize);
if (needFullWriteback) {
fullWriteback();
}
} else {
SCOPEDLOCK(m_exclusiveProcessLock);
if (m_actualSize > 0) {
writeAcutalSize(0);
}
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
recaculateCRCDigest();
}
MMKVInfo("loaded [%s] with %zu values", m_mmapID.c_str(), m_dic.size());
}
}
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
}
m_needLoadFromFile = false;
}
这里的代码虽然长,但逻辑还是非常清晰的,步骤如下:
打开文件并获取文件大小,将文件的大小对齐到页的整数倍,不足则补 0(与内存映射的原理有关,内存映射是基于页的换入换出机制实现的)
通过
mmap
函数将文件映射到内存中,得到指向该区域的指针m_ptr
。对文件进行长度校验及 CRC 校验(循环冗余校验,可以校验文件完整性),在失败的情况下会根据当前策略进行抉择,如果策略是失败时恢复,则继续读取,并且在最后将 map 中的内容回写到文件。
通过
m_ptr
构造出一块用于管理 MMKV 映射内存的MMBuffer
对象,如果需要解密,通过之前构造的AESCrypt
进行解密。由于 MMKV 使用了 protobuf 进行序列化,通过
MiniPBCoder::decodeMap
方法将 protobuf 转换成对应的 map。构造用于输出的
CodedOutputData
类,如果需要回写(CRC 校验或文件长度校验失败),则调用fullWriteback
方法将 map 中的数据回写到文件。
修改
数据写入
Java 层的 MMKV 对象继承了 SharedPreferences
及 SharedPreferences.Editor
接口并实现了一系列如 putInt
、putLong
的方法用于对存储的数据进行修改,我们以 putInt
为例:
@Override
public Editor putInt(String key, int value) {
encodeInt(nativeHandle, key, value);
return this;
}
它调用到了 encodeInt
这个 Native 方法:
extern "C" JNIEXPORT JNICALL jboolean Java_com_tencent_mmkv_MMKV_encodeInt(
JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint value) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
return (jboolean) kv->setInt32(value, key);
}
return (jboolean) false;
}
这里将 Java 层持有的 NativeHandle 转为了对应的 MMKV 对象,之后调用了其 setInt32
方法:
bool MMKV::setInt32(int32_t value, const std::string &key) {
if (key.empty()) {
return false;
}
// 构造值对应的 MMBuffer,通过 CodedOutputData 将其写入 Buffer
size_t size = pbInt32Size(value);
MMBuffer data(size);
CodedOutputData output(data.getPtr(), size);
output.writeInt32(value);
return setDataForKey(std::move(data), key);
}
这里首先获取到了写入的 value 在 protobuf 中所占据的大小,之后为其构造了对应的 MMBuffer
并将数据写入了这段 Buffer,最后调用到了 setDataForKey
方法(std::move
是 C++ 11 的特性,我们可以简单理解成赋值,它通过直接移动内存减少了拷贝)。
同时可以发现 CodedOutputData
是与 Buffer 交互的桥梁,可以通过它实现向 MMBuffer
中写入数据。
bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
if (data.length() == 0 || key.empty()) {
return false;
}
// 获取写锁
SCOPEDLOCK(m_lock);
SCOPEDLOCK(m_exclusiveProcessLock);
// 确保数据已读入内存
checkLoadData();
// 将 data 写入 map 中
auto itr = m_dic.find(key);
if (itr == m_dic.end()) {
itr = m_dic.emplace(key, std::move(data)).first;
} else {
itr->second = std::move(data);
}
m_hasFullWriteback = false;
return appendDataWithKey(itr->second, key);
}
这里在确保数据已读入内存的情况下将 data 写入了对应的 map,之后调用了 appendDataWithKey
方法:
bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
size_t keyLength = key.length();
// 计算写入到映射空间中的 size
size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
size += data.length() + pbRawVarint32Size((int32_t) data.length());
// 要写入,获取写锁
SCOPEDLOCK(m_exclusiveProcessLock);
// 确定剩余映射空间足够
bool hasEnoughSize = ensureMemorySize(size);
if (!hasEnoughSize || !isFileValid()) {
return false;
}
if (m_actualSize == 0) {
auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
if (allData.length() > 0) {
if (m_crypter) {
m_crypter->reset();
auto ptr = (unsigned char *) allData.getPtr();
m_crypter->encrypt(ptr, ptr, allData.length());
}
writeAcutalSize(allData.length());
m_output->writeRawData(allData); // note: don't write size of data
recaculateCRCDigest();
return true;
}
return false;
} else {
writeAcutalSize(m_actualSize + size);
m_output->writeString(key);
m_output->writeData(data); // note: write size of data
auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
if (m_crypter) {
m_crypter->encrypt(ptr, ptr, size);
}
updateCRCDigest(ptr, size, KeepSequence);
return true;
}
}
这里首先计算了即将写入到映射空间的内容大小,之后调用了 ensureMemorySize
方法确保剩余映射空间足够。
如果 m_actualSize
为 0,则会通过 MiniPBCoder::encodeDataWithObject
将整个 map 转换为对应的 MMBuffer
,加密后通过 CodedOutputData
写入,最后重新计算 CRC 校验码。否则会将 key
和对应 data
写入,最后更新 CRC 校验码。
m_actualSize
是位于文件的首部的,因此是否为 0 取决于文件对应位置。
同时值得注意的是:由于 protobuf 不支持增量更新,为了避免全量写入带来的性能问题,MMKV 在文件中的写入并不是通过修改文件对应的位置,而是直接在后面 append 一条新的数据,即使是修改了已存在的 key。而读取时只记录最后一条对应 key 的数据,这样显然会在文件中存在冗余的数据。这样设计的原因我认为是出于性能的考量,MMKV 中存在着一套内存重整机制用于对冗余的 key-value 数据进行处理。它正是在确保内存充足时实现的。
内存重整
我们接下来看看 ensureMemorySize
是如何确保映射空间是否足够的:
bool MMKV::ensureMemorySize(size_t newSize) {
// ...
if (newSize >= m_output->spaceLeft()) {
// 如果内存剩余大小不足以写入,尝试进行内存重整,将 map 中的数据重新写入 protobuf 文件
static const int offset = pbFixed32Size(0);
MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);
size_t lenNeeded = data.length() + offset + newSize;
if (m_isAshmem) {
if (lenNeeded > m_size) {
MMKVWarning("ashmem %s reach size limit:%zu, consider configure with larger size",
m_mmapID.c_str(), m_size);
return false;
}
} else {
size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size());
size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2);
// 如果内存重整后仍不足以写入,则将大小不断乘2直至足够写入,最后通过 mmap 重新映射文件
if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
size_t oldSize = m_size;
do {
// double 空间直至足够
m_size *= 2;
} while (lenNeeded + futureUsage >= m_size);
// ...
if (ftruncate(m_fd, m_size) != 0) {
MMKVError("fail to truncate [%s] to size %zu, %s", m_mmapID.c_str(), m_size,
strerror(errno));
m_size = oldSize;
return false;
}
// 用零填充不足部分
if (!zeroFillFile(m_fd, oldSize, m_size - oldSize)) {
MMKVError("fail to zeroFile [%s] to size %zu, %s", m_mmapID.c_str(), m_size,
strerror(errno));
m_size = oldSize;
return false;
}
// unmap
if (munmap(m_ptr, oldSize) != 0) {
MMKVError("fail to munmap [%s], %s", m_mmapID.c_str(), strerror(errno));
}
// 重新通过 mmap 映射
m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
}
// check if we fail to make more space
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}
}
}
// 加密数据
if (m_crypter) {
m_crypter->reset();
auto ptr = (unsigned char *) data.getPtr();
m_crypter->encrypt(ptr, ptr, data.length());
}
// 重新构建并写入数据
writeAcutalSize(data.length());
delete m_output;
m_output = new CodedOutputData(m_ptr + offset, m_size - offset);
m_output->writeRawData(data);
recaculateCRCDigest();
m_hasFullWriteback = true;
}
return true;
}
这里代码看起来也比较长,它对 MMKV 的内存重整进行了实现,步骤如下:
当剩余映射空间不足以写入需要写入的内容,尝试进行内存重整
内存重整会将文件清空,将 map 中的数据重新写入文件,从而去除冗余数据
若内存重整后剩余映射空间仍然不足,不断将映射空间 double 直到足够,并用
mmap
重新映射
删除
通过 Java 层 MMKV 的 remove
方法可以实现删除操作:
@Override
public Editor remove(String key) {
removeValueForKey(key);
return this;
}
它调用了 removeValueForKey
这个 Native 方法:
extern "C" JNIEXPORT JNICALL void Java_com_tencent_mmkv_MMKV_removeValueForKey(JNIEnv *env,
jobject instance,
jlong handle,
jstring oKey) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
kv->removeValueForKey(key);
}
}
这里调用了 Native 层 MMKV 的 removeValueForKey
方法:
void MMKV::removeValueForKey(const std::string &key) {
if (key.empty()) {
return;
}
SCOPEDLOCK(m_lock);
SCOPEDLOCK(m_exclusiveProcessLock);
checkLoadData();
removeDataForKey(key);
}
它在数据读入内存的前提下,调用了 removeDataForKey
方法:
bool MMKV::removeDataForKey(const std::string &key) {
if (key.empty()) {
return false;
}
auto deleteCount = m_dic.erase(key);
if (deleteCount > 0) {
m_hasFullWriteback = false;
static MMBuffer nan(0);
return appendDataWithKey(nan, key);
}
return false;
}
这里实际上是构造了一条 size 为 0 的 MMBuffer
并调用 appendDataWithKey
将其 append 到 protobuf 文件中,并将 key 对应的内容从 map 中删除。读取时发现它的 size 为 0,则会认为这条数据已经删除。
读取
我们通过 getInt
、getLong
等操作可以实现对数据的读取,我们以 getInt
为例:
@Override
public int getInt(String key, int defValue) {
return decodeInt(nativeHandle, key, defValue);
}
它调用到了 decodeInt
这个 Native 方法:
extern "C" JNIEXPORT JNICALL jint Java_com_tencent_mmkv_MMKV_decodeInt(
JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint defaultValue) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
return (jint) kv->getInt32ForKey(key, defaultValue);
}
return defaultValue;
}
它调用到了 MMKV.getInt32ForKey
方法:
int32_t MMKV::getInt32ForKey(const std::string &key, int32_t defaultValue) {
if (key.empty()) {
return defaultValue;
}
SCOPEDLOCK(m_lock);
auto &data = getDataForKey(key);
if (data.length() > 0) {
CodedInputData input(data.getPtr(), data.length());
return input.readInt32();
}
return defaultValue;
}
它首先调用了 getDataForKey
方法获取到了 key 对应的 MMBuffer
,之后通过 CodedInputData
将数据读出并返回。可以发现,长度为 0 时会将其视为不存在,返回默认值。
const MMBuffer &MMKV::getDataForKey(const std::string &key) {
checkLoadData();
auto itr = m_dic.find(key);
if (itr != m_dic.end()) {
return itr->second;
}
static MMBuffer nan(0);
return nan;
}
这里实际上是通过在 Map
中寻找从而实现,找不到会返回 size 为 0 的 Buffer。
文件回写
MMKV 中,在一些特定的情景下,会通过 fullWriteback
方法立即将 map 的内容回写到文件。
回写时机主要有以下几个:
通过
MMKV.reKey
方法修改加密的 key。删除一系列的 key 时(通过
removeValuesForKeys
方法)读取文件时文件校验或 CRC 校验失败。
bool MMKV::fullWriteback() {
if (m_hasFullWriteback) {
return true;
}
if (m_needLoadFromFile) {
return true;
}
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}
// 如果 map 空了,直接清空文件
if (m_dic.empty()) {
clearAll();
return true;
}
// 将 m_dic 转换为对应的 MMBuffer
auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
SCOPEDLOCK(m_exclusiveProcessLock);
if (allData.length() > 0) {
if (allData.length() + Fixed32Size <= m_size) {
// 如果足够写入,直接写入
if (m_crypter) {
m_crypter->reset();
auto ptr = (unsigned char *) allData.getPtr();
m_crypter->encrypt(ptr, ptr, allData.length());
}
writeAcutalSize(allData.length());
delete m_output;
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
m_output->writeRawData(allData); // note: don't write size of data
recaculateCRCDigest();
m_hasFullWriteback = true;
return true;
} else {
// 如果剩余空间不够写入,调用 ensureMemorySize 从而进行内存重整与扩容
return ensureMemorySize(allData.length() + Fixed32Size - m_size);
}
}
return false;
}
这里首先在 map 为空的情况下,由于代表了所有数据已被删除,因此通过 clearAll
清除了文件与数据。
否则它会对当前映射空间是否足够写入 map 中回写的数据,如果足够则会将数据写入,否则会调用 ensureMemorySize
从而进行内存重整与扩容。
Protobuf 处理
Protobuf 编码
在我们开始对 Protobuf 部分代码进行研究前,让我们先研究一下 Protobuf 编码的格式。
Protobuf 采用了一种 TLV(Tag-Length-Value)的格式进行编码,其格式如下:
可以看到,每条字段都由 Tag、Length、Value 三部分组成,其中Length 是可选的。
Tag
Tag 由 field_number
和 wire_type
两部分组成,其中:
field_number:字段编号
wire_type:protobuf 编码类型
并且 Tag 采用了 Varints 编码,它是一种可变长的 int 编码(类似 dex 文件的 LEB128)。
wire_type 共有 3 位,可以存放 8 种编码格式,目前已经实现了如下 6 种:
值 | 含义 | 用途 |
---|---|---|
0 | Varint | 可变整型 |
1 | 64-bit | 固定 64 位 |
2 | Length-delimited | string、bytes 等 |
3 | Start group(已废弃) | group 开始 |
4 | End group(已废弃) | group 结束 |
5 | 32-bit | 固定 32 位 |
可以发现,Start group 与 End group 已经废弃,对于 Length 这个字段,只有 Length-delimited
用到,其余的 Varint
、64-bit
、32-bit
等都不需要 Length 字段。
Varints 编码
Varints 编码是一种可变长的 int 编码,它的编码规则如下:
第一位标明了是否需要读取下一字节
存储了数值的补码,且低位在前高位在后。
解码过程
可以简单模拟一下解码的过程,我们接收到一串二进制数据,我们可以先读取一个 Varints 编码块,其后面 3 位为 wire_type,而前面的表示 field_number。之后它会根据 wire_type 来决定是根据 Length 读取固定大小的 Value 还是采用 Varint 等方式读取后面的 Value。
Protobuf 实现
在 MMKV 中通过 MiniPBCoder
完成了 Protobuf 的序列化及反序列化。我们可以通过 MiniPBCoder::decodeMap
将 MMKV 存储的 protobuf 文件反序列化为对应的 Map,可以通过 MiniPBCoder::encodeDataWithObject
将 Map 序列化为对应存储的字节流。
序列化
我们先看看它是如何完成序列化的过程的:
static MMBuffer encodeDataWithObject(const T &obj) {
MiniPBCoder pbcoder;
return pbcoder.getEncodeData(obj);
}
它调用到了 getEncodeData
方法,并传入了对应的 Map:
MMBuffer MiniPBCoder::getEncodeData(const unordered_map<string, MMBuffer> &map) {
m_encodeItems = new vector<PBEncodeItem>();
// 准备 PBEncodeItem 数组
size_t index = prepareObjectForEncode(map);
PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
if (oItem && oItem->compiledSize > 0) {
m_outputBuffer = new MMBuffer(oItem->compiledSize);
m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());
writeRootObject();
}
return std::move(*m_outputBuffer);
}
可以看到,它首先通过 prepareObjectForEncode
方法将 Map
中的键值对转为了对应的 PBEncodeItem
对象数组,之后构造了对应的用于写入的 CodedOutputData
以及写入的 m_outputBuffer
,然后调用了 writeRootObject
方法将数据通过 CodedOutputData
写入到 m_outputBuffer
中。
PBEncodeItem 数组的准备
我们先看到 prepareObjectForEncode
方法:
size_t MiniPBCoder::prepareObjectForEncode(const unordered_map<string, MMBuffer> &map) {
// 放入一个新的 EncodeItem
m_encodeItems->push_back(PBEncodeItem());
// 获取刚刚的 Item 以及其对应的 index
PBEncodeItem *encodeItem = &(m_encodeItems->back());
size_t index = m_encodeItems->size() - 1;
{
// 将该 EncodeItem 作为一个 Container
encodeItem->type = PBEncodeItemType_Container;
encodeItem->value.strValue = nullptr;
// 遍历 Map
for (const auto &itr : map) {
const auto &key = itr.first;
const auto &value = itr.second;
if (key.length() <= 0) {
continue;
}
// 将 key 作为一个 EncodeItem 放入数组
size_t keyIndex = prepareObjectForEncode(key);
if (keyIndex < m_encodeItems->size()) {
// 将 value 作为一个 EncodeItem 放入数组
size_t valueIndex = prepareObjectForEncode(value);
if (valueIndex < m_encodeItems->size()) {
// 计算 container 添加 key 和 value 后的 size
(*m_encodeItems)[index].valueSize += (*m_encodeItems)[keyIndex].compiledSize;
(*m_encodeItems)[index].valueSize += (*m_encodeItems)[valueIndex].compiledSize;
} else {
m_encodeItems->pop_back(); // pop key
}
}
}
encodeItem = &(*m_encodeItems)[index];
}
encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;
return index;
}
可以看到,这里实际上会首先在 m_encodeItems
数组中先放入一个作为 Container 的 PBEncodeItem
,之后遍历 Map,对每个 Key 和 Value 分别构建对应的 PBEncodeItem
并放入,并且将其 size 计算入 Container 的 valueSize
。最后会返回该 Container 的 index。
对于 Key 其会写入一个 String 类型的
PBEncodeItem
对于 Value 其会写入一个
Data
类型存储 MMBuffer 的PBEncodeItem
。
将数据写入 MMBuffer
接着我们看看它是如何实现将数据写入的,我们看到 writeRootObject
方法:
void MiniPBCoder::writeRootObject() {
for (size_t index = 0, total = m_encodeItems->size(); index < total; index++) {
PBEncodeItem *encodeItem = &(*m_encodeItems)[index];
switch (encodeItem->type) {
case PBEncodeItemType_String: {
m_outputData->writeString(*(encodeItem->value.strValue));
break;
}
case PBEncodeItemType_Data: {
m_outputData->writeData(*(encodeItem->value.bufferValue));
break;
}
case PBEncodeItemType_Container: {
m_outputData->writeRawVarint32(encodeItem->valueSize);
break;
}
case PBEncodeItemType_None: {
MMKVError("%d", encodeItem->type);
break;
}
}
}
}
这里的实现非常简单,根据是 String 类型还是 Data 类型还是 Container 类型,分别写入 String、MMBuffer 以及 Varint32。其中 Container 写入的就是后面的 size 大小。
因此写入到文件后文件最后的格式如下:
反序列化
我们可以通过 MiniPBCoder.decodeMap
将其反序列化为 Map,我们可以看看它是如何实现的:
void MiniPBCoder::decodeMap(unordered_map<string, MMBuffer> &dic,
const MMBuffer &oData,
size_t size) {
MiniPBCoder oCoder(&oData);
oCoder.decodeOneMap(dic, size);
}
它调用到了 decodeOnMap
方法:
void MiniPBCoder::decodeOneMap(unordered_map<string, MMBuffer> &dic, size_t size) {
if (size == 0) {
auto length = m_inputData->readInt32();
}
while (!m_inputData->isAtEnd()) {
const auto &key = m_inputData->readString();
if (key.length() > 0) {
auto value = m_inputData->readData();
if (value.length() > 0) {
dic[key] = move(value);
} else {
dic.erase(key);
}
}
}
}
可以看到,它的实现非常简单,先读取了一个 Varint32 的 valueSize,之后不断通过 CodedInputData
分别读取 key 和 value,这对我们前面的猜想进行了印证,并且当遇到 Length 为 0 的 value 时,会将对应的项删掉。
跨进程实现
本部分主要参考自官方文档:MMKV for Android 多进程设计与实现
跨进程锁的选择
SharedPreferences 在 Android 7.0 之后便不再对跨进程模式进行支持,原因是跨进程无法保证线程安全,而 MMKV 则通过了文件锁解决了这个问题。
其实本来是可以采用在共享内存中创建 pthread_mutex
实现两端的线程同步,但由于 Android 对 Linux 的部分机制进行了阉割,它无法保证获取锁的进程被杀死后,系统会对锁的信息进行清理。这就会导致等待锁的进程饿死。
因此 MMKV 采用了文件锁的设计,它的缺点在于不支持递归加锁,不支持锁的升级/降级,因此 MMKV 自行对这两个功能进行了实现。
文件锁
文件锁是 Linux 中基于文件实现的跨进程锁,我们需要维护一个 flock
结构体,它的结构如下:
struct flock {
short l_type; */\* Type of lock: F_RDLCK, F_WRLCK, F_UNLCK \*/*
short l_whence; */\* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END \*/*
off_t l_start; */\* Starting offset for lock \*/*
off_t l_len; */\* Number of bytes to lock \*/*
pid_t l_pid; */\* PID of process blocking our lock (F_GETLK only) \*/*
};
其中我们重点关注 l_type
,它表达了锁的类型,它有三种状态:
F_RDLOCK:也就是读锁,是一种共享锁
F_WRLOCK:也就是写锁,是一种互斥锁
F_UNLOCK:也就是无锁,代表要对其进行解锁
我们通过 fcntl
函数可以提交对 flock
的修改:
int fcntl(int fd, int cmd, struct flock lock)
其中 fd 也就是文件描述符,cmd 表达了要进行的操作,flock 表示 flock
结构体,它里面包含了对锁进行操作的类型。
cmd 有以下三种取值:
F_GETLK:获取文件锁
F_SETLK:设置文件锁(非阻塞),设置不成功直接返回
F_SETLKW:设置文件锁(阻塞),阻塞等到设置成功
文件锁存在着一定缺点:
不支持递归加锁(重入锁):如果我们重复加锁会导致阻塞,如果我们解锁会把所有的锁都给解除。
存在着死锁问题:如果我们两个进程同时将读锁升级为死锁,可能会陷入互相等待从而发生死锁。
文件锁封装
MMKV 中对文件锁的递归锁和锁升级/降级机制进行了实现。
递归锁(可重入) 若一个进程/线程已经拥有了锁,那么后续的加锁操作不会导致卡死,并且解锁也不会导致外层的锁被解掉。由于文件锁是基于状态的,没有计数器,因此在解锁时会导致外层的锁也被解掉。
锁升级/降级锁升级是指将已经持有的共享锁,升级为互斥锁,也就是将读锁升级为写锁,锁降级则是反过来。文件锁支持锁升级,但是容易死锁:假如 A、B 进程都持有了读锁,现在都想升级到写锁,就会发生死锁。另外,由于文件锁不支持递归锁,也导致了锁降级一降就降到没有锁。
MMKV 中基于文件锁实现了上述的递归锁以及锁的升级、降级功能。
加锁
调用 FileLock.lock
或 FileLock.try_lock
方法会调用到 FileLock.doLock
方法,他们两者的区别是前者是阻塞式获取锁,会等待到锁的释放,后者则是非阻塞式获取锁。在 FileLock.doLock
中完成了锁的获取:
bool FileLock::doLock(LockType lockType, int cmd) {
bool unLockFirstIfNeeded = false;
// 加读锁(共享锁)
if (lockType == SharedLockType) {
// 读锁数量++
m_sharedLockCount++;
// 有其他锁的情况下,不需要真正再加一次锁
if (m_sharedLockCount > 1 || m_exclusiveLockCount > 0) {
return true;
}
} else {
m_exclusiveLockCount++;
// 之前加过写锁,则不需要再重新加锁
if (m_exclusiveLockCount > 1) {
return true;
}
// 要加写锁,如果已经存在读锁,可能是其他进程获取的,如果是则需要先将自己的读锁释放掉,再加写锁
if (m_sharedLockCount > 0) {
unLockFirstIfNeeded = true;
}
}
// 加读锁或写锁获取到的锁类型 F_RDLCK 或 F_WRLCK
m_lockInfo.l_type = LockType2FlockType(lockType);
if (unLockFirstIfNeeded) {
// 如果已经存在读锁,先看看能否获取写锁
auto ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
if (ret == 0) {
return true;
}
// 不能获取写锁说明其他线程获取了读锁,则将自己的读锁释放避免死锁
auto type = m_lockInfo.l_type;
// 执行解锁
m_lockInfo.l_type = F_UNLCK;
ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
if (ret != 0) {
MMKVError("fail to try unlock first fd=%d, ret=%d, error:%s", m_fd, ret,
strerror(errno));
}
m_lockInfo.l_type = type;
}
// 执行对应的加锁(读锁或写锁)
auto ret = fcntl(m_fd, cmd, &m_lockInfo);
if (ret != 0) {
MMKVError("fail to lock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
return false;
} else {
return true;
}
}
可以看到,上面的步骤对于写锁而言,在加写锁时,如果当前进程持有了读锁,那我们需要尝试加写锁。如果加写锁失败说明其他线程持有了读锁,我们需要将目前的读锁释放掉,再加写锁,从而避免死锁(这种情况说明两个进程的读锁都想升级为写锁)。
同时可以发现,MMKV 中通过维护了 m_sharedLockCount
以及 m_exclusiveLockCount
从而实现了递归加锁,如果存在其他锁时,就不再需要真正第二次加锁了。
解锁
通过 FileLock.unlock
可以完成对锁的解锁:
bool FileLock::unlock(LockType lockType) {
bool unlockToSharedLock = false;
if (lockType == SharedLockType) {
if (m_sharedLockCount == 0) {
return false;
}
m_sharedLockCount--;
// 解读锁,只需要减少 count 即可,如果此时存在其他的锁就不需要真正解锁了
if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) {
return true;
}
} else {
if (m_exclusiveLockCount == 0) {
return false;
}
// 解写锁
m_exclusiveLockCount--;
if (m_exclusiveLockCount > 0) {
return true;
}
// 如果之前我们是存在写锁的,则只是降级为读锁,因为我们之前将读锁升级为了写锁
if (m_sharedLockCount > 0) {
unlockToSharedLock = true;
}
}
m_lockInfo.l_type = static_cast<short>(unlockToSharedLock ? F_RDLCK : F_UNLCK);
auto ret = fcntl(m_fd, F_SETLK, &m_lockInfo);
if (ret != 0) {
MMKVError("fail to unlock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
return false;
} else {
return true;
}
}
在解锁时,对于解写锁时,如果我们的写锁是由读锁升级而来,则不会真的进行解锁,而是改为加读锁,从而实现将写锁降级为读锁(因为读锁还没解除)。
状态同步
跨进程共享 MMKV 文件面临着状态同步问题:写指针同步、内存重整同步、内存增长同步。
写指针同步:其他进程可能写入了新的键值,此时需要更新写指针的位置。它通过在文件头部保存了有效内存的大小
m_actualSize
,每次都对其进行比较从而实现写指针的同步。内存重整同步:如果发生了内存重整,可能导致前面的键值全部失效,需要全部抛弃重新加载。为了实现内存重整同步,是通过使用一个单调递增的序列号
m_sequence
进行比较,每进行一次内存重整将其 + 1从而实现。内存增长同步:通过文件大小的比较从而实现。
MMKV 中的状态同步通过 checkLoadData
方法实现:
void MMKV::checkLoadData() {
if (m_needLoadFromFile) {
SCOPEDLOCK(m_sharedProcessLock);
m_needLoadFromFile = false;
loadFromFile();
return;
}
if (!m_isInterProcess) {
return;
}
// TODO: atomic lock m_metaFile?
MMKVMetaInfo metaInfo;
metaInfo.read(m_metaFile.getMemory());
if (m_metaInfo.m_sequence != metaInfo.m_sequence) {
// 序列号不同,说明发生了内存重整,清空后重新加载
MMKVInfo("[%s] oldSeq %u, newSeq %u", m_mmapID.c_str(), m_metaInfo.m_sequence,
metaInfo.m_sequence);
SCOPEDLOCK(m_sharedProcessLock);
clearMemoryState();
loadFromFile();
} else if (m_metaInfo.m_crcDigest != metaInfo.m_crcDigest) {
// CRC 不同,说明发生了改变
MMKVDebug("[%s] oldCrc %u, newCrc %u", m_mmapID.c_str(), m_metaInfo.m_crcDigest,
metaInfo.m_crcDigest);
SCOPEDLOCK(m_sharedProcessLock);
size_t fileSize = 0;
if (m_isAshmem) {
fileSize = m_size;
} else {
struct stat st = {0};
if (fstat(m_fd, &st) != -1) {
fileSize = (size_t) st.st_size;
}
}
if (m_size != fileSize) {
// 如果 size 相同,说明发生了文件增长
MMKVInfo("file size has changed [%s] from %zu to %zu", m_mmapID.c_str(), m_size,
fileSize);
clearMemoryState();
loadFromFile();
} else {
// size 相同,说明需要进行写指针同步,只需要部分进行loadFile
partialLoadFromFile();
}
}
}
可以看到,除了写指针同步的情况,其余情况都是重新读取文件实现同步。
总结
MMKV 是一个基于 mmap 实现的 K-V 存储工具,它的序列化基于 protobuf 实现,引入了 CRC 校验从而对文件完整性进行校验,并且它支持了通过 AES 算法对 protobuf 文件进行加密。
MMKV 的初始化过程主要完成了对
rootDir
的初始化及创建,它位于应用的内部存储 file 下的 mmkv 文件夹。MMKV 的获取需要通过
mmapWithID
完成,它会结合传入的mmapId
与relativePath
通过 md5 生成一个唯一的mmapKey
,通过它查找 map 获取对应的 MMKV 实例,若找不到对应的实例会构建一个新的 MMKV 对象。Java 层通过持有 Native 层对象的地址从而实现与 Native 对象进行通信。在 MMKV 对象创建时,会创建用于 AES 加密的
AESCrypt
对象,并且会调用loadFromFile
方法将文件的内容通过mmap
映射到内存中,映射会以页的整数倍进行,若不足的地方会补 0。映射完成后会构造对应的MMBuffer
对映射区域进行管理并创建对应的CodedOutputData
对象,之后会通过MiniPBCoder
将其读入到m_dic
这个 Map 中,它以 String 为 key,MMBuffer
为 value。MMKV 在数据写入前会调用
checkLoadData
方法确保数据已读入并且对跨进程的信息进行同步,之后会将数据转换为MMBuffer
对象并写入 map 中,然后调用ensureMemorySize
确保映射空间足够的情况下,通过 构造 MMKV 对象时创建的CodedOutputData
将数据写入 protobuf 文件。并且 MMKV 的数据更新和写入都是通过在文件后进行 append,会造成存在冗余 key-value 数据。ensureMemorySize
方法在内存不足的情况下首先进行内存重整,它会清空文件,从 map 重新将数据写入文件,从而清理冗余数据,如果仍然不够则会以每次两倍对文件大小进行扩容,并重新通过mmap
进行映射。MMKV 的删除操作实际上是通过在文件中对同样的 key 写入长度为 0 的
MMBuffer
实现,当读取时发现其长度为 0,则将其视为已删除。MMKV 的读取是通过
CodedInputData
实现,它在读如的MMBuffer
长度为 0 时会将其视为不存在。实际上CodedInputData
与CodedOutputData
就是与MMBuffer
进行交互的桥梁。MMKV 还存在着文件回写机制,在以下的时机会将 map 中的数据立即写入文件,空间不足则会进行内存重整:
通过
MMKV.reKey
方法修改加密的 key。删除一系列的 key 时(通过
removeValuesForKeys
方法)读取文件时文件校验或 CRC 校验失败。
MMKV 对跨进程读写进行了支持,它通过文件锁实现跨进程加锁,并且通过对文件锁引入读锁和写锁的计数,从而解决了其存在的不支持递归锁和锁升级/降级问题。不使用
pthread_mutex
通过共享内存加锁的原因是 Android 对 Linux 进行了阉割,如果持有锁的进程被杀死无法保证清除锁的信息,可能导致等待锁的其他进程饿死。加写锁时,如果当前进程持有了读锁,那我们需要尝试将其升级为写锁。如果升级写锁失败说明其他线程持有了读锁,我们需要将当前进程的读锁释放掉,再加写锁,从而避免死锁(这种情况说明两个进程的读锁都想升级为写锁)。
解写锁时,如果我们的写锁是由读锁升级而来,则不会真的进行解锁,而是改为加读锁,从而实现将写锁降级为读锁(因为读锁还没解除)。
MMKV 解决了写指针同步、内存重整同步以及内存增长同步问题,写指针同步通过在文件的起始处添加一个写指针值,在
checkLoadData
中会对它进行比较,从而获取最新的写指针m_actualSize
,而内存重整同步通过一个序号m_sequence
来实现,每当发生一次内存重整对其 + 1,通过比较即可确定。而内存增长同步则通过比较文件大小实现。参考资料
https://github.com/Tencent/MMKV/blob/master/readme_cn.md
https://github.com/Tencent/MMKV/wiki/design
https://github.com/Tencent/MMKV/wiki/android_ipc
往期精彩回顾
技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。
扫码关注公众号【音视频开发进阶】,一起学习多媒体音视频开发~~~
喜欢就点个「在看」吧 ▽