写点什么

关于 Android 中工作者线程的思考

  • 2015-12-16
  • 本文字数:4349 字

    阅读完需:约 14 分钟

本文系 2015 北京 GDG Devfest 分享内容整理。

在 Android 中,我们或多或少使用了工作者线程,比如 Thread,AsyncTask,HandlerThread,甚至是自己创建的线程池,使用工作者线程我们可以将耗时的操作从主线程中移走。然而在 Android 系统中为什么存在工作者线程呢,常用的工作者线程有哪些不易察觉的问题呢,关于工作者线程有哪些优化的方面呢,本文将一一解答这些问题。

工作者线程的存在原因

  • 因为 Android 的 UI 单线程模型,所有的 UI 相关的操作都需要在主线程 (UI 线程) 执行
  • Android 中各大组件的生命周期回调都是位于主线程中,使得主线程的职责更重
  • 如果不使用工作者线程为主线程分担耗时的任务,会造成应用卡顿,严重时可能出现 ANR(Application Not Responding), 即程序未响应。

因而,在 Android 中使用工作者线程显得势在必行,如一开始提到那样,在 Android 中工作者线程有很多,接下来我们将围绕 AsyncTask,HandlerThread 等深入研究。

AsyncTask

AsyncTask 是 Android 框架提供给开发者的一个辅助类,使用该类我们可以轻松的处理异步线程与主线程的交互,由于其便捷性,在 Android 工程中,AsyncTask 被广泛使用。然而 AsyncTask 并非一个完美的方案,使用它往往会存在一些问题。接下来将逐一列举 AsyncTask 不容易被开发者察觉的问题。

AsyncTask 与内存泄露

内存泄露是 Android 开发中常见的问题,只要开发者稍有不慎就有可能导致程序产生内存泄露,严重时甚至可能导致 OOM(OutOfMemory,即内存溢出错误)。AsyncTask 也不例外,也有可能造成内存泄露。

以一个简单的场景为例:
在 Activity 中,通常我们这样使用 AsyncTask

复制代码
//In Activity
new AsyncTask<String, Void, Void>() {
@Override
protected Void doInBackground(String... params) {
//some code
return null;
}
}.execute("hello world");

上述代码使用的匿名内存类创建 AsyncTask 实例,然而在 Java 中,非静态内存类会隐式持有外部类的实例引用,上面例子 AsyncTask 创建于 Activity 中,因而会隐式持有 Activity 的实例引用。

而在 AsyncTask 内部实现中,mFuture 同样使用匿名内部类创建对象,而 mFuture 会作为执行任务加入到任务执行器中。

复制代码
private final WorkerRunnable<Params, Result> mWorker;
public AsyncTask() {
mFuture = new FutureTask<Result>(mWorker) {
@Override
protected void done() {
//some code
}
};
}

而 mFuture 加入任务执行器,实际上是放入了一个静态成员变量 SERIAL_EXECUTOR 指向的对象 SerialExecutor 的一个 ArrayDeque 类型的集合中。

复制代码
public static final Executor SERIAL_EXECUTOR = new SerialExecutor();
private static class SerialExecutor implements Executor {
final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();
public synchronized void execute(final Runnable r) {
mTasks.offer(new Runnable() {
public void run() {
//fake code
r.run();
}
});
}
}

当任务处于排队状态,则 Activity 实例引用被静态常量 SERIAL_EXECUTOR 间接持有。

在通常情况下,当设备发生屏幕旋转事件,当前的 Activity 被销毁,新的 Activity 被创建,以此完成对布局的重新加载。

而本例中,当屏幕旋转时,处于排队的 AsyncTask 由于其对 Activity 实例的引用关系,导致这个 Activity 不能被销毁,其对应的内存不能被 GC 回收,因而就出现了内存泄露问题。

关于如何避免内存泄露,我们可以使用静态内部类 + 弱引用的形式解决。

cancel 的问题

AsyncTask 作为任务,是支持调用者取消任务的,即允许我们使用 AsyncTask.canncel() 方法取消提交的任务。然而其实 cancel 并非真正的起作用。

首先,我们看一下 cancel 方法:

复制代码
public final boolean cancel(boolean mayInterruptIfRunning) {
mCancelled.set(true);
return mFuture.cancel(mayInterruptIfRunning);
}

cancel 方法接受一个 boolean 类型的参数,名称为mayInterruptIfRunning,意思是是否可以打断正在执行的任务。

当我们调用 cancel(false),不打断正在执行的任务,对应的结果是

  • 处于 doInBackground 中的任务不受影响,继续执行
  • 任务结束时不会去调用onPostExecute方法,而是执行onCancelled方法

当我们调用 cancel(true),表示打断正在执行的任务,会出现如下情况:

  • 如果 doInBackground 方法处于阻塞状态,如调用 Thread.sleep,wait 等方法,则会抛出 InterruptedException。
  • 对于某些情况下,有可能无法打断正在执行的任务

