MMKV的源码分析:为什么他的性能更高,为什么他比SP好,为什么他的数据更加的精简,比传统IO更高效的文件操作方式;

909 阅读7分钟

目录:

  1. 为什么使用MMKV,而不是用SP,SP的缺点

    1.1 对比MMKV、SP写入数据的速度

    1.2 我们可以看看SP的源码,存储大量数据的时候,他的问题在哪里

  2. MMKV是什么,MMKV为什么性能更高

    2.1. 高效的文件操作:FileChannel

    2.2. 更精简的数据格式:二进制

    2.3. 数据更新方式:增量写入


一、为什么使用MMKV,而不是用SP

在很久以前,我们都是使用SP来存储轻量级数据的,但不同的程序员对他的理解不同,会导致滥用以及错误的使用,使其存储了很多数据,从而引出了问题,如:

  • 性能瓶颈​​:SP 使用 XML 格式,读写时需全量解析,数据量大时效率骤降。

  • ​ANR 风险​​:commit() 同步写入可能阻塞主线程,apply() 虽是异步但仍有潜在 ANR。

  • ​数据冗余​​:每次更新需全量写入文件,导致 I/O 开销大。

1.1 对比MMKV、SP写入数据的速度

  private lateinit var mmkv: MMKV
    private lateinit var sp: SharedPreferences
    private val testCount = 1000  // 测试次数(可根据需要调整)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 初始化 MMKV
        MMKV.initialize(this)
        mmkv = MMKV.defaultMMKV()
        
        // 初始化 SP
        sp = getSharedPreferences("benchmark_sp", Context.MODE_PRIVATE)

        // 执行测试
        testMMKVPerformance()
        testSPPerformance()
    }


    /** MMKV 存储性能测试 */
    private fun testMMKVPerformance() {
        val startTime = System.nanoTime()
        for (i in 0 until testCount) {
            mmkv.putInt(""+i, i)  // 同步写入
        }
        val totalTimeMs = (System.nanoTime() - startTime) / 1_000_000

        Log.d("Benchmark",
            """
            MMKV 结果:
            存储次数 = $testCount
            总耗时 = ${totalTimeMs}ms
            平均耗时 = ${totalTimeMs.toDouble() / testCount}ms/次
            """.trimIndent())
    }

    /** SP 存储性能测试 */
    private fun testSPPerformance() {
        val startTime = System.nanoTime()
        for (i in 0 until testCount) {
            // 使用 commit() 确保同步写入
            sp.edit().putInt(""+i, i).commit()
        }
        repeat(testCount) {
        }
        val totalTimeMs = (System.nanoTime() - startTime) / 1_000_000

        Log.d("Benchmark",
            """
            SP 结果:
            存储次数 = $testCount
            总耗时 = ${totalTimeMs}ms
            平均耗时 = ${totalTimeMs.toDouble() / testCount}ms/次
            """.trimIndent())
    }

}

图片.png

图片.png

当数据量达到一万的时候,SP会卡住。但现在内存量、CPU更好的情况下,其实效果不是很明显。


1.2 我们可以看看SP的源码,存储大量数据的时候,他的问题在哪里,为什么会出现,只有知道这个,我们才知道为什么MMKV做了哪些优化

(1)初始化

图片.png

SharedPreferences是一个接口,我们看看他的实现类

图片.png

SharedPreferencesImpl构造方法,有一个方法startLoadFromDisk();

图片.png

用于xml文件反序列到内存(Map)里面,通过子线程的方式

图片.png

里面有一个mLoaded变量,当加载过程中,那么不管读写数据都需要等待他加载完成,即使他是用线程异步加载数据,也会阻塞住,我们看看下面这里。

图片.png

比如我们调用getInt方法,在执行方法前,会调用awaitLoadedLocked方法

图片.png

可以看到里面就在阻塞,等待mLoaded变量为true,也就是数据加载完成。所以,随着数据量越来越多,那么接在肯定会越来越慢,如果你在主线程读取数据,在一启动程序的时候get数据,那么就会阻塞。


(2)存储的数据格式是XML

图片.png 我们可以找到这个文件来看看,在你的应用程序目录——data——data——shared_prefs里面

图片.png

图片.png

XML虽然可读性强,但在性能和存储效率上存在显著缺陷。

  • 体积​​:约 ​​50 字节​​(含标签、属性、换行符)。

  • ​冗余内容​​:标签名称(<boolean>)、属性名(name, value)重复占用空间。

  • 解析数据,启动时全量加载,大文件导致卡顿。

所以在很多网络传输的情况下,我们基本上会选择JSON,或者其他方式,XML少用。MMKV采用了二进制编码的方式。


(3)全量更新

  • 频繁更新时重复序列化消耗 CPU,频繁写入也会导致磁盘寿命下降。
  • 全量更新​​:即使修改一个键值,也会重写整个 XML 文件。

所以MMKV出现了。


二、MMKV是什么,MMKV为什么性能更高

MMKV​​ 是由腾讯开源的一款高效、轻量级的移动端键值存储框架,专为替代 Android 的 SharedPreferences(SP)而设计,适用于高频读写和数据量较大的场景。

