目录:
-
为什么使用MMKV,而不是用SP,SP的缺点
1.1 对比MMKV、SP写入数据的速度
1.2 我们可以看看SP的源码,存储大量数据的时候,他的问题在哪里
-
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())
}
}
当数据量达到一万的时候,SP会卡住。但现在内存量、CPU更好的情况下,其实效果不是很明显。
1.2 我们可以看看SP的源码,存储大量数据的时候,他的问题在哪里,为什么会出现,只有知道这个,我们才知道为什么MMKV做了哪些优化
(1)初始化
SharedPreferences是一个接口,我们看看他的实现类
SharedPreferencesImpl构造方法,有一个方法startLoadFromDisk();
用于xml文件反序列到内存(Map)里面,通过子线程的方式
里面有一个mLoaded变量,当加载过程中,那么不管读写数据都需要等待他加载完成,即使他是用线程异步加载数据,也会阻塞住,我们看看下面这里。
比如我们调用getInt方法,在执行方法前,会调用awaitLoadedLocked方法
可以看到里面就在阻塞,等待mLoaded变量为true,也就是数据加载完成。所以,随着数据量越来越多,那么接在肯定会越来越慢,如果你在主线程读取数据,在一启动程序的时候get数据,那么就会阻塞。
(2)存储的数据格式是XML
我们可以找到这个文件来看看,在你的应用程序目录——data——data——shared_prefs里面
XML虽然可读性强,但在性能和存储效率上存在显著缺陷。
-
体积:约 50 字节(含标签、属性、换行符)。
-
冗余内容:标签名称(
<boolean>
)、属性名(name
,value
)重复占用空间。 -
解析数据,启动时全量加载,大文件导致卡顿。
所以在很多网络传输的情况下,我们基本上会选择JSON,或者其他方式,XML少用。MMKV采用了二进制编码的方式。
(3)全量更新
- 频繁更新时重复序列化消耗 CPU,频繁写入也会导致磁盘寿命下降。
- 全量更新:即使修改一个键值,也会重写整个 XML 文件。
所以MMKV出现了。
二、MMKV是什么,MMKV为什么性能更高
MMKV 是由腾讯开源的一款高效、轻量级的移动端键值存储框架,专为替代 Android 的 SharedPreferences
(SP)而设计,适用于高频读写和数据量较大的场景。
上述的三个问题,他都进行了解决。
- 使用 二进制编码代替传统的XML。相比 XML 的标签冗余,二进制编码体积更精简
- 使用增量更新:代替全量更新。每次写入仅追加新数据到文件末尾,旧数据标记为无效,避免全量重写。
- 使用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 仍是教学重点?可能是因为:
- 传统 I/O 在 Java 1.0 中就已存在,而
NIO
是在 Java 1.4 引入的,教学材料通常优先覆盖基础内容。 - 传统 I/O:适合小文件、顺序读写、简单场景(如配置文件读取)。
- 比如我们在Android记录一些日志的时候,这些日志,可能内容比较多,频繁的读写,这里我们就可以使用内存映射技术,比如XLog框架(如微信的Mars XLog)在底层实现中,主要使用了
mmap
(内存映射文件)技术来优化日志写入性能
2.2 二进制编码
SharedPreferences
使用 XML 格式存储数据,XML 是文本格式,存在的问题,如 冗余数据多:标签重复(如 <string name="key">value</string>
),文件体积大。
每次修改需全量重写整个 XML 文件。通过源码,我们可以知道当我们通过 SharedPreferences.Editor
提交修改(如 apply()
或 commit()
)时,最终会调用 SharedPreferencesImpl
类的 enqueueDiskWrite
方法。
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 操作。
- 崩溃安全:即使写入中断,仅最后一次修改可能丢失,历史数据仍完整。
-
工作流程:
-
- 初始写入:键值对
{"name": "Alice", "age": 25}
写入文件。
- 初始写入:键值对
-
- 修改
age
为26
:追加{"age": 26}
到文件末尾,旧age
标记为失效。
- 修改
-
- 文件定期整理(如达到阈值时):合并有效数据,清理无效数据。
-
那么他是如何实现的?
我们如果去看mmkv的源码,发现他都是navite方法,都是C语言实现的,我们直接上github上面。
可以看到,通过移动下标,来进行追加。