如下,就是一个 cancel 方法无法打断正在执行的任务的例子

复制代码
AsyncTask<String,Void,Void> task = new AsyncTask<String, Void, Void>() {
@Override
protected Void doInBackground(String... params) {
boolean loop = true;
while(loop) {
Log.i(LOGTAG, "doInBackground after interrupting the loop");
}
return null;
}
}
task.execute("hello world");
try {
Thread.sleep(2000);// 确保 AsyncTask 任务执行
task.cancel(true);
} catch (InterruptedException e) {
e.printStackTrace();
}

上面的例子,如果想要使 cancel 正常工作需要在循环中,需要在循环条件里面同时检测isCancelled()才可以。

串行带来的问题

Android 团队关于 AsyncTask 执行策略进行了多次修改,修改大致如下:

  • 自最初引入到 Donut(1.6) 之前,任务串行执行
  • 从 Donut 到 GINGERBREAD_MR1(2.3.4), 任务被修改成了并行执行
  • 从 HONEYCOMB(3.0)至今,任务恢复至串行,但可以设置executeOnExecutor()实现并行执行。

然而 AsyncTask 的串行实际执行起来是这样的逻辑

  • 由串行执行器控制任务的初始分发
  • 并行执行器一次执行单个任务,并启动下一个

在 AsyncTask 中,并发执行器实际为 ThreadPoolExecutor 的实例,其 CORE_POOL_SIZE 为当前设备 CPU 数量 +1,MAXIMUM_POOL_SIZE 值为 CPU 数量的 2 倍 + 1。

以一个四核手机为例,当我们持续调用 AsyncTask 任务过程中

  • 在 AsyncTask 线程数量小于 CORE_POOL_SIZE(5 个) 时,会启动新的线程处理任务,不重用之前空闲的线程
  • 当数量超过 CORE_POOL_SIZE(5 个),才开始重用之前的线程处理任务

但是由于 AsyncTask 属于默认线性执行任务,导致并发执行器总是处于某一个线程工作的状态,因而造成了 ThreadPool 中其他线程的浪费。同时由于 AsyncTask 中并不存在 allowCoreThreadTimeOut(boolean) 的调用,所以 ThreadPool 中的核心线程即使处于空闲状态也不会销毁掉。

Executors

Executors 是 Java API 中一个快速创建线程池的工具类,然而在它里面也是存在问题的。

以 Executors 中获取一个固定大小的线程池方法为例

复制代码
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,0L,
TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
}

在上面代码实现中,CORE_POOL_SIZE 和 MAXIMUM_POOL_SIZE 都是同样的值,如果把 nThreads 当成核心线程数,则无法保证最大并发,而如果当做最大并发线程数,则会造成线程的浪费。因而 Executors 这样的 API 导致了我们无法在最大并发数和线程节省上做到平衡。

为了达到最大并发数和线程节省的平衡,建议自行创建 ThreadPoolExecutor,根据业务和设备信息确定 CORE_POOL_SIZE 和 MAXIMUM_POOL_SIZE 的合理值。

HandlerThread

HandlerThread 是 Android 中提供特殊的线程类,使用这个类我们可以轻松创建一个带有 Looper 的线程,同时利用 Looper 我们可以结合 Handler 实现任务的控制与调度。以 Handler 的 post 方法为例,我们可以封装一个轻量级的任务处理器

复制代码
private Handler mHandler;
private LightTaskManager() {
HandlerThread workerThread = new HandlerThread("LightTaskThread");
workerThread.start();
mHandler = new Handler(workerThread.getLooper());
}
public void post(Runnable run) {
mHandler.post(run);
}
public void postAtFrontOfQueue(Runnable runnable) {
mHandler.postAtFrontOfQueue(runnable);
}
public void postDelayed(Runnable runnable, long delay) {
mHandler.postDelayed(runnable, delay);
}
public void postAtTime(Runnable runnable, long time) {
mHandler.postAtTime(runnable, time);
}

在本例中,我们可以按照如下规则提交任务

  • post 提交优先级一般的任务
  • postAtFrontOfQueue 将优先级较高的任务加入到队列前端
  • postAtTime 指定时间提交任务
  • postDelayed 延后提交优先级较低的任务

上面的轻量级任务处理器利用 HandlerThread 的单一线程 + 任务队列的形式,可以处理类似本地 IO(文件或数据库读取)的轻量级任务。在具体的处理场景下,可以参考如下做法:

  • 对于本地 IO 读取,并显示到界面,建议使用 postAtFrontOfQueue
  • 对于本地 IO 写入,不需要通知界面,建议使用 postDelayed
  • 一般操作,可以使用 post

线程优先级调整

在 Android 应用中,将耗时任务放入异步线程是一个不错的选择,那么为异步线程调整应有的优先级则是一件锦上添花的事情。众所周知,线程的并行通过 CPU 的时间片切换实现,对线程优先级调整,最主要的策略就是降低异步线程的优先级,从而使得主线程获得更多的 CPU 资源。