上述的三个问题,他都进行了解决。

  1. 使用 二进制编码代替传统的XML。相比 XML 的标签冗余,二进制编码体积更精简
  2. 使用增量更新​​:代替全量更新。每次写入仅追加新数据到文件末尾,旧数据标记为无效,避免全量重写。
  3. 使用mmap​​的技术:代替传统的读IO。内存映射读取文件数据更快,通过FileChannel类。

2.1 FileChannel

  • ​问题背景​​:
    传统文件读写(如 FileInputStream/FileOutputStream)需通过系统调用(read/write),存在以下问题:

    • ​数据拷贝开销​​:数据需从​​用户空间​​拷贝到​​内核空间​​,再写入磁盘。
    • ​频繁系统调用​​:小数据频繁读写时,上下文切换开销大。
  • ​MMKV 的优化​​:
    MMKV 使用 ​FileChannel.mmap​ 将文件直接映射到内存:

    • ​零拷贝读写​​:直接通过内存地址操作文件数据,无需用户态与内核态的数据拷贝。
    • ​异步刷盘​​:由操作系统负责将内存中的修改同步到磁盘,避免阻塞主线程。
    • ​崩溃一致性​​:依赖操作系统的页缓存(Page Cache)机制,保证数据持久化。

​FileChannel​​ 是 Java NIO(New I/O)库中的一个核心类,用于高效操作文件。FileChannel 是Java NIO的封装,支持内存映射。

public static void mmapExample(String path) throws Exception {
    try (FileChannel channel = FileChannel.open(
            Paths.get(path),
            StandardOpenOption.READ,
            StandardOpenOption.WRITE
    )) {
        // 将整个文件映射到内存
        MappedByteBuffer mappedBuffer = channel.map(
            FileChannel.MapMode.READ_WRITE, // 读写模式
            0,                              // 起始位置
            channel.size()                  // 映射长度
        );
        
        // 直接修改内存中的数据(自动同步到文件)
        mappedBuffer.put(0, (byte) 'J');    // 修改第一个字符为 'J'
    }
}

我在思考,为什么传统 I/O 仍是教学重点?​​可能是因为:

  1. 传统 I/O 在 Java 1.0 中就已存在,而 NIO 是在 Java 1.4 引入的,教学材料通常优先覆盖基础内容。
  2. 传统 I/O:适合小文件、顺序读写、简单场景(如配置文件读取)。
  3. 比如我们在Android记录一些日志的时候,这些日志,可能内容比较多,频繁的读写,这里我们就可以使用内存映射技术,比如XLog框架(如微信的Mars XLog)在底层实现中,​​主要使用了 mmap(内存映射文件)技术来优化日志写入性能​

2.2 二进制编码

SharedPreferences 使用 XML 格式存储数据,XML 是文本格式,存在的问题,如 ​​冗余数据多​​:标签重复(如 <string name="key">value</string>),文件体积大。

每次修改需全量重写整个 XML 文件。通过源码,我们可以知道当我们通过 SharedPreferences.Editor 提交修改(如 apply()commit())时,最终会调用 SharedPreferencesImpl 类的 enqueueDiskWrite 方法。

图片.png 图片.png

MMKV 采用 ​​Protocol Buffers(protobuf)​​ 的二进制编码格式,优势如下:

  • ​数据紧凑​​:二进制编码直接存储数据的二进制形式,无冗余标签。
  • ​快速解析​​:二进制数据可直接按字节偏移读取,无需解析语法结构。
  • ​类型安全​​:通过预定义的数据结构(如 key 的类型和长度)避免解析错误。

​示例对比​​:

<!-- SharedPreferences 的 XML 格式 -->
<string name="username">Alice</string>
<int name="age" value="25" />

对应的二进制编码可能仅需 ​​20 字节​​(XML 可能需要 50 字节以上)。


2.3 增量更新

  • ​问题背景​​:
    SharedPreferences 在修改数据时,必须全量重写整个文件,导致:

    • ​IO 开销大​​:频繁写入大文件时性能急剧下降。
    • ​数据损坏风险​​:写入过程中若发生崩溃,文件可能处于不完整状态。
  • ​MMKV 的优化​​:
    MMKV 采用 ​​追加写入(Append-Only)​​ 策略:

    • ​仅追加新数据​​:每次修改时,新数据追加到文件末尾,旧数据标记为无效。
    • ​无需全量重写​​:避免重复写入未修改的数据,减少 IO 操作。
    • ​崩溃安全​​:即使写入中断,仅最后一次修改可能丢失,历史数据仍完整。
  • ​工作流程​​:

      1. 初始写入:键值对 {"name": "Alice", "age": 25} 写入文件。
      1. 修改 age26:追加 {"age": 26} 到文件末尾,旧 age 标记为失效。
      1. 文件定期整理(如达到阈值时):合并有效数据,清理无效数据。

那么他是如何实现的

我们如果去看mmkv的源码,发现他都是navite方法,都是C语言实现的,我们直接上github上面。

图片.png

图片.png

图片.png

可以看到,通过移动下标,来进行追加。

图片.png