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(部分硬件资源外存统一描述符)映射到虚拟空间中,所以能够实现进程间的通信、数据存取。
映射流程(仅供参考)
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倍)、更为简单
扩展性、兼容性好:你可以更新数据结构,而不影响和破坏原有的旧程序
数据结构
时间效率对比:
数据格式 | 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 的使用非常简单,所有变更立马生效,无需调用 sync
、apply
。
依赖
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
- 任何实现了
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 还额外实现了一遍
SharedPreferences
、SharedPreferences.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 set = new HashSet();
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,在读取性能上也有相近或超越的表现。
(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)
多进程性能
可见,MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite。
性能对比: 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得方式完成数据更新操作。
MMKV
利用mmap完成数据的读写,读写高效。
SharedPreference | MMKV | |
---|---|---|
读写方式 | IO | mmap |
数据格式 | XML | 总体结构、整型编码、二进制 |
更新方式 | 全量更新 | 增量与全量写入 |
SharedPreferences注意点
- 只要file name相同,拿到的就是同一个SharedPreferencesImpl对象,内部有缓存机制,首次获取才会创建对象。
- 在SharedPreferencesImpl构造方法中,会开启子线程把对应的文件key-value全部加载进内存,加载结束后,mLoaded被设置为true。
- 调用getXXX方法时,会阻塞等待直到mLoaded为true,也就是getXXX方法是有可能阻塞UI线程的,另外,调用contains和 edit等方法也是。
- 写数据时,会先拿到一个EditorImpl对象,然后putXXX,这时只是把数据写入到内存中,最后调用commit或者apply方法,才会真正写入文件。
- 不管是commit还是apply方法,第一步都是调用commitToMemory方法生成一个MemoryCommitResult对象,注意这里会先处理clear旧的key-value,再处理新添加的key-value,另外value为this或者null都表示需要被remove掉。
- 调用commit方法,就会同步执行写入文件的操作,该方法是耗时操作,不能在主线程中调用,该方法最后会返回成功或失败结果。
- 调用apply方法,就会把任务放到QueuedWork的队列中,然后在HandlerThread中执行,然后apply方法会立即返回。但如果是Android8.0之前,这里就是放到QueuedWork的一个单线程中执行了。
- 最后是写入文件,会先把原有的文件命名为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;
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(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(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);
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对应的内存块(已经知道内存起始地址,长度)进行解析数据。
总结
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