Android多线程的一切

1. 基本介绍**

**
**

在我学习 Android 多线程优化方法的过程中,发现我对多线程优化的了解太片面。

写这篇文章的目的是完善我对 Android 多线程优化方法的认识,分享这篇文章的目的是希望大家也能从这些知识从得到一些启发。

这篇文章分为下面三部分。

  • 第一部分讲的是多线程优化的基础知识,包括线程的介绍和线程调度基本原理的介绍。
  • 第二部分讲的是多线程优化需要预防的一些问题,包括线程安全问题的介绍和实现线程安全的办法。
  • 第三部分讲的是多线程优化可以使用的一些方法,包括线程之间的协作方式与 Android 执行异步任务的常用方式。

2. 阅读技巧

**
**

阅读本文时,可以带着下面这些问题边思考边阅读。

  • 这个说法的依据是什么?
  • 怎么以自己的方式去解释这个概念?
  • 怎么在自己的项目中应用这个技巧?
  • 这个概念的具体代码实现是怎样的?
  • 这个实现存在哪些问题?

3. 缩略词

**
**

  • GC

    • Garbage Collector(垃圾回收器)
    • Garbage Collection(垃圾回收动作)
  • ART

    Android Runtime(Android 应用运行时环境)

  • JVM

    Java Virtual Machine(Java 虚拟机)

  • JUC

    java.util.concurrent(Java 并发包)

1

什么是线程?

我们这一节的内容包括下面几个部分。

  • 线程简介
  • 线程的四个属性
  • 线程的六个方法
  • 线程的六种状态

1.1 线程

**
**

线程是进程中可独立执行的最小单位,也是 CPU 资源分配的基本单位。

进程是程序向操作系统申请资源的基本条件,一个进程可以包含多个线程,同一个进程中的线程可以共享进程中的资源,如内存空间和文件句柄。

操作系统会把资源分配给进程,但是 CPU 资源比较特殊,它是分配给线程的,这里说的 CPU 资源也就是 CPU 时间片。

进程与线程的关系,就像是饭店与员工的关系,饭店为顾客提供服务,而提供服务的具体方式是通过一个个员工实现的。

线程的作用是执行特定任务,这个任务可以是下载文件、加载图片、绘制界面等。

1.2 线程的四个属性

**
**

线程有编号、名字、类别以及优先级四个属性,除此之外,线程的部分属性还具有继承性,下面我们就来看看线程的四个属性的作用和线程的继承性。

1.2.1 编号

**
**

  • 作用

    线程的编号(id)用于标识不同的线程,每条线程拥有不同的编号。

  • 注意事项

    • 不能作为唯一标识

      某个编号的线程运行结束后,该编号可能被后续创建的线程使用,因此编号不适合用作唯一标识

    • 只读

      编号是只读属性,不能修改

1.2.2 名字

**
**

每个线程都有自己的名字(name),名字的默认值是 Thread-线程编号,比如 Thread-0 。

除了默认值,我们也可以给线程设置名字,以我们自己的方式去区分每一条线程。

  • 作用

    给线程设置名字可以让我们在某条线程出现问题时,用该线程的名字快速定位出问题的地方

1.2.3 类别

**
**

线程的类别(daemon)分为守护线程和用户线程,我们可以通过 setDaemon(true) 把线程设置为守护线程。

当 JVM 要退出时,它会考虑是否所有的用户线程都已经执行完毕,是的话则退出。

而对于守护线程,JVM 在退出时不会考虑它是否执行完成。

  • 作用

    守护线程通常用于执行不重要的任务,比如监控其他线程的运行情况,GC 线程就是一个守护线程。

  • 注意事项

    setDaemon() 要在线程启动前设置,否则 JVM 会抛出非法线程状态异常(IllegalThreadStateException)。

1.2.4 优先级

**
**

  • 作用

    线程的优先级(Priority)用于表示应用希望优先运行哪个线程,线程调度器会根据这个值来决定优先运行哪个线程。

  • 取值范围

    Java 中线程优先级的取值范围为 1~10,默认值是 5,Thread 中定义了下面三个优先级常量。

    • 最低优先级:MIN_PRIORITY = 1
    • 默认优先级:NORM_PRIORITY = 5
    • 最高优先级:MAX_PRIORITY = 10
  • 注意事项

    • 不保证

      线程调度器把线程的优先级当作一个参考值,不一定会按我们设定的优先级顺序执行线程

    • 线程饥饿

      优先级使用不当会导致某些线程永远无法执行,也就是线程饥饿的情况,关于线程饥饿,在第 7 大节会有更多的介绍

1.2.5 继承性

**
**

线程的继承性指的是线程的类别和优先级属性是会被继承的,线程的这两个属性的初始值由开启该线程的线程决定。

假如优先级为 5 的守护线程 A 开启了线程 B,那么线程 B 也是一个守护线程,而且优先级也是 5 。

这时我们就把线程 A 叫做线程 B 的父线程,把线程 B 叫做线程 A 的子线程。

1.3 线程的六个方法

**
**

线程的常用方法有六个,它们分别是三个非静态方法 start()、run()、join() 和三个静态方法 currentThread()、yield()、sleep() 。

下面我们就来看下这六个方法都有哪些作用和注意事项。

1.3.1 start()

**
**

  • 作用

    start() 方法的作用是启动线程。

  • 注意事项

    该方法只能调用一次,再次调用不仅无法让线程再次执行,还会抛出非法线程状态异常。

1.3.2 run()

**
**

  • 作用

    run() 方法中放的是任务的具体逻辑,该方法由 JVM 调用,一般情况下开发者不需要直接调用该方法。

  • 注意事项

    如果你调用了 run() 方法,加上 JVM 也调用了一次,那这个方法就会执行两次

1.3.3 join()

**
**

  • 作用

    join() 方法用于等待其他线程执行结束。

    如果线程 A 调用了线程 B 的 join() 方法,那线程 A 会进入等待状态,直到线程 B 运行结束。

  • 注意事项

    join() 方法导致的等待状态是可以被中断的,所以调用这个方法需要捕获中断异常

