1. 基础概念

进程

进程 = 程序 + 执行

进程是系统分配资源的基本单位(内存、CPU时间片)
进程是用来实现多进程并发执行的一个实体,实现对CPU的虚拟化,让每个进程感觉都拥有一个CPU,核心技术就是上下文切换和进程调度。
进程是正在运行的程序的实例。进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程是执行中的程序,除了可执行代码外还包含进程的活动信息和数据,比如用来存放函数变量、局部变量、返回值的用户栈,存放进程相关数据的数据段,内核中进程间切换的内核栈,动态分配的堆。

早期操作系统程序都是单个运行的,CPU利用率低下,为了提高CPU的利用率,加载多个程序到内存并发运行,在单核CPU中这种属于伪并发。其实在同一时间只运行一个程序

image

线程

通常在一个进程中可以包含若干个线程,它们可以利用进程所拥有的资源,在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位,由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效的提高系统内多个程序间并发执行的程度。

线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程,如Win32线程;由用户进程自行调度的用户线程,如Linux平台的POSIX Thread;或者由内核与用户进程,如Windows 7的线程,进行混合调度。

同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。

一个进程可以有很多线程,每条线程并行执行不同的任务。

在多核或多CPU,或支持Hyper-threading的CPU上使用多线程程序设计的好处是显而易见,即提高了程序的执行吞吐率。在单CPU单核的计算机上,使用多线程技术,也可以把进程中负责I/O处理、人机交互而常被阻塞的部分与密集计算的部分分开来执行,编写专门的workhorse线程执行密集计算,从而提高了程序的执行效率。

进程间的通信

同一台计算机的进程通信称为IPC(Inter-process-conmumunication),不同计算机之间的通信被称为RPC(Remote-process-conmumunication),需要通过网络,并遵守共同的协议,比如Dubbo就是一个RPC框架,而Http协议也经常用在RPC上,比如SpringCloud微服务

进程间有几种通信方式?

  • 管道:分为匿名管道(pipe)及命名管道(named pipe)
    • 匿名管道可用具有亲缘关系的父子进程间的通信
    • 命名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间进行通信
  • 信号(sign):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。
  • 消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限的进程可以按照一定规则向消息队列中添加新信息;对消息队列有读权限的进程可以从消息队列中读取消息
  • 共享内存(shared memory):可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依赖某种同步操作,入互斥锁和信号量等。
  • 信号量(semaphore):主要作为进程间及同一种进程的不同线程之间的同步和互斥手段。
  • 套接字(socket):这是一种更为一般的进程间通信机制。可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用Unix domain socket(比如同一机器中的MySQL中的控制台mysql shell 和MuSQL服务程序的连接),这种方式不需要经过网络协议栈,不需要打包拆包、计算校验、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。

CPU核心数和线程数的关系

目前,主流的CPU都是多核的,线程是CPU调度的最小单位。同一CPU核心只能运行一个线程,也就是说CPU内核和同时运行的线程数是1:1的关系,也就是说8核CPU同时可以执行8个线程的代码。但Intel引入超线程技术后,产生了逻辑处理器的概念,使得核心数与线程数形成1:2的关系。在我们前面的Windows任务管理器贴图就能看出来,内核数是6,而逻辑处理器数是12个

上下文切换(Context switch)

