一、SharePreferences是什么
SharePreferences是android中被设计用来存放应用中简单键值对的api。
二、SharePreferences存在的问题
1. 加载缓慢。
虽然在获取SharedPreferences对象时做了缓存,但是首次SharedPreferences 文件的加载使用了异步线程,而且加载线程并没有设置线程优先级,如果这个时候主线程读取数据就需要等待文件加载线程的结束。这就可能导致出现主线程等待低优先级线程锁的问题,这种情况在SP文件较大时尤为明显,比如一个 100KB 的 SP 文件读取等待时间大约需要 50~100ms。
//android.app.ContextImpl.java
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
...
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);//缓存SharedPreferences对象
return sp;
}
}
...
return sp;
}
//android.app.SharedPreferencesImpl.java
//SharedPreferencesImpl构造函数中调用了startLoadFromDisk
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
//低优先级线程
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
private void loadFromDisk() {
...
Map map = null;
...
map = (Map) XmlUtils.readMapXml(str);//io操作
....
synchronized (mLock) {//竞争锁
...
mMap = map;//将sp中的内容存到内存中
...
}
}
2. 主线程同步落盘可能导致的卡顿。
在主线程调用android.app.SharedPreferencesImpl.EditorImpl#commit提交对sp的改动时,无论sp是否有改动,最终都会将内存中的键值对写到磁盘中。当xml中的数据过多时这种主线程中的io就有可能导致卡顿甚至ANR。
//android.app.SharedPreferencesImpl.EditorImpl.java
public boolean commit() {
long startTime = 0;
if (DEBUG) {
startTime = System.currentTimeMillis();
}
//比较内存中的键值对是否有改动
MemoryCommitResult mcr = commitToMemory();
//将内存中存储的键值对写到磁盘
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
//android.app.SharedPreferencesImpl.java
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
//执行磁盘写入
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();//
return;
}
}
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
3. 异步落盘可能导致的ANR。
与commit()不同,apply()会将写磁盘的操作丢到一个链表中,默认会在100ms后执行io操作。值得注意的是,在ActivityThread#handleStop等地方会检查是否有apply的任务未执行,然后等待这些任务执行完。如果我们疯狂的调用apply()提交了一堆任务,那么此时在页面跳转的过程中就很容易发生ANR。
//android.app.QueuedWork#queue
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();
synchronized (sLock) {
sWork.add(work);//将io操作添加到sWork集合中
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);//延时执行这个任务
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
4. 全量写入。
即使我们只是修改了一个键值对,那么在调用apply()或commit()尝试将这些改动写到本地时都会将内存中的键值对集合重新全部写到sp文件中,这无疑是低效的,我们应该寻找一种能增强更新的方式。
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
...
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
writeTime = System.currentTimeMillis();
FileUtils.sync(str);
...
}
5. 跨进程不安全
SP提供了用于跨进程场景下的MODE_MULTI_PROCESS,但是这种方式在跨进程频繁读写有可能导致数据全部丢失。
MODE_MULTI_PROCESS提供的作用在于:每次context#getSharedPreferences取SP时会强行比较一把SP文件的时间戳以及文件大小与当前进程是否一致,否则一律重新加载SP文件到内存。
考虑到修改SP文件的流程如下:先将原SP文件备份一份,再进行全量写入,最后再将原备份文件删除(这种做法的意义在于,考虑到写入SP文件时由于进程死亡等原因导致写入失败时,SP仍能recover回来)。
加载SP文件的流程如下:每次加载SP文件时检查是否存在备份文件,如果备份文件存在,则用备份文件替换原文件。
显然这样的逻辑在处理多进程读写时是有很大缺陷的,比如这样一个场景:A进程在写SP,B进程以多进程模式加载SP,此时由于A进程还没写完,磁盘中同时存在SP和SP备份文件,B进程就会把SP原文件干掉,最终导致A进程写文件失败。
因此,我们需要一把跨进程锁来保证对同一文件跨进程读写的可靠性,或者依赖类似ContentProvider+sql的方式。
//在context#getSharedPreferences时如果mode是MODE_MULTI_PROCESS则调用此方法
private boolean hasFileChangedUnexpectedly() {
...
final StructStat stat;
try {
/*
* Metadata operations don't usually count as a block guard
* violation, but we explicitly want this one.
*/
BlockGuard.getThreadPolicy().onReadFromDisk();
stat = Os.stat(mFile.getPath());
} catch (ErrnoException e) {
return true;
}
synchronized (mLock) {
return !stat.st_mtim.equals(mStatTimestamp) || mStatSize != stat.st_size;//比较SP文件的时间戳和大小来判断SP是否被更改
}
}
三、 MMKV
尽管我们可以替换通过复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如优化卡顿、合并多次 apply 操作、支持跨进程操作等,但是依然不能彻底解决问题,而腾讯开源的MMKV对如上几点问题都给出了很好的解决方案。
1.使用mmap内存映射改善了IO延迟,且提升了写入的稳定性
什么是mmap?
mmap是linux下的一个系统调用,可以实现把文件映射到内存空间中,使得我们可以像访问普通内存一样对文件进行访问。
void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset)
它能带来如下好处:
- 减少系统调用。我们只需要一次 mmap() 系统调用,后续所有的调用像操作内存一样,而不会出现大量的 read/write 系统调用。
- 减少数据拷贝。普通的 read() 调用,数据需要经过两次拷贝;而 mmap 只需要从磁盘拷贝一次就可以了,并且由于做过内存映射,也不需要再拷贝回用户空间。
- 可靠性高。mmap 把数据写入页缓存后,跟缓存 I/O 的延迟写机制一样,可以依靠内核线程定期写回磁盘。值得一提的是,mmap 在内核崩溃、突然断电的情况下也一样有可能引起内容丢失,当然我们也可以使用 msync 来强制同步写。
2.独特的增量更新机制
区别于SP的全量更新,MMVM拥有增量更新的能力。考虑到MVVM采用了protocol buffer来保存所有的键值对信息,但由于pb不支持增量更新,因此每次MMVM数据有更新时,会将新键值对序列化后追加到文件末尾,在下一次读取文件抑或其他时机再做文件重整,将重复的键值对删除,再将整个文件重新序列化。
3.自定义文件锁实现跨进程的读写
考虑到多进程对文件频繁的读写场景,我们期望选择的跨进程锁能满足如下条件:
- 读读共享,读写互斥。
- 支持递归加锁。即支持一个进程对一把锁重复加锁,且后续一次解锁也不会导致外层锁全部被释放。
- 支持锁升/降级。即读锁升级为写锁,写锁降级为读锁。且两个进程同时锁升级时不会导致死锁,某一进程锁降级时不会导致一降就降到没有锁。
- 持有锁的进程被干掉之后能够把锁释放掉。
我们有如下选择:
- 创建于共享内存的pthread 库的 pthread_mutex。它天然支持递归加锁和锁的升降级,但是在android上加了锁了进程被杀后不保证锁能够被释放(linux中是可以保证的)。
- 基于文件描述符的文件锁。它支持递归加锁,但是后续一次解锁会导致外层的锁全部被释放;支持锁的升级,但在两个进程同时锁升级时会导致死锁;不支持锁降级;支持进程被杀时自动释放锁。
若选择第一种方案,需要我们能够自己处理好进程被杀后锁的释放。两个进程之间相互注册死亡通知再手动释放进程锁是一种思路,但是进程自己无法感知自己的死亡,这种情况下有可能导致一个进程被杀后留下一个没有被释放的锁。(关于如何使用pthread_mutex进程锁可以参考使用共享内存作为进程互斥锁的代码)
若选择第二种方案,则需要我们自己做读写计数来实现锁的升降级以及递归。
MMKV最终选择了第二种方案,具体可以参阅MMKV for Android 多进程设计与实现