(转载公司内部论坛本人文章2020.6.15)
导语: Android开发中,我们为了提高性能,往往会把一些耗时的操作放在异步线程中执行,比如文件读写和网络请求。Android开启异步线程的方式多种多样,我们该如何选择异步方式,又该如何合理使用线程池呢?
之前项目中,开启异步线程的方式没有统一,存在new Thread和new Handler多种方式。这种混乱的异步方式不利于线程管理,频繁创建和销毁线程也带来了一定的性能损耗。为此,我在为项目添加线程池的过程过程中,对Android的异步方式和线程池做了一些总结和探讨。
Android(Linux)线程调度原理
一般情况下,CPU同一个时间点只能处理单个线程(对于Android常见的8核CPU,理论上同一个时间点能处理8个线程)。而多线程并发的过程,其实是线程轮流获取CPU使用权的过程,只是这个使用时间足够短。JVM按照特定的机制分配CPU使用权,进行线程调度。
Android同时兼顾了两种线程调度模型,分别是
- 分时调度模型:简单理解就是轮流获取CPU使用权
- 抢占式调度模型:优先级高的线程先获取CPU使用权。具体优先级设置,可以参考文章
异步不等于不耗时
- 根据android线程调度原理,线程过多,会导致CPU频繁切换线程降低线程的运行效率。
- 如果线程优先级设置不当,可能会造成UI线程抢不到CPU的情况。(在没有明确设置的情况下,一个线程初始的优先级等于其parent的优先级。如果我们从UI线程来创建一个子线程的,那么这个子线程的优先级就等于UI线程的优先级。)
- 频繁地创建和销毁线程也会带来不必要的性能消耗;
异步的方式有哪些?
new Thread
- 缺乏统一的管理;
- 会频繁创建及销毁线程,性能损耗较大;
- 优先级与UI线程一致,跟UI线程抢CPU资源
- 需要自己处理线程间的切换
AsyncTask
- Android提供的工具类。
- 无需自己处理线程切换。
- 有自己的线程池
HandlerThread
- 自带消息循环的线程。
- 长时间运行,不断从队列中获取任务。
IntentService
- 继承自Service在内部创建HandlerThread。
- 优先级较高,不易被系统Kill。
- 使用起来不够方面
线程池
- 易复用,减少频繁创建、销毁的时间。
- 功能强大,如定时、任务队列、并发数控制等。
线程池的优点
- 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
- 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
- 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。
线程池分类
Android中创建线程池,主要是通过ThreadPoolExecutor类来实现。Executors类实现了不同特性的线程池,它们都直接或者间接通过ThreadPoolExecutor来实现自己的功能。它们分别是:
- FixedThreadPool
通过Executors的newFixedThreadPool()方法创建,它是个线程数量固定的线程池,该线程池的线程全部为核心线程,没有超时机制。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
- CachedThreadPool
通过Executors的newCachedThreadPool()方法来创建,线程都是非核心线程,当有新任务来时如果没有空闲的线程则直接创建新的线程不会去排队而直接执行,线程空闲60S被会被回收,适合执行大量耗时小的任务。理论上该线程池不会有占用系统资源的无用线程。
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
- ScheduledThreadPool
通过Executors的newScheduledThreadPool()方法来创建,有数量固定的核心线程,且有数量无限多的非核心线程,但是它的非核心线程超时时间是0s,所以非核心线程一旦空闲立马就会被回收。这类线程池适合用于执行定时任务和固定周期的重复任务。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE,
DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
new DelayedWorkQueue());
}
- SingleThreadExecutor
通过Executors的newSingleThreadExecutor()方法来创建,它内部只有一个核心线程,它确保所有任务进来都要排队按顺序执行。它的意义在于,统一所有的外界任务到同一线程中,让调用者可以忽略线程同步问题。
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}
- 自定义线程池
ThreadPoolExecutor是ExecutorService接口的实现类。可以使用ThreadPoolExecutor来创建自己的线程池,常用构造方法如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
threadFactory, defaultHandler);
}
- corePoolSize:线程池中核心线程的数量,如果不设置allowCoreThreadTimeOut为true,核心线程会一直存在,这样就避免了一般情况下CPU创建和销毁线程带来的开销但同时却增加了内存占用。
- maximumPoolSize:线程池中的最大线程数,当任务数量超过最大线程数时其它任务可能就会被阻塞。最大线程数=核心线程+非核心线程。
- keepAliveTime:非核心线程的超时时长,当执行时间超过这个时间时,非核心线程就会被回收。当allowCoreThreadTimeOut设置为true时,此属性也作用在核心线程上。
- unit:枚举时间单位,TimeUnit。
- workQueue:线程池中的任务队列,我们提交给线程池的runnable会被存储在这个对象上。
线程池一定会提升效率吗
- 使用线程池需要特别注意同时并发线程数量的控制。因为CPU只能同时执行固定数量的线程数,一旦同时并发的线程数量超过CPU能够同时执行的阈值,CPU就需要花费精力来判断到底哪些线程的优先级比较高,在不同的线程之间进行调度切换。一旦同时并发的线程数量达到一定的量级,CPU在不同线程之间进行调度的时间就可能过长,反而导致性能严重下降;
- 每开一个新的线程,都会耗费至少64K以上的内存。线程池中存在了过多的并发数量不仅会影响CPU的调度时间而且会增加内存占用
- 线程的优先级具有继承性,特别要注意线程池中线程的优先级。如果设置不当,会造成和UI线程抢资源的情况
如何设置合适的线程池
设置合适的线程池,其实就是设置ThreadPoolExecutor构造函数最核心的是3个参数:corePoolSize、maximumPoolSize,workQueue,它们最大程度地决定了线程池的任务分配和线程分配策略。遗憾的是,在Android中如何设置一个合适的线程池,业界并没有通用的方案,可能是因为每个应用对线程的实际使用情况不一样。不过我们还是可以参考Android原生AsyncTask.THREAD_POOL_EXECUTOR线程池的设置方法,获得一些提示:
public static final Executor THREAD_POOL_EXECUTOR;
static {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
sPoolWorkQueue, sThreadFactory);
threadPoolExecutor.allowCoreThreadTimeOut(true);
THREAD_POOL_EXECUTOR = threadPoolExecutor;
}
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
private static final int KEEP_ALIVE_SECONDS = 30;
private static final BlockingQueue<Runnable> sPoolWorkQueue =
new LinkedBlockingQueue<Runnable>(128);
看THREAD_POOL_EXECUTOR的源码可以发现,corePoolSize核心线程数跟CPU的核数有关,为CPU_COUNT - 1,但同时又限制线程数最少2个,最多4个。而最大线程数也和CPU核数有关,CPU核数*2 + 1。workQueue限制最大任务数为128。之所以核心线程数和CPU核数有关,根据Android线程调度原理可以知道,一旦同时运行的耗时线程过多,可能会占用CPU资源造成UI线程抢不到CPU的情况。这也是业界为什么常把线程池分为CPU密集型线程池和IO密集型线程池:
- IO密集型任务:IO密集型任务不消耗CPU,核心池可以很大。常见的IO密集型任务如文件读写,网络请求等等。
- CPU密集型任务:核心池大小和CPU核心数相关。常见的CPU密集型任务如比较复杂的计算操作,此时需要使用大量的CPU计算单元。
根据这个思路,我把我们项目的中的线程池也分为CPU密集型和IO密集型两类,具体实现如下:
/**
* 线程池线程数
*/
public static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 5));
/**
* 线程池线程数的最大值
*/
private static final int MAXIMUM_POOL_SIZE = CORE_POOL_SIZE;
private static final int KEEP_ALIVE_SECONDS = 5;
private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<>();
/**
* 线程工厂
*/
private static final DefaultThreadFactory sThreadFactory = new DefaultThreadFactory();
private static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "IGameThreadPool-" +
poolNumber.getAndIncrement() +
"-Thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon()) {
t.setDaemon(false);
}
if (t.getPriority() != Thread.NORM_PRIORITY) {
t.setPriority(Thread.NORM_PRIORITY);
}
return t;
}
}
static {
sCPUThreadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
sPoolWorkQueue, sThreadFactory, sHandler);
sCPUThreadPoolExecutor.allowCoreThreadTimeOut(true);
sIOThreadPoolExecutor = Executors.newCachedThreadPool(sThreadFactory);
}
仿照THREAD_POOL_EXECUTOR创建CPU密集型线程池,控制CPU密集型任务并发数。对于一些IO密集型任务比如文件读写和网络请求,则用Executors的CachedThreadPool,线程空闲60s后会被回收不再占用内存。统一了线程命名和线程优先级,便于管理和问题跟踪。
效果
改造后,项目中,网络框架,缓存读写,已经其他地方都使用了统一的线程池。使用线程池后性能也有一定的提升,主要体现在线程复用了。
App冷启动完成后,静止10秒钟测得数据:
优化前:
线程开启数:154,内存:158m
优化后:
线程开启数:134,内存:153m
参考:
https://cloud.tencent.com/developer/article/1427621
https://juejin.im/entry/593109e72f301e005830cd76
https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html