安卓面试

Android性能调优(9)—异步任务

2018-05-07  本文已影响27人  godliness

一、前言

在程序开发的实践当中,为了让程序表现得更加流畅,我们通常会使用到多线程来提升程序的并发执行性能。但是编写多线程的代码一直以来都是一个相对棘手的问题,所以想要获得更佳的程序性能,我们非常有必要掌握多线程并发编程的基础技能。

 一旦我们在UI线程里面添加了耗时的任务,这些代码就很可能阻碍主线程去响应点击滑动事件,阻碍主线程的UI绘制等等。为了让屏幕的刷新帧率达到60fps,我们需要确保16ms内完成单次刷新的操作。一旦我们在主线程里面执行的任务过于繁重就可能导致接收到刷新信号的时候因为资源被占用而无法完成这次刷新操作,这样就会产生掉帧的现象,用户就可以明显感知到卡顿不流畅。

线程调度器是操作系统的一部分,负责决定系统中哪些线程应该运行,何时以及多长时间。Android的线程调度器使用两个主要因素来确定线程如何在整个系统中调度:Nice Value和Cgroups

本文将概述如何在Android中进行线程调度,并简要演示如何显式地设置线程优先级,以确保即使多线程在后台运行,应用程序仍然响应。

二、Android中线程调度策略

Android中主要有两个因素来完成线程调度机制:Nice ValuesCgroups

荐:https://www.androiddesignpatterns.com/2014/01/thread-scheduling-in-android.html

1、Nice Values:

在Android中Nice Vlues作用是用来衡量线程优先级的一种度量,具有较高nice值的线程(既较低的优先级),将比具有较低nice值(既较高优先级)的线程运行次数少,其中Android平台最重要的两个线程优先级是:defaultbackground线程优先级。

线程的优先级应该与线程预期要做的工作量成反比:线程执行的工作越多,线程的优先级应该越低,从而不会过多与UI线程争夺资源。出于这个原因,UI线程通常被赋予default级别优先级,而工作线程通常被赋予background级别优先级。

Nice values在理论上很重要,因为他们减少了后台工作线程中断UI线程的可能性。 但在实践中,只有Nice values并不足够。例如,存在20个后台线程和一个单独的执行UI的前台线程。虽然他们每个的优先级很低,但是合起来这个20个后台线程将影响前台线程的性能,结果就是损害了用户体验。因为在任何时刻几个应用程序可能已经有等待运行的后台线程,Android OS必须以某种方式处理这些问题—Cgroups。

2、Cgroups:

为了处理这个问题,Android系统使用Linux cgroups(Linux内核的一个功能,用来限制,控制与分离一个进程组群的资源)强制执行更严格的foreground、background调度策略。background优先级的线程被隐式的移动到了background cgroup,当其它组中的线城处于工作状态,它们被限制只有很小的几率(5%到10%)利用CPU。这种分离允许后台线程执行一些任务,但不会对用户可见的前台线程产生较大的影响。

除了自动将低优先级线程分配给background cgroup,Android也将当前不在前台运行的应用程序的线程移动到background cgroup中。将应用程序线程自动分组保证了当前前台线程总是优先的,无论有多少应用程序在后台运行。

三、Thread与Memory

线程数量的增加会导致内存的消耗增加,同时系统会花费更多时间在线程上下文切换上,而不是在真正执行业务代码上。所以平衡好这两者的关系是非常重要的。

同时多线程访问共享资源也会带来很多问题,例如内存可见性问题,ABA问题等。在Android系统中也会面临上面提到的种种问题。

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

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

为了解决上面的问题,我们需要谨记的原则就是:不要在任何非UI线程里面去持有UI对象的引用。系统为了确保所有的UI对象都只会被UI线程所进行创建,更新,销毁的操作,特地设计了对应的工作机制(当Activity被销毁的时候,由该Activity所触发的非UI线程都将无法对UI对象进行操作,否者就会抛出程序执行异常的错误)来防止UI对象被错误的使用。

四、Android提供的异步方式

1、Thread

2、AsyncTask

3、HandlerThread

4、IntentService

5、ThreadPoolExceutor

接下来我们将逐一探讨它们分别适合的场景

五、Thread

Thread是所有异步的基础,不管使用什么样的工具完成,他的内部仍然离不开Thread。

通常来说,一个线程需要经历三个生命阶段:开始,执行,结束。线程会在任务执行完毕之后结束,那么为了确保线程的存活,我们会在执行阶段给线程赋予不同的任务,然后在里面添加退出的条件从而确保任务能够执行完毕后退出。

这是最简单的异步方式,每当我们的代码中存在这样开启异步任务的方式我们都应该修改掉它。

那么它到底带来了哪些问题呢?

1、线程的创建与销毁不是无性能损耗的,相反却比较大,这种方式是不可取的。

2、缺乏统一的线程管理。

3、容易造成内存泄漏(匿名内部类默认持有外部类的引用)。

4、在Android中线程调度策略中我们有提到过(将会与UI线程处于同一级别争夺CPU资源)。

