04 多线程编程

2019/03/27 posted in  Android进阶之光

4.1 线程基础

4.1.1 进程与线程

1.什么是进程
进程是操作系统结构的基础,是程序在一个数据集合上运行的过程,是系统进行资源分配和调度的基本单位。进程可以被看作程序的实体,同样,它也是线程的容器。进程就是程序的实体,是受操作系统管理的基本运行单元。
2.什么是线程
线程是操作系统调度的最小单元,也叫作轻量级进程。在一个进程中可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。
3.为何要使用多线程
在操作系统级别上来看主要有以下几个方面:

  • 使用多线程可以减少程序的响应时间,使程序具备了更好的交互性。
  • 与进程相比,线程创建和切换开销更小,同时多线程在数据共享方面效率非常高。
  • 避免多CPU或者多核计算机的资源浪费,提高CPU的利用率。
  • 使用多线程能简化程序的结构,使程序便于理解和维护。

4.1.2 线程的状态

Java线程在运行的声明周期中可能会处于6种不同的状态,这6种线程状态分别为如下所示。

  • New:新创建状态。线程被创建,还没有调用 start 方法,在线程运行之前还有一些基础工作要做。
  • Runnable:可运行状态。一旦调用start方法,线程就处于Runnable状态。一个可运行的线程可能正在 运行也可能没有运行,这取决于操作系统给线程提供运行的时间。
  • Blocked:阻塞状态。表示线程被锁阻塞,它暂时不活动。
  • Waiting:等待状态。线程暂时不活动,并且不运行任何代码,这消耗最少的资源,直到线程调度器重新激活它。
  • Timed waiting:超时等待状态。和等待状态不同的是,它是可以在指定的时间自行返回的。
  • Terminated:终止状态。表示当前线程已经执行完毕。导致线程终止有两种情况:第一种就是run方法执行完毕正常退出;第二种就是因为一个没有捕获的异常而终止了run方法,导致线程进入终止状态。

4.1.3 创建线程

多线程的实现一般有以下3种方法,其中前两种为最常用的方法。

  1. 继承Thread类,重写run()方法
  2. 实现Runnable接口,并实现该接口的run()方法
  3. 实现Callable接口,重写call()方法

Callable接口实际是属于Executor框架中的功能类,Callable接口与Runnable接口的功能类似,但提供了比Runnable更强大的功能,主要表现为以下3点:

  1. Callable可以在任务接受后提供一个返回值;
  2. Callable中的call()方法可以抛出异常;
  3. 运行Callable可以拿到一个Future对象,Future对象表示异步计算的结果,它提供了检查计算是否完成的方法。由于线程属于异步计算模型,因此无法从别的线程中得到函数的返回值,在这种情况下就可 以使用 Future 来监视目标线程调用 call()方法的情况。但调用 Future的get()方法以获取结果时,当前线程就会阻塞,直到call()方法返回结果。

4.1.4 理解中断

当线程的run方法执行完毕,或者在方法中出现没有捕获的异常时,线程将终止。

  • interrupt()方法可以用来请求中断线程。当一个线程调用 interrupt 方法时,线程的中断标识位将被置位(中断标识位为true),线程会不时地检测这个中断标识位,以判断线程是否应该被中断。
  • Thread.currentThread().isInterrupted() 判断线程是否被置位
  • Thread.interrupted(),对中断标识位进行复位。

但是如果一个线程被阻塞,就无法检测中断状态。如果一个线程处于阻塞状态,线程在检查中断标识位时如果发现中断标识位为true,则会在阻塞方法调用处抛出InterruptedException异常,并且在抛出异常前将线程的中断标识位复位,即重新设置为false。需要注意的是被中断的线程不一定会终止,中断线程是为了引起线程的注意,被中断的线程可以决定如何 去响应中断。如果是比较重要的线程则不会理会中断,而大部分情况则是线程会将中断作为一个终止的请求。

抛出InterruptedException异常后合理的处理方式:

  1. 在catch子句中,调用Thread.currentThread.interrupt()来设置中断状态(因为抛出异常后中断标识位会复位),让外界通过判断Thread.currentThread().isInterrupted()来决定是否终止线程还是继续下去。

    void my Task() {
        try {
    sleep(50)
    } catch(InterruptedException) {
    Thread.currentThread().interrupted();
    //...
    }
    }
  2. 更好的做法就是,不使用try来捕获这样的异常,让方法直接抛出,这样调用者可以捕获这个异常。

    void myTask() throw InterruptedException {
        sleep(50)
    }

4.1.5 安全地终止线程

4.2 同步