1.3.4 Thread.currentThread()

**
**

  • 作用

    currentThread() 方法是一个静态方法,用于获取执行当前方法的线程。

    我们可以在任意方法中调用 Thread.currentThread() 获取当前线程,并设置它的名字和优先级等属性。

1.3.5 Thread.yield()

**
**

  • 作用

    yield() 方法是一个静态方法,用于使当前线程放弃对处理器的占用,相当于是降低线程优先级。

    调用该方法就像是是对线程调度器说:“如果其他线程要处理器资源,那就给它们,否则我继续用”。

  • 注意事项

    该方法不一定会让线程进入暂停状态。

1.3.6 Thread.sleep(ms)

**
**

  • 作用

    sleep(ms) 方法是一个静态方法,用于使当前线程在指定时间内休眠(暂停)。

线程不止提供了上面的 6 个方法给我们使用,而其他方法的使用在文章的后面会有一个更详细的介绍。

1.4 线程的六种状态

1.4.1 线程的生命周期

**
**

和 Activity 一样,线程也有自己的生命周期,而且生命周期事件也是由用户(开发者)触发的。

从 Activity 的角度来看,用户点击按钮后打开一个 Activity,就相当于是触发了 Activity 的 onCreate() 方法。

从线程的角度来看,开发者调用了 start() 方法,就相当于是触发了 Thread 的 run() 方法。

如果我们在上一个 Activity 的 onPause() 方法中进行了耗时操作,那么下一个 Activity 的显示也会因为这个耗时操作而慢一点显示,这就相当于是 Thread 的等待状态。

线程的生命周期不仅可以由开发者触发,还会受到其他线程的影响,下面是线程各个状态之间的转换示意图。

img

我们可以通过 Thread.getState() 获取线程的状态,该方法返回的是一个枚举类 Thread.State。

线程的状态有新建、可运行、阻塞、等待、限时等待和终止 6 种,下面我们就来看看这 6 种状态之间的转换过程。

1.4.2 新建状态

**
**

当一个线程创建后未启动时,它就处于新建(NEW)状态。

1.4.3 可运行状态

**
**

当我们调用线程的 start() 方法后,线程就进入了可运行(RUNNABLE)状态。

可运行状态又分为预备(READY)和运行(RUNNING)状态。

  • 预备状态

    处于预备状态的线程可被线程调度器调度,调度后线程的状态会从预备转换为运行状态,处于预备状态的线程也叫活跃线程。

  • 运行状态

    运行状态表示线程正在运行,也就是处理器正在执行线程的 run() 方法。

    当线程的 yield() 方法被调用后,线程的状态可能由运行状态变为预备状态。

1.4.4 阻塞状态

**
**

当下面几种情况发生时,线程就处于阻塞(BLOCKED)状态。

  • 发起阻塞式 I/O 操作
  • 申请其他线程持有的锁
  • 进入一个 synchronized 方法或代码块失败

1.4.5 等待状态

**
**

一个线程执行特定方法后,会等待其他线程执行执行完毕,此时线程进入了等待(WAITING)状态。

  • 等待状态

    下面的几个方法可以让线程进入等待状态。

    • Object.wait()
    • LockSupport.park()
    • Thread.join()
  • 可运行状态

    下面的几个方法可以让线程从等待状态转变为可运行状态,而这种转变又叫唤醒。

    • Object.notify()
    • Object.notifyAll()
    • LockSupport.unpark()

1.4.6 限时等待状态

**
**

限时等待状态 (TIMED_WAITING)与等待状态的区别就是,限时等待是等待一段时间,时间到了之后就会转换为可运行状态。

下面的几个方法可以让线程进入限时等待状态,下面的方法中的 ms、ns、time 参数分别代表毫秒、纳秒以及绝对时间。

  • Thread.sleep(ms)
  • Thread.join(ms)
  • Object.wait(ms)
  • LockSupport.parkNonos(ns)
  • LockSupport.parkUntil(time)

1.4.7 终止状态

当线程的任务执行完毕或者任务执行遇到异常时,线程就处于终止(TERMINATED)状态。

2

线程调度的原理是什么?

阅读完上一节的内容后,我们对线程有了基本的了解,知道了什么是线程,也知道了线程的生命周期是怎么流转的。

这一节我们就来看看线程是怎么被调度的,这一节包括以下内容。

  • Java 内存模型简介
  • 高速缓存
  • Java 线程调度机制

2.1 Java 的内存模型简介

了解 Java 的内存模型,能帮助我们更好地理解线程的安全性问题,下面我们就来看看什么是 Java 的内存模型。

img

Java 内存模型(Java Memory Model,JMM)规定了所有变量都存储在主内存中,每条线程都有自己的工作内存。

JVM 把内存划分成了好几块,其中方法区和堆内存区域是线程共享的。

假如现在有三个线程同时对值为 5 的变量 a 进行自增操作,那最终的结果应该是 8 。

但是自增的真正实现是分为下面三步的,而不是一个不可分割的(原子的)操作。

  1. 将变量 a 的值赋值给临时变量 temp
  2. 将 temp 的值加 1
  3. 将 temp 的值重新赋给变量 a。

假如线程 1 在进行到第二步的时候,其他两条线程读取了变量 a ,那么最终的结果就是 7,而不是预期的 8 。

这种现象就是线程安全的其中一个问题:原子性。

2.2 高速缓存

**
**

2.2.1 高速缓存简介

img

现代处理器的处理能力要远胜于主内存(DRAM)的访问速率,主内存执行一次内存读/写操作需要的时间,如果给处理器使用,处理器可以执行上百条指令。

为了弥补处理器与主内存之间的差距,硬件设计者在主内存与处理器之间加入了高速缓存(Cache)。

处理器执行内存读写操作时,不是直接与主内存打交道,而是通过高速缓存进行的。

