浅析SharedPreferences

1. 问题清单

  • SharedPreferences的初始化
    • SharedPreferences是怎么初始化的?
    • 初始化会造成主线程阻塞么?如果会,这种阻塞又是怎么造成的?
  • SharedPreferences读写操作
    • SharedPreferences的读写操作为什么是线程安全的?
    • Commit操作一定是当前线程执行么?如果不在,又是怎么实现的同步呢?
    • Apply操作是在子线程进行磁盘写入,难道就不会阻塞主线程了么?
注:1)下文中的SP表示SharedPreferences,SPImpl表示SharedPreferencesImpl。 2)以下所有分析排除 MODE_MULTI_PROCESS 模式

2. SharedPreferences的初始化

2.1 SharedPreferences是怎么初始化的?

不论我们是在Activity,还是Service中通过getSharedPreferences(fileName,mode)获取某个SharedPreferences对象,最终其实调用的都是ContextImpl类的如下方法:
public SharedPreferences getSharedPreferences(String name, int mode) {*}

所以下面我们从ContextImpl类来对初始化过程进行分析。

首先我们来看一下ComtextImpl类中与SharedPreferences相关的代码:

--> ContextImpl.java
/**
 * Map from package name, to preference name, to cached preferences.
 *
 * 因为一个进程只会存在一个ContextImpl.class对象,所以同一进程内的所有sharedPreferences都保存在
 * 了这个静态列表里。
 * 
 * ArrayMap泛型说明:
 * 1) String: packageName
 * 2) String: SharedPreferences文件名
 * 3) SharedPreferenceImpl: SharedPreferences对象
 */
private static ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>> sSharedPrefs;
    
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
    SharedPreferencesImpl sp;
    synchronized (ContextImpl.class) {
        if (sSharedPrefs == null) {
            sSharedPrefs = new ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>>();
        }

        final String packageName = getPackageName();
        ArrayMap<String, SharedPreferencesImpl> packagePrefs = sSharedPrefs.get(packageName);
        if (packagePrefs == null) {
            packagePrefs = new ArrayMap<String, SharedPreferencesImpl>();
            sSharedPrefs.put(packageName, packagePrefs);
        }
        
        ...
        
        sp = packagePrefs.get(name);
        if (sp == null) {
            File prefsFile = getSharedPrefsFile(name);
            sp = new SharedPreferencesImpl(prefsFile, mode);
            packagePrefs.put(name, sp);
            return sp;
        }
    }
    ...
    return sp;
}
复制代码

上面的代码中,ContextImpl类定义了一个静态成员变量sSharedPrefs,其类型为ArrayMap,通过这个Map来保存加载到内存中的SharedPreferences对象。当用户需要获取SP对象的时候,首先会在sSharedPrefs查找,如果没有找到,就会创建一个新的SP对象,在创建这个新对象的时候,会在子线程读取磁盘文件,然后以Map的形式保存在新创建的SP对象中。下面我们来看一下这里需要关注的几个小点:

首先,对于同一个进程来说,ContextImpl类的Class对象只会有一个,所以当前进程中的所有SharedPreferences对象都是保存在sSharedPrefs中的。sSharedPrefs是一个ArrayMap对象,通过其泛型定义我们可以知道SharedPreferences对象在内存中是以两个维度分类保存:1)包名,2)文件名。

另外,因为ContextImpl类中并没有定义将SharedPreferences对象移除出sSharedPrefs的方法,所以其一旦加载到内存,就会存在至进程销毁。相对的,也就是说SP对象一旦加载到内存,后面任何时间使用,都是直接从内存获取,不会再出现读取磁盘的情况。

SharedPreferences对象初始化的过程还是比较简单的,但是有一个问题需要注意,我们在下一节进行分析。

2.2 初始化会造成主线程阻塞么?如果会,这种阻塞又是怎么造成的?

在上一节中我们提到,初始化时SP磁盘文件读取的过程是在子线程中进行的,那么应该是不会造成主线程阻塞才对,但是事实是什么样子呢?让我们先来看看初始化时读取文件的代码,

