深度揭秘!Android MMKV 多进程数据共享实现原理大剖析
一、引言
在 Android 开发中,多进程应用场景日益普遍。比如在某些大型应用里,为了防止主进程内存溢出,会把一些独立的业务模块(像推送服务、广告展示等)放到单独的进程中运行。不过,多进程开发也带来了数据共享的难题。传统的 Android 数据存储方式(像 SharedPreferences)在多进程环境下存在性能不佳、数据不一致等问题。
MMKV(Multi - Process Key - Value)是腾讯开源的一个高性能、轻量级的键值对存储框架,它专门针对多进程数据共享场景进行了优化。本文会从源码层面深入剖析 MMKV 多进程数据共享的实现方法,带你了解其内部运行机制。
二、MMKV 基础概述
2.1 MMKV 简介
MMKV 基于 mmap 内存映射技术和 Protobuf 数据编码,实现了高效的数据存储和读取。其核心优势在于高性能和对多进程数据共享的良好支持。与 SharedPreferences 相比,MMKV 在读写速度上有显著提升,尤其在多进程场景下表现更为出色。
2.2 MMKV 初始化
在使用 MMKV 之前,需要先进行初始化操作。以下是在 Android 应用的 Application 类中进行初始化的示例代码:
import com.tencent.mmkv.MMKV;
import android.app.Application;
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// 调用 MMKV 的 initialize 方法进行初始化,传入当前应用的上下文
String rootDir = MMKV.initialize(this);
// 打印 MMKV 的根目录,方便调试查看
android.util.Log.d("MMKV", "MMKV root: " + rootDir);
}
}
在 MMKV.java 文件中,initialize 方法的具体实现如下:
public static String initialize(Context context) {
// 获取应用的内部存储目录
File root = context.getFilesDir();
// 在内部存储目录下创建一个名为 "mmkv" 的子目录,用于存储 MMKV 的数据文件
File rootDir = new File(root, "mmkv");
// 调用 native 方法进行底层的初始化操作,并返回根目录的路径
return initialize(rootDir.getAbsolutePath());
}
// 调用 native 层的初始化方法
private static native String initialize(String rootDir);
接着看 MMKV.cpp 文件中 initializeMMKV 方法的实现:
#include <sys/stat.h>
#include <pthread.h>
#include <string>
// 全局变量,存储 MMKV 的根目录
std::string MMKV::g_rootDir;
// 全局的实例锁,用于线程同步
pthread_mutex_t MMKV::g_instanceLock;
void MMKV::initializeMMKV(const std::string &rootDir) {
// 检查传入的根目录是否为空,如果为空则直接返回,不进行初始化
if (rootDir.empty()) {
return;
}
// 将传入的根目录赋值给全局变量 g_rootDir
g_rootDir = rootDir;
// 创建根目录,权限设置为 0777,表示所有用户都有读、写、执行权限
mkdir(g_rootDir.c_str(), 0777);
// 初始化线程锁的属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
// 设置线程锁的类型为递归锁,允许同一个线程多次获取该锁
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 初始化全局实例锁
pthread_mutex_init(&g_instanceLock, &attr);
// 销毁线程锁的属性对象,释放资源
pthread_mutexattr_destroy(&attr);
}
2.3 MMKV 基本使用
MMKV 提供了类似于 SharedPreferences 的 API,方便开发者进行数据的存储和读取。以下是一个简单的示例:
import com.tencent.mmkv.MMKV;
public class MMKVExample {
public void useMMKV() {
// 获取默认的 MMKV 实例
MMKV mmkv = MMKV.defaultMMKV();
// 存储一个字符串数据,键为 "key",值为 "value"
mmkv.encode("key", "value");
// 读取键为 "key" 的字符串数据,如果不存在则返回默认值 "default"
String value = mmkv.decodeString("key", "default");
// 打印读取到的值
android.util.Log.d("MMKV", "Value: " + value);
}
}
三、多进程数据共享的挑战
3.1 数据一致性问题
在多进程环境下,多个进程可能会同时对同一份数据进行读写操作。如果没有合适的同步机制,就会出现数据不一致的问题。例如,一个进程正在写入数据,而另一个进程同时读取该数据,可能会读取到不完整或错误的数据。
3.2 性能问题
传统的多进程数据共享方式(如通过文件读写或 ContentProvider)可能会带来较大的性能开销。频繁的 I/O 操作会导致应用响应变慢,影响用户体验。
3.3 并发访问问题
多个进程同时访问共享数据时,可能会出现并发冲突。例如,多个进程同时尝试写入数据,可能会导致数据覆盖或丢失。
四、MMKV 多进程数据共享的核心实现原理
4.1 mmap 内存映射技术
4.1.1 mmap 原理简介
mmap(Memory - Mapped File)是一种将文件映射到进程虚拟地址空间的技术。通过 mmap,进程可以像访问内存一样直接访问文件,避免了频繁的 I/O 操作,从而提高了数据读写的性能。
在 Linux 系统中,mmap 函数的原型如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr:指定映射的起始地址,通常传入NULL,让系统自动分配。length:映射的文件长度。prot:映射区域的保护权限,如PROT_READ(可读)、PROT_WRITE(可写)等。flags:映射的标志,如MAP_SHARED(共享映射,多个进程可以共享该映射区域)、MAP_PRIVATE(私有映射)等。fd:文件描述符,指向要映射的文件。offset:文件的偏移量,指定从文件的哪个位置开始映射。
4.1.2 MMKV 中的 mmap 实现
在 MMKV 中,通过 mmap 技术将数据文件映射到内存中,多个进程可以共享该内存映射区域,从而实现数据的共享。以下是 MMKV.cpp 中 initializeMmap 方法的实现:
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
bool MMKV::initializeMmap() {
// 打开数据文件,以读写模式打开,如果文件不存在则创建,权限为 0666
m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, 0666);
if (m_fd < 0) {
// 打开文件失败,记录错误日志并返回 false
perror("open");
return false;
}
// 获取文件的当前大小
struct stat st;
if (fstat(m_fd, &st) != 0) {
// 获取文件状态失败,关闭文件并返回 false
perror("fstat");
close(m_fd);
return false;
}
// 初始化文件大小
m_size = st.st_size;
if (m_size == 0) {
// 如果文件大小为 0,将文件大小调整为初始大小(如 4096 字节)
if (ftruncate(m_fd, DEFAULT_MMAP_SIZE) != 0) {
// 调整文件大小失败,关闭文件并返回 false
perror("ftruncate");
close(m_fd);
return false;
}
m_size = DEFAULT_MMAP_SIZE;
}
// 进行内存映射,将文件映射到进程的虚拟地址空间,映射区域可读可写,多个进程共享该映射
m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {
// 内存映射失败,关闭文件并返回 false
perror("mmap");
close(m_fd);
return false;
}
// 记录实际使用的大小
m_actualSize = m_size;
return true;
}
4.2 Protobuf 数据编码
4.2.1 Protobuf 简介
Protobuf(Protocol Buffers)是一种轻量级、高效的数据序列化协议。它通过定义数据结构的 .proto 文件,生成对应的代码,将数据序列化为二进制格式进行存储和传输。Protobuf 具有体积小、解析速度快的特点,适合在高性能场景下使用。
4.2.2 MMKV 中的 Protobuf 应用
MMKV 使用 Protobuf 对存储的数据进行编码和解码。以下是一个简单的示例,展示如何使用 Protobuf 对数据进行编码:
#include <google/protobuf/io/coded_stream.h>
#include <google/protobuf/io/zero_copy_stream_impl.h>
// 编码一个字符串数据
std::string encodeString(const std::string &value) {
// 创建一个输出流,用于存储编码后的数据
google::protobuf::io::ArrayOutputStream aos;
google::protobuf::io::CodedOutputStream cos(&aos);
// 写入字符串的类型标识
cos.WriteVarint32(ProtobufType_String);
// 写入字符串的长度
cos.WriteVarint32(value.length());
// 写入字符串的内容
cos.WriteRaw(value.data(), value.length());
// 获取编码后的字节数组
return aos.GetBufferAsString();
}
4.3 文件锁机制
4.3.1 文件锁原理
文件锁是一种用于控制多个进程对同一文件进行并发访问的机制。在 Linux 系统中,主要有两种类型的文件锁:共享锁(读锁)和排他锁(写锁)。多个进程可以同时持有共享锁,但同一时刻只能有一个进程持有排他锁。
4.3.2 MMKV 中的文件锁实现
MMKV 使用文件锁来保证多进程环境下的数据一致性。以下是 FileLock.cpp 中文件锁的实现:
#include <fcntl.h>
#include <unistd.h>
bool FileLock::lockRead() {
// 定义文件锁结构体
struct flock lock;
// 初始化文件锁结构体
lock.l_type = F_RDLCK; // 设置为读锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
// 尝试获取读锁,如果失败则返回 false
if (fcntl(m_fd, F_SETLKW, &lock) != 0) {
perror("fcntl");
return false;
}
return true;
}
bool FileLock::lockWrite() {
// 定义文件锁结构体
struct flock lock;
// 初始化文件锁结构体
lock.l_type = F_WRLCK; // 设置为写锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
// 尝试获取写锁,如果失败则返回 false
if (fcntl(m_fd, F_SETLKW, &lock) != 0) {
perror("fcntl");
return false;
}
return true;
}
bool FileLock::unlock() {
// 定义文件锁结构体
struct flock lock;
// 初始化文件锁结构体
lock.l_type = F_UNLCK; // 设置为解锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
// 释放文件锁,如果失败则返回 false
if (fcntl(m_fd, F_SETLK, &lock) != 0) {
perror("fcntl");
return false;
}
return true;
}
4.4 数据同步机制
4.4.1 数据同步的必要性
在多进程环境下,由于多个进程可能同时对共享数据进行读写操作,为了保证数据的一致性,需要进行数据同步。当一个进程修改了共享数据后,其他进程需要及时得知并更新自己的缓存。
4.4.2 MMKV 中的数据同步实现
MMKV 通过文件锁和内存映射的特性实现数据同步。当一个进程修改了内存映射区域的数据后,由于使用的是 MAP_SHARED 标志,其他进程可以立即看到这些修改。同时,通过文件锁机制,保证了同一时刻只有一个进程可以对数据进行写操作,避免了数据冲突。
以下是 MMKV.cpp 中数据写入的实现:
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;
// 写入键的长度
*((uint32_t *) ptr) = (uint32_t) key.length();
ptr += sizeof(uint32_t);
// 写入键的内容
memcpy(ptr, key.data(), key.length());
ptr += key.length();
// 写入值的长度
*((uint32_t *) ptr) = (uint32_t) value.length();
ptr += sizeof(uint32_t);
// 写入值的内容
memcpy(ptr, value.data(), value.length());
// 更新实际使用的大小
m_actualSize = newSize;
// 标记数据已修改
m_dirty = true;
// 同步数据到文件
if (m_dirty) {
sync();
}
return true;
}
五、MMKV 多进程数据共享的源码分析
5.1 多进程实例获取
在多进程环境下,不同进程需要获取同一个 MMKV 实例来实现数据共享。以下是 MMKV.java 中获取 MMKV 实例的代码:
public static MMKV defaultMMKV() {
// 获取默认的 MMKV 实例,使用默认的标识
return getInstance(MMKV.MULTI_PROCESS_MODE, null);
}
public static MMKV getInstance(int mode, String cryptKey) {
// 调用 native 方法获取 MMKV 实例的句柄
long handle = getMMKVWithID(MMKV.DEFAULT_MMAP_ID, mode, cryptKey);
// 根据句柄创建 MMKV 实例
return new MMKV(handle);
}
private static native long getMMKVWithID(String mmapID, int mode, String cryptKey);
在 MMKV.cpp 中,getMMKVWithID 方法的实现如下:
extern "C" JNIEXPORT jlong JNICALL
Java_com_tencent_mmkv_MMKV_getMMKVWithID(JNIEnv *env, jclass /* clazz */, jstring jMmapID, jint jMode, jstring jCryptKey) {
// 将 Java 字符串转换为 C++ 字符串
const char *mmapID = env->GetStringUTFChars(jMmapID, nullptr);
const char *cryptKey = nullptr;
if (jCryptKey) {
cryptKey = env->GetStringUTFChars(jCryptKey, nullptr);
}
// 获取 MMKV 实例
MMKV *kv = MMKV::getInstance(mmapID, (MMKVMode) jMode, cryptKey);
// 释放 Java 字符串资源
env->ReleaseStringUTFChars(jMmapID, mmapID);
if (cryptKey) {
env->ReleaseStringUTFChars(jCryptKey, cryptKey);
}
// 返回 MMKV 实例的指针
return (jlong) kv;
}
MMKV *MMKV::getInstance(const std::string &mmapID, MMKVMode mode, const std::string &cryptKey) {
// 加锁,保证线程安全
SCOPEDLOCK(g_instanceLock);
// 从实例映射中查找是否已经存在该实例
auto itr = g_instanceDic.find(mmapID);
if (itr != g_instanceDic.end()) {
// 如果存在,返回该实例
return itr->second;
}
// 创建新的 MMKV 实例
MMKV *kv = new MMKV(mmapID, mode, cryptKey);
// 将新实例添加到实例映射中
g_instanceDic[mmapID] = kv;
return kv;
}
5.2 多进程数据写入
在多进程环境下,数据写入操作需要考虑数据一致性和并发访问的问题。以下是 MMKV.cpp 中多进程数据写入的实现:
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.unlock();
return result;
}
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;
// 写入键的长度
*((uint32_t *) ptr) = (uint32_t) key.length();
ptr += sizeof(uint32_t);
// 写入键的内容
memcpy(ptr, key.data(), key.length());
ptr += key.length();
// 写入值的长度
*((uint32_t *) ptr) = (uint32_t) value.length();
ptr += sizeof(uint32_t);
// 写入值的内容
memcpy(ptr, value.data(), value.length());
// 更新实际使用的大小
m_actualSize = newSize;
// 标记数据已修改
m_dirty = true;
// 同步数据到文件
if (m_dirty) {
sync();
}
return true;
}
5.3 多进程数据读取
多进程数据读取操作同样需要考虑数据一致性的问题。以下是 MMKV.cpp 中多进程数据读取的实现:
std::string MMKV::getDataWithLock(const std::string &key) {
// 获取文件读锁
if (!m_fileLock.lockRead()) {
return "";
}
// 调用 getData 方法进行数据读取
std::string result = getData(key);
// 释放文件读锁
m_fileLock.unlock();
return result;
}
std::string MMKV::getData(const std::string &key) {
// 加锁,保证线程安全
SCOPEDLOCK(m_lock);
// 从内存映射区域的起始位置开始查找
size_t offset = 0;
while (offset < m_actualSize) {
// 读取键的长度
uint32_t keyLength = *((uint32_t *) (m_ptr + offset));
offset += sizeof(uint32_t);
// 读取键的内容
std::string currentKey(m_ptr + offset, keyLength);
offset += keyLength;
// 读取值的长度
uint32_t valueLength = *((uint32_t *) (m_ptr + offset));
offset += sizeof(uint32_t);
// 如果找到匹配的键
if (currentKey == key) {
// 返回对应的值
return std::string(m_ptr + offset, valueLength);
}
// 跳过当前值
offset += valueLength;
}
// 未找到匹配的键,返回空字符串
return "";
}
六、MMKV 多进程数据共享的使用示例
6.1 单进程使用示例
import com.tencent.mmkv.MMKV;
public class SingleProcessExample {
public void singleProcessUsage() {
// 获取默认的 MMKV 实例
MMKV mmkv = MMKV.defaultMMKV();
// 存储一个字符串数据
mmkv.encode("key", "value");
// 读取存储的数据
String value = mmkv.decodeString("key", "default");
// 打印读取到的值
android.util.Log.d("MMKV", "Value: " + value);
}
}
6.2 多进程使用示例
6.2.1 主进程代码
import com.tencent.mmkv.MMKV;
public class MainProcessExample {
public void mainProcessUsage() {
// 获取支持多进程的 MMKV 实例
MMKV mmkv = MMKV.mmkvWithID("multi_process_mmkv", MMKV.MULTI_PROCESS_MODE);
// 存储一个字符串数据
mmkv.encode("key", "value from main process");
}
}
6.2.2 子进程代码
import com.tencent.mmkv.MMKV;
public class ChildProcessExample {
public void childProcessUsage() {
// 获取支持多进程的 MMKV 实例,使用与主进程相同的 ID
MMKV mmkv = MMKV.mmkvWithID("multi_process_mmkv", MMKV.MULTI_PROCESS_MODE);
// 读取主进程存储的数据
String value = mmkv.decodeString("key", "default");
// 打印读取到的值
android.util.Log.d("MMKV", "Value: " + value);
}
}
七、MMKV 多进程数据共享的性能分析
7.1 读写性能
MMKV 通过 mmap 内存映射技术和 Protobuf 数据编码,显著提高了数据的读写性能。与传统的 SharedPreferences 相比,MMKV 在多进程环境下的读写速度有了大幅提升。
7.2 并发性能
文件锁机制保证了多进程环境下的数据一致性,同时允许多个进程同时进行读操作,提高了并发性能。但在高并发写操作的场景下,由于文件锁的竞争,可能会导致性能下降。
7.3 内存占用
MMKV 使用内存映射技术将数据文件映射到内存中,因此会占用一定的内存空间。但由于采用了动态扩容和内存管理策略,MMKV 的内存占用相对合理。
八、MMKV 多进程数据共享的常见问题及解决方案
8.1 数据不一致问题
8.1.1 问题原因
多个进程同时对共享数据进行读写操作,可能会导致数据不一致。例如,一个进程正在写入数据,而另一个进程同时读取该数据,可能会读取到不完整或错误的数据。
8.1.2 解决方案
使用文件锁机制保证同一时刻只有一个进程可以对数据进行写操作。在进行数据读取时,先获取读锁,确保读取到的数据是最新的。
8.2 性能瓶颈问题
8.2.1 问题原因
在高并发写操作的场景下,文件锁的竞争可能会导致性能下降。频繁的 I/O 操作也会影响性能。
8.2.2 解决方案
尽量减少不必要的写操作,采用批量写入的方式。优化内存映射的大小,避免频繁的扩容操作。
8.3 兼容性问题
8.3.1 问题原因
不同版本的 Android 系统可能对 mmap 和文件锁的实现存在差异,导致兼容性问题。
8.3.2 解决方案
在使用 MMKV 时,确保使用的是最新版本,以获取更好的兼容性。同时,进行充分的测试,确保在不同版本的 Android 系统上都能正常工作。
九、总结与展望
9.1 总结
MMKV 通过 mmap 内存映射技术、Protobuf 数据编码、文件锁机制和数据同步机制,实现了高效的多进程数据共享。它解决了传统 Android 数据存储方式在多进程环境下的性能和数据一致性问题,为开发者提供了一个可靠的解决方案。
9.2 展望
未来,MMKV 可以在以下方面进行进一步的优化和扩展:
- 支持更多的数据类型:目前 MMKV 主要支持基本数据类型和字符串,未来可以考虑支持更多的数据类型,如自定义对象、集合等。
- 优化并发性能:在高并发写操作的场景下,进一步优化文件锁机制,减少锁竞争带来的性能开销。
- 增强安全性:加强数据的加密和验证机制,提高数据的安全性。
总之,MMKV 作为一款优秀的 Android 多进程数据共享框架,具有广阔的发展前景。通过不断的优化和扩展,它将为 Android 开发者提供更加高效、安全的数据共享解决方案。