面试必问 PreferenceManager 手写实现避坑指南
面试现场,面试官盯着屏幕问:“手写一个 PreferenceManager,要求支持持久化。”你心里一紧,脑子里只有 SharedPreferences 的 API,却说不清底层怎么把 Map 存成文件,更别提多线程同步和类型转换了。这是典型的面试必问原理题,答不上来直接挂。
很多学员觉得 PreferenceManager 就是个配置读取器,调调 getInt、getString 完事。但在 Android 开发高阶面试中,这往往考察的是对单例模式、线程安全、IO 优化的综合掌握。今天拆解 3 个高频坑,带你从源码级理解如何手写一个健壮的 PreferenceManager。
坑一:单例初始化不线程安全,并发下出现“双亲”
现象
在多线程环境下(比如后台线程写配置,主线程读配置),偶尔会出现两个不同的 PreferenceManager 实例。一个实例读到的数据是空的,另一个实例有数据,导致状态不一致,甚至崩溃。
根本原因
这是最经典的 DCL(Double-Checked Locking) 缺失或实现错误导致的。 很多初学者会写这样的单例:
public class PreferenceManager {private static PreferenceManager instance;public static PreferenceManager getInstance() {if (instance == null) {instance = new PreferenceManager(); // 这里有问题}return instance;}
}
在 JVM 内存模型中,new PreferenceManager() 并非原子操作,它分为三步:
- 分配内存空间。
- 初始化对象(执行构造函数)。
- 将引用指向内存地址。
如果线程 A 执行完第 1 步被中断,此时 instance 已经被赋值(非 null),但对象还没初始化完。线程 B 进来检查 instance == null 为假,直接返回这个半成品对象。后续调用方法时,字段都是默认值(null/0),导致 NPE 或数据错乱。
正确写法对比
必须使用 volatile 关键字修饰静态变量,禁止指令重排序。
错误写法(非原子初始化):
// ❌ 错误:缺少 volatile,存在指令重排风险
public class PreferenceManager {private static PreferenceManager instance;public static PreferenceManager getInstance() {if (instance == null) {synchronized (PreferenceManager.class) {if (instance == null) {instance = new PreferenceManager();}}}return instance;}
}
正确写法(DCL + Volatile):
// ✅ 正确:volatile 保证可见性与有序性
public class PreferenceManager {private static volatile PreferenceManager instance;private PreferenceManager() {// 私有构造}public static PreferenceManager getInstance() {if (instance == null) {synchronized (PreferenceManager.class) {if (instance == null) {instance = new PreferenceManager();}}}return instance;}
}
复现与修复代码
在实际项目中,建议直接参考 Android 官方库 androidx.preference 的设计思路,它内部虽然没直接暴露单例,但其 PreferenceManager.getDefaultSharedPreferences 内部对 Context 和 File 的获取做了严格的上下文绑定,避免了跨进程或跨 Context 导致的文件冲突。
如果你手写,务必在单元测试中用 CountDownLatch 模拟 100 个线程同时调用 getInstance(),验证返回的 hashCode 是否一致。
坑二:多线程读写 SharedPreferences,数据丢失或格式损坏
现象
主线程调用 putString,同时子线程调用 putInt。结果发现,文件里的 XML 结构乱了,或者某个 Key 的值变成了另一个 Key 的值。重启 App 后,部分配置丢失。
根本原因
SharedPreferences 底层基于 XML 文件持久化。它的读写操作不是线程安全的。
- 写操作:
edit().apply()是异步的,会将数据先存到内存mMap,再后台线程写入文件。 - 读操作:直接从内存
mMap读。
如果两个线程同时 edit(),或者一个线程 apply 正在写文件,另一个线程也在 apply,底层 XML 序列化器(XmlUtils)并没有加锁保护整个文件的写入过程。虽然 SharedPreferencesImpl 内部对 mMap 加了 synchronized,但文件 IO 阶段是并发的。
更严重的坑是:commit() 是同步阻塞的,apply() 是异步的。 很多开发者混用。如果在 apply() 后立即调用 commit(),或者在应用退出的瞬间调用 apply(),可能导致内存数据还没刷盘,进程就被杀了,数据丢失。
正确写法对比
手写 PreferenceManager 时,不能直接透传 SharedPreferences.Editor,必须封装串行化写入队列或全局锁。
错误写法(直接透传 Editor):
// ❌ 错误:多线程并发 edit 可能导致文件写入冲突
public void putString(String key, String value) {SharedPreferences.Editor editor = mSharedPreferences.edit();editor.putString(key, value);editor.apply(); // 异步写,无全局同步控制
}
正确写法(使用 ReentrantLock 串行化 IO):
// ✅ 正确:使用锁保证同一时间只有一个写操作进入文件层
public class SafePreferenceManager {private final SharedPreferences mSharedPreferences;private final ReentrantLock mWriteLock = new ReentrantLock();public void putString(String key, String value) {mWriteLock.lock();try {SharedPreferences.Editor editor = mSharedPreferences.edit();editor.putString(key, value);// 使用 commit 确保在锁内同步写完,或者确保 apply 的任务被正确调度// 注意:这里为了面试演示安全,用 commit 阻塞,生产环境建议用 Handler 单线程池处理 applyeditor.commit(); } finally {mWriteLock.unlock();}}
}
进阶技巧:单线程池优化
在高性能场景下,ReentrantLock 会阻塞调用线程。更好的做法是维护一个单线程 ExecutorService,所有写操作投递到这个线程执行,天然串行,无需加锁。
private final ExecutorService mWriteExecutor = Executors.newSingleThreadExecutor();public void putStringAsync(String key, String value) {mWriteExecutor.execute(() -> {SharedPreferences.Editor editor = mSharedPreferences.edit();editor.putString(key, value);editor.apply();});
}
坑三:类型强转异常与默认值处理不当
现象
调用 getInt("user_age", 0),但之前存入的是字符串 "18"。运行时抛出 ClassCastException,App 闪退。或者,当 Key 不存在时,没有返回默认值,而是返回了 null,导致后续逻辑 NPE。
根本原因
SharedPreferences 存储的是 Map<String, ?>,值可以是 String, int, long, float, boolean, Set<String>。
底层在读取时,是直接 map.get(key) 然后强转。如果你存入的是 String,取出时强转 int,必崩。
此外,很多手写实现忽略了 Key 不存在 的情况,直接 return (int) map.get(key),当 map.get 返回 null 时,拆箱 null 会抛 NPE。
正确写法对比
必须做类型检查和默认值兜底。
错误写法(盲目强转):
// ❌ 错误:无类型检查,无默认值保护
public int getInt(String key) {Map<String, ?> map = mSharedPreferences.getAll();return (int) map.get(key); // 如果是 String 或 null,直接崩
}
正确写法(安全转换 + 默认值):
// ✅ 正确:类型检查 + 默认值
public int getInt(String key, int defValue) {Object value = mSharedPreferences.getAll().get(key);if (value instanceof Integer) {return (Integer) value;} else if (value instanceof String) {try {return Integer.parseInt((String) value);} catch (NumberFormatException e) {return defValue; // 解析失败返回默认值}}return defValue;
}
复现与修复代码
在测试中,故意存入 putString("age", "18"),然后调用 getInt("age", 0)。
- 错误写法:崩溃。
- 正确写法:返回 18。
如果存入 putString("age", "abc"),调用 getInt("age", 0)。
- 正确写法:捕获异常,返回默认值 0,不崩溃。
坑四:内存泄漏与 Context 绑定错误
现象
Activity 销毁后,PreferenceManager 依然持有 Activity 的 Context 引用,导致内存泄漏。或者在 Application 中初始化时,传入了 Activity Context,导致文件路径错误。
根本原因
SharedPreferences 的创建依赖于 Context。
- 如果传入 Activity Context,生成的 SharedPreferences 文件会关联到该 Activity 的生命周期。虽然文件本身不会删除,但如果在 Activity 中持有 Manager 引用,Activity 泄漏会导致 Manager 泄漏。
- 如果传入 Application Context,文件路径稳定,生命周期长。
核心原则:PreferenceManager 必须使用 Application Context 初始化。
正确写法对比
错误写法(使用 Activity Context):
// ❌ 错误:在 Activity 中用 this 初始化
public class MyActivity extends Activity {private PreferenceManager manager;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);manager = new PreferenceManager(this); // this 是 Activity Context}
}
正确写法(强制 Application Context):
// ✅ 正确:内部强制转为 Application Context
public class PreferenceManager {private final Context mContext;private final SharedPreferences mSharedPreferences;public PreferenceManager(Context context) {// 关键:无论传入什么 Context,都转成 Application Contextif (context.getApplicationContext() != null) {mContext = context.getApplicationContext();} else {mContext = context;}mSharedPreferences = mContext.getSharedPreferences("config", Context.MODE_PRIVATE);}
}
规避建议
在构造函数中,不要信任调用者传入的 Context。始终通过 context.getApplicationContext() 获取应用级 Context。这样可以确保:
- 文件路径全局唯一且稳定。
- 不会因为 Activity 销毁而意外影响配置文件的访问(虽然 SP 文件本身不随 Activity 销毁,但引用链会断)。
- 避免内存泄漏。
总结与面试应答策略
手写 PreferenceManager 不是让你重写 Android 系统代码,而是考察你对并发、IO、内存、异常处理的综合理解。
面试回答模板:
- 单例:使用 DCL 模式,
volatile保证线程安全。 - 线程安全:
SharedPreferences本身非线程安全,写操作需加锁或使用单线程池串行化。 - 类型安全:读取时做
instanceof检查,提供默认值兜底,防止 ClassCastException 和 NPE。 - 内存安全:内部强制使用 Application Context,避免 Activity 泄漏。
- IO 优化:区分
commit(同步)和apply(异步),关键配置用commit,非关键用apply,且注意进程退出前的数据落盘。
避坑清单:
- 别信
apply是线程安全的。 - 别直接强转 SharedPreferences 的值。
- 别用 Activity Context 初始化全局 Manager。
- 别忘了
volatile。
这个知识点你面试被问过吗?留言说说,看看还有谁踩过“并发写 SP 导致文件损坏”的坑。