揭秘 Android MMKV:数据存储与读取的底层原理深度剖析(1)

341 阅读15分钟

揭秘 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 方法中,当 munmapftruncatemmap 操作失败时,会打印错误信息并返回 false,表示操作失败。

7.3 容错机制设计

MMKV 的容错机制主要包括以下几个方面:

  • 数据备份:在数据存储过程中,定期将数据备份到另一个文件中,防止数据丢失。
  • 错误恢复:当出现异常情况时,尝试进行错误恢复操作,如重新进行内存映射、重新打开文件等。

八、总结与展望

8.1 总结

通过对 Android MMKV 数据存储和读取原理的深入分析,我们了解到 MMKV 是一款高性能、可靠的键值对存储框架。它通过 mmap 内存映射技术和 Protobuf 数据编码,实现了高效的数据存储和读取。同时,MMKV 支持多进程数据共享,通过文件锁机制保证了多进程间的数据一致性。在性能优化方面,MMKV 采用了预分配内存、动态扩容、读写锁分离等策略,提高了系统的并发性能。在异常处理和容错机制方面,MMKV 对各种异常情况进行了处理,并设计了数据备份和错误恢复机制,保证了系统的稳定性。

8.2 展望

随着 Android 应用的不断发展,对数据存储和读取的性能要求也越来越高。未来,MMKV 可以在以下几个方面进行进一步的优化和扩展:

  • 支持更多的数据类型:目前 MMKV 主要支持基本数据类型和字符串,未来可以考虑支持更多的数据类型,如自定义对象、集合等。
  • 优化多进程性能:在多进程环境下,文件锁的竞争可能会成为性能瓶颈。未来可以探索更高效的多进程同步机制,提高多进程环境下的性能。
  • 与其他存储框架的结合:可以将 MMKV 与其他存储框架(如 SQLite)结合使用,充分发挥各自的优势,提供更强大的数据存储解决方案。

总之,MMKV 作为一款优秀的 Android 数据存储框架,具有广阔的发展前景。通过不断的优化和扩展,它将为 Android 开发者提供更加高效、可靠的数据存储和读取解决方案。