// SharedPreferences本身是一个接口,其实现是SharedPreferencesImpl类。
// 构造方法
SharedPreferencesImpl(File file, int mode) {
    ...
    startLoadFromDisk();
}
复制代码

创建SP对象时读取磁盘文件的代码是在SharedPreferencesImpl类的构造函数中,里面有一个重要的方法 startLoadFromDisk() ,让我们详细看下这个方法,

-->SharedPreferencesImpl.java
private void startLoadFromDisk() {
    synchronized (this) {
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            synchronized (SharedPreferencesImpl.this) {
                loadFromDiskLocked();
            }
        }
    }.start();
}
复制代码

从上面的方法来看,的确是在子线程读取的磁盘文件,所以SP对象初始化过程本身的确不会造成主线程的阻塞。但是这样就真的不会阻塞了么?我们来看一下获取具体preference值的代码,

-->SharedPreferencesImpl.java
@Nullable
public String getString(String key, @Nullable String defValue) {
    synchronized (this) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}
复制代码

请看,awaitLoadedLocked(),这个是什么?,看名字就是要阻塞当前线程,具体看下,

-->SharedPreferencesImpl.java
private void awaitLoadedLocked() {
    ...
    while (!mLoaded) {
        try {
            wait();
        } catch (InterruptedException unused) {
        }
    }
}
复制代码

如果mLoaded==false就wait(),mLoaded又是什么,我们回到初始化读取磁盘的代码中,

private void loadFromDiskLocked() {
        ...
        读取磁盘文件代码(省略)
        ...
        mLoaded = true;
        ...
        notifyAll();
    }
复制代码

从上面的代码可以看出,只有子线程从磁盘加载完数据之后,mLoaded才会被设置为true,所以也就是说虽然从磁盘读取数据是在子线程进行并不会阻塞其他线程,但是如果在文件读取完成之前获取某个具体的preference值,那么这个线程是要被阻塞住,直到子线程加载完文件为止的。这么看来,如果在主线程获取某个preference值,那么就有可能发生阻塞主线程的情况。

3. SharedPreferences读写操作

当SharedPreferences初始化完成后,所有的读操作都是在内存中进行的,而写操作分为内存操作和磁盘操作两部分。下面以三个问题为线索,对读写操作进行一个简单的分析。

3.1 SharedPreferences的读写操作为什么是线程安全的?

SP的读操作就是从SharedPreferencesImpl对象的成员变量mMap里获取键值对的过程,而写操作,不论是通过Editor的commit()方法还是apply()方法,都是首先在当前线程将修改的数据提交到mMap中,然后继续在当前线程或者其他线程完成磁盘的写入操作。下面来看读取和写入内存的相关代码,

-->SharedPreferencesImpl.java
@Nullable
public String getString(String key, @Nullable String defValue) {
    synchronized (this) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}
复制代码
-->SharedPreferencesImpl$EditorImpl.java
public boolean commit() {
    MemoryCommitResult mcr = commitToMemory();
    ...
    Commit的写入磁盘操作
}
    
public void apply() {
    final MemoryCommitResult mcr = commitToMemory();
    ...
    Apply的写入磁盘操作
}

private MemoryCommitResult commitToMemory() {
    ...
    synchronized (SharedPreferencesImpl.this) {
        将新数据保存人mMap
    }
    ...
}
复制代码

从上面几段代码可以看出,SP对mMap的读写操作是加的同一把锁,所以在对内存进行操作时,的确是线程安全的。考虑到SP对象的生命周期与进程一致,一旦加载到内存就不会再去读取磁盘文件,所以只要内存中的状态是一致的,就可以保证读写的一致性。这种一致性也保证了SP的读取是线程安全的。至于写入磁盘的操作,自己慢慢来就可以了,反正也不会有人再去读取磁盘上的文件。

3.2 Commit操作一定是当前线程执行么?如果不是,又是怎么实现的同步呢?

commit()方法分为两步进行,第一步通过commitToMemory()方法,将数据插入mMap中,这是对内存中的数据进行更新,第二步通过enqueueDiskWrite(mcr, null)方法,将mMap写入到磁盘文件。commit()方法从调用线程的角度看的确是一个同步的操作,即会阻塞当前线程。但是这个方法里有一些微妙的地方需要分析一下,下面看相关代码,

