1. 什么是主线程
应用启动时系统会为应用创建一个名为“主线程”的执行线程,这个线程负责将事件分派给相应的用户界面,也会与UI组件交互。
“主线程”是不能被阻塞的,一旦被阻塞,将无法分派任何事件。从用户的角度看会显示应用被挂起,如果挂起5秒钟,用户会看到ANR的对话框。
我们知道,为了让屏幕的刷新帧率达到 60fps,我们需要确保 16ms 内完成单次刷新的操作。一旦我们在主线程里面执行的任务过于繁重就可能导致接收到刷新信号的时候因为资源被占用而无法完成这次刷新操作,这样就会产生掉帧的现象,刷新帧率自然也就跟着下降了(一旦刷新帧率降到 20fps 左右,用户就可以明显感知到卡顿不流畅了)。
Android ui控件包是非线程安全的,我们不能在工作线程操作界面,所以只能在UI线程操作用户界面。因此,android的单线程模式必须遵守两条规则:
- 不要阻塞UI线程
- 不要在UI线程外访问android ui控件包
Android的主线程执行,我们常常会使用handler。
1 |
Runnable task = getTask(); new Handler(Looper.getMainLooper()).post(task); |
2. 什么是后台任务
根据上述单线程模式,要保证应用 UI 的响应能力,关键是不能阻塞 UI 线程。如果执行的操作不能很快完成,则应确保它们在单独的线程(“后台”或“工作”线程)中运行。
线程在使用过程中需要关注它的优先级和并发数这两个关键的属性。
2.1 线程优先级
线程优先级对于后台任务影响是非常重要的,它在平台里面的表现会比较特殊,我们知道线程的优先级默认会与创建它的线程是一致的,也就是说如果创建这个任务的线程是主线程,那么他就和主线程的优先级会一致,这样会导致严重的问题:它会和主线程抢CPU资源。我们知道Android 系统会根据当前运行的可见的程序和不可见的后台程序对线程进行归类,划分为 forground 的那部分线程会大致占用掉 CPU 的90%左右的时间片,background 的那部分线程就总共只能分享到5%-10%左右的时间片,所以控制好线程数量和优先级在app后台任务中是非常关键的。
2.2 线程并发数
与UI线程无关的操作都要放在工作线程中,这样会带来一个新的问题就是线程数量过大。CPU只能执行固定数量的线程数,一旦并发的线程数超过CPU可以同时执行的阀值,CPU就需要花费时间判断线程的优先级,并在不同的线程之间进行调度切换,线程数量越多,这个耗时也就越长,导致性能严重下降。
线程数量的增加会增加内存消耗,每新创建一个线程,都会耗费至少64K+的内存,ThreadPoolExecutor提供了初始化的并发线程数量,以及最大的并发数量进行设置。
值得注意的是我们常常会使用availableProcessors()来获取可以使用的CPU数量,不过这样并不准确,因为这个方法只会返回正在活跃的CPU数量,一些睡眠状态的CPU是不会统计的。正确获取CPU数量的方法如下
手机的CPU信息记录在”sys/devices/system/cpu目录下面, 这个方法是列出了手机中实际的CPU数量,目录结构如下:
3. Android后台任务常见的使用场景
Android app中与UI无关的操作都会放在线程中处理,随着业务的膨胀,app会越来越臃肿,导致程序运行缓存,性能低。我们需要对app中后台任务经常使用的场景逐个分析,找到我们的诉求和一些使用的误区,总结并帮助我们更好的管理这些后台任务。
3.1 常用后台任务工具类
App是以业务为核心的应用程序,业务庞大的app会存在很多这样的与UI无关但与业务功能相关的后台任务。在android平台我们没必要自己new Thread来执行,Android已经默认提供了很多了工具类来支持,例如ExecutorService, AsyncTask, Loader, HandlerThread等,下面来逐个介绍。
3.1.1 ExecutorService
线程池管理类,能管理并发线程数和线程的执行顺序
具体流程如下:
1)当池子大小小于corePoolSize就新建线程,并处理请求。(注意:corePoolSize是线程池中一直存活的线程数,即使它们空闲,也会保留在线程池里面,除非设置了allowCoreThreadTimeOut)
2)当池子大小等于corePoolSize,把请求放入workQueue中,池子里的空闲线程就去从workQueue中取任务并处理
3)当workQueue放不下新入的任务时,新建线程入池,并处理请求,如果池子大小撑到了maximumPoolSize就用RejectedExecutionHandler来做拒绝处理
4)另外,当池子的线程数大于corePoolSize的时候,由于corePoolSize数量的线程是持续保留的,多出的线程会等待keepAliveTime长的时间,如果无任务可处理就自行销毁
优点:
- 线程数控制。 ThreadPoolExecutor提供了初始化的并发线程数量,通过ThreadPoolExecutor的corePoolSize我们可以设置在CPU中一直活跃的线程数,这样解决了线程频繁创建的性能消耗,同时结合corePoolSize和线程队列,可以让新增的后台任务在corePoolSize已经满的情况下加入到队列中等待,解决了CPU调度切换性能问题。
- 线程执行顺序控制。当corePoolSize已满时新增的任务会在指定的队列中排队,这个队列可以是任意的BlockingQueue,例如你可以用PriorityBlockingQueue。
缺点:
- 后台任务的生命周期控制较弱。它的Shutdown方法会阻止接收新任务,并等待此前在线程池中的任务执行完才会退出。它的另一个方法shutdownnow会尝试停止正在运行中的任务,并停止处理等待中的任务,这个方法是通过interrupt()来实现,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt()方法是无法中断当前的线程的。
3.1.1.1 ThreadPoolExecutor常见的使用方式
3.1.1.2、CachedThreadPool
1 2 3 |
public static ExecutorService newCachedThreadPool(){ return new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.MILLISECONDS,new SynchronousQueue<Runnable>()); } |
- 它是一个可以无限扩大的线程池;
- 它比较适合处理执行时间比较小的任务;
- corePoolSize为0,maximumPoolSize为无限大,意味着线程数量可以无限大;
- keepAliveTime为60S,意味着线程空闲时间超过60S就会被杀死;
- 采用SynchronousQueue装等待的任务,这个阻塞队列没有存储空间,这意味着只要有请求到来,就必须要找到一条工作线程处理他,如果当前没有空闲的线程,那么就会再创建一条新的线程。
SynchronousQueue 内部没有容量,但是由于一个插入操作总是对应一个移除操作,反过来同样需要满足。那么一个元素就不会再SynchronousQueue 里面长时间停留,一旦有了插入线程和移除线程,元素很快就从插入线程移交给移除线程。也就是说这更像是一种信道(管道),资源从一个方向快速传递到另一方 向。显然这是一种快速传递元素的方式,也就是说在这种情况下元素总是以最快的方式从插入着(生产者)传递给移除着(消费者),这在多任务队列中是最快处理任务的方式。在线程池里的一个典型应用是Executors.newCachedThreadPool()就使用了SynchronousQueue,这个线程池根据需要(新任务到来时)创建新的线程,如果有空闲线程则会重复使用,线程空闲了60秒后会被回收。
3.1.2 AsyncTask
AsyncTask简单的说是处理了工作线程和UI交互。AsyncTask适合处理短暂的后台任务并更新UI的场景。
注意AsyncTask在使用过程中会有一些陷阱:
- 生命周期导致的异常。当activity停止的时候,asynctask仍然会在后台运行,执行完成返回时会操作已经销毁的activity,导致出错。
- 内存泄漏。Asynctask常常作为内部类放在Activity或Fragment里面,当它在UI线程执行onPostExecute,它可以很方便的操作外部类的UI资源。这样确实很方便,不过它同时也持有了外部类的引用,如果Activity或Fragment生命周期结束,它的实例可能仍然被AsyncTask持有无法及时释放。
- 耦合会比较高,重用性低。AsyncTask是一个工具类,较轻量级,适合短时间的后台任务。它常常通过OuterClass的实例来操作UI界面的方式来实现回调UI线程的,往往使用的时候需要代码的耦合会比较高,导致重用性低。
3.1.3 Loader
Loader是在android3.0版本引入的,在support也提供了支持。它很好的解决了activity生命周期与thread之间的关系问题。
通常在Thead创建时可能会持有activity的引用,在activity生命周期结束后thread仍在执行,此时会导致activity无法释放内存,造成内存泄漏。另外activity生命周期外thread去操作界面也是毫无意义的。
一般场景下activity与thread的关系:
使用loader时,activity与thread的关系:
Loader的优点:
a)解决潜在的内存泄漏问题
在loader启动前检查loader如果是内部类则不能是非静态内部类,如果是非静态内部类则抛错,避免了loader持有activity或fragment的引用。
b) 绑定了Activity的生命周期
在configuration改变导致activity重新创建后,仍然能从缓存中获取运行中的后台任务
Configuration改变导致activity销毁时会调用onRetainNonConfigurationInstance,这个方法可以存储任意Object,甚至可以是activity本身。当activity以新的configuration重新创建后,在新的activity里面可以通过getLastNonConfigurationInstance() 获取这些对象。
3.1.3.1 LoaderManager的类图
Loader是一个用于处理后台任务的类,它有一些默认实现,如AsyncTaskLoader。Loader会以LoaderInfo的形式在LoaderManager里面存在,而LoaderManager则与Activity的生命周期绑定在一起了。这样很方便的管理后台任务的启动和停止。
3.2 低优先级任务执行
app经常有一些长时间且优先级低的任务需要处理(例如文件扫描,日志打点和上传,数据落地等),我们往往会用单个低优先级的线程和队列去处理。Android为我们提供了HandlerThread,恰好能满足这个要求。HandlerThread利用了Handler的队列特性,让任务排队在一个设置了优先级的线程中处理。
使用例子:
1 |
HandlerThread workerThread = new HandlerThread("LightTaskThread", Process.THREAD_PRIORITY_BACKGROUND); |
官方API介绍:
3.3 定时任务
使用场景:除了优先级和线程数量外,任务的执行方式也会有不同,典型的例子是启动时的初始化,定时从网络拉取数据,动画特效等,这些任务需要考虑延时处理和顺序的依赖, 或者循环执行。定时类型包括:延时执行,循环执行。
Android常见的定时任务工具类有四种
- Handler– 可以让一个任务延时一段时间后在UI线程执行
- ScheduledThreadPoolExecutor– 用后台线程池执行定时或周期性任务
- AlarmManager– 可以在后台服务器执行任意的定时任务
- Timer– 结合Timer一起使用的定时任务,单独在一个线程执行。在android平台使用可能会存在内存泄漏等不正确的行为。
3.3.1 Handler
Handler允许你发一个Message或者一个Runnable到一个线程的消息队列中(MessageQueue)。每一个Handler实例都会包括一个线程和一个与之绑定的消息队列。
Handler有两种使用场景:
- 让一个任务延时执行
- 让一个任务在另一个不同的线程中执行
当一个application的进程创建时,它的主线程会只运行一个Message Queue来管理application顶层的对象(例如:activities, broadcast receivers)和所有他们创建的window。你也可以创建你自己的线程,然后通过Handler与Application的主线程进行通信。
3.3.2 ScheduledThreadPoolExecutor
这是一个可以延时,周期执行的ThreadPoolExecutor。如果有多线程任务,或者需要ThreadPoolExecutor的一些额外的功能和灵活性,它会比Timer好很多。
已经添加的定时任务会在启用的时候立即执行,但是不会有实时的保证什么时候去执行,例如如果两个任务都在同一个时间点执行,那么他们会根据任务提交的顺序先进先出(FIFO)执行。
如果在任务被执行前主动取消了这个任务,那么这个任务的执行将会暂停,默认情况下,这个取消的任务不会从队列中移除直到他们的时间过期,这样保证了这个任务后续的检查和监控,但是可能会引起另一个问题,就是可能无限期的保留这些被取消的任务。
通过 scheduleAtFixedRate or scheduleWithFixedDelay 方法处理的连续的周期任务不会重叠,虽然它们可能会在不同的线程中去处理,但是他们的会按先后顺序执行。
如果把ScheduledThreadPoolExecutor设置为单线程,那么它和Timer是一样的。
3.3.3 AlarmManager
可以在app不在前台的时候仍然可以执行定时任务,它是利用手机的alarm service来实现的。但是如果手机关机或重启,这些定时任务将会被清理。AlarmManager的一个主要使用场景是app的任务需要在一个特定的时间去运行,即便app当前没有运行。
需要特别注意:从API19(KITKAT)开始这个闹钟是不准确的,因为系统将会推迟闹钟目的是减少唤醒次数和电池消耗。AlarmManager也提供了新的API来确保如果app需要精确的时间唤醒,参考:setWindow(int, long, long, PendingIntent) 和 setExact(int, long, PendingIntent)
app的targetversion在19之前的闹钟仍然是精准执行的。
3.3.4 Timer
它是单线程的,且所有的任务都是顺序执行。
Timer的这个特性就要求它的的任务执行需要快,不然会阻塞Timer的执行线程,这样会使Timer中接下来的任务都会跟着延时,当这个任务完成时可能导致后面堆积的任务紧跟着连续执行。
当Timer中所有的任务都执行完成后,Timer的执行线程会终止且被回收。
Timer是线程安全的,这意味着多个线程可以共用一个Timer实例,而不需要加同步锁。
如上面所说Timer不保证任务执行的实时性,它简单的用 Object.wait(long) 来实现延时处理的。
注意:Timer中可以添加任意多的任务数(上千的任务数是没有问题的)。Timer内部使用二进制堆来存放任务队列的,所以Timer的寻找一个任务的成本是O(log n),n是任务数
3.4 长时间的后台任务
Service是android提供的可以一直在后台运行的组件,它在显示调用startService后启动,并一直运行,除非通过stopService停止。相比于app里面的后台任务,它的生命周期就长很多,更不容易被系统杀死,该程序的优先级是介于前台程序与纯后台程序之间的,用于处理耗时很长的后台任务(例如:下载等)。
4. 有哪些已有的框架
4.1 Priority Job Queue
这个框架涵盖了后台任务常见的场景,如线程优先级,线程并发数,定时,延时和顺序执行,它也很方便的提供了启动和取消操作方便在app生命周期内进行操作。作为线程框架它不仅仅只有这些,它可以配置里面的任何属性,例如你可以用自己的定时器,自己的日志打印等,此外也可以根据当前的网络环境决定是否执行和失败后重试的控制,是不是很强大,我们来分析下这个框架的实现:
4.1.1 框架类图
4.1.2 时序图
这个框架是个典型的生产者消费者模式,新增的job按照优先级插入到优先级队列中,并且可以配置cosumer的数量来处理队列中的任务并发数量。
4.1.3 框架分析
优点:
- 有基本的优先级队列和对应的消费者池
- 所有的任务都在一个JobManager中统一管理,可以批量终止,例如取消等操作
- Job是面向接口的,代码可复用性高
- 可配置性高,所有可能的任务操作都可以配置,包括:日志打印,线程创建,定时等。
缺点:
- 代码大部分代码没有使用平台默认的线程池的实现,且可拓展性高导致代码量较大,包含70个java文件
具体实现分析:
- android平台已经有了现成的组件实现优先级和线程数控制,为何需要自己重复造轮子呢?
它的优先级队列为啥不用PriorityBlockingQueue?
有两个原因:
- 首先是因为它的后台任务是可以落地的,在运行过程中会包含本地和内存两个队列。
- 其次框架中对任务的操作是按照命令模式设计的,添加任务只是其中的一个命令,它还包括很多其它的命令,如:定时,结果返回,网络环境改变等配置变更,取消任务等。相比普通的任务队列,这些命令也需要单独按优先级处理。
它的线程并发数量控制为啥不用ThreadPoolExecutor?
也是有两个原因:
- 消费者线程空闲后不会直接从任务队列取数据,而是发一个命令到JobMangerThread等待它调配,因为此时可能有更重要的命令需要在这之前执行。
- 更重要的是这个框架的任务是有重试机制的,而ThreadPoolExecutor不支持。
- JobManagerThread为啥不用 PriorityBlockingQueue ?
JobManagerThread用了一个队列数组类区存储不同优先级的消息队列,具体代码如下:
这样有两个的优点:
- 效率高
JobManagerThread的消息优先级只有4个级别,在级别不多的情况使用数组比用单个队列进行排序效率高很多。
b. 它的定时任务和非定时任务都在同一个队列里面,类似ScheduledThreadPoolExecutor的实现。
4.2 android-job
android-job是一个 把android多个定时任务的实现集中在一起实现在不同手机环境都能兼容的框架。它依赖JobScheduler,GcmNetworkManager, AlarmManager,这个3个都有他们的优点和缺点:AlarmManager 在不同的android版本都有调整;JobScheduler 在android 5.0开始支持;GcmNetworkManager只有在有google play service的手机上才能运行。这么看来如果想要使用android支持的这些定时框架不是一件容易的事情,而android-job很好的解决了这些兼容的问题。
Android支持的这些定时组件在priority-job-queue框架中也可以轻易适配。
5. 总结
对于长生命周期的后台任务和低优先级的后台任务,我们可以用IntentService和HandlerThread处理。而普通任务和定时任务,对于业务不复杂的场景建议使用ThreadPoolExecutor+PriorityBlockingQueue处理,这样会比较轻量级,几个类就搞定,效果也能达到预期。
对于业务庞大的app,如果使用ThreadPoolExecutor+PriorityBlockingQueue,就显得单薄,它无法保证应用退出后有多少后台任务仍然在执行,而且一些后台任务也很难复用,在例如app快速启动这样对后台任务分组和顺序依赖较强的场景,priority-job-queue框架是一个不错的选择。如果考虑apk size的问题,可以裁剪掉Priority-job-queue框架中的定时器兼容逻辑和后台任务本地存储逻辑的逻辑,或者其他不需要的内容。