庖丁解牛之SharedPreferences超级大卡顿

背景

 最近在排查app卡顿问题,在公司内部的bug管理平台上发现这个类卡顿问题,知道卡顿了多长时间吗,足足4s多,这让线上用户怎么想?让我怎么想?

java.lang.Object.wait(Native Method)
java.lang.Thread.parkFor(Thread.java:1220)
sun.misc.Unsafe.park(Unsafe.java:299)
java.util.concurrent.locks.LockSupport.park(LockSupport.java:157)
java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:813)
java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(AbstractQueuedSynchronizer.java:973)
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(AbstractQueuedSynchronizer.java:1281)
java.util.concurrent.CountDownLatch.await(CountDownLatch.java:202)
android.app.SharedPreferencesImpl$EditorImpl$1.run(SharedPreferencesImpl.java:363)
android.app.QueuedWork.waitToFinish(QueuedWork.java:88)
android.app.ActivityThread.handleServiceArgs(ActivityThread.java:3336)
android.app.ActivityThread.access$2300(ActivityThread.java:197)
android.app.ActivityThread$H.handleMessage(ActivityThread.java:1709)
android.os.Handler.dispatchMessage(Handler.java:111)
android.os.Looper.loop(Looper.java:224)
android.app.ActivityThread.main(ActivityThread.java:5958)
java.lang.reflect.Method.invoke(Native Method)
java.lang.reflect.Method.invoke(Method.java:372)
com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:1113)

  刚开始以为是系统Unsafe的卡顿,就没怎么细看,后来发现不对中间居然看见了SharedPreferences的代码,之前就知道SharedPreferences这个玩意坑很多,我又回忆起之前面试一个面试者,他提到过如何采用objectbox替换SharedPreferences解决卡顿问题,我怀疑就是这玩意导致的,这激起了我的好奇心

问题原因分析

  项目中用了SharedPreferences 这个玩意,谁知道这额玩意有大坑呀,给我们app卡的不行不行的,代码在apply的时候,SharedPreferences 内部发送了一个异步任务取执行文件的写操作,按道理说写操作都是在异步线程中执行的,不应该会卡顿主线程呀,是的,读写操作时在异步线程,QueuedWork.waitToFinish 这个方法是在主线程中执行,具体的调用到代码在ActiviytThread 类的handleStopActivity 方法和handleServiceArgs 方法中等多处方法中有调用,我们出问题的地方就是调用了handleServiceArgs方法,QueuedWork.waitToFinish 这个方法中执行了线程操作,所以导致了主线程卡住了

commit 造成的卡顿

 我们先看一下SharedPreferencesImpl 这个类,这个类是具体的实现类,我们看一下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方法中,首先执行写入任务也就是enqueueDiskWrite这个方法,我们稍后分析,然后让调用线程处于等待状态,当写入任务执行成功后唤起调用commit的线程,假设调用commit的线程就是主线线程,并且写入任务耗时还比较多的,这不就阻塞住主线程了吗?

private void enqueueDiskWrite(final MemoryCommitResult mcr,
                              final Runnable postWriteRunnable) {
    final boolean isFromSyncCommit = (postWriteRunnable == null);

    final Runnable writeToDiskRunnable = new Runnable() {
            public void run() {
                synchronized (mWritingToDiskLock) {
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
                    mDiskWritesInFlight--;
                }
                if (postWriteRunnable != null) {
                    postWriteRunnable.run();
                }
            }
        };

    // Typical #commit() path with fewer allocations, doing a write on
    // the current thread.
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (mLock) {
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) {
            writeToDiskRunnable.run();
            return;
        }
    }
    //往系统的队列中发送任务,然后在工作线程中执行任务
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

 enqueueDiskWrite 方法中首先判断的postWriteRunnable 是否等于null,如果等于空了,就在当前调用的地方执行写入操作,如果不是就往QueuedWork 队列中发送任务

总结一下:如果是使用commit方式提交,会阻塞调用commit方法的线程,如果写入任务很多比较耗时,就卡住了,所以不要在主线程执行写入文件的操作,但是我们上线卡顿日志是另外一种情况,是使用了apply提交的时候才会出现的

apply造成的卡顿

public void apply() {
    final long startTime = System.currentTimeMillis();

    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            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() {
            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);
}

 enqueueDiskWrite是执行异步任务的方法,我们之前已经见过这个方法,在apply方法中调用enqueueDiskWrite方法的时候最后一个参数是不等于空的,也就是说我们要执行一个异步任务,最终这异步任务的执行是在QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit)方法中

QueuedWork是干什么的呢?

 QueuedWork就是android系统提供的一个执行异步任务的工具类,内部的实现逻辑的就是创建一个HandlerThread作为工作线程,然后QueuedWorkHandler和这个HandlerThread进行管理,每当有任务添加进来就在这个异步线程中执行,这个异步线程的名字queued-work-looper

