前言
相信大家都有听说过SharedPreference是线程安全的,但进程是不安全的,包括google官方也不建议在多进程下使用SharedPreference,那么SharedPreference为什么进程不安全呢?在多进程并发的情况下,会出现什么问题呢?今天,我们就从源码的角度分析一下SharedPreference为什么进程不安全,没有看过源码的小伙伴可以参考一下我的这篇文章:SharedPreferences源码浅析
1、跨进程不具有“可见性”
可见性是Java并发相关的一个术语,但是我觉得用在这里还是比较贴切的,这里所谓的没有可见性,指的是:当一个进程修改了SharedPreference里的数据,另一个进程并不知道。为什么?我们从代码里找答案,首先看一下读取数据的代码,我们以getString为例:
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
代码很简单,读取数据的时候,直接从mMap中拿。那这个mMap是哪来的呢?是在loadFromDisk()方法中,将本地xml文件解析成键值对存入mMap中,loadFromDisk()是被startLoadFromDisk()调用的,而startLoadFromDisk()有两个地方调用,一个是初始化的时候,另一个是在startReloadIfChangedUnexpectedly(),但是这个方法只有在Android 11之前并且Mode是MODE_MULTI_PROCESS的时候才会走,也就是说:只有在初始化的时候,会将本地数据解析到mMap内
然后我们在看看存数据的地方,看过源码的小伙伴应该都知道,不管我们是通过commit还是apply,都会先通过commitToMemory()将修改同步到内存缓存(mMap),然后调用enqueueDiskWrite()创建一个写入任务,最终会调用到writeToFile()方法去进行真正的磁盘写入。
我们读取数据的时候是直接从mMap中拿,修改的时候,先同步给mMap,再写入磁盘。在单一进程下,没毛病,但是在多进程的情况下呢?我的mMap只有在初始化的时候读了一次磁盘,后续我就不读磁盘了,也就是说,如果别的进程修改了磁盘里的内存,我当前进程是不知道的,只有下次初始化的时候,这个修改才能被看到,这就是为什么没有可见性。
2、多进程并发有可能出现数据丢失
除了可见性是个问题,其实还有一个比较严重的问题,那就是在一些特殊情况下,数据会丢失,而且丢失的不是单条数据,而是整个xml!
我们来看一下writeToFile方法,注意留意mFile.delete();
和mBackupFile.delete();
的调用时机
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
//debug用的数据,用来观测执行时间
long startTime = 0;
long existsTime = 0;
long backupExistsTime = 0;
long outputStreamCreateTime = 0;
long writeTime = 0;
long fsyncTime = 0;
long setPermTime = 0;
long fstatTime = 0;
long deleteTime = 0;
if (DEBUG) {
startTime = System.currentTimeMillis();
}
boolean fileExists = mFile.exists();
if (DEBUG) {
existsTime = System.currentTimeMillis();
backupExistsTime = existsTime;
}
// 重命名当前文件,以便在下次读取时将其用作备份
if (fileExists) {
boolean needsWrite = false;
// 如果磁盘状态早于此提交,则需要写入
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
if (isFromSyncCommit) {
//同步的,commit走这里
needsWrite = true;
} else {
//异步的,apply走这里,比commit多了一个判断,校验一下是否是本次提交,防止并发导致错乱
synchronized (mLock) {
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}
if (!needsWrite) {
//不需要写入,直接通知完成
mcr.setDiskWriteResult(false, true);
return;
}
boolean backupFileExists = mBackupFile.exists();
if (DEBUG) {
backupExistsTime = System.currentTimeMillis();
}
//判断备份文件是否存在
if (!backupFileExists) {
//没有备份文件,将mFile重命名成备份文件
if (!mFile.renameTo(mBackupFile)) {
Log.e(TAG, "Couldn't rename file " + mFile
+ " to backup file " + mBackupFile);
mcr.setDiskWriteResult(false, false);
return;
}
} else {
//有备份,删除file
mFile.delete();
}
}
// 上述步骤完成后,将只剩下备份文件,原文件被删除,后续写入操作将重新创建一个mFile去写入
// 尝试写入文件,删除备份并尽可能以原子方式返回true。 如果发生任何异常,删除新文件; 下次我们将从备份中恢复。
try {
FileOutputStream str = createFileOutputStream(mFile);
if (DEBUG) {
outputStreamCreateTime = System.currentTimeMillis();
}
if (str == null) {
mcr.setDiskWriteResult(false, false);
return;
}
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
writeTime = System.currentTimeMillis();
FileUtils.sync(str);
fsyncTime = System.currentTimeMillis();
str.close();
//写入完成,根据mMode,设置文件权限
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
if (DEBUG) {
setPermTime = System.currentTimeMillis();
}
//保存文件状态
try {
final StructStat stat = Os.stat(mFile.getPath());
synchronized (mLock) {
mStatTimestamp = stat.st_mtime;
mStatSize = stat.st_size;
}
} catch (ErrnoException e) {
// Do nothing
}
if (DEBUG) {
fstatTime = System.currentTimeMillis();
}
// 写入成功,删除备份文件
mBackupFile.delete();
if (DEBUG) {
deleteTime = System.currentTimeMillis();
}
//记录文件修改时间
mDiskStateGeneration = mcr.memoryStateGeneration;
mcr.setDiskWriteResult(true, true);
if (DEBUG) {
Log.d(TAG, "write: " + (existsTime - startTime) + "/"
+ (backupExistsTime - startTime) + "/"
+ (outputStreamCreateTime - startTime) + "/"
+ (writeTime - startTime) + "/"
+ (fsyncTime - startTime) + "/"
+ (setPermTime - startTime) + "/"
+ (fstatTime - startTime) + "/"
+ (deleteTime - startTime));
}
long fsyncDuration = fsyncTime - writeTime;
mSyncTimes.add(Long.valueOf(fsyncDuration).intValue());
mNumSync++;
if (DEBUG || mNumSync % 1024 == 0 || fsyncDuration > MAX_FSYNC_DURATION_MILLIS) {
mSyncTimes.log(TAG, "Time required to fsync " + mFile + ": ");
}
//走到这里说明写入成功完成了
return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}
// 走到这里说明写入失败了,清理未成功写入的文件
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
mcr.setDiskWriteResult(false, false);
}
在写入的时候,这里有一个备份机制,会先创建一个备份,然后删除原文件重新写入,如果写入成功了就删除备份,如果写入失败了,就从备份中恢复。多线程这样是完全ok的,因为加了锁,这个段代码不会并发,但是如果在多进程场景下,是锁不住的,就会并发:进程A刚写入成功,进程B刚创建完备份,然后进程A写入成功后把备份删了,进程B创建备份后把原文件删了...这时,原文件和备份都被删除了,接下来是B进程开始写入数据,如果这个时候,写入出错了,那就凉了,整个文件都丢了。
这就是多进程使用SharedPreference出现数据丢失的原因,虽然概率很低,但是确实能遇到,我就遇到过,查明这个原因的过程很是让人头疼。所以,我们还是要避免在多进程下使用SharedPreference,如果有多进程的场景,可以使用ContentProvider对SharedPreference进行封装,使所有读写都转发到同一个进程内,这个是官方的建议,使用别的方式也行,反正总的思路就是将读写放到一个进程内~