1、简述
有段时间没写博客了,写博客的习惯还是应该保持的。
写在前面,要很好的理解SP的工作机制,请一定要先看QueuedWork介绍文章,先了解QueuedWork的工作机制。
本片博客主要是对Android的一个常用组件SharedPreferences
(以下简称SP
)进行分析,首先分析SP
日常使用步骤中,每一步的源码,看看后面发生了什么,然后对SP
存在的问题进行分析和寻找解决。
首先提几个问题,如果都能给出答案的同学就可以不用看了,如果还有不知道的,看了文章之后能找出答案并会有所收获。
问题:
- SP是什么时候读取磁盘的数据?是打开APP的时候还是第一次使用SP的时候?从磁盘读取数据是一次全部xml文件的数据都读出来还是只读当前SP操作的xml。
- 对于当前SP操作的xml,每次commit/apply提交数据,只是将修改的数据写到磁盘还是将所有的都要写到磁盘。
- 我们提交的数据有可能还未保存,程序就退出,导致数据丢失吗?
- SP为什么会有可能造成 “卡顿” 呢?使用
apply()
的方式修改数据就不会卡顿了吗? - 假设一个场景:对于当前SP操作的xml,先使用commit提交一个需要耗时10ms的任务,记为任务1,立即再使用apply提交一个需要耗时5ms的任务,记为任务2。那么任务2一定会在任务1前执行完的结论,对不对?
接下来我们就带着问题,开始源码的分析。
对于SP,我们一般是按如下步骤使用的:
//获得SP
SharedPreferences sp = getSharedPreferences("test", Context.MODE_PRIVATE);
//获得Editor
SharedPreferences.Editor editor = sp.edit();
//设置数据
editor.putString("key", "value");
//提交
editor.commit();
//or
editor.apply();
接下来会分析每一步的源码。
在分析源码之前,先看看类的组织关系:
public interface SharedPreferences {
//监听SP的改变
public interface OnSharedPreferenceChangeListener {
void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
}
public interface Editor {
//省略一系列的putxxx()方法
Editor putString(String key, @Nullable String value);
boolean commit();
void apply();
}
//省略一系列的getxxx()方法
String getString(String key, @Nullable String defValue);
Editor edit();
void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
}
可以看到SharedPreferences
和Editor
都是接口,实现类分别是SharedPreferencesImpl和EditorImpl, 还可以通过OnSharedPreferenceChangeListener
监听SP的改变。
2、源码分析
接下来开始分析每一步的源码。
2.1 获得SP
对应的代码如下:
SharedPreferences sp = getSharedPreferences("test", Context.MODE_PRIVATE);
进去之后
public class ContextWrapper extends Context {
Context mBase;
...
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
return mBase.getSharedPreferences(name, mode);
}
...
}
可以看到,将获取的逻辑,代理给了Context mBase
去实现,Context
是抽象类,对应的方法也是抽象方法,逻辑的实现在ContextImpl
这个类里面:
这里去看源码的同学注意了,因为受保护的原因,在IDE里面是搜不到
ContextImpl
的,可以直接去SDK的里面看,路劲为:/SDK/sources/android-29/android/app/ContextImpl.java
class ContextImpl extends Context {
private ArrayMap<String, File> mSharedPrefsPaths;
//...
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
//...
File file;
//为什么使用类锁,而不是对象锁?
//使用类锁之后同一时刻岂不就只有一个线程能获取SharedPreferences
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
//获取name对应的File,app启动后,首次使用为空,再次就使用不为空
file = mSharedPrefsPaths.get(name);
//获取到的File为空
if (file == null) {
//据name创建对应的xml File
file = getSharedPreferencesPath(name);
//将name和File的对应关系记录下来
mSharedPrefsPaths.put(name, file);
}
}
//获取File对应的SharedPreferencesImpl
return getSharedPreferences(file, mode);
}
@Override
public File getSharedPreferencesPath(String name) {
//创建xml文件
return makeFilename(getPreferencesDir(), name + ".xml");
}
//据name创建对应的xml文件
private File makeFilename(File base, String name) {
if (name.indexOf(File.separatorChar) < 0) {
final File res = new File(base, name);
...
return res;
}
...
}
//...
}
在该方法中,首先获取当前名称对应的File,如果File为空,则通过getSharedPreferencesPath
方法,创建对应的xml文件,即 ${name}.xml。然后将name和File的对应关系记录在ArrayMap<String, File> mSharedPrefsPaths
中。
在这里的同步代码块中,操作的是对象的私有属性
private ArrayMap<String, File> mSharedPrefsPaths;
为什么不用对象锁,而是选择用更大范围的类锁呢?
上面获取到File后,然后通过getSharedPreferences(file, mode)
方法获取对应的SharedPreferencesImpl
,进去看看
class ContextImpl extends Context {
//静态变量,记录了所有程序的File和SharedPreferencesImpl对应关系
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
...
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
//这里使用类锁倒是没什么疑问,毕竟可能多个实例多个线程同时操作
//sSharedPrefsCache,此时当前对象锁已经不满足了。
synchronized (ContextImpl.class) {
//获取当前程序对应的File和SharedPreferencesImpl记录
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
//获取File对应的SharedPreferencesImpl,当app启动,首次使用,这里获取到的sp为null;再次使用就不为空了
sp = cache.get(file);
if (sp == null) {
...
//创建SharedPreferencesImpl
sp = new SharedPreferencesImpl(file, mode);
//记录File和SharedPreferencesImpl的对应关系
cache.put(file, sp);
return sp;
}
}
...
//返回获取到的SharedPreferencesImpl
return sp;
}
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
if (sSharedPrefsCache == null) {
sSharedPrefsCache = new ArrayMap<>();
}
final String packageName = getPackageName();
//sSharedPrefsCache是静态变量,里面记录了所有的程序的File和SharedPreferencesImpl对应关系
//这里据当前程序的包名,获取当前程序对应的File和SharedPreferencesImpl记录
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}
...
return packagePrefs;
}
}
在方法中,首先获取当前程序对应的ArrayMap<File, SharedPreferencesImpl>
记录,然后在从中获取传入的File
对应的SharedPreferencesImpl
,当APP启动后,首次使用name对应的SP时,这个SharedPreferencesImpl
是为空的,接下来就使用new
创建一个实例,并记录下和File的对应关系,以后再次使用的时候,直接据File取出来就行了。
接下来看看new SharedPreferencesImpl()
里面的逻辑:
final class SharedPreferencesImpl implements SharedPreferences {
...
SharedPreferencesImpl(File file, int mode) {
mFile = file;
//创建备份文件
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
//从磁盘读取数据到内存
startLoadFromDisk();
}
...
}
在构造函数中,首先是据传入的文件创建备份文件,然后将传入文件中的数据读取到内存中。
先看看备份文件的创建:
final class SharedPreferencesImpl implements SharedPreferences {
...
static File makeBackupFile(File prefsFile) {
return new File(prefsFile.getPath() + ".bak");
}
...
}
直接据传入文件的路径创建备份文件,创建的备份文件名称类似xxx.xml.bak
。
接下来看看从磁盘加载数据的逻辑:
final class SharedPreferencesImpl implements SharedPreferences {
...
private void startLoadFromDisk() {
//将是否已加载的状态置为false
synchronized (mLock) {
mLoaded = false;
}
//直接开启一个线程读文件
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
...
}
在方法里,直接开启一个线程读取文件的数据,读数据的逻辑在loadFromDisk()
,进去看看:
final class SharedPreferencesImpl implements SharedPreferences {
...
private void loadFromDisk() {
synchronized (mLock) {
//同步检查是否加载的标记,避免重复加载
if (mLoaded) {
return;
}
//如果备份文件存在,删除现有xml文件,将备份文件重命名为xml文件
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
...
Map<String, Object> map = null;
StructStat stat = null;
try {
//获取文件信息
stat = Os.stat(mFile.getPath());
//文件是否可读
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
str = new BufferedInputStream(
new FileInputStream(mFile), 16 * 1024);
//读取文件数据并解析到Map中
map = (Map<String, Object>) XmlUtils.readMapXml(str);
} catch (Exception e) {...} finally {...}
}
} catch (ErrnoException e) {...} catch (Throwable t) {...}
synchronized (mLock) {
//将是否加载的标记置为true
mLoaded = true;
...
try {
...
//记录从文件读取到的键值对数据
if (map != null) {
mMap = map;
...
} else {
mMap = new HashMap<>();
}
...
} catch (Throwable t) {
...
} finally {
//唤醒等待读取完成的线程
mLock.notifyAll();
}
}
}
...
}
该方法的加载逻辑也挺简单的,就是从xml文件中读取数据,解析到Map中,然后通知等待数据读取完成的线程。
这个方法中,为什么要使用两个
synchronized
,但是同一个锁呢?
因为:
1、 我们同步的代码块,范围要尽量的小(注意尽量不要在for循环中使用同步);两个synchronized
分别作用于可能会被多线程同时修改地方。
2、 前后synchronized
代码块之间,涉及到修改的都是局部变量,所以没必要加锁。什么情况下会有线程需要被通知数据已经读取完成呢?
比如对于同一xml,一个线程正在将数据读到内存中,此时另一个线程调用getxxx()方法想从内存中获取数据,那么调用getxxx()方法的线程就会在mLock
锁上等待。
final class SharedPreferencesImpl implements SharedPreferences {
...
public float getFloat(String key, float defValue) {
//需要首先获取到锁才能返回数据
synchronized (mLock) {
awaitLoadedLocked();
Float v = (Float)mMap.get(key);
return v != null ? v : defValue;
}
}
...
}
到此public SharedPreferences getSharedPreferences(String name, int mode)
方法就分析完了:经过这个方法后,在内存中会建立如下的对应关系:
在ContextImpl实例中,会建立name
到xxx.xml
的映射,同时也会建立xxx.xml
到SharedPreferencesImpl
的映射,在该方法中,据name
获取的SharedPreferences
最终就是SharedPreferencesImpl
。在SharedPreferencesImpl
中的Map属性保存了xxx.xml
文件中的数据。
目前已经可以解答问题1了:
当APP启动后,第一次获取如下调用:
SharedPreferences sp = getSharedPreferences("test", Context.MODE_PRIVATE);
会将"test"
对应的xml文件中的全部数据,读取到内存中,注意,只会读取name对应的${name}.xml
文件数据。
到这里已经获取到了SharedPreferences实例,接下来就分析通过它如何获取Editor实例。
2.2 获取Editor实例
代码如下:
SharedPreferences.Editor editor = sp.edit();
这里会调用SharedPreferencesImpl#edit()
,进去看看:
final class SharedPreferencesImpl implements SharedPreferences {
...
@Override
public Editor edit() {
//等待从文件中数据加载完成
synchronized (mLock) {
awaitLoadedLocked();
}
//返回EditorImpl实例
return new EditorImpl();
}
...
}
该方法中,首先确保从xxx.xml
文件加载数据完成,然后返回EditorImpl
实例。
进入awaitLoadedLocked()
看看是如何确保数据加载完成的:
final class SharedPreferencesImpl implements SharedPreferences {
...
private void awaitLoadedLocked() {
//循环检查是否已加载的标志位,直到加载完成
while (!mLoaded) {
try {
//未加载完成,阻塞等待,注意:这里如果在主线程使用,阻塞的就是主线程
mLock.wait();
} catch (InterruptedException unused) {
}
}
...
}
...
}
在该方法中,如果数据未加载完成,则阻塞当前使用SP的线程,直到xxx.xml
文件中的数据加载完成。
接下来使用EditorImpl
的无参构造函数创建实例返回,EditorImpl
类会在接下来分析。
到这里,获取Editor的代码就分析完了,主要就是两点:
- 通过锁的方式,确保从
xxx.xml
文件中将数据加载到内存中。 - 构建并返回
EditorImpl
实例
接下来看看通过putxxx
系列方法是如何设置数据的。
2.3 设置数据
要分析的代码如下:
editor.putString("key", "value");
SharedPreferences.Editor
是接口,它的实现类是EditorImpl
,这里会调用到实现类对应的方法:
public final class EditorImpl implements Editor {
//锁
private final Object mEditorLock = new Object();
//临时存储putxxx设置的数据
private final Map<String, Object> mModified = new HashMap<>();
...
@Override
public Editor putString(String key, @Nullable String value) {
//锁,确保多线程修改安全。
//大家可以思考一下,这里如果使用类锁,会有什么缺点。
synchronized (mEditorLock) {
//将数据临时存在Map中
mModified.put(key, value);
return this;
}
}
...
}
在该方法中,以线程安全的方式,将putxxx
方法设置的数据临时存在Map
中,这个Map
中的数据,稍后会写到磁盘文件里。
设置数据的逻辑挺简单的,接下来看看提交数据的逻辑。
2.4 同步提交数据commit
数据提交分为同步的commit() 和异步的apply() ,这里先分析commit()。
要分析的代码如下:
editor.commit();
直接进入到接口的实现类EditorImpl
看对应的方法:
final class SharedPreferencesImpl implements SharedPreferences {
private Map<String, Object> mMap;//存储对应的xxx.xml文件里面的内容
...
public final class EditorImpl implements Editor {
//存储putxxx设置的数据
private final Map<String, Object> mModified = new HashMap<>();
...
@Override
public boolean commit() {
//SharedPreferencesImpl有一个属性Map<String, Object> mMap,这个Map存的就是对应xxx.xml文件里面的全部数据。
//将EditorImpl中存的putxxx()设置的数据更新到mMap中,并返回一个内存更新的结果。
MemoryCommitResult mcr = commitToMemory();
//将更新后的内存写入到磁盘的任务放入到队列
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//等待写操作完成
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
...
}
...
//返回写到磁盘的数据是否有更新
return mcr.writeToDiskResult;
}
}
}
我们在前面讲过,name
会对应一个${name}.xml
文件,这个文件又会对应一个SharedPreferencesImpl实例
。在SharedPreferencesImpl实例中,Map<String, Object> mMap
属性就是用来存储从xml文件中读出来的数据的。在EditorImpl实例中有一个属性Map<String, Object> mModified
,这个属性存储我们通过putxxx设置的数据。
在上面的方法中,首先将mModified
中的数据更新到mMap
中,然后将mMap
中数据写到磁盘的任务放入队列,这里提个问题:commit()方法中的写数据任务就一定是在调用commit()方法的线程执行吗? 答案是否
,稍后会详细分析。
先看进去看一下commitToMemory() 是如何将putxxx方法设置的数据更新到xml文件对应的内存数据中的:
final class SharedPreferencesImpl implements SharedPreferences {
//存储对应的xxx.xml文件里面的内容
private Map<String, Object> mMap;
...
public final class EditorImpl implements Editor {
//存储putxxx设置的数据
private final Map<String, Object> mModified = new HashMap<>();
...
private MemoryCommitResult commitToMemory() {
...
List<String> keysModified = null;
//存储待写到磁盘的内存数据
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
...
//将存储xml中数据的Map赋值给本地变量
mapToWriteToDisk = mMap;
...
synchronized (mEditorLock) {
...
//遍历暂时存putxxx设置数据的mModified
for (Map.Entry<String, Object> e : mModified.entrySet()) {
//获取key
String k = e.getKey();
//获取值
Object v = e.getValue();
//当值为null或者等于this时,如果mapToWriteToDisk中有对应的key,则将对应的数据删除
if (v == this || v == null) {
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else {
//如果mModified的一个key在mapToWriteToDisk中也存在,则使用mModified中key对应的值更新mapToWriteToDisk
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
...
}
...
}
}
//将更新后的数据mapToWriteToDisk存在MemoryCommitResult对象中,并返回
return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
mapToWriteToDisk);
}
}
}
在该方法中,通过key
的对比,将mModified中的数据更新到了mapToWriteToDisk中,然后将其放入到MemoryCommitResult实例并返回。
到目前为止,mapToWriteToDisk中已经有我们putxxx设置的数据和之前存在的数据,接下来就是调用enqueueDiskWrite将它们写入到磁盘:
final class SharedPreferencesImpl implements SharedPreferences {
//存储对应的xxx.xml文件里面的内容
private Map<String, Object> mMap;
...
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);
}
//将正在更新xxx.xml的任务数减1
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
//对于commit,因为参数postWriteRunnable为null,所以isFromSyncCommit为true,会先进入这里
if (isFromSyncCommit) {
boolean wasEmpty = false;
//对于同一个xxx.xml,是否还有未完成的写数据任务
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
//如果没有,则直接在当前线程写数据;如果有,走后续的流程,将写数据的任务放到队列中
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
//将写数据的任务放到队列中
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
}
在该方法中,commit提交的写数据任务,对于同一个xxx.xml文件,如果还有未完成的写数据任务,那么则将任务放到QueuedWork
的队列中执行;如果没有,则在当前线程直接执行。
这里也就解答了前面的问题:commit()方法中的写数据任务就一定是在调用线程执行吗?
好了,等执行到commit()方法中 mcr.writtenToDiskLatch.await();
代码,等待写任务执行完成。如果之前提交的写数据任务就在调用commit() 的线程执行,那么到这里写数据的任务已经执行完了;如果写数据任务在QueuedWork的队列中等待执行,那么到这里首先会阻塞当前线程,直到写数据的任务被执行完成后才会被唤醒。
在QueuedWork中,任务会在新的一个线程中执行。接下来看看写数据任务writeToDiskRunnable
里面都做了什么。
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
//将内存数据写到磁盘
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
//将正在更新xxx.xml文件的任务数减1
synchronized (mLock) {
mDiskWritesInFlight--;
}
//执行写数据后的任务
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
可以看到,任务中:
- writeToFile先将内存的数据写到磁盘
- 数据写完后,将正在更新
xxx.xml
文件的任务数减1。mDiskWritesInFlight
字段的作用之一,就是commit()写数据任务的时候,决定是将任务在当前线程执行还是放到QueuedWork
中执行。 - 执行写数据后的任务
接下来看看writeToFile是如何将内存数据写到磁盘的:
final class SharedPreferencesImpl implements SharedPreferences {
...
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
...
try {
FileOutputStream str = createFileOutputStream(mFile);
...
//通过XmlUtils工具将Map直接写到文件
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
...
FileUtils.sync(str);
str.close();
//写成功,唤醒等待写完成的线程继续执行
mcr.setDiskWriteResult(true, true);
...
return;
} catch (XmlPullParserException e) {
...
} catch (IOException e) {
...
}
//写失败,也唤醒等待写完成的线程继续执行
mcr.setDiskWriteResult(false, false);
}
...
private static class MemoryCommitResult {
//commit实现同步的关键
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
...
void setDiskWriteResult(boolean wasWritten, boolean result) {
...
//写数据完成后,将CountDownLatch减1,那么在CountDownLatch上等待的线程就可以继续执行了
writtenToDiskLatch.countDown();
}
...
}
}
可以看到,首先通过流的方式将内存中的数据写到了磁盘文件中,无论成功或者失败都会调用MemoryCommitResult#setDiskWriteResult唤醒等待该写数据任务完成的线程继续执行,这方法也是commit() 提交的任务无论是在commit() 被调用线程执行还是在QueuedWork队列中执行,都能有同步效果的关键。
下面分析一下commit的同步是如何实现的,看如下精简后的代码:
final class SharedPreferencesImpl implements SharedPreferences {
private static class MemoryCommitResult {
//这里的1表示数据还未写到磁盘,如果写到磁盘或者写失败,会将值置为0
//此时会将在其上等待写数据完成的线程唤醒
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
writeToDiskResult = result;
//值减1,唤醒阻塞的线程
writtenToDiskLatch.countDown();
}
...
}
public final class EditorImpl implements Editor {
...
@Override
public boolean commit() {
//里面有更新后的内存数据
MemoryCommitResult mcr = commitToMemory();
//将写数据的任务放入队列或者直接执行
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//这里会直接阻塞当前线程
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally { ...}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
}
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);
}
...
}
};
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
//直接在当前线程执行
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
//放到队列中执行
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
//将内存数据写到磁盘
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
try {
...
mcr.setDiskWriteResult(true, true);
...
return;
} catch (XmlPullParserException e) {
} catch (IOException e) {
}
...
mcr.setDiskWriteResult(false, false);
}
}
在EditorImpl#commit()
方法中,先调用SharedPreferencesImpl#enqueueDiskWrite
方法将内存数据写到磁盘的任务放在当前线程直接执行或者放到队列中执行:
- 如果在当前线程中执行,会在任务执行完成的时候调用
mcr.setDiskWriteResult(false, false)
将MemoryCommitResult#writtenToDiskLatch
值置为0,那么当commit()方法中执行到mcr.writtenToDiskLatch.await()
就不用阻塞,直接往下执行; - 如果是将任务放到队列中执行,那么那么当commit()方法中执行到
mcr.writtenToDiskLatch.await()
就会阻塞,当队列中的任务被执行,在任务的最后,会调用mcr.setDiskWriteResult(false, false)
将MemoryCommitResult#writtenToDiskLatch
值置为0,那么会唤醒之前阻塞的线程,让其继续执行,这让在队列中执行ccommit()提交的任务,最后也达到了同步的效果。
到这里可以回答问题2了:
问题2:
对于当前SP操作的xml,每次commit/apply提交数据,只是将修改的数据写到磁盘还是将所有的都要写到磁盘?
答: 是将${name}.xml
对应的内存数据更新之后,全部再写到${name}.xml
文件中。
在上面的代码分析中提到了commit() 写数据的任务是有可能放在QueuedWork中执行的,对于QueuedWork是如何工作的,有兴趣的朋友可以查看上面提到的[QueuedWork介绍]()这篇文章。
到这里,commit() 同步提交方法分析完了,杰接下来分析apply() 异步提交。
2.5 异步apply()
异步提交数据是通过apply()
方法,接下来以下面的代码作为分析的入口点
Editor editor = ...;
editor.apply();
这里的Editor
是接口,我们直接去实现类EditorImpl
中看对应方法逻辑
final class SharedPreferencesImpl implements SharedPreferences {
//记录对于同一个xml文件,有多少个数据正在等待写入到磁盘的任务
private int mDiskWritesInFlight = 0;
...
public final class EditorImpl implements Editor {
...
@Override
public void apply() {
//1. 写数据任务 2. 写完数据后的任务 3. 等待写数据完成的任务
//将通过对比临时存储putxxx()方法设置的数据的Map和存储xml文件全部数据的Map
//将临时Map中的数据更新到存储全部数据的Map中。
//具体的更新逻辑前面已经分析过了
final MemoryCommitResult mcr = commitToMemory();
//构建一个等待数据写完成的任务
//这里是什么作用呢?
//就是确保异步写的数据不会丢失。具体的原理是:例如,当Activity onPause时,队列的工作线程还在执行写数据的任务,
// 此时Activity线程会取出QueuedWork中的这个任务执行,因为写数据任务未完成,执行到writtenToDiskLatch.await()会阻塞当前的线程直到工作线程写完数据。
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
//将等待写完成的任务添加到QueuedWork的Finisher队列中,
QueuedWork.addFinisher(awaitCommit);
//构建一个写完数据后执行的任务
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
//执行等待写完成的任务
awaitCommit.run();
//等待写完成的任务这里执行了,就不需要在QueuedWork中执行,所以将其从QueuedWork中删除
QueuedWork.removeFinisher(awaitCommit);
}
};
//将写数据任务和写完数据后需要执行的任务放到队列中
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
...
}
...
}
...
}
在apply()
方法的主要逻辑:
- 将临时存储putxxx()方法设置的数据的Map和存储xml文件全部数据的Map比较,将临时Map中的数据更新到存储xml全部数据的Map中,并将结果保存在MemoryCommitResult实例中。
- 然后构建了一个等待写数据完成的任务,添加到QueuedWork的
sFinishers
队列中。
这个等待写完成的任务awaitCommit
有什么作用呢? 作用就是当Activity结束的时候,确保写数据任务已完成。
原理: 当Activity onPause的时候,会调用QueuedWork的waitToFinish()
方法,而该方法又会取出sFinishers
队列中的所有任务在当前线程执行,如果当前对应的写任务还未完成,那么执行awaitCommit
任务的时候,会阻塞当前线程,直到写任务完成后才会唤醒当前线程。
- 将写数据任务和写完数据后需要执行的任务放到队列中。
接下来看看SharedPreferencesImpl#enqueueDiskWrite
的逻辑
final class SharedPreferencesImpl implements SharedPreferences {
//记录对于同一个xml文件,有多少个写数据到磁盘的任务正在等待被执行
private int mDiskWritesInFlight = 0;
...
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
//是否是同步的写数据,commit()方法进来后postWriteRunnable为空,表示同步写
//apply方法进来后postWriteRunnable不为null,表示不是同步写
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();
}
}
};
//同步commit()方法调用进来isFromSyncCommit为true
if (isFromSyncCommit) {
boolean wasEmpty = false;
//判断对于当前要写入的xml文件,前面是否还有写任务未完成
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
//如果没有,当前线程执行写的任务;如果有,将任务放入到队列,注意这里是commit()提交的任务要放入到任务队列。
// 这里大家可以这么想:对于同一个xml文件,如果前面还有需要写的任务未完成,那当前的
//任务肯定不能立即执行啊,如果现在执行了,那么后修改的数据反而先同步到文件,先修改的数据反而后同步到文件,数据容易错误。
//对于同一个xml文件,还是得按先修改的先同步到文件,后修改的后同步到文件的顺序。
//什么情况下commit()任务会放到队列中执行呢?
//举个例子:比如对于xxx.xml,先调用apply()同步数据,此时写任务是放到任务队列中的,当该任务还在队列中未执行完成的时候,对于同一个xml文件,通过commit()再发起一个同步数据的任务
//此时就不会在当前线程立即执行写任务,而是放入到队列中执行。这样能确保数据的写入的顺序和数据更新的顺序是一样的。
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
//将写数据的任务放入到队列中等待执行。
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
}
代码都有详情的注释,读者可以对照着仔细的捋一下逻辑。
这里总结一下该方法的大致逻辑:
在该方法中,
- 如果是通过commit()方法调用进来的,并且对于同一个xml文件,如果还有写数据到磁盘的任务未执行完,那么将当前的任务放到QueuedWork队列中执行;如果没有了,那么直接将写数据到磁盘的任务在当前线程就执行了。
- 如果是通过apply方法调用进来的,那么写数据到磁盘的任务一定是放到QueuedWork队列中执行。
关于QueuedWork的运行机制可以看这篇[文章]()
这里我们可以回答问题3、4、5了
问题3: 我们提交的数据有可能还未保存,程序就退出,导致数据丢失吗?
答: 不会,在Activity/Service的onPause、onStop中都会调用QueuedWork#waitToFinish
方法确保我们提交的任务执行完成。
问题4: SP为什么会有可能造成 “卡顿” 呢?使用
apply()
的方式修改数据就不会卡顿了吗?
答: 会造成卡顿原因有:
1. commit提交的将内存中数据写到磁盘的任务很多情况是在commit调用线程直接执行的。如果是主线程调用commit,那么写磁盘的任务就是在主线程执行。
2. 在Activity/Service的onPause、onStop中都会调用QueuedWork#waitToFinish
来在主线程中将未完成写磁盘任务全部执行完成,这也就有可能导致卡顿。
使用apply()
提交的写数据任务,如果Activity要结束的时候还未执行,那么直接会放到主线程执行,这也就有可能导致卡顿的。
问题5: 假设一个场景:对于当前SP操作的xml,先使用commit提交一个需要耗时10ms的任务,记为任务1,立即再使用apply提交一个需要耗时5ms的任务,记为任务2。那么任务2一定会在任务1前执行完的结论,对不对?
答: 不对。SP会使用mDiskWritesInFlight
字段记录对于同一个xml文件,还有多少个将内存数据写入到xml的任务待执行,如果提交的时候这个这个字段值大于0,说明对于当前xml,前面还有写数据的任务未执行完,那么无论是commit提交还是apply提交,都会将写数据到磁盘的任务放到QueuedWork队列中按顺序执行。
接下来看看QueuedWork#queue
是如何将将写数据的任务放入队列中的:
public class QueuedWork {
private static final long DELAY = 100;
...
public static void queue(Runnable work, boolean shouldDelay) {
//获取Handler
Handler handler = getHandler();
synchronized (sLock) {
//将写数据的任务放到队列中
sWork.add(work);
//可以看到如果是commit提交进来的任务,则是立即发送消息触发执行,
//如果是apply提交进来的任务,则是延迟
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
}
可以看到,如果是commit提交的任务,则马上使用Handler发送消息,出发任务在子线程中被执行;如果是apply提交的任务,则延迟100ms
在出发任务的执行。
这里为什么要延迟100ms呢?
猜测: 尽量让线程不要频繁的唤醒和睡眠来执行小而密的任务,尽量让任务集中,线程唤醒后就集中处理后再休眠,减少线程切换的开支。
3、总结
到这里,对于SP的使用过程中的每一步的源码都分析了,相信读者对于SP的背后运行机制,会有一个较深的理解。
接下来我还会写一篇文章关于在Android设备上的轻量级数据存储的思考。