⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。
当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。
本文是 Android 开源库系列的第 2 篇文章,完整文章目录请移步到文章末尾~
前言
我们继续上一篇文章的分析:
- Android 开源库 #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
- Android 开源库 #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
- 两种写回策略
在获得事务对象后,我们继续分析 Editor 接口中的 commit 同步写回策略和 apply 异步写回策略。
6.1 commit 同步写回策略
Editor#commit 同步写回相对简单,核心步骤分为 4 步:
- 1、调用
commitToMemory()
创建MemoryCommitResult
事务对象; - 2、调用
enqueueDiskWrite(mrc, null)
提交磁盘写回任务(在当前线程执行); - 3、调用 CountDownLatch#await() 阻塞等待磁盘写回完成;
- 4、调用 notifyListeners() 触发回调监听。
commit 同步写回示意图
其实严格来说,commit 同步写回也不绝对是在当前线程同步写回,也有可能在后台 HandlerThread 线程写回。但不管怎么样,对于 commit 同步写回来说,都会调用 CountDownLatch#await() 阻塞等待磁盘写回完成,所以在逻辑上也等价于在当前线程同步写回。
SharedPreferencesImpl.java
1 | java复制代码public final class EditorImpl implements Editor { |
6.2 apply 异步写回策略
Editor#apply 异步写回相对复杂,核心步骤分为 5 步:
- 1、调用
commitToMemory()
创建MemoryCommitResult
事务对象; - 2、创建
awaitCommit
Ruunnable 并提交到 QueuedWork 中。awaitCommit 中会调用 CountDownLatch#await() 阻塞等待磁盘写回完成; - 3、创建
postWriteRunnable
Runnable,在 run() 中会执行 awaitCommit 任务并将其从 QueuedWork 中移除; - 4、调用
enqueueDiskWrite(mcr, postWriteRunnable)
提交磁盘写回任务(在子线程执行); - 5、调用 notifyListeners() 触发回调监听。
可以看到不管是调用 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite()
提交磁盘写回任务。
区别在于:
- 在 commit 中 enqueueDiskWrite() 的第 2 个参数是 null;
- 在 apply 中 enqueueDiskWrite() 的第 2 个参数是一个
postWriteRunnable
写回结束的回调对象,enqueueDiskWrite() 内部就是根据第 2 个参数来区分 commit 和 apply 策略。
apply 异步写回示意图
SharedPreferencesImpl.java
1 | java复制代码@Override |
QueuedWork.java
1 | java复制代码// 提交 aWait 任务(后文详细分析) |
这里有一个疑问:
在 apply() 方法中,在执行 enqueueDiskWrite() 前创建了 awaitCommit 任务并加入到 QueudWork 等待队列,直到磁盘写回结束才将 awaitCommit 移除。这个 awaitCommit 任务是做什么的呢?
我们稍微再回答,先继续往下走。
6.3 enqueueDiskWrite() 提交磁盘写回事务
可以看到,不管是 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite() 提交写回磁盘任务。虽然 enqueueDiskWrite() 还没到真正调用磁盘写回操作的地方,但确实创建了与磁盘 IO 相关的 Runnable 任务,核心步骤分为 4 步:
- 步骤 1:根据是否有 postWriteRunnable 回调区分是 commit 和 apply;
- 步骤 2:创建磁盘写回任务(真正执行磁盘 IO 的地方):
- 2.1 调用 writeToFile() 执行写回磁盘 IO 操作;
- 2.2 在写回结束后对前文提到的 mDiskWritesInFlight 计数自减 1;
- 2.3 执行 postWriteRunnable 写回成功回调;
- 步骤 3:如果是异步写回,则提交到 QueuedWork 任务队列;
- 步骤 4:如果是同步写回,则检查 mDiskWritesInFlight 变量。如果存在并发写回的事务,则也要提交到 QueuedWork 任务队列,否则就直接在当前线程执行。
其中步骤 2 是真正执行磁盘 IO 的地方,逻辑也很好理解。不好理解的是,我们发现除了 “同步写回而且不存在并发写回事务” 这种特殊情况,其他情况都会交给 QueuedWork
再调度一次。
在通过 QueuedWork#queue
提交任务时,会将 writeToDiskRunnable 任务追加到 sWork 任务队列中。如果是首次提交任务,QueuedWork 内部还会创建一个 HandlerThread
线程,通过这个子线程实现异步的写回任务。这说明 SharedPreference 的异步写回相当于使用了一个单线程的线程池,事实上在 Android 8.0 以前的版本中就是使用一个 singleThreadExecutor 线程池实现的。
提交任务示意图
SharedPreferencesImpl.java
1 | java复制代码private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { |
QueuedWork 调度:
QueuedWork.java
1 | java复制代码@GuardedBy("sLock") |
比较不理解的是:
同一个文件的多次写回串行化可以理解,对于多个文件的写回串行化意义是什么,是不是可以用多线程来写回多个不同的文件?或许这也是 SharedPreferences 是轻量级框架的原因之一,你觉得呢?
6.4 主动等待写回任务结束
现在我们可以回答 6.1 中遗留的问题:
在 apply() 方法中,在执行 enqueueDiskWrite() 前创建了 awaitCommit 任务并加入到 QueudWork 等待队列,直到磁盘写回结束才将 awaitCommit 移除。这个 awaitCommit 任务是做什么的呢?
要理解这个问题需要管理分析到 ActivityThread 中的主线程消息循环:
可以看到,在主线程的 Activity#onPause、Activity#onStop、Service#onStop、Service#onStartCommand 等生命周期状态变更时,会调用 QueudeWork.waitToFinish():
ActivityThread.java
1 | java复制代码@Override |
waitToFinish() 会执行所有 sFinishers 等待队列中的 aWaitCommit 任务,主动等待所有磁盘写回任务结束。在写回任务结束之前,主线程会阻塞在等待锁上,这里也有可能发生 ANR。
主动等待示意图
至于为什么 Google 要在 ActivityThread 中部分生命周期中主动等待所有磁盘写回任务结束呢?官方并没有明确表示,结合头条和抖音技术团队的文章,我比较倾向于这 2 点解释:
- 解释 1 - 跨进程同步(主要): 为了保证跨进程的数据同步,要求在组件跳转前,确保当前组件的写回任务必须在当前生命周期内完成;
- 解释 2 - 数据完整性: 为了防止在组件跳转的过程中可能产生的 Crash 造成未写回的数据丢失,要求当前组件的写回任务必须在当前生命周期内完成。
当然这两个解释并不全面,因为就算要求主动等待,也不能保证跨进程实时同步,也不能保证不产生 Crash。
抖音技术团队观点
QueuedWork.java
1 | java复制代码@GuardedBy("sLock") |
Android 7.1 QueuedWork 源码对比:
1 | java复制代码public static boolean hasPendingWork() { |
- writeToFile() 姗姗来迟
最终走到具体调用磁盘 IO 操作的地方了!
7.1 写回步骤
writeToFile() 的逻辑相对复杂一些了。经过简化后,剩下的核心步骤只有 4 大步骤:
- 步骤 1:过滤无效写回事务:
+ 1.1 事务的 memoryStateGeneration 内存版本小于 mDiskStateGeneration 磁盘版本,跳过;
+ 1.2 同步写回必须写回;
+ 1.3 异步写回事务的 memoryStateGeneration 内存版本版本小于 mCurrentMemoryStateGeneration 最新内存版本,跳过。
- 步骤 2:文件备份:
+ 2.1 如果不存在备份文件,则将旧文件重命名为备份文件;
+ 2.2 如果存在备份文件,则删除无效的旧文件(上一次写回出并且后处理没有成功删除的情况)。
- 步骤 3:全量覆盖写回磁盘:
+ 3.1 打开文件输出流;
+ 3.2 将 mapToWriteToDisk 映射表全量写出;
+ 3.3 调用 FileUtils.sync() 强制操作系统页缓存写回磁盘;
+ 3.4 写入成功,则删除备份文件(如果没有走到这一步,在将来读取文件时,会重新恢复备份文件);
+ 3.5 将磁盘版本记录为当前内存版本;
+ 3.6 写回结束(成功)。
- 步骤 4:后处理: 删除写至半途的无效文件。
7.2 写回优化
继续分析发现,SharedPreference 的写回操作并不是简单的调用磁盘 IO,在保证 “可用性” 方面也做了一些优化设计:
- 优化 1 - 过滤无效的写回事务:
如前文所述,commit 和 apply 都可能出现并发修改同一个文件的情况,此时在连续修改同一个文件的事务序列中,旧的事务是没有意义的。为了过滤这些无意义的事务,在创建 MemoryCommitResult
事务对象时会记录当时的 memoryStateGeneration
内存版本,而在 writeToFile() 中就会根据这个字段过滤无效事务,避免了无效的 I/O 操作。
- 优化 2 - 备份旧文件:
由于写回文件的过程存在不确定的异常(比如内核崩溃或者机器断电),为了保证文件的完整性,SharedPreferences 采用了文件备份机制。在执行写回操作之前,会先将旧文件重命名为 .bak
备份文件,在全量覆盖写入新文件后再删除备份文件。
如果写回文件失败,那么在后处理过程中会删除写至半途的无效文件。此时磁盘中只有一个备份文件,而真实文件需要等到下次触发写回事务时再写回。
如果直到应用退出都没有触发下次写回,或者写回的过程中 Crash,那么在前文提到的创建 SharedPreferencesImpl 对象的构造方法中调用 loadFromDisk() 读取并解析文件数据时,会从备份文件恢复数据。
- 优化 3 - 强制页缓存写回:
在写回文件成功后,SharedPreference 会调用 FileUtils.sync()
强制操作系统将页缓存写回磁盘。
写回示意图
SharedPreferencesImpl.java
1 | java复制代码// 内存版本 |
至此,SharedPreferences 核心源码分析结束。
- SharedPreferences 的其他细节
SharedPreferences 还有其他细节值得学习。
8.1 SharedPreferences 锁总结
SharedPreferences 是线程安全的,但它的线程安全并不是直接使用一个全局的锁对象,而是采用多种颗粒度的锁对象实现 “锁细化” ,而且还贴心地使用了 @GuardedBy
注解标记字段或方法所述的锁级别。
使用 @GuardedBy 注解标记锁级别
1 | java复制代码@GuardedBy("mLock") |
对象锁 | 功能 | 描述 |
---|---|---|
1、SharedPreferenceImpl#mLock | SharedPreferenceImpl 对象的全局锁 | 全局使用 |
2、EditorImpl#mEditorLock | EditorImpl 修改器的写锁 | 确保多线程访问 Editor 的竞争安全 |
3、SharedPreferenceImpl#mWritingToDiskLock | SharedPreferenceImpl#writeToFile() 的互斥锁 | writeToFile() 中会修改内存状态,需要保证多线程竞争安全 |
4、QueuedWork.sLock | QueuedWork 的互斥锁 | 确保 sFinishers 和 sWork 的多线程资源竞争安全 |
5、QueuedWork.sProcessingWork | QueuedWork#processPendingWork() 的互斥锁 | 确保同一时间最多只有一个线程执行磁盘写回任务 |
8.2 使用 WeakHashMap 存储监听器
SharedPreference 提供了 OnSharedPreferenceChangeListener 回调监听器,可以在主线程监听键值对的变更(包含修改、新增和移除)。
SharedPreferencesImpl.java
1 | java复制代码@GuardedBy("mLock") |
SharedPreferences.java
1 | java复制代码public interface SharedPreferences { |
比较意外的是: SharedPreference 使用了一个 WeakHashMap 弱键散列表存储监听器,并且将监听器对象作为 Key 对象。这是为什么呢?
这是一种防止内存泄漏的考虑,因为 SharedPreferencesImpl 的生命周期是全局的(位于 ContextImpl 的内存缓存),所以有必要使用弱引用防止内存泄漏。想想也对,Java 标准库没有提供类似 WeakArrayList 或 WeakLinkedList 的容器,所以这里将监听器对象作为 WeakHashMap 的 Key,就很巧妙的复用了 WeakHashMap 自动清理无效数据的能力。
提示: 关于 WeakHashMap 的详细分析,请阅读小彭说 · 数据结构与算法 专栏文章 《WeakHashMap 和 HashMap 的区别是什么,何时使用?》
8.3 如何检查文件被其他进程修改?
在读取和写入文件后记录 mStatTimestamp 时间戳和 mStatSize 文件大小,在检查时检查这两个字段是否发生变化
SharedPreferencesImpl.java
1 | java复制代码// 文件时间戳 |
至此,SharedPreferences 全部源码分析结束。
- 总结
可以看到,虽然 SharedPreferences 是一个轻量级的 K-V 存储框架,但的确是一个完整的存储方案。从源码分析中,我们可以看到 SharedPreferences 在读写性能、可用性方面都有做一些优化,例如:锁细化、事务化、事务过滤、文件备份等,值得细细品味。
在下篇文章里,我们来盘点 SharedPreferences 中存在的 “缺点”,为什么 SharedPreferences 没有乘上新时代的船只。请关注。
版权声明
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
参考资料
- Android SharedPreferences 的理解与使用 —— ghroosk 著
- 一文读懂 SharedPreferences 的缺陷及一点点思考 —— 业志陈 著
- 反思|官方也无力回天?Android SharedPreferences 的设计与实现 —— 却把青梅嗅 著
- 剖析 SharedPreference apply 引起的 ANR 问题 —— 字节跳动技术团队
- 今日头条 ANR 优化实践系列 - 告别 SharedPreference 等待 —— 字节跳动技术团队
推荐阅读
Android 开源库系列完整目录如下(2023/07/12 更新):
- #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
- #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
- #3 IO 框架 Okio 的实现原理,到底哪里 OK?
- #4 IO 框架 Okio 的实现原理,如何检测超时?
- #5 序列化框架 Gson 原理分析,可以优化吗?
- #6 适可而止!看 Glide 如何把生命周期安排得明明白白
- #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!
- #8 内存缓存框架 LruCache 的实现原理,手写试试?
- #9 这是一份详细的 EventBus 使用教程
⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~
本文转载自: 掘金