既然操作系统要在多个进程(线程)之间进行调度,而每个线程在使用CPU时总是要使用CPU中的资源,比如CPU寄存器和程序计数器。这就意味着,操作系统要保证线程在调度前后的正常执行,所以,操作系统中就有上下文切换的概念,它是指CPU从一个进程或线程到另一个进程或线程的切换。
上下文是CPU寄存器和程序计数器在任何时间点的内容。
寄存器是CPU内部的一小部分非常快的内存(相对于CPU内部的缓存和CPU外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:
1.暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方
2.从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它
3.返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。

  • 从数据角度来看,以程序员的角度来看,是方法调用过程中的各种局部变量与资源
  • 从线程角度来看,是方法的调用栈中存储的各类信息。
    引发上下文切换的原因一般包括:线程、进程切换、系统调用等等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、缓存中的来回拷贝。就CPU时间而言,一次上下文切换大概需要5000~20000个时钟周期,相对一个简单指令几个乃至十几个左右的执行时钟周期,可以看出这个成本的巨大。

并行和并发

举个例子,如果有条高速公路A上面有8条车道,那么最大的并行车辆就是8辆。一个CPU就相当于一个高速公路,核心数或线程数就相当于车道数量。
当我们谈论并发时,一定要加个单位时间,也就是说单位时间内并发量是多少?离开了单位时间讨论并发是没有意义的
综合来说:
并发Concurrent:指应用能够交替执行不同的任务,比如单CPU核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,来达到“同时执行效果”
并行Parallel:指应用能够同时执行不同的任务数量能力
两者的区别:并发是交替执行、并行是同时执行。

2. Java中的线程

Java程序天生就是多线程的

一个Java程序从Main方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但是实际上Java程序天生就是多线程程序,因为执行main()方法的是一个名称为main()的线程。
而一个Java程序的运行就算是没有用户自己开启的线程,实际上也有很多JVM自行启动的线程,一般来说有:

1
2
3
4
5
6
7
8
9
public class OnlyMain {
public static void main(String[] args) {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(threadMXBean.getAllThreadIds());
for (ThreadInfo threadInfo : threadInfos) {
System.out.printf("[%s] %s\n",threadInfo.getThreadId(), threadInfo.getThreadName());
}
}
}
1
2
3
4
5
6
7
8
[1] main //main线程,用户程序入口
[2] Reference Handler // 清除Reference的线程
[3] Finalizer //调用对象的finalize方法的线程
[4] Signal Dispatcher // 分发处理发送给JVM信号的线程
[5] Attach Listener // 内存dump,线程dump,类信息统计,获取系统属性等
[13] Common-Cleaner // 执行对象的清理操作,尤其是针对那些需要显式释放的非 Java 堆资源(native 内存、文件句柄、网络连接等)
[14] Monitor Ctrl-Break //监控Ctrl-Break中断信号的
[15] Notification Thread // MBean(管理 Bean)可以通过发送 “通知”(Notification)来告知外部其状态变化(如属性修改、事件触发等)。

不同JDK版本可能会有所差异,但是Java的特性决定了Java程序天生就是多线程的

线程的启动和中止

面试题:线程的启动方式有几种?

官方说法是两种,一种是创建派生Thread类用以执行,另一种是实现Runnable接口。
本质上无论是线程池还是其他方式,都是这两种方式实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

public class CallableTest {
private static class UseCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "hello world";
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 无返回值的线程启动方式
new Thread(
()-{
System.out.println("无返回值");
}
).start();
// 有返回值的线程启动方式
UseCallable useCallable = new UseCallable();
FutureTask<String> futureTask = new FutureTask<>(useCallable);
new Thread(futureTask).start();
System.out.println(futureTask.get());
}

}

线程中止

1.线程自然终止
线程的run方法执行完成,或者是抛出了未处理的异常导致线程提前结束

2.线程手动终止
暂停、恢复和停止操作对应在线程Thread的API就是suspend()、resume()和stop().但是这些API是过期的,不建议使用。以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占用着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程的不会保证线程的资源正常释放,通常是没有给与线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。正因为suspend()、resume()和stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法

线程中断

安全的中止则是其他线程通过调用某个线程A的interrupt()方法对其进行中断操作,中断好比其他线程对该线程打了个招呼,不代表线程A会立即停止自己的工作,同样的A线程完全可以不理会这种中断请求。线程通过检测自身的中断标志位是否被置为True来进行响应。

线程会通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()来进行判断当前线程是否被中断,不过Thread.interrupted()会同时将中断标识位改写为false。

如果一个线程处于阻塞状态(如线程调用了thread.sleep、thread.join、thread.wait等),则在线程在检查中断标识时如果发现中断标识为True,则会在这些阻塞方法调用处抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标识位清除,即立即设置为false。

不建议自定义一个取消标志位(例:public static boolean flag)来中止线程的运行。因为run方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。这种情况下,使用中断会更好。

处于死锁状态下的线程无法被中断

run和start区别

单独调用run方法,执行的是普通方法,并不会创建线程,start方法是创建线程后,在新建线程中执行run方法

线程的状态/生命周期

Java中的线程状态分为以下6种:

  • 1.初始(New):新创建了一个线程对象,还没有调用start()方法。
  • 2.运行(Runnable):Java线程中将就绪(ready)和运行中(running)两种状态笼统称为”运行”。
  • 3.阻塞(Blocked):阻塞态,表示线程阻塞于锁
  • 4.等待(Waiting):进入该状态的线程需要等待其他线程做出一些特定动作(通知或者中断)。
  • 5.超时等待(Timed_Waiting):该状态不同于Waiting,它可以在指定时间后自行返回。
  • 6.终止(Terminated):表示该线程已经执行完毕。

其他线程相关方法

yield()方法主动让出CPU资源,但让出的时间不可控且资源不会释放

线程的优先级

在Java线程中,通过priority这个整形成员变量来控制优先级,优先级的范围为1~10,可以在线程构建的时候通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多于优先级低的线程。

