知识梳理系列之二——消息机制中的若干重要问题

    技术2022-07-11  87

    知识梳理系列之二——消息机制中的若干重要问题

    轮询器Looper子线程为何要手动准备和轮询?如何保证线程Looper唯一?Looper.loop中是死循环为什么MainLooper不会阻塞主线程 消息与队列 Message/MessageQueue线程MessageQueue唯一IdleHandler的作用Message如何避免内存抖动——消息复用几个重要变量的含义:从obtain()看消息复用 消息处理者Handler延时消息是怎样被入队和分发处理的?题外话:Handler内存泄露的注意项Handler内存泄露的引用链解决方法 这是一个老生常谈的知识,本文不是全面文章,主要记录一些非常有用的原理性知识点,方便深入理解。

    轮询器Looper

    子线程为何要手动准备和轮询?

    一、MainThread的Looper创建和准备

    Android 的启动过程是: 创建init进程 --> Zygote进程 --> SystemServer进程 --> 各个应用进程 在SystemServer进程启动后(由Zygote进程fork出)在调用run方法时,调用了Looper.prepareMainLooper();,在老版本的则是在ActivityThread.main()中调用的prepareMainLooper

    // SystemServer.java package com.android.server; public final class SystemServer { ... public static void main(String[] args) { new SystemServer().run(); } private void run() { ... Looper.prepareMainLooper(); ... startBootstrapService(); startCoreService(); startOtherService(); ... Looper.loop(); ... } ... }

    于是,主线程在启动后,Activity#onCreate(Bundle) 调用时候就已经准备好Looper了 而工作线程Looper是没有调用prepare()/loop()的,因此需要自己手动调用或者使用HandlerThread/IntentService

    如何保证线程Looper唯一?

    遇到一个问Looper存储在哪里的问题

    我们来看下Looper.java

    // Looper.java ... public static void prepare() { // 工作线程的prepare传入了true,表示允许消息队列退出 prepare(true); } private static void prepare(boolean quitAllowed) { if (sThreadLocal.get() != null) { // 在调用prepare的时候如果ThreadLocal中就已经存在Looper了 // 则抛出异常,避免重复prepare和同一个线程出现多个Looper对象 throw new RuntimeException("Only one Looper may be created per thread"); } // 向ThreadLocal 中存入创建的Looper对象 sThreadLocal.set(new Looper(quitAllowed)); } public static void prepareMainLooper() { // 主线程的prepare传入了false,表示主线程的消息队列是不允许对出的!! prepare(false); synchronized (Looper.class) { if (sMainLooper != null) { throw new IllegalStateException("The main Looper has already been prepared."); } // 此处为Looper对象的成员变量sMainLooper初始化 // myLooper()方法实际还是调用ThreadLocal.get() sMainLooper = myLooper(); } }

    所以我们有答案了,Looper是保存在ThreadLocal这个线程本地存储里的,每一个线程只有一个线程本地存储,所以确保了同一个线程只有一个Looper!

    附:ThreadLocal是怎样的数据结构:

    // Looper.java // Looper类中有静态常量ThreadLocal对象,通过ThreadLocal.get()获取Looper实例(线程唯一) static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>(); // ThreadLocal.java // get方法获取了当前线程,通过线程取得线程的ThreadLocalMap引用,然后根据指定泛型对象获取实例 // 简单说就是从Map中获取了ThreadLocal<Looper>的键值对对象Entry,最后返回Entry.value即Looper对象 // 而这个Map是一个哈希数组,key是ThreadLocal<?>对象,就是说只有唯一的一个ThreadLocal<Looper>, // 因为ThreadLocal<Looper>与别的泛型的ThreadLocal对象的hash值不同,因此确保了一个线程只有一个Looper public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } // Thread.java ThreadLocal.ThreadLocalMap threadLocals = null;

    总结: 1. 主线程Looper在启动时就已经prepare/loop,子线程需要手动执行或使用HandlerThread/IntentService; 2. Looper被存储在相应Thread的ThreadLocal对象中,这个对象是一个哈希数组,保证了Looper线程唯一;

    Looper.loop中是死循环为什么MainLooper不会阻塞主线程

    在loop方法中,实际是一个死循环,即不断的从消息队列中获取消息,并分发给target(Handler)来处; 那么主线程是如何不会被这个死循环卡死的?

    简化loop方法如下:

    //Looper.java public static void loop() { // 获取不到Looper 抛出异常 final Looper me = myLooper(); if (me == null) { throw new RuntimeException( "No Looper; Looper.prepare() wasn't called on this thread."); } // 获取Looper 持有的消息队列 final MessageQueue queue = me.mQueue; ... for (;;) { // 进入死循环 不断从消息队列中获取下一个消息 // 官方在这里注释了might block表示获取下一个消息有可能会阻塞 Message msg = queue.next(); // might block if (msg == null) {// 拿到空消息退出循环 这时候loop也退出了 // No message indicates that the message queue is quitting. return; } ... try { // 把非空消息分发给目标Handler msg.target.dispatchMessage(msg); } ... // 对消息进行回收 msg.recycleUnchecked(); } }

    通过loop方法可以看出端倪,死循环在 queue.next() 方法处阻塞 再看简化的MessageQueue.next方法

    // MessageQueue.java Message next() { // MessageQueue持有Native消息队列的指针,如果已经退出了就会返回空消息 final long ptr = mPtr; if (ptr == 0) { return null; } int pendingIdleHandlerCount = -1; // -1 only during first iteration int nextPollTimeoutMillis = 0; for (;;) { // 再进入一个死循环,调用本地方法去检测描述符有没有新的消息可以poll出来 // 参数nextPollTimeOutMillis表示的是没有检测到新消息时的超时阻塞时间,-1表示一直阻塞 nativePollOnce(ptr, nextPollTimeoutMillis); // 休眠被唤醒后执行同步代码块 synchronized (this) { // 获取系统时间 和 消息队列头部Message final long now = SystemClock.uptimeMillis(); Message prevMsg = null; Message msg = mMessages; // 省略同步屏障有关处理 ... if (msg != null) { if (now < msg.when) { // 如果消息的时间没有到(比如延时消息),重新计算nextPollTimeoutMillis // 下一次循环,nativePollOnce阻塞的时间就是确定的 nextPollTimeoutMillis = (int) Math.min( msg.when - now, Integer.MAX_VALUE); } else { mBlocked = false; if (prevMsg != null) { // 有同步屏障才会走这里 prevMsg.next = msg.next; } else { // 更新消息队列对头 mMessages = msg.next; } // 修改use标记和从消息队列中剥离出msg,最后返回msg msg.next = null; if (DEBUG) Log.v(TAG, "Returning message: " + msg); msg.markInUse(); return msg; } } else { // 取到空消息,重置nextPollTimeoutMillis 继续循环 nextPollTimeoutMillis = -1; } ... } ... }

    至此,已经找到问题的关键部分了。 Looper.loop()方法的死循环是在MessageQueue.next()获取队列中下一个消息时,阻塞的,并且如果主线程消息队列一直没有新消息时,就会一直阻塞,是被一个nativePollOnce()的本地方法阻塞的。

    回到问题,阻塞了为什么没有导致主线程卡死。由于 Linux pipe/epoll机制,主线程会在此时(没有新的消息和事件进入),释放CPU资源,进入休眠状态,等待新的消息或者事件唤醒。

    Linux pipe/epoll 机制是一种IO多路复用的机制,基于事件驱动,监测了多个文件描述符。即使没有消息,如果有其他的操作(比如事件)唤醒了主线程,主线程就退出休眠的状态,继续工作,而消息Looper的继续阻塞不会导致主线程卡死。

    而在消息机制中,来了新的消息,也会调用一个本地方法唤醒休眠中的主线程

    // MessageQueue.java private native static void nativeWake(long ptr);

    消息与队列 Message/MessageQueue

    引:

    一个线程不仅只有一个Looper,并且也只有一个MessageQueue,这是如何保证的;

    如果向队列中发送大量消息,消息又一直在不断被处理,那么为什么不会频繁GC Message导致内存抖动?

    带着这两个问题来看源码

    线程MessageQueue唯一

    // Looper.java private static void prepare(boolean quitAllowed) { if (sThreadLocal.get() != null) { throw new RuntimeException("Only one Looper may be created per thread"); } // 这里创建并向ThreadLocal中存入了Looper实例 sThreadLocal.set(new Looper(quitAllowed)); } // 构造方法中创建了MessageQueue实例 private Looper(boolean quitAllowed) { mQueue = new MessageQueue(quitAllowed); mThread = Thread.currentThread(); }

    第一个问题已经有答案了: 因为Looper由线程私有存储ThreadLocal的哈希数组结构保证线程唯一,MessageQueue又是由Looper实例化的成员变量,那么也是线程唯一的。

    IdleHandler的作用

    在MessageQueue中有个接口IdleHandler,顾名思义是一个空闲时处理任务的处理器。

    public static interface IdleHandler { boolean queueIdle(); }

    这个接口提供了一个返回boolean值的方法。 在MessageQueue中有一个ArrayList<IdleHandler>的集合 此外,还提供了addIdle、removeIdle等方法用于向集合添加和移除IdleHandler。 那么这个IdleHandler有什么作用呢?在何时调用queueIdle方法? 在MessageQueue#next()中有了答案:

    Message next() { // 省略了nativePollOnce等逻辑 ... // 在阻塞结束(消息队列中的消息执行完了,而队列中下一个要执行的消息还没有达到可执行的时间)时 if (pendingIdleHandlerCount < 0 && (mMessages == null || now < mMessages.when)) { pendingIdleHandlerCount = mIdleHandlers.size(); } // 获取列表中IdleHandler个数 if (pendingIdleHandlerCount <= 0) { // 没有IdleHandler直接进入下一次循环 mBlocked = true; continue; } if (mPendingIdleHandlers == null) { mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)]; } mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers); } for (int i = 0; i < pendingIdleHandlerCount; i++) { final IdleHandler idler = mPendingIdleHandlers[i]; mPendingIdleHandlers[i] = null; // release the reference to the handler boolean keep = false; try { // 在这里调用了queueIdle方法!!! keep = idler.queueIdle(); } catch (Throwable t) { Log.wtf(TAG, "IdleHandler threw exception", t); } if (!keep) { synchronized (this) { mIdleHandlers.remove(idler); } } } }

    也就是当消息队列空闲时,可以执行IdleHandler的一些任务,这样可以提升性能。 比如在onResume方法中,使用主线程的消息队列的IdleHandler做一些准备工作,或者单纯的想获取UI绘制完成的回调,在绘制完成以后立即可以执行一些操作。

    Message如何避免内存抖动——消息复用

    在使用Message时,通常建议使用Message.obtain()系列的方法,这是有原因的。 当然也可以通过new Message() 来创建消息,只是更推荐上面的方式,并且new的方式最终也会被做一些响应处理。

    几个重要变量的含义:

    // Message.java public final class Message implements Parcelable { ... Message next;// 消息单向链表的下一个消息对象 /** @hide */ public static final Object sPoolSync = new Object();// 同步锁 private static Message sPool;// 私有静态成员,消息池 private static int sPoolSize = 0;// 消息池的大小,即消息对象个数 private static final int MAX_POOL_SIZE = 50;// 消息池最多可容纳50个消息对象 ... }

    由此可以看出消息池最多可以服用的消息对象是50个,这个池是一个单向链表的数据结构;

    从obtain()看消息复用

    // Message.java public static Message obtain() { synchronized (sPoolSync) {// 同步锁 if (sPool != null) { // 当池可以复用时,取出单向链表队头的消息对象使用, // 然后把队头重置为next,并维护池中可用的消息对象的个数 Message m = sPool; sPool = m.next; m.next = null; m.flags = 0; // clear in-use flag sPoolSize--; return m; } } // 消息池被清空了智能创建新的消息对象 return new Message(); }

    答案就是,消息对象维护了一个消息池,由于消息本身是一种单向链表的结构,维护对头来复用消息对象,并且在Looper.loop()方法中通过recycleUnchecked()方法来回收消息对象。


    消息处理者Handler

    Looper获取了消息队列中的消息后,通过Handler#dispathMessage()方法来分发给Handler,就会重发handleMessage方法,于是被重写的handleMessage中的业务逻辑就被执行了。

    延时消息是怎样被入队和分发处理的?

    在日常使用中,经常使用sendMessageDelay/postDelay/sendMesageAtTime等方法,那么延时消息是怎么入队的呢?

    // Handler.java public final boolean sendMessageDelayed(@NonNull Message msg, long delayMillis) { if (delayMillis < 0) { delayMillis = 0; } return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis); }

    首先,这些方法最终都是调用sendMessageAtTime实现的!

    // Handler.java public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) { MessageQueue queue = mQueue; ... return enqueueMessage(queue, msg, uptimeMillis);// }

    其次,直接调用了enqueueMessage进行入队!这个方法最终调用了MessageQueue#enqueueMessage()方法

    那么是怎么实现延时呢? 下面是简化后的MessageQueue#enqueueMessage()方法

    // MessageQueue.java boolean enqueueMessage(Message msg, long when) { // 一些可用非空判断 ... synchronized (this) { // 在退出队列的回收异常退出 if (mQuitting) { IllegalStateException e = new IllegalStateException( msg.target + " sending message to a Handler on a dead thread"); Log.w(TAG, e.getMessage(), e); msg.recycle(); return false; } // 标记入队的消息inUse 获取时间 获取消息队列对头 msg.markInUse(); msg.when = when; Message p = mMessages; boolean needWake; if (p == null || when == 0 || when < p.when) { // 当消息队列中没有消息或者新入队的消息时间小或为0,需要先执行入队的新消息 // 变更了对头 msg.next = p; mMessages = msg; needWake = mBlocked; } else { // 同步屏障有关省略 ... Message prev; for (;;) { prev = p; p = p.next; // 找到第一个时间比要入队新消息的消息然后放入,或者没有就放队尾 // 实质上是消息队列用when这个时间来排序了,时间小的在队头,从小到大的顺序排列 // 新消息找到正确的入队位置就退出此循环 if (p == null || when < p.when) { break; } // 同步屏障有关省略 ... } // 然后这一步就是真正的入队 msg.next = p; // invariant: p == prev.next prev.next = msg; } // We can assume mPtr != 0 because mQuitting is false. if (needWake) {// 决定是否要唤醒主线程 nativeWake(mPtr); } } return true; }

    答案有了:延时消息也是在触发时就入队了,只是消息队列里面会依据when这个时间标志从小到大屏排序; 那怎样保证延时处理呢?答案就在Looper.loop()方法中,阻塞的过程中有一个nextPollTimeoutMillis参数,如果消息队列中队头就是一个延时消息,那么loop在取消息的时候,会计算一个新的nextPollTimeoutMillis阻塞超时时间,来在delay的时间向Handler交付Message,于是就实现了延时消息!!!

    题外话:Handler内存泄露的注意项

    Handler在Activity中使用时常常要注意避免内存泄露,这里泄露的对象就是Activity,常常在使用延时消息时,需要注意如果延时消息还没有处理,Activity就销毁了,那么就会出问题。

    Handler内存泄露的引用链

    MainThread --> ThreadLocal<Looper> --> Looper --> MessageQueue --> Message --> Handler --> Activity

    匿名内部类Handler持有Activity的引用,Message.target.dispatchMessage()持有了target(Handler)的引用,就是延时消息持有了Handler的引用,延时消息又enqueue在MessageQueue中,MessageQueue由是由Looper创建的,最终存放在主线程ThreadLocal中了。

    解决方法

    只要打破引用链的环节就解决了

    让Handler作为静态内部类或者外部类,不持有Activity引用,即可避免Activity泄露,但是此时没有Activity的引用了,可以引入弱引用、软引用;让Handler作为普通内部类使用,但是在Activity生命周期结束时,移除消息队列中的所有回调和消息;(即onDestroy中 调用Handler#removeCallbacksAndMessages一类的remove方法)
    Processed: 0.011, SQL: 9