探索 Android 多线程优化方法 | 开发者说·DTalk
The following article is from code小生 Author code小生
本文原作者: 灯不利多,原文发布于掘金: https://juejin.im/post/5d45a75de51d4561ee1bdf10
前言
基本介绍
在我学习 Android 多线程优化方法的过程中,发现我对多线程优化的了解太片面。
写这篇文章的目的是完善我对 Android 多线程优化方法的认识,分享这篇文章的目的是希望大家也能从这些知识从得到一些启发。
第一部分讲的是多线程优化的基础知识,包括线程的介绍和线程调度基本原理的介绍。
第二部分讲的是多线程优化需要预防的一些问题,包括线程安全问题的介绍和实现线程安全的办法。
第三部分讲的是多线程优化可以使用的一些方法,包括线程之间的协作方式与 Android 执行异步任务的常用方式。
在阅读本文时,画图和思考可以帮助你更好地记忆和理解文中的内容。
1. 画图
2. 思考
学习不是为了被现有的知识所束缚,而是以现有的知识为基石,发展出新的思想。
这个说法的依据是什么?
怎么以自己的方式去解释这个概念? 怎么在自己的项目中应用这个技巧? 这个概念的具体代码实现是怎样的? 这个实现存在哪些问题?
AS
Android Studio (Android 应用开发工具)
GC
ART
Android Runtime (Android 应用运行时环境)
JVM
Java Virtual Machine (Java 虚拟机)
JUC
java.util.concurrent (Java 并发包)
能不能不用多线程?
不管你懂不懂多线程,你也必须要用多线程。
假如我们现在运行的是用 AS 建的一个啥也没有的 demo 项目,那也不代表我们运行的是一个单线程应用。
因为这个应用是运行在 ART 上的,而 ART 自带了 GC 线程,再加上主线程,它依旧是一个多线程应用。
第三方线程
假如我们的应用中只有一个线程,意味着加载图片时 Loading 动画无法播放,界面是卡死的,用户会失去耐心。
而且 Android 强制要求开发者在发起网络请求时,必须在工作线程,不能在主线程,也就是开发 Android 应用必须使用多线程。
为什么要做多线程优化?
既然上面说到了使用多线程是不可避免的,那使用多线程又会遇到哪些问题呢?
做多线程优化是为了解决多线程的安全性和活跃性问题。
这两个问题会导致多线程程序输出错误的结果以及任务无法执行,下面我们就来看看这两个问题的表现:
但是如果两个人做一个菜呢?
小张在做一个菜,做着做着锅被老王抢走了,老王不知道小张有没有放盐,就又放了一次盐,结果炒出来的菜太咸了,没法吃,然后他们就决定要出去皇城 PK。
这里的 "菜" 对应着我们程序中的数据。
而这种现象就是导致线程出现安全性的原因之一: 竞态 (Race Condition)。
活跃性问题
自从上次出了皇城 PK 的事情后,经理老李出了一条规定,打架扣 100,这条规定一出,小张和老王再也不敢 PK 了,不过没过几天,他们就找到了一种新的方式来互怼。
有一天,小张在做菜,小张要先放盐再放糖,而老王拿着盐,老王要先放糖再放盐,结果过了两个小时两个人都没把菜做出来,经理老李再次陷入懵逼的状态。
这就是线程活跃性问题的现象之一: 死锁 (Deadlock)。
关于线程安全性的三个问题和线程活跃性的四个问题,在本文后面会做更详细的介绍。
什么是线程?
上一节我们讲到了多线程编程可能会导致程序出现这样那样的问题,那什么是线程呢?
我们这一节的内容包括下面几个部分:
线程简介
线程的四个属性
线程的六个方法
线程的六种状态
1. 线程简介
线程是进程中可独立执行的最小单位,也是 CPU 资源分配的基本单位。
2. 线程的四个属性
编号
作用: 线程的编号 (id) 用于标识不同的线程,每条线程拥有不同的编号。
注意事项
不能作为唯一标识: 某个编号的线程运行结束后,该编号可能被后续创建的线程使用,因此编号不适合用作唯一标识
只读: 编号是只读属性,不能修改
每个线程都有自己的名字 (name),名字的默认值是 Thread-线程编号,比如 Thread-0。除了默认值,我们也可以给线程设置名字,以我们自己的方式去区分每一条线程。
作用: 给线程设置名字可以让我们在某条线程出现问题时,用该线程的名字快速定位出问题的地方。
类别
线程的类别 (daemon) 分为守护线程和用户线程,我们可以通过 setDaemon (true) 把线程设置为守护线程。当 JVM 要退出时,它会考虑是否所有的用户线程都已经执行完毕,是的话则退出。而对于守护线程,JVM 在退出时不会考虑它是否执行完成。
作用: 守护线程通常用于执行不重要的任务,比如监控其他线程的运行情况,GC 线程就是一个守护线程。 注意事项: setDaemon() 要在线程启动前设置,否则 JVM 会抛出非法线程状态异常 (IllegalThreadStateException)。
作用: 线程的优先级 (Priority) 用于表示应用希望优先运行哪个线程,线程调度器会根据这个值来决定优先运行哪个线程。 取值范围: Java 中线程优先级的取值范围为 1~10,默认值是 5,Thread 中定义了下面三个优先级常量。
最低优先级: MIN_PRIORITY = 1
默认优先级: NORM_PRIORITY = 5
最高优先级: MAX_PRIORITY = 10
注意事项
不保证: 线程调度器把线程的优先级当作一个参考值,不一定会按我们设定的优先级顺序执行线程。 线程饥饿: 优先级使用不当会导致某些线程永远无法执行,也就是线程饥饿的情况,关于线程饥饿,在第 7 大节会有更多的介绍。
继承性
线程的常用方法有六个,它们分别是三个非静态方法 start()、run()、join() 和三个静态方法 currentThread()、yield()、sleep() 。
下面我们就来看下这六个方法都有哪些作用和注意事项:
start()
作用: start() 方法的作用是启动线程。 注意事项: 该方法只能调用一次,再次调用不仅无法让线程再次执行,还会抛出非法线程状态异常。
run()
作用: run() 方法中放的是任务的具体逻辑,该方法由 JVM 调用,一般情况下开发者不需要直接调用该方法。 注意事项: 如果你调用了 run() 方法,加上 JVM 也调用了一次,那这个方法就会执行两次。
join()
作用: join() 方法用于等待其他线程执行结束。如果线程 A 调用了线程 B 的 join() 方法,那线程 A 会进入等待状态,直到线程 B 运行结束。
注意事项: join() 方法导致的等待状态是可以被中断的,所以调用这个方法需要捕获中断异常。
作用: currentThread() 方法是一个静态方法,用于获取执行当前方法的线程。我们可以在任意方法中调用 Thread.currentThread() 获取当前线程,并设置它的名字和优先级等属性。
作用: yield() 方法是一个静态方法,用于使当前线程放弃对处理器的占用,相当于是降低线程优先级。调用该方法就像是是对线程调度器说: "如果其他线程要处理器资源,那就给它们,否则我继续用"。
注意事项: 该方法不一定会让线程进入暂停状态。
Thread.sleep(ms)
作用: sleep (ms) 方法是一个静态方法,用于使当前线程在指定时间内休眠 (暂停)。线程不止提供了上面的 6 个方法给我们使用,而其他方法的使用在文章的后面会有一个更详细的介绍。
4. 线程的六种状态
线程的生命周期
从线程的角度来看,开发者调用了 start() 方法,就相当于是触发了 Thread 的 run() 方法。如果我们在上一个 Activity 的 onPause() 方法中进行了耗时操作,那么下一个 Activity 的显示也会因为这个耗时操作而慢一点显示,这就相当于是 Thread 的等待状态。
线程的生命周期不仅可以由开发者触发,还会受到其他线程的影响,下面是线程各个状态之间的转换示意图。
新建状态
当我们调用线程的 start() 方法后,线程就进入了可运行 (RUNNABLE) 状态。
可运行状态又分为预备 (READY) 和运行 (RUNNING) 状态。
预备状态: 处于预备状态的线程可被线程调度器调度,调度后线程的状态会从预备转换为运行状态,处于预备状态的线程也叫活跃线程。
运行状态: 运行状态表示线程正在运行,也就是处理器正在执行线程的 run() 方法。当线程的 yield() 方法被调用后,线程的状态可能由运行状态变为预备状态。
当下面几种情况发生时,线程就处于阻塞 (BLOCKED) 状态:
发起阻塞式 I/O 操作
申请其他线程持有的锁
进入一个 synchronized 方法或代码块失败
一个线程执行特定方法后,会等待其他线程执行执行完毕,此时线程进入了等待 (WAITING) 状态。
Object.wait()
LockSupport.park()
Thread.join()
Object.notify()
Object.notifyAll()
LockSupport.unpark()
限时等待状态
下面的几个方法可以让线程进入限时等待状态,下面的方法中的 ms、ns、time 参数分别代表毫秒、纳秒以及绝对时间:
Thread.sleep (ms) Thread.join (ms)
Object.wait (ms)
LockSupport.parkNonos (ns)
LockSupport.parkUntil (time)
终止状态
线程调度的原理是什么?
这一节我们就来看看线程是怎么被调度的,这一节包括以下内容:
Java 内存模型简介
高速缓存
Java 线程调度机制
1. Java 的内存模型简介
Java 内存模型 (Java Memory Model,JMM) 规定了所有变量都存储在主内存中,每条线程都有自己的工作内存。JVM 把内存划分成了好几块,其中方法区和堆内存区域是线程共享的。假如现在有三个线程同时对值为 5 的变量 a 进行自增操作,那最终的结果应该是 8。但是自增的真正实现是分为下面三步的,而不是一个不可分割的 (原子的) 操作:
将变量 a 的值赋值给临时变量 temp 将 temp 的值加 1
将 temp 的值重新赋给变量 a
缓存条目可进一步划分为 Tag、Data Block 和 Flag 三个部分。
Tag: 包含了与缓存行中数据对应的内存地址的部分信息 (内存地址的高位部分比特)。 Data Block: 也叫缓存行 (Cache Line),是高速缓存与主内存之间数据交换的最小单元,可以存储从内存中读取的数据,也可以存储准备写进内存的数据。 Flag: 用于表示对应缓存行的状态信息。
分时调度模型: 分时调度模型是让所有线程轮流获取 CPU 使用权,并且平均分配每个线程占用 CPU 的时间片。
抢占式调度模型: JVM 采用的是抢占式调度模型,也就是先让优先级高的线程占用 CPU,如果线程的优先级都一样,那就随机选择一个线程,并让该线程占用 CPU。也就是如果我们同时启动多个线程,并不能保证它们能轮流获取到均等的时间片。如果我们的程序想干预线程的调度过程,最简单的办法就是给每个线程设定一个优先级。
什么是线程的安全性问题?
竞态 原子性 可见性 有序性
1. 竞态
2. 原子性
4. 有序性
怎么实现线程安全?
锁的五个特点
临界区: 持有锁的线程获得锁后和释放锁前执行的代码叫做临界区 (Critical Section)。
排他性: 锁具有排他性,能够保障一个共享变量在任一时刻只能被一个线程访问,这就保证了临界区代码一次只能够被一个线程执行,临界区的操作具有不可分割性,也就保证了原子性。
串行: 锁相当于是把多个线程对共享变量的操作从并发改为串行。
三种保障: 锁能够保护共享变量实现线程安全,它的作用包括保障原子性、可见性和有序性。
调度策略: 锁的调度策略分为公平策略和非公平策略,对应的锁就叫公平锁和非公平锁。公平锁会在加锁前查看是否有排队等待的线程,有的话会优先处理排在前面的线程。公平锁以增加上下文切换为代价,保障了锁调度的公平性,增加了线程暂停和唤醒的可能性。
锁的两个问题
锁泄漏: 锁泄漏是指一个线程获得锁后,由于程序的错误导致锁一直无法被释放,导致其他线程一直无法获得该锁。
活跃性问题: 锁泄漏会导致活跃性问题,这些问题包括死锁、和锁死等。
1. 监视器锁: 因为使用 synchronized 实现的线程同步是通过监视器 (monitor) 来实现的,所以内部锁也叫监视器锁。
2. 自动获取/释放: 线程对同步代码块的锁的申请和释放由 JVM 内部实施,线程在进入同步代码块前会自动获取锁,并在退出同步代码块时自动释放锁,这也是同步代码块被称为内部锁的原因。
3. 锁定方法/类/对象: synchronized 关键字可以用来修饰方法,锁住特定类和特定对象。
4. 临界区: 同步代码块就是内部锁的临界区,线程在执行临界区代码前必须持有该临界区的内部锁。
7. 非公平锁: 内部锁是使用的是非公平策略,是非公平锁,也就是不会增加上下文切换开销。
内部锁基本用法
// 锁句柄
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 继续执行
下面是显式锁的四个特点:
可重入: 显式锁是可重入锁,也就是一个线程持有了锁后,能再次成功申请这个锁。
手动获取/释放: 显式锁与内部锁区别在于,使用显式锁,我们要自己释放和获取锁,为了避免锁泄漏,我们要在 finally 块中释放锁。
临界区: lock() 与 unlock() 方法之间的代码就是显式锁的临界区。
公平/非公平锁: 显式锁允许我们自己选择锁调度策略。ReentrantLock 有一个构造函数,允许我们传入一个 fair 值,当这个值为 true 时,说明现在创建的这个锁是一个公平锁。由于公平锁的开销比非公平锁大,所以 ReentrantLock 的默认调度策略是非公平策略。
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 释放了显式锁
显示锁获取锁的四个方法
lock(): 获取锁,获取失败时线程会处于阻塞状态。
tryLock(): 获取锁,获取成功时返回 true,获取失败时会返回 false,不会处于阻塞状态。
tryLock(long time, TimeUnit unit): 获取锁,获取到了会返回 true,如果在指定时间内未获取到,则返回 false。在指定时间内处于阻塞状态,可中断。
lockInterruptibly(): 获取锁,可中断。
内部锁与显式锁的区别
1. 灵活性: 内部锁是基于代码的锁,锁的申请和释放只能在一个方法内执行,缺乏灵活性。显式锁是基于对象的锁,锁的申请和释放可以在不同的方法中执行,这样可以充分发挥面向对象编程的灵活性。
2. 锁调度策略: 内部锁只能是非公平锁。显式锁可以自己选择锁调度策略。
3. 便利性: 内部锁简单易用,不会出现锁泄漏的情况。显式锁需要自己手动获取/释放锁,使用不当的话会导致锁泄漏。
4. 阻塞: 如果持有内部锁锁的线程一直不释放这个锁,那其他申请这个锁的线程只能一直等待。显式锁 Lock 接口有一个 tryLock() 方法,当其他线程持有锁时,这个方法会返回直接返回 false。这样就不会导致线程处于阻塞状态,我们就可以在获取锁失败时做别的事情。
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释放了读锁
volatile 关键字
volatile 关键字可用于修饰共享变量,对应的变量就叫 volatile 变量,volatile 变量有下面几个特点:
比锁低: volatile 的开销比锁低,volatile 变量的读写操作不会导致上下文切换,所以 volatile 关键字也叫轻量级锁 。 比普通变量高: volatile 变量读操作的开销比普通变量要高,这是因为 volatile 变量的值每次都要从高速缓存或主内存中读取,无法被暂存到寄存器中。
7. 原子类型
// 初始值为 1
AtomicInteger integer = new AtomicInteger(1);
// 自增
int result = integer.incrementAndGet();
// 结果为 2
System.out.println(result);
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);
}
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);
}
AtomicReference 与 AtomicReferenceFieldUpdater 的区别
8. 锁的使用技巧
使用锁会带来一定的开销,而掌握锁的使用技巧可以在一定程度上减少锁带来的开销和潜在的问题,下面就是一些锁的使用技巧:
长锁不如短锁: 尽量只对必要的部分加锁
大锁不如小锁: 进可能对加锁的对象拆分
公锁不如私锁: 进可能把锁的逻辑放到私有代码中,如果让外部调用者加锁,可能会导致锁不正当使用导致死锁
嵌套锁不如扁平锁: 在写代码时要避免锁嵌套
分离读写锁: 尽可能将读锁和写锁分离
粗化高频锁: 合并处理频繁而且过短的锁,因为每一把锁都会带来一定的开销
消除无用锁: 尽可能不加锁,或者用 volatile 代替
什么是线程的活跃性问题?
下面我们就来看看死锁产生的四个条件和避免死锁的三个方法:
死锁产生的四个条件
当多个线程发生了死锁后,这些线程和相关共享变量就会满足下面四个条件:
资源互斥: 涉及的资源必须是独占的,也就是资源每次只能被一个线程使用
资源不可抢夺: 涉及的资源只能被持有该资源的线程主动释放,无法被其他线程抢夺 (被动释放)
占用并等待资源: 涉及的线程至少持有一个资源,还申请了其他资源,而其他资源刚好被其他线程持有,并且线程不释放已持有资源
循环等待资源: 涉及的线程必须等待别的线程持有的资源,而别的线程又反过来等待该线程持有的资源
避免死锁的三个方法
1. 粗锁法
使用粗粒度的锁代替多个锁,锁的范围变大了,访问共享资源的多个线程都只需要申请一个锁,因为每个线程只需要申请一个锁就可以执行自己的任务,这样 "占用并等待资源" 和 "循环等待资源" 这两个条件就不成立了。
2. 锁排序法
锁排序法指的是相关线程使用全局统一的顺序申请锁。
3. tryLock
4. 开放调用
开放调用 (Open Call) 就是一个方法在调用外部方法时不持有锁,开放调用能破坏 "占用并等待资源" 这个条件。
2. 锁死
信号丢失锁死
嵌套监视器丢失锁死
3. 活锁
活锁 (Livelock) 是指线程一直处于运行状态,但是任务却一直无法继续执行的一种现象。
4. 饥饿
线程之间怎么协作?
1. 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 执行结束");
}
}
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 结束等待,开始做自己想做的事情
2. wait/notify
一个线程因为执行操作 (目标动作) 所需的保护条件未满足而被暂停的过程就叫等待 (wait)。
wait/notify 协作方式有下面九个特点:
1. 暂停/唤醒: Object.wait() 的作用是让线程暂停 (状态改为 WAITING),而 Object.notify() 的作用是唤醒一个被暂停的线程。
2. 所有对象: 由于 Object 是所有对象的父类,所以所有对象都可以实现等待和通知。
3. 获取监视器锁
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();
}
}
当我们在两个线程中分别执行上面两个函数后,会得到下面的输出:
等待线程获取了锁
保护条件不成立,等待线程进入等待状态
通知线程获取了锁
通知线程即将唤醒等待线程
等待线程被唤醒,开始执行目标动作
wait/notify 原理
1. 过早唤醒: 等待线程在保护条件未成立时被唤醒的现象就叫过早唤醒。过早唤醒使得无须被唤醒的等待线程也被唤醒了,导致资源浪费。
循环体外判断条件: 如果等待线程在执行 wait() 方法前没有判断保护条件是否成立,那么有可能导致通知线程在等待线程进入临界区前就更新了共享变量,使得保护条件成立,并进行了通知,但是等待线程并没有暂停,所以也没有被唤醒。这种现象相当于等待线程错过了一个发送给它的“信号”,所以叫信号丢失。只要对保护条件的判断和 wait() 方法的调用放在循环语句中,就可以避免这种情况导致的信号丢失。
notify() 使用不当: 信号丢失的另一个表现是在应该调用 notifyAll() 的情况下调用了 notify(),在这种情况下,避免信号丢失的办法是使用 notifyAll() 进行通知
3. 欺骗性唤醒: 等待线程可能在没有其他线程执行 notify()/notifyAll() 的情况下被唤醒,这种现象叫欺骗性唤醒。
虽然欺骗性唤醒出现的概率比较低,但是 Java 允许这种现象存在,这是 Java 平台对操作系统妥协的一种结果。
避免欺骗性唤醒: 避免欺骗性唤醒的方法就是在循环中判断条件是否满足,不满足时则继续等待,也就是再次调用 wait() 方法。
减少 wait/notify 上下文切换的常用方法有下面两种:
使用 notify() 代替 notifyAll(): 在保证程序正确性的情况下,使用 notify() 代替 notifyAll(),notify() 不会导致过早唤醒,从而减少上下文切换开销
尽快释放对应内部锁: 通知线程执行完 notify()/notifyAll() 后尽快释放对应的内部锁,这样可以避免被唤醒的线程在 wait() 调用返回前,再次申请对应内部锁时,由于该锁未被通知线程释放,导致该线程被暂停
notify()/notifyAll() 的选用
1. 只需唤醒一个线程
2. 对象的等待集中只包含同质等待线程
3. await/signal
await/signal 简介
使用 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() 方法的调用,要放在循环语句中。 引导区内: 循环语句和执行目标动作要放在同一个显式锁引导的临界区中,这么做是为了避免欺骗性唤醒和信号丢失的问题。
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();
}
}
等待线程获取了锁
保护条件不成立,等待线程进入等待状态
通知线程获取了锁
通知线程即将唤醒等待线程
等待线程被唤醒,开始执行目标动作
awaitUntil() 用法
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();
}
等待线程获取了锁
保护条件不满足,并且等待时间未到,等待进入等待状态
已超时,结束等待任务
通知线程获取了锁
通知线程即将唤醒等待线程
4. await/countDown
await/countDown 简介
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 时,会唤醒对应的等待线程。
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 基本用法
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();
}
}
}
第一天爬山,老李先来
老王到了,小张还没到
小张到了
人来齐了,开始爬山
第二天爬山,老王先到
小张到了,老李还没到
老李到了
人来齐了,开始爬山
怎么让一个线程停止?
1. stop() 方法
2. interrupt() 方法
静态
重置
既然 interrupt() 只是对布尔值的一个修改,那我们可以在 Java 层自己设一个布尔标志位,让每个线程共享这个布尔值。
当我们想取消某个任务时,就在外部把这个标志位改为 true。
注意事项: 直接使用布尔标志位会有可见性问题,所以要用 volatile 关键字修饰这个值。 使用场景: 当我们需要用到 sleep() 方法时,我们可以使用 interrupt() 来中断任务,其他时候可以使用布尔标志位。
什么是 ConcurrentHashMap?
1. ConcurrentHashMap 简介
同步容器之一的 Hashtable 存在如下的问题:
大锁: 对 Hashtable 对象加锁 长锁: 直接对方法加锁 读写锁共用: 只有一把锁,从头锁到尾
2. ConcurrentHashMap 简史
JDK 5
JDK 6
JDK 7
JDK 8
小锁
分段锁 (JDK 5~7)
桶节点锁 (JDK 8)
短锁
先尝试获取,失败再加锁
读失败再加锁 (JDK 5~7)
volatile 读 CAS 写 (JDK 7~8)
弱一致性
添加元素后不一定马上能读到 清空后可能仍有元素 遍历前的段元素变化能读到 遍历后的段元素变化读不到 遍历时元素发生变化不会抛异常
使用线程有哪些准则?
严禁直接创建线程
提供基础线程池供各个业务线使用
选择合适的异步方式
线程必须命名
重视优先级设置
怎么在 Android 中执行异步任务?
1. 异步简介
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);
}
})
}
})
}
});
2. Thread
3. HandlerThread
4. IntentService
5. AsyncTAsk
6. 线程池
线程池简介
使用线程池执行异步任务有下面两个优点:
易于复用: 通过线程池创建的线程容易复用,这样就避免了线程频繁创建和销毁的开销。
功能强大: 线程池提供了几个强大的功能,比如定时、任务队列、并发数控制等。
我们可以通过 Executors 创建线程池,当 Executors 不能满足我们的需要时,我们可以自定义 ThreadPoolExecutor 实现满足我们需要的线程池。
线程池基本用法
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);
}
});
}
7. RxJava
RxJava 简介
对于 CPU 密集型任务,它提供了 CPU 任务专用的线程池,也就是 Schdulers.computation()。
如果我们项目集成了 RxJava,我们可以使用 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));
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);
}
RxJava 取消处理
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 的原理就是监听传进来的控件的生命周期,当发现这个控件的被销毁时,往往也就意味着页面被关闭了,这时候就可以取消这个任务。
8. Kotlin 协程
Kotlin 协程简介
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() 方法才会被执行。
Kotlin 协程的取消处理
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)
}
}
"开发者说·DTalk" 面向中国开发者们征集 Google
长按右侧二维码
报名参与