高速缓存相当于是一个由硬件实现的容量极小的散列表,这个散列表的 key 是一个对象的内存地址,value 可以是内存数据的副本,也可以是准备写入内存的数据。

2.2.2 高速缓存内部结构

img

从内部结构来看,高速缓存相当于是一个链式散列表(Chained Hash Table),它包含若干个桶,每个桶包含若干个缓存条目(Cache Entry)。

2.2.3 缓存条目结构

img

缓存条目可进一步划分为 Tag、Data Block 和 Flag 三个部分。

  • Tag

    Tag 包含了与缓存行中数据对应的内存地址的部分信息(内存地址的高位部分比特)

  • Data Block

    Data Block 也叫缓存行(Cache Line),是高速缓存与主内存之间数据交换的最小单元,可以存储从内存中读取的数据,也可以存储准备写进内存的数据。

  • Flag

    Flag 用于表示对应缓存行的状态信息

2.3 Java 线程调度原理

**
**

在任意时刻,CPU 只能执行一条机器指令,每个线程只有获取到 CPU 的使用权后,才可以执行指令。

也就是在任意时刻,只有一个线程占用 CPU,处于运行的状态。

多线程并发运行实际上是指多个线程轮流获取 CPU 使用权,分别执行各自的任务。

线程的调度由 JVM 负责,线程的调度是按照特定的机制为多个线程分配 CPU 的使用权。

线程调度模型分为两类:分时调度模型和抢占式调度模型。

  • 分时调度模型

    分时调度模型是让所有线程轮流获取 CPU 使用权,并且平均分配每个线程占用 CPU 的时间片。

  • 抢占式调度模型

    JVM 采用的是抢占式调度模型,也就是先让优先级高的线程占用 CPU,如果线程的优先级都一样,那就随机选择一个线程,并让该线程占用 CPU。

    也就是如果我们同时启动多个线程,并不能保证它们能轮流获取到均等的时间片。

    如果我们的程序想干预线程的调度过程,最简单的办法就是给每个线程设定一个优先级。

3

什么是线程的安全性问题?

阅读完上一节的内容后,我们对 Java 的线程调度机制有了基本的了解。

这一节我们就来看看线程调度机制导致的线程安全问题,这一节的内容包括以下几个部分。

  • 竞态
  • 原子性
  • 可见性
  • 有序性

3.1 竞态

**
**

线程安全问题指的是多个线程之间对一个或多个共享可变对象交错操作时,有可能导致数据异常。

多线程编程中经常遇到的问题就是一样的输入在不同的时间有不一样的输出,这种一个计算结果的正确性与时间有关的现象就是竞态,也就是计算的正确性依赖于相对时间顺序或线程的交错。

竞态不一定导致计算结果的不正确,而是不排除计算结果有时正确有时错误的可能。

竞态往往伴随着脏数据和丢失更新的问题,脏数据就是线程读到一个过时的数据,丢失更新就是一个线程对数据做的更新,没有体现在后续其他线程对该数据的读取上。

对于共享变量,竞态可以看成访问(读/写)同一组共享变量的多个线程锁执行的操作相互交错,比如一个线程读取共享变量,并以该共享变量为基础进行计算的期间,另一个线程更新了该共享变量的值,导致脏数据或丢失更新。

对于局部变量,由于不同的线程各自访问的是自己的局部变量,所以局部变量的使用不会导致竞态。

3.2 原子性

**
**

原子(Atomic)的字面意识是不可分割的,对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程看来是不可分割的,那么该操作就是原子操作,相应地称该操作具有原子性(Atomicity)。

所谓不可分割,就是访问(读/写)某个共享变量的操作,从执行线程以外的其他线程看来,该操作只有未开始和结束两种状态,不会知道该操作的中间部分。

拿炒菜举例,炒菜可分为几个步骤:放油、放菜、放盐、放糖等。

但是从客人的角度来看,一个菜只有两种状态:没做好和做好了。

访问同一组共享变量的原子操作是不能被交错的,这就排除了一个线程执行一个操作的期间,另一个线程读取或更新该操作锁访问的共享变量,导致脏数据和丢失更新。

3.3 可见性

**
**

在多线程环境下,一个线程对某个共享变量进行更新后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果,这就是线程安全问题的另一种表现形式:可见性。

可见性是指一个线程对共享变量的更新,对于其他读取该变量的线程是否可见。

可见性问题与计算机的存储系统有关,程序中的变量可能会被分配到寄存器而不是主内存中,每个处理器都有自己的寄存器,一个处理器无法读取另一个处理器的寄存器上的内容。

即使共享变量是分配到主内存中存储的,也不饿能保证可见性,因为处理器不是直接访问主内存,而是通过高速缓存进行的。

一个处理器上运行的线程对变量的更新,可能只是更新到该处理器的写缓冲器(Store Buffer)中,还没有到高速缓存中,更别说处理器了。

可见性描述的是一个线程对共享变量的更新,对于另一个线程是否可见,保证可见性意味着一个线程可以读取到对应共享变量的新值。

从保证线程安全的角度来看,光保证原子性还不够,还要保证可见性,同时保证可见性和原子性才能确保一个线程能正确地看到其他线程对共享变量做的更新。

3.4 有序性

**
**

有序性是指一个处理器在为一个线程执行的内存访问操作,对于另一个处理器上运行的线程来看是乱序的。

顺序结构是结构化编程中的一种基本结构,它表示我们希望某个操作先于另外一个操作执行。

但是在多核处理器的环境下,代码的执行顺序是没保障的,编译器可能改变两个操作的先后顺序,处理器也可能不是按照程序代码的顺序执行指令。

重排序(Reordering)处理器和编译器是对代码做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能,但是它会对多线程程序的正确性产生影响,导致线程安全问题。

现代处理器为了提高指令的执行效率,往往不是按程序顺序注意执行指令的,而是哪条指令就绪就先执行哪条指令,这就是处理器的乱序执行。

4

怎么实现线程安全?

要实现线程安全就要保证上面说到的原子性、可见性和有序性。

