Android开发经验谈Android技术知识Android开发

探索 Android 多线程优化方法

2019-08-08  本文已影响8人  灯不利多
首图.png 目录.png

前言

1. 基本介绍

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

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

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

2. 阅读技巧

在阅读本文时,画图和思考可以帮助你更好地记忆和理解文中的内容。

3. 缩略词

1. 能不能不用多线程?

不管你懂不懂多线程,你也必须要用多线程

2. 为什么要做多线程优化?

做多线程优化是为了解决多线程的安全性和活跃性问题。

这两个问题会导致多线程程序输出错误的结果以及任务无法执行,下面我们就来看看这两个问题的表现。

关于线程安全性的三个问题和线程活跃性的四个问题,在本文后面会做更详细的介绍。

3. 什么是线程?

3.1 线程简介

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

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

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

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

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

下面我们就来看看线程的四个属性、六个方法以及六种状态。

3.2 线程的四个属性

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

3.2.1 编号

3.2.2 名字

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

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

3.2.3 类别

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

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

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

3.2.4 优先级

3.2.5 继承性

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

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

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

3.3 线程的六个方法

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

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

3.3.1 start()

3.3.2 run()

3.3.3 join()

3.3.4 Thread.currentThread()

3.3.5 Thread.yield()

3.3.6 Thread.sleep(ms)

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

3.4 线程的六种状态

3.4.1 线程的生命周期

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

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

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

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

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

线程的生命周期.png

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

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

3.4.2 新建状态

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

3.4.3 可运行状态

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

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

3.4.4 阻塞状态

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

3.4.5 等待状态

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

3.4.6 限时等待状态

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

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

3.4.7 终止状态

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

4. 线程调度的原理是什么?

这一节会线程调度原理相关的对 Java 内存模型、高速缓存、Java 线程调度机制进行一个简单的介绍。

4.1 Java 的内存模型

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

Java 内存模型.png

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

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

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

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

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

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

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

4.2 高速缓存

4.2.1 高速缓存简介

现代计算机系统高速缓存结构.png

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

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

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

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

4.2.2 高速缓存内部结构

高速缓存内部结构.png

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

4.2.3 缓存条目结构

缓存条目结构.png

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

4.3 Java 线程调度原理

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

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

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

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

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

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

线程安全问题不是说线程不安全,而是多个线程之间交错操作有可能导致数据异常。

下面我们就来看下与线程安全相关的竞态和实现线程安全要保证的三个点:原子性、可见性和有序性。

5.1 竞态

多线程编程中经常遇到的问题就是一样的输入在不同的时间有不一样的输出,这种一个计算结果的正确性与时间有关的现象就是竞态。

竞态是指计算的正确性依赖于相对时间顺序或线程的交错,竞态不一定导致计算结果的不正确,而是不排除计算结果有时正确有时错误的可能。

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

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

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

5.2 原子性

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

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

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

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

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

5.3 可见性

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

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

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

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

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

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

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

5.4 有序性

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

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

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

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

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

6. 怎么实现线程安全?

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

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

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

6.1 锁

锁示意图.png

文章的开头提到的“打架扣 100”就是一种现实生活中的锁,可以让小张和老王乖乖干活,别再炒出不能吃的菜。

这也就是锁(Lock)的作用,让多个线程更好地协作,避免多个线程的操作交错导致数据异常的问题。

6.1.1 锁的五个特点

6.1.2 锁的两个问题

6.2 内部锁

6.2.1 内部锁简介

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

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

6.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 继续执行

6.3 显式锁

6.3.1 显式锁简介

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

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

6.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 释放了显式锁

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

6.4 内部锁与显式锁的区别

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

6.5 读写锁

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

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

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

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

读写锁有下面六个特点。

下面我们来看下使用读写锁的三个步骤。

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释放了读锁

6.6 volatile 关键字

volatile 变量读写操作.png

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

6.7 原子类型

6.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 操作实现的。

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

6.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);
}


6.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);
}

6.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。

6.8 锁的使用技巧

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

7. 什么是线程的活跃性问题?

上一大节介绍了锁的作用和基本用法,锁能让线程进入阻塞状态,而这种阻塞就会导致任务无法正常执行,也就是线程出现活跃性问题,这也就是我们这一节要讲的内容。

