提到数据耐久化存储的计划,Android供给的手段有许多,在项目中常用的便是SharedPreference(简称SP),可是SP运用尽管简单,可是存在缺点:

  • 写入速度慢,尤其在主线程频频履行写入操作易导致卡顿或许ANR;
  • 不支持跨进程

因而针对这种缺点,咱们往往会换其他的技术计划,例如不能跨进程存取数据,那么就运用SQLite做数据存储,经过Provider对外供给数据,可是这种计划仍然存在呼应速度慢的问题,很有或许呈现ANR,即便是放在了子线程中存取数据,可是仍然会存在同步问题,直到MMKV的呈现,好像一下就解决了上述的两个问题。

那么在文章开篇,咱们经过一个小的demo验证一下SharedPreference和MMKV存储数据功率,看详细的效果如何。

object LocalStorageUtil {
    private const val TAG = "LocalStorageUtil"
    fun testSP(context: Context) {
        val sp = context.getSharedPreferences("spfile", Context.MODE_PRIVATE)
        //记载时间
        val currentTime = System.currentTimeMillis()
        for (index in 0..1000) {
            sp.edit().putInt("$index", index).apply()
        }
        Log.d(TAG, "testSP: cost ${System.currentTimeMillis() - currentTime}")
    }
    fun testMMKV(){
        val mmkv = MMKV.defaultMMKV()
        //记载时间
        val currentTime = System.currentTimeMillis()
        for (index in 0..1000) {
            mmkv.putInt("$index", index).apply()
        }
        Log.d(TAG, "testMMKV: cost ${System.currentTimeMillis() - currentTime}")
    }
}

看下耗时:

D/LocalStorageUtil: testSP: cost 182
D/LocalStorageUtil: testMMKV: cost 15

咱们看到,经过MMKV存储数据的功率有SP的10倍之多,并且这只要1000次连续存储,在数据量越来越大的时分,MMKV的优势就越显着,那么接下来咱们先经过剖析SharedPreference的源码,有利于了解MMKV源码。

1 SharedPreference源码剖析

/**
 * Retrieve and hold the contents of the preferences file 'name', returning
 * a SharedPreferences through which you can retrieve and modify its
 * values.  Only one instance of the SharedPreferences object is returned
 * to any callers for the same name, meaning they will see each other's
 * edits as soon as they are made.
 *
 * <p>This method is thread-safe.
 *
 * <p>If the preferences directory does not already exist, it will be created when this method
 * is called.
 *
 * <p>If a preferences file by this name does not exist, it will be created when you retrieve an
 * editor ({@link SharedPreferences#edit()}) and then commit changes ({@link
 * SharedPreferences.Editor#commit()} or {@link SharedPreferences.Editor#apply()}).
 *
 * @param name Desired preferences file.
 * @param mode Operating mode.
 *
 * @return The single {@link SharedPreferences} instance that can be used
 *         to retrieve and modify the preference values.
 *
 * @see #MODE_PRIVATE
 */
public abstract SharedPreferences getSharedPreferences(String name, @PreferencesMode int mode);

首要咱们在运用SP之前,首要会获取到SharedPreference实例,便是经过调用getSharedPreferences办法,终究回来值是SharedPreferences接口实例,详细完结类便是SharedPreferencesImpl。

1.1 SharedPreferencesImpl类剖析

首要经过Context获取SharedPreferences实例时,会传入一个文件名

ContextImpl # getSharedPreferences

@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
    // At least one application in the world actually passes in a null
    // name.  This happened to work because when we generated the file name
    // we would stringify it to "null.xml".  Nice.
    if (mPackageInfo.getApplicationInfo().targetSdkVersion <
            Build.VERSION_CODES.KITKAT) {
        if (name == null) {
            name = "null";
        }
    }
    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);
}

传入文件名之后,就会在mSharedPrefsPaths中查找是否创立过这个文件,咱们可以看到mSharedPrefsPaths是一个Map,完结文件名与详细文件的映射。 假如这个文件不存在,那么就会创立一个文件,即调用getSharedPreferencesPath办法,然后将其存入mSharedPrefsPaths这个Map调集中。

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

