What You Should Know About SharedPreferences

Shared preference is a built-in key-value store for primitive data types. It’s very easy to use, but there are some hidden mines that you may want to know.

Open SharedPreferences

When we need to use a shared preference, we usually do something like this:

SharedPreferences pref = getSharedPreferences(my_prefs, Context.MODE_PRIVATE);

However, internally, it needs to do some I/O operation to e.g. ensure the dir exists, and all openings are synchronized by a lock:

@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
    // ...

    File file;
    synchronized (ContextImpl.class) {
        if (mSharedPrefsPaths == null) {
            mSharedPrefsPaths = new ArrayMap<>();
        }
        file = mSharedPrefsPaths.get(name);
        if (file == null) {
            file = getSharedPreferencesPath(name);
            mSharedPrefsPaths.put(name, file);
        }
    }
    return getSharedPreferences(file, mode);
}

@Override
public File getSharedPreferencesPath(String name) {
    return makeFilename(getPreferencesDir(), name + ".xml");
}

Creating New Thread

Each time a new SharedPreferences is opened, it will create a new thread to load data from local disk, which is an expensive operation:

SharedPreferencesImpl(File file, int mode) {
    // ...
    startLoadFromDisk();
}

private void startLoadFromDisk() {
    synchronized (mLock) {
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk();
        }
    }.start();
}

Reading Data

After you open a shared preference, you may have code that looks like this on your main thread:

String value = pref.getString(key, null);

There’s indeed no I/O operations on the main thread. However, the getString() (or any other getX() methods) can be blocking:

@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

Here, the awaitLoadedLocked() method is basically blocking current thread until the initial loading is done, including both file reading and XML parsing.

Workaround

One (ugly) way to work around this, is to create own wrapper of shared preferences to force the initialization in background, e.g.:

public class SharedPreferencesWrapper {
    private final Future<SharedPreferences> pref;
    public SharedPreferencesWrapper(final Context context,
                                    final Executor executor,
                                    final String prefName) {
        pref = executor.execute(new Callable<SharedPreferences>() {
            @Override
            public SharedPreferences call() {
                return context.getSharedPreferences(prefName, Context.MODE_PRIVATE);
            }
        });
    }

    public SharedPreferences get() {
        return pref.get();
    }
}

Note that when calling reading the values, it can still block, so we should create the wrapper as early as possible:

// For example, we can create the wrapper before loading the view:
SharedPreferencesWrapper pref = new SharedPreferencesWrapper(this, executorService, my_prefs);

// Do something else, e.g.
setContentView(R.layout.activity_awesome);

// Still can block here, but the chance is smaller.
String value = pref.get().getString(key, null);

However, it will still create a new thread loading the data from disk, and there’s not much we can do about it.

All Key-Values Stay in Memory

When a shared preference is opened, not only that it needs to load all the data, but also that all the data will stay in memory until the process is killed. Here’s the relevant code in ContextImpl:

@Override
public SharedPreferences getSharedPreferences(File file, int 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;
        }
    }

    // ...

    return sp;
}

Therefore, if you store a huge amount of data in the shared preferences, it could consume lots of memory. On the other hand, it makes the following reading fast while maintaining the consistency.

Multi-Process Support is Naive

When you open a shared preference, you can pass the (currently deprecated) flag of Context.MODE_MULTI_PROCESS. However, the multi-process support is really naive, it merely reloads everything when it’s opened:

@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
    // ...
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
        getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
        // If somebody else (some other process) changed the prefs
        // file behind our back, we reload it.  This has been the
        // historical (if undocumented) behavior.
        sp.startReloadIfChangedUnexpectedly();
    }
    return sp;
}

The startReloadIfChangedUnexpectedly() method merely reloads all the data, if the file is changed after it’s read last time. However, if there’s pending writes, it won’t reload:

void startReloadIfChangedUnexpectedly() {
    synchronized (mLock) {
        // TODO: wait for any pending writes to disk?
        if (!hasFileChangedUnexpectedly()) {
            return;
        }
        startLoadFromDisk();
    }
}

Saving Data

There are two options to save the data to disk. The first approach is to do it synchronously by calling commit(), which immediately writes the data to the disk using the current thread.

The other way is to do it asynchronously by calling apply(). It will immediately update the memory, and schedule a task (with a delay of 100ms) to write the data to disk.

The first thing to note is, if there’s a crash happening before the scheduled task is run, the data will be lost.

The other problem is, to guarantee all the writes are done, it will block when an Activity or Service is stopped, or after Service’s command handling, etc. As a result, if you have lots of writes in these cases, you might end up with an ANR with a stack trace like below:

java.lang.wait (Object.java:-2)
java.lang.parkFor$ (Thread.java:2128)
sun.misc.park (Unsafe.java:325)
java.util.concurrent.locks.park (LockSupport.java:161)
java.util.concurrent.locks.parkAndCheckInterrupt (AbstractQueuedSynchronizer.java:840)
java.util.concurrent.locks.doAcquireSharedInterruptibly (AbstractQueuedSynchronizer.java:994)
java.util.concurrent.locks.acquireSharedInterruptibly (AbstractQueuedSynchronizer.java:1303)
java.util.concurrent.await (CountDownLatch.java:203)
android.app.run (SharedPreferencesImpl.java:366)
android.app.waitToFinish (QueuedWork.java:88)
android.app.handleStopService (ActivityThread.java:3778)
android.app.-wrap30 (ActivityThread.java:-1)
android.app.handleMessage (ActivityThread.java:1752)
android.os.dispatchMessage (Handler.java:102)
android.os.loop (Looper.java:154)
android.app.main (ActivityThread.java:6776)
java.lang.reflect.invoke (Method.java:-2)
com.android.internal.os.run (ZygoteInit.java:1496)
com.android.internal.os.main (ZygoteInit.java:1386)

Therefore, if you have lots of things to store, use a proper database like SQLite, or try to use commit() in a worker thread if you have to use shared preferences.


See also

comments powered by Disqus