常见的实现线程安全的办法是使用锁和原子类型,而锁可分为内部锁、显式锁、读写锁、轻量级锁(volatile)四种。

下面我们就来看看这四种锁和原子类型的用法和特点。

4.1 锁

img

**4.1.1 锁的五个特点

**

  • 临界区

    持有锁的线程获得锁后和释放锁前执行的代码叫做临界区(Critical Section)。

  • 排他性

    锁具有排他性,能够保障一个共享变量在任一时刻只能被一个线程访问,这就保证了临界区代码一次只能够被一个线程执行,临界区的操作具有不可分割性,也就保证了原子性。

  • 串行

    锁相当于是把多个线程对共享变量的操作从并发改为串行。

  • 三种保障

    锁能够保护共享变量实现线程安全,它的作用包括保障原子性、可见性和有序性。

  • 调度策略

    锁的调度策略分为公平策略和非公平策略,对应的锁就叫公平锁和非公平锁。

    公平锁会在加锁前查看是否有排队等待的线程,有的话会优先处理排在前面的线程。

    公平锁以增加上下文切换为代价,保障了锁调度的公平性,增加了线程暂停和唤醒的可能性。

4.1.2 锁的两个问题

  • 锁泄漏

    锁泄漏是指一个线程获得锁后,由于程序的错误导致锁一直无法被释放,导致其他线程一直无法获得该锁。

  • 活跃性问题

    锁泄漏会导致活跃性问题,这些问题包括死锁、和锁死等。

4.2 内部锁

**
**

4.2.1 内部锁简介

**
**

Java 为我们提供了 synchronized 关键字来实现内部锁,被 synchronized 关键字修饰的方法和代码块就叫同步方法和同步代码块。

下面我们来看下内部锁的七个特点。

  • 监视器锁

    因为使用 synchronized 实现的线程同步是通过监视器(monitor)来实现的,所以内部锁也叫监视器锁。

  • 自动获取/释放

    线程对同步代码块的锁的申请和释放由 JVM 内部实施,线程在进入同步代码块前会自动获取锁,并在退出同步代码块时自动释放锁,这也是同步代码块被称为内部锁的原因。

  • 锁定方法/类/对象

    synchronized 关键字可以用来修饰方法,锁住特定类和特定对象。

  • 临界区

    同步代码块就是内部锁的临界区,线程在执行临界区代码前必须持有该临界区的内部锁。

  • 锁句柄

    内部锁锁的对象就叫锁句柄,锁句柄通常会用 private 和 final 关键字进行修饰。

    因为锁句柄变量一旦改变,会导致执行同一个同步代码块的多个线程实际上用的是不同的锁。

  • 不会泄漏

    泄漏指的是锁泄漏,内部锁不会导致锁泄漏,因为 javac 编译器把同步代码块编译为字节码时,对临界区中可能抛出的异常做了特殊处理,这样临界区的代码出了异常也不会妨碍锁的释放。

  • 非公平锁

    内部锁是使用的是非公平策略,是非公平锁,也就是不会增加上下文切换开销。

4.2.2 内部锁基本用法

**
**

// 锁句柄
private final String hello = "hello";

private void getLock1() {
  synchronized (hello) {
    System.out.println("ThreadA 拿到了内部锁");
    ThreadUtils.sleep(2 * 1000);
  }
  System.out.println("ThreadA 释放了内部锁");
}
private void getLock2() {
  System.out.println("ThreadB 尝试获取内部锁");
  synchronized (hello) {
    System.out.println("ThreadB 拿到了内部锁");
  }
  System.out.println("ThreadB 继续执行");
}

当我们在两个线程中分别运行上面两个函数后,我们可以得到下面的输出。

ThreadA 拿到了内部锁
ThreadB 尝试获取内部锁
ThreadA 释放了内部锁
ThreadB 拿到了内部锁
ThreadB 继续执行

4.3 显式锁

**
**

4.3.1 显式锁简介

**
**

显式锁(Explict Lock)是 Lock 接口的实例,Lock 接口对显式锁进行了抽象,ReentrantLock 是它的实现类。

下面是显式锁的四个特点。

  • 可重入

    显式锁是可重入锁,也就是一个线程持有了锁后,能再次成功申请这个锁。

  • 手动获取/释放

    显式锁与内部锁区别在于,使用显式锁,我们要自己释放和获取锁,为了避免锁泄漏,我们要在 finally 块中释放锁

  • 临界区

    lock() 与 unlock() 方法之间的代码就是显式锁的临界区

  • 公平/非公平锁

    显式锁允许我们自己选择锁调度策略。

    ReentrantLock 有一个构造函数,允许我们传入一个 fair 值,当这个值为 true 时,说明现在创建的这个锁是一个公平锁。

    由于公平锁的开销比非公平锁大,所以 ReentrantLock 的默认调度策略是非公平策略。

4.3.2 显式锁基本用法

**
**

private final Lock lock = new ReentrantLock();

private void lock1() {
  lock.lock();
  System.out.println("线程 1 获取了显式锁");
  try {
    System.out.println("线程 1 开始执行操作");
    ThreadUtils.sleep(2 * 1000);
  } finally {
    lock.unlock();
    System.out.println("线程 1 释放了显式锁");
  }
}
private void lock2() {
  lock.lock();
  System.out.println("线程 2 获取了显式锁");
  try {
    System.out.println("线程 2 开始执行操作");
  } finally {
    System.out.println("线程 2 释放了显式锁");
    lock.unlock();
  }
}

当我们分别在两个线程中分别执行了上面的两个函数后,我们可以得到下面的输出。

线程 1 获取了显式锁
线程 1 开始执行操作
线程 1 释放了显式锁
线程 2 获取了显式锁
线程 2 开始执行操作
线程 2 释放了显式锁

4.3.3 显示锁获取锁的四个方法