Android 中的线程优先级和 Linux 系统进程优先级有些类似,其值都是从 -20 至 19。其中 Android 中,开发者可以控制的优先级有:

  • THREAD_PRIORITY_DEFAULT,默认的线程优先级,值为 0
  • THREAD_PRIORITY_LOWEST,最低的线程级别,值为 19
  • THREAD_PRIORITY_BACKGROUND 后台线程建议设置这个优先级,值为 10
  • THREAD_PRIORITY_MORE_FAVORABLE 相对THREAD_PRIORITY_DEFAULT稍微优先,值为 -1
  • THREAD_PRIORITY_LESS_FAVORABLE 相对THREAD_PRIORITY_DEFAULT稍微落后一些,值为 1

为线程设置优先级也比较简单,通用的做法是在 run 方法体的开始部分加入下列代码

android.os.Process.setThreadPriority(priority);通常设置优先级的规则如下:

  • 一般的工作者线程,设置成THREAD_PRIORITY_BACKGROUND
  • 对于优先级很低的线程,可以设置THREAD_PRIORITY_LOWEST
  • 其他特殊需求,视业务应用具体的优先级

总结

在 Android 中工作者线程如此普遍,然而潜在的问题也不可避免,建议在开发者使用工作者线程时,从工作者线程的数量和优先级等方面进行审视,做到较为合理的使用。


感谢徐川对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群(已满),InfoQ 读者交流群(#2))。

2015-12-16 17:297308

评论

发布
暂无评论
发现更多内容

Emby for Mac(轻松管理多媒体影音库)1.9.9中文版

iMac小白

万字长文分享快手 Kolors 可图大模型应用实践

快手技术

#开源 #大模型

创业开发一个体育直播软件平台的宏观环境:机会与威胁分析

软件开发-梦幻运营部

即时通讯和即时通信,即时通讯和实时通信

BeeWorks

掌握良好的API设计原则:是什么、为什么和怎么办

幂简集成

API API 设计

华为发布全新星盾安全架构 助力金融类鸿蒙原生应用更安全

最新动态

PromQL全方位解读:监控与性能分析的关键技术

快乐非自愿限量之名

性能分析 解读

远程访问Voice Engine AI工具:无需公网IP,轻松实现高效创作

贝锐

AI 花生壳 贝锐

面试官:你的项目有哪些难点?

王磊

Java 面试

万字干货:从消息流平台Serverless之路,看Serverless标准演进

华为云开发者联盟

Serverless lvm 信息流 企业号 8 月 PK 榜

全网热议!GitHub发布的最简单的黑客入门教程,你值得拥有!

我再BUG界嘎嘎乱杀

黑客 网络安全 安全 信息安全 网安

单集群 100 节点!资源占用远小于 Grafana Mimir——GreptimeDB 海量数据写入性能报告

Greptime 格睿科技

数据库 性能测试 数据存储 Mimir 基准测试

ByteHouse高性能向量检索实践——“以图搜图”

字节跳动数据平台

数据库 大数据 云原生 Clickhouse 数仓

简单好用的京东商品详情API使用入门指南

技术冰糖葫芦

API 安全 API 文档 API 测试 API 优先 pinduoduo API

IoTDB 集群性能优化:内存配置详解

Apache IoTDB

强烈推荐这个大佬的 Redis 系列,看完直呼过瘾

码哥字节

redis 底层原理 Redis 7

可信数据库发展大会回顾 | 天谋科技乔嘉林:Apache TsFile & IoTDB 联合赋能智慧能源

Apache IoTDB

发布更便宜的GPT-4o mini,OpenAI也打起了价格战

蓉蓉

openai GPT-4o mini

解锁AI安全新高度,安全极客全新升级

云起无垠

解析 LSP 经济循环系统,如何推动生态形成价值增长?

股市老人

Serial for Mac(全功能串行终端管理软件)v2.0.17激活版

iMac小白

集成与 API 支持:开放架构的灵活性

可观测技术

华为大咖说 | 揭秘巴黎奥运会门票定价:为什么最低门票定24欧?

华为云PaaS服务小智

人工智能 华为云

大模型检索X一键成片,巴黎奥运的AI新演绎

阿里云CloudImagine

云计算 AI技术 大模型 视频云 奥运会

LSP 协议:模块化 PoS 的全新叙事方案

股市老人

观测云:企业监控的定制化与精细化管理专家

可观测技术

观测云:零售行业数据分析的利器

可观测技术

telegraf 常用命令总结

不在线第一只蜗牛

服务器 命令

玩转漫画、小说等跨模态AI创作,百度文库联合玄机科技举办国内首个AI漫画大赛

Geek_2d6073

关于Android中工作者线程的思考_Android/iOS_段建华_InfoQ精选文章