MMKV 高性能的数据存取框架解读

MMKV

目标

了解MMKV

MMKV的基本应用

MMKV的原理概念

多进程设计思想

性能对比

源码解读

简介

MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。

官方文档:https://github.com/Tencent/MMKV/blob/master/README_CN.md

项目地址:https://github.com/Tencent/MMKV

mmap

简单解释(仅供参考)

把文件描述符fd(部分硬件资源外存统一描述符)映射到虚拟空间中,所以能够实现进程间的通信、数据存取。


image-20210819112108803.png

映射流程(仅供参考)

1、用户进程调用内存映射函数库mmap,当前进程在虚拟地址空间中,寻找一段空闲的满足要求的虚拟地址

2、此时内核收到相关请求后会调用内核的mmap函数,注意,不同于用户空间库函数。内核mmap函数通过虚拟文件系统定位到文件磁盘物理地址,既实现了文件地址和虚拟地址区域的映射关系。 此时,这片虚拟地址并没有任何数据关联到主存中。

注意,前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

3、进程的读或写操作访问虚拟地址空间这一段映射地址,现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页中断。

4、由于引发了缺页中断,内核则调用nopage函数把所缺的页从磁盘装入到主存中

5、之后用户进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

应用

Linux进程的创建

Android Binder

微信MMKV组件

美团Logan

参考文章

Android-内存映射mmap

mmap的理解

Android应用使用mmap实例

ProtoBuf

简介

protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。

更多内容、实际应用可参考官方文档。

官方文档:https://developers.google.com/protocol-buffers/docs/overview

特性

语言无关、平台无关:即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台

高效:即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单

扩展性、兼容性好:你可以更新数据结构,而不影响和破坏原有的旧程序

数据结构

20191225165541255.png

时间效率对比:

数据格式 1000条数据 5000条数据
Protobuf 195ms 647ms
Json 515ms 2293ms

空间效率对比:

数据格式 5000条数据
Protobuf 22MB
Json 29MB

参考文章

https://www.jianshu.com/p/73c9ed3a4877
https://www.jianshu.com/p/a24c88c0526a

简单使用

MMKV 的使用非常简单,所有变更立马生效,无需调用 syncapply

依赖

dependencies {
    implementation 'com.tencent:mmkv:1.0.10'
    // replace "1.0.10" with any available version
}

初始化

配置 MMKV 根目录

在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 Application 里:

public void onCreate() {
    super.onCreate();

    String rootDir = MMKV.initialize(this);
    System.out.println("mmkv root: " + rootDir);
     //data/user/0包名/files/mmkv
}

其他初始化的方法

//指定日志级别
initialize(Context context, MMKVLogLevel logLevel)
//指定存储地址和日志级别
initialize(String rootDir)
initialize(String rootDir, MMKVLogLevel logLevel)
//MMKV.LibLoader用来解决Android 设备(API level 19)在安装/更新 APK 时出错问题
initialize(String rootDir, MMKV.LibLoader loader)
initialize(String rootDir, MMKV.LibLoader loader, MMKVLogLevel logLevel)

CRUD 操作

MMKV 提供一个全局的实例,可以直接使用:

import com.tencent.mmkv.MMKV;
//……

MMKV kv = MMKV.defaultMMKV();

kv.encode("bool", true);
boolean bValue = kv.decodeBool("bool");

kv.encode("int", Integer.MIN_VALUE);
int iValue = kv.decodeInt("int");

kv.encode("string", "Hello from mmkv");
String str = kv.decodeString("string");

删除 & 查询

MMKV kv = MMKV.defaultMMKV();

kv.removeValueForKey("bool");
System.out.println("bool: " + kv.decodeBool("bool"));
    
kv.removeValuesForKeys(new String[]{"int", "long"});
System.out.println("allKeys: " + Arrays.toString(kv.allKeys()));

boolean hasBool = kv.containsKey("bool");

区分存储

使用MMKV.mmkvWithID即可创建不同的存储区域的MMKV实例。

MMKV kv = MMKV.mmkvWithID("MyID");
kv.encode("bool", true);

支持的数据类型

  • 支持以下 Java 语言基础类型:

    • boolean、int、long、float、double、byte[]
  • 支持以下 Java 类和容器:

    • String、Set<String>
    • 任何实现了Parcelable的类型

SharedPreferences 迁移

  • MMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来
/**
 * An highly efficient, reliable, multi-process key-value storage framework.
 * THE PERFECT drop-in replacement for SharedPreferences and MultiProcessSharedPreferences.
 */
