通过多线程提升性能

熟练运用 Android 上的多线程可以帮助您提升应用的性能。此页面讨论了使用线程的几个方面:使用 UI 或主线程;应用生命周期与线程优先级之间的关系;以及平台提供的用于帮助管理线程复杂性的方法。在这些方面,此页面都描述了潜在的陷阱以及避免这些陷阱的策略。

主线程

当用户启动您的应用时,Android 会创建一个新的Linux 进程以及一个执行线程。这个**主线程**,也称为 UI 线程,负责屏幕上发生的一切。了解其工作原理可以帮助您设计应用,以便最佳地利用主线程来获得最佳性能。

内部机制

主线程设计非常简单:它的唯一工作是从线程安全的作业队列中获取并执行工作块,直到应用终止。框架会从各种地方生成一些这些工作块。这些地方包括与生命周期信息关联的回调、用户事件(例如输入)或来自其他应用和进程的事件。此外,应用也可以自行显式地将块入队,而无需使用框架。

您的应用执行的几乎任何代码块都与事件回调相关联,例如输入、布局膨胀或绘制。当某些内容触发事件时,发生事件的线程会将其自身推送出事件,并将其放入主线程的消息队列中。然后,主线程可以处理该事件。

在动画或屏幕更新发生期间,系统尝试每 16 毫秒左右执行一个工作块(负责绘制屏幕),以便以每秒 60 帧的速度流畅渲染。为了实现此目标,UI/View 层次结构必须在主线程上更新。但是,当主线程的消息队列包含的任务过多或过长,以至于主线程无法快速完成更新时,应用应将此工作移动到工作线程。如果主线程无法在 16 毫秒内完成执行工作块,用户可能会观察到卡顿、滞后或 UI 对输入响应迟缓。如果主线程阻塞约五秒钟,系统会显示应用无响应 (ANR) 对话框,允许用户直接关闭应用。

将大量或长时间运行的任务从主线程移动,这样它们就不会干扰流畅的渲染和对用户输入的快速响应,这是您在应用中采用多线程的最大原因。

线程和 UI 对象引用

根据设计,Android View 对象不是线程安全的。应用应在主线程上创建、使用和销毁 UI 对象。如果您尝试在主线程以外的线程中修改甚至引用 UI 对象,则可能导致异常、静默故障、崩溃和其他未定义的错误行为。

引用问题分为两大类:显式引用和隐式引用。

显式引用

非主线程上的许多任务的最终目标都是更新 UI 对象。但是,如果这些线程之一访问视图层次结构中的对象,则可能导致应用不稳定:如果工作线程在任何其他线程引用该对象的同时更改该对象的属性,则结果是未定义的。

例如,考虑一个在工作线程上持有对 UI 对象的直接引用的应用。工作线程上的对象可能包含对View的引用;但在工作完成之前,View已从视图层次结构中移除。当这两个操作同时发生时,引用会将View对象保留在内存中并在其上设置属性。但是,用户永远看不到此对象,并且应用在对它的引用消失后会删除该对象。

在另一个示例中,View对象包含对其所属 activity 的引用。如果该 activity 被销毁,但仍然存在引用它的线程工作块(直接或间接),则垃圾回收器将不会回收该 activity,直到该工作块完成执行。

这种情况可能在屏幕旋转等某些 activity 生命周期事件发生时存在线程工作的情况中造成问题。系统在挂起的进程完成之前无法执行垃圾回收。结果,在垃圾回收发生之前,内存中可能存在两个Activity对象。

对于这种情况,我们建议您的应用不要在带线程的工作任务中包含对 UI 对象的显式引用。避免此类引用有助于避免这些类型的内存泄漏,同时还可以避免线程争用。

在所有情况下,您的应用都应仅在主线程上更新 UI 对象。这意味着您应该制定一个协商策略,允许多个线程将工作传回主线程,该主线程负责将更新实际 UI 对象的工作委派给最顶层的 activity 或 fragment。

隐式引用

带线程的对象中常见的代码设计缺陷可以在下面的代码片段中看到

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

这段代码的缺陷在于,它将线程对象MyAsyncTask声明为某个Activity的非静态内部类(或Kotlin中的内部类)。这种声明会创建对封闭Activity实例的隐式引用。结果,该对象会一直持有对Activity的引用,直到线程工作完成,从而导致Activity销毁延迟。这种延迟反过来又会增加内存压力。

解决这个问题的直接方法是将重载的类实例定义为静态类,或将其放在单独的文件中,从而消除隐式引用。

另一种解决方案是在适当的Activity生命周期回调函数(例如onDestroy)中始终取消并清理后台任务。但是,这种方法可能比较繁琐且容易出错。一般来说,不应将复杂的非UI逻辑直接放在Activity中。AsyncTask现已弃用,不建议在新代码中使用。有关可用的并发原语的更多详细信息,请参阅Android上的线程处理

线程和应用Activity生命周期

应用生命周期会影响线程在应用中的工作方式。您可能需要决定线程是否应该在Activity销毁后继续存在。您还应该了解线程优先级与Activity是在前台还是后台运行之间的关系。

持久化线程

线程在其创建它们的Activity的生命周期结束后仍然存在。线程会继续不间断地执行,而不管Activity的创建或销毁如何,尽管一旦没有更多活动的应用程序组件,它们将与应用程序进程一起终止。在某些情况下,这种持久性是理想的。

考虑这样一种情况:一个Activity生成一组线程工作块,然后在工作线程能够执行这些块之前被销毁。应用程序应该如何处理正在进行的块?

如果这些块将更新一个不再存在的UI,则没有理由继续工作。例如,如果工作是从数据库加载用户信息,然后更新视图,则线程不再必要。