活跃性问题不是说线程过于活跃,而是线程不够活跃,导致任务无法取得进展。

我们这一节就来看一下常见的四个线程活跃性问题:死锁、锁死、活锁和饥饿。

7.1 线程的四个活跃性问题

7.1 死锁

死锁.png

死锁是线程的一种常见多线程活跃性问题,如果两个或更多的线程,因为相互等待对方而被永远暂停,那么这就叫死锁现象。

下面我们就来看看死锁产生的四个条件和避免死锁的三个方法。

7.1.1 死锁产生的四个条件

当多个线程发生了死锁后,这些线程和相关共享变量就会满足下面四个条件。

  1. 资源互斥

    涉及的资源必须是独占的,也就是资源每次只能被一个线程使用

  2. 资源不可抢夺

    涉及的资源只能被持有该资源的线程主动释放,无法被其他线程抢夺(被动释放)

  3. 占用并等待资源

    涉及的线程至少持有一个资源,还申请了其他资源,而其他资源刚好被其他线程持有,并且线程不释放已持有资源

  4. 循环等待资源

    涉及的线程必须等待别的线程持有的资源,而别的线程又反过来等待该线程持有的资源

只要产生了死锁,上面的条件就一定成立,但是上面的条件都成立也不一定会产生死锁。

7.1.2 避免死锁的三个方法

要想消除死锁,只要破坏掉上面的其中一个条件即可。

由于锁具有排他性,且无法被动释放,所以我们只能破坏掉第三个和第四个条件。

  1. 粗锁法

    使用粗粒度的锁代替多个锁,锁的范围变大了,访问共享资源的多个线程都只需要申请一个锁,因为每个线程只需要申请一个锁就可以执行自己的任务,这样“占用并等待资源”和“循环等待资源”这两个条件就不成立了。

    粗锁法的缺点是会降低并发性,而且可能导致资源浪费,因为采用粗锁法时,一次只能有一个线程访问资源,这样其他线程就只能搁置任务了。

  2. 锁排序法

    锁排序法指的是相关线程使用全局统一的顺序申请锁。

    假如有多个线程需要申请锁,我们只需要让这些线程按照一个全局统一的顺序去申请锁,这样就能破坏“循环等待资源”这个条件。

  3. tryLock

    显式锁 ReentrantLock.tryLock(long timeUnit) 这个方法允许我们为申请锁的操作设置超时时间,这样就能破坏“占用并等待资源”这个条件。

  4. 开放调用

    开放调用(Open Call)就是一个方法在调用外部方法时不持有锁,开放调用能破坏“占用并等待资源”这个条件。

7.2 锁死

等待线程由于唤醒的条件永远无法成立,导致任务一直无法继续执行,那么这个线程是被锁死(Lockout)了。

锁死和死锁的区别在于,即使产生死锁的条件全部都不成立,还是有可能发生锁死。

锁死可分为信号丢失锁死和嵌套监视器锁死。

7.2.1 信号丢失锁死

信号丢失锁死是由于没有对应的通知线程唤醒等待线程,导致等待线程一直处于等待状态的一种活跃性问题。

信号丢失锁死的一个典型例子就是等待线程执行 Object.wait()/Condition.await() 前没有判断保护条件,而保护条件已经成立,但是后续没有其他线程更新保护条件并通知等待线程,这也就是为什么要强调 Object.wait()/Condition.await() 要放在循环语句中执行。

7.2.2 嵌套监视器丢失锁死

嵌套监视器锁死指的是嵌套地使用锁导致线程永远无法被唤醒,在代码上的表现就是两个嵌套的同步代码块。

避免嵌套监视器锁死的办法只需要避免嵌套使用内部锁。

7.3 活锁

活锁(Livelock)是指线程一直处于运行状态,但是任务却一直无法继续执行的一种现象。

7.4 饥饿

线程饥饿(Starvation)是指线程一直无法获得所需资源,导致任务一直无法执行。

8. 线程之间怎么协作?

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

中断型协作放在第 8 大节讲,我们这一节主要讲等待型协作。

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

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

8.1 join

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

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

下面是 join() 方法的简单用法。