public class MMKV implements SharedPreferences, SharedPreferences.Editor {
  • MKV 还额外实现了一遍 SharedPreferencesSharedPreferences.Editor 这两个 interface,在迁移的时候只需两三行代码即可,其他 CRUD 操作代码都不用改。
private void testImportSharedPreferences() {
    //SharedPreferences preferences = getSharedPreferences("myData", MODE_PRIVATE);
    MMKV preferences = MMKV.mmkvWithID("myData");
    // 迁移旧数据
    {
        SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
        preferences.importFromSharedPreferences(old_man);
        old_man.edit().clear().commit();
    }
    // 跟以前用法一样
    SharedPreferences.Editor editor = preferences.edit();
    editor.putBoolean("bool", true);
    editor.putInt("int", Integer.MIN_VALUE);
    editor.putLong("long", Long.MAX_VALUE);
    editor.putFloat("float", -3.14f);
    editor.putString("string", "hello, imported");
    HashSet<String> set = new HashSet<String>();
    set.add("W"); set.add("e"); set.add("C"); set.add("h"); set.add("a"); set.add("t");
    editor.putStringSet("string-set", set);
    // 无需调用 commit()
    //editor.commit();
}

进阶使用

日志

日志切面AOP思想

MMKV 默认将日志打印到 logcat,不便于对线上问题进行定位和解决。你可以在 App 启动时接收转发 MMKV 的日志。实现MMKVHandler接口,添加类似下面的代码:

@Override
public boolean wantLogRedirecting() {
    return true;
}

@Override
public void mmkvLog(MMKVLogLevel level, String file, int line, String func, String message) {
    String log = "<" + file + ":" + line + "::" + func + "> " + message;
    switch (level) {
        case LevelDebug:
            //Log.d("redirect logging MMKV", log);
            break;
        case LevelInfo:
            //Log.i("redirect logging MMKV", log);
            break;
        case LevelWarning:
            //Log.w("redirect logging MMKV", log);
            break;
        case LevelError:
            //Log.e("redirect logging MMKV", log);
            break;
        case LevelNone:
            //Log.e("redirect logging MMKV", log);
            break;
    }
}

如果你不希望 MMKV 打印日志,你可以关掉它(虽然我们强烈不建议你这么做)。
注意:除非有非常强烈的证据表明MMKV的日志拖慢了App的速度,你不应该关掉日志。没有日志,日后万一用户有问题,将无法跟进。

MMKV.setLogLevel(MMKVLogLevel.LevelNone);

加密

MMKV 默认明文存储所有 key-value,依赖 Android 系统的沙盒机制保证文件加密。如果你担心信息泄露,你可以选择加密 MMKV。

String cryptKey = "My-Encrypt-Key";
MMKV kv = MMKV.mmkvWithID("MyID", MMKV.SINGLE_PROCESS_MODE, cryptKey);

你可以更改密钥,也可以将一个加密 MMKV 改成明文,或者反过来。

final String mmapID = "testAES_reKey1";
// an unencrypted MMKV instance
MMKV kv = MMKV.mmkvWithID(mmapID, MMKV.SINGLE_PROCESS_MODE, null);

// change from unencrypted to encrypted
kv.reKey("Key_seq_1");

// change encryption key
kv.reKey("Key_seq_2");

// change from encrypted to unencrypted
kv.reKey(null);

自定义 library loader

一些 Android 设备(API level 19)在安装/更新 APK 时可能出错, 导致 libmmkv.so 找不到。然后就会遇到 java.lang.UnsatisfiedLinkError 之类的 crash。有个开源库 ReLinker 专门解决这个问题,你可以用它来加载 MMKV:

String dir = getFilesDir().getAbsolutePath() + "/mmkv";
MMKV.initialize(dir, new MMKV.LibLoader() {
    @Override
    public void loadLibrary(String libName) {
        ReLinker.loadLibrary(MyApplication.this, libName);
    }
});

Relinker简介:

本地库加载框架,github1000+的star

原理:

尝试使用系统原生方式去加载so,如果加载失败,Relinker会尝试从apk中拷贝so到App沙箱目录下,然后再去尝试加载so。最终,我们可以使用 ReLinker.loadLibrary(context, “mylibrary”) 来加载本地库。

Native Buffer

当从 MMKV 取一个 String or byte[]的时候,会有一次从 native 到 JVM 的内存拷贝。如果这个值立即传递到另一个 native 库(JNI),又会有一次从 JVM 到 native 的内存拷贝。当这个值比较大的时候,整个过程会非常浪费。Native Buffer 就是为了解决这个问题。
Native Buffer 是由 native 创建的内存缓冲区,在 Java 里封装成 NativeBuffer 类型,可以透明传递到另一个 native 库进行访问处理。整个过程避免了先拷内存到 JVM 又从 JVM 拷回来导致的浪费。示例代码:

int sizeNeeded = kv.getValueActualSize("bytes");
NativeBuffer nativeBuffer = MMKV.createNativeBuffer(sizeNeeded);
if (nativeBuffer != null) {
    int size = kv.writeValueToNativeBuffer("bytes", nativeBuffer);
    Log.i("MMKV", "size Needed = " + sizeNeeded + " written size = " + size);

    // pass nativeBuffer to another native library
    // ...

    // destroy when you're done
    MMKV.destroyNativeBuffer(nativeBuffer);
}

跨进程通信的实现

本质:共享MMKV实例化信息完成对象的伪复制

  • 通信的数据对象