--> SharedPreferencesImpl$EditorImpl.java
public boolean commit() {
    // 在当前线程将数据保存到mMap中
    MemoryCommitResult mcr = commitToMemory();
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
    try {
        // 如果是在singleThreadPool中执行写入操作,通过await()暂停主线程,知道写入操作完成。
        // commit的同步性就是通过这里完成的。
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    }
    /*
     * 回调的时机:
     * 1. commit是在内存和硬盘操作均结束时回调
     * 2. apply是内存操作结束时就进行回调
     */
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}
复制代码

首先是commitToMemory()这个方法,没什么好说的,就是将新数据更新到mMap中而已。然后咱们来看enqueueDiskWrite(mcr,null)方法,这个方法负责将数据写入到磁盘文件,神奇的现象就发生在这个方法中。

private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
    final Runnable writeToDiskRunnable = new Runnable() {
            public void run() {
                synchronized (mWritingToDiskLock) {
                    writeToFile(mcr);
                }
                synchronized (SharedPreferencesImpl.this) {
                    mDiskWritesInFlight--;
                }
                ...
            }
        };

    final boolean isFromSyncCommit = (postWriteRunnable == null);
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (SharedPreferencesImpl.this) {
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) {
            writeToDiskRunnable.run();
            return;
        }
    }

    QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
复制代码

从上面这段代码可以看出,commit()方法在写入磁盘文件这一步,有可能是在当前线程执行,也有可能是在QueueWork的线程池中执行,QueueWork是啥,我们后面再说,先让我们看下里面关键的if代码块,

if (isFromSyncCommit) {
    /*
     * 如果调用的是Editor.commit(),那么在本次commit之前,没有其他的writeToDisk任务要完成
     * 的话,直接在当前线程执行writeToFile()。但是如果在本次commit之前有其他的writeToDisk任务
     * 还没有完成,那么即使是commit,一样需要放到子线程去执行。
     *
     * 所以说commit有可能是在当前线程,也有可能是在子线程。如果当前线程是主线程,就有可能发生
     * 在主线程进行io操作的可能。
     *
     * 这样做的目的有一点,就是如果先apply后commit,那么不放到一个线程中去执行,就有可能出现
     * apply的数据在commit之后被写入到磁盘,这样磁盘中的数据其实就会是错误的,并且和内存中的
     * 数据不一致。
     *
     * 那么如果扔到了子线程,commit的同步是怎么保证的?
     * mcr里有个CountDownLatch,通过CountDownLatch.await()进行等待。
     */
    boolean wasEmpty = false;
    synchronized (SharedPreferencesImpl.this) {
        wasEmpty = mDiskWritesInFlight == 1;
    }
    if (wasEmpty) {
        writeToDiskRunnable.run();
        return;
    }
}

QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
复制代码

isFromSyncCommit==true 表示当前是调用的commit()方法。这一段代码里有一个逻辑,用来判断是在当前线程写入磁盘还是在QueueWork的线程池中写入磁盘。关键的变量就是 mDiskWritesInFlight ,这个变量表示当前SP对象有多少个磁盘写入任务未完成,其在commitToMemory()的时候+1,在写入成功后-1。

我们看上面的代码说当 mDiskWritesInFlight == 1 时,直接在当前线程调用 writeToDiskRunnable.run(),即在当前线程写入磁盘。当 mDiskWritesInFlight > 1 时,就插入到QueueWork的线程池中执行。

3.3 Apply操作是在子线程进行磁盘写入,难道就不会阻塞主线程了么?

AcitivtyThread在调用handlePauseActivity()的时候,此方法中有一句代码:

if (r.isPreHoneycomb()) {
    QueuedWork.waitToFinish();
}
复制代码

从这句代码可以看出,即使使用apply提交修改,依然可能出现阻塞主线程的情况。不过到4.0以后的系统,就没有了这个限制,可能谷歌也是觉得这么做太影响流畅度了,这点还需要确认。

转载于:https://juejin.im/post/5bcbd780f265da0ad948056a

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值