public void tryJoin() {
  Thread threadA = new ThreadA();
  Thread threadB = new ThreadB(threadA);
  threadA.start();
  threadB.start();
}
public class ThreadA extends Thread {
  @Override
  public void run() {
    System.out.println("线程 A 开始执行");
    ThreadUtils.sleep(1000);
    System.out.println("线程 A 执行结束");
  }
}
public class ThreadB extends Thread {
  private final Thread threadA;

  public ThreadB(Thread thread) {
    threadA = thread;
  }

  @Override
  public void run() {
    try {
      System.out.println("线程 B 开始等待线程 A 执行结束");
      threadA.join();
      System.out.println("线程 B 结束等待,开始做自己想做的事情");
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

当我们执行完上面的代码后,会得到下面的输出。

线程 A 开始执行
线程 B 开始等待线程 A 执行结束
线程 A 执行结束
线程 B 结束等待,开始做自己想做的事情

8.2 wait/notify

8.2.1 wait/notify 简介

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

一个线程因为执行操作(目标动作)所需的保护条件未满足而被暂停的过程就叫等待(wait)。

一个线程更新了共享变量,使得其他线程需要的保护条件成立,唤醒了被暂停的线程的过程就叫通知(notify)。

wait() 方法的执行线程叫等待线程,notify() 方法执行的线程叫通知线程。

wait/notify 协作方式有下面几个特点。

8.2.2 wait/notify 基本用法

下面是 wait/notify 使用的示例代码。

final Object lock = new Object();
private volatile boolean conditionSatisfied;

public void startWait() throws InterruptedException {
  synchronized (lock) {
    System.out.println("等待线程获取了锁");
    while(!conditionSatisfied) {
      System.out.println("保护条件不成立,等待线程进入等待状态");
      lock.wait();
    }
    System.out.println("等待线程被唤醒,开始执行目标动作");
  }
}
public void startNotify() {
  synchronized (lock) {
    System.out.println("通知线程获取了锁");
    System.out.println("通知线程即将唤醒等待线程");
    conditionSatisfied = true;
    lock.notify();
  }
}

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

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

8.2.3 wait/notify 原理

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

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

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

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

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

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

8.2.4 wait/notify 存在的问题

减少 wait/notify 上下文切换的常用方法有下面两种。

8.2.5 notify()/notifyAll() 的选用

notify() 可能导致信号丢失,而 notifyAll() 虽然会把不需要唤醒的等待线程也唤醒,但是在正确性方面有保障。

所以一般情况下优先使用 notifyAll() 保障正确性。

一般情况下,只有在下面两个条件都实现时,才会选择使用 notify() 实现通知。

  1. 只需唤醒一个线程

    当一次通知只需要唤醒最多一个线程时,我们可以考虑使用 notify() 实现通知,但是光满足这个条件还不够。

    在不同的等待线程使用不同的保护条件时,notify() 唤醒的一个任意线程可能不是我们需要唤醒的那个线程,所以需要条件 2 来排除。

  2. 对象的等待集中只包含同质等待线程

    同质等待线程指的是线程使用同一个保护条件并且 wait() 调用返回后的逻辑一致。

    最典型的同质线程是使用同一个 Runnable 创建的不同线程,或者同一个 Thread 子类 new 出来的多个实例。

8.3 await/signal

8.3.1 await/signal 简介

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

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

8.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();
  }
}

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

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

8.3.3 awaitUntil() 用法

上面我们说到 Condition 接口可以解决 Object.wait(ms) 无法判断等待的结束是由于超时还是唤醒,而解决办法就是使用 awaitUntil(timeout, unit) 方法。

如果是由于超时导致等待结束,那么 awaitUntil() 会返回 false,否则会返回 true,表示等待是被唤醒的,下面我们就看看这个方法是怎么用的。

private void startTimedWait() throws InterruptedException {
  lock.lock();
  System.out.println("等待线程获取了锁");
  // 3 秒后超时
  Date date = new Date(System.currentTimeMillis() + 3 * 1000);
  boolean isWakenUp = true;
  try {
    while (!conditionSatisfied) {
      if (!isWakenUp) {
        System.out.println("已超时,结束等待任务");
        return;
      } else {
        System.out.println("保护条件不满足,并且等待时间未到,等待进入等待状态");
        isWakenUp = condition.awaitUntil(date);
      }
    }
    System.out.println("等待线程被唤醒,开始执行目标动作");
  } finally {
      lock.unlock();
  }
}
public void startDelayedNotify() {
  threadSleep(4 * 1000);
  startNotify();
}
等待线程获取了锁
保护条件不满足,并且等待时间未到,等待进入等待状态
已超时,结束等待任务
通知线程获取了锁
通知线程即将唤醒等待线程

8.4 await/countDown

8.4.1 await/countDown 简介

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

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

8.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
等待线程结束等待

8.5 CyclicBarrier

8.5.1 CyclicBarrier 简介

有的时候多个线程需要互相等待对方代码中的某个地方(集合点),这些线程才能继续执行,这时可以使用 CyclicBarrier(栅栏)。

CyclicBarrier 是 JDK 5 引入的一个类,CyclicBarrier 协作方式有下面几个特点。

使用 CyclicBarrier.await() 实现等待的线程叫参与方(Party),除了最后一个执行 CyclicBarrier.await() 方法的线程外,其他执行该方法的线程都会被暂停。

和 CountDownLatch 不同,CyclicBarrier 是可以重复使用的,也就是等待结束后,可以再次进行一轮等待。

8.5.1 CyclicBarrier 基本用法

老王和小张整天这么整也不是办法,有一天老李就想了个办法,组织几天爬山,下面我们就来看看在爬山前他们都做了什么。

final int parties = 3;
final Runnable barrierAction = new Runnable() {
  @Override
  public void run() {
    System.out.println("人来齐了,开始爬山");
  }
};
final CyclicBarrier barrier = new CyclicBarrier(parties, barrierAction);

public void tryCyclicBarrier() {
  firstDayClimb();
  secondDayClimb();
}

private void firstDayClimb() {
  new PartyThread("第一天爬山,老李先来").start();
  new PartyThread("老王到了,小张还没到").start();
  new PartyThread("小张到了").start();
}

private void secondDayClimb() {
  new PartyThread("第二天爬山,老王先来").start();
  new PartyThread("小张到了,老李还没到").start();
  new PartyThread("老李到了").start();
}
public class PartyThread extends Thread {
  private final String content;