**
**

  • lock()

    获取锁,获取失败时线程会处于阻塞状态

  • tryLock()

    获取锁,获取成功时返回 true,获取失败时会返回 false,不会处于阻塞状态

  • tryLock(long time, TimeUnit unit)

    获取锁,获取到了会返回 true,如果在指定时间内未获取到,则返回 false。

    在指定时间内处于阻塞状态,可中断。

  • lockInterruptibly()

    获取锁,可中断。

4.4 内部锁与显式锁的区别

**
**

看完了内部锁和显式锁的介绍,下面我们来看下内部锁和显式锁的五个区别。

  • 灵活性

    内部锁是基于代码的锁,锁的申请和释放只能在一个方法内执行,缺乏灵活性。

    显式锁是基于对象的锁,锁的申请和释放可以在不同的方法中执行,这样可以充分发挥面向对象编程的灵活性。

  • 锁调度策略

    内部锁只能是非公平锁。

    显式锁可以自己选择锁调度策略。

  • 便利性

    内部锁简单易用,不会出现锁泄漏的情况。

    显式锁需要自己手动获取/释放锁,使用不当的话会导致锁泄漏。

  • 阻塞

    如果持有内部锁锁的线程一直不释放这个锁,那其他申请这个锁的线程只能一直等待。

    显式锁 Lock 接口有一个 tryLock() 方法,当其他线程持有锁时,这个方法会返回直接返回 false。

    这样就不会导致线程处于阻塞状态,我们就可以在获取锁失败时做别的事情。

  • 适用场景

    在多个线程持有锁的平均时间不长的情况下我们可以使用内部锁

    在多个线程持有锁的平均较长的情况下我们可以使用显式锁(公平锁)

4.5 读写锁

**
**

4.5.1 读写锁简介

**
**

锁的排他性使得多个线程无法以线程安全的方式在同一时刻读取共享变量,这样不利于提高系统的并发性,这也是读写锁出现的原因。

读写锁 ReadWriteLock 接口的实现类是 ReentrantReadWriteLock。

只读取共享变量的线程叫读线程,只更新共享变量的线程叫写线程。

读写锁是一种改进的排他锁,也叫共享/排他(Shared/Exclusive)锁。

读写锁有下面六个特点。

  • 读锁共享

    读写锁允许多个线程同时读取共享变量,读线程访问共享变量时,必须持有对应的读锁,读锁可以被多个线程持有。

  • 写锁排他

    读写锁一次只允许一个线程更新共享变量,写线程访问共享变量时,必须持有对应的写锁,写锁在任一时刻只能被一个线程持有。

  • 可以降级

    读写锁是一个支持降级的可重入锁,也就是一个线程在持有写锁的情况下,可以继续获取对应的读锁。

    这样我们可以在修改变量后,在其他地方读取该变量,并执行其他操作。

  • 不能升级

    读写锁不支持升级,读线程只有释放了读锁才能申请写锁

  • 三种保障

    读写锁虽然允许多个线程读取共享变量,但是由于写锁的特性,它同样能保障原子性、可见性和有序性。

  • 适用场景

    读写锁会带来额外的开销,只有满足下面两个条件,读写锁才是合适的选择

    • 读操作比写操作频繁很多
    • 读取共享变量的线程持有锁的时间较长

4.5.2 读写锁基本用法

private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock readLock = readWriteLock.readLock();
private final Lock writeLock = readWriteLock.writeLock();

private void write1() {
  writeLock.lock();
  System.out.println("写线程1获取了写锁");
  try {
    System.out.println("写线程1开始执行操作");
    ThreadUtils.sleep(3 * 1000);
  } finally {
    writeLock.unlock();
    System.out.println("写线程1释放了写锁");
  }
}

private void write2() {
  writeLock.lock();
  System.out.println("写线程2获取了写锁");
  try {
    System.out.println("写线程2开始执行操作");
  } finally {
    writeLock.unlock();
    System.out.println("写线程2释放了写锁");
  }
}
private void read1() {
  readLock.lock();
  System.out.println("读线程1获取了读锁");
  try {
    System.out.println("读线程1开始执行操作");
    ThreadUtils.sleep(3 * 1000);
  } finally {
    readLock.unlock();
    System.out.println("读线程1释放了读锁");
  }
}

private void read2() {
  readLock.lock();
  System.out.println("读线程2获取了读锁");
  try {
    System.out.println("读线程2开始执行操作");
    ThreadUtils.sleep(3 * 1000);
  } finally {
    readLock.unlock();
    System.out.println("读线程2释放了读锁");
  }
}

当在四个线程中分别执行上面的四个函数时,我们可以得到下面的输出。

写线程1获取了写锁
写线程1开始执行操作
写线程1释放了写锁
写线程2获取了写锁
写线程2开始执行操作
写线程2释放了写锁
读线程1获取了读锁
读线程1开始执行操作
读线程2获取了读锁
读线程2开始执行操作
读线程1释放了读锁
读线程2释放了读锁

4.6 volatile 关键字

img

volatile 关键字可用于修饰共享变量,对应的变量就叫 volatile 变量,volatile 变量有下面几个特点。

  • 易变化

    volatile 的字面意思是“不稳定的”,也就是 volatile 用于修饰容易发生变化的变量,不稳定指的是对这种变量的读写操作要从高速缓存或主内存中读取,而不会分配到寄存器中。

  • 开销

    • 比锁低

      volatile 的开销比锁低,volatile 变量的读写操作不会导致上下文切换,所以 volatile 关键字也叫轻量级锁 。

    • 比普通变量高

      volatile 变量读操作的开销比普通变量要高,这是因为 volatile 变量的值每次都要从高速缓存或主内存中读取,无法被暂存到寄存器中。

  • 释放/存储屏障

    对于 volatile 变量的写操作,JVM 会在该操作前插入一个释放屏障,并在该操作后插入一个存储屏障。

    存储屏障具有冲刷处理器缓存的作用,所以在 volatile 变量写操作后插入一个存储屏障,能让该存储屏障前的所有操作结果对其他处理器来说是同步的。

  • 加载/获取屏障

    对于 volatile 变量的读操作,JVM 会在该操作前插入一个加载屏障,并在操作后插入一个获取屏障。

    加载屏障通过冲刷处理器缓存,使线程所在的处理器将其他处理器对该共享变量做的更新同步到该处理器的高速缓存中。

  • 保证有序性

    volatile 能禁止指令重排序,也就是使用 volatile 能保证操作的有序性。

  • 保证可见性

    读线程执行的加载屏障和写线程执行的存储屏障配合在一起,能让写线程对 volatile 变量的写操作对读线程可见,从而保证了可见性。

  • 原子性

    在原子性方面,对于 long/double 型变量,volatile 能保证读写操作的原子型。

    对于非 long/double 型变量,volatile 只能保证写操作的原子性。

    如果 volatile 变量写操作前涉及共享变量,竞态仍然可能发生,因为共享变量赋值给 volatile 变量时,其他线程可能已经更新了该共享变量的值。