终究调用了另一个getSharedPreferences重载办法,在这个办法中,会拿到创立好的.xml文件构建SharedPreferencesImpl类。

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) {
            checkMode(mode);
            if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
                if (isCredentialProtectedStorage()
                        && !getSystemService(UserManager.class)
                                .isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
                    throw new IllegalStateException("SharedPreferences in credential encrypted "
                            + "storage are not available until after user is unlocked");
                }
            }
            sp = new SharedPreferencesImpl(file, mode);
            cache.put(file, sp);
            return sp;
        }
    }
    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;
}

SharedPreferencesImpl的结构办法

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;
    mThrowable = null;
    startLoadFromDisk();
}

从SharedPreferencesImpl中的结构办法中可以看到,每次创立SharedPreferencesImpl都会调用startLoadFromDisk从磁盘中读取文件,咱们看下详细完结。

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

从源码中咱们可以看到,是开启了一个名为SharedPreferencesImpl-load的线程去从磁盘中取文件,并且是经过new Thread这种办法,假如多次创立SharedPreferencesImpl目标,那么就会创立多个线程,会浪费系统资源。

SharedPreferencesImpl # loadFromDisk

private void loadFromDisk() {
    // ......
    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }
    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(
                        new FileInputStream(mFile), 16 * 1024);
                map = (Map<String, Object>) XmlUtils.readMapXml(str);
            } catch (Exception e) {
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
        thrown = t;
    }
    synchronized (mLock) {
        mLoaded = true;
    // ...... 
}

在这个办法中,会经过BufferedInputStream(IO)从文件中读取数据,并将其转换为一个Map数据结构,其实咱们经过检查文件中的数据格式,也能知道,其实便是key-value这种数据结构。

<int name="801" value="801" />
<int name="802" value="802" />
<int name="803" value="803" />
<int name="804" value="804" />
<int name="805" value="805" />
<int name="806" value="806" />
<int name="807" value="807" />
<int name="808" value="808" />
<int name="809" value="809" />
<int name="1000" value="1000" />

那么至此初始化的任务就完结了,这儿需求留意一个同步的问题,便是加载磁盘数据时是异步的,所以有一个标志位mLoaded,在调用startLoadFromDisk时会设置为false,等到磁盘数据加载完结之后,才会设置为true。

所以这儿咱们需求重视几个耗时点:

  • 从磁盘加载数据时,会把全量的数据加载进来,例如之前存在10_000条数据,那么也会全部读出来,因而IO读取会耗时;
  • 数据读取完结之后,解析XML dom节点时也会耗时。

1.2 SharedPreference读写剖析

前面咱们介绍完初始化流程,接下来便是读写操作了,首要咱们先看写操作;

sp.edit().putInt("$index", index).apply()

从文章开头的比如看,首要会经过SharedPreference获取到Editor目标,其实便是从SharedPreferenceImpl中获取Editor目标,对应的完结类便是EditorImpl。

SharedPreferenceImpl # EditorImpl

public final class EditorImpl implements Editor {
    private final Object mEditorLock = new Object();
    @GuardedBy("mEditorLock")
    private final Map<String, Object> mModified = new HashMap<>();
    @GuardedBy("mEditorLock")
    private boolean mClear = false;
    // ......
    @Override
    public Editor putInt(String key, int value) {
        synchronized (mEditorLock) {
            mModified.put(key, value);
            return this;
        }
    }
    // ......
}

在调用putInt办法时,会将其存储在HashMap中,然后可以调用apply或许commit办法将其写入文件,可是两者是有区别的。

EditorImpl # apply

@Override
public void apply() {
    final long startTime = System.currentTimeMillis();
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }
                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };
    QueuedWork.addFinisher(awaitCommit);
    Runnable postWriteRunnable = new Runnable() {
            @Override
            public void run() {
                awaitCommit.run();
                QueuedWork.removeFinisher(awaitCommit);
            }
        };
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}

