前言
APP 的性能優(yōu)化之路是永無止境的, 這里學(xué)習(xí)一個騰訊開源用于提升本地存儲效率的輕量級存儲框架 MMKV
目前項目中在輕量級存儲上使用的是 SharedPreferences, 雖然 SP 兼容性極好, 但 SP 的低性能一直被詬病, 線上也出現(xiàn)了一些因為 SP 導(dǎo)致的 ANR
網(wǎng)上有很多針對 SP 的優(yōu)化方案, 這里筆者使用的是通過 Hook SP 在 Application 中的創(chuàng)建, 將其替換成自定義的 SP 的方式來增強性能, 但 SDK 28 以后禁止反射 QueuedWork.getHandler 接口, 這個方式就失效了
因此需要一種替代的輕量級存儲方案, MMKV 便是這樣的一個框架
一. 集成與測試
以下介紹簡單的使用方式, 更多詳情請查看 Wiki
依賴注入
在 App 模塊的 build.gradle 文件里添加:
dependencies {
implementation 'com.tencent:mmkv:1.0.22'
// replace "1.0.22" with any available version
}
初始化
// 設(shè)置初始化的根目錄
String dir = getFilesDir().getAbsolutePath() + "/mmkv_2";
String rootDir = MMKV.initialize(dir);
Log.i("MMKV", "mmkv root: " + rootDir);
獲取實例
// 獲取默認(rèn)的全局實例
MMKV kv = MMKV.defaultMMKV();
// 根據(jù)業(yè)務(wù)區(qū)別存儲, 附帶一個自己的 ID
MMKV kv = MMKV.mmkvWithID("MyID");
// 多進程同步支持
MMKV kv = MMKV.mmkvWithID("MyID", MMKV.MULTI_PROCESS_MODE);
CURD
// 添加/更新數(shù)據(jù)
kv.encode(key, value);
// 獲取數(shù)據(jù)
int tmp = kv.decodeInt(key);
// 刪除數(shù)據(jù)
kv.removeValueForKey(key);
SP 的遷移
private void testImportSharedPreferences() {
MMKV mmkv = MMKV.mmkvWithID("myData");
SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
// 遷移舊數(shù)據(jù)
mmkv.importFromSharedPreferences(old_man);
// 清空舊數(shù)據(jù)
old_man.edit().clear().commit();
......
}
數(shù)據(jù)測試
以下是 MMKV仔涩、SharedPreferences 和 SQLite 同步寫入 1000 條數(shù)據(jù)的測試結(jié)果
// MMKV
MMKV: MMKV write int: loop[1000]: 12 ms
MMKV: MMKV read int: loop[1000]: 3 ms
MMKV: MMKV write String: loop[1000]: 7 ms
MMKV: MMKV read String: loop[1000]: 4 ms
// SharedPreferences
MMKV: SharedPreferences write int: loop[1000]: 119 ms
MMKV: SharedPreferences read int: loop[1000]: 3 ms
MMKV: SharedPreferences write String: loop[1000]: 187
MMKV: SharedPreferences read String: loop[1000]: 2 ms
// SQLite
MMKV: sqlite write int: loop[1000]: 101 ms
MMKV: sqlite read int: loop[1000]: 136 ms
MMKV: sqlite write String: loop[1000]: 29 ms
MMKV: sqlite read String: loop[1000]: 93 ms
可以看到 MMKV 無論是對比 SP 還是 SQLite, 在性能上都有非常大的優(yōu)勢, 官方提供的數(shù)據(jù)測試結(jié)果如下
更詳細(xì)的性能測試見 wiki
了解 MMKV 的使用方式和測試結(jié)果, 讓我對其實現(xiàn)原理產(chǎn)生了很大的好奇心, 接下來便看看它是如何將性能做到這個地步的, 這里對主要對 MMKV 的基本操作進行剖析
- 初始化
- 實例化
- encode
- decode
- 進程讀寫的同步
我們從初始化的流程開始分析
二. 初始化
public class MMKV implements SharedPreferences, SharedPreferences.Editor {
// call on program start
public static String initialize(Context context) {
String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
return initialize(root, null);
}
static private String rootDir = null;
public static String initialize(String rootDir, LibLoader loader) {
...... // 省略庫文件加載器相關(guān)代碼
// 保存根目錄
MMKV.rootDir = rootDir;
// Native 層初始化
jniInitialize(MMKV.rootDir);
return rootDir;
}
private static native void jniInitialize(String rootDir);
}
MMKV 的初始化, 主要是將根目錄通過 jniInitialize 傳入了 Native 層, 接下來看看 Native 的初始化操作
// native-bridge.cpp
namespace mmkv {
MMKV_JNI void 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.cpp
static unordered_map<std::string, MMKV *> *g_instanceDic;
static ThreadLock g_instanceLock;
static std::string g_rootDir;
void initialize() {
// 1.1 獲取一個 unordered_map, 類似于 Java 中的 HashMap
g_instanceDic = new unordered_map<std::string, MMKV *>;
// 1.2 初始化線程鎖
g_instanceLock = ThreadLock();
......
}
void MMKV::initializeMMKV(const std::string &rootDir) {
// 由 Linux Thread 互斥鎖和條件變量保證 initialize 函數(shù)在一個進程內(nèi)只會執(zhí)行一次
// https://blog.csdn.net/zhangxiao93/article/details/51910043
static pthread_once_t once_control = PTHREAD_ONCE_INIT;
// 1. 進行初始化操作
pthread_once(&once_control, initialize);
// 2. 將根目錄保存到全局變量
g_rootDir = rootDir;
// 拷貝字符串
char *path = strdup(g_rootDir.c_str());
if (path) {
// 3. 根據(jù)路徑, 生成目標(biāo)地址的目錄
mkPath(path);
// 釋放內(nèi)存
free(path);
}
}
可以看到 initializeMMKV 中主要任務(wù)是初始化數(shù)據(jù), 以及創(chuàng)建根目錄
- pthread_once_t: 類似于 Java 的單例, 其 initialize 方法在進程內(nèi)只會執(zhí)行一次
- 創(chuàng)建 MMKV 對象的緩存散列表 g_instanceDic
- 創(chuàng)建一個線程鎖 g_instanceLock
- mkPath: 根據(jù)字符串創(chuàng)建文件目錄
接下來我們看看這個目錄創(chuàng)建的過程
目錄的創(chuàng)建
// MmapedFile.cpp
bool mkPath(char *path) {
// 定義 stat 結(jié)構(gòu)體用于描述文件的屬性
struct stat sb = {};
bool done = false;
// 指向字符串起始地址
char *slash = path;
while (!done) {
// 移動到第一個非 "/" 的下標(biāo)處
slash += strspn(slash, "/");
// 移動到第一個 "/" 下標(biāo)出處
slash += strcspn(slash, "/");
done = (*slash == '\0');
*slash = '\0';
if (stat(path, &sb) != 0) {
// 執(zhí)行創(chuàng)建文件夾的操作, C 中無 mkdirs 的操作, 需要一個一個文件夾的創(chuàng)建
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;
}
以上是 Native 層創(chuàng)建文件路徑的通用代碼, 邏輯很清晰
好的, 文件目錄創(chuàng)建好了之后, Native 層的初始化操作便結(jié)束了, 接下來看看 MMKV 實例構(gòu)建的過程
三. 實例化
public class MMKV implements SharedPreferences, SharedPreferences.Editor {
@Nullable
public static MMKV mmkvWithID(String mmapID, int mode, String cryptKey, String relativePath) {
......
// 執(zhí)行 Native 初始化, 獲取句柄值
long handle = getMMKVWithID(mmapID, mode, cryptKey, relativePath);
if (handle == 0) {
return null;
}
// 構(gòu)建一個 Java 的殼對象
return new MMKV(handle);
}
private native static long
getMMKVWithID(String mmapID, int mode, String cryptKey, String relativePath);
// jni
private long nativeHandle;
private MMKV(long handle) {
nativeHandle = handle;
}
}
可以看到 MMKV 實例構(gòu)建的主要邏輯通過 getMMKVWithID 方法實現(xiàn), 看它內(nèi)部做了什么
// 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) {
return (jlong) kv;
}
// 獲取獨立存儲 id
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);
// 通過 mmkvWithID 函數(shù)獲取一個 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;
}
}
......
// 強轉(zhuǎn)成句柄, 返回到 Java
return (jlong) kv;
}
}
可以看到最終通過 MMKV::mmkvWithID 函數(shù)獲取到 MMKV 的對象
// MMKV.cpp
MMKV *MMKV::mmkvWithID(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {
if (mmapID.empty()) {
return nullptr;
}
SCOPEDLOCK(g_instanceLock);
// 1. 通過 mmapID 和 relativePath, 組成最終的 mmap 文件路徑的 key
auto mmapKey = mmapedKVKey(mmapID, relativePath);
// 2. 從全局緩存中查找
auto itr = g_instanceDic->find(mmapKey);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
// 3. 創(chuàng)建緩存文件
if (relativePath) {
// 根據(jù) mappedKVPathWithID 獲取 mmap 的最終文件路徑
// mmapID 使用 md5 加密
auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);
// 不存在則創(chuàng)建一個文件
if (!isFileExist(filePath)) {
if (!createFile(filePath)) {
return nullptr;
}
}
......
}
// 4. 創(chuàng)建實例對象
auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
// 5. 緩存這個 mmapKey
(*g_instanceDic)[mmapKey] = kv;
return kv;
}
mmkvWithID 函數(shù)的實現(xiàn)流程非常的清晰, 這里我們主要關(guān)注一下實例對象的創(chuàng)建流程
// MMKV.cpp
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))
// 拼裝 .crc 文件路徑
, m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
// 1. 將文件映射到內(nèi)存
, 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 跨進程匿名共享內(nèi)存
if (m_isAshmem) {
// 創(chuàng)共享內(nèi)存的文件
m_ashmemFile = new MmapedFile(m_mmapID, static_cast<size_t>(size), MMAP_ASHMEM);
m_fd = m_ashmemFile->getFd();
} else {
m_ashmemFile = nullptr;
}
// 根據(jù) cryptKey 創(chuàng)建 AES 加解密的引擎
if (cryptKey && cryptKey->length() > 0) {
m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
}
......
// sensitive zone
{
SCOPEDLOCK(m_sharedProcessLock);
// 2. 根據(jù) m_mmapID 來加載文件中的數(shù)據(jù)
loadFromFile();
}
}
可以從 MMKV 的構(gòu)造函數(shù)中看到很多有趣的信息, MMKV 是支持 Ashmem 共享內(nèi)存的, 這意味著即使是跨進程大數(shù)據(jù)的傳輸, 它也能夠提供很好的性能支持
不過這里我們主要關(guān)注兩個關(guān)鍵點
- m_metaFile 文件的映射
- loadFromFile 數(shù)據(jù)的載入
接下來我們先看看, 文件的映射
一) 文件映射到內(nèi)存
// 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) {
// 用于內(nèi)存映射的文件
if (m_fileType == MMAP_FILE) {
// 1. 打開文件
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 {
// 2. 創(chuàng)建文件鎖
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);
}
// 3. 驗證文件的大小是否小于一個內(nèi)存頁, 一般為 4kb
if (m_segmentSize < DEFAULT_MMAP_SIZE) {
m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE);
// 3.1 通過 ftruncate 將文件大小對其到內(nèi)存頁
// 3.2 通過 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;
}
}
// 4. 通過 mmap 將文件映射到內(nèi)存, 獲取內(nèi)存首地址
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;
}
}
}
// 用于共享內(nèi)存的文件
else {
......
}
}
MmapedFile 的構(gòu)造函數(shù)處理的事務(wù)如下
- 打開指定的文件
- 創(chuàng)建這個文件鎖
- 修正文件大小, 最小為 4kb
- 前 4kb 用于統(tǒng)計數(shù)據(jù)總大小
- 通過 mmap 將文件映射到內(nèi)存
好的, 通過 MmapedFile 的構(gòu)造函數(shù), 我們便能夠獲取到映射后的內(nèi)存首地址了, 操作這塊內(nèi)存時 Linux 內(nèi)核會負(fù)責(zé)將內(nèi)存中的數(shù)據(jù)同步到文件中
比起 SP 的數(shù)據(jù)同步, mmap 顯然是要優(yōu)雅的多, 即使進程意外死亡, 也能夠通過 Linux 內(nèi)核的保護機制, 將進行了文件映射的內(nèi)存數(shù)據(jù)刷入到文件中, 提升了數(shù)據(jù)寫入的可靠性
結(jié)下來看看數(shù)據(jù)的載入
二) 數(shù)據(jù)的載入
// MMKV.cpp
void MMKV::loadFromFile() {
......// 忽略匿名共享內(nèi)存相關(guān)代碼
// 若已經(jīng)進行了文件映射
if (m_metaFile.isFileValid()) {
// 則獲取相關(guān)數(shù)據(jù)
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 {
// 1. 獲取文件大小
m_size = 0;
struct stat st = {0};
if (fstat(m_fd, &st) != -1) {
m_size = static_cast<size_t>(st.st_size);
}
// 1.1 將文件大小對其到內(nèi)存頁的整數(shù)倍
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
......
}
// 2. 獲取文件映射后的內(nèi)存地址
m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
......
} else {
// 3. 讀取內(nèi)存文件的前 32 位, 獲取存儲數(shù)據(jù)的真實大小
memcpy(&m_actualSize, m_ptr, Fixed32Size);
......
bool loadFromFile = false, needFullWriteback = false;
if (m_actualSize > 0) {
// 4. 驗證文件的長度
if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) {
// 5. 驗證文件 CRC 的正確性
if (checkFileCRCValid()) {
loadFromFile = true;
} else {
// 若不正確, 則回調(diào)異常 CRC 異常
auto strategic = mmkv::onMMKVCRCCheckFail(m_mmapID);
if (strategic == OnErrorRecover) {
loadFromFile = true;
needFullWriteback = true;
}
}
} else {
// 回調(diào)文件長度異常
auto strategic = mmkv::onMMKVFileLengthError(m_mmapID);
if (strategic == OnErrorRecover) {
writeAcutalSize(m_size - Fixed32Size);
loadFromFile = true;
needFullWriteback = true;
}
}
}
// 6. 需要從文件獲取數(shù)據(jù)
if (loadFromFile) {
......
// 構(gòu)建輸入緩存
MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
if (m_crypter) {
// 解密輸入緩沖中的數(shù)據(jù)
decryptBuffer(*m_crypter, inputBuffer);
}
// 從輸入緩沖中將數(shù)據(jù)讀入 m_dic
m_dic.clear();
MiniPBCoder::decodeMap(m_dic, inputBuffer);
// 構(gòu)建輸出數(shù)據(jù)
m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
m_size - Fixed32Size - m_actualSize);
// 進行重整回寫, 剔除重復(fù)的數(shù)據(jù)
if (needFullWriteback) {
fullWriteback();
}
}
// 7. 說明文件中沒有數(shù)據(jù), 或者校驗失敗了
else {
SCOPEDLOCK(m_exclusiveProcessLock);
// 清空文件中的數(shù)據(jù)
if (m_actualSize > 0) {
writeAcutalSize(0);
}
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
// 重新計算 CRC
recaculateCRCDigest();
}
......
}
}
......
m_needLoadFromFile = false;
}
好的, 可以看到 loadFromFile 中對于 CRC 驗證通過的文件, 會將文件中的數(shù)據(jù)讀入到 m_dic 中緩存, 否則則會清空文件
- 因此用戶惡意修改文件之后, 會破壞 CRC 的值, 這個存儲數(shù)據(jù)便會被作廢, 這一點要尤為注意
-
從文件中讀取數(shù)據(jù)到 m_dic 之后, 會將 mdic 回寫到文件中, 其重寫的目的是為了剔除重復(fù)的數(shù)據(jù)
- 關(guān)于為什么會出現(xiàn)重復(fù)的數(shù)據(jù), 在后面 encode 操作中再分析
三) 回顧
到這里 MMKV 實例的構(gòu)建就完成了, 有了 m_dic 這個內(nèi)存緩存, 我們進行數(shù)據(jù)查詢的效率就大大提升了
從最終的結(jié)果來看它與 SP 是一致的, 都是初次加載時會將文件中所有的數(shù)據(jù)加載到散列表中, 不過 MMKV 多了一步數(shù)據(jù)回寫的操作, 因此當(dāng)數(shù)據(jù)量比較大時, 對實例構(gòu)建的速度有一定的影響
// 寫入 1000 條數(shù)據(jù)之后, MMVK 和 SharedPreferences 實例化的時間對比
E/TAG: create MMKV instance time is 4 ms
E/TAG: create SharedPreferences instance time is 1 ms
從結(jié)果上來看, MMVK 的確在實例構(gòu)造速度上有一定的劣勢, 不過得益于是將 m_dic 中的數(shù)據(jù)寫入到 mmap 的內(nèi)存, 其真正進行文件寫入的時機由 Linux 內(nèi)核決定, 再加上文件的頁緩存機制, 所以速度上雖有劣勢, 但不至于無法接受
四. encode
關(guān)于 encode 即數(shù)據(jù)的添加與更新的流程, 這里以 encodeString 為例
public class MMKV implements SharedPreferences, SharedPreferences.Editor {
public boolean encode(String key, String value) {
return encodeString(nativeHandle, key, value);
}
private native boolean encodeString(long handle, String key, String value);
}
看看 native 層的實現(xiàn)
// native-bridge.cpp
namespace mmkv {
MMKV_JNI jboolean encodeString(JNIEnv *env, jobject, jlong handle, jstring oKey, jstring oValue) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
// 若是 value 非 NULL
if (oValue) {
// 通過 setStringForKey 函數(shù), 將數(shù)據(jù)存入
string value = jstring2string(env, oValue);
return (jboolean) kv->setStringForKey(value, key);
}
// 若是 value 為 NULL, 則移除 key 對應(yīng)的 value 值
else {
kv->removeValueForKey(key);
return (jboolean) true;
}
}
return (jboolean) false;
}
}
這里我們主要分析一下 setStringForKey 這個函數(shù)
// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
if (key.empty()) {
return false;
}
// 1. 將數(shù)據(jù)編碼成 ProtocolBuffer
auto data = MiniPBCoder::encodeDataWithObject(value);
// 2. 更新鍵值對
return setDataForKey(std::move(data), key);
}
這里主要分為兩步操作
- 數(shù)據(jù)編碼
- 更新鍵值對
一) 數(shù)據(jù)的編碼
MMKV 采用的是 ProtocolBuffer 編碼方式, 這里就不做過多介紹了, 具體請查看 Google 官方文檔
// MiniPBCoder.cpp
MMBuffer MiniPBCoder::getEncodeData(const string &str) {
// 1. 創(chuàng)建編碼條目的集合
m_encodeItems = new vector<PBEncodeItem>();
// 2. 為集合填充數(shù)據(jù)
size_t index = prepareObjectForEncode(str);
PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
if (oItem && oItem->compiledSize > 0) {
// 3. 開辟一個內(nèi)存緩沖區(qū), 用于存放編碼后的數(shù)據(jù)
m_outputBuffer = new MMBuffer(oItem->compiledSize);
// 4. 創(chuàng)建一個編碼操作對象
m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());
// 執(zhí)行 protocolbuffer 編碼, 并輸出到緩沖區(qū)
writeRootObject();
}
// 調(diào)用移動構(gòu)造函數(shù), 重新創(chuàng)建實例返回
return move(*m_outputBuffer);
}
size_t MiniPBCoder::prepareObjectForEncode(const string &str) {
// 2.1 創(chuàng)建 PBEncodeItem 對象用來描述待編碼的條目, 并添加到 vector 集合
m_encodeItems->push_back(PBEncodeItem());
// 2.2 獲取 PBEncodeItem 對象
PBEncodeItem *encodeItem = &(m_encodeItems->back());
// 2.3 記錄索引位置
size_t index = m_encodeItems->size() - 1;
{
// 2.4 填充編碼類型
encodeItem->type = PBEncodeItemType_String;
// 2.5 填充要編碼的數(shù)據(jù)
encodeItem->value.strValue = &str;
// 2.6 填充數(shù)據(jù)大小
encodeItem->valueSize = static_cast<int32_t>(str.size());
}
// 2.7 計算編碼后的大小
encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;
return index;
}
可以看到, 再未進行編碼操作之前, 編碼后的數(shù)據(jù)大小就已經(jīng)確定好了, 并且將它保存在了 encodeItem->compiledSize 中, 接下來我們看看執(zhí)行數(shù)據(jù)編碼并輸出到緩沖區(qū)的操作流程
// 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) {
// 主要關(guān)心編碼 String
case PBEncodeItemType_String: {
m_outputData->writeString(*(encodeItem->value.strValue));
break;
}
......
}
}
}
// CodedOutputData.cpp
void CodedOutputData::writeString(const string &value) {
size_t numberOfBytes = value.size();
......
// 1. 按照 varint 方式編碼字符串長度, 會改變 m_position 的值
this->writeRawVarint32((int32_t) numberOfBytes);
// 2. 將字符串的數(shù)據(jù)拷貝到編碼好的長度后面
memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes);
// 更新 position 的值
m_position += numberOfBytes;
}
可以看到 CodedOutputData 的 writeString 中按照 protocol buffer 進行了字符串的編碼操作
其中 m_ptr 是上面開辟的內(nèi)存緩沖區(qū)的地址, 也就是說 writeString 執(zhí)行結(jié)束之后, 數(shù)據(jù)就已經(jīng)被寫入緩沖區(qū)了
有了編碼好的數(shù)據(jù)緩沖區(qū), 接下來看看更新鍵值對的操作
二) 鍵值對的更新
// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
// 編碼數(shù)據(jù)獲取存放數(shù)據(jù)的緩沖區(qū)
auto data = MiniPBCoder::encodeDataWithObject(value);
// 更新鍵值對
return setDataForKey(std::move(data), key);
}
bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
......
// 將鍵值對寫入 mmap 文件映射的內(nèi)存中
auto ret = appendDataWithKey(data, key);
// 寫入成功, 更新散列數(shù)據(jù)
if (ret) {
m_dic[key] = std::move(data);
m_hasFullWriteback = false;
}
return ret;
}
bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
// 1. 計算 key + value 的 ProtocolBuffer 編碼后的長度
size_t keyLength = key.length();
size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
size += data.length() + pbRawVarint32Size((int32_t) data.length());
SCOPEDLOCK(m_exclusiveProcessLock);
// 2. 驗證是否有足夠的空間, 不足則進行數(shù)據(jù)重整與擴容操作
bool hasEnoughSize = ensureMemorySize(size);
if (!hasEnoughSize || !isFileValid()) {
return false;
}
// 3. 更新文件頭的數(shù)據(jù)總大小
writeAcutalSize(m_actualSize + size);
// 4. 將 key 和編碼后的 value 寫入到文件映射的內(nèi)存
m_output->writeString(key);
m_output->writeData(data);
// 5. 獲取文件映射內(nèi)存當(dāng)前 <key, value> 的起始位置
auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
if (m_crypter) {
// 加密這塊區(qū)域
m_crypter->encrypt(ptr, ptr, size);
}
// 6. 更新 CRC
updateCRCDigest(ptr, size, KeepSequence);
return true;
}
好的, 可以看到更新鍵值對的操作還是比較復(fù)雜的, 首先將鍵值對數(shù)據(jù)寫入到文件映射的內(nèi)存中, 寫入成功之后更新散列數(shù)據(jù)
關(guān)于寫入到文件映射的過程, 上面代碼中的注釋也非常的清晰, 接下來我們 ensureMemorySize 是如何進行數(shù)據(jù)的重整與擴容的
數(shù)據(jù)的重整與擴容
// MMKV.cpp
bool MMKV::ensureMemorySize(size_t newSize) {
......
// 計算新鍵值對的大小
constexpr size_t ItemSizeHolderSize = 4;
if (m_dic.empty()) {
newSize += ItemSizeHolderSize;
}
// 數(shù)據(jù)重寫:
// 1. 文件剩余空閑空間少于新的鍵值對
// 2. 散列為空
if (newSize >= m_output->spaceLeft() || m_dic.empty()) {
// 計算所需的數(shù)據(jù)空間
static const int offset = pbFixed32Size(0);
MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);
size_t lenNeeded = data.length() + offset + newSize;
if (m_isAshmem) {
......
} else {
//
// 計算每個鍵值對的平均大小
size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size());
// 計算未來可能會使用的大小(類似于 1.5 倍)
size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2);
// 1. 所需空間 >= 當(dāng)前文件總大小
// 2. 所需空間的 1.5 倍 >= 當(dāng)前文件總大小
if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
// 擴容為 2 倍
size_t oldSize = m_size;
do {
m_size *= 2;
} while (lenNeeded + futureUsage >= m_size);
.......
}
}
......
// 進行數(shù)據(jù)的重寫
writeAcutalSize(data.length());
......
}
return true;
}
從上面的代碼我們可以了解到
-
數(shù)據(jù)的重寫時機
- 文件剩余空間少于新的鍵值對大小
- 散列為空
-
文件擴容時機
- 所需空間的 1.5 倍超過了當(dāng)前文件的總大小時, 擴容為之前的兩倍
三) 回顧
至此 encode 的流程我們就走完了, 回顧一下整個 encode 的流程
- 使用 ProtocolBuffer 編碼 value
- 將 key 和 編碼后的 value 使用 ProtocolBuffer 的格式 append 到文件映射區(qū)內(nèi)存的尾部
- 文件空間不足
- 判斷是否需要擴容
- 進行數(shù)據(jù)的回寫
- 即在文件后進行追加
- 文件空間不足
- 對這個鍵值對區(qū)域進行統(tǒng)一的加密
- 更新 CRC 的值
- 將 key 和 value 對應(yīng)的 ProtocolBuffer 編碼內(nèi)存區(qū)域, 更新到散列表 m_dic 中
通過 encode 的分析, 我們得知 MMKV 文件的存儲方式如下
接下來看看 decode 的流程
五. decode
decode 的過程同樣以 decodeString 為例
// native-bridge.cpp
MMKV_JNI jstring
decodeString(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jstring oDefaultValue) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
// 通過 getStringForKey, 將數(shù)據(jù)輸出到傳出參數(shù)中 value 中
string value;
bool hasValue = kv->getStringForKey(key, value);
if (hasValue) {
return string2jstring(env, value);
}
}
return oDefaultValue;
}
// MMKV.cpp
bool MMKV::getStringForKey(const std::string &key, std::string &result) {
if (key.empty()) {
return false;
}
SCOPEDLOCK(m_lock);
// 1. 從內(nèi)存緩存中獲取數(shù)據(jù)
auto &data = getDataForKey(key);
if (data.length() > 0) {
// 2. 解析 data 對應(yīng)的 ProtocolBuffer 數(shù)據(jù)
result = MiniPBCoder::decodeString(data);
return true;
}
return false;
}
const MMBuffer &MMKV::getDataForKey(const std::string &key) {
// 從散列表中獲取 key 對應(yīng)的 value
auto itr = m_dic.find(key);
if (itr != m_dic.end()) {
return itr->second;
}
static MMBuffer nan(0);
return nan;
}
好的可以看到 decode 的流程比較簡單, 先從內(nèi)存緩存中獲取 key 對應(yīng)的 value 的 ProtocolBuffer 內(nèi)存區(qū)域, 再解析這塊內(nèi)存區(qū)域, 從中獲取真正的 value 值
思考
看到這里可能會有一個疑問, 為什么 m_dic 不直接存儲 key 和 value 原始數(shù)據(jù)呢, 這樣查詢效率不是更快嗎?
- 如此一來查詢效率的確會更快, 因為少了 ProtocolBuffer 解碼的過程
從圖上的結(jié)果可以看出, MMKV 的讀取性能時略低于 SharedPreferences 的, 這里筆者給出自己的思考
- m_dic 在數(shù)據(jù)重整中也起到了非常重要的作用, 需要依靠 m_dic 將數(shù)據(jù)寫入到 mmap 的文件映射區(qū), 這個過程是非常耗時的, 若是原始的 value, 則需要對所有的 value 再進行一次 ProtocolBuffer 編碼操作, 尤其是當(dāng)數(shù)據(jù)量比較龐大時, 其帶來的性能損耗更是無法忽略的
既然 m_dic 還承擔(dān)著方便數(shù)據(jù)復(fù)寫的功能, 那能否再添加一個內(nèi)存緩存專門用于存儲原始的 value 呢?
- 當(dāng)然可以, 這樣 MMKV 的讀取定是能夠達(dá)到 SharedPreferences 的水平, 不過 value 的內(nèi)存消耗則會加倍, MMKV 作為一個輕量級緩存的框架, 查詢時時間的提升幅度還不足以用內(nèi)存加倍的代價去換取, 我想這是 Tencent 在進行多方面權(quán)衡之后, 得到的一個比較合理的解決方案
六. 進程讀寫的同步
說起進程間讀寫同步, 我們很自然的想到 Linux 的共享內(nèi)存配合信號量使用的案例, 但是這種方式有一個弊端, 那就是當(dāng)持有鎖的進程意外死亡的時候, 并不會釋放其擁有的信號量, 若多進程之間存在競爭, 那么阻塞的進程將不會被喚醒, 這是非常危險的
MMKV 是采用 文件鎖 的方式來進行進程間的同步操作
- LOCK_SH(共享鎖): 多個進程可以使用同一把鎖, 常被用作讀共享鎖
- LOCK_EX(排他鎖): 同時只允許一個進程使用, 常被用作寫鎖
- LOCK_UN: 釋放鎖
接下來我看看 MMKV 加解鎖的操作
一) 文件共享鎖
MMKV::MMKV(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
: m_mmapID(mmapedKVKey(mmapID, relativePath))
// 創(chuàng)建文件鎖的描述
, m_fileLock(m_metaFile.getFd())
// 描述共享鎖
, m_sharedProcessLock(&m_fileLock, SharedLockType)
// 描述排它鎖
, m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType)
// 判讀是否為進程間通信
, m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0 || (mode & CONTEXT_MODE_MULTI_PROCESS) != 0)
, m_isAshmem((mode & MMKV_ASHMEM) != 0) {
......
// 根據(jù)是否跨進程操作判斷共享鎖和排它鎖的開關(guān)
m_sharedProcessLock.m_enable = m_isInterProcess;
m_exclusiveProcessLock.m_enable = m_isInterProcess;
// sensitive zone
{
// 文件讀操作, 啟用了文件共享鎖
SCOPEDLOCK(m_sharedProcessLock);
loadFromFile();
}
}
可以看到在我們前面分析過的構(gòu)造函數(shù)中, MMKV 對文件鎖進行了初始化, 并且創(chuàng)建了共享鎖和排它鎖, 并在跨進程操作時開啟, 當(dāng)進行讀操作時, 啟動了共享鎖
二) 文件排它鎖
bool MMKV::fullWriteback() {
......
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 will extend file & full rewrite, no need to write back again
return ensureMemorySize(allData.length() + Fixed32Size - m_size);
}
}
return false;
}
在進行數(shù)據(jù)回寫的函數(shù)中, 啟動了排它鎖
三) 讀寫效率表現(xiàn)
其進程同步讀寫的性能表現(xiàn)如下
可以看到進程同步讀寫的效率也是非常 nice 的
關(guān)于跨進程同步就介紹到這里, 當(dāng)然 MMKV 的文件鎖并沒有表面上那么簡單, 因為文件鎖為狀態(tài)鎖, 無論加了多少次鎖, 一個解鎖操作就全解除, 顯然無法應(yīng)對子函數(shù)嵌套調(diào)用的問題, MMKV 內(nèi)部通過了自行實現(xiàn)計數(shù)器來實現(xiàn)鎖的可重入性, 更多的細(xì)節(jié)可以查看 wiki
總結(jié)
通過上面的分析, 我們對 MMKV 有了一個整體上的把控, 其具體的表現(xiàn)如下所示
項目 | 評價 | 描述 |
---|---|---|
正確性 | 優(yōu) | 支持多進程安全, 使用 mmap, 由操作系統(tǒng)保證數(shù)據(jù)回寫的正確性 |
時間開銷 | 優(yōu) | 使用 mmap 實現(xiàn), 減少了用戶空間數(shù)據(jù)到內(nèi)核空間的拷貝 |
空間開銷 | 中 | 使用 protocl buffer 存儲數(shù)據(jù), 同樣的數(shù)據(jù)會比 xml 和 json 消耗空間小 使用的是數(shù)據(jù)追加到末尾的方式, 只有到達(dá)一定閾值之后才會觸發(fā)鍵值合并, 不合并之前會導(dǎo)致同一個 key 存在多份 |
安全 | 中 | 使用 crc 校驗, 甄別文件系統(tǒng)和操作系統(tǒng)不穩(wěn)定導(dǎo)致的異常數(shù)據(jù) |
開發(fā)成本 | 優(yōu) | 使用方式較為簡單 |
兼容性 | 優(yōu) | 各個安卓版本都前后兼容 |
雖然 MMKV 一些場景下比 SP 稍慢(如: 首次實例化會進行數(shù)據(jù)的復(fù)寫剔除重復(fù)數(shù)據(jù), 比 SP 稍慢, 查詢數(shù)據(jù)時存在 ProtocolBuffer 解碼, 比 SP 稍慢), 但其逆天的數(shù)據(jù)寫入速度、mmap Linux 內(nèi)核保證數(shù)據(jù)的同步, 以及 ProtocolBuffer 編碼帶來的更小的本地存儲空間占用等都是非常棒的閃光點
在分析 MMKV 的代碼的過程中, 從中學(xué)習(xí)到了很多知識, 非常感謝 Tencent 為開源社區(qū)做出的貢獻