线程的调度

线程调度指系统为线程分配CPU使用权的过程,主要调度方式分为两种:

  • 协同式线程调度
  • 抢占式线程调度

使用协程式线程调度的多线程系统,线程执行的时间由线程本身来控制,线程把自己的工作执行完成后,主动通知系统切换到另一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完之后通知系统进行线程切换,所以就没有线程同步的问题,但是坏处也很明显,如果一个线程出了问题,程序就会阻塞。

使用抢占式线程调度的多线程系统,每个线程执行的时间以及是否切换都由系统决定。这种情况下,线程的执行时间不可控,所以不会有【一个线程导致整个进程阻塞】这种问题的出现。

Java线程调度使用了抢占式调度的方式,在Java中,Thread.yield()可以让出CPU执行时间,但是对于获取执行时间,线程本身是没有办法的。对于获取CPU执行时间,线程唯一可以使用的手段是设置线程优先级,Java程序设置了10个级别的程序优先级,当两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。

线程和协程

线程其实是操作系统层面的实体,Java中的线程是怎么和操作系统层面对应起来的呢?
其实任何语言实现线程主要有三种方式:使用内核线程实现(1:1),使用用户线程实现(1:N),使用用户线程+轻量级进程混合实现(N:M)

内核线程实现

使用内核线程的实现方式也被称为1:1实现。将内核线程(操作系统内核支持的线程),由于内核线程的支持,每个线程都成为了一个独立的调度单元,即使某个线程在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,由操作系统处理。
局限性:由于基于内核线程实现,所以各种线程的操作:创建、析构以及同步,都需要涉及到系统调用。系统调用的代价是十分昂贵的,需要在用户态和内核态进行来回切换。其次,每个语言层面的线程都需要有一个内核线程的支持,需要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。

用户线程实现

用户线程属于完全建立在用户空间的线程库上,内核不能感知到,用户线程的建立、同步、销毁和调度完成完全在用户态完成,不需要内核的帮助。程序如果实现得当,不需要进行用户态-内核态的切换,操作非常快速且低消耗,能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。

用户线程的优势在于不需要操作系统内核支援,缺陷同样也在于没有内核线程的支援上,所有的线程操作都需要用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配给到进程,那诸如“阻塞如何处理”,“多处理器系统如何将线程映射到其他处理器上”这类问题解决起来非常困难,甚至有些在语言层面是不可能实现的。因为使用用户线程实现的程序往往比较复杂,所以一般的应用程序都不倾向使用用户线程。Jav语言曾经使用过用户线程,最终又放弃了。但是近年来以高并发为卖点的Golang等语言又普遍支持了用户线程。

混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。这种混合实现下,即存在用户线程,又存在内核线程。

用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然可以保持低开销、快速,并且可以支持大规模的用户线程并发。
同样又可以使用内核提供的线程调度功能以及处理器映射,并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中,用户线程与轻量级进程的数量比是不定的。是N:M的关系

Java线程的实现

Java线程在早期的CLassic虚拟机上(1.2之前),是用户线程实现的,但是JDK1.3起,主流商用的Java虚拟机的线程模型普遍被替换为基于操作系统原生线程模型来实现(1:1线程模型)。
以Hotspot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以Hotspot自己是不回去干涉线程调度的,全权交给底层的操作系统进行处理。
Java的线程调度最终取决于操作系统,映射到操作系统的原生线程,所以操作系统的优先级有时候并不能和Java一一对应,所以Java优先级并不是特别靠谱。

协程

随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。互联网架构在处理一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,也就是我们常说的微服务,这种服务细分的架构在减少了单个服务复杂度、增加复用性的同时,也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一服务提供者都要同时处理更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。
Java目前并发编程机制与上述互联网的架构演进趋势产生了一些矛盾,1:1的内核线程模型依然是如今Java虚拟机线程实现的主流选择,但是这种线程模型的天生缺陷切换、调度成本高昂,系统能容纳的线程数量也很有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的资源浪费。
另外我们常见的JavaWeb服务器,比如Tomcat的线程池的容量通常在几十个到两百之间,当把数以百万计的请求往线程池里面灌的时候,系统即使能处理过来,其中的切换损耗也是相当可观的。
这样的话对于Java语言来说,用户线程的引入成为了解决上述问题一个非常可行的方案。其次,Go语言等支持用户线程的新型编程语言给Java带来了巨大的压力,也使得Java在面对是否引入用户线程这个问题面前避无可避。
用户线程又被称为协程,内核线程的切换开销来自于保护和恢复线程的成本,如果改用用户线程,这部分开销依然不能够省略掉,但是,把保护、恢复现场以及调度的工作从操作系统交到程序员手上,则可以通过很多手段来缩减这些开销。
由于最初多数用户线程是被设计为协同式调度,所以用户线程有了一个别名-协程,完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”

