MMKV 是騰訊于 2018 年 9 月 20 日開源的一個 K-V 組件,下面是官方對它的介紹:
MMKV 是基于 mmap 內(nèi)存映射的 key-value 組件为流,底層序列化/反序列化使用 protobuf 實現(xiàn)赠堵,性能高碾褂,穩(wěn)定性強榆俺。從 2015 年中至今在微信上使用,其性能和穩(wěn)定性經(jīng)過了時間的驗證预烙。近期也已移植到 Android / macOS / Windows 平臺匕累,一并開源。
從上面的介紹默伍,可以發(fā)現(xiàn)它與 Android 中的 SharedPreferences 是極其相似的,但是它的性能卻遠(yuǎn)超于 SharedPreferences衰琐。根據(jù)官方的宣傳也糊,寫入隨機 int 1000次,下面是它們兩者的性能對比:
可以發(fā)現(xiàn)羡宙,它相比 SP 的性能提升不是一點半點狸剃,接近了 100 倍!那么它是如何達到這么高的效率的呢狗热?相信很多人已經(jīng)從上面官方對它的介紹中找到了原因——mmap钞馁。
沒錯,又是 mmap匿刮,Binder 的底層原理用到了它僧凰,騰訊和美團的日志庫用到了它,如今騰訊的 K-V 組件也用到了它熟丸,關(guān)于 mmap 的介紹及基本使用可以看我之前的一篇筆記:內(nèi)存映射—— mmap() 函數(shù)的使用
那么今天就讓我們來研究一下 MMKV 的具體實現(xiàn)训措。
注意:
1. MMKV 的核心內(nèi)容使用 C++ 編寫,同時涉及了很多 JNI 的使用光羞,因此這篇文章并不是純 Java绩鸣。
2. 由于筆者還是大二學(xué)生,C++ 功底一般纱兑,本文純屬因興趣開始的研究呀闻,因此只能盡量以理解具體流程為主,如果有誤請路過的大佬指出潜慎。
如果你還不知道什么是 JNI 和 NDK捡多,可以看我之前的另一篇博客:NDK的基本使用蓖康,這一篇就夠了
初始化
MMKV 在使用前,需要在 Application 中進行初始化:
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String rootDir = MMKV.initialize(this);
}
那么我們先進入 initialize 方法看看具體做了什么:
public static String initialize(Context context) {
String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
return initialize(root);
}
可以看到局服,這里指定 rootDir 為 context.getFilesDir().getAbsolutePath() + "/mmkv";
钓瞭,然后調(diào)用了 initialize(rootDir)
下面我們看到 initialize(rootDir):
public static String initialize(String rootDir) {
MMKV.rootDir = rootDir;
jniInitialize(MMKV.rootDir);
return rootDir;
}
它調(diào)用了一個 Native 方法 jniInitialize,下面我們看到它的具體實現(xiàn):
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);
}
}
可以看到淫奔,首先它將 Java 中的 rootDir 轉(zhuǎn)換為了一個 char* kstr山涡,在不為 null 的情況下調(diào)用了 MMKV 類的 static 方法 initializeMMKV。下面來到 initializeMMKV:
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 賦值給了這個 static 變量 g_rootDir鸭丛,之后將 rootDir 復(fù)制了一份 path,通過 path 來將 rootDir 這個目錄在硬盤中創(chuàng)建出來唐责。
為什么要復(fù)制一份而不直接將 rootDir 傳進去呢鳞溉?我們不妨看看 mkPath 的代碼:
bool mkPath(char *path) {
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) {
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;
}
可以看到,它從前向后一層層進行判斷是否目錄是否存在鼠哥,若不存在則調(diào)用 mkdir 函數(shù)來進行目錄的創(chuàng)建熟菲,在這個過程中會將傳入的路徑的一些字段置為 '\0' ,因此為了保證原來的 rootDir 不會被改變朴恳,因此復(fù)制了一份 char * 傳入抄罕。
那么到這里,初始化的過程就結(jié)束了于颖,主要的步驟就是一些變量的賦值以及目錄的創(chuàng)建呆贿。
MMKV 對象的獲取
MMKV 中有一種叫做 mmapID 的 String 用于區(qū)別存儲,類似于 SP 中的 name森渐。通過 MMKV.mmkvWithId 即可獲取對應(yīng) MMKV 對象做入,下面我們可以看一下其具體實現(xiàn):
通過 mmapID 獲取
這里以 mmkvWithID(String mmapID, int mode)
進行舉例,其實其他的重載都是類似的同衣,都調(diào)用了 getMMKVWithID 這個 Native 方法竟块,只是傳入的參數(shù)不同。
private native static long
getMMKVWithID(String mmapID, int mode, String cryptKey, String relativePath);
上面是它的 Java 定義耐齐,下面我們看到它的具體實現(xiàn):
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;
if (!mmapID) {
return (jlong) kv;
}
string str = jstring2string(env, mmapID);
bool done = false;
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;
}
}
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;
}
可以看到彩郊,首先是進行了 mmapID 的判空,為空則返回 nullptr蚪缀,之后則根據(jù) cryptKey 及 relativePath 為空與否分別傳入了不同的參數(shù)調(diào)用 MMKV 類的 mmkvWithID 這一 static 方法秫逝。
細(xì)心的你可能會發(fā)現(xiàn),這里返回的是一個 jlong 類型的值询枚,MMKV 對象的地址被強轉(zhuǎn)為了 Java 中的 long 類型违帆,這個返回值對應(yīng)了 Java 層 MMKV 類中的 nativeHandle 這個 long 值,其他的對該 MMKV 對象的操作都需要傳入該 long 值金蜀,然后在該方法內(nèi)進行強轉(zhuǎn)即可得到這個對象的地址
下面我們看到 mmkvWithID 方法:
MMKV *MMKV::mmkvWithID(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {
if (mmapID.empty()) {
return nullptr;
}
SCOPEDLOCK(g_instanceLock);
auto mmapKey = mmapedKVKey(mmapID, relativePath); // 1
auto itr = g_instanceDic->find(mmapKey);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
if (relativePath) { // 2
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); // 3
(*g_instanceDic)[mmapKey] = kv;
return kv;
}
其中刷后, g_instanceDic 為一個 unordered_map 對象的畴,它的聲明如下:
unordered_map<std::string, MMKV *> *g_instanceDic;
這里可以看到,首先在注釋 1 處通過 mmapedKVKey 方法傳入 mmapID 及 relativePath 獲取到了對應(yīng)的 mmapKey尝胆,之后通過 mmapKey 在 map 中獲取對應(yīng)的 MMKV 對象丧裁,如果找到則直接返回。
在注釋 2 處在 relativePath 不為空的情況下含衔,調(diào)用了 mappedKVPathWithID 方法傳入 mmapID煎娇、mode、relativePath 這三個參數(shù)獲取到了對應(yīng)的 filePath贪染,并創(chuàng)建了相應(yīng)的文件夾缓呛。
可能有人會問,這個 relativePath 是什么呢杭隙?既然是相對路徑哟绊,那么我在這里猜測可能是存儲的文件相對與 rootDir 的路徑吧。
之后在注釋 3 處調(diào)用了 MMKV 的構(gòu)造函數(shù)痰憎,創(chuàng)建完后將其放入 map 中并返回票髓。
mmapKey 的生成
下面我們先看看 mmapKey 是如何獲取的,來到 mmapedKVKey 方法:
static string mmapedKVKey(const string &mmapID, string *relativePath) {
if (relativePath && g_rootDir != (*relativePath)) {
return md5(*relativePath + "/" + mmapID);
}
return mmapID;
}
可以看到铣耘,在 relativePath 不為空且 rootDir 與 relativePath 不相同的情況下洽沟,mmapKey 是對 relativePath/mmapID
進行 MD5 加密后的字符串
而在 relativePath 為空的情況下, mmapKey 就是 mmapID涡拘。
這樣的生成方法的主要目的我猜測是區(qū)分存儲不同 relativePath 下相同 mmapID 的數(shù)據(jù)。
relativePath 對應(yīng)的 filePath
下面我們看到 mappedKVPathWithID据德,來看看 relativePath 和 mmapID 所生成的目錄:
static string mappedKVPathWithID(const string &mmapID, MMKVMode mode, string *relativePath) {
if (mode & MMKV_ASHMEM) {
return string(ASHMEM_NAME_DEF) + "/" + encodeFilePath(mmapID);
} else if (relativePath) {
return *relativePath + "/" + encodeFilePath(mmapID);
}
return g_rootDir + "/" + encodeFilePath(mmapID);
}
可以看到鳄乏,這和方法里面主要是根據(jù)不同的情況返回不同的目錄。
MMKV 的構(gòu)造函數(shù)
下面我們來看看創(chuàng)建 MMKV 對象的 MMKV 構(gòu)造函數(shù):
MMKV::MMKV(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
: m_mmapID(mmapedKVKey(mmapID, relativePath))
, m_path(mappedKVPathWithID(m_mmapID, mode, relativePath)) // 1
, m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
, m_metaFile(m_crcPath, DEFAULT_MMAP_SIZE, (mode & MMKV_ASHMEM) ? MMAP_ASHMEM : MMAP_FILE)
, m_crypter(nullptr)
, m_fileLock(m_metaFile.getFd())
, m_sharedProcessLock(&m_fileLock, SharedLockType)
, m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType)
, m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0)
, m_isAshmem((mode & MMKV_ASHMEM) != 0) {
m_fd = -1;
m_ptr = nullptr;
m_size = 0;
m_actualSize = 0;
m_output = nullptr;
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;
}
if (cryptKey && cryptKey->length() > 0) {
m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
}
m_needLoadFromFile = true;
m_hasFullWriteback = false;
m_crcDigest = 0;
m_sharedProcessLock.m_enable = m_isInterProcess;
m_exclusiveProcessLock.m_enable = m_isInterProcess;
// sensitive zone
{
SCOPEDLOCK(m_sharedProcessLock);
loadFromFile();
}
}
它的構(gòu)造函數(shù)里面主要是一些賦值操作棘利,賦值過后調(diào)用了 loadFromFile 方法橱野,其中可以看到注釋 1 處是調(diào)用了mappedKVPathWithID 來獲取文件存放的目錄。也就是說文件存放目錄由 mappedKVPathWithID 得到善玫。
從文件讀取數(shù)據(jù)
下面讓我們看到 loadFromFile 方法:
void MMKV::loadFromFile() {
...
m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU); // 1
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); // 2
}
// round up to (n * pagesize)
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
size_t oldSize = m_size; // 3
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);
}
m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0); // 5
if (m_ptr == MAP_FAILED) {
MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno));
} else {
memcpy(&m_actualSize, m_ptr, Fixed32Size); // 6
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) {
if (checkFileCRCValid()) { // 7
loadFromFile = true;
} else {
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 inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy); // 8
if (m_crypter) {
decryptBuffer(*m_crypter, inputBuffer);
}
m_dic.clear();
MiniPBCoder::decodeMap(m_dic, inputBuffer); // 9
m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
m_size - Fixed32Size - m_actualSize);
if (needFullWriteback) {
fullWriteback();
}
} else {
SCOPEDLOCK(m_exclusiveProcessLock);
if (m_actualSize > 0) {
writeAcutalSize(0);
}
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
recaculateCRCDigest();
}
MMKVInfo("loaded [%s] with %zu values", m_mmapID.c_str(), m_dic.size());
}
}
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
}
m_needLoadFromFile = false;
}
這里代碼非常長水援,不過問題不大,我們慢慢分析
先看到注釋 1 處茅郎,通過 open 函數(shù)打開了對應(yīng)的要寫入的文件
之后看到注釋 2 處蜗元,通過 fstat 方法獲取到了文件對應(yīng)的大小
然后再看到注釋 3 處,取了一片足夠存放該文件原信息的 DEFAULT_MMAP_SIZE 整數(shù)倍的文件大小作為該文件的新容量系冗。
之后在注釋 4 處奕扣,調(diào)用了 zeroFillFile(m_fd, oldSize, m_size - oldSize) 方法來將文件以 0 字符填充到新容量的大小(這里有點類似我 mmap 博客中分析的 Logan mmap 工具庫中打開文件后的處理)掌敬。
而注釋 5 處惯豆,則是 MMKV 的核心池磁,它調(diào)用了 mmap 函數(shù)來將該文件映射到了 m_ptr 指向的內(nèi)存為起點的內(nèi)存中。
(下面的這一部分參考了 微信MMKV源碼分析(二) | mmap映射 這篇博客)
之后楷兽,在注釋 6 處讀取了文件中數(shù)據(jù)長度地熄,并在注釋 7 處驗證了數(shù)據(jù)的有效性。
之后芯杀,又在注釋 8 處將數(shù)據(jù)讀取到 MMBuffer 這一緩沖區(qū)中端考。若之前標(biāo)記為加密,則在此處進行解密瘪匿。
然后跛梗,在注釋 9 處對數(shù)據(jù)進行了反序列化操作,放入了 m_dic 這一 Map 中棋弥,通過官方文檔知道使用的是 protobuf 協(xié)議核偿,這里不深究序列化和反序列化過程。
MMKV 的修改操作
修改數(shù)據(jù)
寫入數(shù)據(jù)通常通過 encode 方法顽染,根據(jù)不同的重載可以寫入不同類型的數(shù)據(jù)漾岳。下面我們以 encode(String, int) 為例來分析。
public boolean encode(String key, int value) {
return encodeInt(nativeHandle, key, value);
}
可以看到粉寞,這里調(diào)用了 Native 方法 encodeInt尼荆,我們看看它的具體實現(xiàn):
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;
}
可以看到,首先將 nativeHandle 強轉(zhuǎn)回了我們的 MMKV 指針唧垦,之后調(diào)用了這個對象的 setInt32 方法捅儒。
我們看到 setInt32 方法:
bool MMKV::setInt32(int32_t value, const std::string &key) {
if (key.empty()) {
return false;
}
size_t size = pbInt32Size(value);
MMBuffer data(size);
CodedOutputData output(data.getPtr(), size);
output.writeInt32(value);
return setDataForKey(std::move(data), key);
}
可以看到,這里首先創(chuàng)建了一個足夠放下 value 的 Buffer振亮,之后又通過該 Buffer 創(chuàng)建了一個 CodedOutputData 對象用于寫入巧还,最后通過其 writeInt32 方法將數(shù)據(jù)寫入。寫入之后又調(diào)用了 setDataForKey 方法為其設(shè)置 key坊秸。
將數(shù)據(jù)寫入
我們先看到 CodedOutputData 的 writeInt32 方法:
void CodedOutputData::writeInt32(int32_t value) {
if (value >= 0) {
this->writeRawVarint32(value);
} else {
this->writeRawVarint64(value);
}
}
可以看到麸祷,在 value >= 0 時調(diào)用的是 writeRawVarint32 方法,否則調(diào)用了 writeRawVarint64 方法褒搔。
其實進入這些方法會發(fā)現(xiàn)它們所做的事都是將數(shù)據(jù)按字節(jié)寫入到 m_ptr 中阶牍,而 m_ptr 則是之前 data 的 getPtr 所返回的 ptr。也就是說星瘾,這里實際上是將數(shù)據(jù)按字節(jié)寫入了我們的 MMBuffer 中走孽,CodedOutputData 類的作用是為其提供寫入。
為 data 設(shè)置 Key
那么我們再看看 MMKV 是如何將 key 和 data 放入 map 的:
bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
if (data.length() == 0 || key.empty()) {
return false;
}
SCOPEDLOCK(m_lock);
SCOPEDLOCK(m_exclusiveProcessLock);
checkLoadData(); // 1
// m_dic[key] = std::move(data);
auto itr = m_dic.find(key); // 2
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);
}
這里為了優(yōu)化用到了 C++ 11 中的一些特性琳状,有興趣的可以去了解一下融求,這里不詳細(xì)介紹,只介紹大體流程算撮。
首先生宛,在注釋 1 處檢查了讀取的操作是否完成县昂。
之后開始在 Map 中尋找對應(yīng) key 的元素,如果沒有找到則創(chuàng)建一個新的元素陷舅。否則將對應(yīng)元素的 value 替換為我們之前寫入的 data倒彰。
寫入文件
之后則調(diào)用了 appendDataWithKey 方法,讓我們繼續(xù)進去看看:
bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
size_t keyLength = key.length();
// size needed to encode the key
size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength); // 1
// size needed to encode the value
size += data.length() + pbRawVarint32Size((int32_t) data.length());
SCOPEDLOCK(m_exclusiveProcessLock);
bool hasEnoughSize = ensureMemorySize(size);
if (!hasEnoughSize || !isFileValid()) { // 2
return false;
}
if (m_actualSize == 0) { // 3
auto allData = MiniPBCoder::encodeDataWithObject(m_dic); // 4
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); // 5
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;
}
}
代碼比較長,跟著我的思路來看。
首先在注釋 1 處進行了需要的 size 的計算罕拂。
之后在注釋 2 處核驗了空間是否足夠以及文件的有效性。
然后在注釋 3 處判斷了當(dāng)前文件是否為空创淡。
若還沒寫入數(shù)據(jù)則在注釋 4 處將數(shù)據(jù)轉(zhuǎn)換為 protobuf 的文本,之后進行了一些加密南吮,加密后調(diào)用了 writeAcutalSize 方法重新計算了 m_actualSize琳彩,然后調(diào)用了 m_output 的 writeRawData 方法來將數(shù)據(jù)寫入文件的映射區(qū)。之后計算了 CRC 校驗碼部凑。關(guān)于 CRC 校驗碼我們后面再談露乏。
若已經(jīng)寫入過數(shù)據(jù),則在注釋 5 處改變了 m_actualSize涂邀,然后將 key 和 data 寫入文件瘟仿。最后重新計算了 CRC 校驗碼。
我們可以以 writeRawData 為例看看它具體是如何寫入文件的:
void CodedOutputData::writeRawData(const MMBuffer &data) {
size_t numberOfBytes = data.length();
memcpy(m_ptr + m_position, data.getPtr(), numberOfBytes);
m_position += numberOfBytes;
}
可以看到比勉,實際上就是通過 memcpy 的方式將數(shù)據(jù)復(fù)制到了 m_ptr 所指向的那塊內(nèi)存中劳较,由于這塊內(nèi)存是與文件形成了映射的,所以文件的內(nèi)容也會被系統(tǒng)自動回寫浩聋。
寫入優(yōu)化與文件重整
其實據(jù)官方的描述观蜗,protobuf 這種文件雖然具有占用的空間小的特點,但是是不支持增量更新的赡勘,那么它們的解決方法是如何呢嫂便?
標(biāo)準(zhǔn) protobuf 不提供增量更新的能力捞镰,每次寫入都必須全量寫入闸与。考慮到主要使用場景是頻繁地進行寫入更新岸售,我們需要有增量更新的能力:將增量 kv 對象序列化后践樱,直接 append 到內(nèi)存末尾;這樣同一個 key 會有新舊若干份數(shù)據(jù)凸丸,最新的數(shù)據(jù)在最后拷邢;那么只需在程序啟動第一次打開 mmkv 時,不斷用后讀入的 value 替換之前的值屎慢,就可以保證數(shù)據(jù)是最新有效的瞭稼。
可以看到忽洛,官方的處理是在寫入時不斷添加到末尾,然后在讀入數(shù)據(jù)時环肘,不斷用后讀入的值替換新的值欲虚。
但是仔細(xì)一想,這樣不斷 append 的話悔雹,那么會帶來一個問題:文件的大小在不斷增大复哆。因此 MMKV 的開發(fā)者使用了下面這種解決方式:
我們需要在性能和空間上做個折中:以內(nèi)存 pagesize 為單位申請空間,在空間用盡之前都是 append 模式腌零;當(dāng) append 到文件末尾時梯找,進行文件重整、key 排重益涧,嘗試序列化保存排重結(jié)果锈锤;排重后空間還是不夠用的話,將文件擴大一倍饰躲,直到空間足夠牙咏。
那么這個文件重整機制是在哪里體現(xiàn)的呢?
其實我們之前在寫入文件時沒有去研究這個函數(shù)——ensureMemorySize嘹裂,它就實現(xiàn)了這個排重機制妄壶。
bool MMKV::ensureMemorySize(size_t newSize) {
if (!isFileValid()) { // 1
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}
if (newSize >= m_output->spaceLeft()) { // 2
// try a full rewrite to make space
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);
// 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
if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) { // 1
size_t oldSize = m_size;
do { // 3
m_size *= 2;
} while (lenNeeded + futureUsage >= m_size);
MMKVInfo(
"extending [%s] file size from %zu to %zu, incoming size:%zu, futrue 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) {
MMKVError("fail to munmap [%s], %s", m_mmapID.c_str(), strerror(errno));
}
m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0); // 4
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) { // 5
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;
}
這里代碼特別特別長...
首先在注釋 1 處校驗了文件的合法性
之后在注釋 2 處,若空間不足夠寄狼,則開始嘗試一次文件重整丁寄,將所有數(shù)據(jù)序列化后進行了一次計算。
此時會出現(xiàn)兩種情況:
- 重整后空間足夠泊愧,則將文件清空重新寫入
- 重整后空間仍不足伊磺,則不斷將文件擴容至 2 倍的大小,直到足夠?qū)?shù)據(jù)放入删咱。
現(xiàn)在邏輯很清晰了屑埋,那么我們看看具體實現(xiàn)。
看到注釋 3 處痰滋,在內(nèi)存不足的情況下摘能,執(zhí)行了一個 do-while 循環(huán),不斷將 m_size 乘 2敲街,直到滿足我們的需要团搞。
之后執(zhí)行了用 0 填充新文件、釋放之前的映射的操作后多艇,又在注釋 4 處進行了內(nèi)存的重新映射逻恐。最后檢驗了文件的完整性。
之后在注釋 5 處,首先在需要加密的情形下進行了加密复隆。之后將數(shù)據(jù)清空并重新寫入了映射的內(nèi)存拨匆。
這樣就完成了一次文件重整。
MMKV 的查詢操作
下面我們看看 MMKV 提供的查詢操作挽拂,仍然以 Int 為例:
public int decodeInt(String key, int defaultValue) {
return decodeInt(nativeHandle, key, defaultValue);
}
它調(diào)用了 native 方法 decodeInt(nativeHandle, key, defaultValue)涮雷,我們看看它的具體實現(xiàn):
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;
}
可以看到,調(diào)用了 MMKV 對象的 getInt32ForKey 方法轻局,在無法找到的情況下則會返回默認(rèn)值洪鸭。
我們看看 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 方法獲取到對應(yīng)的 MMBuffer仑扑,然后從中通過 CodedInputData 來按字節(jié)獲取對應(yīng)的數(shù)據(jù)览爵,獲取不到時則會返回默認(rèn)值。
我們看看 getDataForKey 的實現(xiàn):
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;
}
可以看到镇饮,其實就是從 m_dic 中取出對應(yīng) key 的 MMBuffer蜓竹,找不到則會返回 NULL。
到這里查詢操作就結(jié)束了储藐,可以看到是十分簡單的
MMKV 的刪除操作
下面我們看看 MMKV 的刪除操作俱济,來到 removeValueForKey 方法,它調(diào)用了 removeValueForKey(nativeHandle, key) 這個 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);
}
}
可以看到蛛碌,這里調(diào)用了 removeValueForKey 方法,我們進去看看:
void MMKV::removeValueForKey(const std::string &key) {
if (key.empty()) {
return;
}
SCOPEDLOCK(m_lock);
SCOPEDLOCK(m_exclusiveProcessLock);
checkLoadData();
removeDataForKey(key);
}
首先辖源,這里進行了是否加載數(shù)據(jù)的檢查蔚携,在數(shù)據(jù)已經(jīng)加載的情況下會執(zhí)行 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;
}
這里很簡單,其實就是將 key 從 map 中刪除克饶,之后將該 key 寫入空的 value 到文件酝蜒。
總結(jié)
MMKV 是一種基于 mmap 的 K-V 存儲庫,與 SharedPreferences 的定位類似矾湃,但它的效率比 SharedPreferences 高了近百倍亡脑,原因是它使用了 mmap 這種內(nèi)存映射技術(shù),使得相比 SharedPreferences 減少了拷貝及提交的時間邀跃。
它是可以通過 mmkvWithID 方法來根據(jù)一個 mmapID 獲取到對應(yīng)的 MMKV 對象的霉咨,在獲取 MMKV 對象時會從本地讀取數(shù)據(jù)到內(nèi)存中的一個 map。
在寫入數(shù)據(jù)時坞嘀,不論什么類型的數(shù)據(jù)都會在內(nèi)存中以 MMBuffer 的形式存在躯护,這些數(shù)據(jù)都以一個個字節(jié)的形式存放于 Buffer 中惊来,在寫入數(shù)據(jù)時會將數(shù)據(jù)同時寫入文件丽涩,由于 protobuf 協(xié)議無法做到增量更新,因此其實是通過不斷向文件后 append 新的 value 來實現(xiàn)的。然后在讀取時不斷的以最后的 value 替換之前的 value矢渊。
當(dāng)寫入空間不足時继准,會進行文件重整,將所有數(shù)據(jù)重新序列化一次矮男,若文件重整后內(nèi)存仍然不足移必,則會先將文件進行 double 的擴容。最后毡鉴,會將文件清空并重新寫入重整后的數(shù)據(jù)崔泵。
在查詢數(shù)據(jù)時,會從 map 中取出 Buffer猪瞬,再將 Buffer 中的數(shù)據(jù)轉(zhuǎn)換為對應(yīng)的真實類型并返回憎瘸。
在刪除數(shù)據(jù)時,會找到對應(yīng)的 key 并從 map 中刪除陈瘦,之后將 key 在文件中對應(yīng)的 value 置為 0幌甘。
CodedInputData 及 CodedOutputData 類其實就是一個 Buffer 與真實數(shù)據(jù)的中介,將真實數(shù)據(jù)寫入 Buffer 需要通過 CodedOutputData痊项,而將 Buffer 中的數(shù)據(jù)轉(zhuǎn)換為真實數(shù)據(jù)則需要 CodedInputData锅风。