本文目录:
- 写在前面
- 获取 SharedPreferences 实例
- 加载 xml 数据文件
- 初次读取数据的耗时分析
- commit 和 apply 的对比
写在前面
SharedPreferences 平时用来持久化一些基本数据类型或者一些可序列化的对象。
根据我们日常的经常,持久化操作是耗时的,涉及到文件的 IO 操作,但是实际使用 SharedPreferences 时,发现只有第一次读取数据是有概率卡主线程几十到几百毫秒,而之后的读取时间几乎可以忽略不计。
我们有了这样的疑问:
- 为什么初次读取数据会有概率的阻塞?
- 为什么除了初次读取数据可能阻塞,而可以在后面的读取很快?
- 为什么都推荐使用 apply 而不是 commit 提交数据?
带着问题去理解它的实现。
获取 SharedPreferences 实例
SharedPreferences 是由 Context 返回的,比如我们的 Application,Activity。所以具体的实现每个应用的上下文环境有关,每个应用有自己的单独的文件夹存放这些数据,对其他应用不可见。
获取 SharedPreferences 的方法定义在抽象类 Context 中:
public abstract SharedPreferences getSharedPreferences(String name, int mode);
public abstract SharedPreferences getSharedPreferences(File file, int mode);
如果查看 Application 或者 Activity 的源码,会找不到具体的实现。这是因为它们继承了 ContextWrapper,代理模式,代理 ContextImpl 的实例 mBase 中。ContextImpl 是具体的实现。
两种获取 SharedPreferences 的方法中,我们基本上用的是 getSharedPreferences(String name, int mode);
,参数只传了文件的名字。
查看内部的代码可以看到,虽然只有一个名字,ContextImpl 会构建出文件的具体路径。再接着调用 getSharedPreferences(File file, int mode);
方法返回 SharedPreferencesImpl 实例。
所以 SharedPreferences 的操作,本质上就是对文件的操作。最后会落实到一个 xml 文件上:
@Override
public File getSharedPreferencesPath(String name) {
return makeFilename(getPreferencesDir(), name + ".xml");
}
标准路径在 /data/data/应用包名/shared_prefs
文件夹中,且都是 xml 文件。
创建好 File 对象后,会在 getSharedPreferences(File file, int mode)
中打开文件并执行初始操作,把 SharedPreferencesImpl 实例返回:
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
checkMode(mode);
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
这里我们看到了另一个重要的类 SharedPreferencesImpl,它和 ContextImpl 一样,是接口的具体实现类。
每一个 File 文件对应一个 SharedPreferencesImpl 实例。为了提高效率,ContextImpl 有做缓存 cache,这里的缓存是强引用,在整个进程的生命周期中都存在,意味着每个文件的 SharedPreferencesImpl 实例在整个进程中只会被创建一次。
这个方法的末尾有一个特殊的处理需要注意一下,是关于模式 Context.MODE_MULTI_PROCESS
,可以看到在这个模式下,会调用:
sp.startReloadIfChangedUnexpectedly();
这个方法执行下去,会检查文件是否被修改了,如果文件被修改了,会调用 startLoadFromDisk
来更新文件。因为多进程环境下,这里的文件有可能被其他进程修改。
加载 xml 数据文件
为什么除了初次读取数据可能卡顿,而可以在后面的读取很快?
我们进入 SharedPreferences 的加载流程,就是把文件的内容载入内存的过程。
载入文件的方法在 startLoadFromDisk
中,顾名思义,就是开始从磁盘加载数据。
调用该方法有两个地方:
- 构造函数里会被调用。所以第一次创建 SharedPreferencesImpl 会马上把文件内容载入内存。
- 在
Context.MODE_MULTI_PROCESS
下,文件发生修改时被调用。目的就是多进程下更新数据。
startLoadFromDisk
方法如下:
private void startLoadFromDisk() {
synchronized (this) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
可以看到直接开启一个新线程,调用 loadFromDisk 加载文件:
private void loadFromDisk() {
...
str = new BufferedInputStream(
new FileInputStream(mFile), 16*1024);
map = XmlUtils.readMapXml(str);
...
}
本质上,就是读取一个 xml 文件,被内容解析为 Map 对象。这个 map 包含了我们之前保存的所有键值对的数据。并且把 map对象保存为 mMap 成员变量,直接在内存中常驻:
synchronized (SharedPreferencesImpl.this) {
mLoaded = true;
if (map != null) {
mMap = map
...
} else {
mMap = new HashMap<>();
}
notifyAll();
}
这里可以解释我们的疑问,为什么 SharedPreferences 的读取非常快,载入完成后,后面的读操作都是针对 mMap 的,响应速度是内存级别的非常快。比如 getString:
public String getString(String key, @Nullable String defValue) {
synchronized (this) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
我们也就可以理解为什么 SharedPreferences 不希望存大量数据了,一个很重要的原因也是内存缓存,如果数据量很大的话,这里会占据很大一块内存。
初次读取数据的耗时分析
为什么初次读取数据会有概率的阻塞?
对应用性能监控中发现,SharedPreferences 初次读取数据的会发现概率发生阻塞,一般会被卡 20~40 ms。如果系统 IO 原本就繁忙的话,甚至可能会卡好几秒。
所以在应用启动中,我们去获取一些配置,不得不在主线程对 SharedPreferences 进行初次操作。如果在短时间内读取多个 不同的 SharedPreferences,应用的启动会耗费很长的时间。
这和一个锁有关,就是 SharePreferencesImpl.this。在初始化加载文件的时候,和读取数据的时候都会用到这个锁。
在 SharePreferencesImpl 构造中,调用 loadFromDisk ,加锁保护了对 mLoad 和 mMap 的读写:
private void loadFromDisk() {
synchronized (SharedPreferencesImpl.this) {
...
}
}
而每次读取数据的时候,也加了这个锁去保护这些成员,比如 getString:
public String getString(String key, @Nullable String defValue) {
synchronized (this) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
所以这里形成了一个竞争关系,如果在本地 xml 文件的加载过程中,先执行了 loadFromDisk,那么 getString 就会阻塞等待。
loadFromDisk 是 IO 耗时操作,虽然 loadFromDisk 操作被分配到另一个线程执行,但因为读取数据的时候,争用了这个锁,会发生概率卡顿。
commit 和 apply 的对比
为什么都推荐使用 apply 而不是 commit 提交数据?
先看我们平时修改 SharedPreferences 的姿势:
SharedPreferences sp = context.getSharedPreferences("test", Mode.PRIVATE);
Editor editor = sp.edit();
editor.putString("key", "Hello World!");
editor.commit(); 或者 editor.apply();
可以看到具体修改被它的内部类 EditorImpl 接管,最后才调用 commit 或者 apply,而这两者的区别就是我们要讨论的。
EditorImpl 内部有一个内存缓存,用来保存用户修改后的操作:
private final Map<String, Object> mModified = Maps.newHashMap();
在执行 commit 或者 apply 前,比如上面的 editor.putString("key","Hello World!")
会把修改存储在 mModified 中:
public Editor putString(String key, @Nullable String value) {
synchronized (this) {
mModified.put(key, value);
return this;
}
}
}
到这里,只是把修改缓存在了内存中。然后调用 commit 和 apply 把修改持久化。
这两个方法都会调用一个 commitToMemory 方法,做两件事情:
- 一个是把修改提交到内存
- 创建 MemoryCommitResult 用来做后面的本地 IO。
修改很简单,就是遍历 mModified,把修改的内容全部同步给 mMap。
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
// "this" is the magic value for a removal mutation. In addition,
// setting a value to "null" for a given key is specified to be
// equivalent to calling remove on that key.
if (v == this || v == null) {
if (!mMap.containsKey(k)) {
continue;
}
mMap.remove(k);
} else {
if (mMap.containsKey(k)) {
Object existingValue = mMap.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mMap.put(k, v);
}
mcr.changesMade = true;
if (hasListeners) {
mcr.keysModified.add(k);
}
}
而 MemoryCommitResult 是一个数据容器,记录着一些后面进行磁盘写入操作需要使用到的数据,比如有:
-
boolean changesMade
,标记变量,用来标记数据是否发生改变。 -
Map<?, ?> mapToWriteToDisk
, 最终要写入到本地的数据,会指向 SharedPreferencesImpl 的内存缓存 mMap
同步修改到 mMap
经过这个阶段,内存的数据就被更新了。并创建好 MemoryCommitResult 对象后,接下来就是不一样的操作。
先看 commit 方法:
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;
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
在调用 enqueueDiskWrite 的时候,因为没有构建 postWriteRunnable,最终会在当前线程直接执行写入操作:
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
...
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (SharedPreferencesImpl.this) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
...
}
直接调用 writeToDiskRunnable.run()
没有再开线程,直接阻塞写入。
apply 方法:
public void apply() {
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
QueuedWork.add(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.remove(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}
可以看到先构造了一个 postWriteRunnable 传入 enqueueDiskWrite。
在方法的执行中,可以看到最后会在一个单线程线程池 QueuedWork.singleThreadExecutor()
中执行写入操作:
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
...
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
所以,commit 是阻塞的,apply 是非阻塞的。
平时使用的时候,尽量使用 apply 避免卡主主线程。因为写入前都已经更新修改到缓存了,不用担心读到脏数据。