  public PartyThread(String content) {
    this.content = content;
  }

  @Override
  public void run() {
    System.out.println(content);
    try {
      barrier.await();
    } catch (BrokenBarrierException e) {
      e.printStackTrace();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

运行上面的代码后,可以得到下面的输出。

第一天爬山,老李先来
老王到了,小张还没到
小张到了
人来齐了,开始爬山
第二天爬山,老王先到
小张到了,老李还没到
老李到了
人来齐了,开始爬山

8.5.3 CyclicBarrier 原理

CyclicBarrier 内部有一个用于实现等待/通知的 Condition(条件变量)类型的变量 trip 。

而且 CyclicBarrier 内部还有一个分代(Generation)对象,用于表示CyclicBarrier 实例是可以重复使用的。

当前分代的初始状态是 parties(参与方总数),CyclicBarrier.await() 方法每执行一次,parties 的值就会减 1。

调用了 CyclicBarrier 方法的参与方相当于是等待线程,而最后一个参与方相当于是通知线程。

当最后一个参与方调用了 CyclicBarrier.await() 方法时,在该方法中会先执行 barrierAction.run() ,再执行 trip.signalAll() 唤醒所有等待线程,接着开始下一个分代,也就是 parties 的值会恢复为初始值。

Generation 中有一个布尔值 broken,当调用 CyclicBarrier.await() 方法的线程被中断时,broken 的值就会变为 true。

这时会抛出一个 BrokenBarrierExcetpion 异常,这个异常用于表示当前分代已经被破坏了,无法完成该分代应该完成的任务了。

也就是使用 CyclicBarrier 的每一个线程,都不能被中断(interrupt() 方法被调用)。

9. 怎么让一个线程停止?

9.1 stop() 方法

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

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

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

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

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

9.2 interrupt() 方法

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

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

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

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

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

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

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

9.3 布尔标志位

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

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

10. 什么是 ConcurrentHashMap?

10.1 ConcurrentHashMap 简介

ConcurrentHashMap 是一个并发容器,并发容器是相对于同步容器的一个概念。

我们经常使用的 HashMap 和 ArrayList 等数据容器是线程不安全的,比如使用 HashMap 时需要自己加锁,这时候就需要线程安全的数据容器:同步容器和异步容器。

同步容器指的是 Hashtable 等线程安全的数据容器,同步容器实现线程安全的方式存在性能问题。

同步容器之一的 Hashtable 存在如下的问题。

而并发容器比如 ConcurrentHashMap、CopyOnWriteArrayList 等就不存在这个问题,下面我就来看看它们是怎么实现的。

10.2 ConcurrentHashMap 简史

ConcurrentHashMap 从 JDK 5~8 ,每一个版本都进行了优化,下面我们就看下各个版本对 ConcurrentHashMap 做的优化。

  1. JDK 5

    在 JDK 5 中,ConcurrentHashMap 的实现是使用分段锁,在必要时加锁。

    Hashtable 是整个哈希表加锁,而 JDK 5 引入的 ConcurrentHashMap 使用段(Segment)存储键值对,在必要时对段进行加锁,不同段之间的访问不受影响。

    JDK 5 的 ConcurrentHashMap 中的哈希算法对于比较小的整数,比如三万以下的整数作为 key 时,无法让元素均匀分布在各个段中,导致它退化成了一个 Hashtable。

  2. JDK 6

    在 JDK 6 中,ConcurrentHashMap 优化了二次 Hash 算法,用了 single-word Wang/Jenkins 哈希算法,这个算法可以让元素均匀分布在各个段中。

  3. JDK 7

    JDK 7 的 ConcurrentHashMap 初始化段的方式跟之前的版本不一样,以前是 ConcurrentHashMap 构造出来后直接实例化 16 个段,而 JDK 7 开始,是需要哪个就创建哪个。

    懒加载实例化段会涉及可见性问题,所以在 JDK 7 的 ConcurrentHashMap 中使用了 volatile 和 UNSAFE.getObjectVolatile() 来保证可见性。

  4. JDK 8

    在 JDK 8 中,ConcurrentHashMap 废弃了段这个概念,实现改为基于 HashMap 原理进行并发化。

    对不必加锁的地方,尽量使用 volatile 进行访问,对于一定要加锁的操作,会选择小的范围加锁。

10.3 ConcurrentHashMap 特点

11. 使用线程有哪些准则?

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

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

  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。

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

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

12.1 异步简介

异步指的是代码不是按照我们写的顺序来执行的,除了多线程,像是 OnClickListener 中的代码也算是异步执行的。

在编写异步代码时,要注意的是有可能写出回调地狱,回调地狱代码可能过两天后你自己看自己写的代码都不会知道是干什么用的,比如下面这样的。

btn.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View v) {
    sendRequest(request, new Callback() {
      public void onSuccess(Response response) {
        handler.post(new Runnable() {
                    @Override         
          public void run() {
            updateUI(response);
          }
        })
      }
    })
  }
});

12.2 Thread

直接创建 Thread 是最简单的异步方式,但是使用这种方式除了方便简单之外,没有任何其他优势。

而且使用这种方式有很多缺点,比如说不容易被复用,导致频繁创建和销毁线程的开销大。

假如我们要执行一个定时任务,直接创建 Thread 虽然也能实现,但是比较麻烦。

12.3 HandlerThread

HandlerThread 本质上也是一个 Thread,但是它自带了消息循环。

HandlerThread 内部是以串行的方式执行任务,它比较适合需要长时间执行,不断从队列中取出任务执行的场景。

12.4 IntentService

IntentService 是 Service 组件的子类,它的内部有一个 HandlerThread,所以它具备了 HandlerThread 的特性。

它有两点优势,第一点是相对于 Service 来说,IntenService 的执行是在工作线程而不是主线程。

第二点是它是一个 Service,如果应用使用了 Service,会提高应用的优先级,这样就不容易被系统干掉。

12.5 AsyncTAsk

AsyncTask 是 Android 提供的异步工具类,它的内部实现使用了线程池,使用 AsyncTask 的好处就是不用我们自己处理线程切换。

使用 AsyncTask 要注意它在不同版本的实现不一致,但这个不一致是在 API 14 以下的,而我们现在大部分应用的适配都是在 15 及以上,所以这个问题基本上已经没有了。

12.6 线程池

12.6.1 线程池简介

线程池示意图.png

使用线程池执行异步任务有下面两个优点。

我们可以通过 Executors 创建线程池,当 Executors 不能满足我们的需要时,我们可以自定义 ThreadPoolExecutor 实现满足我们需要的线程池。

12.6.2 线程池基本用法

通过下面的 ThreadPoolUtils,各个业务线使用线程时可以通过这个类直接获取全局线程池。

将线程池的线程数固定为 5 个,可以避免直接创建线程导致线程数过多。

通过 ThreadFactory,我们可以在创建线程时设置名字,这样能避免无法定位问题到出问题的线程。

private static ExecutorService sService = Executors.newFixedThreadPool(5,
  new ThreadFactory() {
  @Override
  public Thread newThread(Runnable r) {
    Thread thread = new Thread(r);
    thread.setName("ThreadPoolUtils");
    return thread;
  }
});

下面这段代码是在执行任务前把线程的名字改掉,并且在任务执行完毕后把线程的名字改回来,这样就能达到一个复用的效果。

public void executeTask() {
  ThreadPoolUtils.getService().execute(new Runnable() {
    @Override
    public void run() {
      String oldName = Thread.currentThread().getName();
      Thread.currentThread().setName("newName");
      System.out.println("执行任务");
      System.out.println("任务执行完毕");
      Thread.currentThread().setName(oldName);
    }
  });
}

12.7 RxJava

12.7.1 RxJava 简介

RxJava 是一个异步框架,在这里我们主要关注它的基本用法、异常和取消的处理。

RxJava 根据任务类型的不同提供了不同的线程池,对于 I/O 密集型任务,比如网络请求,它提供了 I/O 线程池。

对于 CPU 密集型任务,它提供了 CPU 任务专用的线程池,也就是 Schdulers.computation()。

如果我们项目集成了 RxJava,我们可以使用 RxJava 的线程池。

12.7.1 RxJava 基本用法

对于 12.1 小节中的代码,使用 RxJava 写的话是下面这样的。

btn.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View v) {
    sendRequest(request)
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(new Consumer<Response>() {
        @Override
        public void accept(Response response) throws Exception {
          updateUI(response);
        }
      });
  }
});

