SharedPreference
- 数据格式
XML格式保存,使用Pull解析
- 初始化
创建SharedPreferencesImpl时解析数据,子线程使用Java IO读取整个文件,进行XML解析,并将所有数据存入内存Map集合,其他操作都需要等待初始化完成
- 保存
commit同步提交,阻塞调用线程
apply异步提交,通过HandlerThread创建子线程
- 更新
把Map中的数据,全部序列化为XML,覆盖文件保存
- 不支持多进程
可能出现ANR
调用apply方法异步提交数据
// SharedPreferencesImpl#EditorImpl#apply
public void apply() {
...
final Runnable awaitCommit = new Runnable(){
...
}
// 将runnable添加进队列中
QueuedWork.addFinisher(awaitCommit);
...
// 通过HandlerThread执行IO操作
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
...
}
Android是基于消息驱动的,所有代码都是由Handler驱动执行的,Activity生命周期也不例外。
在Activity启动流程中,我们知道Activity生命周期最终会由ActivityThread中的一个Handler发送到主线程执行。其中onStop时执行handleStopActivity。
回调onStop之后,如果QueuedWork中有未完成的任务,则会同步执行其中的任务。
所以,如果任务耗时过长,则可能出现ANR
@Override
public void handleStopActivity(IBinder token, int configChanges,
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
...
// 回调onStop
performStopActivityInner(...);
...
// 阻塞等待队列执行完毕
QueuedWork.waitToFinish();
}
优化方向(MMKV)
更高效的文件操作(mmap)
比XML更精简的数据格式(二进制、protobuf)
更优的数据更新方式(增量更新)
MMKV
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。
Github
I/O
工作原理
- 对文件的操作只有内核才能执行
- 虚拟内存被操作系统划分为两块:用户空间和内核空间,用户空间时用户程序代码运行的地方,内核空间是内核代码运行的地方,内核空间由所有进程共享。为了安全,它们是隔离(内存隔离)的,即使用户程序崩溃了,内核也不受影响
写文件流程
调用write向内核发起系统调用,上下文从用户态切换为内核态
CPU将用户缓冲区的数据拷贝到内核空间的缓冲区(CPU拷贝)
CPU利用 DMA 控制器将数据从内核缓冲区拷贝到磁盘缓冲区进行数据传输(DMA拷贝)
上下文从内核态切换回用户态,write系统调用执行返回
写文件经历了两次拷贝:
- CPU拷贝,数据 -> 内核
- DMA拷贝,内核 -> 文件
注:SP是基于I/O的存储方式
mmap(memory mapping 内存映射)
原理
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。
- 对文件进行mmap,会在进程的虚拟内存分配地址空间,创建映射关系
- 实现这样的映射关系后,就可以采用指针的方式读写操作这一段内存,而系统会自动回写到对应的文件磁盘上
注:mmap的关键点是实现了用户空间和内核空间的数据直接交互而省去了空间不同数据不同的繁琐过程
优势
MMAP对文件的读写操作只需要从磁盘到用户主存的一次数据拷贝过程,减少了数据的拷贝次数,提高了文件操作效率
MMAP使用逻辑内存对磁盘文件进行映射,操作内存就相当于操作文件,不需要开启线程,操作MMAP的速度和操作内存的速度一样快
MMAP提供一段可供随时写入的内存块,App只管往里面写数据,由操作系统如内存不足、进程退出等时候负责将内存回写到文件,不必担心Crash导致数据丢失
注:MMAP是零拷贝的(不需要CPU参与的拷贝),也可理解为一次拷贝(/DMA拷贝)
Binder
Binder是基于mmap实现的跨进程通讯机制
在Activity启动过程中,ZygoteInit.nativeZygoteInit() 调用c++代码创建Binder对象
具体过程为:
-> zygote fork 一个应用进程
-> RuntimeInit
-> ZyzoteInit
-> ProcessState(进程状态对象),一个进程会有一个ProcessState对象
// ZygoteInit#zygoteInit
public static final Runnable zygoteInit(int targetSdkVersion, String[] argv,
ClassLoader classLoader) {
// 为当前的VM设置未捕获异常器
RuntimeInit.commonInit();
// Binder驱动初始化,该方法完成后,可通过Binder进行进程通信
ZygoteInit.nativeZygoteInit();
// 主要调用SystemServer的main方法
return RuntimeInit.applicationInit(targetSdkVersion, argv, classLoader);
}
ZygoteInit.nativeZygoteInit() 方法调用native创建ProcessState并创建了内存映射关系
注:
DEFAULT_BINDER_VM_SIZE 为Binder传输数据的大小限制
_SC_PAGE_SIZE为一页,一般为4096个字节(4K)
所以:Binder默认能传输的大小为:1M - 8k,这里指的是同步方式
异步(aidl 指定 oneway):(1M-8K) / 2 = 500+K
同步:1M-8K
通过mmap方法映射了一个虚拟文件:/dev/binder
mDriverFD位文件句柄
应用
微信Mars
美团Logan
网易七鱼
爱奇艺xCrash
Protobuf(变长编码)
protobuf 是google开源的一个序列化框架,类似xml,json,最大的特点是基于二进制,比传统的XML表示同样一段内容要短小得多。
MMKV正式基于protobuf协议进行数据存储,存储方式为增量更新,也就是不需要每次修改数据都要重新将所有数据写入文件了。
为什么使用二进制
一个字节 = 8位
整数1 = 4个字节32位,转化为二进制:0000 0000 0000 0000 0000 0000 0000 0001
如果使用二进制格式,即可用一个字节表示:0000 0001
数据更紧凑、精简了
场景:
http1:使用字符串文本格式传输
http2:使用二进制格式传输
数据结构
protobuf是二进制存储格式,第一位代表的是key和value的总长度,后面是key长度->key, value长度->value。。。。。 依次排列,可以用二进制查看工具来看一下:
写入方式
1个字节8位,低7位是数据位,第1位为标志位(0表示读取截止,1表示需要继续读取)
编解码过程看这里
扩容
Linux采用了分页来管理内存,存入数据先要创建一个文件,并要给这个文件分配一个固定的大小。如果存入了一个很小的数据,那么这个文件其余的内存就会被浪费。相反如果存入的数据比文件大,就需要动态扩容。
增量更新
- 写入优化
将增量key-value对象序列化后,append 到内存文件
由于数据读取出来后,会放入一个map集合,这样后面的数据就会覆盖前面的数据,所以总能拿到最新的值
- 当数据大于文件时,需要进行扩容,然后重新进行mmap映射
多进程
具体过程参考这里
flock文件锁
处理进程间的同步时使用了flock文件锁
文件锁的使用:
//通过open方法打开一个文件
string m_path;
int m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
//通过文件句柄对文件上锁
int flock(m_fd, operation);
其中的参数operation是上锁的类型
LOCK_SH, 共享锁,多个进程可以同时使用,可以作为读锁
LOCK_EX, 排他锁,同时只允许一个进程使用,可以作为写锁
LOCK_UN, 解锁
LOCK_BN, 非阻塞请求, 与读写锁配合使用
使用flock对一个文件上读锁,或者写锁,都是会阻塞的,比如A进程持有一个文件的写锁,B进程想要对这个文件上写锁,就会阻塞住,如果不想被阻塞,可以配合LOCK_BN属性使用,即LOCK_BN | LOCK_EX.
flock有几个特点:
- flock支持对一个文件多次上锁,并且因为是状态锁,没有计数器,不管加了多少次锁,都只需要解锁一次.所以,mmkv中对flock封装时,加了计数器,就是保证上了几次锁,就要执行几次解锁.
- 锁升级,降级,当一个进程对一个文件加了读锁后,如果再次执行flock操作,传入的operation是LOCK_EX,那么这个进程对文件的读锁就升级为了写锁,这就是锁升级,反之,就是锁降级,但是文件锁降级是无法进行的,因为他不支持递归,导致一降级就没锁了.
为了解决上面的问题,mmkv对文件锁进行了封装,增加了读写锁计数器,支持递归
文件校验
利用文件锁可以实现同一时间只有一个进程对file进行操作了,但是A进程修改了文件后,B进程怎么知道这个修改呢?
mmkv并没有去对保存key-value数据的那个文件枷锁,而是锁了个.crc校验文件.这个校验文件就是来解决上面的问题的.
struct MMKVMetaInfo {
uint32_t m_crcDigest = 0;
uint32_t m_version = 1;
uint32_t m_sequence = 0; // full write-back count
unsigned char m_vector[AES_KEY_LEN] = {0};
}
这个mmkv.default.crc文件中有一个校验码,就是mmkv.default文件的MD5值,通过他可以判断文件是否合法.
还有一个序列号,当序列化文件进行了去重,扩容操作,这个序列号就会增加,
当mmkv初始化时,会读取crc文件,记录其中的校验码,序列号,,
当要读取数据时,会通过checkLoadData来做校验
如果序列号不一致,说明发生了内存重整,重新读取整个文件
- 如果文件大小不一致了,可能发生了扩容,这时重新加载整个文件,
- 文件大小一致,说`明只是新增了k-v,也即是增量更新,这时只要加载新增加的数据.
通过校验文件,在读取数据时,来做校验,就实现了多个进程的数据同步.
总结
mmap提高读写效率
protobuf(变长编码)精简数据,非类型安全
增量更新,避免每次进行大数据量的全量写入
文件锁+校验码(md5),保证多进程数据同步