深入理解并发,线程与等待唤醒机制

384 阅读28分钟

为什么需要并发编程

  • 加快响应用户的时间

我们在做程序开发的时候更应该如此,特别是我们做互联网项目,网页的响应 时间若提升 1s,如果流量大的话,就能增加不少转换量。做过高性能 web 前端调优 的都知道,要将静态资源地址用两三个子域名去加载,为什么?因为每多一个子域 名,浏览器在加载你的页面的时候就会多开几个线程去加载你的页面资源,提升网 站的响应速度。

  • 使代码模块化、异步化、简单化

例如我们实现电商系统,下订单和给用户发送短信、邮件就可以进行拆分, 将给用户发送短信、邮件这两个步骤独立为单独的模块,并交给其他线程去执行。 这样既增加了异步的操作,提升了系统性能,又使程序模块化,清晰化和简单化。

  • 充分利用CPU的资源

多核下如果还是使用单线程的技术做思路明显就 out 了,无法充分利用 CPU 的多核特点。如果设计一个多线程的程序的话,那它就可以同时在多个 CPU 的多 个核的多个线程上跑,可以充分地利用 CPU,减少 CPU 的空闲时间,发挥它的运算 能力,提高并发量。

理解进程和线程

进程是资源分配的基本单位。线程是资源调度的基本的单位。

进程

进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程 (例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如 网易云音乐、360 安全卫士等)。显然,程序是死的、静态的,进程是活的、动态 的。进程可以分为系统进程和用户进程。凡是用于完成操作系统的各种功能的进 程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由 你启动的进程。站在操作系统的角度,进程是程序运行资源分配(以内存为主)的最小单位。

线程

线程必须依赖于进程而存在,线程是进程中的一个实体,是 CPU 调度和分 派的基本单位,它是比进程更小的、能独立运行的基本单位。线程自己基本上不 拥有系统资源,,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈), 但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。一个进程可 以拥有多个线程,一个线程必须有一个父进程。线程,有时也被称为轻量级进程 (Lightweight Process,LWP),早期 Linux 的线程实现几乎就是复用的进程,后来 才独立出自己的 API。

进程之间的通信

  1. 管道
  • 匿名管道:

父进程fork出一个字子进程,则这个父、子进程之间的通信就是通过匿名管道进行的,因为他们是父子关系

  • 命名管道:

两个独立的进程进行通信,比如Nginx进程和Redis进程进行通信,这两个独立进程进行通信就可以通过命名管道进行通信。这只是一个例子,实际上这两个进程之间没有开发这种管道通信方式

  1. 信号
  2. 消息队列
  3. 共享内存
  4. 信号量
  5. 套接字

Mysql的控制台和Mysql的服务器其实就是通过套接字的通讯方式

CPU核心数与线程数之间的关系

主流CPU都是多核的,线程是CPU调度的基本单位,同一时刻,一个CPU核心只能运行一个线程 前面说过,目前主流 CPU 都是多核的,线程是 CPU 调度的最小单位。同一 时刻,一个 CPU 核心只能运行一个线程,也就是 CPU 内核和同时运行的线程数 是 1:1 的关系,也就是说 8 核 CPU 同时可以执行 8 个线程的代码。但 Intel 引入 超线程技术后,产生了逻辑处理器的概念,使核心数与线程数形成 1:2 的关系。 在我们前面的 Windows 任务管理器贴图就能看出来,内核数是 6 而逻辑处理器 数是 12。 在 Java 中提供了 Runtime.getRuntime().availableProcessors(),可以让我们获 取当前的 CPU 核心数,注意这个核心数指的是逻辑处理器数。 获得当前的 CPU 核心数在并发编程中很重要,并发编程下的性能优化往往 和 CPU 核心数密切相关。

上下文切换

在进行表给你发编程调优的时候就得用到上下文切换。CPU内存放的数据,存放的是当前线程所用的数据,cpu上下文切换,需要对线程或者进程所携带的数据进行一个切换或者保存。

站在代码层面可以理解为,上下文内容就是各种线程方法中,使用的局部变量,线程被调度出去了操作系统就要把线程中的局部变量值做一个保存。

