0%

Android之多线程

Android 程序的大多数代码操作都必须执行在主线程,例如:系统事件(例如设备屏幕发生旋转),输入事件(例如用户点击滑动等),程序回调服务,UI 绘制以及闹钟事件等等。那么我们在上述事件或者方法中插入的代码也将执行在主线程

一旦我们在主线程里面添加了操作复杂的代码,这些代码就很可能阻碍主线程去响应点击/滑动事件,阻碍主线程的 UI 绘制等等。

我们知道,为了让屏幕的刷新帧率达到 60fps,我们需要确保 16ms 内完成单次刷新的操作。一旦我们在主线程里面执行的任务过于繁重就可能导致接收到刷新信号的时候因为资源被占用而无法完成这次刷新操作,这样就会产生掉帧的现象,刷新帧率自然也就跟着下降了(一旦刷新帧率降到 20fps 左右,用户就可以明显感知到卡顿不流畅了)。

1. Android 系统为我们提供的若干组工具类

1.1. AsyncTask

为 UI 线程与工作线程之间进行快速的切换提供一种简单便捷的机制。适用于当下立即需要启动,但是异步执行的生命周期短暂的使用场景。

默认情况下,所有的 AsyncTask 任务都是被线性调度执行的,他们处在同一个任务队列当中,按顺序逐个执行。假设你按照顺序启动20个 AsyncTask,一旦其中的某个 AsyncTask 执行时间过长,队列中的其他剩余 AsyncTask 都处于阻塞状态,必须等到该任务执行完毕之后才能够有机会执行下一个任务。

如何才能够真正的取消一个 AsyncTask 的执行呢?我们知道 AsyncTaks 有提供 cancel()的方法,但是这个方法实际上做了什么事情呢?线程本身并不具备中止正在执行的代码的能力,为了能够让一个线程更早的被销毁,我们需要在 doInBackground()的代码中不断的添加程序是否被中止的判断逻辑,一旦任务被成功中止,AsyncTask 就不会继续调用 onPostExecute(),而是通过调用 onCancelled()的回调方法反馈任务执行取消的结果。我们可以根据任务回调到哪个方法(是 onPostExecute 还是 onCancelled)来决定是对 UI 进行正常的更新还是把对应的任务所占用的内存进行销毁等。

使用 AsyncTask 很容易导致内存泄漏,一旦把 AsyncTask 写成 Activity 的内部类的形式就很容易因为 AsyncTask 生命周期的不确定而导致 Activity 发生泄漏。

1.2. HandlerThread

为某些回调方法或者等待某些任务的执行设置一个专属的线程,并提供线程任务的调度机制。

HandlerThread 比较合适处理那些在工作线程执行,需要花费时间偏长的任务。我们只需要把任务发送给 HandlerThread,然后就只需要等待任务执行结束的时候通知返回到主线程就好了。

另外很重要的一点是,一旦我们使用了 HandlerThread,需要特别注意给 HandlerThread 设置不同的线程优先级,CPU 会根据设置的不同线程优先级对所有的线程进行调度优化。

1.3. IntentService

适合于执行由 UI 触发的后台 Service 任务,并可以把后台任务执行的情况通过一定的机制反馈给 UI。

首先,因为 IntentService 内置的是 HandlerThread 作为异步线程,所以每一个交给 IntentService 的任务都将以队列的方式逐个被执行到,一旦队列中有某个任务执行时间过长,那么就会导致后续的任务都会被延迟处理。

其次,通常使用到 IntentService 的时候,我们会结合使用 BroadcastReceiver 把工作线程的任务执行结果返回给主 UI 线程。使用广播容易引起性能问题,我们可以使用 LocalBroadcastManager 来发送在程序内部传递的广播,从而提升广播的性能。我们也可以使用 runOnUiThread() 快速回调到主 UI 线程。

最后,包含正在运行的 IntentService 的程序相比起纯粹的后台程序更不容易被系统杀死,该程序的优先级是介于前台程序与纯后台程序之间的

IntentService 继承自普通 Service 同时又在内部创建了一个 HandlerThread,在 onHandlerIntent()的回调里面处理扔到 IntentService 的任务。所以 IntentService 就不仅仅具备了异步线程的特性,还同时保留了 Service 不受主页面生命周期影响的特点。

