Android-性能优化-07- 存储优化-MMKV-自己实现

88 阅读3分钟

前面我们提到,在优化传统IO存储时,不想通过用户空间与内核空间上下文的调度来实现文件读写,所以就会想到mmap能够实现零拷贝读写文件,在效率上面肯定要比传统的磁盘IO要快,那么首先我们先看下mmap函数是如何使用,这里可能会涉及到C++以及JNI的知识储备。

mmap的使用

首先定义一个方法writeBymmap,在native层通过调用mmap函数实现文件的读写。

kotlin
代码解读
复制代码
class NativeLib {

    /**
     * A native method that is implemented by the 'nativelib' native library,
     * which is packaged with this application.
     */
    external fun stringFromJNI(): String
    
    external fun writeBymmap(fileName:String)



    companion object {
        // Used to load the 'nativelib' library on application startup.
        init {
            System.loadLibrary("nativelib")
        }
    }
}

对于mmap函数的参数定义,我们需要了解其中的意义。

c++
代码解读
复制代码
void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset);
  • _addr :  指向要映射的内存起始地址,一般设置为null由系统决定,映射成功之后会返回这块内存地址;
  • _size :  将文件中多大的长度映射到内存空间;
  • _port :  内存保护标志 ,一般为以下四种方式 -> PROT_EXEC 映射区域可被执行 PROT_READ 映射区域可被读取 PROT_WRITE 映射区域可被写入 PROT_NONE 映射区域不能存取;
  • _flags :  这块映射区域是否可以被其他进程共享,如果是私有的,那么只有当前进程可映射;如果是共享的,那么其他进程也可以获取此映射内存;
  • _fd :  要映射到内存中的文件描述符,通过open函数可以获取,存储完成之后,需要调用close;
  • _offset :  文件映射的偏移量,一般设置为0.
c++
代码解读
复制代码
extern "C"
JNIEXPORT void JNICALL
Java_com_lay_nativelib_NativeLib_writeBymmap(JNIEnv *env, jobject thiz, jstring file_name) {

    std::string file = env->GetStringUTFChars(file_name, nullptr);
    //获取文件描述符
    int fd = open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //设置文件大小
    ftruncate(fd, 4 * 1024);
    //调用mmap函数,返回的是物理映射的虚拟内存地址
    int8_t *ptr = static_cast<int8_t *>(mmap(0, 4 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
                                             0));

    //要写入文件的内容
    std::string data("这里是要写入文件的内容");
    //用户空间可以操作这个虚拟内存地址 
    memcpy(ptr, data.data(), data.size());
}

通过调用了mmap函数可以拿到磁盘映射的物理内存的虚拟地址,看下图:

image.png

在内核空间有一块与磁盘空间映射的物理内存区域,而在用户空间是能够拿到这块物理内存的虚拟内存地址,即通过调用mmap函数获取;那么后续想要执行写入操作,那么只需要在用户空间操作虚拟内存即可,就可以将数据写入到磁盘中,不需要通过用户空间和内核空间的上下文调度,从而提高了效率。

经过测试,调用了NativeLib()的writeBymmap方法,在文件中写入了数据。

kotlin
代码解读
复制代码
fun testMmap(fileName: String) {

    //记录时间
    val currentTime = System.currentTimeMillis()
    for (index in 0..1000) {
        NativeLib().writeBymmap(fileName)
    }
    Log.d(TAG, "testMmap: cost ${System.currentTimeMillis() - currentTime}")
}

我们可以采用这种方式计算一下,最终拿到的结果是:

java
代码解读
复制代码
D/LocalStorageUtil: testSP: cost 166
D/LocalStorageUtil: testMmap: cost 16

我们看到与MMKV的效率基本一致,但是前面我们自定义的mmap写文件方式是存在缺陷的:如果我们只想写1个字节的数据,但最终会写入4k的数据,会比较浪费内存。

image.png

跨进程读写数据

对于SharedPreference存储方式来说,无法支持跨进程读写数据,只能在单一进程存储,而如果想要实现跨进程数据存取,其实也很简单,看下图:

image.png

因为磁盘文件存储在手机sd卡中,在其他进程也可以通过读取文件的方式从磁盘获取,但这样又无法避免内核态到用户态的切换 ,因此通过上图看,进程A写入到磁盘数据之后,进程B也可以通过虚拟内存地址拷贝一份数据到本地,从而完成跨进程读数据。

c++
代码解读
复制代码
extern "C"
JNIEXPORT jstring JNICALL
Java_com_lay_nativelib_NativeLib_getDataFromDisk(JNIEnv *env, jobject thiz, jstring file_name) {
    std::string file = env->GetStringUTFChars(file_name, nullptr);
    //获取文件描述符
    int fd = open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //设置文件大小
    ftruncate(fd, 4 * 1024);
    //调用mmap函数,返回的是物理映射的虚拟内存地址
    int8_t *ptr = static_cast<int8_t *>(mmap(0, 4 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
                                             0));
    //需要一块buffer存储数据
    char *buffer = static_cast<char *>(malloc(100));
    //将物理内存拷贝到buffer
    memcpy(buffer, ptr, 100);
    //取消映射
    munmap(ptr, 4 * 1024);
    close(fd);
    //char 转 jstring
    return env->NewStringUTF(buffer);
}

具体的调用为:

kotlin
代码解读
复制代码
NativeLib().getDataFromDisk("/data/data/com.tal.pad.appmarket/files/NewTextFile.txt").also {
    Log.d("MainActivity", "getDataFromDisk: $it")
}

D/MainActivity: getDataFromDisk: 这里是要写入文件的内容

至此,通过mmap获取物理内存映射的虚拟内存地址后,只需要一次拷贝(memcpy)就能够实现文件的读写,而且支持跨进程的存取,这也是MMKV的核心原理。

profile_android_mini.png

上面这张图是从官网copy的一张图,这里显示了使用SharedPreference和MMKV的写入效率,其实为什么MMKV能够提升了几十倍的写入效率,还是得益于mmap的内存映射避免了内核态与用户态的切换,从而突破了传统IO瓶颈(二次拷贝)