騰訊開源組件MMKV的使用及原理(1)

https://github.com/Tencent/MMKV/blob/master/readme_cn.md

在需要持久化保存key-value這樣的鍵值對時,通常考慮使用的是SharedPreference,SP最終以xml文件的形式保存數據,並且是直接IO的方式讀寫數據,在使用中會概率性碰到ANR的問題,不管是使用異步的方法apply,還是阻塞式的commit提交數據,都看會因爲IO的瓶頸導致ANR,在使用commit提交數據,因爲要等待數據寫入完成返回這裏如果IO較慢導致ANR很容易理解;在apply提交時,儘管是異步的方式先把數據保存到內存,然後起一個異步的任務去寫入磁盤,依然會有可能掛在waittofinish上,這個waittofinish方法會在activity暫停,Broadcastreceiver的onreceive調用後,service的命令執行後被調用,爲的是確保前面執行寫入磁盤的異步任務執行完成,如果Io寫入較慢,導致ANR就是難以避免的。

那麼這種文件寫入的IO操作爲什麼性能不高呢?因爲操作系統把虛擬內存分成了用戶空間、內核空間,並且這兩個空間是隔離開的,用戶程序運行在用戶空間的,所以write操作首先需要把數據從用戶空間拷貝到內核空間,然後經過操作系統的調度在從內核空間把數據拷貝到磁盤,完成寫入。

還有一點,如果在使用SharedPreference的過程中出現的crash,可能導致數據丟失,也因爲它用xml文件保存數據,所以數據的更新只能用全量更新的方式。

最後,SharedPreference的鎖性能也差,因爲它的讀寫鎖鎖定的都是SharedPreference對象,鎖粒度偏大。

說了這麼多SharedPreference的不足,就是爲了說mmkv就是來替代SharedPreference的,從mmkv的源碼可以看到它是繼承了SharedPreference,可以認爲是對SharedPreference的再實現。

public class MMKV implements SharedPreferences, SharedPreferences.Editor {}

mmkv是基於mmap內存映射實現的key-value組件,底層序列化,反序列化使用protobuf實現。更詳細的介紹可以參考github上介紹。

一,MMKV的使用,

https://github.com/Tencent/MMKV/wiki/android_setup_cn

從官方的demo及對比數據,可以得知在寫入輕量級k-v數據時,以千次來測試,mmkv耗時是毫米級的,而SharedPreference都在4、5秒左右,差距非常的明顯,當然在讀取時差別不大,因爲都是從內存讀取。

二,MMKV的原理,(使用protobuf序列化反序列的實現,其中的鎖機制,跨進程的實現)

1,mmkv使用mmap內存映射實現對文件的讀寫,mmap就是將磁盤上的一個文件或者其他對象映射到進程的一塊虛擬內存地址空間,這樣進程就可以通過指針讀寫這塊內存,而系統會自動會寫髒頁面到磁盤文件,從而避免了write、read的系統調用。同時這個過程,1,也避免了創建線程的開銷,2,減少了數據拷貝的次數(只需要從磁盤拷貝到用戶主存),3,用戶只管往內存寫入數據,不用擔心crash導致數據丟失,因爲操作系統會負責把內存數據回寫到文件。

對mmap的簡單使用(寫數據,讀數據):