上下文切换是一种计算密集型操作,涉及到cpu和内存的切换,代价相对来讲是比较大的。

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

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

并发和并行

当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少? 离开了单位时间其实是没有意义的。

综合来说: 并发 Concurrent:指应用能够交替执行不同的任务,比如单 CPU 核心下执行多 线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉 到的速度不断去切换这两个任务,已达到"同时执行效果",其实并不是的,只是计算 机的速度太快,我们无法察觉到而已. 并行 Parallel:指应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边 打电话,这两件事情可以同时执行 两者区别:一个是交替执行,存在资源的争夺,会引起上下文切换,一个是同一时刻执行,如下图所示

image.png

认识Java中的线程

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

  • Monitor Ctrl-Break //监控 Ctrl-Break 中断信号的
  • Attach Listener //内存 dump,线程 dump,类信息统计,获取系统属性等
  • Signal Dispatcher // 分发处理发送给 JVM 信号的线程
  • Finalizer // 调用对象 finalize 方法的线程
  • Reference Handler//清除 Reference 的线程
  • main //main 线程,用户程序入口

线程的启动和终止

  • 线程的启动---创建线程的方法
  1. 派生自thread类,实现run方法,在run方法中写业务的代码,

  2. 实现Runnable接口,重写run方法。

  3. 前面两个run方都没有返回值,实现Callable接口,里面有一个方法

V call() throws Exception;

返回值是一个泛型,在将来的某个时时刻,调用Future接口中的get()方法,得到返回值结果 jdk为我们提供了一个Future接口,下面有个get()方法,让我们去获得call()方法返回的结果 把callable包装成Runnable交给线程去执行,然后调用FutureTask的.get()方法获取返回结果

image.png

这种方式可以看作是与Runnable一致的,还有一种是

  1. 线程池技术

这种方式本质上是一种池化技术,关注资源的复用,减少线程的创建,减少IO,提高性能,和新起线程没有关系。

综上:新起线程的方式可以理解为有两种:派升Thread类,重写run方法。实现Runnable接口重写run()方法。实现Callble接口,实际上也跟第二种类似,线程池本质是一种池化技术。

尽管这些线程根据不同的 JDK 版本会有差异,但是依然证明了 Java 程序天 生就是多线程的。

  • 线程终止
    • suspend----线程挂起不释放任何的资源(文件、网卡、锁这些资源不释放)
    • stop----暴力的终止线程的方法(强行的终止对资源的操作,导致文件资源损坏)因此是废弃的
    • resume

优雅的终止线程: 在A线程中调用B线程的中断方法,在A线程中调用B.interrupt()只是跟B线程打了个招呼,说B要中断了,但是B线程可以不理睬。

public boolean isInterrupted(){ return isInterupted(false); }

image.png

目标线程调用interrupted方法并不会真正的中断线程,只是会将中断标志位设置为true,真正的中断需要在代码里面执行操作。

image.png 提供中断机制,在收到中断标志位以后,程序员可以对资源进行处理后再中断线程。

不建议自定义一个取消标志位来中止线程的运行。

因为 run 方法里有阻塞调用时,操作系统将线程挂起(比如调用thread.sleep方法),会无法很快检测到自定义取消标志,线程必须从阻塞调用返回后,才会检查这个取 消标志位。

这种情况下,使用jdk底层interrupt中断会更好,因为:

  • 一、一般的阻塞方法,如 sleep 等本身就支持中断的检查,会抛出中断异常,根据抛出的异常做想用的处理,这样处于sleep状态的线程就会立即处理这个中断异常,很快

  • 二、检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可 以避免声明取消标志位,减少资源的消耗。

image.png

start方法,连续执行两次会怎么样呢?

肯定是会抛出异常,抛出IlegalThreadException

Java中Thread类是对操作系统线程的概念的一个抽象,new thread();只是jvm在堆上面分配一块空间存储这个Thread对象。调用start()方法以后才是真正与操作系统的线程进行关联。run方法只是一个成员方法,执行thread的业务逻辑,这个方法可以单独反复执行。 结合线程的生命周期。