1.4. ThreadPool

把任务分解成不同的单元,分发到各个不同的线程上,进行同时并发处理。

1.4.1. 线程池的基本概念

ThreadPoolExecutor有四个重载的构造方法

corePoolSize:线程池中核心线程的数量

maximumPoolSize:线程池中最大线程数量

keepAliveTime:非核心线程的超时时长,当系统中非核心线程闲置时间超过keepAliveTime之后,则会被回收。如果ThreadPoolExecutor的allowCoreThreadTimeOut属性设置为true,则该参数也表示核心线程的超时时长

unit:第三个参数的单位,有纳秒、微秒、毫秒、秒、分、时、天等

workQueue:线程池中的任务队列,该队列主要用来存储已经被提交但是尚未执行的任务。存储在这里的任务是由ThreadPoolExecutor的execute方法提交来的。

  • workQueue是一个BlockingQueue类型,它是一个特殊的队列,当我们从BlockingQueue中取数据时,如果BlockingQueue是空的,则取数据的操作会进入到阻塞状态,当BlockingQueue中有了新数据时,这个取数据的操作又会被重新唤醒。同理,如果BlockingQueue中的数据已经满了,往BlockingQueue中存数据的操作又会进入阻塞状态,直到BlockingQueue中又有新的空间,存数据的操作又会被冲洗唤醒。

  • 1.ArrayBlockingQueue 这个表示一个规定了大小的BlockingQueue,ArrayBlockingQueue的构造函数接受一个int类型的数据,该数据表示BlockingQueue的大小,存储在ArrayBlockingQueue中的元素按照FIFO(先进先出)的方式来进行存取。

  • 2.LinkedBlockingQueue 这个表示一个大小不确定的BlockingQueue,在LinkedBlockingQueue的构造方法中可以传一个int类型的数据,这样创建出来的LinkedBlockingQueue是有大小的,也可以不传,不传的话,LinkedBlockingQueue的大小就为Integer.MAX_VALUE

  • 3.PriorityBlockingQueue 这个队列和LinkedBlockingQueue类似,不同的是PriorityBlockingQueue中的元素不是按照FIFO来排序的,而是按照元素的Comparator来决定存取顺序的(这个功能也反映了存入PriorityBlockingQueue中的数据必须实现了Comparator接口)。

  • 4.SynchronousQueue 这个是同步Queue,属于线程安全的BlockingQueue的一种,在SynchronousQueue中,生产者线程的插入操作必须要等待消费者线程的移除操作,Synchronous内部没有数据缓存空间,因此我们无法对SynchronousQueue进行读取或者遍历其中的数据,元素只有在你试图取走的时候才有可能存在。我们可以理解为生产者和消费者互相等待,等到对方之后然后再一起离开。

threadFactory :为线程池提供创建新线程的功能,这个我们一般使用默认即可

handler:拒绝策略,当线程无法执行新任务时(一般是由于线程池中的线程数量已经达到最大数或者线程池关闭导致的)默认情况下,当线程池无法处理新线程时,会抛出一个RejectedExecutionException。

1.4.2. 线程池的运行规则

  • 1.execute一个线程之后,如果线程池中的线程数未达到核心线程数,则会立马启用一个核心线程去执行
  • 2.execute一个线程之后,如果线程池中的线程数已经达到核心线程数,且workQueue未满,则将新线程放workQueue中等待执行
  • 3.execute一个线程之后,如果线程池中的线程数已经达到核心线程数但未超过非核心线程数,且wrkQueue已满,则开启一个非核心线程来执行任务
  • 4.execute一个线程之后,如果线程池中的线程数已经超过非核心线程数,则拒绝执行该任

1.4.3. ThreadPool的使用

参考leeyou.xyz

1.4.4. 线程池其他常用功能

  • shutDown() 关闭线程池,不影响已经提交的任务
  • shutDownNow() 关闭线程池,并尝试去终止正在执行的线程
  • allowCoreThreadTimeOut(boolean value) 允许核心线程闲置超时时被回收
  • submit 一般情况下我们使用execute来提交任务,但是有时候可能也会用到submit,使用submit的好处是submit有返回值