public static void queue(Runnable work, boolean shouldDelay) {
    Handler handler = getHandler();

    synchronized (sLock) {
        sWork.add(work);

        if (shouldDelay && sCanDelay) {
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
        } else {
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}

首先往sWork 添加一个任务,sWork是一个LinkedList,这个队列中数据最终在queued-work-looper 线程中依次得到执行

创建handle的过程

private static Handler getHandler() {
    synchronized (sLock) {
        if (sHandler == null) {
            HandlerThread handlerThread = new HandlerThread("queued-work-looper",
                    Process.THREAD_PRIORITY_FOREGROUND);
            handlerThread.start();

            sHandler = new QueuedWorkHandler(handlerThread.getLooper());
        }
        return sHandler;
    }
}

在QueuedWorkHandler 是如何处理消息的

    private static class QueuedWorkHandler extends Handler {
        static final int MSG_RUN = 1;

        QueuedWorkHandler(Looper looper) {
            super(looper);
        }

        public void handleMessage(Message msg) {
            if (msg.what == MSG_RUN) {
                processPendingWork();
            }
        }
    }
}
private static void processPendingWork() {
    long startTime = 0;

    if (DEBUG) {
        startTime = System.currentTimeMillis();
    }

    synchronized (sProcessingWork) {
        LinkedList<Runnable> work;

        synchronized (sLock) {
            work = (LinkedList<Runnable>) sWork.clone();
            sWork.clear();

            // Remove all msg-s as all work will be processed now
            getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
        }

        if (work.size() > 0) {
            for (Runnable w : work) {
                w.run();
            }

            if (DEBUG) {
                Log.d(LOG_TAG, "processing " + work.size() + " items took " +
                        +(System.currentTimeMillis() - startTime) + " ms");
            }
        }
    }
}

实际上就是遍历sWork,挨个执行任务,

那为什么会出现上面的卡顿了?

 apply的中写入操作也是在异步线程执行,不会导致主线程卡顿,但是如果异步任务执行时间过长,当ActvityThread执行了handleStopActivity或者handleServiceArgs或者handlePauseActivity 等方法的时候都会调用QueuedWork.waitToFinish()方法,而此方法中会在异步任务执行完成前一直阻塞住主线程,所以卡顿问题就产生了

public static void waitToFinish() {
    long startTime = System.currentTimeMillis();
    boolean hadMessages = false;

    Handler handler = getHandler();

    synchronized (sLock) {
        if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
            // Delayed work will be processed at processPendingWork() below
            handler.removeMessages(QueuedWorkHandler.MSG_RUN);

            if (DEBUG) {
                hadMessages = true;
                Log.d(LOG_TAG, "waiting");
            }
        }

        // We should not delay any work as this might delay the finishers
        sCanDelay = false;
    }

    StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
    try {
        processPendingWork();
    } finally {
        StrictMode.setThreadPolicy(oldPolicy);
    }

    try {
        while (true) {
            Runnable finisher;

            synchronized (sLock) {
                //关键代码
                finisher = sFinishers.poll();
            }

            if (finisher == null) {
                break;
            }

            finisher.run();
        }
    } finally {
        sCanDelay = true;
    }

    synchronized (sLock) {
        long waitTime = System.currentTimeMillis() - startTime;

        if (waitTime > 0 || hadMessages) {
            mWaitTimes.add(Long.valueOf(waitTime).intValue());
            mNumWaits++;

            if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
                mWaitTimes.log(LOG_TAG, "waited: ");
            }
        }
    }
}

 会从sFinishers队列中取出数据然后执行run方法,我们别忘了在apply的方法中,我们还添加了QueuedWork.addFinisher(awaitCommit);这个awaitCommit 就得到执行了但是awaitCommit中的代码确实是阻塞的代码,等待写入线程执行完毕才能唤起此线程

final Runnable awaitCommit = new Runnable() {
        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");
            }
        }
    };

如果 apply中的写入代码不执行完,主线程就一直卡住了,也就出现了我们上面的问题

SharedPreferences 获取数据也是阻塞的

我们看一下SharedPreferences 的初始化代码

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

数据加载是异步线程执行的

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

那为什么获取数据也是阻塞的?

看一下获取数据的get方法

public int getInt(String key, int defValue) {
    synchronized (mLock) {
        awaitLoadedLocked();
        Integer v = (Integer)mMap.get(key);
        return v != null ? v : defValue;
    }
}

关键awaitLoadedLocked 这个方法,当数据没有加载完,就让调用的线程处于等待中,阻塞住了

private void awaitLoadedLocked() {
    if (!mLoaded) {
        // Raise an explicit StrictMode onReadFromDisk for this
        // thread, since the real read will be in a different
        // thread and otherwise ignored by StrictMode.
        BlockGuard.getThreadPolicy().onReadFromDisk();
    }
    while (!mLoaded) {
        try {
            mLock.wait();
        } catch (InterruptedException unused) {
        }
    }
}

总结

  • commit 方式会阻塞调用的线程
  • apply 放法不会阻塞调用的线程,但是如果写入任务比较耗时,会阻塞住主线程,因为主线程有调用的代码,需要等写入任务执行完了才会继续往下执行

建议

 SharePrefereces这玩意能不用就不用,坑还是比较隐晦的,如果不查看源代码是根本不可能知道的,一般大家用这个类主要是图简单省事 了,我就是存一个数据搞那么复杂干什么呢?我建议如果大家在线上的项目中存储数据还是用自己实现的方案吧,不要用这个了,如果非要用这个我建议开启一个线程然后在线程中调用commit方式更新数据,至少这个方案不会卡住主线程

参考资料

http://blog.chinaunix.net/uid-29506893-id-5761774.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容