Looper.loop()引发的惨案

1、案件描述

在一个安静的下午,一妹子在`技术交流群里反馈(群号:977438066),自己开发的app,账号被挤下线时,重新登录到首页后,发现有一个请求,代码执行了,却没有任何回调,看得出,妹子很着急。

what ??? 还有这种事?原本安静的群,一下活跃了起来,男同胞们一顿狂猜,我总结了下,如下:

  • 会不会请求代码没执行,妹子自己搞错了吧?

  • 发请求前,出现异常,代码被中断运行?

  • 请求过程伴随着页面跳转,导致页面销毁时,请求被自动关闭?

  • 请求过程出现异常,被RxJava全局异常捕获了,并吃掉了,所以收不到失败回调?

这里解释下,妹子采用RxHttp+RxJava结合的方式发请求

经过第一轮询问后,以上猜想轻而易举的被推翻了,我也大概知道了案件的细节,为此,我用代码来还原一下,为简化案件,还原时,我会适当的做出修改,但意思还是那个意思。

2、案件还原

妹子在首页MainActivityOnCreate方法,会并行3个请求,如下:

@Override                                                              
protected void onCreate(Bundle savedInstanceState) {                   
    super.onCreate(savedInstanceState);                                
    setContentView(R.layout.main_activity);                            
    request1();                                                        
    request2();                                                        
    request3();                                                        
}                                                                      

public void request1() {                                               
    RxHttp.get("/service/...")                                         
        .asString()                                                    
        .to(RxLife.toMain(this))  //页面销毁,自动关闭请求,并在UI线程回调                        
        .subscribe(s -> {                                              
            //成功回调                                                     
        }, throwable -> {                                              
            //异常回调                                                     
        });                                                            
}                                                                      

public void request2() {                                               
    //省略请求代码,请求代码类似request1()方法                                        
}                                                                      

public void request3() {                                               
    //省略请求代码,请求代码类似request1()方法                                        
}                                                                      
复制代码

这段代码看起来并没有任何问题,正常登录进来后,都是正常的。

但是当账号被挤下线后(挤到登录页),重新登录到首页后,发现request1()、request2()、request3()三个请求方法都执行了,可request2()方法却迟迟收不到回调,不管成功/失败都收不到。

3、开始办案

以上猜想全部被推翻,接下来怎么办?很明显,我们要明确一点:

请求到底有没有发出去?服务端有没有收到这个请求?

随后,妹子用Adnroid Studio自带的Profiler工具,监控了下,发现请求并未发出来,接着,又找后台人员确认了下,后台也并未收到这个请求。

那就更奇怪了,请求代码执行了,请求却没有发出去?作为程序员的我第一反应,这怎么可能呢?妹子你用的手机有问题吧?要不换个手机试试?显然换了手机,问题一样存在,这就尴尬了。

接下来,跟妹子不断的调试,一而再,再而三的确认了,请求代码没有任何问题,然而,我却陷入了沉思之中,很绝望,很无助,甚至怀疑这是OkHttp的问题。

作为一名老鸟,最后我还是冷静了下来,重新整理了线索,发现又一条线索被遗漏了,那就是账号被挤,自动跳转到登录页面,为什么只有在账号被挤时,才会出现问题?于是乎,我调整了调查方向

  • 账号是如何被挤?又是如何跳转到登录页面的?

终于,凶手露出了水面,凶手就是Looper,原来妹子是通过OkHttp的拦截器来监听账号被挤,并通过Looper来弹出一个Toast提示,并且执行页面跳转逻辑,如下:

public class TokenInterceptor implements Interceptor {

    private Context context;

    //省略部分代码

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        Response originalResponse = chain.proceed(request);
        String code = originalResponse.header("code");
        if ("-1".equals(code)) { //账号被挤
            Looper.prepare();
            Toast.makeText(context, "你的账号在其它设备上登录", Toast.LENGTH_LONG).show();
            context.startActivity(new Intent(context, LoginActivity.class));
            Looper.loop();
        }
        return originalResponse;
    }
}
复制代码

也许你会问,这确定有问题?通过Looper在子线程弹出一个Toast,这不是很正常的一件事?经常这么干,从来没出现任何问题,为啥到你这就出问题?

我让妹子把Looper及Toast代码注释掉,if语句里面只保留一行startActivity,妹子试后开心的跟我说,好了,没问题了,这怎么解释?

4、开始破案

Looper一脸委屈的说道:你说我是凶手,我就是凶手了,证据呢?

ok,我们就来寻找证据,我们知道,Looper.loop()方法内部,会开启一个死循环,如下:

 public static void loop() {
     //省略部分代码
     for (;;) {                                                         
         Message msg = queue.next(); // might block                 
         if (msg == null) {                                             
            // No message indicates that the message queue is quitting.
            return;                                                    
         }
         //省略部分代码   
     }
     //省略部分代码                                                               
 }
复制代码

可以看到,queue.next()这行代码官方注释了,有可能会被堵塞,什么时候会堵塞?没有消息的时候,可见,调用Looper.loop()方法所在的线程会进入死循环。

那这个和我们的案件有什么关系呢?

这就要来说说RxJava的线程池了,上面TokenInterceptor回调所在的线程是RxJavaIO线程,而RxJavaIO线程池的配置,却仅允许一条核心线程执行任务,当任务在执行,其它任务过来时,必须等待至上一个任务结束。

IoScheduler类中可以找到静态内部类ThreadWorkerThreadWorker继承至NewThreadWorker,在该类中,我们可以找到线程池对象,如下:

public class NewThreadWorker extends Scheduler.Worker implements Disposable {
    private final ScheduledExecutorService executor;

    //省略部分代码

    public NewThreadWorker(ThreadFactory threadFactory) {
        //这里创建了线程池
        executor = SchedulerPoolFactory.create(threadFactory);
    }
    //省略部分代码
}    
复制代码

SchedulerPoolFactory.create方法点进去看看

public static ScheduledExecutorService create(ThreadFactory factory) {
    final ScheduledExecutorService exec = Executors.newScheduledThreadPool(1, factory);  //核心线程数量为1
    //省略部分代码
    return exec;
}
复制代码

可以看到,这里传了个1,就是核心线程的数量,继续往下看,最终找到了创建线程池对象代码,如下:

public ScheduledThreadPoolExecutor(int corePoolSize,             
                                   ThreadFactory threadFactory) {
    super(corePoolSize, Integer.MAX_VALUE,                       
          DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,                
          new DelayedWorkQueue(), threadFactory);                
}                                                                
复制代码

这里简单解读一下,该线程池核心线程数量为1,非核心线程数量无上限,非核心线程闲置时间超过10毫秒便会被回收,并使用了延迟队列。

注意注意,前方高能预警

用简单的话来说,该线程池,同一时间,仅会执行一个任务,也就是串行,这也就解释Looper与本案的关系,因为Looper.loop()所在线程进入死循环,该线程所在线程池收到其它任务时,便必须得等待至上一个任务执行完毕,然而上一个任务在死循环,所以下一个任务永远得不到执行,这也就是为什么请求代码执行了,请求却没发出去原因。

5、其它思考

到这,估计很多人会有疑问

  • RxJavaIo线程池,是串行执行的,那么它又是如何做到并行的呢?难道以前写的并行代码,其实都是串行实现的?

  • 线程池已经有任务在执行了,为啥还会拿到该线程池执行新的任务呢?

  • RxJava为啥不使用OkHttp内部的线程池配置,只要有任务来,都开启非核心线程去执行?

ok,接下来一一解答

首先,第一个,RxJava如何根据目前的Io线程池,做到并行任务?

其实很简单,在IoScheduler的静态内部类CachedWorkerPool中,维护了一个线程池队列,每次收到新任务,都会从队列里面取出一个线程池去执行任务,如果没有,则创建一个新的线程池,如下:

static final class CachedWorkerPool implements Runnable {
    //这个就是线程池队列                                   
    private final ConcurrentLinkedQueue<ThreadWorker> expiringWorkerQueue
    final CompositeDisposable allWorkers;                             
    private final ThreadFactory threadFactory;                        

    //省略部分代码                                                              

    //取出一个线程池                                                                  
    ThreadWorker get() {                                              
        if (allWorkers.isDisposed()) {                                
            return SHUTDOWN_THREAD_WORKER;                            
        }                                                             
        while (!expiringWorkerQueue.isEmpty()) {                      
            ThreadWorker threadWorker = expiringWorkerQueue.poll();   
            if (threadWorker != null) {                               
                return threadWorker;  //队列里有,直接返回                                  
            }                                                         
        }                                                             

        // 队列没有,创建一个新的               
        ThreadWorker w = new ThreadWorker(threadFactory);             
        allWorkers.add(w);                                            
        return w;                                                     
    }

    //回收线程池,任务被取消或者正常执行完毕,将线程池添加进缓存队列
    void release(ThreadWorker threadWorker) {                     
        //设置线程池过期时间,60s   keepAliveTime=60s
        threadWorker.setExpirationTime(now() + keepAliveTime);    

        expiringWorkerQueue.offer(threadWorker);                  
    }                                                             
    //省略部分代码
}                                                                 
复制代码

通过多个线程池,就达到了并行的效果;上面代码release方法中,我们注意到,被回收的线程池,存活时间为60s,在CachedWorkerPool 构造方法中,会开启一个定时任务,每间隔60s,就会去检查线程池队列,如果线程池闲置超过60s,便会将线程池关闭,并从队列中移除。

接着,回答第二个问题,线程池已经有任务在执行了,为啥还会拿到该线程池执行新的任务?

看了上面的代码,其实就很好回答了,回收线程池有两个条件会触发,一是任务正常执行完毕,这个好理解,不做解释,另外一个就是,任务被取消,比如,调用Disposable#isDisposed()方法取消任务,但是该方法不会取消线程池里的任务,这就导致了,线程池虽然被回收了,但线程池里的任务依然在执行,所以下次拿到该线程池的任务,只能等待。

最后,就是RxJava为何要如此设计线程池?

原因很简单,防止线程资源被浪费,如上面说到的,线程池虽然被回收了,但里面的线程却依然在执行任务,这样的线程多了,无疑是一种浪费,怎么办?依靠定时器,让被回收的线程池在一定时间后,关闭任务,并从队列中移除。而如果直接通过线程池去回收线程,那么被Looper.loop() 的线程,进入死循环后,将永远得不到回收。

到这,我也丢个问题给大家,RxJava在将线程池丢进缓存队列时,为啥不将线程池关闭掉?欢迎评论群留言讨论

6、总结

回顾下案件,从妹子反馈的问题,账号被挤,重新登录到首页后,request2()方法内的请求代码执行了,却收不到回调,线程池的原因请求压没有得到执行,故收不到回调,那为啥就request2()方法会出问题呢?其实这是一种假象,只要被回收的线程池里还有未完成的任务,那么该线程池再次执行请求,都必须得等待。如果账号在60s内重复被挤3次,那么登录到首页后,3个请求都将得不到执行,因为回收池得3个线程池都不能再执行任务了,直到60s后,被计时器强制关闭并移除。

最后,提醒大家,一定要慎用Looper,不是任何时候都适合用Looper的,像妹子遇到的这种场景,完全可以用主线程的Handler post一个消息出去,然后处理业务,亦或者通过EventBus、LiveData等发送消息到主线程,再处理相关逻辑。

7、关于我

一名已经30出头的待失业老码农,热爱开源,致力持续分享Android开发进阶知识;另外,我还有个自己的群:Android进阶学习交流:977438066,截止目前有近1000人,里面经常会有技术交流,开车勿进,非诚勿扰,入群还可领取一套我整理的包含7个模块,2960页,58万字《全网最全Android开发笔记》!

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

推荐阅读更多精彩内容