经过源码,咱们看到在调用apply时写入磁盘的办法是异步的,在调用enqueueDiskWrite办法时传入了一个Runnable目标,这个时分不会堵塞主线程,可是没有写入是否成功的成果。

EditorImpl # commit

public boolean commit() {
    long startTime = 0;
    if (DEBUG) {
        startTime = System.currentTimeMillis();
    }
    MemoryCommitResult mcr = commitToMemory();
    SharedPreferencesImpl.this.enqueueDiskWrite(
        mcr, null /* sync write on this thread okay */);
    try {
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    } finally {
        if (DEBUG) {
            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                    + " committed after " + (System.currentTimeMillis() - startTime)
                    + " ms");
        }
    }
    notifyListeners(mcr);
    return mcr.writeToDiskResult;
}

而commit办法则是直接往磁盘中写数据,此时会堵塞线程直到数据写入完结,并回来写入成功或许失败的成果;所以两者详细在什么场景下调用,相信同伴们应该能分辩的出来吧。

因为SharedPreference的读写操作仍然是经过传统IO办法完结,所以这儿便是一个耗时点,关于传统的读写操作涉及到应用层与Kernel的通信

Android进阶宝典 -- 腾讯数据持久化方案MMKV原理分析(1)

应用层只是发起读数据的指令,而真实的读写操作是在内核空间,其间传统的IO存储是两次复制,也是比较耗时的一种操作,假如将其换为零复制技术,那么便是一种极佳的优化战略,MMKV便是这么做的, 所以假如了解Binder通信以及mmap的同伴或许就会了解,而不了解的同伴,经过这篇文章则是会了解其间的原理。

2 mmap原理及运用

前面咱们提到,在优化传统IO存储时,不想经过用户空间与内核空间上下文的调度来完结文件读写,所以就会想到mmap可以完结零复制读写文件,在功率上面必定要比传统的磁盘IO要快,那么首要咱们先看下mmap函数是如何运用,这儿或许会涉及到C++以及JNI的知识储备。

2.1 mmap的运用

首要界说一个办法writeBymmap,在native层经过调用mmap函数完结文件的读写。

class NativeLib {
    /**
     * A native method that is implemented by the 'nativelib' native library,
     * which is packaged with this application.
     */
    external fun stringFromJNI(): String
    external fun writeBymmap(fileName:String)
    companion object {
        // Used to load the 'nativelib' library on application startup.
        init {
            System.loadLibrary("nativelib")
        }
    }
}

关于mmap函数的参数界说,咱们需求了解其间的含义。

void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset);
  • _addr : 指向要映射的内存起始地址,一般设置为null由系统决议,映射成功之后会回来这块内存地址;
  • _size : 将文件中多大的长度映射到内存空间;
  • _port : 内存保护标志 ,一般为以下四种办法 -> PROT_EXEC 映射区域可被履行 PROT_READ 映射区域可被读取 PROT_WRITE 映射区域可被写入 PROT_NONE 映射区域不能存取;
  • _flags : 这块映射区域是否可以被其他进程同享,假如是私有的,那么只要当时进程可映射;假如是同享的,那么其他进程也可以获取此映射内存;
  • _fd : 要映射到内存中的文件描述符,经过open函数可以获取,存储完结之后,需求调用close;
  • _offset : 文件映射的偏移量,一般设置为0.
extern "C"
JNIEXPORT void JNICALL
Java_com_lay_nativelib_NativeLib_writeBymmap(JNIEnv *env, jobject thiz, jstring file_name) {
    std::string file = env->GetStringUTFChars(file_name, nullptr);
    //获取文件描述符
    int fd = open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //设置文件巨细
    ftruncate(fd, 4 * 1024);
    //调用mmap函数,回来的是物理映射的虚拟内存地址
    int8_t *ptr = static_cast<int8_t *>(mmap(0, 4 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
                                             0));
    //要写入文件的内容
    std::string data("这儿是要写入文件的内容");
    //用户空间可以操作这个虚拟内存地址 
    memcpy(ptr, data.data(), data.size());
}

