前言
在上一篇文章中我们知道了在多线程并发时,可以使用Synchronized加锁,以保证资源的互斥访问。但是使用锁会引起线程上下文的切换开销,同时需要注意的是,线程的创建和销毁是有一定的性能损耗的,如果程序中多处使用了多线程,该如何优化呢?这就是本文所要探讨的主要内容。
【深入了解Android多线程】当前分为三个部分,这三个部分一起阅读,能更好的帮助你理解,Android在多线程方面设计与优化。
- 深入了解Android多线程(一)Java线程基础
- 深入了解Android多线程(二)线程的性能优化
- 深入了解Android多线程(三)Handler与多线程
锁性能的优化
看这样一个例子
private int value;
public synchronized int getValue() {
return value;
}
public synchronized void setValue(int value) {
this.value = value;
}
阅读过上一篇文章,我们很容易理解,如果线程A正在访问setValue(),即使线程A没有在访问getValue(),其他线程也无法访问getValue()。上一篇文章中提出给两个方法指定不同的监视器,其实Java 还提供了一种弱形式的同步,也就是使用 volatile 。
volatile
1.该关键字确保了对一个变量的更新对其他线程马上可见。当一个变量被声明为 volatile 的时候,线程写入变量的时候不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存,当其他线程读取该共享变量的时候,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
注意:volatile并不是锁!在保证内存可见性上 synchronized 和使用 volatile 是等价的,但是volatile并没有保证操作的原子性。
使用场景:当一个变量的值的改变,不依赖它原来的值时,可以使用volatile替代synchronized。
上面的例子中value的改变和它本身的值无关,所以可以直接使用volatile
private volatile int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
我们将上面的例子再做一些修改
private volatile int value;
public int getValue() {
//累加
return value++;
}
public void setValue(int value) {
this.value = value;
}
这里value出现了一个累加操作,value的改变需要依赖其自身的值,用volatile就无法保证它的原子性,在Android Studio编辑器也会提示,这段代码不具有原子性
为了保证getValue()的原子性,我们就需要重新使用synchronized
private int value;
public synchronized int getValue() {
return value++;
}
public synchronized void setValue(int value) {
this.value = value;
}
但是synchronized使getValue(读操作)和setValue(写操作)共用一个监视器,降低了并发度。java的设计者考虑到这种情况,给出了一种并发度更高的锁—读写分离锁
读写分离锁
读写分离锁顾名思义就是将读取和写入加锁的操作进行分离,从而大大提高系统性能的。
使用读写锁改造一下上面的例子。
private ReentrantReadWriteLock mReentrantReadWriteLock = new ReentrantReadWriteLock();
//读锁
private ReentrantReadWriteLock.ReadLock mReadLock = mReentrantReadWriteLock.readLock();
//写锁
private ReentrantReadWriteLock.WriteLock mWriteLock = mReentrantReadWriteLock.writeLock();
private int value;
public int getValue() {
mReadLock.lock();
try {
return value++;
} finally {
//解除锁的操作必须在finally代码块中
mReadLock.unlock();
}
}
public void setValue(int value) {
mWriteLock.lock();
try {
this.value = value;
} finally {
//解除锁的操作必须在finally代码块中
mWriteLock.unlock();
}
}
读写锁的基本使用就是这样的,但是需要注意的是,解除锁的操作尽量写在finally代码块中,这样可以避免因为程序加锁后代码执行时抛出异常,导致锁无法释放,而产生期望之外的程序异常。
使用场景:任务中执行的读操作远远大于写操作,这时可以考虑读写分离锁。
上述的优化操作依然是加锁,锁在java处理并发任务这一块,功不可没,但是加锁必然带来上下文切换和重新调度时的性能开销,volatile虽然可以实现内存上的可见行,但是并不能操作的原子性,那么有没有办法不加锁还能保证原子性呢?
原子类-Atomic
JDK中提供了一种特殊的原子类,比如AtomicInteger、AtomicBoolean等等,它们是使用CAS算法实现的线程安全的无锁类,专门用于多线程并发操作。
CAS全称Compare And Swap(比较和交换),作为一个Android程序员,我们一般只需要知道Java从硬件上保证了比较-交换操作的原子性,关于它的内部细节,不需要过分深究。
使用场景:当我们在使用java基本数据类型,一些更新、累加操作需要保证原子性时。
注意:当我们需要对一些变量做一些复杂的操作,而这些操作原子类中并没有提供时,我们应该首先考虑使用锁而不是原子类。
我们使用原子类来改写上面的例子
private AtomicInteger value = new AtomicInteger(0);
public int getValue() {
//累加
return value.incrementAndGet();
}
public void setValue(int value) {
//设定新的值
this.value.getAndSet(value);
}
线程池
说完了锁的优化之后,我们在回过头来一下,探讨以下线程的优化。
在Android开发中我们鼓励甚至要求程序员必须使用线程池来创建新的线程。前一篇文章中介绍了6种新建线程的方式,为什么鼓励使用线程池来新建线程?
原因在于线程池有以下几个优点:
1.重用线程池中的线程,避免因为线程的创建和销毁所带来的性能开销。
2.能有效控制线程的最大并发数量,避免大量线程之间因互相抢占cpu而导致的阻塞现象。
3.能够对线程进行简单的管理,并提供定时执行以及指定间隔循环执行等功能。
在Android中线程池都是通过直接或间接配置ThreadPoolExecutor来实现的,下面简单介绍一下ThreadPoolExecutor
//核心线程的数量
int threads = 1;
//最大线程数
int maximumPoolSize = 10;
//非核心线程的闲置超时时间
long keepAliveTime = 100L;
//超时单位
TimeUnit unit = TimeUnit.SECONDS;
//线程池中阻塞任务队列
LinkedBlockingDeque deque = new LinkedBlockingDeque<>();
//线程工厂
ThreadFactory factory = new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "线程的名字");
}
};
//线程池
ExecutorService executorService = new ThreadPoolExecutor(threads,
maximumPoolSize, keepAliveTime, unit, deque, factory);
//向线程池中传入一个runnable
executorService.execute(new Runnable() {
@Override
public void run() {
//do something
}
});
核心线程:即使处于闲置状态,系统也不会销毁的线程。
maximumPoolSize:最大线程数,线程池所能容纳的最大线程数,当活动线程达到这个数值后,后续的任务会阻塞。
keepAliveTime:非核心线程闲置的超时时间:超过这个时长,非核心的线程会被回收。当allowThreadTimeOut属性为true时,这个时间也会作用于核心线程。
workQueue:线程池中阻塞任务队列,通过excute方法提交的runnable对象会存储在这个参数中。
threadFactory:线程工厂,用于初始化统一规格的线程。
线程池在运行时遵守以下的规则
1.如果线程池中线程未达到核心线程的数量,那么会直接启用一个核心的线程来执行任务。
2.如果线程池中的任务达到或者超过核心线程的数量,那么任务会被插入到任务队列中等待执行。
如果步骤2中无法将任务插入到任务队列中(任务队列已满),此时如果线程池中线程数量未达到线程池规定的最大值,那么会立即启动一个非核心线程来执行任务。如果线程数已经达到了线程池中规定的最大值,为抛出异常rejectedExecutionException。
根据不同的任务配置线程池
在实际的开发中,我们需要根据不同的任务类型,配置合适的线程池,这些任务类型大致有以下两种。
CPU密集型操作:核心线程应该尽量少一些,如CPU数量+1(保证核心线程的执行积极度是一样的)
I/O密集型操作:IO操作不占用cpu,线程数量可以多一些,但也不能过多,否则线程切换带来的开销又会影响到性能。
总结起来就是线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
常见的线程池
Java中为我们配置多种常用的线程池,根据执行任务的不同,我们可以直接使用Executors创建出不同的线程池,而不需要再做配置。
1.FixedThreadPool
线程数量固定的线程池,核心线程数量=最大线程数量,并且只有核心线程,当线程处于空闲状态时,它们并不会被回收,除非线程池关闭。线程池队列无限大。
作用:快速响应外界的请求
//线程池
ExecutorService executorService;
//核心线程的数量
int threads = 1;
//重现方法1
executorService = Executors.newFixedThreadPool(1);
//重载方法2
executorService = Executors.newFixedThreadPool(1, new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "线程的名字");
}
});
//像线程池传入一个runnable
executorService.execute(new Runnable() {
@Override
public void run() {
//do something
}
});
2.CachedThreadPool
线程数量无限大闲置的线程池,并且没有存储任务的队列,线程超时时间为60秒。这意味它会立即处理所有加入进来的任务,在没有任务时,线程会因为超时而被回收,这时它是几乎不占用任何系统资源的。
作用:适合处理高并发,且耗时较少的任务。
//线程池
ExecutorService executorService;
executorService = Executors.newCachedThreadPool(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "线程的名字");
}
});
//像线程池传入一个runnable
executorService.execute(new Runnable() {
@Override
public void run() {
//do something
}
});
3.ScheduledThreadPool
核心线程数固定,非核心线程数无限大,非核心线程超时时间10秒。
作用:用于执行定时任务和具有固定周期的重复任务。
//核心线程的数量
int threads = 1;
//定时
long delay = 2000L;
//延迟
long initDelay = 1000L;
//线程工厂
ThreadFactory factory = new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "线程的名字");
}
};
//线程池
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(threads, factory);
//方法1:不延迟直接定时执行
executorService.schedule(new Runnable() {
@Override
public void run() {
//需要定时执行的任务
}
}, delay, TimeUnit.SECONDS);
//方法2:延迟后再定时执行
executorService.scheduleWithFixedDelay(new Runnable() {
@Override
public void run() {
//
}
}, initDelay, delay, TimeUnit.SECONDS);
4.SingleThreadExecutor
线程池中只有一个核心线程,线程池队列无限大
作用:统一外界所有的任务到一个线程中,使这些任务之间不需要处理线程同步的问题。
//线程池
ExecutorService executorService;
//重现方法1
executorService = Executors.newSingleThreadExecutor();
//重载方法2
executorService = Executors.newSingleThreadExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "线程的名字");
}
});
//像线程池传入一个runnable
executorService.execute(new Runnable() {
@Override
public void run() {
//do something
}
});
注意上述所说的无限大实际上是指Java的一个常数Integer.MAX_VALUE,它等于2的31次方-1,我们可以把它近似看作无穷大。
后台任务的选择
在Android开发中会经常遇到不同的后台任务,根据不同的任务类型,我们需要选择不同的实现方式,下面说说一些简单的判断场景。
当一个后台任务只运行在后台且不会回到前台或不会与UI发生交互时,考虑使用线程池。
当一个后台任务在后台短期执行后需要返回前台的,考虑使用AyncTask或HandlerThread
以上情况并不是绝对的,有时候甚至需要结合service、intentService等组件一起,才能完成后台任务,说到底适合当前项目的,才是最好的。