1.4.5. 使用时要注意的几点

使用线程池需要特别注意同时并发线程数量的控制,理论上来说,我们可以设置任意你想要的并发数量,但是这样做非常的不好。因为 CPU 只能同时执行固定数量的线程数,一旦同时并发的线程数量超过 CPU 能够同时执行的阈值,CPU 就需要花费精力来判断到底哪些线程的优先级比较高,需要在不同的线程之间进行调度切换。

一旦同时并发的线程数量达到一定的量级,这个时候 CPU 在不同线程之间进行调度的时间就可能过长,反而导致性能严重下降。另外需要关注的一点是,每开一个新的线程,都会耗费至少 64K+ 的内存。为了能够方便的对线程数量进行控制,ThreadPoolExecutor 为我们提供了初始化的并发线程数量,以及最大的并发数量进行设置。

另外需要关注的一个问题是:Runtime.getRuntime().availableProcesser()方法并不可靠,他返回的值并不是真实的 CPU 核心数,因为 CPU 会在某些情况下选择对部分核心进行睡眠处理,在这种情况下,返回的数量就只能是激活的 CPU 核心数。

2. Android中的任务线程模型

Looper:能够确保线程持续存活并且可以不断的从任务队列中获取任务并进行执行。

Handler:能够帮助实现队列任务的管理,不仅仅能够把任务插入到队列的头部,尾部,还可以按照一定的时间延迟来确保任务从队列中能够来得及被取消掉。

MessageQueue:使用 Intent,Message,Runnable 作为任务的载体在不同的线程之间进行传递。

三个组件打包到一起进行协作,这就是 HandlerThread

HandlerThread

3. 平衡并发的线程数和内存消耗的问题

多线程并发访问同一块内存区域有可能带来很多问题,例如读写的权限争夺问题,ABA 问题等等。为了解决这些问题,我们会需要引入锁的概念。

3.1. Android中多线程引起的问题

Android UI 对象的创建,更新,销毁等等操作都默认是执行在主线程,但是如果我们在非主线程对UI对象进行操作,程序将可能出现异常甚至是崩溃。

在非 UI 线程中直接持有 UI 对象的引用也很可能出现问题。例如Work线程中持有某个 UI 对象的引用,在 Work 线程执行完毕之前,UI 对象在主线程中被从 ViewHierarchy 中移除了,这个时候 UI 对象的任何属性都已经不再可用了,另外对这个 UI 对象的更新操作也都没有任何意义了,因为它已经从 ViewHierarchy 中被移除,不再绘制到画面上了。

View 对象本身对所属的 Activity 是有引用关系的,如果工作线程持续保有 View 的引用,这就可能导致 Activity 无法完全释放。除了直接显式的引用关系可能导致内存泄露之外,我们还需要特别留意隐式的引用关系也可能导致泄露。例如通常我们会看到在 Activity 里面定义的一个 AsyncTask,这种类型的 AsyncTask 与外部的 Activity 是存在隐式引用关系的,只要 Task 没有结束,引用关系就会一直存在,这很容易导致 Activity 的泄漏。更糟糕的情况是,它不仅仅发生了内存泄漏,还可能导致程序异常或者崩溃。

我们需要谨记的原则就是:不要在任何非 UI 线程里面去持有 UI 对象的引用。

系统为了确保所有的 UI 对象都只会被 UI 线程所进行创建,更新,销毁的操作,特地设计了对应的工作机制(当 Activity 被销毁的时候,由该 Activity 所触发的非 UI 线程都将无法对UI对象进行操作,否者就会抛出程序执行异常的错误)来防止 UI 对象被错误的使用。

4. Loaders

当启动工作线程的 Activity 被销毁的时候,我们应该做点什么呢?

为了方便的控制工作线程的启动与结束,Android 为我们引入了 Loader 来解决这个问题。
我们知道 Activity 有可能因为用户的主动切换而频繁的被创建与销毁,也有可能是因为类似屏幕发生旋转等被动原因而销毁再重建。在 Activity 不停的创建与销毁的过程当中,很有可能因为工作线程持有 Activity 的 View 而导致内存泄漏(因为工作线程很可能持有 View 的强引用,另外工作线程的生命周期还无法保证和 Activity 的生命周期一致,这样就容易发生内存泄漏了)。除了可能引起内存泄漏之外,在 Activity 被销毁之后,工作线程还继续更新视图是没有意义的,因为此时视图已经不在界面上显示了。