而使用了 Lambda 表达式后,上面的代码就变成了下面这样。

btn.setOnClickListener(v -> sendRequest(request))
  .subscribeOn(Schedulers.io())
  .observeOn(AndroidSchedulers.mainThread())
  .subscribe(response -> updateUI(response));

但是这两段代码是有潜在隐患的,这个隐患是因为直接使用 Consumer 而不是 Observer,没有对异常进行处理。

12.7.2 RxJava 异常处理

上面那段代码,我们可以在 observeOn() 方法后面加上另一个方法:onErrorReturnItem(),比如下面这样,把异常映射成 Response。

btn.setOnClickListener(v -> sendRequest(request))
  .subscribeOn(Schedulers.io())
  .observeOn(AndroidSchedulers.mainThread())
  .onErrorReturnItem(t -> mapThrowableToResponse(t))
  .subscribe(response -> updateUI(response));

另一个办法就是使用全局捕获异常,捕获到异常后上报异常。

这里要注意的是,捕获到的如果是 OnErrorNotImplmentedException,那我们要上报它的 cause,因为 cause 里面才是真正的异常信息,比如下面这样的。

RxJavaPlugins.setErrorHandler { e -> 
  report(e instanceof OnErrorNotImplmentedException ? e.getCause() : e);
  Exceptions.throwIfFatal(e);
}