线程的生命周期

进入阻塞状态:被Synchronized关键字,内置锁阻塞的时候才会进入阻塞状态,显示加锁进入的阻塞状态一般来讲都是超时等待状态或等待状态。

就绪态:可以运行但是还没有被分配到CPU时间片

等待和超时等待:等待因为某个条件不满足,超时等待也是因为某个条件不满足,但是不是无限制的等,等到一定的时间后即使条件不满足,也重新进入运行态。 掌握线程的状态可以可以让我们在进行Java程序调优的过程中提供很大的帮助,比如在GC过程中线程STW线程处于一种假死状态

ConcurrentHashMap中Thread.yeild方法,让进行初始化的线程获得cpu时间片,更快的完成初始化的工作。ConcurrentHashMap扩容的过程从无到有,ConcurrentHashMap初始化的过程

image.png

线程的优先级

在 Java 线程中,通过一个整型成员变量 priority 来控制优先级,优先级的范 围从 1~10,在线程构建的时候可以通过 setPriority(int)方法来修改优先级,默认 优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。 设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较 高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的 优先级,确保处理器不会被独占。在不同的 JVM 以及操作系统上,线程规划会 存在差异,有些操作系统甚至会忽略对线程优先级的设定。

线程的调度

  • 协同式的线程调度

强迫性几乎没有,线程什么时候让出cpu由线程本身来决定,可以避免线程同步的问题,但是如果程序出现了问题则程序就会一致阻塞。 使用协同 式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系 统进行线程切换,所以没有线程同步的问题,但是坏处也很明显,如果一个线程 出了问题,则程序就会一直阻塞。

  • 抢占式的线程调度

Java中断的线程属于抢占式的调度

  • 使用抢占式线程调度的多线程系统,每个线程执行的时间以及是否切换都由 系统决定。在这种情况下,线程的执行时间不可控,所以不会有「一个线程导致 整个进程阻塞」的问题出现。
  • Java 线程调度就是抢占式调度,为什么?后面会分析。 在 Java 中,Thread.yield()可以让出 CPU 执行时间,但是对于获取执行时间, 线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是 设置线程优先级,Java 设置了 10 个级别的程序优先级,当两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

线程和协程

为什么 Java 线程调度是抢占式调度?这需要我们了解 Java 中线程的实现模式。 我们已经知道线程其实是操作系统层面的实体,Java 中的线程怎么和操作系统层面对应起来呢? 任何语言实现线程主要有三种方式:

  1. 使用内核线程实现(1:1 实现)
  2. 使用用户线程实现(1:N 实现)
  3. 使用用户线程加轻量级进程混合实现(N:M 实现)。

内核线程的实现

内核线程(Kernel-Level Thread, KLT) 就是直接由操作系统内核(Kernel, 下称内核) 支持的线程,使用内核线程实现的方式也被称为1:1.这种线程由内核来完成线程切换,内核通过操纵调度器,对线程进行调度,并负责将线程的任务映射到各个处理器上-----任务映射。

由于内核的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作。相关的调度工作也不 需要额外考虑,已经由操作系统处理了。

局限性:

首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户 态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个语言层面的 线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的 栈空间),因此一个系统支持的线程数量是有限的。

用户线程实现

独立于系统内核,不涉及用户态和内核态的切换,如果程序实现得当, 这种线程不需要 切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的 线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。

用户线程的优劣势

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

总结:对于用户线程,线程的各个生命周期维护起来比较困难,映射多核处理器不知道怎么进行,存在很多尚未解决的问题。

混合实现

用户线程和内核线程一起使用,线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将 内核线程与用户线程一起使用的实现方式, 被称为 N:M 实现。 在这种混合实 现下, 既存在用户线程, 也存在内核线程。 用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析 构等操作依然廉价, 并且可以支持大规模的用户线程并发同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系 统调用要通过内核线程来完成在这种混合模式中, 用户线程与轻量级进程的 数量比是不定的,是 N:M 的关系。

Java线程的实现

J Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的, 但从 JDK 1.3 起, 主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系 统原生线程模型来实现,即采用 1: 1 的线程模型。

以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线 程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉 线程调度的,全权交给底下的操作系统去处理。 所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线 程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决 于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以 Java 优先级并不是特别靠谱。

协程

适合于高并发,高IO,在规模上做了一个增强,但是在速度处理上并没有做增强,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的 延迟)。

newCacheThreadPool----线程池中的线程是无限量的,来一个任务就启动一个线程,但是在实际生产过程中是不允许这样创建线程池的,因为需要限定个数(线程的个数,线程池中任务队列的个数)

守护线程

支持非守护线程工作的线程,但只有守护线程工作的时候,会在某一时刻停止工作 内存的清理,垃圾回收,接收外部信号这些操作可以用守护线程来做。守护线程守护的是Java的资源的回收与调度。

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

线程中的通信,协调和协作

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

管道输入输出流

我们已经知道,进程间有好几种通信机制,其中包括了管道,其实 Java 的 线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存。

设想这么一个应用场景:通过 Java 应用生成文件,然后需要将文件上传到 云端,比如:

1、页面点击导出后,后台触发导出任务,然后将 mysql 中的数据根据导出 条件查询出来,生成 Excel 文件,然后将文件上传到 oss,最后发布一个下载文 件的链接。

2、和银行以及金融机构对接时,从本地某个数据源查询数据后,上报 xml 格 式的数据,给到指定的 ftp、或是 oss 的某个目录下也是类似的。 我们一般的做法是,先将文件写入到本地磁盘,然后从文件磁盘读出来上传 到云盘,但是通过 Java 中的管道输入输出流一步到位,则可以避免写入磁盘这 一步。 Java 中的管道输入/输出流主要包括了如下 4 种具体实现: PipedOutputStream、PipedInputStream、PipedReader 和 PipedWriter,前两种面 向字节,而后两种面向字符。

线程上下文切换是操作系统调度线程的时候产生的上下文切换,系统调用是当你想使用操作系统的API的时候产生系统调用。线程的上下文切换是一定有的,在系统中能够做的就是尽量的减少上下文切换。 协程也是跟线程一样的。 redis在获取系统的时间的时候,没有调用系统的API获得系统的时间,而是其内部进行了计时获得系统的时间,这也是redis快的原因。

Synchronized关键字

对象锁和类锁的区别

对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的(作用在class对象上用的比较少)

  1. 用在同步块上:
public  void incCountBlock(){
    synchronized(this){
    count ++;
    }
}
  1. sync直接放在方法上面----也叫对象锁,因为他是一个成员方法,成员方法和某个对象实例挂钩
public sychronized void incCountMethod(){
count++;
}
  1. 用在同步代码块上,但是锁的是一个单独的对象实例
public void incCountObj(){
synchronized(obj){
count++;
}
}

上面的可以称之为对象锁,类锁即作用于类的静态方法上

public synchronized  static void incCountMethod(){
count ++;
}


对于一个类来讲,它的对象实例有很多个,正确的使用synchronized必须保证加锁的对象是同一个

volatile---最轻量的通信/同步机制

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

image.png

image.png

不加 volatile 时,子线程无法感知主线程修改了 ready 的值,从而不会退出循环, 而加了 volatile 后,子线程可以感知主线程修改了 ready 的值,迅速退出循环。 但是 volatile 不能保证数据在多个线程下同时写时的线程安全,volatile 最适用的 场景:一个线程写,多个线程读

但是volatile无法保证线程的安全性:

image.png

count++还是会出现多线程并发写的不一致问题

synchronized关键字包含了volatole关键字的特性,它保证了可见性,原子性,thread.sleep()内部会对缓存进行刷新

等待通知机制

与对象的wait()方法、notify()方法、notifyAll()方法有关。

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

概括为,一个线程在做一件事情,在这个过程中比如做了一半发现某个条件不满足,而这个条件需要等另外一个线程将工作做完后释放

复习一个知识点,Object类中的方法:

