写点什么

关于 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:297708

评论

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

Dubbo Cluster集群那点你不知道的事。

why技术

源码 面试 dubbo 集群容错

PlantUML 的介绍和使用

Puran

UML PlantUML

Java技术奇迹

ATGU:阿宝哥

为什么你在群里的提的技术问题没人回答?

古时的风筝

程序员 提问的艺术

读《平凡的世界》

YoungZY

读书

createRef、useRef、useMemo对比分析和应用场景

费马

React Hooks useRef useMemo createRef

C#和TS的范型实例化

猫定谔的靴

C# typescript 泛型

大话设计模式 | 1 简单工厂模式

Puran

C# 设计模式 PlantUML

关于Synchronized锁升级,你该了解这些

学习Java的小姐姐

并发编程 synchronized 轻量级锁 偏向锁 重量级锁

前端开发必备工具箱

LeanCloud

CSS 性能优化 vscode 大前端 工具

分布式系统技术:存储之数据库

奈学教育

分布式

面试官为什么喜欢拿 Kafka 考验求职者

奈学教育

kafka

存储让“想象”势不可挡

焱融科技

如何辨别有发展潜力的员工​

Neco.W

工作 招聘

程序员未来会成为非常内卷的职业?

非著名程序员

程序员 程序人生 职业 职业规划

《龙教授私享会职场沟通心法》最佳学习路线(2020最新版)

ATGU:阿宝哥

实时更新:计算机编程语言排行榜—TIOBE世界编程语言排行榜(2020年6月份最新版)

ATGU:阿宝哥

Elasticsearch-Base

子路无倦

elasticsearch search 搜索

ARTS-Week 01

chasel

硬不硬你说了算!近 40 张图解被问千百遍的 TCP 三次握手和四次挥手面试题

小林coding

面试 TCP 网络安全 网络编程 计算机网络

cpu分析利器 — async-profiler

捉虫大师

Java cpu profiler

厉害了,SpaceX-API 开源了

非著名程序员

GitHub 开源 程序员

《Oracle Java SE编程自学与面试指南》最佳学习路线图(2020最新版)

ATGU:阿宝哥

从SDL到DevSecOps:始终贯穿开发生命周期的安全

Fooying

DevOps SDL DevSecOps 安全开发 软件开发生命周期

27岁了,程序员写给自己的一封信

学习Java的小姐姐

程序员 生活 总结 程序媛 职场回顾

从技术思维角度聊一聊『程序员』摆地摊的正确姿势

牧码哥

随笔杂谈 技术人生 经验分享

2020年5月北京BGP机房网络质量评测报告

博睿数据

网络 服务器 存储 机房 主机

9种 分布式ID生成方案,我替你整理好了

程序员小富

Java MySQL 分布式

ARTS打卡-02

Geek_yansheng25

持续集成实践系列 」Jenkins 构建 CI 自动化流水线常见技巧 (二)

狂师

持续集成 jenkins jenkins-plugin CI/CD

LeetCode 1339. Maximum Product of Splitted Binary Tree

隔壁小王

算法

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