12.7.3 RxJava 取消处理

RxJava 可以执行异步任务,异步任务就有可能出现 Acitvity 关闭后,任务还在继续执行的情况,这时候 Activity 就会被 Observer 持有,导致内存泄漏。

当我们调用了 subscribe() 方法后,我们可以得到一个 Disposable 对象,使用这个对象我们可以在页面销毁时取消对应的任务。

也就是我们可以在 Activity 中维护一个 Disposable 列表,在 onDestory() 方法中逐个取消任务。

还有一个更好的办法,就是使用滴滴的开源框架 AutoDispose,这个框架的使用很简单,只需要想下面这样加上一句 as 就可以了。

btn.setOnClickListener(v -> sendRequest(request))
  .subscribeOn(Schedulers.io())
  .observeOn(AndroidSchedulers.mainThread())
  .onErrorReturnItem(t -> mapThrowableToResponse(t))
  .as(AutoDispose.autoDisposable(ViewScopeProvider.from(btn)))
  .subscribe(response -> updateUI(response));

AutoDispose 的原理就是监听传进来的控件的生命周期,当发现这个控件的被销毁时,往往也就意味着页面被关闭了,这时候就可以取消这个任务。

12.8 Kotlin 协程

12.8.1 Kotlin 协程简介

除了 RxJava,我们还可以使用 Kotlin 协程在 Andorid 中实现异步任务。

