为什么开发中需要并发编程?
- 加快响应用户的时间
- 使你的代码模块化、异步化、简单化
- 充分利用CPU资源
基础概念
进程和线程
进程
我们常听说的应用程序,由指令和数据组成。当我们不运行应用程序时,这些应用程序就是放在磁盘上的二进制的代码。一旦我们运行这些应用程序,指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备,从这种角度来说,进程就是用来加载指令、管理内存、管理IO的。
站在操作系统的角度,进程是程序运行资源分配(以内存为主)的最小单位。
线程
线程是CPU调度的最小单位,线程必须依赖于进程而存在,线程是进程中的一个实体,是CPU调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有在运行中必不可少的资源(如程序计数器、一组寄存器和栈),但是它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个进程可以拥有多个线程,一个线程必须有一个父进程。
同一台计算机的进程通信称为IPC(Inter-process communication),不同计算机之间的通信称为RPC,需要通过网络,并遵守共同的协议,比如大家熟悉的Dubbo就是一个PRC框架,而Http协议也经常用在PRC上,比如SpringCloud微服务。
进程间通信有几种方式?
- 管道:分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用于可用于具有亲缘关系的父子进程之间的通信,命名管道除了具有管道所具有的功能外,还允许无亲缘关系进程间的通信。
- 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。
- 消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限的进程可以按照一定的规则向消息队列中添加新消息;对消息队列有读权限的进程则可以从消息队列读取信息。
- 共享内存(shared memory):它使得多个进程可以访问同一块内存区域,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。
- 信号量(semaphore):主要作为进程间及同一进程的不同线程之间的同步和互斥手段。
- 套接字(socket):这是一种更为一般的进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用Unix domain socket(比如同一机器中MySQL中的控制台mysql shell和MySQL服务程序的连接),这种方式不需要经过网络协议栈,不需要打包拆包、计算校验和维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。
CPU核心数和线程数的关系
目前主流CPU都是多核的,线程是CPU调度的最小单位。同一时刻,一个CPU核心职能运行一个线程,也就是CPU和线程数的关系是1:1的关系,也就是说8核处理器可以同时执行8个线程的代码。但Intel引入超线程技术后,使核心数与线程数形成1:2的关系。
在Java中提供了Runtime.getRuntime().availableProcessors(),可以让我们获取当前的CPU核心数,注意这个核心数是逻辑处理器数。
上下文切换
上下文是CPU寄存器和程序计数器在任何时间点的内容。
寄存器是CPU内部的一小部分非常快的内存,它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换可以更详细地描述为内核对CPU上的进程(包括线程)执行以下活动:
- 暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存的某个地方
- 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它
- 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程
引发上下文切换的原因一般包括:线程、进程切换、系统调用等等,就CPU时间而言,一次上下文切换大概需要5000~20000个时钟周期,相对一个简单指令几个乃至十几个左右的执行时钟周期,成本是巨大的。
并行与并发
并发Concurrent:指应用能够交替执行不同的任务,比如单CPU核心下执行多线程并非同时执行多个任务,如果你打开两个线程,这两个线程会交替执行。当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少?离开了单位时间其实是没有意义的。
并行Parallel:指应用能够同时执行不同的任务。
两者之间的区别:一个是交替执行,一个是同时执行。
Java中的多线程
一个Java程序的运行就算用户没有自己 开启线程,实际也有很多JVM自行启动的线程,一般来说有:
[6] Monitor Ctrl-Break//监控Ctrl-Break中断信号
[5] Attach Listener//内存dump、线程dump、类信息统计、获取系统属性等
[4] Signal Dispatcher//分发处理发送给JVM信号的线程
[3] Finalizer//调用对象finalize方法的线程
[2] Reference Handler//清除Referene的线程
[1] main//用户程序入口
线程的启动与中止
启动
启动的方式有:
- 继承Thread
public class ThreadTest {
public static void main() {
Thread thread = new Thread() {
public void run() {
System.out.println("test");
}
}
thread.start();//开始线程
}
}
- 实现Runnable接口
public class ThreadTest {
public static void main() {
Runnable runnable = new Runnable() {
System.out.println("Runnable");
}
Thread thread = new Thread(runnable);
thread.start();//开始线程
}
}
Thread和Runnable的区别
Thread是Java里对线程的唯一抽象,Runnable只是对任务(业务逻辑)的抽象。Thread可以接受任意一个Runnable的实例并执行
Callable、Future和FutureTask
Runnable是一个接口,其中声明了run()方法,由于run()方法无返回值,所以在执行完任务之后无法返回任何结果。
Callable位于java.util.concurrent包下,它也是一个接口,在它里面声明了一个方法call(),这是一个泛型接口,call()函数返回的类型就是传递进来的V类型
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
Future就是对具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。
FutureTask类实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future的到Callable的返回值。
因此我们通过一个线程运行Callable,但Thread不支持构造方法中传递Calable的实例,所以需要通过FutureTask把一个Callable包装成Runnable,然后在通过这个FutureTask,拿到Callable运行后的返回值
新启线程有几种方式?
官方说是两种,一种是继承Thread类,一种是实现Runnable接口,
中止
线程自然终止:要么是run执行完成了,要么是跑出了一个未处理的异常导致线程提前结束。
stop:暂停、恢复和停止操作对应在线程Thread地API就是suspend()、resume()、stop()。但是这些API是过期的,也就是不建议使用的。不建议使用的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。
中断:
安全的中止则是其他线程通过调用某个线程A的interrupt()方法对其进行中断操作,中断好比其他线程对该线程打了个招呼,“A,你要中断了”,不代表线程A会立即停止自己的工作,同样的A线程完全可以不理会这种中断请求。
线程通过方法isInterrupted()来进行判断是否被中断, 也可以调用静态方法Thread.interrupted()来进行判断当前线程是否被中断。
如果一个线程处于阻塞状态,则在线程在检查中断标示时如果发现中断标示为true,则会在这些阻塞方法调用处抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标示位清除,即重新设置为false。
不建议自定义一个取消标志位来中止线程的运行。因为run方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志,这种情况下,使用中断会更好,因为:
- 一般的阻塞方法,如 sleep 等本身就支持中断的检查
- 检查中断位的状态和检查取消标志位没什么区别, 用中断位的状态还可以避免声明取消标志位,减少资源的消耗。
注意:处于死锁状态的线程无法被中断
深入理解run()和start()
Thread类时Java里对线程概念的抽象,可以这样理解:我们通过new Thread()其实只是new出一个Thread的实例,还没有和操作系统中真正的线程挂钩。只有执行了start()方法后,才实现了真正意义上的启动线程。
start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现 的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常。
深入学习Java的线程
线程的状态/生命周期
Java中的线程状态分为6种:
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种 状态笼统的称为“运行”。
线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。 该状态的线程位于可运行线程池中, 等待被线程调度选中, 获取 CPU 的使用权, 此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中 状态(running)。 - 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时 间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
状态之间的变迁如下图所示
其他线程相关方法
yield()方法:使当前线程让出CPU占有权,但让出的时间是不可设定的。也不会释放锁资源,同时执行yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。
比如,ConcurrentHashMap#initTable方法中就使用了这个方法,这是因为ConcurrentHashMap中可能被多个线程同时初始化table,但是其实这个时候只允许一个线程进行初始化操作,其他的线程就需要被阻塞或等待,但是初始化操作其实很快,这里Doug Lea大师为了避免阻塞或者等待这些操作引发的上下文切换等等开销,就让其他不执行初始化操作的线程干脆执行yield()方法,以让出CPU执行权,让执行初始化操作的线程可以更快的执行完成。
线程的优先级
在Java线程中,通过一个整型变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级为5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
线程的调度
线程调度是指系统为线程分配CPU使用权的过程,主要调度方式有两种:协同式线程调度(Cooperative Threads-Scheduling)抢占式线程调度(Preemptive Threads-Scheduling)使用协同式线程调度的多线程系统, 线程执行的时间由线程本身来控制, 线程把自己的工作执行完之后, 要主动通知系统切换到另外一个线程上。使用协同 式线程调度的最大好处是实现简单,
由于线程要把自己的事情做完后才会通知系 统进行线程切换, 所以没有线程同步的问题, 但是坏处也很明显, 如果一个线程 出了问题,则程序就会一直阻塞。
使用抢占式线程调度的多线程系统, 每个线程执行的时间以及是否切换都由 系统决定。在这种情况下, 线程的执行时间不可控, 所以不会有「一个线程导致 整个进程阻塞」的问题出现。
线程和协程
为什么Java线程调度是抢占式调度?这需要我们了解Java中的线程实现模式。
我么已经知道线程其实是操作系统层面的实体,Java中的线程怎么和操作系统层面对应起来呢?任何语言实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
内核线程实现
使用内核线程实现的方式称为1:1实现。内核线程(Kernel-Level-Thread,KLT)就是直接有操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
由于内核线程的支持,每个线程都称为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。
局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个语言层面的线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。
用户线程实现
用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。这种实现不需要切换到内核态,因此操作很快而且低消耗,也能够支持更大的线程数量,部分高性能的数据库就是用这种方式实现的。
其劣势也在于没有系统内核的支持,所有的线程操作都要求用户自己去完成。线程的创建、销毁、切换和调度都是用户需要考虑的问题。Java曾经是使用过用户线程,最终又放弃了。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang。
混合实现
线程除了依赖内核线程和完全由用户线程自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在内核线程。
用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系。
Java线程的实现
Java在JDK1.2以前,是用永固线程实现的,但从JDK1.3起,Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,采用1:1的线程模型。
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,所以HotSpot自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。
所以,这就是我们说Java线程是抢占式的原因。
协程
1:1的内核线程模型是如今Java虚拟机实现的主流选择,但是这种方式有天然的缺陷,即切换、调度成本高,系统能容纳的线程数量也很有限。现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近于计算本身的开销,就会造成严重的浪费。
另外我们常见的Java Web服务器,比如Tomcat的线程池容量通常在几十到两百之间,当把数以百万计的请求往线程池里灌时,系统即使处理得过来,但其中的切换耗损也是相当可观的。
这样的话,对Java语言来说,用户线程的重新引入成为了上述问题一个非常可行的方案。其次,Go语言等支持用户线程等新型语言给Java带来了巨大的压力,也使得Java引入用户线程称为一个绕不开的话题。
协程简介
为什么用户线程又被称为协程呢?我们知道,内核线程的切换换开销是来自于 保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉 吗? 答案还是“不能”。 但是, 一旦把保护、恢复现场及调度的工作从操作系 统交到程序员手上, 则可以通过很多手段来缩减这些开销。
由于最初多数的用户线程是被设计成协同式调度(Cooperati ve Scheduling) 的,所以它有了一个别名—— “协程”(Corouti ne) 完整地做调用栈的保护、 恢复工作,所以今天也被称为“有栈协程”(Stackfull Corouti ne)。
协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核 线程要轻量得多。在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB ,此外内核数据结构(Kernel Data Structures) 还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支 持协程的应用中, 同时并存的协程数量可数以十万计。
总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的 延迟)。
纤程-Java 中的协程
在 JVM 的实现上,以 HotSpot 为例, 协程的实现会有些额外的限制, Java 调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正 常切换协程而不影响整个线程? 另外,如果协程中遇传统的线程同步措施会怎 样? 譬如 Kotlin 提供的协程实现, 一旦遭遇 synchronize 关键字, 那挂起来的 仍将是整个线程。
OpenJDK 在 2018 年 创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber) ”这个 名字。
Loom 项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为 了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟 机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似 的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为 了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。
根据 Loom 团队在 2018 年公布的他们对 Jett y 基于纤程改造后的测试结果, 同样在 5000QPS 的压力下, 以容量为 400 的线程池的传统模式和每个请求配以 一个纤程的新并发处理模式进行对比,前者的请求响应延迟在 10000 至 20000 毫秒之间, 而后者的延迟普遍在 200 毫秒以下。
目前 Java 中比较出名的协程Quasar[ˈkweɪzɑː®](Loom 项目的 Leader 就 是 Quasar 的作者Ron Pressler), Quasar 的实现原理是字节码注入,在字节码 层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚 拟机的现场保护虽然能够工作,但影响性能。
2022 年 9 月 22 日,JDK19(非 LTS 版本) 正式发布,引入了协程,并称为 轻量级虚拟线程。但是这个特性目前还是预览版, 还不能引入生成环境。 因为环 境所限,本课程不提供实际的范例,只讲述基本用法和原理。
守护线程
Daemon(守护) 线程是一种支持型线程,因为它主要被用作程序中后台调 度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的 时候, Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是Daemon 线程。
Daemon 线程被用作完成支持性工作, 但是在 Java 虚拟机退出时 Daemon 线 程中的 finally 块并不一定会执行。在构建 Daemon 线程时, 不能依靠 finally 块中 的内容来确保执行关闭或清理资源的逻辑。
线程间的通信和协调、 协作
很多的时候, 孤零零的一个线程工作并没有什么太多用处, 更多的时候, 我 们是很多线程一起工作,而且是这些线程间进行通信, 或者配合着完成某项工作, 这就离不开线程间的通信和协调、协作。
管道输入输出流
我们已经知道,进程间有好几种通信机制,其中包括了管道,其实 Java 的 线程里也有类似的管道机制, 用于线程之间的数据传输, 而传输的媒介为内存。
设想这么一个应用场景:通过 Java 应用生成文件, 然后需要将文件上传到 云端,比如:
- 页面点击导出后, 后台触发导出任务, 然后将 mysql 中的数据根据导出 条件查询出来, 生成 Excel 文件, 然后将文件上传到 oss,最后发步一个下载文 件的链接。
- 和银行以及金融机构对接时,从本地某个数据源查询数据后, 上报 xml 格 式的数据,给到指定的 ftp、或是 oss 的某个目录下也是类似的。
我们一般的做法是, 先将文件写入到本地磁盘, 然后从文件磁盘读出来上传 到云盘,但是通过Java 中的管道输入输出流一步到位,则可以避免写入磁盘这 一步。
Java 中的管道输入/输出流主要包括了如下 4 种具体实现:PipedOutputStream 、PipedInputStream 、PipedReader 和 PipedWriter,前两种面 向字节,而后两种面向字符。
import java.io.IOException;
import java.io.PipedReader;
import java.io.PipedWriter;
public class Piped {
public static void main(String[] args) throws Exception {
PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader();
/* 将输出流和输入流进行连接,否则在使用时会抛出IOException*/
out.connect(in);
Thread printThread = new Thread(new Print(in), "PrintThread");
printThread.start();
int receive = 0;
try {
/*将键盘的输入,用输出流接受,在实际的业务中,可以将文件流导给输出流*/
while ((receive = System.in.read()) != -1){
out.write(receive);
}
} finally {
out.close();
}
}
static class Print implements Runnable {
private PipedReader in;
public Print(PipedReader in) {
this.in = in;
}
@Override
public void run() {
int receive = 0;
try {
/*输入流从输出流接收数据,并在控制台显示
*在实际的业务中,可以将输入流直接通过网络通信写出 */
while ((receive = in.read()) != -1){
System.out.print((char) receive);
}
} catch (IOException ex) {
}
}
}
}
join方法
现在有 T1、T2、T3 三个线程, 你怎样保证 T2 在 T1 执行完后执行, T3 在 T2 执行完后执行?
答:用 Thread#join 方法即可, 在 T3 中调用 T2.join,在 T2 中调用 T1.join。
join()
把指定的线程加入到当前线程, 可以将两个交替执行的线程合并为顺序执行。 比如在线程 B 中调用了线程 A 的 Join()方法, 直到线程 A 执行完毕后, 才会继续 执行线程 B 剩下的代码。
synchronized 内置锁
Java 支持多个线程同时访问一个对象或者对象的成员变量,但是多个线程同 时访问同一个变量,会导致不可预料的结果。关键字synchronized 可以修饰方法 或者以同步块的形式来进行使用, 它主要确保多个线程在同一个时刻, 只能有一 个线程处于方法或者同步块中, 它保证了线程对变量访问的可见性和排他性, 使 多个线程访问同一个变量的结果正确,它又称为内置锁机制。
对象锁和类锁
对象锁是应用在对象上的锁,类锁是应用在类上的锁,如下便是类锁
private static synchronized void classLock() {
...
}
对象锁如下:
private Object lock = new Object();
private static objectLock() {
synchronized(lock) {
...
}
}
volatile,最轻量的通信/同步机制
volatile 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某 个变量的值,这新值对其他线程来说是立即可见的。
不加 volatile 时,子线程无法感知主线程修改了 ready 的值,从而不会退出循环, 而加了 volatile 后,子线程可以感知主线程修改了 ready 的值,迅速退出循环。
但是 volatile 不能保证数据在多个线程下同时写时的线程安全,volatile 最适用的 场景:一个线程写,多个线程读。
等待/通知机制
线程之间相互配合,完成某项工作,比如: 一个线程修改了一个对象的值, 而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程, 而最终执行又是另一个线程。前者是生产者, 后者就是消费者, 这种模式隔离了 “做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地循环检查变量是否符合预期在 while 循环中设置不满足的条件, 如果条件满足则退出 while循环,从而完成消费者的工作。却存在如下问题:
- 难以确保及时性。
- 难以降低开销。如果降低睡眠的时间,比如休眠1毫秒,这样消费者能 更加迅速地发现条件
变化, 但是却可能消耗更多的处理器资源, 造成了无端的浪费。
等待/通知机制则可以很好的避免,这种机制是指一个线程 A 调用了对象 O 的 wait()方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify()或者notifyAll()方法, 线程 A 收到通知后从对象 O 的 wait()方法返回, 进而执行后续操 作。上述两个线程通过对象 O 来完成交互, 而对象上的 wait()和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
notify():
通知一个在对象上等待的线程,使其从 wait 方法返回,而返回的前提是该线程 获取到了对象的锁,
没有获得锁的线程重新进入 WAITING 状态。
notifyAll():
通知所有等待在该对象上的线程
wait():
调用该方法的线程进入WAITING 状态, 只有等待另外线程的通知或被中断才会返回. 需要注意,调用wait()方法后,会释放对象的锁
wait(long):
超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有 通知就超时返回
wait(long,int):
对于超时时间更细粒度的控制,可以达到纳秒
等待和通知的标准范式
等待方遵循如下原则。
- 获取对象的锁。
- 如果条件不满足, 那么调用对象的 wait()方法, 被通知后仍要检查条件。
- 条件满足则执行对应的逻辑。
synchronized(对象) {
while(条件不满足) {
对象.wait();
}
对应的处理逻辑
}
通知方遵循如下原则。
4. 获得对象的锁。
5. 改变条件。
6. 通知所有等待在对象上的线程。
synchronized(对象) {
改变条件
对象.notifyAll();
}
在调用 wait()、notify()系列方法之前, 线程必须要获得该对象的对象级 别锁, 即只能在同步方法或同步块中调用 wait()方法、notify()系列方法,进 入 wait()方法后, 当前线程释放锁,在从 wait()返回前, 线程与其他线程竞争重新获得锁,执行 notify()系列方法的线程退出调用了 notifyAll 的 synchronized 代码块的时候后,他们就会去竞争。如果其中一个线程获得了该对象锁, 它就会 继续往下执行, 在它退出 synchronized 代码块,释放锁后, 其他的已经被唤醒的线程将会继续竞争获取该锁, 一直进行下去, 直到所有被唤醒的线程都执行完毕。
notify 和notifyAll 应该用谁
尽可能用 notifyall(),谨慎使用 notify() ,因为 notify()只会唤醒一个线程, 我 们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程
方法和锁
调用 yield() 、sleep() 、wait() 、notify()等方法对锁有何影响?
yield() 、sleep()被调用后,都不会释放当前线程所持有的锁。
调用 wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后, 会重新 去竞争锁,锁竞争到后才会执行 wait 方法后面的代码。
调用 notify()系列方法后, 对锁无影响, 线程只有在 syn 同步代码执行完后才 会自然而然的释放锁,所以 notify()系列方法一般都是 syn 同步代码的最后一行。
wait 和notify
为什么 wait 和 notify 方法要在同步块中调用?
主要是因为 Java API 强制要求这样做,如果你不这么做,你的代码会抛出IllegalMonitorStateException 异常。这个问题并不是说只在 Java 语言中会出现,而是会在所有的多线程环境下出现。
为什么你应该在循环中检查等待条件?
处于等待状态的线程可能会收到错误警报和伪唤醒, 如果不在循环中检查等待条件, 程序就会在没有满足结束条件的情况下退出。因此, 当一个等待线程醒来时,不能认为它原来的等待状态仍然是有效的,在 notify()方法调用之后和等待线程醒来之前这段时间它可能会改变。这就是在循环中使用 wait()方法效果更好的原因。
CompleteableFuture
Java 的 1.5 版本引入了 Future,可以把它简单的理解为运算结果的占位符, 它提供了两个方法来获取运算结果。
get():调用该方法线程将会无限期等待运算结果。
get(longti meout, TimeUnit unit):调用该方法线程将仅在指定时间 timeout 内等待结果,如果等待超时就会抛出 TimeoutExcepti on 异常。
Future 可以使用 Runnable 或 Callable 实例来完成提交的任务,它存在如下几个问题:
- 阻塞调用get()方法会一直阻塞,直到等待直到计算完成,它没有提供任何方法可以在完成时通知, 同时也不具有附加回调函数的功能。
- 链式调用和结果聚合处理 在很多时候我们想链接多个 Future 来完成耗时 较长的计算, 此时需要合并结果并将结果发送到另一个任务中,该接口很难完成 这种处理。
- 异常处理 Future 没有提供任何异常处理的方式。
JDK1.8 才新加入的一个实现类 CompletableFuture ,很好的解决了这些问题, CompletableFuture 实现了 Future ,Completi onStage两个接口。 实现了Future 接口,意味着可以像以前一样通过阻塞或者轮询的方式获得结果。