前言:
MMKV是腾讯18年底推出的一套持久化框架,有安卓,IOS,PC版本等等,微信的持久化功能使用的就是MMKV,项目地址:https://github.com/Tencent/MMKV
最大的特点就是高效,号称要比传统的持久化工具要高效100倍,目标是用来替代原生的SharedPreferences(后续SharedPreferences统称为SP)。本文主要是探究MMKV的实现原理以及为什么比SP高效。
本文主要基于安卓的项目进行分析和实验。
一.MMKV实测
1.1导入MMKV和简单实用方式
上面既然说MMKV高效,那我们就实际做一个例子验证一下。
MMKV的使用方式十分简单,首先build.gradle种导入MMKV的包,然后代码中初始化一下即可。
implementation 'com.tencent:mmkv:1.2.13'
val initialize = MMKV.initialize(context)
使用方式和SP几乎一样,如下:
val kv = MMKV.defaultMMKV()
//写入key=i1,value=1的值
kv.encode("i1", 1)
//读区key=i1的值,返回结果是1
val decodeInt = kv.decodeInt("i1")
1.2和SP做对比
为了数据上显示更明显,所以我们分别存储字符串,数字,Boolean,1000次,然后看花费时间对比。
代码如下:
override fun clickItem(position: Int) {
val random = Random(1000)
if (position == 0 || position == 2 || position == 4) {
val sp = when (position) {
0 -> {
requireContext().getSharedPreferences("sp_int", MODE_PRIVATE);
}
2 -> {
requireContext().getSharedPreferences("sp_boolean", MODE_PRIVATE);
}
else -> {
requireContext().getSharedPreferences("sp_string", MODE_PRIVATE);
}
}
val edit = sp.edit()
val currentTimeMillis = System.currentTimeMillis()
for (i in 0 until 1000) {
when (position) {
0 -> {
edit.putInt("key$i", random.nextInt())
}
1 -> {
edit.putBoolean("key$i", true)
}
else -> {
edit.putString("key$i", "key$i")
}
}
edit.commit()
}
Log.i(TAG, "SP spendTime:${System.currentTimeMillis() - currentTimeMillis}")
return
}
if (position == 1 || position == 3 || position == 5) {
val kv = when (position) {
1 -> {
MMKV.defaultMMKV(0, "sp_int")
}
3 -> {
MMKV.defaultMMKV(0, "sp_boolean")
}
else -> {
MMKV.defaultMMKV(0, "sp_string")
}
}
val currentTimeMillis = System.currentTimeMillis()
for (i in 0 until 1000) {
if (position == 1) {
kv.putInt("key$i", random.nextInt())
} else if (position == 3) {
kv.putBoolean("key$i", true)
} else {
kv.putString("key$i", "key$i")
}
}
Log.i(TAG, "MMKV spendTime:${System.currentTimeMillis() - currentTimeMillis}")
}
}
验证下来,1000次操作,最终的结果如下:
//第一次写入随机Int
2022-06-29 16:50:54.211 30092-30092/com.xt.client I/MMKVFragment: SP spendTime:14289
2022-06-29 16:50:56.399 30092-30092/com.xt.client I/MMKVFragment: MMKV spendTime:24
//第二次写入随机Int
2022-06-29 16:50:54.211 30092-30092/com.xt.client I/MMKVFragment: SP spendTime:14189
2022-06-29 16:50:56.399 30092-30092/com.xt.client I/MMKVFragment: MMKV spendTime:25
//第一次写入Boolean
2022-06-29 16:51:10.612 30092-30092/com.xt.client I/MMKVFragment: SP spendTime:12485
2022-06-29 16:51:12.810 30092-30092/com.xt.client I/MMKVFragment: MMKV spendTime:30
//第二次写入Boolean
2022-06-29 16:51:14.567 30092-30092/com.xt.client I/MMKVFragment: SP spendTime:36
2022-06-29 16:51:16.192 30092-30092/com.xt.client I/MMKVFragment: MMKV spendTime:9
//第一次写入String
2022-06-29 16:51:33.950 30092-30092/com.xt.client I/MMKVFragment: SP spendTime:12718
2022-06-29 16:51:38.381 30092-30092/com.xt.client I/MMKVFragment: MMKV spendTime:12
通过结果,我们可以发现这样两个现象:
1.首次写入时,MMKV的效率是极其高的,在20多毫秒,而SP则需要14000毫秒。
2.第二次写入时,如果数据没有发生变化,则SP的效率也是比较高的。在100毫秒以内,无论Int,Boolean还是String。而MMKV一如既往的高效,仍然是20多毫秒。(原因在第二章会分析)
总结一下,就是如果数据发生改变的情况下,MMKV的效率是大幅好于SP的(甚至达到了上百倍的级别),如果数据没有发生改变,因为SP有缓存机制的存在,所以影响则不大。
二.SharedPreferences有哪些问题
都说MMKV是用来替代安卓原生的SharedPreferences的,那么我们自然要探究一下,原生的SP有什么缺陷?
2.1 SP实现原理-写
首先简单了解一下SP的原理。SP的实现类是SharedPreferencesImpl,Editor的实现类是SharedPreferencesImpl.EditorImpl。
我们putString时,最终调用到EditorImpl.putString(),逻辑很简单,就是把key,value存储到Map中。
@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}
我们在看一下最终提交编辑时,commit方法(apply类似),核心都是commitToMemory方法,只不过commit多了一个CountDownLatch的锁。
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
//1.加锁操作,避免多线程
synchronized (SharedPreferencesImpl.this.mLock) {
// We optimistically don't make a deep copy until
// a memory commit comes in when we're already
// writing to disk.
if (mDiskWritesInFlight > 0) {
// We can't modify our mMap as a currently
// in-flight write owns it. Clone it before
// modifying it.
// noinspection unchecked
//2.拷贝一个新的Map,存放原来所有的Map数据。
mMap = new HashMap<String, Object>(mMap);
}
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
boolean changesMade = false;
if (mClear) {
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
mapToWriteToDisk.clear();
}
keysCleared = true;
mClear = false;
}
//3.遍历这次修改的内容Map,对比老的全量Map,进行合成。如果值不变则跳过,如果变化了则存到全量的Map中。
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
// "this" is the magic value for a removal mutation. In addition,
// setting a value to "null" for a given key is specified to be
// equivalent to calling remove on that key.
if (v == this || v == null) {
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else {
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
mModified.clear();
//4.如果changesMade=false,则说明数据没有变化。
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
//5.最终生成MemoryCommitResult对象返回,最终写入的其实就是MemoryCommitResult对象。它是对map的封装类
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
}
主要包含以下几步:
1.加锁操作,避免多线程
2.拷贝一个新的Map,存放原来所有的Map数据。
3.遍历这次修改的内容mModified,对比老的全量mapToWriteToDisk,进行合成。如果值不变则跳过,如果变化了则存到全量的Map中。
4.如果修改了值,则记录changesMade=true。此时mCurrentMemoryStateGeneration+1;
5.最终生成MemoryCommitResult对象返回,最终写入的其实就是MemoryCommitResult对象。它是对map的封装类
最终写入的方法是writeToFile,代码就不贴了,简单来说,就是说没有修改并且原来文件存在的话,则直接回调无需写入操作(这也对应了第一章的实验结果2,为什么无修改时效率也不低)。否则,则写入到XML文件当中。
最终的文件保存在data/data/包名/shared_prefs/文件夹下:
内容格式如下:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<int name="key1251" value="-832759317" />
<int name="key1252" value="1723359929" />
<int name="key1253" value="469068865" />
<int name="key1254" value="-836324061" />
<int name="key1250" value="129252392" />
</map>
2.2 SP实现原理-读
初始化SharedPreferencesImpl的时候,就会去指定文件里面读了,通过startLoadFromDisk方法。
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
startLoadFromDisk();
}
新起一个线程去读取文件内容,然后把读取到的内容放到Map上。
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
然后我们在看getString方法,其余的类似。
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
等待上面文件内容,读取完成后才会继续执行,否则会被阻塞住。
2.3 SP存在的问题
通过对原理啊的了解,我们会发现这样做有很多问题。总一下,主要有如下几个问题:
1.最终写入XML文件实用的是IO操作,IO操作需要两次拷贝,效率是比较低的。(原因自行百度,这里就不再赘述了)
2.实用XML格式进行存储,并且全部以字符串的形式进行保存,浪费存储空间。比如value="469068865"。需要占用17个字节,utf-8一个英文字符占用1个字节,则存储该值需要17个字节。
3.每次编辑时,都需要对文件进行全量的写入操作。因为每次都是对完整的数据Map进行写入操作,哪怕只修改了一个值。这样做无疑是极大的浪费。
4.SP虽然支持多进程访问,但是多进程的读取是相当不安全的,因为进程间内存不能共享,而SP的多进程是每个进程一个对象进行操作。所以我们安全的使用方式仍然是使用一个进程去读取,并提供ContentProvider的方式供其它进程访问或者增加文件锁的方式,这样做无疑增加了我们使用复杂度。
5.线程阻塞问题。上面我们看到,只有全部加载完xml中的内容后,getString的函数才能继续往下执行。所以线程会被阻塞。
三.MMKV如何解决这些问题
既然SP存在这么多的问题,所以腾讯才会放弃SP,新建MMKV项目去解决这些问题,那么MMKV是如何解决这些问题的呢?
3.1 实现高效的文件操作
IO操作是需要进行两次内存拷贝的,第一次从用户内存空间拷贝到内核空间,第二次从内核空间拷贝到磁盘。尤其第一次拷贝,是很浪费CPU性能的。
熟悉binder原理的都知道,binder实现了一次拷贝,其底层原理就是mmap。所以MMKV也使用了mmap的原理。把用户内存的一部分空间和内核内存的一部分空间映射到同一块物理内存上,这样用户对这部分的内存操作,就会直接反映到内核空间上,然后由内存完成最终的写操作,少了一次拷贝,则效率就会大幅上升。而且由于内核的拷贝是发生在系统进程,不会阻塞用户进程的操作。所以实际上mmap的写入执行效率,接近于直接进行内存操作的效率。
3.2 实现更精简的数据格式
上面已经讲到了,SP中如果存储469068865这个int值,需要占用17个字节。
但是实际上,如果用二进制表示的话,只需要4个字节来表示。
同样的,如果存储255这个int值的话,SP需要10个(value+""+255=),而实际上二进制表示只需要1个字节。
所以我们肯定会想有没有更高效的表示方式呢?这个方案也许有很多,目前最为推荐的是google的protobuf这个序列化方案。
详细的protobuf方案这里不做详细介绍,只做简单的原理说明。
存储255,使用protobuf会以01 FF的方式来表示,01代表占用1个字节,FF代表实际的值。所以,只需要两个字节就可以表示255这个value。
这时候你肯定要问,为什么255一定是value呢?怎么保证255读出来的就不是key的值呢?这个答案很简单。Map一定是key-value-key-value的形式。所以我们只要依次读取值,第一个代表key,第二个代表value,第三个代表key,第四个代表value,依次这样读取下去就不会出错。
而MMKV就是用了这样的序列化原理。
3.3 实现更优的数据更新方式
上面讲SP的时候,它每次更新,都需要把整个map中的数据覆盖写入到文件当中。哪怕我仅仅只修改了其中的一项。这时候我们一定会想,有没有办法每次只更新我修改的那一项呢?
答案当然是有的,我们可以想一下map的原理。当我们操作一个map的时候,添加一个很多了key-value数据。如果想改其中的一项,那么只需要输入指定的key-value就可以覆盖之前的值。
同样的,我们持久化的时候,也可以采取类似的操作。我们把每次把修改的内容都填充到文本的最后。原来存储的结构为:
key1:value1,key2:value2,key3:value3,key4:value4
修改key2的值为value2222,则修改后存储的结构如下,并且只修改了红色的部分,所以修改的内容更少。
key1:value1,key2:value2,key3:value3,key4:value4,key2:value2222
读取数据时,首先读取key="key2",value="value2",然后读取到第二个key2时,修改value="value2222"。
当然,这样做也会存在一个问题,N多次的修改之后,因为是每次追加的模式,所以存储内容会变得的无限长。所以MMKV针对这一块做了一个判断,当达到内存上限时,则会出发一次全量的更新,筛除那些重复的数据,从而保证数据恢复到最精简的形式。
3.4 如何解决多进程一致性
MMKV解决多进程问题,是通过校验码的方式来解决的。
读取文件之前,会先去读取CRC校验码,如果校验码和预期一致,则进行读取。
否则更新校验码并且重新读取整个文件。
说到这里,略微扩展一下,这里为什么使用CRC进行校验?原因是CRC相对于MD5速度更快,但是安全性会低一些。
3.5 如何线程阻塞问题
MMVK中,其实也是类似的流程,也是先去通过getDataForKey方法读取所有的值存到Map中(native中Map),然后在通过key去map中取值。
区别SP中是额外新起一个线程去读的,而MMKV中是直接在当前线程读取。
MMKV中执行流程如下:
MMKV整套流程是运行在一个线程的,所以不会有线程切换的损耗,所以也会更高效一些。
其原因其实MMKV通过mmap读取值是一个接近于内存级别的操作,所以不会有过多的耗时,因此无需切换线程。