多线程编程
Android沿用了Java的线程模型,一个Android应用在创建时会开启一个线程,常称作主线程,也叫做UI线程,如果有请求网络等耗时操作时,就需要开启子线程去处理。因此,此文对多线程进行梳理总结
线程基础
1. 进程与线程
这两个的区分在我的另一篇文章# Android面向面试复习-操作系统+计网篇中已经提及,简单复习一下。进程是操作系统结构的基础,是程序在一个数据集合上运行的过程,是系统进行资源分配和调度的基本单位。进程可以看作程序的实体,也是线程的容器。线程是操作系统调度的最小单元,一个进程中可以创建多个线程。这些线程拥有各自的计数器,堆栈,局部变量等属性,并且能够访问共享的内存变量
2. 线程的状态
Java线程在运行的生命周期可能会处于六种不同的状态,如下
- New:新创建状态,线程被创建,还没有调用start方法,运行之前还有一些基础工作
- Runnable:可运行状态,一旦调用start方法,就会处于Runnable状态。处于这个状态的线程可能正在运行,也可能没有,取决于操作系统的调度
- Blocked:阻塞状态,表示线程被锁阻塞,暂不能活动
- Waiting:等待状态,线程暂时不活动,并且不运行任何代码,这消耗最少的资源,知道调度器重新激活这个线程
- Timed Waiting:超时等待,可以在指定的时间自行返回
- Terminated:终止状态,表示当前线程已经执行完毕,比如run方法正常执行退出,或者因为没有被捕获的异常而终止
3. 创建线程
- 继承Thread类,重写run方法
- 实现Runnable接口,重写run方法
- 实现Callable接口,重写call方法
4. 理解中断
当线程的run方法执行完毕,或者方法里出现没有捕获的异常时,线程就要终止。早期Java版本中有stop方法可以终止线程,现在已经被弃用。现版本用interrupt来中断线程,当一个线程调用interrupt方法时,它的中断标志位将被置为true。线程会时不时的检测这个中断标记位,以判断线程是否应该被中断,要想知道线程是否被置位,可以调用isInterrupted方法查看返回值。还可以调用静态方法interrupted来对中断标志位进行复位。但是如果一个线程被阻塞,就无法检测中断状态。如果一个线程处于阻塞状态,那么线程在检查中断标志位时若发现中断标志位为true,就会在阻塞方法调用处抛出阻塞异常,并且在抛出异常前将线程中断标志位复位,即重新设置为false。需要注意的是被中断的线程不一定会终止,中断线程是为了引起线程的注意,被中断的线程可以决定如何响应中断。如果是比较重要的线程,则不会理会中断。而大部分情况是线程会将中断作为一个终止的请求。另外,不要在底层代码里捕获InterruptedException不做处理,这里介绍两种合适的处理方式
- 在catch子句中,调用Thread.currentThread().interrupt()来设置中断状态。因为在抛出异常后中断标志位会复位,让外界通过判断isInterrupted()来决定是终止还是继续下去
void test(){
try{
sleep(50);
}catch(InterruptedException e){
Thread.currentThread().interrupt();
}
}
- 更好的做法是直接抛出异常,方便调用者捕获
void test() throw InterruptedException{
sleep(50);
}
5. 安全的终止线程
上一点我们提到了中断,首先用中断来终止线程,如下
public class test{
public static void main(String[] args) throws InterruptedException {
MyRunner runner = new MyRunner();
Thread thread = new Thread(runner, "MyRunner");
thread.start();
TimeUnit.MILLISECONDS.sleep(10);
thread.interrupt();
}
public static class MyRunner implements Runnable{
private long i;
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()){
i++;
System.out.println("i=" + i);
}
System.out.println("stop");
}
}
}
代码里用sleep方法使得main线程沉睡10ms,留给MyRunner足够的时间来感知中断从而结束,还可以采用boolean变量来控制是否需要停止线程,如下
public class test{
public static void main(String[] args) throws InterruptedException {
MyRunner runner = new MyRunner();
Thread thread = new Thread(runner, "MyRunner");
thread.start();
TimeUnit.MILLISECONDS.sleep(10);
runner.cancel();
}
public static class MyRunner implements Runnable{
private long i;
private volatile boolean on = true;
@Override
public void run() {
while (on){
i++;
System.out.println("i=" + i);
}
System.out.println("stop");
}
public void cancel(){
on = false;
}
}
}
结果如下,两段代码是类似的
此处说明线程执行到了run方法的末尾,即将终止
线程同步
在多线程应用中,两个或者两个以上的线程需要共享对同一个数据的存取,如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法,这种情况被称为竞态条件。此时如果不用同步,是无法保证数据原子性的,所以我们就需要用到锁
1. 重入锁与条件对象
synchronized关键字自动提供了锁以及相关条件。大多数需要显示锁的情况使用synchronized非常方便。但是等我们了解了重入锁和条件对象时,能更好的理解synchronized关键字。重入锁ReentrantLock是Java SE 5.0引入的,就是支持重进入的锁,表示该锁能够支持一个线程对资源的重复加锁。具体结构如下
Lock mLock = new ReentrantLock();
mLock.lock();
try {
}catch (){
}finally {
mLock.unlock();
}
这一结构确保任何时刻只有一个线程进入临界区,临界区就是同一时刻只有一个任务访问的代码区域。一旦一个线程封锁了锁对象,其他线程都无法进入。把解锁操作放到finally区域内是十分必要的,如果因为某些异常,锁资源是必须要释放的,否则其他资源将被永久阻塞。进入临界区时,却发现在某一个条件满足之后它才能执行,这时可以用一个条件对象来管理那些已经获得了一把锁但是却不能做有用工作的线程,条件对象又被称作条件变量。通过下面例子来说明为何需条件对象。假设一个场景需要用支付宝转账,我们先写支付宝类,它的构造方法需传入支付宝账户的数量和每个账户的账户金额。
public class Alipay{
private double[] accounts;
private Lock alipayLock;
public Alipay(int n, double money){
accounts = new double[n];
alipayLock = new ReentrantLock();
for(int i = 0; i < accounts.length; i++){
accounts[i] = money;
}
}
}
接下来实现转账,需要一个from转账方,和to接收方,amount是转账金额,如下
public void transfer(int from, int to, int amount){
alipayLock.lock();
try {
while (accounts[from] < amount){
//wait
}
}catch (){
}finally {
alipayLock.unlock();
}
}
有可能会出现转账方余额不足的情况,如果有其他线程给这个转账方再转足够的钱,就可以转账成功了,但是这个线程已经获取了锁,具有排他性,别的线程无法获取锁来进行存款操作,这时我们就需要引入对象锁。一个锁对象拥有多个相关条件对象,可以用new Condition方法获得一个条件对象,我们得到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁,相关代码如下
public class Alipay{
private double[] accounts;
private Lock alipayLock;
private Condition condition;
public Alipay(int n, double money){
accounts = new double[n];
alipayLock = new ReentrantLock();
condition = alipayLock.newCondition();
for(int i = 0; i < accounts.length; i++){
accounts[i] = money;
}
}
public void transfer(int from, int to, int amount) throws InterruptedException{
alipayLock.lock();
try {
while (accounts[from] < amount){
condition.await();
}
}catch (){
}finally {
alipayLock.unlock();
}
}
}
一旦一个线程调用await方法,就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同一个条件的signalAll()方法时为止。当另一个线程转账给我们此前的转账方时,只重复调用singnalAll()方法,就会重新激活因为这一条件而等待的所有线程,代码如下
public void transfer(int from, int to, int amount) throws InterruptedException{
alipayLock.lock();
try {
while (accounts[from] < amount){
condition.await();
}
accounts[from] = accounts[from] - amount;
accounts[to] = accounts[to] + amount;
condition.signalAll();
}catch (){
}finally {
alipayLock.unlock();
}
}
当调用了signalAll时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能够在当前线程退出同步方法后,通过竞态实现对对象的访问,还有个方法是signal,它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞,如果没有其他线程再次调用signal,那么系统就死锁了
2. 同步方法
Lock接口和Condition接口为程序设计提供了高度的锁定控制,然而大多数情况下并不需要那样的控制,并且可以使用一种嵌入到Java语言内部的机制。Java中每一个对象都有一个内部锁,如果一个方法用synchronized关键字修饰,那么对象的锁将保护整个方法,也就是说,要调用该方法,线程必须获得内部的对象锁,如下
public synchronized void method(){
···
}
这段代码等价于
Lock mLock = new ReentrantLock();
public void method(){
mLock.lock();
try{
···
}finally{
mLock.unlock();
}
}
对于上面转账的例子,可以将Alipay的transfer方法声明为synchronized,而不是使用一个显示的锁。内部对象锁只有一个相关条件,wait方法将一个线程添加到等待集中,使用notifyAll或notify方法解除等待线程的阻塞状态。也就是说wait相当于调用condition.await(),notifyAll等价于signalAll,所以前面例子里的transfer方法也可以这么写
public synchronized void transfer(int from, int to, int amount) throws InterruptedException{
while (accounts[from] < amount){
wait();
}
accounts[from] = accounts[from] - amount;
accounts[to] = accounts[to] + amount;
notifyAll();
}
在此可以看到,使用sychronized关键字来编码要简练很多,由该锁来管理那些试图进入synchronized方法的线程,由该锁中的条件来管理那些调用wait的线程
3. 同步代码块
除了调用同步方法来获得锁,还可以通过使用同步代码块,如下
synchronized(obj){
···
}
其获得了obj的锁,obj是一个对象,我们用同步代码块进行改写上面的例子
public class Alipay{
private double[] accounts;
private Object lock = new Object();
private Condition condition;
public Alipay(int n, double money){
accounts = new double[n];
for(int i = 0; i < accounts.length; i++){
accounts[i] = money;
}
}
public synchronized void transfer(int from, int to, int amount) throws InterruptedException{
synchronized (lock){
accounts[from] = accounts[from] - amount;
accounts[to] = accounts[to] + amount;
}
}
}
在这里创建了一个名为lock的Object类,为的是使用Object类所持有的锁。同步代码块是非常脆弱的,通常不推荐使用,一般实现同步最好用Java的并发包下的集合类,比如阻塞队列。如果同步方法适合自己的程序,尽量使用同步方法,这样可以减少编写代码的数量,减少出错的概率,如果特别需要使用Lock/Condition结构提供的独有特性时,才使用Lock/Condition
4. volatile
有时,仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大,而volatile关键字为实例域的同步访问提供了免锁机制。如果声明一个域为volatile的话,那么编译器和虚拟机知道该域是可能被另一个线程并发更新的。当一个共享变量被volatile关键字修饰后,就具备了两个含义,一个含义是线程修改了变量的值时,变量的新值对于其他线程是立即可见的。另一个含义是禁止使用指令重排序,分为编译期重排序和运行时重排序。先来看一段代码,假设线程1先执行,2后执行,如下
//线程1
boolean stop = false;
while(!stop){
//doSomething
}
//线程2
stop = true;
这是一个线程中断的代码,但是这段代码不一定会将线程中断,虽说无法中断线程这个情况出现的概率很小,但是一旦发生便是死循环。因为每个线程都有私有的工作内存,因此线程1运行时会拷贝一份stop的值放入私有工作内存中,当线程2更改了stop的变量值并返回后,线程2突然需要做其他操作,这时就无法将更改的stop变量写入主存中,这样线程1就不知道线程2对stop变量进行了更改,因此线程1会一直执行下去。当stop用volatile修饰,线程2修改stop值时,会强制将修改的值立刻写入主存,这样使得线程1的工作内存中的stop变量缓存无效,这样线程1在此读取变量stop的值时就会去主存读取
volatile不保证原子性
另外volatile不保证原子性,可看如下代码演示
class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(() -> {
for(int j=0;j<1000;j++)
test.increase();
}).start();
}
//保证前面的线程都执行完
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.inc);
}
}
这段代码每次运行的结果都不一致,因为自增操作是不具备原子性的。自增操作里包含了读取原始值、加1、写入工作内存这三个子操作,也就是说这三个子操作可能被割裂执行。
volatile保证有序性
volatile关键字能禁止指令重排序,因此能保证有序性。禁止指令重排序有两层含义,其一是指代码运行到volatile变量操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还未执行。其二是进行指令优化时,在volatile变量之前的语句不能在volatile变量之后执行
正确使用volatile关键字
synchronized关键字可防止多个线程同时执行一段代码,但是这会很影响程序的执行效率。volatile关键字在有些时候会优于synchronized关键字。但是要注意volatile关键字时无法替代synchronized关键字的,因为其无法保证原子性,通常来说,使用volatile关键字需要具备以下两个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
关于第一点,就是上面提到的自增自减操作。关于第二点,举个例子,包含一个不变式:下界总是小于或等于上界,代码如下
public class NumberRange {
private volatile int lower, upper;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
这种方式定义的upper和lower并不能充分实现类的线程安全,如果两个线程在同一时间使用不一致的值执行setLower和setUpper的话,就会使范围处于不一致的状态。例如,如果初始状态是(0,5),同一时间内,两个线程分别调用setLower(4)和setUpper(3),虽然这两个交叉存入的值是不符合条件的,但是这两个线程都会通过用于保护不变式的检查,使得最后范围是(4,3),显然是不对的
阻塞队列
1. 简介
阻塞队列常用于生产者和消费者场景,生产者是往队列里存放元素的线程,消费者是从队列里取元素的线程。简单来说,阻塞队列就是一个容器。
2. 常见阻塞场景
- 在队列中没有数据的情况下,消费者端所有线程都会被自动阻塞
- 在队列中填满数据的情况下,生产者端所有线程都会被自动阻塞
支持以上两种阻塞场景的队列成为阻塞队列
3. Java中的阻塞队列
Java中提供了七个阻塞队列,如下
- ArrayBlockingQueue:由数组结构组成有界阻塞队列
- LinkedBlockingQueue:由链表结构组成有界阻塞队列
- PriorityBlockingQueue:支持优先级排序的无解阻塞队列
- DelayQueue:支持延时获取元素的无界阻塞队列
- SynchronousQueue:不存储元素的阻塞队列
- LinkedTransferQueue:由链表结构组成的无界阻塞队列
- LinkedBlockingDeque:由链表组成的双向阻塞队列
4. 阻塞队列实现原理
以ArrayBlockingQueue为例
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
private static final long serialVersionUID = -817911632652898426L;
final Object[] items;
int takeIndex;
int putIndex;
int count;
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
可以看出底层维护了一个Object[]类型数组,takeIndex和putInde分别表示队首和队尾元素下标,count表示队列中元素的个数,lock则是一个可重入锁。notEmpty和notFull是等待条件,看一下关键方法put
put
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
可以看出先获取的锁,并且是可中断锁,然后判断当前元素的个数是否等于数组的长度,如果相等,则调用notFull.await()等待。当此线程被其他线程唤醒时,通过enqueue(e)方法插入元素,最后解锁,来看一下enqueue方法
enqueue
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
notEmpty.signal();
}
插入成功后,通过notEmpty唤醒正在等待取元素的线程,查看take方法
take
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
与put方法类似,put方法等待的是notFull信号,而take方法等待的是notEmpty信号。在take方法中,如果可以取元素,通过dequeue方法取得,如下
dequeue
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
notFull.signal();
return x;
}
与enqueue方法类似,在获取元素后,通过notFull的signal方法来唤醒正在等待插入元素的线程
5. 阻塞队列的使用场景
线程池,生产者消费者模式
线程池
编程中经常会使用线程来异步处理任务,但是每个线程的创建和销毁都需要一定的开销,如果每次执行一个任务都需要新开一个线程去执行,则这些线程的创建和销毁将消耗大量资源,并且线程各自独立,很难管理。这时,Java中采用Executor框架把任务的提交和执行解耦,任务的提交交给Runnable或者Callable,而Executor框架用来处理任务。这个框架中最核心的成员就是ThreadPoolExecutor,是线程池实现的核心类,下面详细说明
1. ThreadPoolExecutor
可以通过ThreadPoolExecutor来创建一个线程池,这个类有4个构造方法,其中拥有最多参数的构造方法如下
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
分别解释一下参数含义
- corePoolSize:核心线程数。默认为空,只有任务提交时才创建线程。如果当前线程数少于corePoolSize,就创建新线程处理任务。如果当前线程数多于corePoolSize,就不再创建新线程。如果调用线程池的prestartAllcoreThread方法,则线程池会提前创建并启动所有核心线程来等待任务
- maximumPoolSize:线程池允许创建的最大线程数。如果任务队列满了并且线程数小于maximumPoolSize时,则线程池仍旧会创建新的线程来处理任务
- keepAliveTime:非核心线程闲置的超过时间。如果超过这个时间,非核心线程会被回收。如果任务很多,并且每个任务的执行时间都很短,则可以调大keepAliveTime来提高线程利用率。另外,如果设置allowCoreThreadTimeOut属性为true时,keepAliveTime也会应用到核心线程上
- TimeUnit:keepAliveTime参数的时间单位。可选有天、小时、分钟、秒等
- workQueue:任务队列。如果当前线程数大于corePoolSize,则将任务添加到此任务队列中,该任务队列时BlockingQueue类型的,也就是阻塞队列
- ThreadFactory:线程工厂。可以用线程工厂给每个创建出来的线程设置名字,一般情况下无需设置该参数
- RejectedExecutionHandler:饱和策略。这是当任务队列和线程池都满了时所采取的应对策略,默认是AbortPolicy,表示无法处理新任务,并抛出RejectedExecutionException异常。此外,还有三种策略,分别是
- CallerRunsPolicy:用调用者所在的线程来处理任务。此策略提供简单的反馈控制机制,能够减缓新任务的提交速度
- DiscardPolicy:不能执行的任务,并将该任务删除
- DiscardOldestPolicy:丢弃队列最近的任务,并执行当前任务
2. 线程池的处理流程和原理
当向线程池提交任务时,线程池的处理流程如下
具体可分为三个步骤
- 提交任务后,线程池先判断线程数是否达到了核心线程数。如果未达到,就创建核心线程处理任务,否则执行下一步操作
- 接着线程池判断任务队列是否已满,如果没满,就将任务添加到任务队列,否则执行下一步操作
- 这时,因为任务队列满了,所以线程池就判断线程数是否达到了最大线程数。如果未达到最大线程数,就创建非核心线程处理任务。否则就执行饱和策略,默认会抛出RejectedExecutionException
如果进一步深入每一步的细节,那么可以画出如下概念图
概念图可以看出,我们执行execute方法,会遇到很多情况:
- 如果线程池中的线程数未达到核心线程数,则创建核心线程处理任务
- 如果线程数大于或等于核心线程数,则将任务加入队列,线程池中的空闲线程会不断的从任务队列中取出任务进行处理
- 如果任务队列满了,并且线程数没有达到最大线程数,则创建非核心线程去处理任务
- 如果线程数超过了最大线程数,则执行饱和策略
3. 线程池的种类
通过直接或间接的配置ThreadPoolExecutor的参数可以创建不同类型的线程池,其中有四种比较常用,分别为
- FixedThreadPool:可重用固定线程数,只有核心线程
- CachedThreadPool:根据需要创建线程,没有核心线程
- SingleThreadExecutor:使用单个工作线程,只有一个核心线程
- ScheduledThreadPool:实现定时和周期任务的线程池