腾讯开源组件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

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值