    该类MMKV内部已经实现,传递进程A的mmkv信息给B进程,B进程新建MMKV实例,B就可以通过MMKV实例来完成数据的操作

public final class ParcelableMMKV implements Parcelable {
    private final String mmapID;
    private int ashmemFD = -1;
    private int ashmemMetaFD = -1;
    private String cryptKey = null;

    public ParcelableMMKV(MMKV mmkv) {
        mmapID = mmkv.mmapID();
        ashmemFD = mmkv.ashmemFD();
        ashmemMetaFD = mmkv.ashmemMetaFD();
        cryptKey = mmkv.cryptKey();
    }

    private ParcelableMMKV(String id, int fd, int metaFD, String key) {
        mmapID = id;
        ashmemFD = fd;
        ashmemMetaFD = metaFD;
        cryptKey = key;
    }

    public MMKV toMMKV() {
        if (ashmemFD >= 0 && ashmemMetaFD >= 0) {
            return MMKV.mmkvWithAshmemFD(mmapID, ashmemFD, ashmemMetaFD, cryptKey);
        }
        return null;
    }
}
  • Aidl文件,需要手动创建该文件

    import com.tencent.mmkv.ParcelableMMKV;
    
    interface IAshmemMMKV {
        ParcelableMMKV GetAshmemMMKV();
    }
    

    Aidl定义了跨进程通信的方法细则,这里只需要一个get方法,返回ParcelableMMKV通信实体。

  • 服务端

    服务端Service

    public class UserServer extends Service {
      @Nullable
        @Override
        public IBinder onBind(Intent intent) {
              Log.i(TAG, "onBind, intent=" + intent);
            return new AshmemMMKVGetter();
        }
    
    }
    
    public class AshmemMMKVGetter extends IAshmemMMKV.Stub {
    
        private AshmemMMKVGetter() {
            // 1M, ashmem cannot change size after opened
            final String id = "tetAshmemMMKV";
            try {
                m_ashmemMMKV = MMKV.mmkvWithAshmemID(BenchMarkBaseService.this, id, AshmemMMKV_Size,
                        MMKV.MULTI_PROCESS_MODE, CryptKey);
                m_ashmemMMKV.encode("bool", true);
            } catch (Exception e) {
                Log.e("MMKV", e.getMessage());
            }
        }
    
        public ParcelableMMKV GetAshmemMMKV() {
            return new ParcelableMMKV(m_ashmemMMKV);
        }
    }
    
  • 客户端

    onServiceConnected连接之后

Intent intent = new Intent();
intent.setAction("***.***.***");
intent.setPackage("***.***.***");
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);

 private ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
               IAshmemMMKV ashmemMMKV = IAshmemMMKV.Stub.asInterface(service);
        try {
            ParcelableMMKV parcelableMMKV = ashmemMMKV.GetAshmemMMKV();
            if (parcelableMMKV != null) {
                m_ashmemMMKV = parcelableMMKV.toMMKV();
                if (m_ashmemMMKV != null) {
                    Log.i("MMKV", "ashmem bool: " + m_ashmemMMKV.decodeBool("bool"));
                }
            }
        } catch (RemoteException e) {
            e.printStackTrace();
        }

        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            isBind = false;
          
        }
    }; 

原理

内存准备

通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。

数据组织

数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。考虑到我们要提供的是通用 kv 组件,key 可以限定是 string 字符串类型,value 则多种多样(int/bool/double 等)。要做到通用的话,考虑将 value 通过 protobuf 协议序列化成统一的内存块(buffer),然后就可以将这些 KV 对象序列化到内存中。

message KV {
    string key = 1;
    buffer value = 2;
}

-(BOOL)setInt32:(int32_t)value forKey:(NSString*)key {
    auto data = PBEncode(value);
    return [self setData:data forKey:key];
}

-(BOOL)setData:(NSData*)data forKey:(NSString*)key {
    auto kv = KV { key, data };
    auto buf = PBEncode(kv);
    return [self write:buf];
}

写入优化

标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。

空间增长

使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

-(BOOL)append:(NSData*)data {
    if (space >= data.length) {
        append(fd, data);
    } else {
        newData = unique(m_allKV);
        if (total_space >= newData.length) {
            write(fd, newData);
        } else {
            while (total_space < newData.length) {
                total_space *= 2;
            }
            ftruncate(fd, total_space);
            write(fd, newData);
        }
    }
}

数据有效性

考虑到文件系统、操作系统都有一定的不稳定性,我们另外增加了 crc 校验,对无效数据进行甄别。在 iOS 微信现网环境上,我们观察到有平均约 70万日次的数据校验不通过。

多进程设计思想

官网地址:https://github.com/Tencent/MMKV/wiki/android_ipc

官网有详细的说明,这里主要分享思想:

CS架构:

IPC CS架构有Binder、Socket等,特点是一个单独进程管理数据,数据同步不易出错,简单好用易上手,缺点是慢。

去中心化:

只需要将文件 mmap 到每个访问进程的内存空间,加上合适的进程锁,再处理好数据的同步,就能够实现多进程并发访问。

