Sharedpreferences调用commit()方法时写入文件一定是同步的吗?

大家都知道apply()时写文件不一定是异步的,极端情况比如activity#stop()时候有可能同步。那么标题中的问题呢 ?
先说答案:不一定,在极端情况下,调用commit()方法之后,写入文件也有可能运行在异步线程。

极端情况是这样的场景:put较大的value后调用apply()写入文件,然后立即put新值立即调用commit()。
此次的commit()写入文件就有可能是在异步线程中。

答案的关键就在成员变量mDiskWritesInFlight上,而且还发现了这种极端情况,导致的apply()数据丢失问题(不是多进程场景)。apply()数据丢失,去年我们团队和我自己都发现了,当时太忙没找到原因,这次终于找到了。

下面从简介开始一步步分析。

一 简介和用法

  1. 简介
    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
  2. 用法
//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中的部分成员变量

  1. 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; 
  1. EditorImpl.class中相关成员:
private final Map<String, Object> mModified = new HashMap<>();//缓存新写入的键值对
private final Object mEditorLock = new Object();  //缓存锁,即写锁
private boolean mClear = false;  //是否调用了clear方法
  1. 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过程

  1. context.getSharedPreferences("yourFileName", Context.MODE_PRIVATE)时会从缓存mSharedPrefsPaths中,尝试获取文件名为yourFileName的文件,如果没有获取到则创建文件并存入mSharedPrefsPaths中,(如果获取到了直接用此文件做key)
  2. 然后获取当前应用的包名,用包名为key获取 sSharedPrefsCache中对应的value即ArrayMap<File, SharedPreferencesImpl> packagePrefs,然后用第一步中获取到的文件名为key,获取packagePrefs中的value值即SharedPreferencesImpl;如果获取到了直接返回,
  3. 否则创建SharedPreferencesImpl对象并初始化

3. SharedPreferencesImpl初始化过程

  1. 在SharedPreferencesImpl的构造函数中,首先会给mFile,mMode 等赋值,并将 SharedPreferences文件名后面加上.bak后缀,作为文件名创File(注意并未调用createFile,所以并未在磁盘真正创建文件)。然后开始从文件中加载键值对到mMap中。
  2. 开启一个名为 SharedPreferencesImpl-load 的线程,开始从文件中加载键值对到mMap中。
  3. 通过标志位mLoaded判断是否已经加载成功,加载成功了就不用再重复加载了。
  4. 判断备份文件是否存在,如果存在,则删除原文件,将备份文件重命名为原文件。
  5. 从文件中读取键值对,并赋值给mMap。
  6. 调用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 返回提交结果
}
  1. 调用commitToMemory()方法将缓存mModified中的数据写入到mMap中,并获取到写入文件的数据结构的封装 MemoryCommitResult,里面有待写入的键值对、数据年龄代、监听SP变化的观察者、变化的键列表、计数器等。
  2. 将MemoryCommitResult传入 enqueueDiskWrite()中,此时 enqueueDiskWrite()方法的第二个参数是null
  3. 调用mcr.writtenToDiskLatch.await(); 让线程等待写入文件任务执行完毕。
  4. 通知监听SP的观察者,键值对内容发生变化了。
  5. 返回提交结果。

5. apply()方法

apply方法也一样不是很复杂,只是稍稍有点绕,因为有两个Runnable嵌套。阻塞不是直接在代码调用里面,而是在Runnable里面。

  1. 跟commit()方法一样,先调用commitToMemory()方法将缓存mModified中的数据写入到mMap中,并获取到MemoryCommitResult。
  2. 新建一个等待写入完成的Runnable类型的临时变量awaitCommit,里面阻塞等待计数器归零 mcr.writtenToDiskLatch.await(),这是第一个Runnable。
    在commit方法里面阻塞是直接写在commit方法里面的,这次不一样了是,放在了一个名叫awaitCommit的Runnable里面了,这个Runnable通常情况下不会跑在调用apply方法的进程里面(极端例如activity在onStop时,除外)。
  3. 调用 QueuedWork.addFinisher(awaitCommit); 将这个Runnable加入到QueneWork的sFinishers队列中。就是这个加入,会在特殊场景下,导致apply方法也会阻塞主线程,因为这个队列中的Runnable,会在activity#onStop时,用主线程去执行。
  4. 再新建第二个Runnable,名为postWriteRunnable,里面主要干两件事,一是运行上面创建的awaitCommit,然后是等待阻塞的awaitCommit在文件写入成功被唤醒后,将awaitCommit移除队列QueuedWork.removeFinisher(),这样后续的activity执行onStop就不会阻塞了。
  5. 将 写文件任务postWriteRunnable和对应的数据放入队列enqueueDiskWrite(mcr, postWriteRunnable)。