在多线程应用中,两个或者两个以上的线程需要共享对同一个数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法, 这种情况通常被称为竞争条件。比如很多人同时买火车票,如果不使用同步保证其原子性,有可能出现两个人买同一张票的情况。解决方法如下:当一个线程要使用火车票这个资源时,我们就交给它一把锁,等它把事情做完后再把锁给另一个要用这个资源的线程。这样就不会出现上述情况了。

4.2.1 重入锁与条件对象

synchronized 关键字自动提供了锁以及相关的条件。重入锁ReentrantLock是 Java SE 5.0引入的,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。用 ReentrantLock保护代码块的结构如下所示:

Lock mLock = new ReentrantLock();
mLock.lock() ;
try {
//...
} finally {
mLock.unlock() ;
}

这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其他任何线程都无法进入Lock语句。把解锁的操作放在finally中是十分必 要的。如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远被阻塞。进入临界区时,却 发现在某一个条件满足之后,它才能执行。这时可以使用一个条件对象来管理那些已经获得了一个锁但是 却不能做有用工作的线程,条件对象又被称作条件变量

一个锁对象拥有多个相关的条件对象,可以用newCondition方法获得一个条件对象Condition,我们得到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁。一旦一个线程调用 await 方法,它就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同 一个条件的signalAll方法时为止。调用signalAll方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能 够在当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法是signal,它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。

4.2.2 同步方法

如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。

public synchronized void method() {
}

等价于:

Lock mLock = new ReentrantLock();
public void method() {
    mLock.lock();
    try {
    } finally {
        mLock.unlock();
    }
}

4.2.3 同步代码块

其获得了obj的锁,obj指的是一个对象。

synchronized(obj) {
}

同步代码块是非常脆弱的, 通常不推荐使用。一般实现同步最好用java.util.concurrent包下提供的类,比如阻塞队列。

4.2.4 volatile

有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大;而volatile关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个 线程并发更新的。再讲到volatile关键字之前,我们需要了解一下内存模型的相关概念以及并发编程中的3个 特性:原子性、可见性和有序性。

1.Java内存模型

Java中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此,它存在内存可见性的问题。而局部变量、方法定义的参数则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。Java 内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中, 每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。需要注意的是本地内存是 Java内存模型的一个抽象概念,其并不真实存在,它涵盖了缓存、写缓冲区、寄存器等区域。Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。

线程A与线程B之间若要通信的话,必须要经历下面两个步骤:

  1. 线程A把线程A本地内存中更新过的共享变量刷新到主存中去。
  2. 线程B到主存中去读取线程A之前已更新过的共享变量。

2.原子性、可见性和有序性

  • 原子性
    • 对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行完毕, 要么就不执行。一个语句含有多个操作时,就不是原子性操作。
    • java.util.concurrent.atomic 包中有 很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。例如 AtomicInteger 类提供 了方法incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数自增和自减。可以安全地使用 AtomicInteger类作为共享计数器而无须同步。另外这个包还包含AtomicBoolean、AtomicLong和 AtomicReference这些原子类,这仅供开发并发工具的系统程序员使用,应用程序员不应该使用这些类。
  • 可见性
    • 可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。
    • 当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,所以对其他线程是可见的。当有其他线程需要读取该值时,其他线程会去主存中读取新值。
    • 普通的共享变量不能保证可见性,因为普通共享变量被修改之后,并不会立即被写入主存,何时被写入主存也是不确定的。当其他线程去读取该值时,此时主存中可能还是原来的旧值,这样就无法保证可见性。
  • 有序性
    • Java内存模型中允许编译器和处理器对指令进行重排序,虽然重排序过程不会影响到单线程执行的正确性,但是会影响到多线程并发执行的正确性。
    • 可以通过volatile来保证有序性;
    • 还可以通 过synchronized和Lock来保证有序性。我们知道,synchronized和Lock保证每个时刻只有一个线程执行同步代码,这相当于是让线程顺序执行同步代码,从而保证了有序性。

3.volatile关键字

当一个共享变量被volatile修饰之后,其就具备了两个含义,一个是线程修改了变量的值时,变量的新值对其他线程是立即可见的,即不同线程对这个变量进行操作时具有可见性。另一个含义是禁止使用指令重排序。

什么是重排序呢?重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。

volatile不保证操作原子性
volatile保证有序性

4.正确使用volatile关键字

synchronized关键字可防止多个线程同时执行一段代码,那么这就会很影响程序执行效率。而volatile关键字在某些情况下的性能要优于synchronized。但是要注意volatile关键字是无法替代synchronized关键字的, 因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下两个条件:

  1. 对变量的写操作不会依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