性能对比

单进程

读写效率

mmkv SharedPreferences sqlite
write int 1000 6.5 693.1 774.4
write String 1000 18.9 1003.9 857.3
read int 1000 4.3 1.5 302.9
read String 1000 8.3 1.3 320.7

单进程性能
可见,MMKV 在写入性能上远远超越 SharedPreferences & SQLite,在读取性能上也有相近或超越的表现。

image-20210826111619059.png

(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)

多进程性能

可见,MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite。

image-20210826111725683.png

性能对比: https://github.com/Tencent/MMKV/wiki/android_benchmark_cn

原理上和SharedPreference区别

SharedPreference原理

本质是在本地磁盘记录了一个xml文件,在构造方法中开启一个子线程加载磁盘中的xml文件

@UnsupportedAppUsage
private void startLoadFromDisk() {
    synchronized (mLock) {
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

SharedPreferencesImpl内部维护Map缓存,所以SharedPreference读的效率很高,但是写得时候都是通过FileOutputStreame文件IO得方式完成数据更新操作。


20191224163605643.png

MMKV

利用mmap完成数据的读写,读写高效。

SharedPreference MMKV
读写方式 IO mmap
数据格式 XML 总体结构、整型编码、二进制
更新方式 全量更新 增量与全量写入

SharedPreferences注意点

  1. 只要file name相同,拿到的就是同一个SharedPreferencesImpl对象,内部有缓存机制,首次获取才会创建对象。
  2. 在SharedPreferencesImpl构造方法中,会开启子线程把对应的文件key-value全部加载进内存,加载结束后,mLoaded被设置为true。
  3. 调用getXXX方法时,会阻塞等待直到mLoaded为true,也就是getXXX方法是有可能阻塞UI线程的,另外,调用contains和 edit等方法也是。
  4. 写数据时,会先拿到一个EditorImpl对象,然后putXXX,这时只是把数据写入到内存中,最后调用commit或者apply方法,才会真正写入文件。
  5. 不管是commit还是apply方法,第一步都是调用commitToMemory方法生成一个MemoryCommitResult对象,注意这里会先处理clear旧的key-value,再处理新添加的key-value,另外value为this或者null都表示需要被remove掉。
  6. 调用commit方法,就会同步执行写入文件的操作,该方法是耗时操作,不能在主线程中调用,该方法最后会返回成功或失败结果。
  7. 调用apply方法,就会把任务放到QueuedWork的队列中,然后在HandlerThread中执行,然后apply方法会立即返回。但如果是Android8.0之前,这里就是放到QueuedWork的一个单线程中执行了。
  8. 最后是写入文件,会先把原有的文件命名为bak备份文件,然后创建新的文件全量写入,写入成功后,把bak备份文件删除掉。

安全

基于Android的沙盒模式,在内存读写的方式上做了改变,所以不存在应用程序之前的安全问题。

MMKV使用ProtoBuf 编码,另外增加了内部实现的加密模式(AES CFB),相比SharedPrefrence,在文件暴露的情况下MMKV的数据不具有可读性。

在TV中的应用

配置参数较多、需要频繁读写修改参数的场景

可以提高读写耗时,减少SP带来的耗时成本和操作不当引发的ANR

源码解读

初始化

 public static String initialize(Context context) {
        String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
        MMKVLogLevel logLevel = MMKVLogLevel.LevelInfo;
        return initialize(root, (MMKV.LibLoader)null, logLevel);
    }

    public static String initialize(String rootDir, LibLoader loader, MMKVLogLevel logLevel) {
        if (loader != null) {
            if (BuildConfig.FLAVOR.equals("SharedCpp")) {
                loader.loadLibrary("c++_shared");
            }
            loader.loadLibrary("mmkv");
        } else {
            if (BuildConfig.FLAVOR.equals("SharedCpp")) {
                System.loadLibrary("c++_shared");
            }
            System.loadLibrary("mmkv");
        }
        MMKV.rootDir = rootDir;
        jniInitialize(MMKV.rootDir, logLevel2Int(logLevel));
        return rootDir;
    }

1.当不指定目录的时候,会创建一个app内的/data/data/包名/files/mmkv的目录。所有的文件都保存在里面;

2.加载两个so库,c++_shared以及mmkv, 根据打包配置来选择是否要加载c++_shared

native_bridge.cpp
MMKV_JNI void jniInitialize(JNIEnv *env, jobject obj, jstring rootDir, jint logLevel) {
    if (!rootDir) {
        return;
    }
    const char *kstr = env->GetStringUTFChars(rootDir, nullptr);
    if (kstr) {
        //获取rootDir的url char指针数组字符串,调用MMKV::initializeMMKV进一步初始化。
        MMKV::initializeMMKV(kstr, (MMKVLogLevel) logLevel);
        env->ReleaseStringUTFChars(rootDir, kstr);
    }
}

MMKV.cpp
void initialize() {
    //创建了MMKV实例的散列表
    g_instanceDic = new unordered_map<string, MMKV *>;
    g_instanceLock = new ThreadLock();
    g_instanceLock->initialize();

    mmkv::DEFAULT_MMAP_SIZE = mmkv::getPageSize();
    MMKVInfo("page size:%d", DEFAULT_MMAP_SIZE);
}

ThreadOnceToken_t once_control = ThreadOnceUninitialized;

void MMKV::initializeMMKV(const MMKVPath_t &rootDir, MMKVLogLevel logLevel) {
    g_currentLogLevel = logLevel;
    //初始化全局的线程锁ThreadLock
    ThreadLock::ThreadOnce(&once_control, initialize);

    g_rootDir = rootDir;
    //创建文件夹
    mkPath(g_rootDir);

    MMKVInfo("root dir: " MMKV_PATH_FORMAT, g_rootDir.c_str());
}

MMKV 的实例化

java层的实例化

defaultMMKV

 public static MMKV defaultMMKV() {
        if (rootDir == null) {
            throw new IllegalStateException("You should Call MMKV.initialize() first.");
        }

        long handle = getDefaultMMKV(SINGLE_PROCESS_MODE, null);
        return new MMKV(handle);
    }

//构造函数
private MMKV(long handle) {
    nativeHandle = handle;
}

getDefaultMMKV Native层做好实例化工作返回一个long类型的handle,以这个handler作为Java层MMKV的构造参数

mmkvWithID

与defaultMMKV区别就是多了参数设置

public static MMKV mmkvWithID(String mmapID) {
        if (rootDir == null) {
            throw new IllegalStateException("You should Call MMKV.initialize() first.");
        }

        long handle = getMMKVWithID(mmapID, SINGLE_PROCESS_MODE, null, null);
        return new MMKV(handle);
    }

native层实例化

native-bridge.cpp==>getDefaultMMKV

MMKV.cpp==>mmkvWithID 默认的ID为mmkv.default

native-bridge.cpp
MMKV_JNI jlong getDefaultMMKV(JNIEnv *env, jobject obj, jint mode, jstring cryptKey) {
    MMKV *kv = nullptr;

    if (cryptKey) {
        string crypt = jstring2string(env, cryptKey);
        if (crypt.length() > 0) {
            kv = MMKV::defaultMMKV((MMKVMode) mode, &crypt);
        }
    }
    if (!kv) {
        kv = MMKV::defaultMMKV((MMKVMode) mode, nullptr);
    }

    return (jlong) kv;
}
MMKV.cpp

#define DEFAULT_MMAP_ID "mmkv.default"
MMKV *MMKV::defaultMMKV(MMKVMode mode, string *cryptKey) {
#ifndef MMKV_ANDROID
    return mmkvWithID(DEFAULT_MMAP_ID, mode, cryptKey);
#else
    return mmkvWithID(DEFAULT_MMAP_ID, DEFAULT_MMAP_SIZE, mode, cryptKey);
#endif
}

MMKV.h
static MMKV *mmkvWithID(const std::string &mmapID,
                            int size = mmkv::DEFAULT_MMAP_SIZE,
                            MMKVMode mode = MMKV_SINGLE_PROCESS,
                            std::string *cryptKey = nullptr,
                            MMKVPath_t *relativePath = nullptr);

只要是实例化,最后都是调用mmkvWithID进行实例化。默认的mmkv的id就是mmkv.default

mmkvWithID

MMKV.cpp
MMKV *MMKV::mmkvWithID(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {

    if (mmapID.empty()) {
        return nullptr;
    }
    SCOPED_LOCK(g_instanceLock);
    //取 mmapID relativePath MMKV_PATH_SLASH 的 md5值作为key
    auto mmapKey = mmapedKVKey(mmapID, relativePath);
    auto itr = g_instanceDic->find(mmapKey);
    if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second;
        return kv;
    }
    if (relativePath) {
        if (!isFileExist(*relativePath)) {
            if (!mkPath(*relativePath)) {
                return nullptr;
            }
        }
        MMKVInfo("prepare to load %s (id %s) from relativePath %s", mmapID.c_str(), mmapKey.c_str(),
                 relativePath->c_str());
    }
    //实例化
    auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
    (*g_instanceDic)[mmapKey] = kv;
    return kv;
}

将所有的MMKV实例都会保存在之前实例化的g_instanceDic散列表中。其中mmkv每一个id对应一个文件的路径:

  • 相对路径(android中是 data/data/包名/files/mmkv) + / + mmkvID

如果发现对应路径下的mmkv在散列表中已经缓存了,则直接返回。否则就会把相对路径保存下来,传递给MMKV进行实例化,并保存在g_instanceDic散列表中。

MMKV 的构造函数

MMKV::MMKV(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
    : m_mmapID(mmapedKVKey(mmapID, relativePath)) // historically Android mistakenly use mmapKey as mmapID
    , m_path(mappedKVPathWithID(m_mmapID, mode, relativePath))
    , m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
    , m_file(new MemoryFile(m_path, size, (mode & MMKV_ASHMEM) ? MMFILE_TYPE_ASHMEM : MMFILE_TYPE_FILE))
    , m_metaFile(new MemoryFile(m_crcPath, DEFAULT_MMAP_SIZE, m_file->m_fileType))
    , m_metaInfo(new MMKVMetaInfo())
    , m_crypter(nullptr)
    , m_lock(new ThreadLock())
    , m_fileLock(new FileLock(m_metaFile->getFd(), (mode & MMKV_ASHMEM)))
    , m_sharedProcessLock(new InterProcessLock(m_fileLock, SharedLockType))
    , m_exclusiveProcessLock(new InterProcessLock(m_fileLock, ExclusiveLockType))
    , m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0 || (mode & CONTEXT_MODE_MULTI_PROCESS) != 0) {
    m_actualSize = 0;
    m_output = nullptr;

    if (cryptKey && cryptKey->length() > 0) {
        m_crypter = new AESCrypt(cryptKey->data(), cryptKey->length());
    }

    m_needLoadFromFile = true;
    m_hasFullWriteback = false;

    m_crcDigest = 0;

    m_sharedProcessLock->m_enable = m_isInterProcess;
    m_exclusiveProcessLock->m_enable = m_isInterProcess;

    // sensitive zone
    {
        SCOPED_LOCK(m_sharedProcessLock);
        loadFromFile();
    }
}
  • 1.m_mmapID MMKV的ID通过mmapedKVKey创建:
string mmapedKVKey(const string &mmapID, MMKVPath_t *relativePath) {
    if (relativePath && g_rootDir != (*relativePath)) {
        return md5(*relativePath + MMKV_PATH_SLASH + string2MMKVPath_t(mmapID));
    }
    return mmapID;
}

mmkvID就是经过md5后对应缓存文件对应的路径。

  • 2.m_path mmkv 缓存的路径通过mappedKVPathWithID生成
MMKVPath_t mappedKVPathWithID(const string &mmapID, MMKVMode mode, MMKVPath_t *relativePath) {
#ifndef MMKV_ANDROID
...
#else
    if (mode & MMKV_ASHMEM) {
        return ashmemMMKVPathWithID(encodeFilePath(mmapID));
    } else if (relativePath) {
#endif
        return *relativePath + MMKV_PATH_SLASH + encodeFilePath(mmapID);
    }
    return g_rootDir + MMKV_PATH_SLASH + encodeFilePath(mmapID);
}

能看到这里是根据当前的mode初始化id,如果不是ashmem匿名共享内存模式进行创建,则会和上面的处理类似。id就是经过md5后对应缓存文件对应的路径。

注意这里mode设置的是MMKV_ASHMEM,也就是ashmem匿名共享内存模式则是如下创建方法:

constexpr char ASHMEM_NAME_DEF[] = "/dev/ashmem";

MMKVPath_t ashmemMMKVPathWithID(const MMKVPath_t &mmapID) {
    return MMKVPath_t(ASHMEM_NAME_DEF) + MMKV_PATH_SLASH + mmapID;
}

实际上就是在驱动目录下的一个内存文件地址。

  • 3.m_crcPath 一个.crc文件的路径。这个crc文件实际上用于保存crc数据校验key,避免出现传输异常的数据进行保存了。
  • 4.m_file 一个依据m_path构建的内存文件MemoryFile对象。
  • 5.m_metaFile 一个依据m_crcPath构建的内存文件MemoryFile对象。
  • 6.m_metaInfo 一个MMKVMetaInfo结构体,这个结构体一般是读写的时候,带上的MMKV的版本信息,映射的内存大小,加密crc的key等。
  • 7.m_crypter 默认是一个AESCrypt 对称加密器
  • 8.m_lock ThreadLock线程锁
  • 9.m_fileLock 一个以m_metaFile的fd 文件锁
  • 10.m_sharedProcessLock 类型是InterProcessLock,这是一种文件共享锁
  • 11.m_exclusiveProcessLock 类型是InterProcessLock,这是一种排他锁
  • 12.m_isInterProcess 判断是否打开了多进程模式的标志位,一旦关闭了,所有进程锁都会失效。

Ashmem匿名共享内存

Anonymous Shared Memory-Ashmem

简单理解:

共享内存是Linux自带的一种IPC机制,Android直接使用了该模型,不过做出了自己的改进,进而形成了Android的匿名共享内存(Anonymous Shared Memory-Ashmem)

应用:

APP进程同SurfaceFlinger共用一块内存,如此,就不需要进行数据拷贝,APP端绘制完毕,通知SurfaceFlinger端合成,再输出到硬件进行显示即可

更多文章

https://www.jianshu.com/p/6a8513fdb792

https://www.jianshu.com/p/d9bc9c668ba6

多进程MMKV实例化

多进程通信的过程

        服务端创建MMKV实例
        m_ashmemMMKV = MMKV.mmkvWithAshmemID(BenchMarkBaseService.this, id, AshmemMMKV_Size,MMKV.MULTI_PROCESS_MODE, CryptKey);
        
        Aidl传递实体
        ParcelableMMKV(m_ashmemMMKV);
        
        Aidl传递实体 ParcelableMMKV字段
        mmapID = mmkv.mmapID();
        ashmemFD = mmkv.ashmemFD();
        ashmemMetaFD = mmkv.ashmemMetaFD();
        cryptKey = mmkv.cryptKey();
        
        客户端获取传递实体ParcelableMMKV
        AshmemMMKV ashmemMMKV = IAshmemMMKV.Stub.asInterface(service);
        ParcelableMMKV parcelableMMKV = ashmemMMKV.GetAshmemMMKV();
        
        客户端获取真正的操作数据的MMKV实例
        parcelableMMKV.toMMKV()
        public MMKV toMMKV() {
            if (ashmemFD >= 0 && ashmemMetaFD >= 0) {
                return MMKV.mmkvWithAshmemFD(mmapID, ashmemFD, ashmemMetaFD, cryptKey);
            }
            return null;
        }
        
        看一下mmkvWithAshmemFD
        MMKV.mmkvWithAshmemFD(mmapID, ashmemFD, ashmemMetaFD, cryptKey);

mmkvWithAshmemFD

MMKV *MMKV::mmkvWithAshmemFD(const string &mmapID, int fd, int metaFD, string *cryptKey) {

    if (fd < 0) {
        return nullptr;
    }
    SCOPED_LOCK(g_instanceLock);

    auto itr = g_instanceDic->find(mmapID);
    if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second;
#    ifndef MMKV_DISABLE_CRYPT
        kv->checkReSetCryptKey(fd, metaFD, cryptKey);
#    endif
        return kv;
    }
    auto kv = new MMKV(mmapID, fd, metaFD, cryptKey);
    (*g_instanceDic)[mmapID] = kv;
    return kv;
}


MMKV::MMKV(const string &mmapID, int ashmemFD, int ashmemMetaFD, string *cryptKey)
    : m_mmapID(mmapID)
    , m_path(mappedKVPathWithID(m_mmapID, MMKV_ASHMEM, nullptr))
    , m_crcPath(crcPathWithID(m_mmapID, MMKV_ASHMEM, nullptr))
    , m_dic(nullptr)
    , m_dicCrypt(nullptr)
    , m_file(new MemoryFile(ashmemFD))
    , m_metaFile(new MemoryFile(ashmemMetaFD))
    , m_metaInfo(new MMKVMetaInfo())
    , m_crypter(nullptr)
    , m_lock(new ThreadLock())
    , m_fileLock(new FileLock(m_metaFile->getFd(), true))
    , m_sharedProcessLock(new InterProcessLock(m_fileLock, SharedLockType))
    , m_exclusiveProcessLock(new InterProcessLock(m_fileLock, ExclusiveLockType))
    , m_isInterProcess(true) {

encode 写入数据

encodeString

MMKV_JNI jboolean encodeString(JNIEnv *env, jobject, jlong handle, jstring oKey, jstring oValue) {
    MMKV *kv = reinterpret_cast<MMKV *>(handle);
    if (kv && oKey) {
        string key = jstring2string(env, oKey);
        if (oValue) {
            string value = jstring2string(env, oValue);
            return (jboolean) kv->set(value, key);
        } else {
            kv->removeValueForKey(key);
            return (jboolean) true;
        }
    }
    return (jboolean) false;
}
bool MMKV::set(const string &value, MMKVKey_t key) {
    if (isKeyEmpty(key)) {
        return false;
    }
    auto data = MiniPBCoder::encodeDataWithObject(value);
    return setDataForKey(std::move(data), key);
}

  • 1.encodeDataWithObject 编码压缩内容
  • 2.setDataForKey 保存数据

setDataForKey

保存数据到映射的文件

bool MMKV::setDataForKey(MMBuffer &&data, MMKVKey_t key) {
    if (data.length() == 0 || isKeyEmpty(key)) {
        return false;
    }
    SCOPED_LOCK(m_lock);
    SCOPED_LOCK(m_exclusiveProcessLock);
    checkLoadData();

    auto ret = appendDataWithKey(data, key);
    if (ret) {
        m_dic[key] = std::move(data);
        m_hasFullWriteback = false;
    }
    return ret;
}

设置了互斥锁,和线程锁。整个步骤分为两步骤:

  • 1.checkLoadData 保存数据之前,校验已经存储的数据
  • 2.appendDataWithKey 进行数据的保存

appendDataWithKey

bool MMKV::appendDataWithKey(const MMBuffer &data, MMKVKey_t key) {

    size_t keyLength = key.length();
    // size needed to encode the key
    size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
    // size needed to encode the value
    size += data.length() + pbRawVarint32Size((int32_t) data.length());

    SCOPED_LOCK(m_exclusiveProcessLock);

    bool hasEnoughSize = ensureMemorySize(size);
    if (!hasEnoughSize || !isFileValid()) {
        return false;
    }

    m_output->writeString(key);

    m_output->writeData(data); // note: write size of data

    auto ptr = (uint8_t *) m_file->getMemory() + Fixed32Size + m_actualSize;
    if (m_crypter) {
        m_crypter->encrypt(ptr, ptr, size);
    }
    m_actualSize += size;
    updateCRCDigest(ptr, size);

    return true;
}

判断是否有足够的空间,没有则调用ensureMemorySize进行扩容,实在无法从内存中映射出来,那说明系统没空间了就返回异常。

正常情况下,是往全局缓冲区CodedOutputData 先后在文件内存的末尾写入key和value的数据。并对这部分的数据进行一次加密,最后更新这个存储区域的crc校验码。

这里实际上是调用了CodedOutputData的writeString把数据保存到映射的内存中。

void CodedOutputData::writeString(const string &value) {
    size_t numberOfBytes = value.size();
    this->writeRawVarint32((int32_t) numberOfBytes);
    if (m_position + numberOfBytes > m_size) {
        auto msg = "m_position: " + to_string(m_position) + ", numberOfBytes: " + to_string(numberOfBytes) +
                   ", m_size: " + to_string(m_size);
        throw out_of_range(msg);
    }
    memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes);
    m_position += numberOfBytes;
}

decode MMKV读取数据

MMKV读取数据

MMKV_JNI jstring decodeString(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jstring oDefaultValue) {
    MMKV *kv = reinterpret_cast<MMKV *>(handle);
    if (kv && oKey) {
        string key = jstring2string(env, oKey);
        string value;
        bool hasValue = kv->getString(key, value);
        if (hasValue) {
            return string2jstring(env, value);
        }
    }
    return oDefaultValue;
}
bool MMKV::getString(MMKVKey_t key, string &result) {
    if (isKeyEmpty(key)) {
        return false;
    }
    SCOPED_LOCK(m_lock);
    auto &data = getDataForKey(key);
    if (data.length() > 0) {
        try {
            result = MiniPBCoder::decodeString(data);
            return true;
        } catch (std::exception &exception) {
            MMKVError("%s", exception.what());
        }
    }
    return false;
}

大致可以分分为两步:

  • 1.getDataForKey 通过key找缓存的数据
  • 2.decodeString 对获取到的数据进行解码

getDataForKey

const MMBuffer &MMKV::getDataForKey(MMKVKey_t key) {
    checkLoadData();
    auto itr = m_dic.find(key);
    if (itr != m_dic.end()) {
        return itr->second;
    }
    static MMBuffer nan;
    return nan;
}

由于是一个多进程的组件,因此每一次进行读写之前都需要进行一次checkLoadData的校验。而这个方法从上文可知,通过crc校验码,写回计数,文件长度来判断文件是否发生了变更,是否追加删除数据,从而是否需要重新充内存文件中获取数据缓存到m_dic。

也因此,在getDataForKey方法中,可以直接从m_dic中通过key找value。

decodeString

string MiniPBCoder::decodeString(const MMBuffer &oData) {
    MiniPBCoder oCoder(&oData);
    return oCoder.decodeOneString();
}
string MiniPBCoder::decodeOneString() {
    return m_inputData->readString();
}
string CodedInputData::readString() {
    int32_t size = readRawVarint32();
    if (size < 0) {
        throw length_error("InvalidProtocolBuffer negativeSize");
    }

    auto s_size = static_cast<size_t>(size);
    if (s_size <= m_size - m_position) {
        string result((char *) (m_ptr + m_position), s_size);
        m_position += s_size;
        return result;
    } else {
        throw out_of_range("InvalidProtocolBuffer truncatedMessage");
    }
}

能看到实际上很简单就是从m_dic找到对应的MMBuffer数据,此时的可以通过CodedInputData对MMBuffer对应的内存块(已经知道内存起始地址,长度)进行解析数据。

总结

img

MMKV读写是直接读写到mmap文件映射的内存上,绕开了普通读写io需要进入内核,写到磁盘的过程。光是这种级别优化,都可以拉开三个数量级的性能差距。但是也诞生了一个很大的问题,一个进程在32位的机子中有4g的虚拟内存限制,而我们把文件映射到虚拟内存中,如果文件过大虚拟内存就会出现大量的消耗最后出现异常,对于不熟悉Linux的朋友就无法理解这种现象。

有几个关于MMKV使用的注意事项:

  • 1.保证每一个文件存储的数据都比较小,也就说需要把数据根据业务线存储分散。这要就不会把虚拟内存消耗过快。
  • 2.还需要在适当的时候释放一部分内存数据,比如在App中监听onTrimMemory方法,在Java内存吃紧的情况下进行MMKV的trim操作(不准确,我们暂时以此为信号,最好自己监听进程中内存使用情况)。
  • 2.在不需要使用的时候,最好把MMKV给close掉。甚至调用exit方法。

参考文章:https://www.jianshu.com/p/c12290a9a3f7

官方Demo:https://github.com/Tencent/MMKV/tree/master/Android/MMKV

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容