公众号回复: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
方法创建目录时会修改字符串的内容,因此需要复制一份字符串进行。
通过 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(
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), 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(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(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(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(1, m_dic.size());
size_t futureUsage = avgItemSize * std::max(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(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(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 采用了一种 TLV(Tag-Length-Value)的格式进行编码,其格式如下:
可以看到,每条字段都由 Tag、Length、Value 三部分组成,其中Length 是可选的。
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 编码是一种可变长的 int 编码,它的编码规则如下:
第一位标明了是否需要读取下一字节
存储了数值的补码,且低位在前高位在后。
可以简单模拟一下解码的过程,我们接收到一串二进制数据,我们可以先读取一个 Varints 编码块,其后面 3 位为 wire_type,而前面的表示 field_number。之后它会根据 wire_type 来决定是根据 Length 读取固定大小的 Value 还是采用 Varint 等方式读取后面的 Value。
在 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 &map) {
m_encodeItems = new vector();
// 准备 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
中。
我们先看到 prepareObjectForEncode
方法:
size_t MiniPBCoder::prepareObjectForEncode(const unordered_map &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
。
接着我们看看它是如何实现将数据写入的,我们看到 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 &dic,
const MMBuffer &oData,
size_t size) {
MiniPBCoder oCoder(&oData);
oCoder.decodeOneMap(dic, size);
}
它调用到了 decodeOnMap
方法:
void MiniPBCoder::decodeOneMap(unordered_map &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(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
往期精彩回顾
Android JNI 之 Bitmap 操作
Android JNI 中的线程操作
Android JNI 基础知识
技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。
扫码关注公众号【音视频开发进阶】,一起学习多媒体音视频开发~~~
喜欢就点个「在看」吧 ▽