toString()方法/finalize()方法/clone()方法/hashCode()方法/getClass()方法/registryNatives()方法。。。。。后面几种方法则是本节中常见的方法

image.png

线程的生命周期

image.png

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

notifyAll(): 通知所有等待在该对象上的线程

wait() 调用该方法的线程进入 WAITING 状态,只有等待另外线程的通知或被中断 才会返回.需要注意,调用 wait()方法后,会释放对象的锁

wait(long) 超时等待一段时间,这里的参数时间是毫秒,也就是等待长达 n 毫秒,如果没有 通知就超时返回 wait (long,int) 对于超时时间更细粒度的控制,可以达到纳

等待通知的标准范式

不仅仅是Java中的其他的语言中也遵循这种范式,除了wait()/notify(),后面的显示锁LockSurport中的await()/signal()也是遵循的

wait()和notify()一定要在锁的范围内引用,否则就会出错

等待方

synchronized(this){
    while(条件不满足){
    对象.wait();//调用wait()方法的时候等待方的锁会释放,通知方自然而然的就可以获得锁
    }
    //执行后面的额逻辑
}

通知方

synchronized(this){
//业务逻辑,改变某个条件
this.notify();//通知方法
}

image.png

wait()方法释放锁的原因:调用wait()方法的时候等待方的锁会释放,通知方自然而然的就可以获得锁,实现等待通知机制

要指定唤醒某一个线程,需要用到显示锁,notify()和notifyAll()不能指定唤醒某一个线程

方法和锁

调用yield(),和sleep()方法、wait()、notify()方法对锁的影响

  • yield()方法,和sleep()方法属于线程的方法,被调用后都不会释放当前线程所持有的锁
  • wait()和notify()方法属于对象的方法。
    • 调用wait()方法后会释放当前线程所拥有的锁,否则通知方拿不到锁,而且当前被notify唤醒之后会重新去竞争锁,锁竞争到后才会再次去执行wait()方法后面的逻辑。
    • 调用notify()系列方法之后,对锁无影响,线程只有在syn同步代码块执行完之后才会自然而然的释放锁,因此notify()系列方法一般都是syn同步代码的最后一行

为什么wait()和notify()要在同步代码块中?

防止产生 lost wake up问题

原因 主要是因为 Java API 强制要求这样做,如果你不这么做,你的代码会抛出 IllegalMonitorStateException 异常。

其实真实原因是: 这个问题并不是说只在 Java 语言中会出现,而是会在所有的多线程环境下 出现。

假如我们有两个线程,一个消费者线程,一个生产者线程。生产者线程的任务可以简化成将 count 加一,而后唤醒消费者;消费者则是将 count 减一,而后 在减到 0 的时候陷入睡眠:

  • 生产者伪代码:
count ++;
notify();
  • 消费者伪代码:
while(count<=0){
wait();
count--;
}
count --;

这里会出现什么问题呢? 生产者是两个步骤:

  1. count + 1;
  2. notify();

消费者也是两个步骤:

  1. 检查count的值
  2. 睡眠或者减1

万一这些步骤混杂在一起呢?比如说,初始的时候 count 等于 0,这个时候 消费者检查 count 的值,发现 count 小于等于 0 的条件成立;就在这个时候,发 生了上下文切换,生产者进来了,噼噼啪啪一顿操作,把两个步骤都执行完了, 也就是发出了通知,准备唤醒一个线程。这个时候消费者刚决定睡觉,还没睡呢, 所以这个通知就会被丢掉。紧接着,消费者就睡过去了…导致生产者发出的通知一直未被处理。出现lost wake up现象。

image.png

那么怎么解决这个问题呢?

现在我们应该就能够看到,问题的根源在于,消费者在检查 count 到调用 wait()之间,count 就可能被改掉了。 这就是一种很常见的竞态条件。 很自然的想法是,让消费者和生产者竞争一把锁,竞争到了的,才能够修改 count 的值。

为什么你应该在循环中检查等待条件?

处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等 待条件,程序就会在没有满足结束条件的情况下退出。因此,当一个等待线程醒 来时,不能认为它原来的等待状态仍然是有效的,在 notify()方法调用之后和等 待线程醒来之前这段时间它可能会改变。这就是在循环中使用 wait()方法效果更 好的原因。