纤程-Java中的协程

在JVM的实现上,以Hotspot为例,协程的实现会有些额外的限制,Java调用栈跟本地调用栈是做在一起的。如果在协程中调用了本地方法,还能否正常切换协程而不影响整个线程?另外,如果协程中遇传统的线程同步措施会怎么样?譬如Kotlin提供的协程实现,一旦遭遇synchronize关键字,那挂起来的仍将是整个线程。
所以Java开发组就Java中协程的实现也做了很多努力,OpenJDK在2018年创建了Loom项目,这是Java的官方解决方案,并用了“纤程(Fiber)”这个名字。
Loom项目的意图是重新提供对用户线程的支持,但这些新功能不是为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在Java虚拟机并存,可以在程序中同时使用。新模型有意地保持了与目前线程模型相似的API设计,它们甚至可以拥有一个共同的基类,这样现有的代码就不需要为了使用纤程而进行过多改动,甚至不需要知道背后采用了哪个并发编程模型。
Loom团队在2018年公布的他们对于Jetty基于纤程改造后的测试结果,同样在5000QPS压力下,以容量为400的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比,前者的请求响应延迟在10000至20000毫秒之间,而后者的延迟普遍在200毫秒以下,目前Java中比较出名的协程库是Quasar,Quasar的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖Java虚拟机的线程保护虽然能够工作,但是影响性能。