4.7 原子类型

4.7.1 原子类型简介

**
**

在 JUC 下有一个 atomic 包,这个包里面有一组原子类,使用原子类的方法,不需要加锁也能保证线程安全,而原子类是通过 Unsafe 类中的 CAS 指令从硬件层面来实现线程安全的。

这个包里面有如 AtomicInteger、AtomicBoolean、AtomicReference、AtomicReferenceFIeldUpdater 等。

我们先来看一个使用原子整型 AtomicInteger 自增的例子。

// 初始值为 1
AtomicInteger integer = new AtomicInteger(1);

// 自增
int result = integer.incrementAndGet();

// 结果为 2
System.out.println(result);

AtomicReference 和 AtomicReferenceFIeldUpdater 可以让我们自己的类具有原子性,它们的原理都是通过 Unsafe 的 CAS 操作实现的。

我们下面看下它们的用法和区别。

4.7.2 AtomicReference 基本用法

**
**

class AtomicReferenceValueHolder {
  AtomicReference<String> atomicValue = new AtomicReference<>("HelloAtomic");
}

public void getAndUpdateFromReference() {
  AtomicReferenceValueHolder holder = new AtomicReferenceValueHolder();

  // 对比并设值
  // 如果值是 HelloAtomic,就把值换成 World
  holder.atomicValue.compareAndSet("HelloAtomic", "World");

  // World
  System.out.println(holder.atomicValue.get());

  // 修改并获取修改后的值
  String value = holder.atomicValue.updateAndGet(new UnaryOperator<String>() {
    @Override
    public String apply(String s) {
      return "HelloWorld";
    }
  });
  // Hello World  
  System.out.println(value);
}

4.7.3 AtomicReferenceFieldUpdater 基本用法

**
**

AtomicReferenceFieldUpdater 在用法上和 AtomicReference 有些不同,我们直接把 String 值暴露了出来,并且用 volatile 对这个值进行了修饰。

并且将当前类和值的类传到 newUpdater ()方法中获取 Updater,这种用法有点像反射,而且 AtomicReferenceFieldUpdater 通常是作为类的静态成员使用。

public class SimpleValueHolder {
  public static AtomicReferenceFieldUpdater<SimpleValueHolder, String> valueUpdater
    = AtomicReferenceFieldUpdater.newUpdater(
      SimpleValueHolder.class, String.class, "value");

  volatile String value = "HelloAtomic";

}

public void getAndUpdateFromUpdater() {
  SimpleValueHolder holder = new SimpleValueHolder();
  holder.valueUpdater.compareAndSet(holder, "HelloAtomic", "World");

  // World
  System.out.println(holder.valueUpdater.get(holder));

  String value = holder.valueUpdater.updateAndGet(holder, new UnaryOperator<String>() {
    @Override
    public String apply(String s) {
      return "HelloWorld";
    }
  });

  // HelloWorld
  System.out.println(value);
}

4.7.4 AtomicReference 与 AtomicReferenceFieldUpdater 的区别

**
**

AtomicReference 和 AtomicReferenceFieldUpdater 的作用是差不多的,在用法上 AtomicReference 比 AtomicReferenceFIeldUpdater 更简单。

但是在内部实现上,AtomicReference 内部一样是有一个 volatile 变量。

使用 AtomicReference 和使用 AtomicReferenceFIeldUpdater 比起来,要多创建一个对象。

对于 32 位的机器,这个对象的头占 12 个字节,它的成员占 4 个字节,也就是多出来 16 个字节。

对于 64 位的机器,如果启动了指针压缩,那这个对象占用的也是 16 个字节。

对于 64 位的机器,如果没启动指针压缩,那么这个对象就会占 24 个字节,其中对象头占 16 个字节,成员占 8 个字节。

当要使用 AtomicReference 创建成千上万个对象时,这个开销就会变得很大。

这也就是为什么 BufferedInputStream 、Kotlin 协程 和 Kotlin 的 lazy 的实现会选择 AtomicReferenceFieldUpdater 作为原子类型。

因为开销的原因,所以一般只有在原子类型创建的实例确定了较少的情况下,比如说是单例,才会选择 AtomicReference,否则都是用 AtomicReferenceFieldUpdater。

4.8 锁的使用技巧

**
**

使用锁会带来一定的开销,而掌握锁的使用技巧可以在一定程度上减少锁带来的开销和潜在的问题,下面就是一些锁的使用技巧。

  • 长锁不如短锁

    尽量只对必要的部分加锁

  • 大锁不如小锁

    进可能对加锁的对象拆分

  • 公锁不如私锁

    进可能把锁的逻辑放到私有代码中,如果让外部调用者加锁,可能会导致锁不正当使用导致死锁

  • 嵌套锁不如扁平锁

    在写代码时要避免锁嵌套

  • 分离读写锁

    尽可能将读锁和写锁分离

  • 粗化高频锁

    合并处理频繁而且过短的锁,因为每一把锁都会带来一定的开销

  • 消除无用锁

    尽可能不加锁,或者用 volatile 代替

