淺析SharedPreferences

無尾熊二發表於2018-12-02

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