Loaders

Loader 的出现就是为了确保工作线程能够和 Activity 的生命周期保持一致

  • LoaderManager 会对查询的操作进行缓存,只要对应 Cursor 上的数据源没有发生变化,在配置信息发生改变的时候(例如屏幕的旋转),Loader 可以直接把缓存的数据回调到 onLoadFinished(),从而避免重新查询数据。另外系统会在 Loader 不再需要使用到的时候(例如使用 Back 按钮退出当前页面)回调 onLoaderReset()方法,我们可以在这里做数据的清除等等操作。

  • 在 Activity 或者 Fragment 中使用 Loader 可以方便的实现异步加载的框架,Loader 有诸多优点。但是实现 Loader 的这套代码还是稍微有点点复杂,Android 官方为我们提供了使用 Loader 的示例代码进行参考学习。

5. 线程优先级的重要性

Android 系统会根据当前运行的可见的程序和不可见的后台程序对线程进行归类,划分为 forground 的那部分线程会大致占用掉 CPU 的90%左右的时间片,background 的那部分线程就总共只能分享到5%-10%左右的时间片。之所以设计成这样是因为 forground 的程序本身的优先级就更高,理应得到更多的执行时间。

默认情况下,新创建的线程的优先级默认和创建它的母线程保持一致。如果主 UI 线程创建出了几十个工作线程,这些工作线程的优先级就默认和主线程保持一致了,为了不让新创建的工作线程和主线程抢占 CPU 资源,需要把这些线程的优先级进行降低处理,这样才能给帮助 CPU 识别主次,提高主线程所能得到的系统资源。

在 Android 系统里面,我们可以通过 android.os.Process.setThreadPriority(int) 设置线程的优先级,参数范围从-20到19,数值越小优先级越高。Android 系统还为我们提供了以下的一些预设值,我们可以通过给不同的工作线程设置不同数值的优先级来达到更细粒度的控制。

线程优先级

Android 系统里面的 AsyncTask 与 IntentService已经默认帮助我们设置线程的优先级,但是对于那些非官方提供的多线程工具类,我们需要特别留意根据需要自己手动来设置线程的优先级。

线程优先级

线程优先级

6. 工具篇

从 Android M 系统开始,系统更新了 GPU Profiling 的工具来帮助我们定位 UI 的渲染性能问题。早期的 CPU Profiling 工具只能粗略的显示出 Process,Execute,Update 三大步骤的时间耗费情况。但是仅仅显示三大步骤的时间耗费情况,还是不太能够清晰帮助我们定位具体的程序代码问题,所以在 Android M 版本开始,GPU Profiling 工具把渲染操作拆解成8个详细的步骤进行显示。

Sync & Upload:通常表示的是准备当前界面上有待绘制的图片所耗费的时间,为了减少该段区域的执行时间,我们可以减少屏幕上的图片数量或者是缩小图片本身的大小。

Measure & Layou:这里表示的是布局的 onMeasure 与 onLayout 所花费的时间,一旦时间过长,就需要仔细检查自己的布局是不是存在严重的性能问题。

Animation:表示的是计算执行动画所需要花费的时间,包含的动画有 ObjectAnimator,ViewPropertyAnimator,Transition 等等。一旦这里的执行时间过长,就需要检查是不是使用了非官方的动画工具或者是检查动画执行的过程中是不是触发了读写操作等等。

Input Handling:表示的是系统处理输入事件所耗费的时间,粗略等于对于的事件处理方法所执行的时间。一旦执行时间过长,意味着在处理用户的输入事件的地方执行了复杂的操作。

Misc/Vsync Delay:如果稍加注意,我们可以在开发应用的 Log 日志里面看到这样一行提示:I/Choreographer(691): Skipped XXX frames! The application may be doing too much work on its main thread。这意味着我们在主线程执行了太多的任务,导致 UI 渲染跟不上 vSync 的信号而出现掉帧的情况。