5

线程之间怎么协作?

线程间的常见协作方式有两种:等待和中断。

当一个线程中的操作需要等待另一个线程中的操作结束时,就涉及到等待型线程协作方式。

常用的等待型线程协作方式有 join、wait/notify、await/signal、await/countDown 和 CyclicBarrier 五种,下面我们就来看看这五种线程协作方式的用法和区别。

5.1 join

**
**

使用 Thread.join() 方法,我们可以让一个线程等待另一个线程执行结束后再继续执行。

join() 方法实现等待是通过 wait() 方法实现的,在 join() 方法中,会不断判断调用了 join() 方法的线程是否还存活,是的话则继续等待。

5.2 wait/notify

**
**

5.2.1 wait/notify 简介

**
**

在 Java 中,使用 Object.wait()/Object.wait(long) 和 Object.notify()/Object.notifyAll() 可以用于实现等待和通知。

省略了基本用法。

5.2.2 wait/notify 原理

**
**

JVM 会给每个对象维护一个入口集(Entry Set)和等待集(Wait Set)。

入口集用于存储申请该对象内部锁的线程,等待集用于存储对象上的等待线程。

wait() 方法会将当前线程暂停,在释放内部锁时,会将当前线程存入该方法所属的对象等待集中。

调用对象的 notify() 方法,会让该对象的等待集中的任意一个线程唤醒,被唤醒的线程会继续留在对象的等待集中,直到该线程再次持有对应的内部锁时,wait() 方法就会把当前线程从对象的等待集中移除。

添加当前线程到等待集、暂停当前线程、释放锁以及把唤醒后的线程从对象的等待集中移除,都是在 wait() 方法中实现的。

在 wait() 方法的 native 代码中,会判断线程是否持有当前对象的内部锁,如果没有的话,就会报非法监视器状态异常,这也就是为什么要在同步代码块中执行 wait() 方法。

5.3 await/signal

**
**

5.3.1 await/signal 简介

**
**

wait()/notify() 过于底层,而且还存在两个问题,一是过早唤醒、二是无法区分 Object.wait(ms) 返回是由于等待超时还是被通知线程唤醒。

使用 await/signal 协作方式有下面几个要点。

  • Condition 接口

    在 JDK 5 中引入了 Condition(条件变量) 接口,使用 Condition 也可以实现等待/通知,而且不存在上面提到的两个问题。

    Condition 接口提供的 await()/signal()/signalAll() 相当于是 Object 提供的 wait()/notify()/notifyAll()。

    通过 Lock.newCondition() 可以获得一个 Condition 实例。

  • 持有锁

    与 wait/notify 类似,wait/notify 需要线程持有所属对象的内部锁,而 await/signal 要求线程持有 Condition 实例的显式锁。

  • 等待队列

    Condition 实例也叫条件变量或条件队列,每个 Condition 实例内部都维护了一个用于存储等待线程的等待队列,相当于是 Object 中的等待集。

  • 循环语句

    对于保护条件的判断和 await() 方法的调用,要放在循环语句中

  • 引导区内

    循环语句和执行目标动作要放在同一个显式锁引导的临界区中,这么做是为了避免欺骗性唤醒和信号丢失的问题

5.3.2 await/signal 基本用法

**
**

private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
private volatile boolean conditionSatisfied = false;

private void startWait() {
  lock.lock();
  System.out.println("等待线程获取了锁");
  try {
    while (!conditionSatisfied) {
      System.out.println("保护条件不成立,等待线程进入等待状态");
      condition.await();
    }
    System.out.println("等待线程被唤醒,开始执行目标动作");
  } catch (InterruptedException e) {
    e.printStackTrace();
  } finally {
    lock.unlock();
    System.out.println("等待线程释放了锁");
  }
}
public void startNotify() {
  lock.lock();
  System.out.println("通知线程获取了锁");
  try {
    conditionSatisfied = true;
    System.out.println("通知线程即将唤醒等待线程");
    condition.signal();
  } finally {
    System.out.println("通知线程释放了锁");
    lock.unlock();
  }
}

当我们在两个线程中分别执行了上面的两个函数后,能得到下面的输出。

等待线程获取了锁
保护条件不成立,等待线程进入等待状态
通知线程获取了锁
通知线程即将唤醒等待线程
等待线程被唤醒,开始执行目标动作

5.4 await/countDown

**
**

5.4.1 await/countDown 简介

**
**

使用 join() 实现的是一个线程等待另一个线程执行结束,但是有的时候我们只是想要一个特定的操作执行结束,不需要等待整个线程执行结束,这时候就可以使用 CountDownLatch 来实现。

await/countDown 协作方式有下面几个特点。

  • 先决操作

    CountDownLatch 可以实现一个或多个线程等待其他线程完成一组特定的操作后才继续运行,这组线程就叫先决操作。

  • 先决操作数

    CountDownLatch 内部维护了一个用于计算未完成先决操作数的 count 值,每当 CountDownLatch.countDown() 方法执行一次,这个值就会减 1。

    未完成先决操作数 count 是在 CountDownLatch 的构造函数中设置的。

    要注意的是,这个值不能小于 0,否则会报非法参数异常。

  • 一次性

    当计数器的值为 0 时,后续再调用 await() 方法不会再让执行线程进入等待状态,所以说 CountDownLatch 是一次性协作。

  • 不用加锁

    CountDownLatch 内部封装了对 count 值的等待和通知逻辑,所以在使用 CountDownLatch 实现等待/通知不需要加锁

  • await()

    CountDownLatch.await() 可以让线程进入等待状态,当 CountDownLatch 中的 count 值为 0 时,表示需要等待的先决操作已经完成。

  • countDown()

    调用 CountDownLatch.countDown() 方法后,count 值就会减 1,并且在 count 值为 0 时,会唤醒对应的等待线程。

5.4.2 await/countDown 基本用法

**
**

