一、基础篇
1. SharedPreferences 是什么?
- Android 提供的轻量级键值对存储方案,适合存储少量简单数据(基本类型、
String、Set<String>)。 - 数据保存在
/data/data/<package>/shared_prefs/xxx.xml中,底层是 XML 文件,解析/写入用XmlUtils。 - 内部维护一个
Map<String, Object>作为缓存(mMap),读写都先走内存 Map。
2. 线程安全性
-
读取:线程安全(
mMap用volatile保证可见性)。 -
写入:
commit():同步写,整个过程加锁,线程安全。apply():更新内存 Map 后异步落盘,可能存在多线程覆盖风险(最后一次提交覆盖前面的)。
3. commit() vs apply()
| 方法 | 磁盘写入方式 | 返回值 | 是否阻塞调用线程 |
|---|---|---|---|
| commit() | 同步(当前线程直接写磁盘) | boolean(是否成功) | 会阻塞(主线程调用可能卡 UI) |
| apply() | 异步(单线程后台顺序执行) | void | 不阻塞(但数据可能未及时落盘) |
4. 为什么适合小数据?
- 每次写入都会全量重写 XML 文件(O(n) + I/O)。
- 数据大 → 解析和写入都会变慢,可能引发卡顿/ANR。
5. 多进程问题
-
默认不支持多进程数据同步(每个进程有独立缓存 Map)。
-
解决方案:
- 用
ContentProvider/AIDL/IPC 同步访问。 - 使用 MMKV(mmap 支持多进程)或 Jetpack DataStore。
- 用
6. 优化建议
- 合并写入:一次性
apply()批量写,减少磁盘 I/O。 - 拆分文件:按数据类型/业务拆分,避免大文件。
- 延迟写入:非关键数据延迟到空闲时落盘。
- 预估替代方案:频繁/大数据用 SQLite/Room/MMKV。
二、深挖篇(面试高频追问)
1) commit 和 apply 底层实现区别
-
相同:
- 都先更新内存 Map。
- 磁盘写入用 AtomicFile(写临时文件再 rename 原子替换)。
-
不同:
commit():同步写入,返回写入结果。apply():异步写入(QueuedWork单线程),无返回值。
2) 为什么更推荐 apply?风险是什么?
-
推荐理由:
- 不阻塞调用线程,主线程写入不卡 UI。
- 内存立即更新,后续读取能拿到新值。
-
风险:
- 落盘前进程被杀 → 数据丢失。
- 多线程并发
apply()可能有覆盖。 - 多进程场景不做同步。
3) 多进程如何保持一致性?
-
MODE_MULTI_PROCESS已废弃。 -
可选方案:
- 统一走一个进程 + IPC。
- 用支持多进程的存储方案(MMKV/DataStore)。
- 自行加文件锁并在写后主动 reload(复杂且性能差)。
4) 为什么频繁写会卡顿?
- 每次写是全量序列化 + 磁盘 I/O + fsync。
- 主线程
commit()→ 明显卡 UI。 - 异步
apply()频繁写 → 队列积压、刷盘压力大。
5) 为什么不适合存大量数据?
- 加载慢:首次 getSP 需解析全文件进内存。
- 写入慢:全量重写 XML。
- 类型限制:不支持复杂结构。
6) getStringSet 为什么要复制?
-
返回的可能是内部引用,直接改会:
- 改坏缓存 Map。
- 导致并发修改异常。
-
正确做法:
java 复制编辑 Set<String> copy = new HashSet<>(sp.getStringSet("key", Collections.emptySet())); copy.add("newValue"); sp.edit().putStringSet("key", copy).apply();
7) 能否保证写入顺序?
-
单进程:
- commit:顺序一致。
- apply:一般保持队列顺序,但落盘时间不确定。
-
多进程:不保证顺序与可见性。
8) 初始化会阻塞 UI 吗?
-
可能会:首次加载时从磁盘解析 XML → 大文件或存储慢时卡顿。
-
优化:
- 冷启动尽量延迟访问 SP。
- 在后台线程预加载。
- 大数据迁移到更高效存储方案。
9) SharedPreferences 是线程安全的吗?
结论:
- 读:线程安全(内存中有
volatileMap 缓存,读操作直接从内存取)。 - 写:
SharedPreferences.Editor的写是串行到磁盘的,但并发多次apply()可能出现最后写覆盖前面写的“逻辑冲突”。单进程下不会写坏文件(用 AtomicFile 原子替换)。
原理:
- SP 内部持有一个
Map作为缓存;commit()/apply()都是先合并到内存 Map,再写磁盘。 - 磁盘写使用临时文件 +
rename,保证掉电时文件不损坏。
实战:
- 同一份配置建议集中在一个线程更新;避免多处同时改同一个 key。
- 多进程不保证一致(见 Q107 注意事项)。
10) apply 和 commit 的区别?commit 一定会在主线程操作吗?
结论:
commit():同步写磁盘,返回boolean;在哪个线程调用就在哪个线程写,所以不一定是主线程——但不要在主线程调用,容易卡顿。apply():异步写磁盘(排队到单线程执行),不阻塞当前线程,无返回值;内存 Map 立即可见。
选择建议:
- 绝大多数情况用
apply()(不卡 UI)。 - 关键数据(必须确保已落盘),在后台线程用
commit()。
示例(后台线程安全落盘):
kotlin
复制编辑
withContext(Dispatchers.IO) {
val ok = sp.edit().putBoolean("onboarded", true).commit()
}
11) SharedPreferences 是如何初始化的,它会阻塞主线程吗?
结论:
- 第一次访问某个
xxx.xml时,需要从磁盘解析 XML → 构建内存 Map,这一步可能阻塞调用线程;之后再取同名 SP 对象走缓存,基本不阻塞。 - 如果你在冷启动流程主线程立刻大量访问 SP,可能卡顿。
原理:
ContextImpl#getSharedPreferences()内部有个 缓存 Map(同名返回同一个实例)。第一次 miss 会读文件解析到内存,并放入缓存。
实战:
- 冷启动避免立即读“大 SP 文件”;可以后台预热:
kotlin
复制编辑
// App 启动后台线程预热,避免首读阻塞主线程
withContext(Dispatchers.IO) { context.getSharedPreferences("config", MODE_PRIVATE).all }
12) 每次获取 SP 对象真的会很慢吗?
结论:
- 不会。同名 SP 对象在进程内有缓存,二次获取基本是 O(1)。
- 慢的是第一次加载磁盘 + 解析 XML;或者 SP 文件很大/频繁写入导致 I/O 压力。
建议:
- 复用同一个 SP 实例引用即可(但不是必须);
- 拆分超大的 SP 文件,避免一次解析太多键值。
13) 使用注意事项 & 优化点
1) 写入策略
- 合并写入:把多次
putXXX放到一次apply()/commit()里,减少 I/O。 - 主线程避免
commit();关键标志用后台commit(),其他用apply()。 - 高频写的大 key,考虑放到更合适的存储(Room/MMKV/DataStore)。
2) 文件与数据量
- SP 适合小量配置。文件过大(上百 KB 甚至 MB)会导致首次解析慢、写入是全量重写(慢、耗电)。
- 按业务拆分
xxx.xml,避免“巨无霸”配置文件。
3) 多进程一致性
- 默认不支持多进程实时一致;
MODE_MULTI_PROCESS已废弃且不可靠。 - 多进程场景用 ContentProvider/AIDL 统一代理,或改用 MMKV(多进程模式)/DataStore。
- 跨进程若仍用 SP,需要文件锁 + 主动 reload(复杂且不推荐)。
4) getStringSet() 的坑
- 返回的集合可能是内部引用,不要直接改。正确做法:拷贝后再写回:
java
复制编辑
Set<String> copy = new HashSet<>(sp.getStringSet("k", Collections.emptySet()));
copy.add("x");
sp.edit().putStringSet("k", copy).apply();
5) 冷启动/卡顿优化
- 避免在 Application 主线程立刻读大 SP;必要时后台预热或延迟读取。
- 写入放后台;
apply()会排队异步刷盘。
6) 可靠性
apply()有“小概率未落盘”风险(进程在写入前崩溃/被杀);关键路径调用sync行为需用后台commit()。- 设备存储空间不足会导致写失败,要有兜底(返回值/容错)。
7) 迁移与替代
- 频繁/大数据/多进程:优先 MMKV(mmap + 多进程)或 Jetpack DataStore(单进程、类型安全、可观察流)。
三、总结口诀
“读快写慢,小量配置信,单进程优先,跨进程换方案。”
commit 同步落盘可控但可能卡,apply 异步不卡但有丢失风险。大数据别用 SP,多进程别硬用 SP。