淺析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) {*}java

因此下面咱們從ContextImpl類來對初始化過程進行分析。安全

首先咱們來看一下ComtextImpl類中與SharedPreferences相關的代碼:bash

--> 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對象中。下面咱們來看一下這裏須要關注的幾個小點:app

首先,對於同一個進程來講,ContextImpl類的Class對象只會有一個,因此當前進程中的全部SharedPreferences對象都是保存在sSharedPrefs中的。sSharedPrefs是一個ArrayMap對象,經過其泛型定義咱們能夠知道SharedPreferences對象在內存中是以兩個維度分類保存:1)包名,2)文件名。ide

另外,由於ContextImpl類中並無定義將SharedPreferences對象移除出sSharedPrefs的方法,因此其一旦加載到內存,就會存在至進程銷燬。相對的,也就是說SP對象一旦加載到內存,後面任什麼時候間使用,都是直接從內存獲取,不會再出現讀取磁盤的狀況。函數

SharedPreferences對象初始化的過程仍是比較簡單的,可是有一個問題須要注意,咱們在下一節進行分析。post

2.2 初始化會形成主線程阻塞麼?若是會,這種阻塞又是怎麼形成的?

在上一節中咱們提到,初始化時SP磁盤文件讀取的過程是在子線程中進行的,那麼應該是不會形成主線程阻塞纔對,可是事實是什麼樣子呢?讓咱們先來看看初始化時讀取文件的代碼,ui

// SharedPreferences自己是一個接口,其實現是SharedPreferencesImpl類。
// 構造方法
SharedPreferencesImpl(File file, int mode) {
    ...
    startLoadFromDisk();
}
複製代碼

建立SP對象時讀取磁盤文件的代碼是在SharedPreferencesImpl類的構造函數中,裏面有一個重要的方法 startLoadFromDisk() ,讓咱們詳細看下這個方法,this

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

從上面的方法來看,的確是在子線程讀取的磁盤文件,因此SP對象初始化過程自己的確不會形成主線程的阻塞。可是這樣就真的不會阻塞了麼?咱們來看一下獲取具體preference值的代碼,spa

-->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之後的系統,就沒有了這個限制,可能谷歌也是以爲這麼作太影響流暢度了,這點還須要確認。

相關文章
相關標籤/搜索