前面我们提到,在优化传统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函数可以拿到磁盘映射的物理内存的虚拟地址,看下图:
在内核空间有一块与磁盘空间映射的物理内存区域,而在用户空间是能够拿到这块物理内存的虚拟内存地址,即通过调用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的数据,会比较浪费内存。
跨进程读写数据
对于SharedPreference存储方式来说,无法支持跨进程读写数据,只能在单一进程存储,而如果想要实现跨进程数据存取,其实也很简单,看下图:
因为磁盘文件存储在手机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的核心原理。
上面这张图是从官网copy的一张图,这里显示了使用SharedPreference和MMKV的写入效率,其实为什么MMKV能够提升了几十倍的写入效率,还是得益于mmap的内存映射避免了内核态与用户态的切换,从而突破了传统IO瓶颈(二次拷贝)