使用volatile有很多种场景,这里介绍其中的两种。

  1. 状态标志

    volatile boolean shutdownRequested;
    public void shutdown () {
    shutdownRequested = true;
    }
    public void doWork() {
    while (!shutdownRequested) {
    }
    }
  2. 双重检查模式(DCL)

4.3 阻塞队列

4.3.1 阻塞队列简介

阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元 素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

1.常见阻塞场景

阻塞队列有两个常见的阻塞场景,它们分别是:

  1. 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。队列空,消费者堵塞。
  2. 当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒。队列满,生产者堵塞。

支持以上两种阻塞场景的队列被称为阻塞队列。

2.BlockingQueue的核心方法

  • 放入数据
    • offer(anObject):表示如果可能的话,将anObject加到BlockingQueue里。即如果BlockingQueue可以容纳,则返回true,否则返回false。(本方法不阻塞当前执行方法的线程。)
    • offer(E o,long timeout,TimeUnit unit):可以设定等待的时间。如果在指定的时间内还不能往队列中加入BlockingQueue,则返回失败。
    • put(anObject):将anObject加到BlockingQueue里。如果BlockQueue没有空间,则调用此方法的线程被阻断,直到BlockingQueue里面有空间再继续。
  • 获取数据:
    • poll(time):取走BlockingQueue 里排在首位的对象。若不能立即取出,则可以等 time参数规定的时间。取不到时返回null。
    • poll(long timeout,TimeUnit unit):从BlockingQueue中取出一个队首的对象。如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据;否则直到时间超时还没有数据可取,返回失败。
    • take():取走BlockingQueue里排在首位的对象。若BlockingQueue为空,则阻断进入等待状态,直到 BlockingQueue有新的数据被加入。
    • drainTo():一次性从BlockingQueue获取所有可用的数据对象(还可以指定获取数据的个数)。通过该方法,可以提升获取数据的效率;无须多次分批加锁或释放锁。

4.3.2 Java中的阻塞队列

在Java中提供了7个阻塞队列,它们分别如下所示。

  • ArrayBlockingQueue:由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
  • DelayQueue:使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:不存储元素的阻塞队列。
  • LinkedTransferQueue:由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:由链表结构组成的双向阻塞队列。

4.3.3 阻塞队列的实现原理

4.3.4 阻塞队列的使用场景

4.4 线程池

在编程中经常会使用线程来异步处理任务,但是每个线程的创建和销毁都需要一定的开销。如果每次 执行一个任务都需要开一个新线程去执行,则这些线程的创建和销毁将消耗大量的资源;并且线程都是“各 自为政”的,很难对其进行控制,更何况有一堆的线程在执行。这时就需要线程池来对线程进行管理。在 Java 1.5中提供了Executor框架用于把任务的提交和执行解耦,任务的提交交给Runnable或者Callable,而 Executor框架用来处理任务。Executor框架中最核心的成员就是 ThreadPoolExecutor,它是线程池的核心实现 类。

4.4.1 ThreadPoolExecutor

ThreadPoolExecutor构造方法:

public ThreadPoolExecutor(
    int corePoolSize,   //核心线程数
    int maximumPoolSize,//最大线程数
    long keepAliveTime,//非核心线程超时时间
    TimeUnit unit,//keepAliveTime参数的时间单位
    BlockingQueue<Runnable> workQueue,//任务队列
    ThreadFactory threadFactory,//线程工厂,可创建新线程
    RejectedExecutionHandler handler//饱和策略
)

4.4.2 线程池的处理流程和原理

4.4.3 线程池的种类

通过直接或者间接地配置ThreadPoolExecutor的参数可以创建不同类型的ThreadPoolExecutor,其中有 4 种线程池比较常用,它们分别是 FixedThreadPool、CachedThreadPool、SingleThreadExecutor和 ScheduledThreadPool。下面分别介绍这4种线程池。

1.FixedThreadPool

FixedThreadPool 是可重用固定线程数的线程池。

FixedThreadPool就是一个有固定数量核心线程的线程池,并且这些核心线程不会被回收。当线程数超过 corePoolSize 时,就将任务存储在任务队列中;当线程池有空闲线程时,则从任务队列中去取任务执行。

2.CachedThreadPool

CachedThreadPool是一个根据需要创建线程的线程池。CachedThreadPool 比较适于大量的需要立即处理并且耗时较少的任务。

3.SingleThreadExecutor

SingleThreadExecutor是使用单个工作线程的线程池。能确保所有的任务在一个线程中按照顺序逐一执行。

4.ScheduledThreadPool

ScheduledThreadPool是一个能实现定时和周期性任务的线程池。

4.5 AsyncTask的原理

4.6 本章小结