extern "C"
JNIEXPORT void JNICALL
Java_com_test_mmkvdemo_MainActivity_writeDataByMmap(JNIEnv *env, jobject instance) {
    m_file= "/sdcard/mmkv/test.txt";
    m_fd = open(m_file.c_str(), O_RDWR | O_CREAT, S_IRUSR);
    m_size = getpagesize();
    ftruncate(m_fd, m_size);//將文件設置爲size大小,默認一頁大小
    //映射文件到內存
    m_ptr = (int8_t *)mmap(0, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
    string data("test write data by mmap....");
    memcpy(m_ptr, data.data(), data.size());
    __android_log_print(ANDROID_LOG_DEBUG,"mmap","write data %d ,ptr %p", m_fd, m_ptr);
}

extern "C"
JNIEXPORT void JNICALL
Java_com_test_mmkvdemo_MainActivity_readDataByMmap(JNIEnv *env, jobject instance) {
    char *buf = static_cast<char *>(malloc(100));
    memset(buf, 0, 100);
    memcpy(buf, m_ptr, 100);
    string result(buf);
    __android_log_print(ANDROID_LOG_DEBUG,"mmap","read data %s ,ptr %p", result.c_str(), m_ptr);
    munmap(m_ptr, m_size);
    close(m_fd);
}

這裏不做mmap寫入數據,與IO寫入數據的對比,應爲騰訊的項目已經做了對比,使用mmap寫入數據的速度要比IO直接寫文件高上100倍,

https://mp.weixin.qq.com/s/kDPTt9Rtd-PERXXW-UyUlQ

https://tech.meituan.com/2018/02/11/logan.html

從這個對比,可以知道mmkv要比SharedPreference寫入數據效率會高很多。

在去分析mmkv之前,有必要看下mmkv的是怎麼存儲數據的。

如demo寫入兩個k-v數據:

    private void useMmkv() {
        String path = MMKV.initialize("/sdcard/mmkv");
        MMKV mmkv = MMKV.mmkvWithID("first_mmkv", MMKV.MULTI_PROCESS_MODE);
        mmkv.encode("booltest", true);
        mmkv.encode("inttest", 1);
        Log.d(TAG,"mmkv,useMmkv,booltest="+mmkv.getBoolean("booltest",false)
        +",path="+path);
        Log.d(TAG,"mmkv,useMmkv,inttest="+mmkv.getInt("inttest",0)
                +",path="+path);
    }

生成的first_mmkv文件,使用二進制方式查看,

前面四個字節,表示文件的有效長度,16(十六進制)說明文件內容有效長度是22字節,後面08表示第一個key的長度8個字節,後面接着讀8個字節就是key的值,在接着01表示value的長度1個字節,接着讀取1個字節就是value的值,依次往下讀取,

說明他的存儲方式類似鏈表,

這種存儲格式很容易做增量跟新,只要往後面追加,在讀取時,會把k-v放入一個map集合,這樣後面的數據(如果key相同)就會覆蓋前面的k-v,所以map集合中總是可以拿到最新的值。

 

2,mmkv如何是對數據編碼的。

從上面的mmkv文件截圖看,第一個key佔了2個字節,第二個key佔了一字節,mmkv具體是怎麼處理的。

根據github的介紹,mmkv底層使用protobuf來編碼,解碼數據,接着就看protobuf是如何編碼數據的,這裏有一個概念就是可變長編碼,也就是protobuf採取的編碼方式。

簡單說 定長編碼,如一個int數據,總是佔用4個字節,

變長編碼,如果一個int數據,只需要兩個字節就能表示,那就沒有必要佔用4個字節。

來個例子比較直觀,如127這個十進制的數,按照定長編碼就要佔用4個字節,但是按照protobuf的變長編碼就只要一個字節。

這裏有個關鍵點,在protobuf中,一個字節8位,其中的最高位是標記位,低七位是數據位。如果符號位是0,表示後續字節不再需要了。

在看一個數據128在protobuf中藥佔用幾個字節,

前面說一個字節只有低七位表示數據,最高位符號位如果是1,就表示需要更多字節,還需要再佔用一個字節,所以128就佔2個字節,

128怎麼存?

 首先會寫入 1000 0000,第一個字節,注意這裏的高位1不是有效的數據位,而是標記位,表示後續還有字節要處理。

然後,將128的二進制位右移7位,也即是0000 0001,寫入第二個字節,因爲此時最高位是0,表示不再需要處理後續字節了。

那讀取時怎麼讀取?

首先,讀出第一個字節,1000 0000,判斷最高位符號位是否爲1,來確定是否繼續讀取剩餘字節。

去掉符號位,保留數據位000 0000

然後,因爲第一個字節,最高位1,接着讀取第二個字節,0000 0001,因爲這時的高位爲0,不在讀取剩餘字節了。同樣去掉符號位,保留數據位 000 0001,

最後,拼接,000 0001左移七位,| 上000 0000,多餘的0去掉,結果就是128,

結合代碼,看一下mmkv是怎麼做的:

CodedOutputData.cpp

void CodedOutputData::writeRawVarint32(int32_t value) {
    while (true) {
        if ((value & ~0x7f) == 0) {
            this->writeRawByte(static_cast<uint8_t>(value));
            return;
        } else {
            this->writeRawByte(static_cast<uint8_t>((value & 0x7F) | 0x80));
            value = logicalRightShift32(value, 7);
        }
    }
}

對於一個int型的正數,看if語句的條件,0x7f是127,按位取反就是1000 000,一個數 & 1000 0000,結果低七位肯定是0,那麼高位第8位,如果也是是0,這個結果就等於0,說明value的高位第8位是0,不管其餘7位是多少,value都是小於127的,那就只用一個字節表示就夠了,所以直接寫入一個字節,返回。

否則,就是value大於127,先是value & 7f(注意這裏沒有取反),拿到低7位,然後 | 80,把高位置1,表示還需要更多字節表示這個數據,寫入這個字節(實際是數據的低7位),接着把value右移7位繼續處理。

依據上面的分析,可以自己模擬去實現下變長編碼怎麼去存儲一個數據,

對於一個32位的正整數,需要最多5個字節來存儲(按每個字節僅有7個數據位,要表示32位的數據,就需要32/7 = 5,考慮到最多會右移5次,需要5個標記位,所以最多5個字節就可以表示一個32的正整數)

對於一個64位的正整數,需要最多10個字節存儲(按每個字節僅有7個數據位,要表示64位的數據,就需要64/7 = 9,考慮到最多會右移9次,需要9標記位,所以最多10個字節就可以表示一個64的正整數)

int8_t *m_buf;//保存一段數據申請的空間
int32_t m_position;
int32_t m_index;
//計算需要幾個字節存儲數據

int32_t calculateInt32Size(int32_t value) {
    if ((value & (0xffffffff << 7)) == 0) {
        return 1;
    } else if ((value & (0xffffffff << 14)) == 0) {
        return 2;
    } else if ((value & (0xffffffff << 21)) == 0) {
        return 3;
    } else if ((value & (0xffffffff << 28)) == 0) {
        return 4;
    } else {
        return 5;
    }
}

int32_t calcuateInt64Size(int64_t value) {
    if ((value & (0xffffffffffffffffL << 7)) == 0) {
        return 1;
    } else if ((value & (0xffffffffffffffffL << 14)) == 0) {
        return 2;
    } else if ((value & (0xffffffffffffffffL << 21)) == 0) {
        return 3;
    } else if ((value & (0xffffffffffffffffL << 28)) == 0) {
        return 4;
    } else if ((value & (0xffffffffffffffffL << 35)) == 0) {
        return 5;
    } else if ((value & (0xffffffffffffffffL << 42)) == 0) {
        return 6;
    } else if ((value & (0xffffffffffffffffL << 49)) == 0) {
        return 7;
    } else if ((value & (0xffffffffffffffffL << 56)) == 0) {
        return 8;
    } else if ((value & (0xffffffffffffffffL << 63)) == 0) {
        return 9;
    } else {
        return  10;
    }
}
//單個字節寫入
void writeByte(int8_t value) {
    if (m_position == m_index) {
        //存儲滿
        return;
    }
    m_buf[m_position++] = value;
}
//32位正整數的寫入
extern "C"
JNIEXPORT void JNICALL
Java_com_test_mmkvdemo_MainActivity_writeInt32(JNIEnv *env, jobject instance, jint value_tmp) {
    uint32_t  value = value_tmp;
    m_index = calculateInt32Size(value_tmp);
    while (true) {
        if ((value & ~0x7f) == 0) {
            writeByte(value);
            return;
        } else {
            writeByte((value & 0x7f) | 0x80);
            value >>= 7;
        }
    }
}

上面在對整數編碼時,都特別強調了是正整數,那麼負數怎麼處理的?

負數在計算機中的二進制表示是以補碼的形式表示的,比如-1的補碼就是正1的反碼在加1, 結果就是64個全1.

protobuf爲了讓int32跟int64在編碼格式上兼容,對負數的編碼將int32做int64處理,所以負數的編碼長度都是10個字節.

void CodedOutputData::writeInt32(int32_t value) {
    if (value >= 0) {
        this->writeRawVarint32(value);
    } else {
        this->writeRawVarint64(value);
    }
}

還有一種需要特殊處理的就是浮點數,在protobuf中浮點數是定長編碼爲4個字節,但是float無法通過位移獲取到每個字節,考慮到int32也是四個字節,所以把float轉換成int32處理,那怎麼樣把float轉成int32又不損失精度呢?

static inline int32_t Float32ToInt32(float v) {
    Converter<float, int32_t> converter;
    converter.first = v;
    return converter.second;
}
template <typename T, typename P>
union Converter {
    static_assert(sizeof(T) == sizeof(P), "size not match");
    T first;
    P second;
};

這裏用了共用體,借用內存共用, 就是給共用體的float變量一個浮點數,然後以int32的變量取出來,就可以得到一個用int32表示的不損失精度的浮點數.

3,最後看下mmkv是怎麼實現跨進程的.

https://mp.csdn.net/console/editor/html/104485708

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章