上面把我们常用的api,commit和apply方法介绍完了,它们里面基本就是干5件事:

  1. 一是将新值提交到内存
  2. 阻塞,commit是直接阻塞调用者;apply是将阻塞封装到Runnable里面,大部分情况不会阻塞调用者,极端情况例如activity在onStop时,会阻塞主线程。
  3. 写入文件完成,唤醒阻塞,让代码继续执行。
  4. 通知观察者。
  5. commit还有返回结果;apply没有。

在commit和apply方法里面都要调用到两个方法,一个是提交内存,一个是将写文件任务提交到队列。下面来继续分析:

四 写入内存和写入文件

1. commitToMemory()方法

这个方法顾名思义就是将新值提交到内存,对外暴露的提交数据api是封装在EditorImpl.class中,EditorImpl里面有缓存新值的容器mModified。
在commitToMemory方法里面做的主要事情就是,将mModified中的值拷贝合并到原mMap中。并将新合并后的mMap赋值给临时变量,将临时变量、数据年代等封装到MemoryCommitResult里面,将MemoryCommitResult做为参数,供以后的提交文件使用。
无论是commit()还是apply()方法,写入内存都是发生在主线程(严谨地说应该是调用线程)。

  1. 用SharedPreferencesImpl.this.mLock加锁代码块,这个锁一直到commitToMemory方法最后生成 MemoryCommitResult之前结束。
  2. 判断写入文件而没有完成的任务的次数mDiskWritesInFlight,如果大于0则新拷贝一个mMap(因为写入任务的键值对即 MemoryCommitResult#mapToWriteToDisk是指向mMap的,而写文件的过程是没有加锁的,如果不新建可能会有 正在写文件时mMap中的数据正在变化 这样的场景),并将写入任务局部变量mapToWriteToDisk指向mMap。
  3. 将临时变量mapToWriteToDisk指向mMap,然后将待写入任务次数mDiskWritesInFlight++。
  4. 用mEditorLock加锁后续代码块,一直到mLock加锁的代码块结束之前。
  5. 判断mClear是否为真(即是否调用了EditorImp#clear()方法),如果为真则清空mapToWriteToDisk(mapToWriteToDisk指向mMap,相当于清空了mMap)。
  6. 遍历缓存mModified,并判断key是否是EditorImpl自己,或者value值是否为空,如果是则移除mapToWriteToDisk中的key。否则,判断mModify中跟mapToWriteToDisk中键和值都不相等的键值对,然后拷贝到mapToWriteToDisk中。
  7. 将有变化的键值对的key添加到列表keysModified中。
  8. 清空mModified
  9. 如果数据有变化,将数据年龄代自增一mCurrentMemoryStateGeneration++, 并赋值给写文件任务的MemoryCommitResult中的成员变量memoryStateGeneration。
  10. 将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。这个方法做的事情如下:

  1. 通过postWriteRunnable等于null,将isFromSyncCommit赋值为true,认为是从commit提交过来的。
  2. 创建写文件的Runnable writeToDiskRunnable,这个writeToDiskRunnable的run方法里面主要做三件事:一是 用文件锁mWritingToDiskLock加锁,并在加锁的代码块中调用写文件方法,传入参数是MemoryCommitResult和isFromSyncCommit。二是 用mLock加锁,并在加锁的代码块中将待写入文件任务个数自减一mDiskWritesInFlight--。三是 如果 postWriteRunnable不为空,则执行这它(由前面分析,从commit方法调用过来时候,是空。从apply方法调过来时候不是空)。
  3. 判断如果是从commit方法调用的,而且等待写入文件的任务个数是1,就直接执行写文件任务writeToDiskRunnable,并返回,后续入队列方法不执行。
  4. 第三步中,条件不满足,没有直接执行写文件任务和返回,就会到这一步,这一步就是调用QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit)方法将写文件任务加入队列。有两个参数,第一个是写文件任务,第二个是否从commit方法调用过来,如果不是从commit方法调用过来,MSG消息会延时100MS发送。

3. 写入文件方法writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit)

这个方法里面,主要就是通过加锁判断数据年代决定是否需要真正写入文件,然后将原文件重命名为备份文件,再将数据写入内存,然后删除备份文件。详细如下:

  1. 在原文件存在的情况下,如果磁盘中的数据年代小于局部变量MemoryCommitResult 中的数据年代,判断是从commit调用过来 则需要写磁盘,或者不是从commit调用过来但是成员变量数据年代等于局部变量MemoryCommitResult 中的数据年代也需要写磁盘。
  2. 根据上面的判断,如果不需要写磁盘,则设置标志位mcr.setDiskWriteResult(false, true)并返回。
  3. 备份文件如果不存在,则将原文件重命名为备份文件,如果重命名失败则返回失败,不在将内容写入文件。
  4. 备份文件如果存在,则将原文件删除。这就是它能够保持数据稳定的方法,至少倒数第二次的数据是有效的。
  5. 写入文件,完成后关闭流。
  6. 删除备份文件。
  7. 磁盘数据年代赋值为内存数据年代。
  8. 返回成功结果。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容