大家都知道apply()时写文件不一定是异步的,极端情况比如activity#stop()时候有可能同步。那么标题中的问题呢 ?
先说答案:不一定,在极端情况下,调用commit()方法之后,写入文件也有可能运行在异步线程。
极端情况是这样的场景:put较大的value后调用apply()写入文件,然后立即put新值立即调用commit()。
此次的commit()写入文件就有可能是在异步线程中。
答案的关键就在成员变量mDiskWritesInFlight上,而且还发现了这种极端情况,导致的apply()数据丢失问题(不是多进程场景)。apply()数据丢失,去年我们团队和我自己都发现了,当时太忙没找到原因,这次终于找到了。
下面从简介开始一步步分析。
一 简介和用法
- 简介
sharedpreferences简称SP,主要用来存储轻量级的key-value数据到本地文件中,key必须是String类型,value是int float boolean long String Set<String>这6种类型。假如我的应用包名是your.Package.Name, 存储的SharedPreferences文件名是yourFileName,那么文件文件路径就是/data/data/your.Package.Name/SharedPreferences/yourFileName.xml - 用法
//1 获取对象
SharedPreference sp = context.getSharedPreferences("yourFileName", Context.MODE_PRIVATE);
SharedPreferences.Editor edit = sp.edit();
//2 存放key对应的value值
edit.putString("name", "yangquan").putInt("age", 108);
edit .commit() //or edit.apply();
//3 获取
String name = sp.getSting("name","");
int age = sp.getInt("age", 0);
二 Sharedpreferences和SharedpreferencesImpl加载过程
获取SharedPreferences对象的方法是context.getSharedPreferences("yourFileName", Context.MODE_PRIVATE)
所以先看Context.java,Context.java是一个抽象类,真正的实现在ContextImpl.java里面
1. 先说明一下ContextImpl.java与SharedPreferencesImpl.java中的部分成员变量
- ContextImpl.class中的成员:
//缓存SP文件名和对应的文件
@GuardedBy("ContextImpl.class")
private ArrayMap<String, File> mSharedPrefsPaths;
//缓存SP文件和对应的SPI,**key是包名**。value也是一个map,此map的key是文件,value就是SPI。
@GuardedBy("ContextImpl.class")
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
- EditorImpl.class中相关成员:
private final Map<String, Object> mModified = new HashMap<>();//缓存新写入的键值对
private final Object mEditorLock = new Object(); //缓存锁,即写锁
private boolean mClear = false; //是否调用了clear方法
- SharedPreferencesImpl.class中相关的成员:
private final File mFile; //SP文件,即sSharedPrefsCache的key
private final File mBackupFile; //mFile的备份文件
private final int mMode; //模式,私有的、公共写、公共读、多线程(目前已经不支持)等
private final Object mLock = new Object(); //读锁(不止读的时候用到,具体可以看源码),同时也让主线程等待mLock.awate(),并在子线程中通知唤醒主线程mLock.notifyAll()
private final Object mWritingToDiskLock = new Object(); //内存写磁盘锁
private Map<String, Object> mMap;//存储从文件加载的键值对,和从缓存中拷贝过来的新键值对。
private int mDiskWritesInFlight = 0;//准备写入文件而还没完成的次数
2. 获取SharedPreferencesImpl过程
- context.getSharedPreferences("yourFileName", Context.MODE_PRIVATE)时会从缓存mSharedPrefsPaths中,尝试获取文件名为yourFileName的文件,如果没有获取到则创建文件并存入mSharedPrefsPaths中,(如果获取到了直接用此文件做key)
- 然后获取当前应用的包名,用包名为key获取 sSharedPrefsCache中对应的value即ArrayMap<File, SharedPreferencesImpl> packagePrefs,然后用第一步中获取到的文件名为key,获取packagePrefs中的value值即SharedPreferencesImpl;如果获取到了直接返回,
- 否则创建SharedPreferencesImpl对象并初始化
3. SharedPreferencesImpl初始化过程
- 在SharedPreferencesImpl的构造函数中,首先会给mFile,mMode 等赋值,并将 SharedPreferences文件名后面加上.bak后缀,作为文件名创File(注意并未调用createFile,所以并未在磁盘真正创建文件)。然后开始从文件中加载键值对到mMap中。
- 开启一个名为 SharedPreferencesImpl-load 的线程,开始从文件中加载键值对到mMap中。
- 通过标志位mLoaded判断是否已经加载成功,加载成功了就不用再重复加载了。
- 判断备份文件是否存在,如果存在,则删除原文件,将备份文件重命名为原文件。
- 从文件中读取键值对,并赋值给mMap。
- 调用mLock.notifyAll() 通知唤醒,其他被mLock锁住的线程。
SharedPreferencesImpl.java中有一个方法awaitLoadedLocked(),所有的读取(例如getString()之类的)和获取Editor(也就是 SharedPreferences#editor())方法里面,第一行都是调用这方法,而这个方法在文件加载到内存中时,会一直阻塞(通过mLock.wait()),直到文件加载完成(调用mLock.notifyAll()唤醒),读取和获取Editor方法才会继续执行。
从上面的分析可见,通过context.getSharedPreferences方法获取的同文件名,都是同一个SPI对象。
所以在同一个进程中,无论从哪一个对象和类中获取相同名称的sp文件,其实访问的都是同一个SharedPreferencesImp对象。
在进程第一次调用context.getSharedPreferences("yourFileName", Context.MODE_PRIVATE)方法时,会将yourFileName.xml文件中的所有内容读取到内存中,注意这个过程是阻塞的。虽然读取文件内容是在子线程,但是主线程在mLock.awaite等待,子线程读取完成之后,才会mLock.notifyAll(),主线程才会继续运行。
所以如果是在main线程的话,是会阻塞的。
三 commit()方法和apply()方法
1. 简介
对外调用的写入操作API,被封装到了Editor中,Editor是一个接口,具体的实现在SharedPreferencesImpl的内部类EditorImpl中。它里面有3个成员变量。
private final Map<String, Object> mModified = new HashMap<>();//缓存新写入的键值对
private final Object mEditorLock = new Object(); //缓存锁,即写锁
private boolean mClear = false; //是否调用了clear方法
新写入的键值对,并不是直接存入到mMap中,而是先暂存到mModified,等调用commit()或者 apply()方法后,拷贝到mMap中的。put过程比较简单,都只是提交到缓存map中,需要等commit或者apply的时候,才会更新到主内存和文件中,这样可以在多次更新键值对时,只更新一次文件,提高效率。
写入到内存中后,会新建一个Runnable的写入文件任务,在调用线程或者新线程中,执行这个Runnable将键值对写入文件。
2. put新值
举例EditorImpl#putString("keyPutString", "valuePutString")方法,在加锁的代码块中 mModified.put(key, value);并return this;
3. 内部类 MemoryCommitResult和成员变量mCurrentMemoryStateGeneration、mDiskStateGeneration
在介绍提交到文件之前,一定要先了解MemoryCommitResult这个内部类,这个类的作用就是,在调用commit 或者apply方法将新值保存到内存之后,还将新值和其它的相关数据封装到局部变量MemoryCommitResult里面,保存到文件的内容就是这个局部变量MemoryCommitResult里面封装的内容。
执行真正的提交到文件任务时,会判断内存数据年代memoryStateGeneration新于磁盘数据年代mDiskStateGeneration,才会真正写入文件。
MemoryCommitResult和mCurrentMemoryStateGeneration、mDiskStateGeneration是写入文件时,做相关判断的重要参数。
final long memoryStateGeneration; //数据年代
@Nullable final List<String> keysModified; //记录哪些键值对是有变化的,通知监听SP变化的观察者
@Nullable final Set<OnSharedPreferenceChangeListener> listeners; //观察者列表
final Map<String, Object> mapToWriteToDisk; //指向mMap,准备写入到文件的键值对。
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1); //倒计数器
4. commit()方法
commit方法里面,一共也就十几行代码,去掉打印日志等非重要代码,关键的代码就5行,在如下源码中做了注释。
@Override
public boolean commit() {
........
MemoryCommitResult mcr = commitToMemory(); //1 提交到内存
SharedPreferencesImpl.this.enqueueDiskWrite( //2 将提交到文件任务加入队列
mcr, null /* sync write on this thread okay */);
try {
mcr.writtenToDiskLatch.await(); //3 阻塞,直至等到写文件完成后,被唤醒。
} catch (InterruptedException e) {
return false;
} finally {
........
}
notifyListeners(mcr); //4 通知观察者内容已经变化
return mcr.writeToDiskResult; //5 返回提交结果
}
- 调用commitToMemory()方法将缓存mModified中的数据写入到mMap中,并获取到写入文件的数据结构的封装 MemoryCommitResult,里面有待写入的键值对、数据年龄代、监听SP变化的观察者、变化的键列表、计数器等。
- 将MemoryCommitResult传入 enqueueDiskWrite()中,此时 enqueueDiskWrite()方法的第二个参数是null。
- 调用mcr.writtenToDiskLatch.await(); 让线程等待写入文件任务执行完毕。
- 通知监听SP的观察者,键值对内容发生变化了。
- 返回提交结果。
5. apply()方法
apply方法也一样不是很复杂,只是稍稍有点绕,因为有两个Runnable嵌套。阻塞不是直接在代码调用里面,而是在Runnable里面。
- 跟commit()方法一样,先调用commitToMemory()方法将缓存mModified中的数据写入到mMap中,并获取到MemoryCommitResult。
- 新建一个等待写入完成的Runnable类型的临时变量awaitCommit,里面阻塞等待计数器归零 mcr.writtenToDiskLatch.await(),这是第一个Runnable。
在commit方法里面阻塞是直接写在commit方法里面的,这次不一样了是,放在了一个名叫awaitCommit的Runnable里面了,这个Runnable通常情况下不会跑在调用apply方法的进程里面(极端例如activity在onStop时,除外)。 - 调用 QueuedWork.addFinisher(awaitCommit); 将这个Runnable加入到QueneWork的sFinishers队列中。就是这个加入,会在特殊场景下,导致apply方法也会阻塞主线程,因为这个队列中的Runnable,会在activity#onStop时,用主线程去执行。
- 再新建第二个Runnable,名为postWriteRunnable,里面主要干两件事,一是运行上面创建的awaitCommit,然后是等待阻塞的awaitCommit在文件写入成功被唤醒后,将awaitCommit移除队列QueuedWork.removeFinisher(),这样后续的activity执行onStop就不会阻塞了。
- 将 写文件任务postWriteRunnable和对应的数据放入队列enqueueDiskWrite(mcr, postWriteRunnable)。
上面把我们常用的api,commit和apply方法介绍完了,它们里面基本就是干5件事:
- 一是将新值提交到内存
- 阻塞,commit是直接阻塞调用者;apply是将阻塞封装到Runnable里面,大部分情况不会阻塞调用者,极端情况例如activity在onStop时,会阻塞主线程。
- 写入文件完成,唤醒阻塞,让代码继续执行。
- 通知观察者。
- commit还有返回结果;apply没有。
在commit和apply方法里面都要调用到两个方法,一个是提交内存,一个是将写文件任务提交到队列。下面来继续分析:
四 写入内存和写入文件
1. commitToMemory()方法
这个方法顾名思义就是将新值提交到内存,对外暴露的提交数据api是封装在EditorImpl.class中,EditorImpl里面有缓存新值的容器mModified。
在commitToMemory方法里面做的主要事情就是,将mModified中的值拷贝合并到原mMap中。并将新合并后的mMap赋值给临时变量,将临时变量、数据年代等封装到MemoryCommitResult里面,将MemoryCommitResult做为参数,供以后的提交文件使用。
无论是commit()还是apply()方法,写入内存都是发生在主线程(严谨地说应该是调用线程)。
- 用SharedPreferencesImpl.this.mLock加锁代码块,这个锁一直到commitToMemory方法最后生成 MemoryCommitResult之前结束。
- 判断写入文件而没有完成的任务的次数mDiskWritesInFlight,如果大于0则新拷贝一个mMap(因为写入任务的键值对即 MemoryCommitResult#mapToWriteToDisk是指向mMap的,而写文件的过程是没有加锁的,如果不新建可能会有 正在写文件时mMap中的数据正在变化 这样的场景),并将写入任务局部变量mapToWriteToDisk指向mMap。
- 将临时变量mapToWriteToDisk指向mMap,然后将待写入任务次数mDiskWritesInFlight++。
- 用mEditorLock加锁后续代码块,一直到mLock加锁的代码块结束之前。
- 判断mClear是否为真(即是否调用了EditorImp#clear()方法),如果为真则清空mapToWriteToDisk(mapToWriteToDisk指向mMap,相当于清空了mMap)。
- 遍历缓存mModified,并判断key是否是EditorImpl自己,或者value值是否为空,如果是则移除mapToWriteToDisk中的key。否则,判断mModify中跟mapToWriteToDisk中键和值都不相等的键值对,然后拷贝到mapToWriteToDisk中。
- 将有变化的键值对的key添加到列表keysModified中。
- 清空mModified
- 如果数据有变化,将数据年龄代自增一mCurrentMemoryStateGeneration++, 并赋值给写文件任务的MemoryCommitResult中的成员变量memoryStateGeneration。
- 将memoryStateGeneration,keysModified,监听者listeners,mapToWriteToDisk一起构造MemoryCommitResult()并作为返回值返回出去。
根据上面的四.1.2分析,在提交到内存后,提交文件的局部变量并没做深拷贝,猜想在调用apply方法后再修改内存值,会影响到写文件的局部变量。后续进行验证。
根据四.1.6分析,在没有执行commit或者apply之前,新写入的值通过SharedPreferences.getString()等是获取不到的。这个很明显,不用验证。
2. enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable)方法:
在commit或者apply方法中,调用提交到内存方法后,紧接着调用的就是enqueueDiskWrite这个方法。这个方法可以理解为:将写文件任务提交到队列,当然里面会有很多条件判断;也有可能是根据判断,将写文件任务直接执行而不是提交到队列异步执行。
这个方法需要两个入参,MemoryCommitResult类型的mcr和Runnable类型的postWriteRunnable。这个方法做的事情如下:
- 通过postWriteRunnable等于null,将isFromSyncCommit赋值为true,认为是从commit提交过来的。
- 创建写文件的Runnable writeToDiskRunnable,这个writeToDiskRunnable的run方法里面主要做三件事:一是 用文件锁mWritingToDiskLock加锁,并在加锁的代码块中调用写文件方法,传入参数是MemoryCommitResult和isFromSyncCommit。二是 用mLock加锁,并在加锁的代码块中将待写入文件任务个数自减一mDiskWritesInFlight--。三是 如果 postWriteRunnable不为空,则执行这它(由前面分析,从commit方法调用过来时候,是空。从apply方法调过来时候不是空)。
- 判断如果是从commit方法调用的,而且等待写入文件的任务个数是1,就直接执行写文件任务writeToDiskRunnable,并返回,后续入队列方法不执行。
- 第三步中,条件不满足,没有直接执行写文件任务和返回,就会到这一步,这一步就是调用QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit)方法将写文件任务加入队列。有两个参数,第一个是写文件任务,第二个是否从commit方法调用过来,如果不是从commit方法调用过来,MSG消息会延时100MS发送。
3. 写入文件方法writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit)
这个方法里面,主要就是通过加锁判断数据年代决定是否需要真正写入文件,然后将原文件重命名为备份文件,再将数据写入内存,然后删除备份文件。详细如下:
- 在原文件存在的情况下,如果磁盘中的数据年代小于局部变量MemoryCommitResult 中的数据年代,判断是从commit调用过来 则需要写磁盘,或者不是从commit调用过来但是成员变量数据年代等于局部变量MemoryCommitResult 中的数据年代也需要写磁盘。
- 根据上面的判断,如果不需要写磁盘,则设置标志位mcr.setDiskWriteResult(false, true)并返回。
- 备份文件如果不存在,则将原文件重命名为备份文件,如果重命名失败则返回失败,不在将内容写入文件。
- 备份文件如果存在,则将原文件删除。这就是它能够保持数据稳定的方法,至少倒数第二次的数据是有效的。
- 写入文件,完成后关闭流。
- 删除备份文件。
- 磁盘数据年代赋值为内存数据年代。
- 返回成功结果。
MemoryCommitResult
QueneWork中有个HandlerThread
ps 需要注意的是,Editor.clear只是更改了mClear这个标志位,在commit()或者
SharedPreferencesImpl#apply() 和QueneWork#waitToFinish()的几种线程场景
正常场景:子线程执行写入文件,写入完成后CountDownLatch在子线程减到0,并在子线程通过加锁的方式移除sFinisher队列,这样waitToFinish被调用时,包含mcr.writtenToDiskLatch.await()的awaitCommit Runnabel已经从sFinisher队列移除,不会阻塞主线程。
主线程写文件场景:主线程执行写入文件,
主线程被子线程阻塞场景。
提问:
既然有主线程await等待子线程,有可能ANR吗 ?
clear时候,只是做了标记,然后真正提交的时候,清除了mMap,但是并未清除mModified,所以在为提交到文件的put都不会被清除。
通知sp监听的时候,apply方法其实文件还未写完,所以有可能和文件中不一致
优化方法:
不用,apply而是在子线程用commit
参考文献:
Sp效率分析和理解
面试高频题:一眼看穿 SharedPreferences
让源码告诉你:Android 不要滥用 SharedPreferences(上)
Android 之不要滥用 SharedPreferences(下)
[Google] 再见 SharedPreferences 拥抱 Jetpack DataStore