美文网首页
MMKV 高性能的数据存取框架解读

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

作者: Heezier | 来源:发表于2021-10-25 19:16 被阅读0次

    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

    相关文章

      网友评论

          本文标题:MMKV 高性能的数据存取框架解读

          本文链接:https://www.haomeiwen.com/subject/arrsaltx.html