public void tryAwaitCountDown() {
  startWaitThread();
  startCountDownThread();
  startCountDownThread();
}
final int prerequisiteOperationCount = 2;
final CountDownLatch latch = new CountDownLatch(prerequisiteOperationCount);

private void startWait() throws InterruptedException {
  System.out.println("等待线程进入等待状态");
  latch.await();
  System.out.println("等待线程结束等待");
}
private void startCountDown() {
  try {
    System.out.println("执行先决操作");
  } finally {
    System.out.println("计数值减 1");
    latch.countDown();
  }
}

当我们在两个线程中分别执行 startWait() 和 startCountDown() 方法后,我们会得到下面的输出。

等待线程进入等待状态
执行先决操作
计数值减 1
执行先决操作
计数值减 1
等待线程结束等待

除此以外还有 CyclicBarrier 等…

6

怎么让一个线程停止?

6.1 stop() 方法

**
**

JDK 中的 stop() 方法很早就被弃用了,之所以会被弃用,我们可以来看下 stop() 方法可能导致的两种情况。

第一种情况,假如现在有线程 A 和 线程 B,线程 A 持有了线程 B 需要的锁,然后线程 A 被 stop() 强行结束了,导致这个锁没有被释放,那线程 B 就一直拿不到这个锁了,相当于是线程 B 中的任务永远无法执行了。

第二种情况,假如线程 A 正在修改一个变量,修改到一半,然后被 stop() 强行结束了,这时候线程 B 去读取这个变量,读取到的就是一个异常值,这就可能导致线程 B 出现异常。

因为上述两种资源清理的问题,所以现在很多语言都废弃了线程的 stop() 方法。

虽然线程不能被简单粗暴地终止,但是线程执行的任务是可以停止的,下面我们就来看看怎么停止任务。

6.2 interrupt() 方法

**
**

当我们调用 sleep() 方法时,编译器会要求我们捕获中断异常 InterruptedException,这是因为线程的休眠状态可能会被中断。

在线程休眠期间,如果其他地方调用了线程的 interrupt() 方法,那么这个休眠状态就会被中断,中断后就会接收到一个中断异常。

我们可以在捕获到中断异常后释放锁,比如关闭流或文件。

但是调用线程的 interrupt() 方法不是百分百能中断任务的,假如我们现在有一个线程,它的 run() 方法中有个 while 循环在执行某些操作,那么在其他地方调用该线程的 interrupt() 方法并不能中断这个任务。

在这种情况下,我们可以通过 interrupted() 或 isInterruped() 方法判断任务是否被中断。

interrupted() 与 isInterrupted() 方法都可以获取线程的中断状态,但它们有下面一些区别。

  • 静态

    interrupted() 是静态方法,isInterrupted() 是非静态方法

  • 重置

    interrupted() 会重置中断状态,也就是不管这次获取到的中断状态是 true 还是 false,下次获取到的中断状态都是 false

    isInterrupted() 不会重置中断状态,也就是调用了线程的 interrupt() 方法后,通过该方法获取到的中断状态会一直为 true

不论是使用 interrupted() 还是 isInterrupted() 方法,本质上都是通过 Native 层的布尔标志位判断的。

6.3 布尔标志位

**
**

既然 interrupt() 只是对布尔值的一个修改,那我们可以在 Java 层自己设一个布尔标志位,让每个线程共享这个布尔值。

当我们想取消某个任务时,就在外部把这个标志位改为 true。

  • 注意事项

    直接使用布尔标志位会有可见性问题,所以要用 volatile 关键字修饰这个值。

  • 使用场景

    当我们需要用到 sleep() 方法时,我们可以使用 interrupt() 来中断任务,其他时候可以使用布尔标志位。

7

使用线程有哪些准则?

在使用线程执行异步任务的过程中,我们要准收一些使用准则,这样能在一定程度上避免使用线程的时候带来的问题。

常见的五个线程使用准则是:严谨直接创建线程、使用基础线程池、选择合适的异步方式、线程必须命名以及重视优先级设置。

  1. 严禁直接创建线程

    直接创建线程除了简单方便之外,没有其他优势,所以在实际项目开发过程中,一定要严禁直接创建线程执行异步任务。

  2. 提供基础线程池供各个业务线使用

    这个准则是为了避免各个业务线各自维护一套线程池,导致线程数过多。

    假如我们有 10 条业务线,如果每条业务线都维护一个线程池,假如这个线程池的核心数是 8,那么我们就有 80 条线程,这明显是不合理的。

  3. 选择合适的异步方式

    HandlerThread、IntentService 和 RxJava 等方式都可以执行异步任务,但是要根据任务类型来选择合适的异步方式。

    假如我们有一个可能会长时间执行,但是优先级较低的任务,我们就可以选择用 HandlerThread。

    还有一种情况就是我们需要执行一个定时任务,这种情况下更适合使用线程池来操作。

  4. 线程必须命名

    当我们开发组成员比较多的时候,不论是使用线程还是使用线程池,如果我们不对我们创建的线程命名,如果这个线程发生了异常,我们光靠默认线程名是不知道要找哪个开发人员的。

    如果我们对每个线程都命名了,就可以快速地定位到线程的创建者,可以把问题交给他来解决。

    我们可以在运行期通过 Thread.currentThread().setName(name) 修改线程的名字。

    如果在一段时间内是我们业务线使用,我们可以把线程的名字改成我们业务线的标志,在任务完成后,再把名字改回来。

  5. 重视优先级设置

    Java 采用的是抢占式调度模型,高优先级的任务能先占用 CPU,如果我们想让某个任务先完成,我们可以给它设置一个较高的优先级。

    设置的方式就是通过 android.os.Process.setThreadPriority(priority),这个 priority 的值越小,优先级就越高,它的取值范围在 -20~19。

8

怎么在 Android 中执行异步任务?

在这一节,我们会介绍 Android 中常用的 7 种异步方式:Thread、HandlerThread、IntentService、AsyncTask、线程池、RxJava 和 Kotlin 协程。

这些感觉大家都熟悉呀,略。