通过多线程提升性能

在 Android 上巧妙地使用线程有助于提升应用性能。本页面讨论了处理线程的几个方面:使用 UI 线程(或主线程);应用生命周期与线程优先级之间的关系;以及平台提供的有助于管理线程复杂性的方法。本页面在上述各个领域都描述了潜在的陷阱和规避策略。

主线程

用户启动应用时,Android 会创建一个新的 Linux 进程以及一个执行线程。此主线程(也称为 UI 线程)负责屏幕上发生的一切。了解它的工作原理有助于您设计应用以使用主线程,从而获得最佳性能。

内部机制

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

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

在动画或屏幕更新发生时,系统会尝试每隔大约 16 毫秒执行一个工作块(负责绘制屏幕),以便以每秒 60 帧的速度流畅渲染。为了让系统实现此目标,UI/视图层次结构必须在主线程上更新。然而,当主线程的消息队列包含的任务过多或耗时过长,导致主线程无法足够快地完成更新时,应用应将此工作转移到工作线程。如果主线程无法在 16 毫秒内完成工作块的执行,用户可能会遇到卡顿、延迟或界面对输入响应迟缓的情况。如果主线程阻塞约五秒钟,系统会显示应用无响应 (ANR) 对话框,允许用户直接关闭应用。

将众多或耗时任务从主线程移开,以免它们干扰流畅渲染和对用户输入的快速响应,这是您在应用中采用多线程的最大原因。

线程和 UI 对象引用

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

引用问题分为两个不同的类别:显式引用和隐式引用。

显式引用

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

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

在另一个示例中,View 对象包含对其所属 Activity 的引用。如果该 Activity 已销毁,但仍有一个线程化工作块直接或间接引用它,则垃圾回收器直到该工作块执行完毕后才会回收该 Activity。

在某些情况下,如果线程化工作正在进行时发生屏幕旋转等 Activity 生命周期事件,则此场景可能会导致问题。系统直到正在进行的工作完成后才能执行垃圾回收。因此,在可以执行垃圾回收之前,内存中可能会有两个 Activity 对象。

对于此类场景,我们建议您的应用在线程化工作任务中不要包含对 UI 对象的显式引用。避免此类引用有助于您规避此类内存泄漏问题,同时也能避免线程争用。

在所有情况下,您的应用都应仅在主线程上更新 UI 对象。这意味着您应该制定一个协商策略,允许多个线程将工作通信回主线程,由主线程的任务、顶层 Activity 或 Fragment 负责更新实际的 UI 对象。

隐式引用

下面代码段中显示了一个常见的线程化对象代码设计缺陷

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 启动了一组线程化工作块,然后在工作线程执行这些工作块之前,该 Activity 就被销毁了。应用应该如何处理正在进行的工作块?

如果工作块要更新不再存在的界面,则无需继续执行该工作。例如,如果工作是从数据库加载用户信息,然后更新视图,则不再需要该线程。

相比之下,工作数据包可能具有与界面不完全相关的益处。在这种情况下,您应该使线程持续存在。例如,数据包可能正在等待下载图片、将其缓存到磁盘并更新关联的 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 类

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

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

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

当您的应用使用 HandlerThread 创建线程时,不要忘记根据它正在进行的工作类型设置线程的优先级。请记住,CPU 只能并行处理少量线程;超出这个数量就会遇到优先级和调度问题。因此,只创建您的工作负载所需的线程数量非常重要。

ThreadPoolExecutor 类

有某些类型的工作可以分解为高度并行、分布式的任务。例如,其中一项任务是为 8 兆像素图片的每个 8x8 块计算滤镜。考虑到由此产生的工作数据包数量巨大,HandlerThread 并不是合适的类。

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

此类还有助于您的应用启动最佳数量的线程。构建 ThreadPoolExecutor 对象时,应用会设置最小和最大线程数。随着分配给 ThreadPoolExecutor 的工作负载增加,此类会考虑已初始化的最小和最大线程数,并考虑待处理工作的数量。根据这些因素,ThreadPoolExecutor 决定在任何给定时间应有多少线程处于活动状态。

应该创建多少个线程?

虽然从软件层面来说,您的代码能够创建数百个线程,但这样做可能会产生性能问题。您的应用与后台服务、渲染器、音频引擎、网络等共享有限的 CPU 资源。CPU 实际上只能并行处理少量线程;超出此范围的一切都会遇到优先级和调度问题。因此,仅创建您的工作负载所需的线程数量非常重要。

实际上,有许多变量对此负责,但选择一个值(例如,从 4 开始)并使用 Systrace 进行测试是一种像其他任何策略一样稳固的策略。您可以使用试错法来发现可以在不遇到问题的情况下使用的最小线程数。

决定拥有多少线程的另一个考虑因素是线程并非免费:它们会占用内存。每个线程至少需要占用 64k 内存。这在设备上安装的众多应用中会迅速累积,尤其是在调用堆栈显著增长的情况下。

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