揭秘 Android MMKV:数据存储与读取的底层原理深度剖析
一、引言
在 Android 开发领域,数据的存储与读取是一项基础且关键的操作。传统的数据存储方式,如 SharedPreferences,虽然使用方便,但在性能上存在一定的局限性,尤其是在处理大量数据或高并发读写的场景下。而 MMKV(MultiProcess Key - Value)作为一款高性能的键值对存储框架,凭借其卓越的性能和简洁的 API 受到了开发者的广泛关注。
MMKV 基于 mmap 内存映射技术和 Protobuf 数据编码,实现了高效的数据存储和读取。它不仅支持单进程操作,还能很好地处理多进程间的数据共享问题。本文将深入剖析 MMKV 如何进行数据的存储和读取,从源码级别详细解读每一个步骤,帮助开发者更好地理解和使用这一强大的框架。
二、MMKV 简介
2.1 MMKV 概述
MMKV 是腾讯开源的一个高性能、轻量级的键值对存储框架,其设计初衷是为了解决 Android 平台上 SharedPreferences 性能不佳的问题。它通过 mmap 技术将文件映射到内存中,避免了频繁的 I/O 操作,从而显著提高了读写性能。同时,MMKV 使用 Protobuf 进行数据编码,保证了数据的高效存储和解析。
2.2 MMKV 的优势
- 高性能:基于 mmap 内存映射和 Protobuf 编码,读写速度快。
- 多进程支持:可以在多个进程间安全地共享数据。
- 简单易用:提供了与
SharedPreferences类似的 API,易于上手。
2.3 MMKV 的应用场景
- 配置信息存储:如用户的偏好设置、应用的配置参数等。
- 缓存数据存储:临时缓存一些数据,提高应用的响应速度。
三、MMKV 初始化
3.1 初始化流程概述
MMKV 的初始化是使用它进行数据存储和读取的第一步。在初始化过程中,MMKV 会完成文件的创建或打开、内存映射的设置以及一些必要的数据结构的初始化。
3.2 初始化代码分析
以下是 MMKV 在 Android 中的初始化代码示例:
// 在 Application 的 onCreate 方法中进行初始化
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// 获取应用的文件目录
String rootDir = MMKV.initialize(this);
// 打印初始化后的根目录
Log.d("MMKV", "MMKV root: " + rootDir);
}
}
下面我们深入分析 MMKV.initialize 方法的源码:
// MMKV.java 文件中的 initialize 方法
public static String initialize(Context context) {
// 获取应用的内部存储目录
File root = context.getFilesDir();
// 在内部存储目录下创建一个名为 mmkv 的子目录
File rootDir = new File(root, "mmkv");
// 调用 native 方法进行初始化
return initialize(rootDir.getAbsolutePath());
}
// 调用 native 方法进行初始化
private static native String initialize(String rootDir);
在上述代码中,initialize 方法首先获取应用的内部存储目录,然后在该目录下创建一个名为 mmkv 的子目录,用于存储 MMKV 的数据文件。最后,调用 native 方法 initialize 进行底层的初始化操作。
接下来,我们看看 native 层的初始化代码(C++ 部分):
// MMKV.cpp 文件中的 initializeMMKV 方法
void initializeMMKV(const std::string &rootDir) {
// 检查根目录是否为空
if (rootDir.empty()) {
return;
}
// 初始化 MMKV 的根目录
MMKV::g_rootDir = rootDir;
// 创建根目录
mkdir(MMKV::g_rootDir.c_str(), 0777);
// 初始化线程锁
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&MMKV::g_instanceLock, &attr);
pthread_mutexattr_destroy(&attr);
}
在 native 层的初始化代码中,首先检查根目录是否为空,如果不为空则将其赋值给 MMKV::g_rootDir。然后创建根目录,并初始化一个递归锁 MMKV::g_instanceLock,用于后续的线程同步。
3.3 初始化的作用
初始化的主要作用是为 MMKV 的后续操作做好准备工作,包括确定数据文件的存储位置、创建必要的目录以及初始化线程锁等。通过初始化,MMKV 能够正确地管理数据文件,并保证在多线程环境下的安全性。
四、数据存储原理
4.1 数据存储流程概述
MMKV 的数据存储过程主要包括数据编码、内存写入和文件同步三个步骤。当调用 put 方法存储数据时,MMKV 会先将数据进行 Protobuf 编码,然后将编码后的数据写入到内存映射区域,最后在合适的时机将内存中的数据同步到文件中。
4.2 数据编码
MMKV 使用 Protobuf 进行数据编码,将各种类型的数据转换为字节流。以下是一个简单的示例,展示如何将一个字符串数据进行编码:
// MMKV.java 文件中的 putString 方法
public boolean putString(String key, @Nullable String value) {
// 检查 key 是否为空
if (key == null || key.length() == 0) {
return false;
}
// 调用 native 方法进行字符串存储
return nativePutString(m_nativeHandle, key, value);
}
// 调用 native 方法进行字符串存储
private native boolean nativePutString(long handle, String key, @Nullable String value);
在 Java 层,putString 方法会调用 native 方法 nativePutString 进行字符串的存储。接下来看看 native 层的代码:
// MMKV.cpp 文件中的 putString 方法
bool MMKV::putString(const std::string &key, const std::string &value) {
// 创建一个 Protobuf 的 Writer 对象
CodedOutputStream::ArrayOutputStream aos;
CodedOutputStream cos(&aos);
// 写入字符串的类型标识
cos.WriteVarint32(ProtobufType_String);
// 写入字符串的长度
cos.WriteVarint32(value.length());
// 写入字符串的内容
cos.WriteRaw(value.data(), value.length());
// 获取编码后的字节数组
std::string encodedValue = aos.GetBufferAsString();
// 调用 putData 方法存储编码后的数据
return putData(key, encodedValue);
}
在 native 层的 putString 方法中,首先创建一个 CodedOutputStream 对象,用于进行 Protobuf 编码。然后写入字符串的类型标识和长度,最后写入字符串的内容。编码完成后,将编码后的字节数组存储到 encodedValue 中,并调用 putData 方法进行数据的存储。
4.3 内存写入
编码后的数据会被写入到内存映射区域。以下是 putData 方法的代码分析:
// MMKV.cpp 文件中的 putData 方法
bool MMKV::putData(const std::string &key, const std::string &value) {
// 加锁,保证线程安全
SCOPEDLOCK(m_lock);
// 获取当前的写入位置
size_t oldSize = m_actualSize;
// 计算新数据的长度
size_t newSize = oldSize + value.length() + key.length() + 2 * sizeof(uint32_t);
// 检查是否需要扩容
if (newSize > m_size) {
// 进行扩容操作
if (!ensureMemorySize(newSize)) {
return false;
}
}
// 获取内存映射区域的指针
char *ptr = m_ptr + oldSize;
// 写入 key 的长度
*((uint32_t *) ptr) = (uint32_t) key.length();
ptr += sizeof(uint32_t);
// 写入 key 的内容
memcpy(ptr, key.data(), key.length());
ptr += key.length();
// 写入 value 的长度
*((uint32_t *) ptr) = (uint32_t) value.length();
ptr += sizeof(uint32_t);
// 写入 value 的内容
memcpy(ptr, value.data(), value.length());
// 更新实际使用的大小
m_actualSize = newSize;
// 标记数据已修改
m_dirty = true;
return true;
}
在 putData 方法中,首先加锁保证线程安全。然后计算新数据的长度,并检查是否需要扩容。如果需要扩容,则调用 ensureMemorySize 方法进行扩容操作。接着,将 key 和 value 的长度以及内容依次写入到内存映射区域,并更新实际使用的大小。最后,标记数据已修改,以便后续进行文件同步。
4.4 文件同步
为了保证数据的持久化,MMKV 会在合适的时机将内存中的数据同步到文件中。以下是文件同步的代码分析:
// MMKV.cpp 文件中的 sync 方法
bool MMKV::sync() {
// 加锁,保证线程安全
SCOPEDLOCK(m_lock);
// 如果数据没有修改,则不需要同步
if (!m_dirty) {
return true;
}
// 将内存中的数据同步到文件中
if (msync(m_ptr, m_actualSize, MS_SYNC) != 0) {
return false;
}
// 标记数据已同步
m_dirty = false;
return true;
}
在 sync 方法中,首先加锁保证线程安全。然后检查数据是否被修改,如果没有修改则直接返回。如果数据被修改,则调用 msync 函数将内存中的数据同步到文件中。最后,标记数据已同步。
4.5 多进程数据存储
MMKV 支持多进程数据存储,通过文件锁机制保证多进程间的数据一致性。以下是多进程数据存储的代码分析:
// MMKV.cpp 文件中的 putDataWithLock 方法
bool MMKV::putDataWithLock(const std::string &key, const std::string &value) {
// 获取文件锁
if (!m_fileLock.lockWrite()) {
return false;
}
// 调用 putData 方法存储数据
bool result = putData(key, value);
// 释放文件锁
m_fileLock.unlockWrite();
return result;
}
在 putDataWithLock 方法中,首先获取文件的写锁,然后调用 putData 方法进行数据的存储。存储完成后,释放文件锁。通过文件锁机制,保证了多进程间的数据一致性。
五、数据读取原理
5.1 数据读取流程概述
MMKV 的数据读取过程主要包括从内存映射区域查找数据、数据解码和返回结果三个步骤。当调用 get 方法读取数据时,MMKV 会先在内存映射区域中查找对应的 key,找到后将编码后的数据进行解码,最后返回解码后的数据。
5.2 数据查找
在内存映射区域中查找数据的代码如下:
// MMKV.cpp 文件中的 getData 方法
std::string MMKV::getData(const std::string &key) {
// 加锁,保证线程安全
SCOPEDLOCK(m_lock);
// 从内存映射区域查找数据
size_t offset = 0;
while (offset < m_actualSize) {
// 读取 key 的长度
uint32_t keyLength = *((uint32_t *) (m_ptr + offset));
offset += sizeof(uint32_t);
// 读取 key 的内容
std::string currentKey(m_ptr + offset, keyLength);
offset += keyLength;
// 读取 value 的长度
uint32_t valueLength = *((uint32_t *) (m_ptr + offset));
offset += sizeof(uint32_t);
// 如果找到匹配的 key
if (currentKey == key) {
// 返回 value 的内容
return std::string(m_ptr + offset, valueLength);
}
// 跳过当前 value
offset += valueLength;
}
// 未找到匹配的 key,返回空字符串
return "";
}
在 getData 方法中,首先加锁保证线程安全。然后从内存映射区域的起始位置开始遍历,依次读取 key 的长度、key 的内容、value 的长度。如果找到匹配的 key,则返回对应的 value 的内容。如果遍历完整个内存映射区域都没有找到匹配的 key,则返回空字符串。
5.3 数据解码
找到编码后的数据后,需要进行解码操作。以下是字符串数据解码的代码示例:
// MMKV.cpp 文件中的 decodeString 方法
std::string MMKV::decodeString(const std::string &encodedValue) {
// 创建一个 Protobuf 的 Reader 对象
CodedInputStream cis(encodedValue.data(), encodedValue.length());
// 读取字符串的类型标识
uint32_t type;
cis.ReadVarint32(&type);
// 读取字符串的长度
uint32_t length;
cis.ReadVarint32(&length);
// 读取字符串的内容
std::string value;
cis.ReadString(&value, length);
return value;
}
在 decodeString 方法中,首先创建一个 CodedInputStream 对象,用于进行 Protobuf 解码。然后读取字符串的类型标识和长度,最后读取字符串的内容并返回。
5.4 返回结果
解码完成后,将解码后的数据返回给调用者。以下是 Java 层的 getString 方法的代码:
// MMKV.java 文件中的 getString 方法
@Nullable
public String getString(String key, @Nullable String defaultValue) {
// 检查 key 是否为空
if (key == null || key.length() == 0) {
return defaultValue;
}
// 调用 native 方法进行字符串读取
String value = nativeGetString(m_nativeHandle, key);
// 如果读取失败,返回默认值
return value != null ? value : defaultValue;
}
// 调用 native 方法进行字符串读取
private native String nativeGetString(long handle, String key);
在 Java 层的 getString 方法中,首先检查 key 是否为空,如果为空则返回默认值。然后调用 native 方法 nativeGetString 进行字符串的读取。如果读取失败,则返回默认值。
5.5 多进程数据读取
在多进程环境下,数据读取同样需要考虑数据的一致性。MMKV 通过文件锁机制保证多进程间的数据读取安全。以下是多进程数据读取的代码分析:
// MMKV.cpp 文件中的 getDataWithLock 方法
std::string MMKV::getDataWithLock(const std::string &key) {
// 获取文件锁
if (!m_fileLock.lockRead()) {
return "";
}
// 调用 getData 方法读取数据
std::string result = getData(key);
// 释放文件锁
m_fileLock.unlockRead();
return result;
}
在 getDataWithLock 方法中,首先获取文件的读锁,然后调用 getData 方法进行数据的读取。读取完成后,释放文件锁。通过文件锁机制,保证了多进程间的数据读取安全。
六、数据存储和读取的性能优化
6.1 内存映射优化
MMKV 使用 mmap 内存映射技术将文件映射到内存中,避免了频繁的 I/O 操作。为了进一步优化内存映射的性能,MMKV 采用了以下策略:
- 预分配内存:在初始化时,预先分配一定大小的内存空间,减少后续扩容的次数。
- 动态扩容:当内存空间不足时,动态地进行扩容操作,保证数据的存储。
以下是 ensureMemorySize 方法的代码分析:
// MMKV.cpp 文件中的 ensureMemorySize 方法
bool MMKV::ensureMemorySize(size_t newSize) {
// 计算扩容后的大小
size_t targetSize = m_size;
while (targetSize < newSize) {
// 每次扩容为原来的 2 倍
targetSize *= 2;
}
// 如果不需要扩容,则直接返回
if (targetSize == m_size) {
return true;
}
// 关闭当前的内存映射
if (munmap(m_ptr, m_size) != 0) {
return false;
}
// 调整文件大小
if (ftruncate(m_fd, targetSize) != 0) {
return false;
}
// 重新进行内存映射
m_ptr = (char *) mmap(nullptr, targetSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
return false;
}
// 更新内存大小
m_size = targetSize;
return true;
}
在 ensureMemorySize 方法中,首先计算扩容后的大小,每次扩容为原来的 2 倍。如果不需要扩容,则直接返回。如果需要扩容,则先关闭当前的内存映射,调整文件大小,然后重新进行内存映射。最后更新内存大小。
6.2 数据编码优化
MMKV 使用 Protobuf 进行数据编码,Protobuf 是一种高效的数据编码格式,具有体积小、解析速度快的特点。为了进一步优化数据编码的性能,MMKV 采用了以下策略:
- 避免重复编码:对于相同的数据,只进行一次编码,减少编码的开销。
- 批量编码:将多个数据一起进行编码,减少编码的次数。
6.3 线程同步优化
在多线程和多进程环境下,线程同步是保证数据一致性的关键。MMKV 采用了以下策略来优化线程同步的性能:
- 读写锁分离:使用读写锁来区分读操作和写操作,允许多个线程同时进行读操作,提高并发性能。
- 文件锁优化:在多进程环境下,使用文件锁来保证数据的一致性,同时优化文件锁的获取和释放操作,减少锁竞争的开销。
七、异常处理与容错机制
7.1 异常情况分析
在数据存储和读取过程中,可能会出现各种异常情况,如文件操作失败、内存映射失败、数据编码解码错误等。MMKV 针对这些异常情况进行了相应的处理,保证了系统的稳定性。
7.2 异常处理代码分析
以下是一些异常处理的代码示例:
// MMKV.cpp 文件中的 ensureMemorySize 方法中的异常处理
bool MMKV::ensureMemorySize(size_t newSize) {
// ... 省略部分代码 ...
// 关闭当前的内存映射
if (munmap(m_ptr, m_size) != 0) {
// 处理内存映射关闭失败的异常
perror("munmap");
return false;
}
// 调整文件大小
if (ftruncate(m_fd, targetSize) != 0) {
// 处理文件大小调整失败的异常
perror("ftruncate");
return false;
}
// 重新进行内存映射
m_ptr = (char *) mmap(nullptr, targetSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
// 处理内存映射失败的异常
perror("mmap");
return false;
}
// ... 省略部分代码 ...
}
在 ensureMemorySize 方法中,当 munmap、ftruncate 或 mmap 操作失败时,会打印错误信息并返回 false,表示操作失败。
7.3 容错机制设计
MMKV 的容错机制主要包括以下几个方面:
- 数据备份:在数据存储过程中,定期将数据备份到另一个文件中,防止数据丢失。
- 错误恢复:当出现异常情况时,尝试进行错误恢复操作,如重新进行内存映射、重新打开文件等。
八、总结与展望
8.1 总结
通过对 Android MMKV 数据存储和读取原理的深入分析,我们了解到 MMKV 是一款高性能、可靠的键值对存储框架。它通过 mmap 内存映射技术和 Protobuf 数据编码,实现了高效的数据存储和读取。同时,MMKV 支持多进程数据共享,通过文件锁机制保证了多进程间的数据一致性。在性能优化方面,MMKV 采用了预分配内存、动态扩容、读写锁分离等策略,提高了系统的并发性能。在异常处理和容错机制方面,MMKV 对各种异常情况进行了处理,并设计了数据备份和错误恢复机制,保证了系统的稳定性。
8.2 展望
随着 Android 应用的不断发展,对数据存储和读取的性能要求也越来越高。未来,MMKV 可以在以下几个方面进行进一步的优化和扩展:
- 支持更多的数据类型:目前 MMKV 主要支持基本数据类型和字符串,未来可以考虑支持更多的数据类型,如自定义对象、集合等。
- 优化多进程性能:在多进程环境下,文件锁的竞争可能会成为性能瓶颈。未来可以探索更高效的多进程同步机制,提高多进程环境下的性能。
- 与其他存储框架的结合:可以将 MMKV 与其他存储框架(如 SQLite)结合使用,充分发挥各自的优势,提供更强大的数据存储解决方案。
总之,MMKV 作为一款优秀的 Android 数据存储框架,具有广阔的发展前景。通过不断的优化和扩展,它将为 Android 开发者提供更加高效、可靠的数据存储和读取解决方案。