单个Thread的创建时间点要要优于ThreadPoolExecutor,在要求创建速度的地方如果要使用到它:

六、AsyncTask

AsyncTask是一个让人既爱又恨的组件,它提供了一种简便的异步处理机制,但是它又同时引入了一些令人厌恶的麻烦。一旦对AsyncTask使用不当,很可能对程序的性能带来负面影响,同时还可能导致内存泄露。

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

为了解决上面提到的线性队列等待的问题,我们可以使用AsyncTask.executeOnExecutor()强制指定AsyncTask使用线程池并发调度任务。

为AsyncThask指定一个并行的的线程池:

AsyncThask优点:

创建异步任务变得更加简单,同时屏蔽了线程切换。

AsyncTask.java中我们可以看到,异步线程的优先级已经被默认设置成了:THREAD_PRIORITY_BACKGROUND,不会与UI线程抢占资源。

AsyncTask缺点:

Api实现版本不一致问题:在Android1.5时AsyncTask的执行是串行的,在API1.5—3.0之间AsyncTask是并行的,而到了Android3.0之后AsyncTask的执行又回归到了串行。当然目前我们兼容的最低版本一般都会是最低4.0,那么就不需要对其进行过多的自定义适配,但是一定要注意AsyncTask默认是串行的,用于多线程场景下的话需要调用其重载方法executeOnExecutor()传入自定义的线程池,并且自己处理好同步问题;

还有要特别注意匿名内部类默认持有外部类的引用,有内存泄漏的风险。

七、HandlerThread

大多数情况下,AsyncTask都能够满足多线程并发的场景需要(在工作线程执行任务并返回结果到主线程),但是它并不是万能的。

HandlerThread它组合了Handler,MessageQueue,Looper实现了一个长时间运行的线程,不断的从队列中获取任务进行执行的功能。

简单看下HandlerThread源码:

其实这个问题是如果在子线程中使用Handler该如何处理?HandlerThread就是我们提供了这样一个API。

HandlerThread的优点:

串行执行,没有并发带来的问题;

不退出的前提下一直存在,避免线程相关的对象频繁重建和销毁造成的资源消耗。

HandlerThread的缺点:

串行执行(不同的视角优点也变缺点),并发场景下无能为力;

不指定优先级的情景下默认优先级为THREAD_PRIORITY_DEFAULT,与UI线程同级别。

HandlerThread的正确使用姿势:串行场景,并在构造方法中明确指定优先级。

八、IntentService

 默认的Service是执行在主线程的,可是通常情况下,这很容易影响到程序的绘制性能(抢占了主线程的资源)。除了前面介绍过的AsyncTask与HandlerThread,我们还可以选择使用IntentService来实现异步操作。IntentService继承自普通Service同时又在内部创建了一个HandlerThread,在onHandlerIntent()的回调里面处理扔到IntentService的任务。所以IntentService就不仅仅具备了异步线程的特性,还同时保留了Service不受主页面生命周期影响的特点。

来看下IntentService源码:

通过源码可以看出:

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

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

IntentService的优点:

同HandlerThread的优势;

开启服务,进程优先级会提升;

无需手动关闭,执行完之后自动结束。

IntentService通常适用于与UI无关的操作。

九、ThreadPoolExecutor

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

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

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

ThreadPoolExecutor的优点:

线程的创建和销毁由线程池维护,一个线程在完成任务后并不会立即销毁,而是由后续的任务复用这个线程,从而减少线程的创建和销毁,节约系统的开销;

线程池旨在线程的复用,这就可以节约我们用以往的方式创建线程和销毁所消耗的时间,减少线程频繁调度的开销,从而节约系统资源,提高系统吞吐量;

在执行大量异步任务时提高了性能;

同时Java内置的一套ExecutorService线程池相关的api,可以更方便的控制线程的最大并发数、线程的定时任务、单线程的顺序执行等。

十、总结

使用线程池需要特别注意同时并发线程数量的控制。因为CPU只能同时执行固定数量的线程数,一旦同时并发的线程数量超过CPU能够同时执行的阈值,CPU就需要花费精力来判断到底哪些线程的优先级比较高,在不同的线程之间进行调度切换。一旦同时并发的线程数量达到一定的量级,CPU在不同线程之间进行调度的时间就可能过长,反而导致性能严重下降;

每开一个新的线程,都会耗费至少64K以上的内存。线程池中存在了过多的并发数量不仅会影响CPU的调度时间而且会减少可用内存;

线程的优先级具有继承性,在某线程中创建的线程会继承此线程的优先级。那么我们在UI线程中创建了线程池,其中的线程优先级是和UI线程优先级一样的;所以仍然可能出现20个同样优先级的线程平等的和UI线程抢占资源。

Thread、AsyncTask适合处理单个任务的场景;

HandlerThread适合串行处理多任务的场景;

IntentService适合处理与UI无关的多任务场景;

当需要并行的处理多任务之时,ThreadPoolExecutor是更好的选择,当然也可以使用AsyncTask传入自定义的线程池

注意线程优先级的设置;

特别注意对不同场景下异步方式的选择。

上一篇下一篇

猜你喜欢

热点阅读