使用 Kotlin 协程写出来的异步代码,看上去跟同步代码是非常相似的,下面是一个网络请求的例子。

首先我们定义一个 onClick 扩展方法,把上下文、启动模式和协程体传入 launch 方法中。

fun View.onClick(
  context: CoroutineContext = Dispatchers.Main,
  handler: suspend CoroutineScope.(v: View?) -> Unit
) {
  setOnClickListener { v ->
    GlobalScope.launch(context,CoroutineStart.DEFAULT) {
      handler(v)
    }
  }
}

然后让一个按钮调用这个方法,并且发起网络请求。

btn.onClick {
  val request = Request()
  val response = async { sendRequest(request) }.await()
  updateUI(response)
}

上面这段代码看上去是同步执行的,但是实际上 async {} 中的代码是异步执行的,并且在返回了 Response 之后 updateUI() 方法才会被执行。

12.8.2 Kotlin 协程的取消处理

使用 Kotlin 协程和 RxJava 的作用一样,都是执行异步任务,也都需要注意任务的取消,避免内存泄漏,下面我们就来看下怎么取消 Kotlin 协程执行的异步任务。

对于上面这个例子,我们可以借鉴 AutoDispose 的思路,监听 View 的生命周期,在 View 销毁时取消异步任务。

使用 Kotlin 协程执行任务时我们可以获得一个 Job 对象,通过这个对象我们可以取消对应的任务。

首先我们定义一个监听 View 声明周期的类 AutoDisposableJob,再定义一个 Job 类的扩展函数 autoDispose()。

class AutoDisposableJob(
  private val view: View,
  private val wrapped: Job
) : Job by wrapped, View.OnAttachStateChangeListener {

  init {
    if (ViewCompat.isAttachedToWindow(view)) {
      view.addOnAttachStateChangeListener(this)
    } else {
      cancel()
    }
    invokeOnCompletion {
      view.removeOnAttachStateChangeListener(this)
    }
  }

  override fun onViewDetachedFromWindow(v: View?) {
    cancel()
    view.removeOnAttachStateChangeListener(this)
  }

  override fun onViewAttachedToWindow(v: View?) = Unit

}

fun Job.autoDispose(view: View) = AutoDisposableJob(view, this)

然后再在 onClick() 方法中调用 autoDispose() 扩展方法。

fun View.onClick(
  context: CoroutineContext = Dispatchers.Main,
  handler: suspend CoroutineScope.(v: View?) -> Unit
) {
  setOnClickListener { v ->
    GlobalScope.launch(context,CoroutineStart.DEFAULT) {
      handler(v)
    }.autoDispose(v)
  }
}

参考文献

1. 书籍

  1. 《Java多线程编程实战指南(核心篇)》
  2. 《Java 并发编程实战》
  3. 《Java并发编程之美》

2. 视频

  1. 国内Top团队大牛带你玩转Android性能分析与优化
  2. 大厂资深面试官 带你破解Android高级面试

3. 文章

  1. Java线程中,Blocked,Wait,以及TIMED_WAIT的区别
  2. Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  3. Java多线程(二)之Atomic:原子变量与原子类
  4. 破解 Kotlin 协程(1) - 入门篇
  5. 公平锁,非公平锁,乐观锁,悲观锁

4. 图片

  1. 首图

其他

想要转载本文的朋友,附上作者名和原文链接即可。

如果需要 MD,可以加我的微信,顺便把文章链接发给我,以便我可以跟进读者的评论,及时进行勘误。

上一篇 下一篇

猜你喜欢

热点阅读