Java使用纤程

  1. 引入依赖
    1
    2
    3
    4
    5
    <dependemcy>
    <groupId>co.paralleluniverse</groupId>
    <artifactId>quasar-core</artifactId>
    <version>0.7.9</version>
    </dependemcy>
  2. 使用纤程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class ThreadTest{
    public static void main(String[] args) throws Exception{
    CountDownLatch count = new CountDownLatch(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    // 线程工作池
    ExecutorService executorService = Executors.newCachedThreadPool(2000);

    // 协程工作池
    // ExecutorService executorService = Executors.newFixedThreadPool(2000);

    IntStream.range(0,10000).forEach(i->executorService.submit(()->{
    try{
    TimeUnit.SECONDS.sleep(1);
    } catch(InterruptedException ex){}
    count.countDown();
    })) ;
    count.await();
    stopWatch.stop();
    System.out.print(stopWatch.prettyPrint());
    executorService.shutdownNow();
    }
    }

守护线程

Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。但是我们一般用不上,比如垃圾回收线程就是守护线程。
Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。
这段代码打印的线程除了main线程外,其余线程均为守护线程。当JVM中线程均为守护线程时,JVM虚拟机就会退出。

1
2
3
4
5
6
7
8
9
public class OnlyMain {
public static void main(String[] args) {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(threadMXBean.getAllThreadIds());
for (ThreadInfo threadInfo : threadInfos) {
System.out.printf("[%s] %s\n",threadInfo.getThreadId(), threadInfo.getThreadName());
}
}
}

线程间的通信和协调、协作

很多的时候,孤零零的一个线程工作并没有什么太多的用处,更多的时候,我们是很多的线程一起工作,而且是这些线程间进行通信,或者配合着完成某项工作,这就离不开线程间的通信和协调、协作。

管道输入输出流

进程间有好几种通信机制,其中包括了管道,其实Java的线程里也有类似的管道机制,用于线程间的数据传输,而传输的媒介为内存。
Java中的管道输入输出流主要包括了如下4种具体实现:
PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前面两种面向字节,而后面两种面向字符。

Join方法

面试题

现在有T1、T2、T3三个线程,你怎么保证T2在T1执行完后执行,T3在T2执行完后执行?

join()

把指定线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程B种调用了线程A的Join方法,直到线程A执行完毕后,才会继续执行线程B剩下的代码。

synchronized 内置锁

线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,包括数据之间的共享,协同处理事情。这将会带来巨大的价值。
Java支持多个线程同时访问一个对象或者对象的成员变量,但是多个线程同时访问同一个变量,会导致不可预料的结果。关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,使多个线程访问同一个变量的结果正确,它又被称为内置锁机制。

对象锁和类锁

对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。

volatile 最轻量的通信同步机制

volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,新值对于其他线程来说是立即可见的。

1
2
3
public class VolatileCase{
private volatile static boolean ready;
}

不加volatile时,子线程无法感知主线程修改了ready的值,加了volatile后,子线程可以感知主线程修改了ready的值,但是volatile只能保证可见性,并不能保证多线程状态下操作的原子性。

等待/通知机制

线程之间相互配合,完成某项工作,比如:一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者是消费者,这种模式隔离了“做什么”和“怎么做”,简单的方法是让消费者线程不断地循环检查变量是否符合预期在while循环中设置不满足的条件,如果条件满足则退出循环,从而完成消费者的工作。但是却存在下面的问题:

  1. 难以确保及时性
  2. 难以降低开销。如果降低睡眠的时间,比如休眠1毫秒,这样消费者能更加迅速地发现条件变化,但是却可能消耗更多的处理器资源,造成了无端的浪费。

等待/通知机制则可以很好的避免,这种机制是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()方法或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify()/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方的交互工作。

notify():
通知一个在对象上等待的线程,使其从wait方法返回,而返回的前提是该线程获取到了对象的锁,没有获得锁的线程重新进入WAITING状态。

等待和通知的标准范式

等待方遵循如下原则:

  1. 获取对象的锁
  2. 如果条件不满足,则调用对象的wait方法,被通知后仍要检查条件
  3. 条件满足则执行对应的逻辑。

通知方遵循如下原则:

  1. 获取对象的锁
  2. 如果获取到对象锁,执行业务逻辑操作,然后调用对象的notify方法(通知方法)

方法和锁

调用yield()、sleep()、wait()、notify()等方法对锁有何影响?
yield()、sleep()被调用后,都不会释放当前线程所持有的锁。
调用wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新去竞争锁,锁竞争到后才会执行wait方法后面的代码。
调用notify()系列方法后,对锁无影响,线程只有在syn同步代码执行完后才会自然而然的释放锁,所以notify()系列方法一般都是syn同步代码的最后一行。

wait 和 notify

为什么wait和notify方法要在同步块中调用?

原因:
主要是因为Java API强制要求这样做,如果你不这么做,你的代码会抛出IIIegalMonitorStateException异常。其实真实原因是:
这个问题并不是说只在Java语言中会出现,而是会在所有的多线程环境下出现。

生产者-消费者简单示例
下面是一个简单的生产者-消费者例子,展示为什么wait()和notify()必须在同步块中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
public class ProducerConsumerExample {
private String message;
private boolean hasMessage = false;

// 生产者方法
public void produce(String msg) {
// ❌ 错误:不在同步块中调用wait/notify
// if (hasMessage) {
// wait(); // 会抛出IllegalMonitorStateException
// }

// ✅ 正确:在同步块中
synchronized(this) {
// 如果还有消息没被消费,就等待
while (hasMessage) {
try {
System.out.println("生产者:还有消息未消费,等待中...");
wait(); // 释放锁,等待消费者消费
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

// 生产消息
this.message = msg;
hasMessage = true;
System.out.println("生产者:生产了消息 - " + msg);

// 通知消费者
notify(); // 唤醒等待的消费者线程
}
}

// 消费者方法
public String consume() {
// ✅ 正确:在同步块中
synchronized(this) {
// 如果没有消息,就等待
while (!hasMessage) {
try {
System.out.println("消费者:没有消息,等待中...");
wait(); // 释放锁,等待生产者生产
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

// 消费消息
String consumedMsg = this.message;
hasMessage = false;
System.out.println("消费者:消费了消息 - " + consumedMsg);

// 通知生产者
notify(); // 唤醒等待的生产者线程

return consumedMsg;
}
}

// 测试代码
public static void main(String[] args) {
ProducerConsumerExample example = new ProducerConsumerExample();

// 生产者线程
Thread producer = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
example.produce("消息-" + i);
try {
Thread.sleep(1000); // 模拟生产时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

// 消费者线程
Thread consumer = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
example.consume();
try {
Thread.sleep(1500); // 模拟消费时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

producer.start();
consumer.start();
}
}

运行结果可能输出:

1
2
3
4
5
6
生产者:生产了消息 - 消息-1
消费者:消费了消息 - 消息-1
生产者:生产了消息 - 消息-2
消费者:消费了消息 - 消息-2
生产者:生产了消息 - 消息-3
消费者:消费了消息 - 消息-3

如果不使用同步块会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 错误示例 - 竞态条件
public void wrongProduce(String msg) {
if (hasMessage) { // 步骤1:检查条件
// 在这里,消费者线程可能消费了消息,hasMessage变为false
// 但生产者不知道这个变化,仍然会调用wait()
try {
wait(); // 步骤2:等待 - 但可能错过notify()
} catch (InterruptedException e) {
e.printStackTrace();
}
}

this.message = msg; // 步骤3:可能重复生产,覆盖未消费的消息
hasMessage = true;
notify();
}