Android性能优化之java线程机制与线程调度原理详解

483 阅读29分钟

**这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战
**

前言小计

在平时工作中如若使用不当会出现数据错乱、执行效率低(还不如单线程去运行)或者死锁程序挂掉等等问题,所以掌握了解多线程至关重要;

线程有很多优势:

1、提高多处理器的利用效率;

2、简化业务功能设计;

3、实现异步处理;

多线程的风险:

1、共享数据的线程安全性;

2、多线程执行时的活跃性问题;

3、多线程的所带来的性能损失问题;

多线程相对于其他知识点来讲,有一定的学习门槛,并且了解起来比较费劲;

线程的优势我们很清楚,线程的风险我们也都知道,但是要做好风险控制就没有那么简单了;

本文从基础概念开始到最后的并发模型由浅入深,讲解下线程方面的知识;

本文章已在公众号【Android开发编程】发布

一、什么是线程?

1、线程简介

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

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

  • 操作系统会把资源分配给进程,但是 CPU 资源比较特殊,它是分配给线程的,这里说的 CPU 资源也就是 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;

注意事项:不保证,线程调度器把线程的优先级当作一个参考值,不一定会按我们设定的优先级顺序执行线程;

⑥线程饥饿

优先级使用不当会导致某些线程永远无法执行,也就是线程饥饿的情况;

⑦继承性

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

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

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

3、线程的六个重要方法

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

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

①start()

  • 作用:start() 方法的作用是启动线程;

  • 注意事项:该方法只能调用一次,再次调用不仅无法让线程再次执行,还会抛出非法线程状态异常;

② run()

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

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

③ join()

  • 作用:join() 方法用于等待其他线程执行结束;如果线程 A 调用了线程 B 的 join() 方法,那线程 A 会进入等待状态,直到线程 B 运行结束;

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

④Thread.currentThread()

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

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

⑤Thread.yield()

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

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

  • 注意事项:该方法不一定会让线程进入暂停状态;

⑥ Thread.sleep(ms)

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

4、线程的六种状态

①线程的生命周期

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

image.png

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

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

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

②可运行状态:当我们调用线程的 start() 方法后,线程就进入了可运行(RUNNABLE)状态,可运行状态又分为预备(READY)和运行(RUNNING)状态;

③预备状态:处于预备状态的线程可被线程调度器调度,调度后线程的状态会从预备转换为运行状态,处于预备状态的线程也叫活跃线程,当线程的 yield() 方法被调用后,线程的状态可能由运行状态变为预备状态

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

⑤阻塞状态:当下面几种情况发生时,线程就处于阻塞(BLOCKED)状态,发起阻塞式 I/O 操作、申请其他线程持有的锁、进入一个 synchronized 方法或代码块失败;

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

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

  • Object.wait()

  • LockSupport.park()

  • Thread.join()

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

  • Object.notify()

  • Object.notifyAll()

  • LockSupport.unpark()

⑧限时等待状态

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

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

  • Thread.sleep(ms);

  • Thread.join(ms);

  • Object.wait(ms);

  • LockSupport.parkNonos(ns);

  • LockSupport.parkUntil(time);

⑨ 终止状态

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

二、线程调度的原理

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

1、Java 的内存模型

image.png

  • Java 内存模型规定了所有变量都存储在主内存中,每条线程都有自己的工作内存;

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

2、高速缓存

image.png

①高速缓存简介

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

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

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

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

②高速缓存内部结构

image.png

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

③缓存条目结构

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

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

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

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

3、Java 线程调度机制

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

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

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

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

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

①分时调度模型

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

②抢占式调度模型

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

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

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

三、线程的安全性问题详解

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

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

①原子性

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

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

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

②可见性

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

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

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

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

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

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

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

③ 有序性

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

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

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

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

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

四、实现线程安全

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

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

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

1、锁

image.png

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

锁的五个特点:

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

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

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

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

  • 调度策略:锁的调度策略分为公平策略和非公平策略,对应的锁就叫 公平锁和非公平锁;公平锁会在加锁前查看是否有排队等待的线程,有的话会优先处理排在前面的线程;公平锁以增加上下文切换为代价,保障了锁调度的公平性,增加了线程暂停和唤醒的可能性;

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

2、 volatile 关键字

image.png

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 变量时,其他线程可能已经更新了该共享变量的值;

3、原子类型

原子类型简介:

在 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 操作实现的;

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

①、AtomicReference 基本用法

class AtomicReferenceValueHolder {

AtomicReference 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() {

@Override

public String apply(String s) {

return "HelloWorld";

}

});

// Hello World

System.out.println(value);

}

② 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() {

@Override

public String apply(String s) {

return "HelloWorld";

}

});

// HelloWorld

System.out.println(value);

}

③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、 锁的使用技巧

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

  • 长锁不如短锁:尽量只对必要的部分加锁;

  • 大锁不如小锁:进可能对加锁的对象拆分;

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

  • 嵌套锁不如扁平锁:在写代码时要避免锁嵌套;

  • 分离读写锁:尽可能将读锁和写锁分离;

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

  • 消除无用锁:尽可能不加锁,或者用 volatile 代替;

五、线程的四个活跃性问题

1、死锁

image.png

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

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

2、死锁产生的四个条件

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

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

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

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

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

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

3、 避免死锁的三个方法

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

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

①、粗锁法

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

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

②锁排序法

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

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

③tryLock

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

④开放调用

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

六、线程之间怎么协作?

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

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

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

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();

}

}

}

2、 wait/notify

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

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

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();

}

}

3、 wait/notify 原理

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

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

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

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

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

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

4、notify()/notifyAll()

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

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

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

①只需唤醒一个线程

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

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

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

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

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

5、await/signal

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

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();

}

}

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

等待线程获取了锁

保护条件不成立,等待线程进入等待状态

通知线程获取了锁

通知线程即将唤醒等待线程

等待线程被唤醒,开始执行目标动作

6、 awaitUntil() 用法

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();

}

等待线程获取了锁

保护条件不满足,并且等待时间未到,等待进入等待状态

已超时,结束等待任务

通知线程获取了锁

通知线程即将唤醒等待线程

7、 await/countDown

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

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();

}

}

8、 CyclicBarrier

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

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();

}

}

}

Android 中常用的 7 种异步方式:Thread、HandlerThread、IntentService、AsyncTask、线程池、RxJava 和 Kotlin 协程;

总结:

1、线程有很多优势:

提高多处理器的利用效率;

简化业务功能设计;

实现异步处理;

2、多线程的风险:

共享数据的线程安全性;

多线程执行时的活跃性问题;

多线程的所带来的性能损失问题;

3、下次详解下Android中常用异步方式,从实际出发。