CompleteableFuture

前面已经讲了future只能拿到一个线程运行的结果,但是在实际的工作中,一个任务可能是多个任务的组合,有可能这个任务的输出会是下一个任务的输入,或者多个任务并行的去做,把结果组合起来。这个时候就用到CompleteableFuture,提供了一个任务编排的能力,实现了Future和CompletionStage接口。可以将任务以不同的规则,顺序组合起来。

Java 的 1.5 版本引入了 Future,可以把它简单的理解为运算结果的占位符, 它提供了两个方法来获取运算结果。

  • get():调用该方法线程将会无限期等待运算结果。

  • get(long timeout, TimeUnit unit):调用该方法线程将仅在指定时间 timeout 内等待结果,如果等待超时就会抛出 TimeoutException 异常。

Future 可以使用 Runnable 或 Callable 实例来完成提交的任务,它存在如 下几个问题:

  1. 阻塞 调用 get() 方法会一直阻塞,直到等待直到计算完成,它没有提供任 何方法可以在完成时通知,同时也不具有附加回调函数的功能。

  2. 链式调用和结果聚合处理 在很多时候我们想链接多个 Future 来完成耗时 较长的计算,此时需要合并结果并将结果发送到另一个任务中,该接口很难完成 这种处理。 异常处理 Future 没有提供任何异常处理的方式。

JDK1.8 才新加入的一个实现类 CompletableFuture,很好的解决了这些问题, CompletableFuture 实现了 Future, CompletionStage两个接口。实现了 Future 接口,意味着可以像以前一样通过阻塞或者轮询的方式获得结果。

创建

除了直接new出一个Completeable对象外,还可以通过工厂方法创建Completeable实例

  • 工厂方法:

image.png

Asynsc 表示异步,而 supplyAsync 与 runAsync 不同在于,supplyAsync 异步返 回一个结果,runAsync 是 void。第二个函数第二个参数表示是用我们自己创建的 线程池,否则采用默认的 ForkJoinPool.commonPool()作为它的线程池。

  • 获得结果的方法

public T get() 、public T get(long timeout, TimeUnit unit) 、public T getNow(T valueIfAbsent)

public T join() getNow 有点特殊,如果结果已经计算完则返回结果或者抛出异常,否则返回给定的 valueIfAbsent 值。

join 返回计算的结果或者抛出一个 unchecked 异常(CompletionException),它 和 get 对抛出的异常的处理有些细微的区别。

  • 辅助方法
public static CompletableFuture allOf(CompletableFuture... cfs) 
public static CompletableFuture anyOf(CompletableFuture... cfs) 
  • allOf 方法是当所有的 CompletableFuture 都执行完后执行计算。
  • anyOf 方法是当任意一个 CompletableFuture 执行完后就会执行计算,计算的 结果相同。

CompletionStage 是一个接口,从命名上看得知是一个完成的阶段,它代表 了一个特定的计算的阶段,可以同步或者异步的被完成。你可以把它看成一个计 算流水线上的一个单元,并最终会产生一个最终结果,这意味着几个 CompletionStage 可以串联起来,一个完成的阶段可以触发下一阶段的执行,接 着触发下一次,再接着触发下一次,……….。

总结 CompletableFuture 几个关键点:

1、计算可以由 Future ,Consumer 或者 Runnable 接口中的 apply,accept 或者 run 等方法表示。

2、计算的执行主要有以下 :

a. 默认执行

b. 使用默认的 CompletionStage 的异步执行提供者异步执行。这些方法名使 用 someActionAsync 这种格式表示。

c. 使用 Executor 提供者异步执行。这些方法同样也是 someActionAsync 这 种格式,但是会增加一个 Executor 参数。

image.png

  • volatile不是只针对某个变量,保证其可见性,它底层是通过内存屏障,会同时刷新之前和当前的缓存值。
  • notifyAll()唤醒多个线程之后,多个线程也存在锁竞争的情况。