终于看懂了 SharedPreferences 的源码实现

本文目录:

  • 写在前面
  • 获取 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 文件。

SharedPreferences 文件.png

创建好 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 避免卡主主线程。因为写入前都已经更新修改到缓存了,不用担心读到脏数据。

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

推荐阅读更多精彩内容