经过调用了mmap函数可以拿到磁盘映射的物理内存的虚拟地址,看下图:

Android进阶宝典 -- 腾讯数据持久化方案MMKV原理分析(1)

在内核空间有一块与磁盘空间映射的物理内存区域,而在用户空间是可以拿到这块物理内存的虚拟内存地址,即经过调用mmap函数获取;那么后续想要履行写入操作,那么只需求在用户空间操作虚拟内存即可,就可以将数据写入到磁盘中,不需求经过用户空间和内核空间的上下文调度,然后提高了功率。

经过测验,调用了NativeLib()的writeBymmap办法,在文件中写入了数据。

fun testMmap(fileName: String) {
    //记载时间
    val currentTime = System.currentTimeMillis()
    for (index in 0..1000) {
        NativeLib().writeBymmap(fileName)
    }
    Log.d(TAG, "testMmap: cost ${System.currentTimeMillis() - currentTime}")
}

咱们可以选用这种办法计算一下,终究拿到的成果是:

D/LocalStorageUtil: testSP: cost 166
D/LocalStorageUtil: testMmap: cost 16

咱们看到与MMKV的功率根本共同,可是前面咱们自界说的mmap写文件办法是存在缺点的:假如咱们只想写1个字节的数据,但终究会写入4k的数据,会比较浪费内存。

Android进阶宝典 -- 腾讯数据持久化方案MMKV原理分析(1)

2.2 跨进程读写数据

关于SharedPreference存储办法来说,无法支持跨进程读写数据,只能在单一进程存储,而假如想要完结跨进程数据存取,其实也很简单,看下图:

Android进阶宝典 -- 腾讯数据持久化方案MMKV原理分析(1)

因为磁盘文件存储在手机sd卡中,在其他进程也可以经过读取文件的办法从磁盘获取,但这样又无法避免内核态到用户态的切换 ,因而经过上图看,进程A写入到磁盘数据之后,进程B也可以经过虚拟内存地址复制一份数据到本地,然后完结跨进程读数据。

extern "C"
JNIEXPORT jstring JNICALL
Java_com_lay_nativelib_NativeLib_getDataFromDisk(JNIEnv *env, jobject thiz, jstring file_name) {
    std::string file = env->GetStringUTFChars(file_name, nullptr);
    //获取文件描述符
    int fd = open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //设置文件巨细
    ftruncate(fd, 4 * 1024);
    //调用mmap函数,回来的是物理映射的虚拟内存地址
    int8_t *ptr = static_cast<int8_t *>(mmap(0, 4 * 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
                                             0));
    //需求一块buffer存储数据
    char *buffer = static_cast<char *>(malloc(100));
    //将物理内存复制到buffer
    memcpy(buffer, ptr, 100);
    //取消映射
    munmap(ptr, 4 * 1024);
    close(fd);
    //char 转 jstring
    return env->NewStringUTF(buffer);
}

详细的调用为:

NativeLib().getDataFromDisk("/data/data/com.tal.pad.appmarket/files/NewTextFile.txt").also {
    Log.d("MainActivity", "getDataFromDisk: $it")
}

D/MainActivity: getDataFromDisk: 这儿是要写入文件的内容

至此,经过mmap获取物理内存映射的虚拟内存地址后,只需求一次复制(memcpy)就可以完结文件的读写,并且支持跨进程的存取,这也是MMKV的核心原理。

Android进阶宝典 -- 腾讯数据持久化方案MMKV原理分析(1)

上面这张图是从官网copy的一张图,这儿显示了运用SharedPreference和MMKV的写入功率,其实为什么MMKV可以提升了几十倍的写入功率,还是得益于mmap的内存映射避免了内核态与用户态的切换,然后突破了传统IO瓶颈(二次复制), 从下篇文章开端,咱们将会带着同伴一同手写一套MMKV结构,可以对MMKV和mmap有愈加深化的了解。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。