文章目錄
Android 性能優化 – MMKV使用與原理
前言
APP 的性能優化之路是永無止境的, 這裏學習一個騰訊開源用於提升本地存儲效率的輕量級存儲框架 MMKV
目前項目中在輕量級存儲上使用的是 SharedPreferences, 雖然 SP 兼容性極好, 但 SP 的低性能一直被詬病, 線上也出現了一些因爲 SP 導致的 ANR
網上有很多針對 SP 的優化方案, 這裏筆者使用的是通過 Hook SP 在 Application 中的創建, 將其替換成自定義的 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
}
初始化
// 設置初始化的根目錄
String dir = getFilesDir().getAbsolutePath() + "/mmkv_2";
String rootDir = MMKV.initialize(dir);
Log.i("MMKV", "mmkv root: " + rootDir);
獲取實例
// 獲取默認的全局實例
MMKV kv = MMKV.defaultMMKV();
// 根據業務區別存儲, 附帶一個自己的 ID
MMKV kv = MMKV.mmkvWithID("MyID");
// 多進程同步支持
MMKV kv = MMKV.mmkvWithID("MyID", MMKV.MULTI_PROCESS_MODE);
CURD
// 添加/更新數據
kv.encode(key, value);
// 獲取數據
int tmp = kv.decodeInt(key);
// 刪除數據
kv.removeValueForKey(key);
SP 的遷移
private void testImportSharedPreferences() {
MMKV mmkv = MMKV.mmkvWithID("myData");
SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
// 遷移舊數據
mmkv.importFromSharedPreferences(old_man);
// 清空舊數據
old_man.edit().clear().commit();
......
}
數據測試
以下是 MMKV、SharedPreferences 和 SQLite 同步寫入 1000 條數據的測試結果
// 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, 在性能上都有非常大的優勢, 官方提供的數據測試結果如下
單進程讀寫性能對比
更詳細的性能測試見 wiki
瞭解 MMKV 的使用方式和測試結果, 讓我對其實現原理產生了很大的好奇心, 接下來便看看它是如何將性能做到這個地步的, 這裏對主要對 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) {
...... // 省略庫文件加載器相關代碼
// 保存根目錄
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 函數在一個進程內只會執行一次
// 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. 根據路徑, 生成目標地址的目錄
mkPath(path);
// 釋放內存
free(path);
}
}
可以看到 initializeMMKV 中主要任務是初始化數據, 以及創建根目錄
- pthread_once_t: 類似於 Java 的單例, 其 initialize 方法在進程內只會執行一次
- 創建 MMKV 對象的緩存散列表 g_instanceDic
- 創建一個線程鎖 g_instanceLock
- mkPath: 根據字符串創建文件目錄
接下來我們看看這個目錄創建的過程
目錄的創建
// MmapedFile.cpp
bool mkPath(char *path) {
// 定義 stat 結構體用於描述文件的屬性
struct stat sb = {};
bool done = false;
// 指向字符串起始地址
char *slash = path;
while (!done) {
// 移動到第一個非 "/" 的下標處
slash += strspn(slash, "/");
// 移動到第一個 "/" 下標出處
slash += strcspn(slash, "/");
done = (*slash == '\0');
*slash = '\0';
if (stat(path, &sb) != 0) {
// 執行創建文件夾的操作, C 中無 mkdirs 的操作, 需要一個一個文件夾的創建
if (errno != ENOENT || mkdir(path, 0777) != 0) {
MMKVWarning("%s : %s", path, strerror(errno));
return false;
}
}
// 若非文件夾, 則說明爲非法路徑
else if (!S_ISDIR(sb.st_mode)) {
MMKVWarning("%s: %s", path, strerror(ENOTDIR));
return false;
}
*slash = '/';
}
return true;
}
以上是 Native 層創建文件路徑的通用代碼, 邏輯很清晰
好的, 文件目錄創建好了之後, Native 層的初始化操作便結束了, 接下來看看 MMKV 實例構建的過程
三. 實例化
public class MMKV implements SharedPreferences, SharedPreferences.Editor {
@Nullable
public static MMKV mmkvWithID(String mmapID, int mode, String cryptKey, String relativePath) {
......
// 執行 Native 初始化, 獲取句柄值
long handle = getMMKVWithID(mmapID, mode, cryptKey, relativePath);
if (handle == 0) {
return null;
}
// 構建一個 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 實例構建的主要邏輯通過 getMMKVWithID 方法實現, 看它內部做了什麼
// 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 函數獲取一個 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;
}
}
......
// 強轉成句柄, 返回到 Java
return (jlong) kv;
}
}
可以看到最終通過 MMKV::mmkvWithID 函數獲取到 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. 創建緩存文件
if (relativePath) {
// 根據 mappedKVPathWithID 獲取 mmap 的最終文件路徑
// mmapID 使用 md5 加密
auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);
// 不存在則創建一個文件
if (!isFileExist(filePath)) {
if (!createFile(filePath)) {
return nullptr;
}
}
......
}
// 4. 創建實例對象
auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
// 5. 緩存這個 mmapKey
(*g_instanceDic)[mmapKey] = kv;
return kv;
}
mmkvWithID 函數的實現流程非常的清晰, 這裏我們主要關注一下實例對象的創建流程
// 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. 將文件映射到內存
, m_metaFile(m_crcPath, DEFAULT_MMAP_SIZE, (mode & MMKV_ASHMEM) ? MMAP_ASHMEM : MMAP_FILE)
......
, m_sharedProcessLock(&m_fileLock, SharedLockType)
......
, m_isAshmem((mode & MMKV_ASHMEM) != 0) {
......
// 判斷是否爲 Ashmem 跨進程匿名共享內存
if (m_isAshmem) {
// 創共享內存的文件
m_ashmemFile = new MmapedFile(m_mmapID, static_cast<size_t>(size), MMAP_ASHMEM);
m_fd = m_ashmemFile->getFd();
} else {
m_ashmemFile = nullptr;
}
// 根據 cryptKey 創建 AES 加解密的引擎
if (cryptKey && cryptKey->length() > 0) {
m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
}
......
// sensitive zone
{
SCOPEDLOCK(m_sharedProcessLock);
// 2. 根據 m_mmapID 來加載文件中的數據
loadFromFile();
}
}
可以從 MMKV 的構造函數中看到很多有趣的信息, MMKV 是支持 Ashmem 共享內存的, 這意味着即使是跨進程大數據的傳輸, 它也能夠提供很好的性能支持
不過這裏我們主要關注兩個關鍵點
- m_metaFile 文件的映射
- loadFromFile 數據的載入
接下來我們先看看, 文件的映射
一) 文件映射到內存
// MmapedFile.cpp
MmapedFile::MmapedFile(const std::string &path, size_t size, bool fileType)
: m_name(path), m_fd(-1), m_segmentPtr(nullptr), m_segmentSize(0), m_fileType(fileType) {
// 用於內存映射的文件
if (m_fileType == MMAP_FILE) {
// 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. 創建文件鎖
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. 驗證文件的大小是否小於一個內存頁, 一般爲 4kb
if (m_segmentSize < DEFAULT_MMAP_SIZE) {
m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE);
// 3.1 通過 ftruncate 將文件大小對其到內存頁
// 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 將文件映射到內存, 獲取內存首地址
m_segmentPtr =
(char *) mmap(nullptr, m_segmentSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_segmentPtr == MAP_FAILED) {
MMKVError("fail to mmap [%s], %s", m_name.c_str(), strerror(errno));
close(m_fd);
m_fd = -1;
m_segmentPtr = nullptr;
}
}
}
// 用於共享內存的文件
else {
......
}
}
MmapedFile 的構造函數處理的事務如下
- 打開指定的文件
- 創建這個文件鎖
- 修正文件大小, 最小爲 4kb
- 前 4kb 用於統計數據總大小
- 通過 mmap 將文件映射到內存
好的, 通過 MmapedFile 的構造函數, 我們便能夠獲取到映射後的內存首地址了, 操作這塊內存時 Linux 內核會負責將內存中的數據同步到文件中
比起 SP 的數據同步, mmap 顯然是要優雅的多, 即使進程意外死亡, 也能夠通過 Linux 內核的保護機制, 將進行了文件映射的內存數據刷入到文件中, 提升了數據寫入的可靠性
結下來看看數據的載入
二) 數據的載入
// MMKV.cpp
void MMKV::loadFromFile() {
......// 忽略匿名共享內存相關代碼
// 若已經進行了文件映射
if (m_metaFile.isFileValid()) {
// 則獲取相關數據
m_metaInfo.read(m_metaFile.getMemory());
}
// 獲取文件描述符
m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
if (m_fd < 0) {
MMKVError("fail to open:%s, %s", m_path.c_str(), strerror(errno));
} else {
// 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 將文件大小對其到內存頁的整數倍
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
......
}
// 2. 獲取文件映射後的內存地址
m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
......
} else {
// 3. 讀取內存文件的前 32 位, 獲取存儲數據的真實大小
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 {
// 若不正確, 則回調異常 CRC 異常
auto strategic = mmkv::onMMKVCRCCheckFail(m_mmapID);
if (strategic == OnErrorRecover) {
loadFromFile = true;
needFullWriteback = true;
}
}
} else {
// 回調文件長度異常
auto strategic = mmkv::onMMKVFileLengthError(m_mmapID);
if (strategic == OnErrorRecover) {
writeAcutalSize(m_size - Fixed32Size);
loadFromFile = true;
needFullWriteback = true;
}
}
}
// 6. 需要從文件獲取數據
if (loadFromFile) {
......
// 構建輸入緩存
MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
if (m_crypter) {
// 解密輸入緩衝中的數據
decryptBuffer(*m_crypter, inputBuffer);
}
// 從輸入緩衝中將數據讀入 m_dic
m_dic.clear();
MiniPBCoder::decodeMap(m_dic, inputBuffer);
// 構建輸出數據
m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
m_size - Fixed32Size - m_actualSize);
// 進行重整回寫, 剔除重複的數據
if (needFullWriteback) {
fullWriteback();
}
}
// 7. 說明文件中沒有數據, 或者校驗失敗了
else {
SCOPEDLOCK(m_exclusiveProcessLock);
// 清空文件中的數據
if (m_actualSize > 0) {
writeAcutalSize(0);
}
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
// 重新計算 CRC
recaculateCRCDigest();
}
......
}
}
......
m_needLoadFromFile = false;
}
好的, 可以看到 loadFromFile 中對於 CRC 驗證通過的文件, 會將文件中的數據讀入到 m_dic 中緩存, 否則則會清空文件
-
因此用戶惡意修改文件之後, 會破壞 CRC 的值, 這個存儲數據便會被作廢, 這一點要尤爲注意
-
從文件中讀取數據到 m_dic 之後, 會將 mdic 回寫到文件中 , 其重寫的目的是爲了剔除重複的數據
-
關於爲什麼會出現重複的數據, 在後面 encode 操作中再分析
三) 回顧
到這裏 MMKV 實例的構建就完成了, 有了 m_dic 這個內存緩存, 我們進行數據查詢的效率就大大提升了
從最終的結果來看它與 SP 是一致的, 都是初次加載時會將文件中所有的數據加載到散列表中, 不過 MMKV 多了一步數據回寫的操作, 因此當數據量比較大時, 對實例構建的速度有一定的影響
// 寫入 1000 條數據之後, MMVK 和 SharedPreferences 實例化的時間對比
E/TAG: create MMKV instance time is 4 ms
E/TAG: create SharedPreferences instance time is 1 ms
從結果上來看, MMVK 的確在實例構造速度上有一定的劣勢, 不過得益於是將 m_dic 中的數據寫入到 mmap 的內存, 其真正進行文件寫入的時機由 Linux 內核決定, 再加上文件的頁緩存機制, 所以速度上雖有劣勢, 但不至於無法接受
四. encode
關於 encode 即數據的添加與更新的流程, 這裏以 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 層的實現
// 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 函數, 將數據存入
string value = jstring2string(env, oValue);
return (jboolean) kv->setStringForKey(value, key);
}
// 若是 value 爲 NULL, 則移除 key 對應的 value 值
else {
kv->removeValueForKey(key);
return (jboolean) true;
}
}
return (jboolean) false;
}
}
這裏我們主要分析一下 setStringForKey 這個函數
// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
if (key.empty()) {
return false;
}
// 1. 將數據編碼成 ProtocolBuffer
auto data = MiniPBCoder::encodeDataWithObject(value);
// 2. 更新鍵值對
return setDataForKey(std::move(data), key);
}
這裏主要分爲兩步操作
- 數據編碼
- 更新鍵值對
一) 數據的編碼
MMKV 採用的是 ProtocolBuffer 編碼方式, 這裏就不做過多介紹了, 具體請查看 Google 官方文檔
// MiniPBCoder.cpp
MMBuffer MiniPBCoder::getEncodeData(const string &str) {
// 1. 創建編碼條目的集合
m_encodeItems = new vector<PBEncodeItem>();
// 2. 爲集合填充數據
size_t index = prepareObjectForEncode(str);
PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
if (oItem && oItem->compiledSize > 0) {
// 3. 開闢一個內存緩衝區, 用於存放編碼後的數據
m_outputBuffer = new MMBuffer(oItem->compiledSize);
// 4. 創建一個編碼操作對象
m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());
// 執行 protocolbuffer 編碼, 並輸出到緩衝區
writeRootObject();
}
// 調用移動構造函數, 重新創建實例返回
return move(*m_outputBuffer);
}
size_t MiniPBCoder::prepareObjectForEncode(const string &str) {
// 2.1 創建 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 填充要編碼的數據
encodeItem->value.strValue = &str;
// 2.6 填充數據大小
encodeItem->valueSize = static_cast<int32_t>(str.size());
}
// 2.7 計算編碼後的大小
encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;
return index;
}
可以看到, 再未進行編碼操作之前, 編碼後的數據大小就已經確定好了, 並且將它保存在了 encodeItem->compiledSize 中, 接下來我們看看執行數據編碼並輸出到緩衝區的操作流程
// 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) {
// 主要關心編碼 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. 將字符串的數據拷貝到編碼好的長度後面
memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes);
// 更新 position 的值
m_position += numberOfBytes;
}
可以看到 CodedOutputData 的 writeString 中按照 protocol buffer 進行了字符串的編碼操作
其中 m_ptr 是上面開闢的內存緩衝區的地址, 也就是說 writeString 執行結束之後, 數據就已經被寫入緩衝區了
有了編碼好的數據緩衝區, 接下來看看更新鍵值對的操作
二) 鍵值對的更新
// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
// 編碼數據獲取存放數據的緩衝區
auto data = MiniPBCoder::encodeDataWithObject(value);
// 更新鍵值對
return setDataForKey(std::move(data), key);
}
bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
......
// 將鍵值對寫入 mmap 文件映射的內存中
auto ret = appendDataWithKey(data, key);
// 寫入成功, 更新散列數據
if (ret) {
m_dic[key] = std::move(data);
m_hasFullWriteback = false;
}
return ret;
}
bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
// 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. 驗證是否有足夠的空間, 不足則進行數據重整與擴容操作
bool hasEnoughSize = ensureMemorySize(size);
if (!hasEnoughSize || !isFileValid()) {
return false;
}
// 3. 更新文件頭的數據總大小
writeAcutalSize(m_actualSize + size);
// 4. 將 key 和編碼後的 value 寫入到文件映射的內存
m_output->writeString(key);
m_output->writeData(data);
// 5. 獲取文件映射內存當前 <key, value> 的起始位置
auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
if (m_crypter) {
// 加密這塊區域
m_crypter->encrypt(ptr, ptr, size);
}
// 6. 更新 CRC
updateCRCDigest(ptr, size, KeepSequence);
return true;
}
好的, 可以看到更新鍵值對的操作還是比較複雜的, 首先將鍵值對數據寫入到文件映射的內存中, 寫入成功之後更新散列數據
關於寫入到文件映射的過程, 上面代碼中的註釋也非常的清晰, 接下來我們 ensureMemorySize 是如何進行數據的重整與擴容的
數據的重整與擴容
// MMKV.cpp
bool MMKV::ensureMemorySize(size_t newSize) {
......
// 計算新鍵值對的大小
constexpr size_t ItemSizeHolderSize = 4;
if (m_dic.empty()) {
newSize += ItemSizeHolderSize;
}
// 數據重寫:
// 1. 文件剩餘空閒空間少於新的鍵值對
// 2. 散列爲空
if (newSize >= m_output->spaceLeft() || m_dic.empty()) {
// 計算所需的數據空間
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. 所需空間 >= 當前文件總大小
// 2. 所需空間的 1.5 倍 >= 當前文件總大小
if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
// 擴容爲 2 倍
size_t oldSize = m_size;
do {
m_size *= 2;
} while (lenNeeded + futureUsage >= m_size);
.......
}
}
......
// 進行數據的重寫
writeAcutalSize(data.length());
......
}
return true;
}
從上面的代碼我們可以瞭解到
- 數據的重寫時機
- 文件剩餘空間少於新的鍵值對大小
- 散列爲空
- 文件擴容時機
- 所需空間的 1.5 倍超過了當前文件的總大小時, 擴容爲之前的兩倍
三) 回顧
至此 encode 的流程我們就走完了, 回顧一下整個 encode 的流程
-
使用 ProtocolBuffer 編碼 value
-
將key和編碼後的 value使用 ProtocolBuffer 的格式 append 到文件映射區內存的尾部
- 文件空間不足
- 判斷是否需要擴容
- 進行數據的回寫
- 即在文件後進行追加
- 文件空間不足
-
對這個鍵值對區域進行統一的加密
-
更新 CRC 的值
-
將 key 和 value 對應的 ProtocolBuffer 編碼內存區域, 更新到散列表 m_dic 中
通過 encode 的分析, 我們得知 MMKV 文件的存儲方式如下
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, 將數據輸出到傳出參數中 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. 從內存緩存中獲取數據
auto &data = getDataForKey(key);
if (data.length() > 0) {
// 2. 解析 data 對應的 ProtocolBuffer 數據
result = MiniPBCoder::decodeString(data);
return true;
}
return false;
}
const MMBuffer &MMKV::getDataForKey(const std::string &key) {
// 從散列表中獲取 key 對應的 value
auto itr = m_dic.find(key);
if (itr != m_dic.end()) {
return itr->second;
}
static MMBuffer nan(0);
return nan;
}
好的可以看到 decode 的流程比較簡單, 先從內存緩存中獲取 key 對應的 value 的 ProtocolBuffer 內存區域, 再解析這塊內存區域, 從中獲取真正的 value 值
思考
看到這裏可能會有一個疑問, 爲什麼 m_dic 不直接存儲 key 和 value 原始數據呢, 這樣查詢效率不是更快嗎?
- 如此一來查詢效率的確會更快, 因爲少了 ProtocolBuffer 解碼的過程
讀取性能對比
從圖上的結果可以看出, MMKV 的讀取性能時略低於 SharedPreferences 的, 這裏筆者給出自己的思考
- m_dic 在數據重整中也起到了非常重要的作用, 需要依靠 m_dic 將數據寫入到 mmap 的文件映射區, 這個過程是非常耗時的, 若是原始的 value, 則需要對所有的 value 再進行一次 ProtocolBuffer 編碼操作, 尤其是當數據量比較龐大時, 其帶來的性能損耗更是無法忽略的
既然 m_dic 還承擔着方便數據複寫的功能, 那能否再添加一個內存緩存專門用於存儲原始的 value 呢?
- 當然可以, 這樣 MMKV 的讀取定是能夠達到 SharedPreferences 的水平, 不過 value 的內存消耗則會加倍, MMKV 作爲一個輕量級緩存的框架, 查詢時時間的提升幅度還不足以用內存加倍的代價去換取, 我想這是 Tencent 在進行多方面權衡之後, 得到的一個比較合理的解決方案
六. 進程讀寫的同步
說起進程間讀寫同步, 我們很自然的想到 Linux 的共享內存配合信號量使用的案例, 但是這種方式有一個弊端, 那就是當持有鎖的進程意外死亡的時候, 並不會釋放其擁有的信號量, 若多進程之間存在競爭, 那麼阻塞的進程將不會被喚醒, 這是非常危險的
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))
// 創建文件鎖的描述
, 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) {
......
// 根據是否跨進程操作判斷共享鎖和排它鎖的開關
m_sharedProcessLock.m_enable = m_isInterProcess;
m_exclusiveProcessLock.m_enable = m_isInterProcess;
// sensitive zone
{
// 文件讀操作, 啓用了文件共享鎖
SCOPEDLOCK(m_sharedProcessLock);
loadFromFile();
}
}
可以看到在我們前面分析過的構造函數中, MMKV 對文件鎖進行了初始化, 並且創建了共享鎖和排它鎖, 並在跨進程操作時開啓, 當進行讀操作時, 啓動了共享鎖
二) 文件排它鎖
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;
}
在進行數據回寫的函數中, 啓動了排它鎖
三) 讀寫效率表現
其進程同步讀寫的性能表現如下
進程同步讀寫表現
可以看到進程同步讀寫的效率也是非常 nice 的
關於跨進程同步就介紹到這裏, 當然 MMKV 的文件鎖並沒有表面上那麼簡單, 因爲文件鎖爲狀態鎖, 無論加了多少次鎖, 一個解鎖操作就全解除, 顯然無法應對子函數嵌套調用的問題, MMKV 內部通過了自行實現計數器來實現鎖的可重入性, 更多的細節可以查看 wiki
總結
通過上面的分析, 我們對 MMKV 有了一個整體上的把控, 其具體的表現如下所示
項目 | 評價 | 描述 |
---|---|---|
正確性 | 優 | 支持多進程安全, 使用 mmap, 由操作系統保證數據回寫的正確性 |
時間開銷 | 優 | 使用 mmap 實現, 減少了用戶空間數據到內核空間的拷貝 |
空間開銷 | 中 | 使用 protocl buffer 存儲數據, 同樣的數據會比 xml 和 json 消耗空間小 使用的是數據追加到末尾的方式, 只有到達一定閾值之後纔會觸發鍵值合併, 不合並之前會導致同一個 key 存在多份 |
安全 | 中 | 使用 crc 校驗, 甄別文件系統和操作系統不穩定導致的異常數據 |
開發成本 | 優 | 使用方式較爲簡單 |
兼容性 | 優 | 各個安卓版本都前後兼容 |
雖然 MMKV 一些場景下比 SP 稍慢(如: 首次實例化會進行數據的複寫剔除重複數據, 比 SP 稍慢, 查詢數據時存在 ProtocolBuffer 解碼, 比 SP 稍慢), 但其逆天的數據寫入速度、mmap Linux 內核保證數據的同步, 以及 ProtocolBuffer 編碼帶來的更小的本地存儲空間佔用等都是非常棒的閃光點
在分析 MMKV 的代碼的過程中, 從中學習到了很多知識, 非常感謝 Tencent 爲開源社區做出的貢獻
參考文獻
- https://github.com/Tencent/MMKV/wiki/android_setup_cn
- https://developers.google.com/protocol-buffers/docs/encoding
- https://time.geekbang.org/column/article/76677
- https://www.cnblogs.com/kex1n/p/7100107.html
轉載自SharryChoo的博客