相反,工作数据包可能具有一些与UI无关的好处。在这种情况下,您应该保持线程的持久性。例如,这些数据包可能正在等待下载图像,将其缓存到磁盘,并更新相关的View对象。虽然该对象不再存在,但下载和缓存图像的行为仍然可能会有所帮助,以防用户返回到已销毁的Activity。

手动管理所有线程对象的生命周期响应可能会变得非常复杂。如果您没有正确地管理它们,您的应用可能会遭受内存竞争和性能问题。将ViewModelLiveData结合使用,您可以加载数据并在数据更改时收到通知,而无需担心生命周期。ViewModel对象是解决此问题的一种方案。ViewModel 在配置更改中得以保留,这提供了一种方便的方法来持久化您的视图数据。有关ViewModel的更多信息,请参阅ViewModel指南,要了解有关LiveData的更多信息,请参阅LiveData指南。如果您还想了解有关应用程序体系结构的更多信息,请阅读应用体系结构指南

线程优先级

进程和应用程序生命周期中所述,您的应用程序线程接收的优先级部分取决于应用程序在应用程序生命周期中的位置。在创建和管理应用程序中的线程时,务必设置它们的优先级,以便正确的线程在正确的时间获得正确的优先级。如果设置得太高,您的线程可能会中断UI线程和RenderThread,导致您的应用丢失帧。如果设置得太低,您可能会使异步任务(例如图像加载)比必要的慢。

每次创建线程时,都应调用setThreadPriority()。系统的线程调度程序优先考虑高优先级的线程,同时平衡这些优先级与最终完成所有工作的需求。通常,前台组中的线程从设备获得大约95%的总执行时间,而后台组获得大约5%的执行时间。

系统还使用Process类为每个线程分配其自身的优先级值。

默认情况下,系统将线程的优先级设置为与生成线程相同的优先级和组成员资格。但是,您的应用程序可以使用setThreadPriority()显式调整线程优先级。

Process类通过提供一组常量来帮助简化分配优先级值的过程,您的应用可以使用这些常量来设置线程优先级。例如,THREAD_PRIORITY_DEFAULT表示线程的默认值。对于执行不太紧急工作的线程,您的应用程序应将其优先级设置为THREAD_PRIORITY_BACKGROUND

您的应用程序可以使用THREAD_PRIORITY_LESS_FAVORABLETHREAD_PRIORITY_MORE_FAVORABLE常量作为增量器来设置相对优先级。有关线程优先级的列表,请参阅Process类中的THREAD_PRIORITY常量。

有关管理线程的更多信息,请参阅有关ThreadProcess类的参考文档。

线程处理的辅助类

对于使用Kotlin作为主要语言的开发者,我们建议使用协程。协程提供许多好处,包括编写无需回调的异步代码以及用于范围、取消和错误处理的结构化并发。

框架还提供相同的Java类和原语来促进线程处理,例如ThreadRunnableExecutors类,以及其他一些类,例如HandlerThread。有关更多信息,请参阅Android上的线程处理

HandlerThread类

HandlerThread实际上是一个长期运行的线程,它从队列中获取工作并对其进行操作。

考虑一下从Camera对象获取预览帧的常见挑战。当您注册Camera预览帧时,您会在onPreviewFrame()回调中接收它们,该回调在其调用的事件线程上调用。如果此回调在UI线程上调用,则处理巨大的像素数组的任务将干扰渲染和事件处理工作。

在此示例中,当您的应用程序将Camera.open()命令委托给Handler线程上的工作块时,关联的onPreviewFrame()回调将在Handler线程上而不是UI线程上进行。因此,如果您要在像素上进行长时间运行的工作,这可能是更好的解决方案。

当您的应用程序使用HandlerThread创建线程时,不要忘记根据正在执行的工作类型设置线程的优先级。请记住,CPU只能并行处理少量线程。设置优先级可以帮助系统了解在所有其他线程争夺注意力时如何安排此工作。

ThreadPoolExecutor类

某些类型的工作可以简化为高度并行、分布式任务。例如,一个这样的任务是为8兆像素图像的每个8x8块计算滤镜。由于这会产生大量的工作数据包,HandlerThread不是合适的类。

ThreadPoolExecutor是一个辅助类,可以简化此过程。此类管理线程组的创建、设置它们的优先级以及管理如何在这些线程之间分配工作。随着工作负载的增加或减少,该类会启动或销毁更多线程以适应工作负载。

此类还有助于您的应用程序生成最佳数量的线程。当它构造一个ThreadPoolExecutor对象时,应用程序会设置线程的最小数量和最大数量。随着提供给ThreadPoolExecutor的工作负载的增加,该类将考虑初始化的最小和最大线程计数,并考虑待处理的工作量。基于这些因素,ThreadPoolExecutor决定在任何给定时间应该有多少线程处于活动状态。

应该创建多少线程?

虽然从软件层面来看,您的代码能够创建数百个线程,但这样做可能会导致性能问题。您的应用程序与后台服务、渲染器、音频引擎、网络等共享有限的CPU资源。CPU实际上只能并行处理少量线程;高于此数量的线程将导致优先级和调度问题。因此,务必仅创建工作负载所需的线程数量。

实际上,有很多变量负责这一点,但是选择一个值(例如,首先选择4),并使用Systrace对其进行测试,这与其他任何策略一样可靠。您可以使用反复试验的方法来发现您可以在不遇到问题的情况下使用的最小线程数。

决定线程数量的另一个考虑因素是线程不是免费的:它们占用内存。每个线程至少占用64k的内存。在设备上安装的许多应用程序中,这会迅速累积,尤其是在调用堆栈大幅增长的情况下。

许多系统进程和第三方库通常会启动自己的线程池。如果您的应用程序可以重用现有的线程池,则此重用可以通过减少对内存和处理资源的竞争来提高性能。