[TOC]
JUC高并发编程
1、Java并发知识体系详解
1、知识体系
2、java高并发
2、Java 并发 - 理论基础
从理论的角度引入并发安全问题以及JMM应对并发问题的原理。
1、BAT大厂的面试问题
- 多线程的出现是要解决什么问题的?
- 线程不安全是指什么? 举例说明
- 并发出现线程不安全的本质什么?
- 并发的三要素:可见性,原子性和有序性。
- Java是怎么解决并发问题的?
- 3个关键字,JMM和8个Happens-Before
- 线程安全是不是非真即假?
- 不是
- 线程安全有哪些实现思路?
- 如何理解并发和并行的区别?
2、并发与并行
1、串行模式
串行表示所有任务都按先后顺序进行。
串行意味着必须先装完一车柴才能运送这车柴,只有运送到了,才能卸下这车柴,并且只有完成了这整个三个步骤,才能进行下一个步骤。
串行是一次只能取得一个任务,并执行这个任务。
2、并行模式
并行意味着可以同时取得多个任务,并同时去执行所取得的这些任务。
并行模式相当于将长长的一条队列,划分成了多条短队列,所以并行缩短了任务队列的长度。
并行的效率从代码层次上强依赖于多进程/多线程代码,从硬件角度上则依赖于多核 CPU。
多核 cpu下,每个核(core)
都可以调度运行线程,这时候线程可以是并行的。
3、并发
并发(concurrent)指的是多个程序可以同时运行的现象,更细化的是多进程可以同时运行或者多指令可以同时运行。但这不是重点,在描述并发的时候也不会去扣这种字眼是否精确,==并发的重点在于它是一种现象==, ==并发描述的是多进程同时运行的现象==。但实际上,对于单核心 CPU 来说,同一时刻只能运行一个线程,线程实际还是串行执行
的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行
的 。所以,这里的”同时运行”表示的不是真的同一时刻有多个线程运行的现象,这是并行的概念,而是提供一种功能让用户看来多个程序同时运行起来了,但实际上这些程序中的进程不是一直霸占 CPU 的,而是执行一会停一会。 总结为一句话就是: ==微观串行,宏观并行== ,一般会将这种 线程轮流使用 CPU 的做法称为并发( concurrent
)
要解决大并发问题,通常是将大任务分解成多个小任务,由于操作系统对进程的调度是随机的,所以切分成多个小任务后,可能会从任一小任务处执行。这可能会出现一些现象:
- 可能出现一个小任务执行了多次,还没开始下个任务的情况。这时一般会采用队列或类似的数据结构来存放各个小任务的成果;
- 可能出现还没准备好第一步就执行第二步的可能。这时,一般采用多路复用或异步的方式,比如只有准备好产生了事件通知才执行某个任务;
- 可以多进程/多线程的方式并行执行这些小任务。也可以单进程/单线程执行这些小任务,这时很可能要配合多路复用才能达到较高的效率。
4、并发与并行的区别
并发是指一个处理器同时处理多个任务。并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。就好像两个人各拿一把铁锨在挖坑,一小时后,每人一个大坑。所以无论从微观还是从宏观来看,二者都是一起执行的。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。这就好像两个人用同一把铁锨,轮流挖坑,一小时后,两个人各挖一个小一点的坑,要想挖两个大一点得坑,一定会用两个小时。
并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)。(下图来自Erlang 之父 Joe Armstrong)
当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。这种方式我们称之为并发(Concurrent)。
当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。
引用 Rob Pike(golang 语言的创造者) 的一段描述:
- 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
- 并行(parallel)是同一时间动手做(doing)多件事情的能力
- 例子:
- 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
- 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
- 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
并发:同一时刻多个线程在访问同一个资源,多个线程对一个点
- 例子:春运抢票 电商秒杀…
并行:多项工作一起执行,之后再汇总
- 例子:泡方便面,电水壶烧水,一边撕调料倒入桶中
5、管程
管程(monitor)是保证了同一时刻只有一个进程在管程内活动,即**管程内定义的操作在同一时刻只被一个进程调用(由编译器实现)**。但是这样并不能保证进程以设计的顺序执行。
JVM 中同步是基于进入和退出管程(monitor)对象实现的,每个对象都会有一个管程(monitor)对象,管程(monitor)会随着 java 对象一同创建和销毁。
执行线程首先要持有管程对象,然后才能执行方法,当方法完成之后会释放管程,方法在执行时候会持有管程,其他线程无法再获取同一个管程。
管程,在java中叫锁,在操作系统中叫监视器,是一种同步机制。
6、用户线程和守护线程
用户线程:平时用到的普通线程、自定义线程
守护线程:运行在后台,是一种特殊的线程。比如垃圾回收线程。
当主线程结束后,用户线程还在运行,JVM 存活;
如果没有用户线程,都是守护线程,JVM 结束 。
可以通过调用
Thread.currentThread().isDaemon()
查看当前线程是不是守护线程可以通过调用
当前线程.setDaemon(true)
将当前线程设置为守护线程- 这个方法应该在
当前线程.start()
执行之前设置
- 这个方法应该在
注意:
- 垃圾回收器线程就是一种守护线程
- Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求
3、为什么需要多线程
众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;
- 导致
可见性
问题
- 导致
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 导致
原子性
问题
- 导致
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
- 导致
有序性
问题
- 导致
4、多线程的应用
1、应用之异步调用
1、异步与同步
以调用方角度来讲,如果
- 需要等待结果返回,才能继续运行就是同步
- 不需要等待结果返回,就能继续运行就是异步
2、设计
多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…
3、结论
- 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
- tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
- ui 程序中,开线程进行其他操作,避免阻塞 ui 线程
2、应用之提高效率
充分利用多核 cpu 的优势,提高运行效率。想象下面的场景,执行 3 个计算,最后将计算结果汇总:
1 | 计算 1 花费 10 ms |
- 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms
- 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3,那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms
注意:需要在多核 cpu 才能提高效率,单核仍然时是轮流执行
结论
- 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
- 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
- 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
- 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
- IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化
5、线程不安全示例
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。
1 | public class ThreadUnsafeExample { |
1 | public static void main(String[] args) throws InterruptedException { |
结果:
1 | 997 // 结果总是小于1000 |
6、并发出现问题的根源:并发三要素
上述代码输出的值为什么总是小于1000?并发出现问题的根源是什么?
- 并发的三要素
- 可见性
- 原子性
- 有序性
1、可见性:CPU缓存引起
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
例子:
代码:
1 | //线程1执行的代码 |
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
2、原子性:分时复用引起
原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
经典的银行取钱问题:比如从账户A和账户B同时对一个银行账号取钱1000元,那么必然包括2个操作:
- 从银行账号读取余额,取钱1000元
- 取完钱之后,银行将账号的余额进行更新(-1000)
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A取钱1000元之后,操作突然中止。然后又从B取出了1000元,取出1000元之后,再执行银行余额更新减去1000元的操作。这样就会导致账号减去了1000元,但是账户A与账户B一共取到了2000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
3、有序性:重排序引起
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
1 | int i = 0; |
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?
- 不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
1、CPU的指令重排序
1、名词
Clock Cycle Time
:主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的Cycle Time 是 1s- 例如,运行一条加法指令一般需要一个时钟周期时间
CPI
:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数IPC
:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数CPU 执行时间
:程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示- 程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
2、指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?
可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段
术语参考:
- instruction fetch (IF)
- instruction decode (ID)
- execute (EX)
- memory access (MEM)
- register write back (WB)
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。
指令重排的前提是,重排指令不能影响结果,例如:
1 | // 可以重排的例子 |
3、支持流水线的处理器
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
4、SuperScalar 处理器
大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC > 1
2、重排序(Java 内存模型JMM)
1、重排序的分类
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
2、处理器重排序与内存屏障指令
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:
1 | // Processor A |
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。
下面是常见处理器允许的重排序类型的列表:
Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 |
---|---|---|---|---|
sparc-TSO | N | N | N | Y |
x86 | N | N | N | Y |
ia64 | Y | Y | Y | Y |
PowerPC | Y | Y | Y | Y |
上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。
从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。
- ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
- ※注 2:上表中的 x86 包括 x64 及 AMD64。
- ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
- ※注 4:数据依赖性后文会专门说明。
为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。 |
StoreLoad Barriers
会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers
是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
3、数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写
操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意:
- 这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,
- 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
4、as-if-serial 语义
as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
1 | double pi = 3.14; //A |
上面三个操作的数据依赖关系如下图所示:
如上图所示:
- A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。
- 因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。
- 但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。
下图是该程序的两种执行顺序:
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
5、程序顺序规则
根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。
这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。
6、重排序对多线程的影响
重排序是否会改变多线程程序的执行结果。请看下面的示例代码:
1 | class ReorderExample { |
flag 变量是个标记,用来标识变量 a 是否已被写入。
这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?
答案是:不一定能看到。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:(注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。)
如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:
在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。
从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
结论:
- 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);
- 但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
3、顺序一致性(Java 内存模型JMM)
1、数据竞争与顺序一致性保证
当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:
- 在一个线程中写一个变量,
- 在另一个线程读同一个变量,
- 而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
- 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)
- 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
- 这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
2、顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。
在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。
为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。
- 假设有两个线程 A 和 B 并发执行。
- 其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。
- B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
- 假设这两个线程使用监视器来正确同步:
- A 线程的三个操作执行后释放监视器,
- 随后 B 线程获取同一个监视器。
那么程序在顺序一致性模型中的执行效果将如下图所示:
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如:在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。
3、同步程序的顺序一致性效果
下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。
代码:
1 | class SynchronizedExample { |
上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。
下面是该程序在两个内存模型中的执行时序对比图:
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
4、未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。
为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。
和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
- JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。
第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:
如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。
在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。
当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:
如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A”写了一半”的无效值。
4、总结
1、处理器内存模型
顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:
- 放松了程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
- 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
- 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。
注意:
- 这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。
下面的表格展示了常见处理器内存模型的细节特征:
内存模型名称 | 对应的处理器 | Store-Load 重排序 | Store-Store 重排序 | Load-Load 和 Load-Store 重排序 | 可以更早读取到其它处理器的写 | 可以更早读取到当前处理器的写 |
---|---|---|---|---|---|---|
TSO | sparc-TSO X64 | Y | Y | |||
PSO | sparc-PSO | Y | Y | Y | ||
RMO | ia64 | Y | Y | Y | Y | |
PowerPC | PowerPC | Y | Y | Y | Y | Y |
在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在前面说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。
上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:
如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。
2、JMM、处理器内存模型与顺序一致性内存模型之间的关系
- JMM 是一个语言级的内存模型
- 处理器内存模型是硬件级的内存模型
- 顺序一致性内存模型是一个理论参考模型。
下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图:
从上图我们可以看出:
- 常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱
- 处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。
- 同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
3、JMM 的设计
从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:
- 一方面要**为程序员提供足够强的
内存可见性保证
**; - 另一方面,**对编译器和处理器的
限制
要尽可能的放松
**。
下面让我们看看 JSR-133 是如何实现这一目标的。为了具体说明,请看前面提到过的计算圆面积的示例代码:
1 | double pi = 3.14; //A |
上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
由于 A happens- before B,happens- before 的定义会要求:
- A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。
- 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。
- 也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM 对这两种不同性质的重排序,采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。
下面是 JMM 的设计示意图:
从上图可以看出两点:
- JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens - before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
- JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除(
锁消除
)。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
4、JMM 的内存可见性保证
Java 程序的内存可见性保证按程序类型可以分为下列三类:
- 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:
只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。
5、JSR-133 对旧内存模型的修补
JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:
- 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
- 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。
7、JAVA是怎么解决并发问题的:JMM(Java内存模型)
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
JMM 体现在以下几个方面:
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
理解的第一个维度:核心知识点
- JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
volatile
、synchronized
和final
三个关键字Happens-Before
规则
- JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
理解的第二个维度:可见性,有序性,原子性
原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
请分析以下哪些操作是原子性操作:
x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
1
2
3
4
2. ```java
y = x;
//语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
1
2
3
4. ```java
x = x + 1; //语句4: 同语句3
上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过
synchronized
和Lock
来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
- Java提供了
volatile
关键字来保证可见性。 - 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
- 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
- 另外,通过
synchronized
和Lock
也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
- Java提供了
有序性
- 在Java里面,可以通过
volatile
关键字来保证一定的”有序性”(具体原理在下面讲述)。另外可以通过synchronized
和Lock
来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。
- 在Java里面,可以通过
1、关键字:volatile、synchronized 和 final
1、volatile
2、synchronized
3、final
2、Happens-Before 规则
上面提到了可以用 volatile
和 synchronized
来保证有序性。除此之外,JVM 还规定了先行发生(Happens-Before)原则,让一个操作无需控制就能先于另一个操作完成。
从 JDK5 开始,java 使用新的 JSR-133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 提出了 happens-before
的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见:(变量都是指成员变量或静态成员变量)
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x; static Object m = new Object(); new Thread(()->{ synchronized(m) { x = 10; } },"t1").start(); new Thread(()->{ synchronized(m) { System.out.println(x); } },"t2").start();
1
2
3
4
5
6
7
8
9
10
11
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
- ```java
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x; x = 10; new Thread(()->{ System.out.println(x); },"t2").start();
1
2
3
4
5
6
7
8
9
10
11
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
- ```java
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
static int x; public static void main(String[] args) { Thread t2 = new Thread(()->{ while(true) { if(Thread.currentThread().isInterrupted()) { System.out.println(x); break; } } },"t2"); t2.start(); new Thread(()->{ sleep(1); x = 10; t2.interrupt(); },"t1").start(); while(!t2.isInterrupted()) { Thread.yield(); } System.out.println(x); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
- 具有传递性,如果 `x hb-> y` 并且 `y hb-> z` 那么有 `x hb-> z` ,配合 `volatile` 的防指令重排,有下面的例子
- ```java
volatile static int x;
static int y;
new Thread(()->{
y = 10;
x = 20;
},"t1").start();
new Thread(()->{
// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
System.out.println(x);
},"t2").start();
注意:
- 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!
- happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
happens-before 与 JMM 的关系如下图所示:
如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。
1、单一线程原则(Single Thread rule)
在一个线程内,在程序前面的操作先行发生于后面的操作。
2、管程锁定规则(Monitor Lock Rule)
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
3、volatile 变量规则(Volatile Variable Rule)
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
4、线程启动规则(Thread Start Rule)
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
5、线程加入规则(Thread Join Rule)
Thread 对象的结束先行发生于 join() 方法返回。
6、线程中断规则(Thread Interruption Rule)
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
7、对象终结规则(Finalizer Rule)
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
8、传递性(Transitivity)
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
8、线程安全:不是一个非真即假的命题
一个类在可以被多个线程安全调用时就是线程安全的。
线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类:
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容
- 线程对立。
1、不可变(Immutable)
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。
多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变的类型:
final 关键字修饰的基本数据类型
String
枚举类型
Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。
- 但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
日期格式转换类
DateTimeFormatter
平常用的的日期格式转换类
SimpleDateFormat
在多线程下是不安全的,有很大几率出现java.lang.NumberFormatException
或者出现不正确的日期
解析结果在 Java 8 后通过了
DateTimeFormatter
解决这个问题,在文档中你可以发现对DateTimeFormatter
的描述:1
2
This class is immutable and thread-safe.
对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
1 | public class ImmutableExample { |
1 | public V put(K key, V value) { |
1 | Exception in thread "main" java.lang.UnsupportedOperationException |
1、不可变的设计要素
以String
为例,说明一下不可变设计的要素:
1 | public final class String |
String
整一个类被final
修饰了,保证了String
没有任何子类,所以也不用担心子类去修改重写它的方法而导致破坏不可变性- hash虽然没有加上什么final修饰,但是hash是私有的并且String类没有提供hash的set方法,外部没有办法修改hash的值,所以也算保证了hash的不可变性
- char[]数组使用了
final
修饰,在构造方法当中赋值,保证了value
值的不可变性; - 但是这样只是保证了char[]数组这个引用变量的不可变性,怎么保证char[]数组里面的值具有不可变性呢?
- 主要是依赖了String的构造方法。
String
的构造方法:
1 | // 无参 |
2、保护性拷贝(defensive copy)
使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那不就破坏了String的不可变性了吗?那么下面就看一看这些方法是如何实现的,就以 substring 为例:
1 | public String substring(int beginIndex) { |
发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出了修改:
1 | public String(char value[], int offset, int count) { |
结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】
2、绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
3、相对线程安全
相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
对于下面的代码,如果删除元素的线程删除了 Vector 的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。
1 | public class VectorUnsafeExample { |
1 | Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3 |
如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。
1 | executorService.execute(() -> { |
4、线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
5、线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
9、线程安全的实现方法
1、互斥同步
synchronized
和 ReentrantLock
。
2、非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
1、CAS(JUC中CAS, Unsafe和原子类相关)
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:==先进行操作==**,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)**。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要==操作和冲突检测这两个步骤具备原子性==,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:**比较并交换(Compare-and-Swap,CAS)**。
CAS 指令需要有 3 个操作数,分别是:
- 内存地址 V
- 旧的预期值 A
- 新值 B。
当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。(否则一直循环重试,直到成功为止)
2、AtomicInteger
J.U.C 包里面的整数原子类 AtomicInteger
,其中的 compareAndSet()
和 getAndIncrement()
等方法都使用了 Unsafe 类的 CAS 操作。
以下代码使用了 AtomicInteger 执行了自增的操作:
1 | private AtomicInteger cnt = new AtomicInteger(); |
以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt()
1 | public final int incrementAndGet() { |
以下代码是 getAndAddInt() 源码,其中:
- var1 指示对象内存地址;
- var2 指示该字段相对对象内存地址的偏移;
- var4 指示操作需要加的数值,这里为 1。
具体过程:
- 通过 getIntVolatile(var1, var2) 得到旧的预期值;
- 通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
- 可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
1 | public final int getAndAddInt(Object var1, long var2, int var4) { |
3、ABA
ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference
来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
3、无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
1、栈封闭(JUC中线程池相关)
多个线程访问同一个方法的==局部变量==时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
1 | import java.util.concurrent.ExecutorService; |
1 | public static void main(String[] args) { |
1 | 100 |
2、线程本地存储(Thread Local Storage)(JUC中ThreadLocal详解)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“==一个请求对应一个服务器线程”(Thread-per-Request)==的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
可以使用 ==java.lang.ThreadLocal== 类来实现线程本地存储功能。
对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。
1 | public class ThreadLocalExample { |
输出结果:1
为了理解 ThreadLocal,先看以下代码:
1 | public class ThreadLocalExample1 { |
它所对应的底层结构图为:
每个 Thread 都有一个 ==ThreadLocal.ThreadLocalMap 对象==,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员。
1 | /* ThreadLocal values pertaining to this thread. This map is maintained |
当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLoca -> value 键值对插入到该 Map 中。
1 | public void set(T value) { |
get() 方法类似:
1 | public T get() { |
hreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。
注意:
- **在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ==ThreadLocal 有内存泄漏的情况==**;
- 应该尽可能在每次使用 ThreadLocal 后==手动调用 remove()==,以==避免出现 ThreadLocal 经典的内存泄漏==甚至是造成自身业务混乱的风险。
3、可重入代码(Reentrant Code)
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征:
- 例如不依赖存储在堆上的数据和公用的系统资源;
- 用到的状态量都由参数中传入
- 不调用非可重入的方法等。
4、无状态
在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】
3、Java 并发 - 线程基础
1、BAT大厂的面试问题
- 线程有哪几种状态?分别说明从一种状态到另一种状态转变有哪些方式?
- 通常线程有哪几种使用方式?
- 基础线程机制有哪些?
- 线程的中断方式有哪些?
- 线程的互斥同步方式有哪些?如何比较和选择?
- 线程之间有哪些协作方式?
2、进程与线程
1、进程
进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
在当代面向线程设计的计算机结构中,进程是线程的容器。
进程:
- 是程序的实体;
- 是计算机中的程序关于某数据集合上的一次运行活动;
- 是系统进行资源分配和调度的基本单位;
- 是操作系统结构的基础。
- 程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程:
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
- 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器
等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)
2、线程
线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
线程:
- 一个进程之内可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
- Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器
3、进程与线程的区别
- 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程;
- 进程——资源分配的最小单位。
- 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。
- 线程——程序执行的最小单位。
- 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
- 进程拥有共享的资源,如内存空间等,供其内部的线程共享
- 进程间通信较为复杂
- 同一台计算机的进程通信称为 IPC(Inter-process communication)
- 信号量:信号量是一个计数器,用于多进程对共享数据的访问,解决同步相关的问题并避免竞争条件
- 共享存储:多个进程可以访问同一块内存空间,需要使用信号量用来同步对共享存储的访问
- 管道通信:管道是用于连接一个读进程和一个写进程以实现它们之间通信的一个共享文件,pipe文件
- 匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信,只支持半双工通信
- 命名管道(Names Pipes):以磁盘文件的方式存在,可以实现本机任意两个进程通信,遵循FIFO
- 消息队列:内核中存储消息的链表,由消息队列标识符标识,能在不同进程之间提供全双工通信,对比管道:
- 匿名管道存在于内存中的文件;命名管道存在于实际的磁盘介质或者文件系统;消息队列存放在内核中,只有在内核重启(操作系统重启)或者显示地删除一个消息队列时,该消息队列才被真正删除
- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
- 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
- 套接字:与其它通信机制不同的是,它可用于不同机器间的进程通信
- 同一台计算机的进程通信称为 IPC(Inter-process communication)
- 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
- Java中的通信机制:volatile、等待/通知机制、join方式、InheritableThreadLocal、MappedByteBuffer
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
3、线程状态转换
1、线程的五状态模型(操作系统)
- 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
- 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
- 【运行状态】指获取了 CPU 时间片运行中的状态
- 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
- 【阻塞状态】
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
- 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
- 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
- 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
2、线程的七状态模型(操作系统)
3、线程的六状态模型(java)
这是从 Java API 层面来描述的
根据 Thread.State 枚举,分为六种状态:
NEW
线程刚被创建,但是还没有调用 start() 方法RUNNABLE
当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【==可运行状态==】、【==运行状态==】和【==阻塞状态==】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)BLOCKED
,WAITING
,TIMED_WAITING
都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述TERMINATED
当线程代码运行结束
假设有线程 Thread t
- NEW –> RUNNABLE
- 当调用
t.start()
方法时,由NEW --> RUNNABLE
- 当调用
- RUNNABLE <–> WAITING
- t 线程用 synchronized(obj) 获取了对象锁后
- 调用
obj.wait()
方法时,t 线程从RUNNABLE --> WAITING
- 调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时- 线程被notify之后直接从waitset进入entrylist,对应的状态就是
WAITING --> BLOCKED
- 等到锁释放之后,t线程进入锁的竞争
- 竞争锁成功,t 线程从
WAITING --> RUNNABLE
- 竞争锁失败,t 线程从
WAITING --> BLOCKED
- 竞争锁成功,t 线程从
- 线程被notify之后直接从waitset进入entrylist,对应的状态就是
- 调用
- t 线程用 synchronized(obj) 获取了对象锁后
- RUNNABLE <–> WAITING
- 当前线程调用
t.join()
方法时,当前线程从RUNNABLE --> WAITING
- 注意是当前线程在t 线程对象的监视器上等待
- t 线程运行结束,或调用了当前线程的
interrupt()
时,当前线程从WAITING --> RUNNABLE
- 当前线程调用
- RUNNABLE <–> WAITING
- 当前线程调用
LockSupport.park()
方法会让当前线程从RUNNABLE --> WAITING
- 调用
LockSupport.unpark(目标线程)
或调用了线程 的interrupt()
,会让目标线程从WAITING --> RUNNABLE
- 当前线程调用
- RUNNABLE <–> TIMED_WAITING
- t 线程用 synchronized(obj) 获取了对象锁后
- 调用
obj.wait(long n)
方法时,t 线程从RUNNABLE --> TIMED_WAITING
- t 线程等待时间超过了 n 毫秒,或调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时- 线程被notify之后直接从waitset进入entrylist,对应的状态就是
WAITING --> BLOCKED
- 等到锁释放之后,t线程进入锁的竞争
- 竞争锁成功,t 线程从
TIMED_WAITING --> RUNNABLE
- 竞争锁失败,t 线程从
TIMED_WAITING --> BLOCKED
- 竞争锁成功,t 线程从
- 线程被notify之后直接从waitset进入entrylist,对应的状态就是
- 调用
- t 线程用 synchronized(obj) 获取了对象锁后
- RUNNABLE <–> TIMED_WAITING
- 当前线程调用
t.join(long n)
方法时,当前线程从RUNNABLE --> TIMED_WAITING
- 注意是当前线程在t 线程对象的监视器上等待
- 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的
interrupt()
时,当前线程从TIMED_WAITING --> RUNNABLE
- 当前线程调用
- RUNNABLE <–> TIMED_WAITING
- 当前线程调用
Thread.sleep(long n)
,当前线程从RUNNABLE --> TIMED_WAITING
- 当前线程等待时间超过了 n 毫秒,当前线程从
TIMED_WAITING --> RUNNABLE
- 当前线程调用
- RUNNABLE <–> TIMED_WAITING
- 当前线程调用
LockSupport.parkNanos(long nanos)
或LockSupport.parkUntil(long millis)
时,当前线程从RUNNABLE --> TIMED_WAITING
- 调用
LockSupport.unpark(目标线程)
或调用了线程 的interrupt()
,或是等待超时,会让目标线程从TIMED_WAITING--> RUNNABLE
- 当前线程调用
- RUNNABLE <–> BLOCKED
- t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从
RUNNABLE --> BLOCKED
- 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有
BLOCKED
的线程重新竞争,如果其中 t 线程竞争成功,从BLOCKED --> RUNNABLE
,其它失败的线程仍然BLOCKED
- t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从
- RUNNABLE <–> TERMINATED
- 当前线程所有代码运行完毕,进入
TERMINATED
- 当前线程所有代码运行完毕,进入
线程一共有六种状态:
- 新建(new)
- 可运行(runnable)
- 阻塞(blocking)
- 无限期等待(waiting)
- 限期等待(timed waiting)
- 死亡(terminated)
1、新建(New)
创建后尚未启动。
2、可运行(Runnable)
可能正在运行,也可能正在等待 CPU 时间片。
包含了操作系统线程状态中的 Running
和 Ready
。
3、阻塞(Blocking)
等待获取一个排它锁,如果其线程释放了锁就会结束此状态。
4、无限期等待(Waiting)
等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
进入方法 | 退出方法 |
---|---|
没有设置 Timeout 参数的 Object.wait() 方法 | Object.notify() / Object.notifyAll() |
没有设置 Timeout 参数的 Thread.join() 方法 | 被调用的线程执行完毕 |
LockSupport.park() 方法 | - |
5、限期等待(Timed Waiting)
无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
- 调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
- 调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。
睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
阻塞和等待的区别:
- 阻塞是被动的,它是在等待获取一个排它锁。
- 而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。
进入方法 | 退出方法 |
---|---|
Thread.sleep() 方法 | 时间结束 |
设置了 Timeout 参数的 Object.wait() 方法 | 时间结束 / Object.notify() / Object.notifyAll() |
设置了 Timeout 参数的 Thread.join() 方法 | 时间结束 / 被调用的线程执行完毕 |
LockSupport.parkNanos() 方法 | - |
LockSupport.parkUntil() 方法 | - |
6、死亡(Terminated)
可以是线程结束任务之后自己结束,或者产生了异常而结束。
4、线程的四种使用方式
有三种使用线程的方法:
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 继承 Thread 类;
- 使用线程池
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。
1、实现 Runnable 接口
- 编写需要的类并实现Runnable接口,实现里面的 run() 方法。
- 通过 Thread 调用 start() 方法来启动线程。
代码:
1 | public class MyRunnable implements Runnable { |
1 | public static void main(String[] args) { |
实现 Runnable 接口的优缺点:
- 缺点:代码复杂一点。
- 优点:
- 线程任务类只是实现了Runnable接口,可以继续继承其他类,避免了单继承的局限性
- 同一个线程任务对象可以被包装成多个线程对象
- 适合多个多个线程去共享同一个资源
- 实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立
- 线程池可以放入实现Runnable或Callable线程任务对象
2、实现 Callable 接口
与Runnable 接口大致相同:
- 编写需要的类并实现Callable接口,实现里面的 call() 方法,该方法有返回值。
- 通过 Thread 调用 start() 方法来启动线程。
区别是:与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。
1 | public class MyCallable implements Callable<Integer> { |
1 | public static void main(String[] args) throws ExecutionException, InterruptedException { |
实现 Callable 接口的优缺点:
- 优点:同 Runnable,并且能得到线程执行的结果
- 缺点:编码复杂
3、继承 Thread 类
同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。
建议线程先创建子线程,主线程的任务放在之后,否则主线程(main)永远是先执行完
1 | public class MyThread extends Thread { |
1 | public static void main(String[] args) { |
继承 Thread 类的优缺点:
- 优点:编码简单
- 缺点:线程类已经继承了Thread类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)
4、使用线程池
Java标准库提供了ExecutorService
接口表示线程池,因为ExecutorService
只是接口,Java标准库提供的几个常用实现类有:
- FixedThreadPool:线程数固定的线程池;
- CachedThreadPool:线程数根据任务动态调整的线程池;
- SingleThreadExecutor:仅单线程执行的线程池。
创建这些线程池的方法都被封装到Executors
这个类中。
1 | import java.util.concurrent.*; |
线程池的具体细节放在下面线程池篇具体说明
5、Thread 与 Runnable 的底层关系
使用Runnable的方法创建线程的代码:
1 | Thread t = new Thread(()->{ log.debug("running"); }, "t2"); |
Thread底层代码:
1 | // Thread的一个构造函数 |
总结:
- Thread类本身实现了Runnable接口
- 如果直接使用Thread的方式创建线程对象,则原理是重写了Thread的run方法
- 如果使用的Runnable的方式创建线程对象,在原理是将Runnable对象封装成target,在Thread中调用target.run方法
6、实现接口 VS 继承 Thread
实现接口会更好一些,因为:
- 使用接口更容易与线程池等高级 API 配合
- 使用接口让任务类脱离了 Thread 继承体系,更灵活
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大。
7、调用start()方法,线程是否会马上创建?
- 线程不一定马上创建的
- 看start()方法的源码知道start()方法底层调用了start0()方法,这是一个被
native
修饰的方法,它的调用依赖于操作系统 - 当操作系统认为当前可以创建线程的时候,线程才会被创建
8、查看进程线程的方法
- windows
- 任务管理器可以查看进程和线程数,也可以用来杀死进程
tasklist
查看进程taskkill
杀死进程
- linux
ps -fe
查看所有进程ps -fT -p <PID>
查看某个进程(PID)的所有线程kill
杀死进程top 按大写 H
切换是否显示线程top -H -p <PID>
查看某个进程(PID)的所有线程
- Java
jps 命令
查看所有 Java 进程jstack <PID>
查看某个 Java 进程(PID)的所有线程状态jconsole
来查看某个 Java 进程中线程的运行情况(图形界面)- jconsole 远程监控配置:
- 需要以如下方式运行你的 java 类
- java -Djava.rmi.server.hostname=
ip地址
-Dcom.sun.management.jmxremote -
Dcom.sun.management.jmxremote.port=连接端口
-Dcom.sun.management.jmxremote.ssl=是否安全连接 -Dcom.sun.management.jmxremote.authenticate=是否认证 java类
- java -Djava.rmi.server.hostname=
- 修改 /etc/hosts 文件将 127.0.0.1 映射至主机名
- 如果要认证访问,还需要做如下步骤:
- 复制 jmxremote.password 文件
- 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写
- 连接时填入 controlRole(用户名),R&D(密码)
- 需要以如下方式运行你的 java 类
- jconsole 远程监控配置:
9、线程运行的原理
1、栈与栈帧
JVM 中由堆、栈、方法区所组成。
Java Virtual Machine Stacks (Java 虚拟机栈):每个线程启动后,虚拟机就会为其分配一块栈内存
我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?
其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2、线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。原因:
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了
sleep
、yield
、wait
、join
、park
、synchronized
、lock
等方法
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- Java 创建的线程是内核级线程,线程的调度是在内核态运行的,而线程中的代码是在用户态运行,所以线程切换(状态改变)会导致用户与内核态转换,这是非常消耗性能
- Java 中 main 方法启动的是一个进程也是一个主线程,main 方法里面的其他线程均为子线程
5、线程的常见方法
方法名 | static(静态) | 功能说明 | 注意 |
---|---|---|---|
start() | 启动一个新线程,在新的线程运行 run 方法中的代码 | start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException | |
run() | 新线程启动后会调用的方法 | 如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为 | |
join() | 等待线程运行结束 | ||
join(long n) | 等待线程运行结束,最多等待 n 毫秒 | ||
getId() | 获取线程长整型的 id | id 唯一 | |
getName() | 获取线程名 | ||
setName(String) | 修改线程名 | ||
getPriority() | 获取线程优先级 | ||
setPriority(int) | 修改线程优先级 | java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率 | |
getState() | 获取线程状态 | Java 中线程状态是用 6 个 enum 表示,分别为:NEW , RUNNABLE , BLOCKED , WAITING , TIMED_WAITING , TERMINATED |
|
isInterrupted() | 判断是否被打断 | 不会清除==打断标记== | |
isAlive() | 线程是否存活(还没有运行完毕) | ||
interrupt() | 打断线程 | 如果被打断线程正在 sleep ,wait ,join 会导致被打断的线程抛出 InterruptedException,并清除==打断标记== ;如果打断的正在运行的线程,则会设置==打断标记==;park 的线程被打断,也会设置==打断标记== |
|
interrupted() | static | 判断当前线程是否被打断 | 会清除==打断标记== |
currentThread() | static | 获取当前正在执行的线程 | |
sleep(long n) | static | 让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程 | |
yield() | static | 提示线程调度器让出当前线程对CPU的使用 | 主要是为了测试和调试 |
6、不推荐的方法
还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁
方法名 | static | 功能说明 |
---|---|---|
stop() | 停止线程运行 | |
suspend() | 挂起(暂停)线程运行 | |
resume() | 恢复线程运行 |
7、基础线程机制
1、Executor
Executor
管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
主要有三种 Executor:
CachedThreadPool
: 一个任务创建一个线程;FixedThreadPool
:所有任务只能使用固定大小的线程;SingleThreadExecutor
:相当于大小为 1 的 FixedThreadPool。
具体使用:(代码)
1 | public static void main(String[] args) { |
2、Daemon
守护线程是程序运行时在后台提供服务的线程,属于程序中不可或缺的一部分。
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
main() 属于非守护线程。
使用 setDaemon()
方法将一个线程设置为守护线程。
1 | public static void main(String[] args) { |
3、sleep()
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为==毫秒==。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。
1 | public void run() { |
案例——防止CPU占用100%
在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权给其他程序
1 | public class TestCpu { |
- 可以用 wait 或 条件变量达到类似的效果
- 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
- sleep 适用于无需锁同步的场景
4、yield()
对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行(让位操作)。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。
1 | public void run() { |
5、run/start、sleep/yield、线程优先级
1、run与start
1、run
run:称为线程体,包含了要执行的这个线程的内容,方法运行结束,此线程随即终止。直接调用 run 是在主线程中执行了 run,没有启动新的线程,需要顺序执行
2、start
start:使用 start 是启动新的线程,此线程处于就绪(可运行)状态,通过新的线程间接执行 run 中的代码
说明:线程控制资源类
3、面试问题:run() 方法中的异常不能抛出,只能 try/catch
- 因为父类中没有抛出任何异常,子类不能比父类抛出更多的异常
- 异常不能跨线程传播回 main() 中,因此必须在本地进行处理
4、run与start之间的区别
直接调用 run 是在主线程中执行了 run,没有启动新的线程,相当于变成了普通类的执行,此时将只有主线程在执行该线程
使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码
调用start()方法之前与之后线程的状态:
代码:
public class Test5 { public static void main(String[] args) { Thread t1 = new Thread("t1") { @Override public void run() { log.debug("running..."); } }; System.out.println(t1.getState()); t1.start(); System.out.println(t1.getState()); } }
1
2
3
4
5
6
7
- 结果:
- ```sh
NEW
RUNNABLE
12:51:05.298 [t1] c.Test5 - running...
2、sleep与yield之间的区别
1、sleep
- 调用 sleep 会让当前线程从
Running
进入Timed Waiting
状态(阻塞)- 使用sleep后,线程失去cpu的时间片。同时也不能在获取cpu的时间片。
- 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
- 睡眠结束后的线程未必会立刻得到执行
- 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
2、yield
- 调用 yield 会让当前线程从
Running
进入Runnable
就绪状态,然后调度执行其它线程- 使用yield后,如果线程进入Runnable就绪状态还是有可能签到cpu时间片的,这是与sleep()最大的不同
- 具体的实现依赖于操作系统的任务调度器
- 会放弃 CPU 资源,锁资源不会释放
3、线程优先级(priority)
- 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
- 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用
8、线程中断
一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。
1、InterruptedException
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。
1 | public class InterruptExample { |
1 | public static void main(String[] args) throws InterruptedException { |
1 | Main run |
2、interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
1 | public class InterruptExample { |
1 | public static void main(String[] args) throws InterruptedException { |
1 | Thread end |
3、Executor 的中断操作
- 调用 Executor 的
shutdown()
方法会等待线程都执行完毕之后再关闭, - 但是如果调用的是
shutdownNow()
方法,则相当于调用每个线程的 interrupt() 方法。
以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。
1 | public static void main(String[] args) { |
1 | Main run |
如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程。
1 | Future<?> future = executorService.submit(() -> { |
9、线程互斥同步
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问:
- 第一个是 JVM 实现的 synchronized;
- 而另一个是 JDK 实现的 ReentrantLock。
1、synchronized
1、同步一个代码块
1 | public void func() { |
它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。
对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。
1 | public class SynchronizedExample { |
1 | public static void main(String[] args) { |
1 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
2、同步一个方法
1 | public synchronized void func () { |
它和同步代码块一样,作用于同一个对象。
3、同步一个类
1 | public void func() { |
作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。
1 | public class SynchronizedExample { |
1 | public static void main(String[] args) { |
1 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
4、同步一个静态方法
1 | public synchronized static void fun() { |
作用于整个类。
2、ReentrantLock(JUC中的ReentrantLock)
ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。
1 | public class LockExample { |
1 | public static void main(String[] args) { |
1 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
3、比较
- 锁的实现
- synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
- 性能
- 新版本 Java 对 synchronized 进行了很多优化,例如==自旋锁==等,synchronized 与 ReentrantLock 大致相同。
- 等待可中断
- 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
- ReentrantLock 可中断;
- 而 synchronized 不行。
- 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
- 公平锁
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
- synchronized 中的锁是非公平的;
- ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
- 锁绑定多个条件
- 一个 ReentrantLock 可以同时绑定多个 Condition 对象。
4、使用选择
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。
这是因为:
- synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。
- 并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
10、线程之间的协作
当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。
1、join()
在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。
原理:调用者轮询检查线程 alive 状态,t1.join()等价于:原理:调用者轮询检查线程 alive 状态,t1.join()等价于:
1 | synchronized (t1) { |
- join 方法是被 synchronized 修饰的,本质上是一个对象锁,其内部的 wait 方法调用也是释放锁的,但是释放的是当前线程的对象锁,而不是外面的锁
- t1 会强占 CPU 资源,直至线程执行结束,当调用某个线程的 join 方法后,该线程抢占到 CPU 资源,就不再释放,直到线程执行完毕
线程同步:
- join 实现线程同步,因为会阻塞等待另一个线程的结束,才能继续向下运行
- 需要外部共享变量,不符合面向对象封装的思想
- 必须等待线程结束,不能配合线程池使用
- Future 实现(同步):get() 方法阻塞等待执行结果
- main 线程接收结果
- get 方法是让调用线程同步等待
1 | public class Test { |
1、为什么需要join()
如果想要某线程(A)优先于某线程(B)运行(场景:线程B需要线程A的运算结果),这个时候就得线程B就需要使用join()来挂起当前线程,直到目标线程(A)结束。
对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。
1 | public class JoinExample { |
1 | public static void main(String[] args) { |
1 | A |
2、为什么不用sleep(),而使用join()
使用sleep也可以实现以上效果,但是不好:因为在设计情况下你不清楚A线程需要多次时间得到运算结果,所以B线程不知道要sleep多少时间。
3、join(long)
join(long)可以设置等待时间,单位是ms。
- 如果到了设置的时间还没有结果,线程会结束等待,继续往下运行
- 如果在设置的时间之前就应经有结果了,线程会立即往下运行,不会等到设定的时间
4、join的底层原理——保护性暂停模式的时间增强
先看一下join的源码:
1 | public final void join() throws InterruptedException { |
可以将join的底层实现与保护性暂停模式的时间增强进行对比,会发现join的底层用的是保护性暂停模式的时间增强
2、wait()、notify()、notifyAll()
调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
obj.wait()
让进入 object 监视器的线程到 waitSet 等待wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止
wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify
其实还有一个wait(long timeout, int nanos)方法,只是这个方法是一个无效方法:它的意思是可以把时间精确到纳秒,而实际上无论你在第二个参数填写什么值(大于0小于999999),他都只是将第一个参数的值加一
public final void wait(long timeout, int nanos) throws InterruptedException { if (timeout < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (nanos < 0 || nanos > 999999) { throw new IllegalArgumentException( "nanosecond timeout value out of range"); } if (nanos > 0) { timeout++; } wait(timeout); }
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
- `obj.notify()` 在 object 上正在 waitSet 等待的线程中挑一个唤醒
- `obj.notifyAll()` 让 object 上正在 waitSet 等待的线程全部唤醒
**它们都属于 Object 的一部分,而不属于 Thread**。
**==只能用在同步方法或者同步控制块中使用==**,否则会在运行时抛出 `IllegalMonitorStateExeception`。也侧面说明了wait/notify只能用在重量级锁。
**使用 wait() 挂起期间,线程会释放锁**。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
```java
public class WaitNotifyExample {
public synchronized void before() {
System.out.println("before");
notifyAll();
}
public synchronized void after() {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after");
}
}
1 | public static void main(String[] args) { |
1 | before |
1、wait() 和 sleep() 的区别
- sleep 是 Thread 的静态方法,wait 是 Object 的方法,任何对象实例都能调用;
- **sleep 不会释放锁,它也不需要占用锁。wait 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)**。
- sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
2、wait() 和 sleep() 的共同点
- 它们都可以被 interrupted 方法中断。
- 它们状态
TIMED_WAITING
- 在哪里睡着,就在哪里醒来。
3、await()、signal()、signalAll()
java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
使用 Lock 来获取一个 Condition 对象。
1 | public class AwaitSignalExample { |
1 | public static void main(String[] args) { |
1 | before |
4、interrupt
1、打断 sleep,wait,join 的线程
sleep,wait,join、这几个方法都会让线程进入阻塞状态(join底层就是wait,其实join与wait本质上是一样的)
可以使用interrupt方法来打断线程:
- 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
- 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
- 对于Running的线程,也就是正常运行的线程被打断(interrupt)后,不会立刻中断它,而是将其的打断标记isInterrupted()设置为true,可以在正常运行的线程中通过这个打断标记来选择是否终止自身线程。
- 也就是说:因为直接把线程终结了,人家线程事情都没干完。不如跟他说一声,说我要打断你,他处理完事情后自行了断不更好
2、多线程设计模式——两阶段终止
详细请看——并发的相关多线程设计模式
3、打断 park 线程
打断 park 线程,不会清空打断状态
1 | public static void main(String[] args) { |
输出:
1 | 21:11:19.373 [t1] c.TestInterrupt - park... |
如果打断标记已经是 true,则 park 会失效
1 | public static void main(String[] args) { |
输出:
1 | 21:11:54.003 [t1] c.TestInterrupt - park... |
提示:可以使用 Thread.interrupted()
清除打断状态
1 | log.debug("打断状态:{}", Thread.currentThread().interrupted()); |
4、关键字:synchronized详解
在C程序代码中我们可以利用操作系统提供的互斥锁来实现同步块的互斥访问及线程的阻塞及唤醒等工作。在Java中除了提供Lock API外还在语法层面上提供了synchronized关键字来实现互斥同步原语。
1、BAT大厂的面试问题
- Synchronized可以作用在哪里?分别通过对象锁和类锁进行举例。
- Synchronized本质上是通过什么保证线程安全的?
- 分三个方面回答:
- 加锁和释放锁的原理
- 可重入原理
- 保证可见性原理
- 分三个方面回答:
- Synchronized有什么样的缺陷?Java Lock是怎么弥补这些缺陷的?
- Synchronized和Lock的对比,和选择?
- Synchronized在使用时有何注意事项?
- Synchronized修饰的方法在抛出异常时,会释放锁吗?
- 多个线程等待同一个snchronized锁的时候,JVM如何选择下一个获取锁的线程?
- Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
- 我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
- 什么是锁的升级和降级?什么是JVM里的偏斜锁、轻量级锁、重量级锁?
- 不同的JDK中对Synchronized有何优化?
2、Synchronized的使用
在应用Sychronized关键字时需要把握如下注意点:
- 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
- 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;
- 例外:*锁对象是.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁**。
- synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会==释放锁==
1、对象锁
包括==方法锁==(默认锁对象为this,当前实例对象)和==同步代码块锁==(自己指定锁对象)
1、代码块形式
手动指定锁定对象:
可以是this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();
public void run() {
// 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
synchronized (this) {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}1
2
3
4我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束也可以是自定义的锁
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
37public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();
// 创建2把锁
Object block1 = new Object();
Object block2 = new Object();
public void run() {
// 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
synchronized (block1) {
System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
}
synchronized (block2) {
System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}1
2
3
4
5
6
7
8block1锁,我是线程Thread-0
block1锁,Thread-0结束
block2锁,我是线程Thread-0 // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
block1锁,我是线程Thread-1
block2锁,Thread-0结束
block1锁,Thread-1结束
block2锁,我是线程Thread-1
block2锁,Thread-1结束
2、方法锁形式:synchronized修饰普通方法,锁对象默认为this
1 | public class SynchronizedObjectLock implements Runnable { |
1 | 我是线程Thread-0 |
2、类锁
指synchronize修饰静态的方法或指定锁对象为Class对象。
1、synchronize修饰静态方法
synchronize修饰普通方法与修饰静态方法的区别:
- synchronized用在普通方法上,默认的锁就是this,当前实例
- synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
修饰普通方法:
1 | public class SynchronizedObjectLock implements Runnable { |
1 | 我是线程Thread-0 |
修饰静态方法
1 | public class SynchronizedObjectLock implements Runnable { |
1 | 我是线程Thread-0 |
2、synchronized指定锁对象为Class对象
1 | public class SynchronizedObjectLock implements Runnable { |
1 | 我是线程Thread-0 |
3、关于synchronized锁的总结
对于Synchronized实现同步的基础:java中每一个对象都可以作为锁。
具体可以分为以下三种情况:
- 对于普通同步方法,锁是当前实例对象;(对象锁)
- 对于静态同步方法,锁是当前类的Class 对象;(类锁)
- 对于同步方法块,锁是Synchonized 括号里配置的对象
对于对象锁:
- 如果一个实例对象的非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁;
- 别的实例对象的非静态同步方法因为跟该实例对象的非静态同步方法用的是不同的锁, 所以无须等待该实例对象已获取锁的非静态同步方法释放锁就可以获取他们自己的锁;
- 每一个对象都有属于自己的对象锁(可以有多把对象锁)。
对于类锁:
- 所有的静态同步方法用的也是同一把锁——类对象本身(类锁),这与对象锁是两个不同的对象,所以静态同步方法与非静态同步方法之间是不会有竞态条件的;
- 一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁;
- 但不管是同一个实例对象的静态同步方法之间,还是不同的实例对象的静态同步方法之间,只要它们同一个类的实例对象!
- 类锁只有一把。
对于同步代码块:
- 同步代码块的锁是Synchonized 括号里配置的对象;
- 如果Synchonized 括号里是对象,那么他就是对象锁;如果Synchonized 括号里是类,那么他就是类锁;
- 所以他可以有一把,也可以有多把(主要看如果Synchonized 括号里是类还是对象)
举个例子:把synchronized的锁看成一座大楼
- 类锁就是锁住大楼的锁
- 对象锁就是锁住大楼里面房间的锁,每一个房间都有属于它的一把锁
3、Synchronized的原理分析
1、加锁和释放锁的原理
现象、时机(内置锁this)、深入JVM看字节码(反编译看monitor指令)
深入JVM看字节码,创建如下的代码:
1
2
3
4
5
6
7
8public class SynchronizedDemo2{
static final Object lock = new Object(); static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}使用javac命令进行编译生成.class文件
1
>javac SynchronizedDemo2.java
使用javap命令反编译查看.class文件的信息
1
>javap -verbose SynchronizedDemo2.class
得到如下的信息:
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
38Code:
stack=2,locals=3,args_size=1
0: getstatic #2 // <- lock引用( synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将lock对象 MarkWord 重置,唤醒EntryList
16: goto 24
19: astore_2 // e -> slot2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock 对象 MarkWord 重置,唤醒EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 8: 0
line 9: 6
line 10: 14
line 11: 24
LocalVar iableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_ _frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
注意:
- 方法级别的 synchronized 不会在字节码指令中有所体现
- 在字节码中的
16: goto 24
当中,执行到这里会跳转到第24行的字节码执行24:return
返回 - 那么第19行到第23行的字节码的作用是什么?
- 仔细阅读字节码的内容会发现:他们的作用是当同步代码块中的内容出现异常的时候,为了防止当前的锁得不到释放而造成死锁,在第19到第23行进行异常的抛出和锁的释放
关注字节码当中的monitorenter
和monitorexit
即可。
Monitorenter
和Monitorexit
指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:
- monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
- 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
- 这把锁已经被别的线程获取了,等待锁释放
monitorexit指令
:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。
下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
该图可以看出,任意线程对Object的访问,首先要获得Object的监视器monitor,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
2、可重入原理:加锁次数计数器
上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。
Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
3、保证可见性的原理:内存模型和happens-before规则
Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码:
1 | public class MonitorDemo { |
该代码的happens-before关系如图所示:
在图中每一个箭头连接的两个节点就代表之间的happens-before关系:
- 黑色的是通过程序顺序规则推导出来,
- 红色的为监视器锁规则推导而出:
- 线程A释放锁happens-before线程B加锁;
- 蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。
- 现在我们来重点关注:
2 happens-before 5
,通过这个关系我们可以得出什么?- 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由
2 happens-before 5
关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。
- 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由
- 现在我们来重点关注:
4、synchronized 是给对象加锁的原理——对象的对象头
synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:
- Mark Word(标记字段)
- Klass Pointer(类型指针)
以 32 位虚拟机为例:
普通对象:
1 | |--------------------------------------------------------------| |
数组对象:
1 | |---------------------------------------------------------------------------------| |
其中 Mark Word 结构为:
1 | |-------------------------------------------------------|--------------------| |
64 位虚拟机 Mark Word:
1 | |--------------------------------------------------------------------|--------------------| | Mark Word (64 bits) | State | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased | |--------------------------------------------------------------------|--------------------| | ptr_to_lock_record:62 | 00 | Lightweight Locked | |--------------------------------------------------------------------|--------------------| | ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked | |--------------------------------------------------------------------|--------------------| |
Monitor
被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针,如下图所示,右侧就是对象对应的 Monitor 对象。
当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。
另外 Monitor 中还有两个队列分别是EntryList
和WaitList
,主要是用来存放进入及等待获取锁的线程。
如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。
- Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor 结构如下
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?
如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。
由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:
1 | class FineGrainLock{ |
4、JVM中锁的优化
简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)下,如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
锁粗化(Lock Coarsening)
:也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。锁消除(Lock Elimination)
:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(栈上分配)(同时还可以减少Heap上的垃圾收集开销)。轻量级锁(Lightweight Locking)
:这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。偏向锁(Biased Locking)
:是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。适应性自旋(Adaptive Spinning)
:当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。
1、锁的类型
在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁
、偏向锁
、轻量级所
、重量级锁
,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
2、自旋锁与自适应自旋锁
1、自旋锁
引入背景:
大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但==不放弃CPU的执行时间==。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。
自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin
来更改。
自旋重试成功的情况:
线程1 (core1上) | 对象Mark | 线程2 ( core2上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取monitor | 10 (重量锁)重量锁指针 | - |
成功(加锁) | 10 (重量锁)重量锁指针 | - |
执行同步块 | 10 (重量锁)重量锁指针 | - |
执行同步块 | 10 (重量锁)重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10 (重量锁)重量锁指针 | 自旋重试 |
执行完毕 | 10 (重量锁)重量锁指针 | 自旋重试 |
成功(解锁) | 01(无锁) | 自旋重试 |
- | 10 (重量锁)重量锁指针 | 成功(加锁) |
- | 10 (重量锁)重量锁指针 | 执行同步块 |
- | …… | …… |
自旋重试失败的情况:
线程1 (core1上) | 对象Mark | 线程2 ( core2上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取monitor | 10 (重量锁)重量锁指针 | - |
成功(加锁) | 10 (重量锁)重量锁指针 | - |
执行同步块 | 10 (重量锁)重量锁指针 | - |
执行同步块 | 10 (重量锁)重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10 (重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10 (重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10 (重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10 (重量锁)重量锁指针 | 阻塞 |
- | …… | …… |
可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)
2、自适应自旋锁
在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。
总结:
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能
3、锁消除
锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。
当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。
比如如下操作:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象(线程安全)的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象(线程不安全)的连续append()操作。
1 | public static String test03(String s1, String s2, String s3) { |
对上述代码使用javap 编译的结果:
众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)
4、锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
这里贴上根据上述Javap 编译地情况编写的实例java类
1 | public static String test04(String s1, String s2, String s3) { |
在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的外部,使整个一连串地append()操作只需要加锁一次就可以了。
5、轻量级锁
在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。
如果要理解轻量级锁,那么必须先要了解HotSpot虚拟机中对象头的内存布局。在对象头中(Object Header
)存在两部分:(对象头的大小:(压缩指针)12字节,(不支持压缩指针)16字节)
- 第一部分用于存储对象自身的运行时数据,
HashCode
、GC Age
、锁标记位
、是否为偏向锁
。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word
,它是实现轻量级锁和偏向锁的关键。 - 另外一部分存储的是指向方法区对象类型数据的指针(
Klass Point
),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
轻量级锁加锁
在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record
)的空间,用于存储锁对象目前的Mark Word
的拷贝(JVM会将对象头中的Mark Word
拷贝到锁记录中,官方称为Displaced Mark Ward
)这个时候线程堆栈与对象头的状态如图:
如上图所示:如果当前对象没有被锁定,那么锁标志位为01状态
,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record
的空间用于存储锁对象目前的Mark Word
的拷贝。
然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word
更新为指向Lock Record
的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新
为(Mark Word
中最后的2bit)00
,即表示此对象处于轻量级锁定状态,如图:
如果这个更新操作失败:
- JVM会检查当前的
Mark Word
中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。(可重入锁) - 如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那**轻量级锁就不再有效,直接膨胀为重量级锁(锁膨胀)**,没有获得锁的线程会被阻塞。此时,
锁的标志位为10
。Mark Word
中存储的时指向重量级锁的指针。
轻量级解锁时:
- 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
- 锁记录的值不为 null,这时会使用原子的CAS操作将
Displaced Mark Word
替换回到对象头中:- 如果成功,则表示没有发生竞争关系,解锁成功
- 如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁,进入重量级锁的解锁流程
6、锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
1 | static Object obj = new Object(); |
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
- 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
- 此时Object的对象头的锁标志为
10
。
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
两个线程同时争夺锁,导致锁膨胀的流程图如下:
7、偏向锁
引入背景:
在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。
为了解决这一问题,HotSpot的作者在Java SE 1.6 中对Synchronized进行了优化,引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word
里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。
1、偏向状态
64 位虚拟机 Mark Word:
1 | |--------------------------------------------------------------------|--------------------| | Mark Word (64 bits) | State | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased | |--------------------------------------------------------------------|--------------------| | ptr_to_lock_record:62 | 00 | Lightweight Locked | |--------------------------------------------------------------------|--------------------| | ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked | |--------------------------------------------------------------------|--------------------| |
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为
101
,这时它的 thread、epoch、age 都为 0 - 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟- 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中(54位的threadID)
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为
001
,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值 - 如果你想禁用偏向锁,添加 VM 参数
-XX:-UseBiasedLocking
禁用偏向锁
2、偏向锁的撤销
1、方法一:调用对象的hashCode方法(对象仍可偏向)
- 如果默认开启了偏向锁,但当调用了对象的hashCode方法则会破坏对象的偏向锁
- 正常状态对象一开始是没有 hashCode 的,第一次调用才生成
- 调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
- 偏向锁没有其它记录hashCode的方法,所以调用对象的hashCode会撤销对象的偏向锁
- 在调用 hashCode 后使用偏向锁,记得去掉
-XX:-UseBiasedLocking
(禁用偏向锁)
2、方法二:当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(对象变为不可偏向)
演示代码:(加上了VM参数 -XX:BiasedLockingStartupDelay=0
来禁用延迟)
1 |
|
输出:
1 | 20:48:31.674 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101 |
分析:
- 由于t2线程使用了wait,所以t2需要t1线程的notify唤醒,所以t1线程肯定由于t2线程,得到偏向锁。然后唤醒t2线程后,t2线程去争夺锁,导致了t1线程的偏向锁的破坏,并且t1线程变为不可偏向。
- 第一行:由于禁用延迟,所以t1线程一开始就处于
101
的偏向锁,只是此时t1线程还没得到锁,所以它的 thread、epoch、age 都为 0 - 第二行:t1线程拿到了锁,Mark Word记录了当前线程的ThreadID(54位)、epoch(2位)、unused(1位)和age(4位)
- 第三行:t1线程释放了锁,由于t1线程为偏向锁,所以Mark Word依旧记录了t1线程的ThreadID(54位)
- 递四行:t1线程唤醒了t2线程,当此时t2x线程还没有抢夺t1线程的偏向锁,所以Mark Word没变
- 第五行:t2线程抢夺t1的偏向锁,破坏了t1线程的偏向锁,偏向锁膨胀为轻量级锁(Mark Word后三位为
000
)- 此时Mark Word记录的是ptr_to_lock_record:62
- 第六行:t2线程释放锁,Mark Word后三位为
001
底层:
偏向锁使用了一种==等待竞争出现才会释放锁==的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放偏向锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
3、调用 wait/notify
因为 wait/notify(等待唤醒)模式是应用在重量级锁上的,所以调用 wait/notify就意味着此时是重量级锁,而不是偏向锁与轻量级锁。
3、批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20
次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
1 | private static void test3() throws InterruptedException { |
输出:
1 | [t1] - 0 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101 |
注意:
1 | [t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101 |
在第20次(从0开始,到19)后,批量重偏向
4、批量撤销
当撤销偏向锁阈值超过 40
次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
1 | import lombok.extern.slf4j.Slf4j; |
输出:
- t1线程前面的39个对象全部拥有了偏向锁
- t2线程前19次因为破坏了t1线程对象的偏向锁,升级为轻量级锁
- t2线程从第20次后进入批量重偏向,从第20次到第39次全部都是批量重偏向,t2线程拥有偏向锁
- t3线程的前19个对象为轻量级锁(t2修改为轻量级锁)
- t3线程从第20个对象开始,此时对象的偏向锁是偏向t2线程的,所以t3线程会破坏t2线程的偏向锁,升级为轻量级锁,从第20个到第39个都是这样。
- 由于JVM进行了前39次的偏向锁撤销,在进行第40次撤销操作时,JVM会将整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
- 如果把loopNumber的值修改为38,即只进行38次偏向锁撤销,那么在第39次偏向锁撤销,JVM依旧会采用偏向锁升级为轻量级锁,此时的对象依旧是可偏向的(
101
)
8、锁的优缺点对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步快的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 | 追求响应时间,同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步快执行速度较长 |
5、Synchronized与Lock
1、synchronized的缺陷
效率低
:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时;不够灵活
:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活无法知道是否成功获得锁
,相对而言,Lock可以拿到状态,如果成功获取锁,….,如果获取失败,…..- 如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
- 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
- 线程执行发生异常,此时 JVM 会让线程自动释放锁。
- 那么如果这个获取锁的线程由于要等待 I/O 或者其他原因(比如调用 sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。
2、Lock解决相应问题
Lock类这里不做过多解释,主要看里面的4个方法:
lock()
:加锁unlock()
:解锁tryLock()
:尝试获取锁,返回一个boolean值tryLock(long,TimeUtil)
:尝试获取锁,可以设置超时
Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合
解决了这个问题。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。
Lock可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断)。
ReentrantLock的lockInterruptibly()方法
可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。
注:
ReentrantLock
为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大- JUC中的JUC锁:ReentrantLock
3、总结:Lock 与的 Synchronized 区别
- Lock 不是 Java 语言内置的,synchronized 是 Java 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问;
- synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
- Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用synchronized 时,等待的线程会一直等待下去,不能够响应中断;
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到;
- Lock 可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优于synchronized。
6、再深入理解
synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛地使用。
- 使用Synchronized有哪些要注意的?
- 锁对象不能为空,因为锁的信息都保存在对象头里;
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错;
- 避免死锁;
- 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
- synchronized是公平锁吗?
- synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待;
- 不过这种抢占的方式可以预防饥饿。
- 使用Synchronized可以解决可见性问题吗?
- 可以在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。
5、关键字:volatile详解
相比Sychronized(重量级锁,对系统性能影响较大),volatile提供了另一种解决可见性和有序性问题的方案。
1、BAT大厂的面试问题
- volatile关键字的作用是什么?
- volatile能保证原子性吗?
- 之前32位机器上共享的long和double变量的为什么要用volatile?现在64位机器上是否也要设置呢?
- i++为什么不能保证原子性?
- volatile是如何实现可见性的?
- 内存屏障
- volatile是如何实现有序性的?
- happens-before等
- 说下volatile的应用场景?
2、volatile的作用详解
1、防重排序
我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:
1 | public class Singleton { |
现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:
- 分配内存空间。
- 初始化对象。
- 将内存空间的地址赋值给对应的引用。
但是由于操作系统可以对指令进行重排序
,所以上面的过程也可能会变成如下过程:
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
注意:加上volatile的变量会保证在它之前的指令不会被重排序。原因:在加上volatile的变量的地方会加上一个内存屏障,保证在它之前的指令不会重排序到它下面去。
2、实现可见性
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下面的例子,就可以知道其作用:
1 | public class VolatileTest { |
直观上说,这段代码的结果只可能有两种:
- b=3;a=3
- b=2;a=1
不过运行上面的代码(可能时间上要长一点,概率要小很多),你会发现除了上两种结果之外,还出现了第三种结果:
- b=3;a=1
分析:为什么会出现b=3;a=1这种结果呢?
- 正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。
- 相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。
- 那b=3;a=1的结果是怎么出来的?
- 原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。
- 如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。
3、保证原子性:单次读/写
volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。
先从如下两个问题来理解(后文再从内存屏障的角度理解):
- 问题1: i++为什么不能保证原子性?
- 问题2: 共享的long和double变量的为什么要用volatile?
1、问题1: i++为什么不能保证原子性?
对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。
现在我们就通过下列程序来演示一下这个问题:
1 | public class VolatileTest01 { |
大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:981
可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:
- 读取i的值。
- 对i加1。
- 将i的值写回内存。
i++的相关字节码指令:
1 | getstatic i // 获取静态变量i的值 |
对于i–也是类似:
1 | getstatic i // 获取静态变量i的值 |
volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。
注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。
2、问题2: 共享的long和double变量的为什么要用volatile?
因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。
如下是JLS中的解释:
17.7 Non-Atomic Treatment of double and long
- For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
- Writes and reads of volatile long and double values are always atomic.
- Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
- Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
- Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.
目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。
3、volatile的实现原理
1、volatile 可见性实现
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)
实现:
- 内存屏障,又称内存栅栏,是一个 CPU 指令。
- 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入==特定类型的内存屏障来禁止==+ ==特定类型的编译器重排序和处理器重排序==**,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序**。
- 对 volatile 变量的写指令后会加入写屏障
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
- 对 volatile 变量的读指令前会加入读屏障
- 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
1 | import org.openjdk.jcstress.annotations.*; |
写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。
1 | public class Test { |
通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
1 | ...... |
lock 前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
1、lock 指令
在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)
来保证。
2、缓存一致性
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。
LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议
。
所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
2、volatile 有序性实现
1、volatile 的 happens-before 关系
happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
1 | //假设线程A执行writer方法,线程B执行reader方法 |
根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。
- 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
- 根据 volatile 规则:2 happens-before 3。
- 根据 happens-before 的传递性规则:1 happens-before 4。
因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。
2、volatile 禁止重排序
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
Java 编译器会在==生成指令系列==时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM 会针对编译器制定 volatile 重排序规则表:
“ NO “ 表示禁止重排序。
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
内存屏障 | 说明 |
---|---|
StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 |
StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 |
LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 |
LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 |
1 | import org.openjdk.jcstress.annotations.*; |
还是那句话,不能解决指令交错:
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
3、double-checked locking 问题
以著名的 double-checked locking 单例模式为例
1 | public final class Singleton { |
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
- 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外
但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:
1 | 0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton; |
其中:
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 引用地址
- 21 表示利用一个对象引用,调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE
也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
可能有人注意到:在synchronized内部的变量不是可以保证原子性、有序性和可见性吗?为什么21与24还会被重排序?
- 被synchronized完全接管的变量确实可以保证原子性、有序性和可见性,但是必须是被synchronized完全接管的变量;
- 在代码上
INSTANCE
并没有被synchronized完全接管,线程在synchronized内部使用INSTANCE
的时候,在synchronized外部还是可能有其它线程接触INSTANCE
- 所以在synchronized内部,
INSTANCE
还是有可能被重排序(24与21重排序)
解决方法:对 INSTANCE
使用 volatile
修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效
4、double-checked locking 解决
1 | public final class Singleton { |
字节码上看不出来 volatile 指令的效果:
1 | // -------------------------------------> 加入对 INSTANCE 变量的读屏障 |
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
- 可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
- 有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
- 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
4、volatile的应用场景
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
1、模式1:状态标志
也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
1 | volatile boolean shutdownRequested; |
2、模式2:一次性安全发布(one-time safe publication)
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
1 | public class BackgroundFloobleLoader { |
3、模式3:独立观察(independent observation)
安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
1 | public class UserManager { |
4、模式4:volatile bean 模式
在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。
1 |
|
5、模式5:开销较低的读-写锁策略
volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
1 |
|
6、模式6:双重检查(double-checked)
单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。
6、关键字:final详解
1、BAT大厂的面试问题
- 所有的final修饰的字段都是编译期常量吗?
- 如何理解private所修饰的方法是隐式的final?
- 说说final类型的类如何拓展?比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
- final方法可以被重载吗?
- 可以
- 父类的final方法能不能够被子类重写?
- 不可以
- 说说final域重排序规则?
- 说说final的原理?
- 使用 final 的限制条件和局限性?
- 看本文最后的一个思考题
2、final基础使用
1、修饰类
当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的。
注意:final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字是没有任何意义的。
那么final类型的类如何拓展?
- 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合,如下代码大概写个组合实现的意思:
1 | /** |
2、修饰方法
private 方法是隐式的final
final方法是可以被重载的
1、private final
类中所有private方法都隐式地指定为final的,由于无法取用private方法,所以也就不能覆盖它。可以对private方法增添final关键字,但这样做并没有什么好处。
看下面的例子:
1 | public class Base { |
Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()是不可执行的,因为Base中的test方法是private的,无法被访问到。
2、final方法是可以被重载的
我们知道父类的final方法是不能够被子类重写的,那么final方法可以被重载吗?
答案是可以的,下面代码是正确的。
1 | public class FinalExampleParent { |
3、修饰参数
Java允许在参数列表中以声明的方式将参数指明为final,这意味这你无法在方法中更改参数引用所指向的对象。
这个特性主要用来向匿名内部类传递数据。
4、修饰变量
1、所有final修饰的字段都是编译器常量吗?
现在来看编译期常量和非编译期常量,如:
1 | public class Test { |
k的值由随机数对象决定,所以不是所有的final修饰的字段都是编译期常量,只是k的值在被初始化后无法被更改。
2、static final
一个既是static又是final 的字段只占据一段不能改变的存储空间,它必须在定义的时候进行赋值,否则编译器将不予通过。
1 | import java.util.Random; |
上面代码某次输出结果:
1 | k=2 k2=7 |
我们可以发现对于不同的对象k的值是不同的,但是k2的值却是相同的,这是为什么呢?
- 因为static关键字所修饰的字段并不属于一个对象,而是属于这个类的。
- 也可简单的理解为static final所修饰的字段仅占据内存的一个一份空间,一旦被初始化之后便不会被更改
3、blank final
Java允许生成空白final,也就是说被声明为final但又没有给出定值的字段,但是必须在该字段被使用之前被赋值,这给予我们两种选择:
- 在定义处进行赋值(这不叫空白final)
- 在构造器中进行赋值,保证了该值在被使用前赋值。
这增强了final的灵活性。
看下面代码:
1 | public class Test { |
可以看到i2的赋值更为灵活。
但是请注意,如果字段由static和final修饰,仅能在定义处赋值,因为该字段不属于对象,属于这个类。
3、final域重排序规则
上面final的使用,应该属于Java基础层面的,当理解这些后我们就真的算是掌握了final吗? 有考虑过final在多线程并发的情况吗?
在java内存模型中我们知道java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。
那么,在多线程情况下,final会进行怎样的重排序? 会导致线程安全的问题吗?
下面,就来看看final的重排序。
1、final域为基本类型
先看一段示例性的代码:(假设线程A在执行writer()方法,线程B执行reader()方法。)
1 | public class FinalDemo { |
1、写final域重排序规则
写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
- JMM禁止编译器把final域的写重排序到构造函数之外;
- 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
我们再来分析writer方法,虽然只有一行代码,但实际上做了两件事情:
- 构造了一个FinalDemo对象;
- 把这个对象赋值给成员变量finalDemo。
我们来画下存在的一种可能执行时序图,如下:
由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。
因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo
2、读final域重排序规则
读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。
read()方法主要包含了三个操作:
- 初次读引用变量finalDemo;
- 初次读引用变量finalDemo的普通域a;
- 初次读引用变量finalDemo的final域b;
假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:
读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用。
2、final域为引用类型
1、对final域修饰的对象的成员域写操作
针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。
注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看:
1 | public class FinalReferenceDemo { |
针对上面的实例程序,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论。
由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
2、对final域修饰的对象的成员域读操作
JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。
3、关于final重排序的总结
按照final修饰的数据类型分类:
- 基本数据类型:
final域写
:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。final域读
:禁止初次读对象的引用与读该对象包含的final域的重排序。
- 引用数据类型:
额外增加约束
:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序
4、final再深入理解
1、final的实现原理
上面我们提到过,写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。
很有意思的是,如果以X86处理为例,X86不会对写-写重排序,所以StoreStore屏障可以省略。由于不会对有间接依赖性的操作重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,以X86为例的话,对final域的读/写的内存屏障都会被省略!具体是否插入还是得看是什么处理器
1、设置 final 变量的原理:
1 | public class TestFinal { |
字节码:
1 | 0: aload_0 |
发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况。
2、获取 final 变量的原理
1 | public class TestFinal { |
结果:
1 |
分析:
- 如果
final static int A = 10;
加入final
的话:那么在字节码的层面可以看到:BIPUSH 10
,即在读取A的时候,它是从栈中直接复制了一个10
给到了A,走的不是共享这条路(没有从其他类中读取数据) - 如果
static int A = 10;
没有加final
的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.A : I
,即在读取A的时候,它是从TestFinal类中获取的到的10,走的是共享这条路(从其他类中读取数据),那么A
就是==共享内存==,性能比==栈内存==要低。 - 对于
final static int B = Short.MAX_VALUE+1;
来说,B是Short
的最大值在加上1(超过了极限)。加入final
的话:那么在字节码的层面可以看到:LDC 32768
(Short的最大值是32767),即读取的是常量池当中的内容,同理也没有走共享内存这条路(没有从其他类中读取数据) - 如果
B
没有加final
的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.B : I
,即在读取B的时候,它是从TestFinal类中获取的,走的是共享这条路(从其他类中读取数据) - 下面的成员变量
a
与b
也是同样的道理:加入final
修饰的,在引用到a/b的时候,会复制一份到调用方的常量池当中,直接从栈内存获取就行(==栈内存==),效率高。没有加final
修饰的,在引用到a/b的时候,会直接到类中获取(==共享内存==),效率比较低。 - 总结:一个final修饰的基本变量可以完全等价于一个常量,整个jvm实例生命周期内都不会变化了,这个值在编译时就已经写死成直接引用了
2、为什么final引用不能从构造函数中”溢出”
这里还有一个比较有意思的问题:上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。
但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“溢出”。以下面的例子来说:
1 | public class FinalReferenceEscapeDemo { |
可能的执行时序如图所示:
假设一个线程A执行writer方法另一个线程执行reader方法。因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this”逸出,该代码依然存在线程安全的问题。
3、使用final的限制条件和局限性
当声明一个 final 成员时,必须在构造函数退出前设置它的值。
1
2
3
4
5
6public class MyClass {
private final int myField = 1;
public MyClass() {
...
}
}- 或者
1
2
3
4
5
6
7
8public class MyClass {
private final int myField;
public MyClass() {
...
myField = 1;
...
}
}将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。
下面的方法仍然可以修改该 list。
1
2private final List myList = new ArrayList();
myList.add("Hello");声明为 final 可以保证如下操作不合法
1
2myList = new ArrayList();
myList = someOtherList;
如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。
- “ 其他方式 “ 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。
4、再思考一个有趣的现象
1 | byte b1=1; |
如果对b1 b2加上final就不会出错:
1 | final byte b1=1; |
7、JUC(java.util.concurrent)
0、JUC - 类汇总和学习总览
1、BAT大厂的面试问题
- JUC框架包含几个部分?
- 每个部分有哪些核心的类?
- 最最核心的类有哪些?
2、Overview
JUC相关的五大类与框架:
主要包含: (注意: 上图是网上找的图,无法表述一些继承关系,同时少了部分类;但是主体上可以看出其分类关系也够了)
- Lock框架和Tools类(把图中这两个放到一起理解)
- Collections: 并发集合
- Atomic: 原子类
- Executors: 线程池
3、相关类与框架
1、Lock框架和Tools类
类结构总览:
- 接口
- Condition
- Condition为接口类型,它将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。
- 其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。可以通过await(),signal()来休眠/唤醒线程。
- Lock
- Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象
- ReadWriteLock
- ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
- Condition
- 抽象类
- AbstractOwnableSynchonizer
- AbstractOwnableSynchonizer为抽象类,可以由线程以独占方式拥有的同步器。
- 此类为创建锁和相关同步器(伴随着所有权的概念)提供了基础。
- AbstractOwnableSynchronizer 类本身不管理或使用此信息。但是,子类和工具可以使用适当维护的值帮助控制和监视访问以及提供诊断。
- (Long)AbstractQueuedLongSynchonizer
- AbstractQueuedLongSynchronizer为抽象类,以 long 形式维护同步状态的一个 AbstractQueuedSynchronizer 版本。
- 此类具有的结构、属性和方法与 AbstractQueuedSynchronizer 完全相同,但所有与状态相关的参数和结果都定义为 long 而不是 int。
- 当创建需要 64 位状态的多级别锁和屏障等同步器时,此类很有用。
- 核心抽象类(int):AbstractQueuedSynchonizer
- AbstractQueuedSynchonizer为抽象类,其为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。
- 此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。
- AbstractOwnableSynchonizer
- 锁常用类
- LockSupport
- LockSupport为常用类,用来创建锁和其他同步类的基本线程阻塞原语。
- LockSupport的功能和”Thread中的 Thread.suspend()和Thread.resume()有点类似”,LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。
- 但是park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。
- ReentrantLock
- ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
- ReentrantReadWriteLock
- ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。
- StampedLock
- 它是java8在java.util.concurrent.locks新增的一个API。
- StampedLock控制锁有三种模式(写,读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。
- LockSupport
- 工具常用类
- CountDownLatch
- CountDownLatch为常用类,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。
- CyclicBarrier
- CyclicBarrier为常用类,其是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。
- 在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。
- Phaser
- Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
- Semaphore
- Semaphore为常用类,其是一个计数信号量,从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。
- 但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。
- Exchanger
- Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换。
- 它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
- CountDownLatch
2、Collections:并发集合
类结构关系:
- Queue
- ArrayBlockingQueue
- 一个由数组支持的有界阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。
- 队列的头部是在队列中存在时间最长的元素。队列的尾部是在队列中存在时间最短的元素。
- 新元素插入到队列的尾部,队列获取操作则是从队列头部开始获得元素。
- LinkedBlockingQueue
- 一个基于已链接节点的、范围任意的 blocking queue。此队列按 FIFO(先进先出)排序元素。
- 队列的头部是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。
- 新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。
- 链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。
- LinkedBlockingDeque
- 一个基于已链接节点的、任选范围的阻塞双端队列。
- ConcurrentLinkedQueue
- 一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。
- 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素。
- 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
- 当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。
- ConcurrentLinkedDeque
- 是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。
- DelayQueue
- 延时无界阻塞队列,使用Lock机制实现并发访问。
- 队列里只允许放可以“延期”的元素,队列中的head是最先“到期”的元素。
- 如果队里中没有元素到“到期”,那么就算队列中有元素也不能获取到。
- PriorityBlockingQueue
- 无界优先级阻塞队列,使用Lock机制实现并发访问。
- priorityQueue的线程安全版,不允许存放null值,依赖于comparable的排序,不允许存放不可比较的对象类型。
- SynchronousQueue
- 没有容量的同步队列,通过CAS实现并发访问,支持FIFO和FILO。
- LinkedTransferQueue
- JDK 7新增,单向链表实现的无界阻塞队列,通过CAS实现并发访问,队列元素使用 FIFO(先进先出)方式。
- LinkedTransferQueue可以说是ConcurrentLinkedQueue、SynchronousQueue(公平模式)和LinkedBlockingQueue的超集,它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。
- ArrayBlockingQueue
- List
- CopyOnWriteArrayList
- ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。
- 这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。
- CopyOnWriteArrayList
- Set
- CopyOnWriteArraySet
- 对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。
- 在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。
- ConcurrentSkipListSet
- 一个基于ConcurrentSkipListMap 的可缩放并发 NavigableSet 实现。set 的元素可以根据它们的自然顺序进行排序,也可以根据创建 set 时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
- CopyOnWriteArraySet
- Map
- ConcurrentHashMap
- 是线程安全HashMap的。ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。
- ConcurrentSkipListMap
- 线程安全的有序的哈希表(相当于线程安全的TreeMap);映射可以根据键的自然顺序进行排序,也可以根据创建映射时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
- ConcurrentHashMap
3、Atomic: 原子类
其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。
- 基础类型
- AtomicBoolean
- 针对bool的原子类。
- AtomicInteger
- 针对interger的原子类。
- AtomicLong
- 针对long的原子类。
- AtomicBoolean
- 数组
- AtomicIntegerArray
- AtomicLongArray
- BooleanArray
- 引用
- AtomicReference
- AtomicMarkedReference
- AtomicStampedReference
- FieldUpdater
- AtomicLongFieldUpdater
- AtomicIntegerFieldUpdater
- AtomicReferenceFieldUpdater
4、Executors:线程池
类结构关系:
- 接口:Executor
- Executor接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。
- ExecutorService
- ExecutorService继承自Executor接口,ExecutorService提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。
- ScheduledExecutorService
- ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。
- AbstractExecutorService
- AbstractExecutorService继承自ExecutorService接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。
- FutureTask
- FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。
- 如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。
- FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。
- 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。
- FutureTask 的线程安全由CAS来保证。
- 核心
- ThreadPoolExecutor
- ThreadPoolExecutor实现了AbstractExecutorService接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。
- 线程池可以解决两个不同问题:由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。
- ScheduledThreadExecutor
- ScheduledThreadPoolExecutor实现ScheduledExecutorService接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。
- Fork/Join框架
- ForkJoinPool 是JDK 7加入的一个线程池类。
- Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。
- 目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。
- ThreadPoolExecutor
- 工具类:Executors
- Executors是一个工具类,用其可以创建ExecutorService、ScheduledExecutorService、ThreadFactory、Callable等对象。
- 它的使用融入到了ThreadPoolExecutor、ScheduledThreadExecutor和ForkJoinPool中。
1、JUC概述
1、什么是JUC
在 Java 中,线程部分是一个重点,本篇文章说的 JUC 也是关于线程的。JUC就是 java.util .concurrent 工具包的简称。这是一个处理线程的工具包,JDK 1.5 开始出现的。
2、多线程编程步骤
- 第一:创建资源类,创建属性和操作方法
- 第二:在资源类的操作方法中
- 判断(使用while,不使用if,或者会出现虚假唤醒问题)
- 干活
- 通知
- 第三:创建多线程调用资源类的方法
- 第四:防止出现虚假唤醒问题
虚假唤醒问题
1、什么是虚假唤醒问题?
当一个条件满足时,很多线程都被唤醒了,但是只有其中部分是有用的唤醒,其它的唤醒都是无用的唤醒;这些无用的唤醒会导致出现一些问题。
2、为什么会出现虚假唤醒问题?
在多线程编程步骤的第二步中的判断中,如果使用的是if语句的话,就会出现虚假唤醒问题。原因:
- if语句块只会判断一次
- wait()方法的特性:在哪里等待,就在哪里开始
在官方文档中就明确规定要**==使用while语句块==,不要使用if语句块**:(因为while语句块可以不断的进行判断)
3、示例(参考下面进程间通信
的代码——将其中的while
修改为if
,并运行发现)
1 | A=>1 |
4、分析(假设一开始为0)
- 调用A –> 1
- 调用C –> 1 C wait
- 如果再调用A,那么A也会wait,A wait –> 1
- 再调用B减1后, –> 0
- 唤醒了A和C,执行A(C没抢到), –> 1
- A执行完后,C抢到了CPU,此时C没有再进行判断,直接执行+1操作, –> 2
5、使用wait/notify的正确姿势——防止虚假唤醒问题
1 | synchronized(lock) { |
2、JUC原子类:CAS,Unsafe和原子类详解
JUC中多数类是通过volatile和CAS来实现的,CAS本质上提供的是一种无锁方案,而Synchronized和Lock是互斥锁方案;java原子类本质上使用的是CAS,而CAS底层是通过Unsafe类实现的。
1、BAT大厂的面试问题
- 线程安全的实现方法有哪些?
- 什么是CAS?
- CAS使用示例,结合AtomicInteger给出示例?
- CAS会有哪些问题?
- 针对这这些问题,Java提供了哪几个解决的?
- AtomicInteger底层实现?
CAS
+volatile
- 请阐述你对Unsafe类的理解?
- 说说你对Java原子类的理解?包含13个,4组分类,说说作用和使用场景。
- AtomicStampedReference是什么?
- AtomicStampedReference是怎么解决ABA的?
- 内部使用Pair来存储元素值及其版本号
- java中还有哪些类可以解决ABA的问题?
AtomicMarkableReference
2、CAS
前面我们说到,线程安全的实现方法包含:
- 互斥同步:
synchronized
和ReentrantLock
- 非阻塞同步:
CAS
、AtomicXXXX
- 无同步方案:
栈封闭
、Thread Local
、可重入代码
1、什么是CAS
CAS的全称为Compare-And-Swap,直译就是对比交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。
简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
CAS操作是原子性的,所以多线程并发使用CAS更新数据时,可以不使用锁。JDK中大量使用了CAS来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。
相信sql大家都熟悉,类似sql中的条件更新一样:update set id=3 from table where id=2。因为单条sql执行具有原子性,如果有多个线程同时执行此sql语句,只有一条能更新成功。但如果有多条sql的话,要保证操作的原子性,就要使用事务了。
2、CAS使用实例
如果不使用CAS,在高并发下,多线程同时修改一个变量的值我们需要synchronized加锁(可能有人说可以用Lock加锁,Lock底层的AQS也是基于CAS进行获取锁的)。
1 | public class Test { |
java中为我们提供了AtomicInteger 原子类(底层基于CAS进行更新数据的),不需要加锁就在多线程并发场景下实现数据的一致性。
1 | public class Test { |
3、为什么无锁效率高
- 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
- 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
- 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
4、CAS 的特点
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
- CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思:
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5、CAS问题
CAS 方式为乐观锁,synchronized 为悲观锁。因此使用 CAS 解决并发问题通常情况下性能更优。
但使用 CAS 方式也会有几个问题:
- ABA问题
- 循环时间长开销大
- 只能保证一个共享变量的原子操作
1、ABA问题
因为CAS需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时则会发现它的值没有发生变化,但是实际上却变化了。
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。
从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference
来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2、循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。
pause指令有两个作用:
- 第一,它可以延迟流水线执行命令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
- 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3、只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。
还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。
从Java 1.5开始,JDK提供了AtomicReference
类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
3、Unsafe类详解
Java原子类是通过UnSafe类实现的,UnSafe类在J.U.C中CAS操作有很广泛的应用。
Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。
但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。
这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。
Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得:
1 | // 通过反射获取一个unsafe对象 |
先来看下这张图,对UnSafe类总体功能:
如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类。
1、Unsafe与CAS
反编译出来的代码:
1 | public final int getAndAddInt(Object paramObject, long paramLong, int paramInt) |
从源码中发现,**内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)**。
又从Unsafe类中发现,原子操作其实只支持下面3种CAS方法:(都是native方法)
compareAndSwapObject
compareAndSwapInt
compareAndSwapLong
1 | public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3); |
三个方法都是有四个参数,这四个参数的含义都是一样的,分别是:(以compareAndSwapInt
为例)
Object paramObject
:操作的对象long paramLong
:操作对象的操作域的偏移地址int paramInt1
:原值int paramInt2
:修改值
2、使用unsafe
我们使用反射得到的unsafe来完成一些操作
1、使用unsafe去修改一个对象的字段(域)的取值
1 | import lombok.Data; |
输出:
1 | Theater{id=1,name="张三"} |
2、使用unsafe模拟实现原值整数
1 | import cn.itcast.n4.UnsafeAccessor; |
2、Unsafe底层
Unsafe的compareAndSwap方法来实现CAS操作,它是一个本地方法,实现位于unsafe.cpp中。
1 | UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) |
可以看到它通过 Atomic::cmpxchg
来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。
如果是Linux的x86,Atomic::cmpxchg
方法的实现如下:
1 | inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { |
而windows的x86的实现如下:
1 | inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { |
如果是多处理器,为cmpxchg指令添加lock前缀。反之,就省略lock前缀(单处理器会不需要lock前缀提供的内存屏障效果)。这里的lock前缀就是使用了处理器的总线锁(最新的处理器都使用缓存锁代替总线锁来提高性能)。
cmpxchg(void* ptr, int old, int new),如果ptr和old的值一样,则把new写到ptr内存,否则返回ptr的值,整个操作是原子的。在Intel平台下,会用lock cmpxchg来实现,使用lock触发缓存锁,这样另一个线程想访问ptr的内存,就会被block住。
3、Unsafe其他功能
Unsafe 提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。
举两个例子,比方说:这个方法可以用来获取给定的 paramField 的内存地址偏移量,这个值对于给定的 field 是唯一的且是固定不变的。
1 | public native long staticFieldOffset(Field paramField); |
再比如说:前一个方法是用来获取数组第一个元素的偏移地址,后一个方法是用来获取数组的转换因子即数组中元素的增量地址的。
1 | public native int arrayBaseOffset(Class paramClass); |
最后看三个方法:分别用来分配内存,扩充内存和释放内存的。
1 | public native long allocateMemory(long paramLong); |
更多相关功能,推荐你看下这篇文章:来自美团技术团队:Java魔法类:Unsafe应用解析
4、AutomicIntrger
1、使用举例
以 AtomicInteger 为例,常用 API:
1 | public final int get():获取当前的值 |
相比 Integer 的优势,多线程中让变量自增:
1 | private volatile int count = 0; |
使用 AtomicInteger 后:
1 | private AtomicInteger count = new AtomicInteger(); |
2、源码解析
1 | public class AtomicInteger extends Number implements java.io.Serializable { |
我们可以看到 AtomicInteger 底层用的是volatile的变量和CAS来进行更改数据的。
- volatile保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值
- CAS 保证数据更新的原子性。
5、延伸到所有原子类:共13个
JDK中提供了13个原子操作类。
1、原子更新基本类型
使用原子的方式更新基本类型,Atomic包提供了以下3个类。
AtomicBoolean
:原子更新布尔类型。AtomicInteger
:原子更新整型。AtomicLong
:原子更新长整型。
以上3个类提供的方法几乎一模一样,可以参考上面AtomicInteger中的相关方法。
其它方法:
1 | AtomicInteger i = new AtomicInteger(0); |
2、原子更新数组
通过原子的方式更新数组里的某个元素,Atomic包提供了以下的4个类:
AtomicIntegerArray
:原子更新整型数组里的元素。AtomicLongArray
:原子更新长整型数组里的元素。AtomicReferenceArray
:原子更新引用类型数组里的元素。这三个类的最常用的方法是如下两个方法:
get(int index)
:获取索引为index的元素值。compareAndSet(int i, E expect, E update)
:如果当前值等于预期值,则以原子方式将数组位置i的元素设置为update值。
举个AtomicIntegerArray例子:
1 | import java.util.concurrent.atomic.AtomicIntegerArray; |
1 | [0, 0] |
3、原子更新引用类型
Atomic包提供了以下三个类:
AtomicReference
:原子更新引用类型。AtomicStampedReference
:原子更新引用类型,内部使用Pair来存储元素值及其版本号。(可以解决CAS的ABA问题)AtomicMarkableReferce
:原子更新带有标记位的引用类型。- 有时候,并不关心引用变量更改了几次(ABA问题),只是单纯的关心是否更改过,所以就有了
AtomicMarkableReference
- 有时候,并不关心引用变量更改了几次(ABA问题),只是单纯的关心是否更改过,所以就有了
这三个类提供的方法都差不多:
- 首先构造一个引用对象;
- 然后把引用对象set进Atomic类;
- 然后调用compareAndSet等一些方法去进行原子操作。
原理都是基于Unsafe实现,但AtomicReferenceFieldUpdater
略有不同,更新的字段必须用volatile
修饰。
举个AtomicReference
例子:
1 | import java.util.concurrent.atomic.AtomicReference; |
1 | p3 is id:102 |
结果说明:
- 新建AtomicReference对象ar时,将它初始化为p1。
- 紧接着,通过CAS函数对它进行设置。如果ar的值为p1的话,则将其设置为p2。
- 最后,获取ar对应的对象,并打印结果。p3.equals(p2)的结果为false。
- 这是因为Person并没有覆盖equals()方法,而是采用继承自Object.java的equals()方法;而Object.java中的equals()实际上是调用”==”去比较两个对象,即比较两个对象的地址是否相等。
举个AtomicMarkableReference
例子:
1 | import lombok.extern.slf4j.Slf4j; |
4、原子更新字段类(原子更新器)
Atomic包提供了四个类进行原子字段更新:
AtomicIntegerFieldUpdater
:原子更新整型的字段的更新器。AtomicLongFieldUpdater
:原子更新长整型字段的更新器。AtomicStampedFieldUpdater
:原子更新带有版本号的引用类型。AtomicReferenceFieldUpdater
:上面已经说过此处不在赘述。
这四个类的使用方式都差不多,是基于反射的原子更新字段的值。要想原子地更新字段类需要两步:
- 第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。
- 第二步,更新类的字段必须使用public volatile修饰。
举个例子:
1 | public class TestAtomicIntegerFieldUpdater { |
再说下对于AtomicIntegerFieldUpdater
的使用稍微有一些限制和约束,约束如下:
- 字段必须是volatile类型的,在线程之间共享变量时保证立即可见。
- eg:volatile int value = 3
- 字段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。但是对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
- 只能是实例变量,不能是类变量,也就是说不能加static关键字。
- 只能是可修改变量,不能是final变量,因为final的语义就是不可修改。实际上final的语义和volatile是有冲突的,这两个关键字不能同时存在。
- 对于AtomicIntegerFieldUpdater和AtomicLongFieldUpdater只能修改int/long类型的字段,不能修改其包装类型(Integer/Long)。如果要修改包装类型就需要使用AtomicReferenceFieldUpdater。
5、原子累加器
原子类型累加器是JDK1.8引进的并发新技术,它可以看做AtomicLong和AtomicDouble的部分加强类型。
原子类型累加器有如下四种:
DoubleAccumulator
DoubleAdder
LongAccumulator
LongAdder
由于上面四种累加器的原理类似,下面以LongAdder为列来介绍累加器的使用。
已经有AtomicLong的getAndIncrement()方法进行累加效果,为什么还要有LongAdder累加器?
我们知道,AtomicLong是利用了底层的CAS操作来提供并发性的,比如addAndGet方法:
public final long addAndGet(long delta) { return unsafe.getAndAddLong(this, valueOffset, delta) + delta; }
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
- 上述方法调用了**Unsafe**类的**getAndAddLong**方法,该方法是个**native**方法,它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。
- 在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时**AtomicLong**的自旋会成为瓶颈。
- 这就是**LongAdder**引入的初衷——解决高并发环境下**AtomicLong**的自旋瓶颈问题。
- 而**LongAdder**的基本思路就是**分散热点**,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。
- 如果要获取真正的long值,只要将各个槽中的变量值累加返回。
- ConcurrentHashMap中的“分段锁”其实就是类似的思路。
###### 1、累加器性能比较——比较 AtomicLong 与 LongAdder
```java
private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {
T adder = adderSupplier.get();
long start = System.nanoTime();
List<Thread> ts = new ArrayList<>(); // 4 个线程,每人累加 50 万
for (int i = 0; i < 40; i++) {
ts.add(new Thread(() -> {
for (int j = 0; j < 500000; j++) {
action.accept(adder);
}
}));
}
ts.forEach(t -> t.start());
ts.forEach(t -> {
try {
join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
System.out.println(adder + " cost:" + (end - start)/1000_000);
}
比较 AtomicLong 与 LongAdder:
1 | for (int i = 0; i < 5; i++) { |
输出:
1 | 1000000 cost:43 |
性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。
2、LongAdder源码
LongAdder 是并发大师 @author Doug Lea (大哥李)的作品,设计的非常精巧
类的继承关系:
1 | public class LongAdder extends Striped64 implements Serializable {...} |
LongAdder 类有几个关键域:(这几个的关键域定义在Striped64抽象类中)
1 | // 累加单元数组, 懒惰初始化 |
其中 Cell 即为累加单元
1 | // 防止缓存行伪共享 |
3、缓存(伪共享问题)
缓存与内存的速度比较:
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。
而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)
缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中
CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。
可以通过缓存一致性协议(MESI)
保证:
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议
。
所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
伪共享:
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行可以存下 2 个的 Cell 对象。这样问题来了:
- Core-0 要修改 Cell[0]
- Core-1 要修改 Cell[1]
无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中 Cell[0]=6000, Cell[1]=8000 要累加Cell[0]=6001, Cell[1]=8000 Q,这时会让 Core-1 的缓存行失效。这种问题被叫做伪共享问题。
@sun.misc.Contended 用来解决这个伪共享问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效
4、核心方法——increment()
1 | public void increment() { |
说明:increment()方法调用了add()方法
1 | public void add(long x) { |
add 流程图:
说明:
- cells是懒惰式创建的,当有竞争的才会创建cells数组,进而创建cells数组里面的cell对象
- 当cells为空是说明当前竞争并不激烈,累加操作交给
base
去操作- 成功:返回
- 失败:进入longAccumulate()方法
- 当cells不为空说明当前存在竞争,查看当前线程cell是否创建
- 没创建:进入longAccumulate()方法创建cell
- 创建:累加操作交给创建的
cell
去操作- 成功:返回
- 失败:进入longAccumulate()方法
由此刻看出,当累加失败或者没有创建cell时都会调用longAccumulate
()方法,以下为longAccumulate()方法源码:
1 | final void longAccumulate(long x, LongBinaryOperator fn, |
longAccumulate流程图:
1 | // 加锁成功,进入下面else if块的逻辑:创建cells |
1 | // cells不为空且cells的长度大于0:创建cell |
1 | // cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null |
每个线程刚进入 longAccumulate 时,会尝试对应一个 cell 对象(找到一个坑位)
获取最终结果通过 sum 方法:
1 | public long sum() { |
6、再说AutomicStampedReference解决CAS的ABA问题
1、AutomicStampedReference解决CAS的ABA问题
AtomicStampedReference主要维护包含一个对象引用以及一个可以自动更新的整数”stamp”的pair对象来解决ABA问题。
1 | public class AtomicStampedReference<V> { |
- 如果元素值和版本号都没有变化,并且和新的也相同,返回true;
- 如果元素值和版本号都没有变化,并且和新的不完全相同,就构造一个新的Pair对象并执行CAS更新pair。
可以看到,java中的实现跟我们上面讲的ABA的解决方法是一致的。
- 首先,使用版本号控制;
- 其次,不重复使用节点(Pair)的引用,每次都新建一个新的Pair来作为CAS比较的对象,而不是复用旧的;
- 最后,外部传入元素值及版本号,而不是节点(Pair)的引用。
2、使用举例
1 | private static AtomicStampedReference<Integer> atomicStampedRef = |
1 | // 输出 |
3、java中还有哪些类可以解决ABA问题?
AtomicMarkableReference
,它不是维护一个版本号,而是维护一个boolean类型的标记,标记值有修改,了解一下。
4、在日常的业务中怎么解决ABA问题?(用乐观锁的方法)
- 加标志位做版本号,例如搞个自增的字段,操作一次就自增加一;
- 加个时间戳,比较时间戳的值
3、JUC锁:LockSupport详解
LockSupport是锁中的基础,是一个提供锁机制的工具类。
1、BAT大厂的面试问题
- 为什么LockSupport也是核心基础类?
- AQS框架借助于两个类:
Unsafe(提供CAS操作)
和LockSupport(提供park/unpark操作)
- AQS框架借助于两个类:
- 写出分别通过wait/notify和LockSupport的park/unpark实现同步?
- LockSupport.park()会释放锁资源吗?那么Condition.await()呢?
- Thread.sleep()、Object.wait()、Condition.await()、LockSupport.park()的区别?重点
- 如果在wait()之前执行了notify()会怎样?
- 如果在park()之前执行了unpark()会怎样?
2、LockSupport简介
LockSupport用来创建锁和其他同步类的基本线程阻塞原语。简而言之,当调用LockSupport.park时,表示当前线程将会等待,直至获得许可,当调用LockSupport.unpark时,必须把等待获得许可的线程作为参数进行传递,好让此线程继续运行。
3、LockSupport源码分析
1、类的属性
1 | public class LockSupport { |
说明:UNSAFE字段表示sun.misc.Unsafe类,查看其源码,点击在这里,一般程序中不允许直接调用,而long型的表示实例对象相应字段在内存中的偏移地址,可以通过该偏移地址获取或者设置该字段的值。
2、类的构造函数
1 | // 私有构造函数,无法被实例化 |
说明:LockSupport只有一个私有构造函数,无法被实例化。
3、核心函数分析
在分析LockSupport函数之前,先引入sun.misc.Unsafe类中的park和unpark函数,因为LockSupport的核心函数都是基于Unsafe类中定义的park和unpark函数,下面给出两个函数的定义:
1 | public native void park(boolean isAbsolute, long time); |
说明:对两个函数的说明如下:
- park函数,阻塞线程,并且该线程在下列情况发生之前都会被阻塞:
- 调用unpark函数,释放该线程的许可。
- 该线程被中断。
- 设置的时间到了。并且,当time为绝对时间时,isAbsolute为true,否则,isAbsolute为false。当time为0时,表示无限等待,直到unpark发生。
- unpark函数,释放线程的许可,即激活调用park后阻塞的线程。这个函数不是安全的,调用这个函数时要确保线程依旧存活。
1、park函数
park函数有两个重载版本,方法摘要如下:
1 | public static void park(); |
说明:两个函数的区别在于park()函数没有没有blocker,即没有设置线程的parkBlocker字段。park(Object)型函数如下:
1 | public static void park(Object blocker) { |
说明:调用park函数时,首先获取当前线程,然后设置当前线程的parkBlocker字段,即调用setBlocker函数,之后调用Unsafe类的park函数,之后再调用setBlocker函数。
那么问题来了,为什么要在此park函数中要调用两次setBlocker函数呢?
原因其实很简单,调用park函数时,当前线程首先设置好parkBlocker字段,然后再调用Unsafe的park函数,此后,当前线程就已经阻塞了,等待该线程的unpark函数被调用,所以后面的一个setBlocker函数无法运行,unpark函数被调用,该线程获得许可后,就可以继续运行了,也就运行第二个setBlocker,把该线程的parkBlocker字段设置为null,这样就完成了整个park函数的逻辑。
如果没有第二个setBlocker,那么之后没有调用park(Object blocker),而直接调用getBlocker函数,得到的还是前一个park(Object blocker)设置的blocker,显然是不符合逻辑的。总之,必须要保证在park(Object blocker)整个函数执行完后,该线程的parkBlocker字段又恢复为null。所以,park(Object)型函数里必须要调用setBlocker函数两次。
setBlocker方法如下:
1 | private static void setBlocker(Thread t, Object arg) { |
说明:此方法用于设置线程t的parkBlocker字段的值为arg。
另外一个无参重载版本,park()函数如下:
1 | public static void park() { |
说明:调用了park函数后,会禁用当前线程,除非许可可用。在以下三种情况之一发生之前,当前线程都将处于休眠状态,即下列情况发生时,当前线程会获取许可,可以继续运行:
- 其他某个线程将当前线程作为目标调用 unpark。
- 其他某个线程中断当前线程。
- 该调用不合逻辑地(即毫无理由地)返回。
2、parkNanos函数
此函数表示在许可可用前禁用当前线程,并最多等待指定的等待时间。具体函数如下:
1 | public static void parkNanos(Object blocker, long nanos) { |
说明:该函数也是调用了两次setBlocker函数,nanos参数表示相对时间,表示等待多长时间。
3、parkUntil函数
此函数表示在指定的时限前禁用当前线程,除非许可可用,具体函数如下:
1 | public static void parkUntil(Object blocker, long deadline) { |
说明:该函数也调用了两次setBlocker函数,deadline参数表示绝对时间,表示指定的时间。
4、unpark函数
此函数表示如果给定线程的许可尚不可用,则使其可用。如果线程在 park 上受阻塞,则它将解除其阻塞状态。否则,保证下一次调用 park 不会受阻塞。如果给定线程尚未启动,则无法保证此操作有任何效果。具体函数如下:
1 | public static void unpark(Thread thread) { |
说明:释放许可,指定线程可以继续运行。
4、park/unpark 原理
每个线程都有自己的一个 Parker 对象(有C++编写),由三部分组成 _counter
, _cond
和 _mutex
打个比喻
- 线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
- 调用 park 就是要看需不需要停下来歇息
- 如果备用干粮耗尽,那么钻进帐篷歇息
- 如果备用干粮充足,那么不需停留,继续前进
- 调用 unpark,就好比令干粮充足
- 如果这时线程还在帐篷,就唤醒让他继续前进
- 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
- 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
1、先调用park()
- 当前线程调用 Unsafe.park() 方法
- 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
- 线程进入 _cond 条件变量阻塞
- 设置 _counter = 0
2、再调用unpark():
- 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
- 唤醒 _cond 条件变量中的 Thread_0
- Thread_0 恢复运行
- 设置 _counter 为 0
3、先调用unpark(),再调用park():
- 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
- 当前线程调用 Unsafe.park() 方法
- 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
- 设置 _counter 为 0
5、LockSupport示例说明
1、使用wait/notify实现线程同步
1 | class MyThread extends Thread { |
运行结果:
1 | before wait |
说明:具体的流程图如下:
使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。具体代码如下:
1 | class MyThread extends Thread { |
运行结果:
1 | before notify |
说明:由于先调用了notify,再调用的wait,此时主线程还是会一直阻塞。
2、使用park/unpark实现线程同步
1 | import java.util.concurrent.locks.LockSupport; |
运行结果:
1 | before park |
说明:本程序先执行park,然后在执行unpark,进行同步,并且在unpark的前后都调用了getBlocker,可以看到两次的结果不一样,并且第二次调用的结果为null,这是因为在调用unpark之后,执行了Lock.park(Object blocker)函数中的setBlocker(t, null)函数,所以第二次调用getBlocker时为null。
上例是先调用park,然后调用unpark,现在修改程序,先调用unpark,然后调用park,看能不能正确同步。具体代码如下:
1 | import java.util.concurrent.locks.LockSupport; |
运行结果:
1 | before unpark |
说明:可以看到,在先调用unpark,再调用park时,仍能够正确实现同步,不会造成由wait/notify调用顺序不当所引起的阻塞。因此park/unpark相比wait/notify更加的灵活。
3、中断响应
1 | import java.util.concurrent.locks.LockSupport; |
运行结果:
1 | before park |
说明:可以看到,在主线程调用park阻塞后,在myThread线程中发出了中断信号,此时主线程会继续运行,也就是说明此时interrupt起到的作用与unpark一样。
6、更深入的理解
1、Thread.sleep()和Object.wait()的区别
首先,我们先来看看Thread.sleep()和Object.wait()的区别,这是一个烂大街的题目了,大家应该都能说上来两点:
- Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁;
- Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去;
- Thread.sleep()到时间了会自动唤醒,然后继续执行;
- Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒;
- Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁;
其实,他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。
2、Thread.sleep()和Condition.await()的区别
Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。
实际上,它在阻塞当前线程之前还干了两件事:
- 一是把当前线程添加到条件队列中
- 二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。
3、Thread.sleep()和LockSupport.park()的区别
LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。
- 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
- Thread.sleep()没法从外部唤醒,只能自己醒过来;
- LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
- Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出;
- LockSupport.park()方法不需要捕获中断异常;
- Thread.sleep()本身就是一个native方法;
- LockSupport.park()底层是调用的Unsafe的native方法;
4、Object.wait()和LockSupport.park()的区别
二者都会阻塞当前线程的运行,他们有什么区别呢? 经过上面的分析相信你一定很清楚了,真的吗? 往下看!
- Object.wait()方法需要在synchronized块中执行;
- LockSupport.park()可以在任意地方执行;
- Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出;
- LockSupport.park()不需要捕获中断异常;
- Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;
- LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
- 如果在wait()之前执行了notify()会怎样? 抛出IllegalMonitorStateException异常;
- 如果在park()之前执行了unpark()会怎样? 线程不会被阻塞,直接跳过park(),继续执行后续内容;
park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。
5、LockSupport.park()会释放锁资源吗?
不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。
4、AbstractQueuedSynchronizer(AQS)
AbstractQueuedSynchronizer抽象类是核心,需要重点掌握。它提供了一个基于FIFO队列,可以用于构建锁或者其他相关同步装置的基础框架。
1、BAT大厂的面试问题
- 什么是AQS?为什么它是核心?
- AQS的核心思想是什么?它是怎么实现的?底层数据结构等
- AQS有哪些核心的方法?
- AQS定义什么样的资源获取方式?
- AQS定义了两种资源获取方式:
独占
(只有一个线程能访问执行,又根据是否按队列的顺序分为公平锁
和非公平锁
,如ReentrantLock
)共享
(多个线程可同时访问执行,如Semaphore
、CountDownLatch
、CyclicBarrier
)。ReentrantReadWriteLock
可以看成是组合式,允许多个线程同时对某一资源进行读。
- AQS定义了两种资源获取方式:
- AQS底层使用了什么样的设计模式?
- 模板
- AQS的应用示例?
2、AbstractQueuedSynchronizer简介
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。
1、AQS核心思想
AQS核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。
AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
1 | private volatile int state;//共享变量,使用volatile修饰保证线程可见性 |
状态信息通过protected类型的getState,setState,compareAndSetState进行操作:
1 | //返回同步状态的当前值 |
2、AQS对资源的共享方式
AQS定义两种资源共享方式:
- Exclusive(独占):只有一个线程能执行,如ReentrantLock。
- 又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的(抢不到就乖乖排队吧)
- 又可分为公平锁和非公平锁:
- Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。
ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。
3、AQS底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用)
使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放) 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这和我们以往通过实现接口的方式有很大区别。
AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
1 | isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。 |
默认情况下,每个方法都抛出 UnsupportedOperationException
。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。
以ReentrantLock为例:
- state初始化为0,表示未锁定状态。
- A线程lock()时,会调用tryAcquire()独占该锁并将state+1。
- 此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。
- 当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
4、总结
AbstractQueuedSynchronizer是阻塞式锁和相关的同步器工具的框架,特点:
- 用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
- getState - 获取 state 状态
- setState - 设置 state 状态
- compareAndSetState - cas 机制设置 state 状态
- 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
- 提供了基于 FIFO 的等待队列,类似于 Monitor 的
EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的
WaitSet
- 子类主要实现这样一些方法(默认抛出
UnsupportedOperationException
)tryAcquire
tryRelease
tryAcquireShared
tryReleaseShared
isHeldExclusively
3、AbstractQueuedSynchronizer数据结构
AbstractQueuedSynchronizer类底层的数据结构是使用CLH(Craig,Landin,and Hagersten)队列
是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
- 其中
Sync queue
,即同步队列,是双向链表,包括head结点和tail结点,head结点主要用作后续的调度。 - 而
Condition queue
不是必须的,其是一个单向链表,只有当使用Condition时,才会存在此单向链表。并且可能会有多个Condition queue。
4、AbstractQueuedSynchronizer源码分析
1、类的继承关系
AbstractQueuedSynchronizer继承自AbstractOwnableSynchronizer抽象类,并且实现了Serializable接口,可以进行序列化。
1 | public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable |
其中AbstractOwnableSynchronizer抽象类的源码如下:
1 | public abstract class AbstractOwnableSynchronizer implements java.io.Serializable { |
AbstractOwnableSynchronizer抽象类中,可以设置独占资源线程和获取独占资源线程。分别为setExclusiveOwnerThread与getExclusiveOwnerThread方法,这两个方法会被子类调用。
2、类的内部类
AbstractQueuedSynchronizer类有两个内部类,分别为Node类与ConditionObject类。
3、类的内部类——Node类
1 | static final class Node { |
每个线程被阻塞的线程都会被封装成一个Node结点,放入队列。每个节点包含了一个Thread类型的引用,并且每个节点都存在一个状态,具体状态如下:
CANCELLED
,值为1,表示当前的线程被取消。SIGNAL
,值为-1,表示当前节点的后继节点包含的线程需要运行,需要进行unpark操作。CONDITION
,值为-2,表示当前节点在等待condition,也就是在condition queue中。PROPAGATE
,值为-3,表示当前场景下后续的acquireShared能够得以执行。- 值为0,表示当前节点在sync queue中,等待着获取锁。
4、类的内部类——ConditionObject类
这个类有点长,耐心看下:
1 | // 内部类 |
此类实现了Condition接口,Condition接口定义了条件操作规范,具体如下:
1 | public interface Condition { |
Condition接口中定义了await、signal方法,用来等待条件、释放条件。之后会详细分析CondtionObject的源码。
5、类的属性
属性中包含了头结点head
,尾结点tail
,状态state
、自旋时间spinForTimeoutThreshold
,还有AbstractQueuedSynchronizer抽象的属性在内存中的偏移地址
,通过该偏移地址,可以获取和设置该属性的值,同时还包括一个静态初始化块,用于加载内存偏移地址
。
1 | public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer |
6、类的构造方法
此类构造方法为从抽象构造方法,供子类调用:
1 | protected AbstractQueuedSynchronizer() {} |
7、类的核心方法——acquire方法
该方法以独占模式获取(资源),忽略中断,即线程在aquire过程中,中断此线程是无效的。源码如下:
1 | public final void acquire(int arg) { |
由上述源码可以知道,当一个线程调用acquire时,调用方法流程如下:
- 首先调用tryAcquire方法,调用此方法的线程会试图在独占模式下获取对象状态。此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。在AbstractQueuedSynchronizer源码中默认会抛出一个异常,即需要子类去重写此方法完成自己的逻辑。之后会进行分析。
- 若tryAcquire失败,则调用addWaiter方法,addWaiter方法完成的功能是将调用此方法的线程封装成为一个结点并放入Sync queue。
- 调用acquireQueued方法,此方法完成的功能是Sync queue中的结点不断尝试获取资源,若成功,则返回true,否则,返回false。
- 由于tryAcquire默认实现是抛出异常,所以此时,不进行分析,之后会结合一个例子进行分析。
首先分析addWaiter方法:
1 | // 添加等待者 |
addWaiter方法使用快速添加的方式往sync queue尾部添加结点,如果sync queue队列还没有初始化,则会使用enq插入队列中,enq方法源码如下:
1 | private Node enq(final Node node) { |
enq方法会使用无限循环来确保节点的成功插入。
现在,分析acquireQueue方法。其源码如下:
1 | // sync队列中的结点在独占且忽略中断的模式下获取(资源) |
首先获取当前节点的前驱节点,如果前驱节点是头结点并且能够获取(资源),代表该当前节点能够占有锁,设置头结点为当前节点,返回。否则,调用shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,首先,我们看shouldParkAfterFailedAcquire方法,代码如下:
1 | // 当获取(资源)失败后,检查并且更新结点状态 |
只有当该节点的前驱结点的状态为SIGNAL时,才可以对该结点所封装的线程进行park操作。否则,将不能进行park操作。再看parkAndCheckInterrupt方法,源码如下:
1 | // 进行park操作并且返回该线程是否被中断 |
parkAndCheckInterrupt方法里的逻辑是首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。再看final块中的cancelAcquire方法,其源码如下:
1 | // 取消继续获取(资源) |
该方法完成的功能就是取消当前线程对资源的获取,即设置该结点的状态为CANCELLED,接着我们再看unparkSuccessor方法,源码如下:
1 | // 释放后继结点 |
该方法的作用就是为了释放node节点的后继结点。
对于cancelAcquire与unparkSuccessor方法,如下示意图可以清晰的表示:
其中node为参数,在执行完cancelAcquire方法后的效果就是unpark了s结点所包含的t4线程。
现在,再来看acquireQueued方法的整个的逻辑。逻辑如下:
- 判断结点的前驱是否为head并且是否成功获取(资源)。
- 若步骤1均满足,则设置结点为head,之后会判断是否finally模块,然后返回。
- 若步骤2不满足,则判断是否需要park当前线程,是否需要park当前线程的逻辑是判断结点的前驱结点的状态是否为SIGNAL,若是,则park当前结点,否则,不进行park操作。
- 若park了当前线程,之后某个线程对本线程unpark后,并且本线程也获得机会运行。那么,将会继续进行步骤①的判断。
注意:
- 是否需要 unpark 是由当前节点的前驱节点的 waitStatus == Node.SIGNAL 来决定,而不是本节点的 waitStatus 决定
8、类的核心方法——release方法
以独占模式释放对象,其源码如下:
1 | public final boolean release(int arg) { |
其中,tryRelease的默认实现是抛出异常,需要具体的子类实现,如果tryRelease成功,那么如果头结点不为空并且头结点的状态不为0,则释放头结点的后继结点,unparkSuccessor方法已经分析过,不再累赘。
除了release()方法之外,还有一个方法——fullyRelease()用来释放锁:因为某线程可能重入,需要将 state 全部释放
1 | // 因为某线程可能重入,需要将 state 全部释放 |
对于其他方法我们也可以分析,与前面分析的方法大同小异,所以,不再累赘。
5、AbstractQueuedSynchronizer示例详解一
借助下面示例来分析AbstractQueuedSyncrhonizer内部的工作机制。示例源码如下:
1 | import java.util.concurrent.locks.Lock; |
运行结果(可能的一种):
1 | Thread[t1,5,main] running |
结果分析:从示例可知,线程t1与t2共用了一把锁,即同一个lock。可能会存在如下一种时序:
说明:首先线程t1先执行lock.lock操作,然后t2执行lock.lock操作,然后t1执行lock.unlock操作,最后t2执行lock.unlock操作。基于这样的时序,分析AbstractQueuedSynchronizer内部的工作机制:
- t1线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:其中,前面的部分表示哪个类,后面是具体的类中的哪个方法,AQS表示AbstractQueuedSynchronizer类,AOS表示AbstractOwnableSynchronizer类。
- t2线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:经过一系列的方法调用,最后达到的状态是禁用t2线程,因为调用了LockSupport.lock。
- t1线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:t1线程中调用lock.unlock后,经过一系列的调用,最终的状态是释放了许可,因为调用了LockSupport.unpark。这时,t2线程就可以继续运行了。此时,会继续恢复t2线程运行环境,继续执行LockSupport.park后面的语句,即进一步调用如下:
- 说明:在上一步调用了LockSupport.unpark后,t2线程恢复运行,则运行parkAndCheckInterrupt,之后,继续运行acquireQueued方法,最后达到的状态是头结点head与尾结点tail均指向了t2线程所在的结点,并且之前的头结点已经从sync队列中断开了。
- t2线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:t2线程执行lock.unlock后,最终达到的状态还是与之前的状态一样。
6、AbstractQueuedSynchronizer示例详解二
下面我们结合Condition实现生产者与消费者,来进一步分析AbstractQueuedSynchronizer的内部工作机制。
Depot(仓库)类:
1 | import java.util.concurrent.locks.Condition; |
测试类:
1 | class Consumer { |
运行结果(可能的一种):
1 | produce = 500, size = 500 |
说明:根据结果,我们猜测一种可能的时序如下:
说明:p1代表produce 500的那个线程,p2代表produce 200的那个线程,c1代表consume 500的那个线程,c2代表consume 200的那个线程。
- p1线程调用lock.lock,获得锁,继续运行,方法调用顺序在前面已经给出。
- p2线程调用lock.lock,由前面的分析可得到如下的最终状态:
- 说明:p2线程调用lock.lock后,会禁止p2线程的继续运行,因为执行了LockSupport.park操作。
- c1线程调用lock.lock,由前面的分析得到如下的最终状态:
- 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含p2的结点)的waitStatus变为了SIGNAL。
- c2线程调用lock.lock,由前面的分析得到如下的最终状态:
- 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含c1的结点)的waitStatus变为了SIGNAL。
- p1线程执行emptyCondition.signal,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:AQS.CO表示AbstractQueuedSynchronizer.ConditionObject类。此时调用signal方法不会产生任何其他效果。
- p1线程执行lock.unlock,根据前面的分析可知,最终的状态如下:
- 说明:此时,p2线程所在的结点为头结点,并且其他两个线程(c1、c2)依旧被禁止,所以,此时p2线程继续运行,执行用户逻辑。
- p2线程执行fullCondition.await,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:最终到达的状态是新生成了一个结点,包含了p2线程,此结点在condition queue中;并且sync queue中p2线程被禁止了,因为在执行了LockSupport.park操作。从方法一些调用可知,在await操作中线程会释放锁资源,供其他线程获取。同时,head结点后继结点的包含的线程的许可被释放了,故其可以继续运行。由于此时,只有c1线程可以运行,故运行c1。
- 继续运行c1线程,c1线程由于之前被park了,所以此时恢复,继续之前的步骤,即还是执行前面提到的acquireQueued方法,之后,c1判断自己的前驱结点为head,并且可以获取锁资源,最终到达的状态如下:
- 说明:其中,head设置为包含c1线程的结点,c1继续运行。
- c1线程执行fullCondtion.signal,其方法调用顺序如下,只给出了主要的方法调用:
- 说明:signal方法达到的最终结果是将包含p2线程的结点从condition queue中转移到sync queue中,之后condition queue为null,之前的尾结点的状态变为SIGNAL。
- c1线程执行lock.unlock操作,根据之前的分析,经历的状态变化如下:
- 说明:最终c2线程会获取锁资源,继续运行用户逻辑。
- c2线程执行emptyCondition.await,由前面的第七步分析,可知最终的状态如下:
- 说明:await操作将会生成一个结点放入condition queue中与之前的一个condition queue是不相同的,并且unpark头结点后面的结点,即包含线程p2的结点。
- p2线程被unpark,故可以继续运行,经过CPU调度后,p2继续运行,之后p2线程在AQS:await方法中被park,继续AQS.CO:await方法的运行,其方法调用顺序如下,只给出了主要的方法调用:
- p2继续运行,执行emptyCondition.signal,根据第九步分析可知,最终到达的状态如下:
- 说明:最终,将condition queue中的结点转移到sync queue中,并添加至尾部,condition queue会为空,并且将head的状态设置为SIGNAL。
- p2线程执行lock.unlock操作,根据前面的分析可知,最后的到达的状态如下:
- 说明: unlock操作会释放c2线程的许可,并且将头结点设置为c2线程所在的结点。
- c2线程继续运行,执行fullCondition. signal,由于此时fullCondition的condition queue已经不存在任何结点了,故其不会产生作用。
- c2执行lock.unlock,由于c2是sync队列中最后一个结点,故其不会再调用unparkSuccessor了,直接返回true。即整个流程就完成了。
- 说明: unlock操作会释放c2线程的许可,并且将头结点设置为c2线程所在的结点。
7、AbstractQueuedSynchronizer总结
对于AbstractQueuedSynchronizer的分析,最核心的就是sync queue的分析。
- 每一个结点都是由前一个结点唤醒;
- 当结点发现前驱结点是head并且尝试获取成功,则会轮到该线程运行。
- condition queue中的结点向sync queue中转移是通过signal操作完成的。
- 当结点的状态为SIGNAL时,表示后面的结点需要运行。
8、使用AQS自定义同步器——实现不可重入锁
自定义锁(不可重入锁):
1 | // 自定义锁(不可重入锁) |
测试:
1 | public class TestAqs { |
5、Lock接口
类结构总览:
1、什么是Lock
Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象。
Lock 锁实现提供了比使用同步方法和语句可以获得的更广泛的锁操作。它们允许更灵活的结构,可能具有非常不同的属性,并且可能支持多个关联的条件对象。Lock 提供了比 synchronized 更多的功能。
2、Lock接口(源码)
1 | public interface Lock { |
下面来逐个讲述 Lock 接口中每个方法的使用。
1、lock()方法 与 unlock()方法
- lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。
- lock锁是不会被打断interrupt的,要想被interrupt打断的话,就得使用
lock.lockInterruptibly()
进行上锁
- lock锁是不会被打断interrupt的,要想被interrupt打断的话,就得使用
- unlock()方法也是平常使用得最多的一个方法,就是用来释放锁。一般与lock()方法搭配使用。
采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用 Lock 必须在 try{}catch{}finally{}块中进行,并且将释放锁的操作放在finally 块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用 Lock来进行同步的话,是以下面这种形式去使用的:
1 | Lock lock = ...;// 具体实现类 |
如果要预防可能发生的死锁,可以尝试使用下面这个方法:tryLock(long time, TimeUnit unit)方法
2、tryLock()方法 与 tryLock(long time, TimeUnit unit)方法
tryLock()方法:尝试获取锁,返回一个boolean值
tryLock(long time, TimeUnit unit)方法:尝试获取锁,可以设置超时
这是一个比单纯lock()更具有工程价值的方法,如果大家阅读过JDK的一些内部代码,就不难发现,tryLock()在JDK内部被大量的使用。
Lock可以通过这两个方法拿到当前线程的锁的状态,并且可以数组超时时间。并根据当前线程是否获得锁,做不同的选择:如果成功获取锁,….,如果获取失败,…..
与lock()相比,tryLock()至少有下面一个好处:
- 可以不用进行无限等待。直接打破形成死锁的条件。如果一段时间等不到锁,可以直接放弃,同时释放自己已经得到的资源。这样,就可以在很大程度上,避免死锁的产生。因为线程之间出现了一种谦让机制。(这也是解决死锁问题的一种方案)
- 可以在应用程序这层进行进行自旋,你可以自己决定尝试几次,或者是放弃。
- 等待锁的过程中可以响应中断interrupt,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况。
- 对于tryLock(空参)来说特别适合在应用层自己对锁进行管理,在应用层进行自旋等待。
3、newCondition()方法
lock可以通过newCondition()方法获得一个Condition对象。
关键字 synchronized 与 wait()/notify()这两个方法一起使用可以实现等待/通知模式, Lock 锁通过 newContition()方法返回 Condition 对象,Condition 类也可以实现等待/通知模式。
用 notify()通知时,JVM 会随机唤醒某个等待的线程, 使用 Condition 类可以进行选择性通知, Condition 比较常用的三个方法:
await()
:会使当前线程等待,同时会释放锁,当其他线程调用 signal()时,线程会重新获得锁并继续执行。signal()
:用于唤醒一个等待的线程。signaAll()
:用于唤醒所有等待的线程。
==注意:在调用 Condition 的 await()/signal()方法前,也需要线程持有相关的 Lock 锁,调用 await()后线程会释放这个锁,在 singal()调用后会从当前Condition 对象的等待队列中,唤醒 一个线程,唤醒的线程尝试获得锁, 一旦获得锁成功就继续执行。==
3、Lock接口的实现类——ReentrantLock(重点)
ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
ReentrantLock 是唯一实现了 Lock 接口的类,并且 ReentrantLock 提供了更多的方法。
相对于 synchronized 它具备如下特点:
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
与 synchronized 一样,都支持可重入
1、BAT大厂的面试问题
- 什么是可重入,什么是可重入锁?它用来解决什么问题?
- ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗?说说其类内部结构关系。
- ReentrantLock是如何实现公平锁的?
- ReentrantLock是如何实现非公平锁的?
- ReentrantLock默认实现的是公平还是非公平锁?
- 使用ReentrantLock实现公平和非公平锁的示例?
- ReentrantLock和Synchronized的对比?
2、ReentrantLock源码分析
1、类的继承关系
ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个Condition条件。
1 | public class ReentrantLock implements Lock, java.io.Serializable |
2、类的内部类
ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:
说明:ReentrantLock类内部总共存在Sync
、NonfairSync
、FairSync
三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。
Sync类
源码
abstract static class Sync extends AbstractQueuedSynchronizer { // 序列号 private static final long serialVersionUID = -5179523762034025860L; // 获取锁 abstract void lock(); // 非公平方式获取 final boolean nonfairTryAcquire(int acquires) { // 当前线程 final Thread current = Thread.currentThread(); // 获取状态 int c = getState(); if (c == 0) { // 表示没有线程正在竞争该锁 if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用,这里体现了非公平性: 不去检查 AQS 队列 // 设置当前线程独占 setExclusiveOwnerThread(current); return true; // 成功 } } // 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入 else if (current == getExclusiveOwnerThread()) { // 当前线程拥有该锁 int nextc = c + acquires; // 增加重入次数 if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); // 设置状态 setState(nextc); // 成功 return true; } // 失败,回到调用处 return false; } // 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它 protected final boolean tryRelease(int releases) { // state-- int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程 throw new IllegalMonitorStateException(); // 抛出异常 // 释放标识 boolean free = false; // 支持锁重入, 只有 state 减为 0, 才释放成功 if (c == 0) { free = true; // 已经释放,清空独占 setExclusiveOwnerThread(null); } // 设置标识 setState(c); return free; } // 判断资源是否被当前线程占有 protected final boolean isHeldExclusively() { // While we must in general read state before owner, // we don't need to do so to check if current thread is owner return getExclusiveOwnerThread() == Thread.currentThread(); } // 新生一个条件 final ConditionObject newCondition() { return new ConditionObject(); } // Methods relayed from outer class // 返回资源的占用线程 final Thread getOwner() { return getState() == 0 ? null : getExclusiveOwnerThread(); } // 返回状态 final int getHoldCount() { return isHeldExclusively() ? getState() : 0; } // 资源是否被占用 final boolean isLocked() { return getState() != 0; } /** * Reconstitutes the instance from a stream (that is, deserializes it). */ // 自定义反序列化逻辑 private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { s.defaultReadObject(); setState(0); // reset to unlocked state } }
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
- Sync类存在如下方法和作用如下:
- ![image](JUC/java-thread-x-juc-reentrantlock-2.png)
- NonfairSync类
- NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法,源码如下:
- ```java
// 非公平锁
static final class NonfairSync extends Sync {
// 版本号
private static final long serialVersionUID = 7316153563782823691L;
// 获得锁
final void lock() {
// 首先用 cas 尝试(仅尝试一次)将 state 从 0 改为 1, 如果成功表示获得了独占锁
if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
// 把当前线程设置独占了锁
setExclusiveOwnerThread(Thread.currentThread());
else // 锁已经被占用,或者set失败
// 以独占模式获取对象,忽略中断
// 如果尝试失败,进入 AQS的acquire方法
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}说明:从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。
FairSyn类
FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法,源码如下:
// 公平锁 static final class FairSync extends Sync { // 版本序列化 private static final long serialVersionUID = -3000897897090466540L; final void lock() { // 以独占模式获取对象,忽略中断 acquire(1); } /** * Fair version of tryAcquire. Don't grant access unless * recursive call or no waiters or is first. */ // 尝试公平获取锁 protected final boolean tryAcquire(int acquires) { // 获取当前线程 final Thread current = Thread.currentThread(); // 获取状态 int c = getState(); if (c == 0) { // 状态为0 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功 // 设置当前线程独占 setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据 // 下一个状态 int nextc = c + acquires; if (nextc < 0) // 超过了int的表示范围 throw new Error("Maximum lock count exceeded"); // 设置状态 setState(nextc); return true; } return false; } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- 说明:
- 跟踪lock方法的源码可知,当资源空闲时,它总是会先判断sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。
- 其中,FairSync类的lock的方法调用如下,只给出了主要的方法。
- ![image](JUC/java-thread-x-juc-reentrantlock-3.png)
- 说明:可以看出只要资源被其他线程占用,该线程就会添加到sync queue中的尾部,而不会先尝试获取资源。这也是和Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。
###### 3、类的属性
ReentrantLock类的sync非常重要,对ReentrantLock类的操作大部分都直接转化为对Sync和AbstractQueuedSynchronizer类的操作。
```java
public class ReentrantLock implements Lock, java.io.Serializable {
// 序列号
private static final long serialVersionUID = 7373984872572414699L;
// 同步队列
private final Sync sync;
}
4、类的构造函数(默认是采用的非公平策略获取锁)
ReentrantLock()型构造函数(默认是采用的非公平策略获取锁)
public ReentrantLock() { // 默认非公平策略 sync = new NonfairSync(); }
1
2
3
4
5
6
7
- ReentrantLock(boolean)型构造函数(可以传递参数确定采用公平策略或者是非公平策略,参数为true表示公平策略,否则,采用非公平策略)
- ```java
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
5、核心函数分析——加锁与解锁
加锁与解锁:(默认为非公平锁实现)(配合上面的源码进行分析)
没有竞争时
第一个竞争出现时
Thread-1 执行了
- CAS 尝试将 state 由 0 改为 1,结果失败
- 进入 tryAcquire 逻辑,这时 state 已经是1,结果仍然失败
- 接下来进入 addWaiter 逻辑,构造 Node 队列
- 图中黄色三角表示该 Node 的 waitStatus 状态,其中 0 为默认正常状态
- Node 的创建是懒惰的
- 其中第一个 Node 称为 Dummy(哑元)或哨兵,用来占位,并不关联线程
当前线程进入 acquireQueued 逻辑
acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞
如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败
进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false.(waitStatue为-1表示该结点有责任唤醒它的后继结点)
shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时state 仍为 1,失败
当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回true
进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)
再次有多个线程经历上述过程竞争失败,变成这个样子
Thread-0 释放锁,进入 tryRelease 流程,如果成功
- 设置 exclusiveOwnerThread 为 null
- state = 0
当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程
找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1
回到 Thread-1 的 acquireQueued 流程
如果加锁成功(没有竞争),会设置
- exclusiveOwnerThread 为 Thread-1,state = 1
- head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread
- 原本的 head 因为从链表断开,而可被垃圾回收
如果这时候有其它线程来竞争(非公平的体现),例如这时有 Thread-4 来了
如果不巧又被 Thread-4 占了先
- Thread-4 被设置为 exclusiveOwnerThread,state = 1
- Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞
6、核心函数分析——可重入原理
1 | static final class NonfairSync extends Sync { |
7、核心函数分析——可打断原理
不可打断模式
在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了。
1 | // Sync 继承自 AQS |
可打断模式:
1 | static final class NonfairSync extends Sync { |
通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。
所以可知,在ReentrantLock的背后,是AQS对其服务提供了支持,下面还是通过例子来更进一步分析源码。
8、核心函数分析——条件变量实现原理
每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject
await
流程:
开始 Thread-0 持有锁,调用 await,进入 ConditionObject 的 addConditionWaiter 流程
创建新的 Node 状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列尾部
接下来进入 AQS 的
fullyRelease
流程,释放同步器上的锁(为什么调用fullyRelease而不是调用realease:因为该线程可能有重入锁,调用fullyRelease可以将该线程所占的所有锁全部释放掉)unpark AQS 队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么 Thread-1 竞争成功
park 阻塞 Thread-0
这里其实是thread0 unpark thread1,但此时1并没有竞争到锁因为0还持有锁,等到thread0 park自己时,1才竞争到锁,因为unpark和park可以互换顺序
signal
流程:
假设 Thread-1 要来唤醒 Thread-0
进入 ConditionObject 的 doSignal 流程,取得等待队列中第一个 Node,即 Thread-0 所在 Node
执行 transferForSignal 流程,将该 Node 加入 AQS 队列尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的waitStatus 改为 -1
Thread-1 释放锁,进入 unlock 流程,略
3、示例分析
公平锁
1 | import java.util.concurrent.locks.Lock; |
运行结果(某一次):
1 | Thread[t1,5,main] running |
说明:该示例使用的是公平策略,由结果可知,可能会存在如下一种时序。
说明:首先,t1线程的lock操作 -> t2线程的lock操作 -> t3线程的lock操作 -> t1线程的unlock操作 -> t2线程的unlock操作 -> t3线程的unlock操作。根据这个时序图来进一步分析源码的工作流程:
- t1线程执行lock.lock,下图给出了方法调用中的主要方法:
- 说明:由调用流程可知,t1线程成功获取了资源,可以继续执行。
- t2线程执行lock.lock,下图给出了方法调用中的主要方法:
- 说明:由上图可知,最后的结果是t2线程会被禁止,因为调用了LockSupport.park。
- t3线程执行lock.lock,下图给出了方法调用中的主要方法:
- 说明:由上图可知,最后的结果是t3线程会被禁止,因为调用了LockSupport.park。
- t1线程调用了lock.unlock,下图给出了方法调用中的主要方法:
- 说明:如上图所示,最后,head的状态会变为0,t2线程会被unpark,即t2线程可以继续运行。此时t3线程还是被禁止。
- t2获得cpu资源,继续运行,由于t2之前被park了,现在需要恢复之前的状态,下图给出了方法调用中的主要方法:
- 说明:在setHead函数中会将head设置为之前head的下一个结点,并且将pre域与thread域都设置为null,在acquireQueued返回之前,sync queue就只有两个结点了。
- t2执行lock.unlock,下图给出了方法调用中的主要方法:
- 说明:由上图可知,最终unpark t3线程,让t3线程可以继续运行。
- t3线程获取cpu资源,恢复之前的状态,继续运行。
- 说明:最终达到的状态是sync queue中只剩下了一个结点,并且该节点除了状态为0外,其余均为null。
- t3执行lock.unlock,下图给出了方法调用中的主要方法:
- 说明:最后的状态和之前的状态是一样的,队列中有一个空节点,头结点为尾节点均指向它。
6、ReadWriteLock接口
ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
在ReadWriteLock接口里面只定义了两个方法:
1 | public interface ReadWriteLock { |
一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成 2 个锁来分配给线程,从而使得多个线程可以同时进行读操作。下面的ReentrantReadWriteLock
实现了 ReadWriteLock 接口。
1、ReadWriteLock接口实现类——ReentrantReadWriteLock读写锁(重要)
现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源, 就不应该允许其他线程对该资源进行读和写的操作了。类似于数据库中的select ...from ... lock in share mode
针对这种场景,JAVA 的并发包JUC提供了读写锁 ReentrantReadWriteLock, ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。
读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。
ReentrantReadWriteLock 里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()和 writeLock()用来获取读锁和写锁。
- 线程进入读锁的前提条件:
- 没有其他线程的写锁
- 没有写请求,或者==有写请求,但调用线程和持有锁的线程是同一个(可重入锁)==。
- 线程进入写锁的前提条件:
- 没有其他线程的读锁
- 没有其他线程的写锁
而读写锁有以下三个重要的特性:
- ==公平选择性==:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
- ==重进入==:读锁和写锁都支持线程重进入。
- ==锁降级==:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
1、BAT大厂的面试问题
- 为了有了ReentrantLock还需要ReentrantReadWriteLock?
- ReentrantReadWriteLock底层实现原理?
- ReentrantReadWriteLock底层读写状态如何设计的?
- 高16位为读锁,低16位为写锁
- 读锁和写锁的最大数量是多少?
- 本地线程计数器ThreadLocalHoldCounter是用来做什么的?
- 缓存计数器HoldCounter是用来做什么的?
- 写锁的获取与释放是怎么实现的?
- 读锁的获取与释放是怎么实现的?
- RentrantReadWriteLock为什么不支持锁升级?
- 什么是锁的升降级?RentrantReadWriteLock为什么不支持锁升级?
2、ReentrantReadWriteLock数据结构
ReentrantReadWriteLock底层是基于ReentrantLock
和AbstractQueuedSynchronizer
来实现的,所以,ReentrantReadWriteLock的数据结构也依托于AQS的数据结构。
3、ReentrantReadWriteLock源码分析
1、类的继承关系
1 | public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {} |
说明:可以看到,ReentrantReadWriteLock实现了ReadWriteLock接口,ReadWriteLock接口定义了获取读锁和写锁的规范,具体需要实现类去实现;同时其还实现了Serializable接口,表示可以进行序列化,在源代码中可以看到ReentrantReadWriteLock实现了自己的序列化逻辑。
2、类的内部类
ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示:
说明:如上图所示,Sync继承自AQS、NonfairSync继承自Sync类、FairSync继承自Sync类;ReadLock实现了Lock接口、WriteLock也实现了Lock接口。
3、内部类——Sync类
类的继承关系
abstract static class Sync extends AbstractQueuedSynchronizer {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 说明:Sync抽象类继承自AQS抽象类,Sync类提供了对ReentrantReadWriteLock的支持。
- 类的内部类
- Sync类内部存在两个内部类,分别为HoldCounter和ThreadLocalHoldCounter,其中HoldCounter主要与读锁配套使用,其中,HoldCounter源码如下:
- ```java
// 计数器
static final class HoldCounter {
// 计数
int count = 0;
// Use id, not reference, to avoid garbage retention
// 获取当前线程的TID属性的值
final long tid = getThreadId(Thread.currentThread());
}说明:HoldCounter主要有两个属性,count和tid,其中count表示某个读线程重入的次数,tid表示该线程的tid字段的值,该字段可以用来唯一标识一个线程。
ThreadLocalHoldCounter的源码如下:
// 本地线程计数器 static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> { // 重写初始化方法,在没有进行set的情况下,获取的都是该HoldCounter值 public HoldCounter initialValue() { return new HoldCounter(); } }
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
- 说明:ThreadLocalHoldCounter重写了ThreadLocal的initialValue方法,ThreadLocal类可以将线程与对象相关联。在没有进行set的情况下,get到的均是initialValue方法里面生成的那个HolderCounter对象。
- 类的属性
- ```java
abstract static class Sync extends AbstractQueuedSynchronizer {
// 版本序列号
private static final long serialVersionUID = 6317671515068378041L;
// 高16位为读锁,低16位为写锁
static final int SHARED_SHIFT = 16;
// 读锁单位
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
// 读锁最大数量
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
// 写锁最大数量
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
// 本地线程计数器
private transient ThreadLocalHoldCounter readHolds;
// 缓存的计数器
private transient HoldCounter cachedHoldCounter;
// 第一个读线程
private transient Thread firstReader = null;
// 第一个读线程的计数
private transient int firstReaderHoldCount;
}
说明:该属性中包括了读锁、写锁线程的最大量、本地线程计数器等。
类的构造函数
// 构造函数 Sync() { // 本地线程计数器 readHolds = new ThreadLocalHoldCounter(); // 设置AQS的状态 setState(getState()); // ensures visibility of readHolds }
1
2
3
4
5
6
7
8
9
10
11
12
13
- 说明:在Sync的构造函数中**设置了本地线程计数器和AQS的状态state**。
###### 4、内部类——Sync核心函数分析
对ReentrantReadWriteLock对象的操作绝大多数都转发至Sync对象进行处理。下面对Sync类中的重点函数进行分析:
- sharedCount函数
- 表示**占有读锁的线程数量**,源码如下:
- ```java
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }说明:直接将state右移16位,就可以得到读锁的线程数量,因为state的高16位表示读锁,对应的低十六位表示写锁数量。
exclusiveCount函数
表示占有写锁的线程数量,源码如下:
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
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
- 说明:直接将状态state和(2^16 - 1)做与运算,其等效于将state模上2^16。**写锁数量由state的低十六位表示**。
- tryRelease函数
- ```java
/*
* Note that tryRelease and tryAcquire can be called by
* Conditions. So it is possible that their arguments contain
* both read and write holds that are all released during a
* condition wait and re-established in tryAcquire.
*/
protected final boolean tryRelease(int releases) {
// 判断是否伪独占线程
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 计算释放资源后的写锁的数量
int nextc = getState() - releases;
// 因为可重入的原因, 写锁计数为 0, 才算释放成功
boolean free = exclusiveCount(nextc) == 0; // 是否释放成功
if (free)
setExclusiveOwnerThread(null); // 设置独占线程为空
setState(nextc); // 设置状态
return free;
}说明:此函数用于释放写锁资源:首先会判断该线程是否为独占线程,若不为独占线程,则抛出异常,否则,计算释放资源后的写锁的数量,若为0,表示成功释放,资源不将被占用,否则,表示资源还被占用。其函数流程图如下:
tryAcquire函数
protected final boolean tryAcquire(int acquires) { /* * Walkthrough: * 1. If read count nonzero or write count nonzero * and owner is a different thread, fail. * 2. If count would saturate, fail. (This can only * happen if count is already nonzero.) * 3. Otherwise, this thread is eligible for lock if * it is either a reentrant acquire or * queue policy allows it. If so, update state * and set owner. */ // 获取当前线程 Thread current = Thread.currentThread(); // 获取状态 // 获得低 16 位, 代表写锁的 state 计数 int c = getState(); // 写线程数量 int w = exclusiveCount(c); if (c != 0) { // 状态不为0 // (Note: if c != 0 and w == 0 then shared count != 0) // 写线程数量为0或者当前线程没有占有独占资源 if ( // c != 0 and w == 0 表示有读锁, 或者 w == 0 || // 如果 exclusiveOwnerThread 不是自己(可重入) current != getExclusiveOwnerThread()) // 获得锁失败 return false; // 写锁计数超过低 16 位, 报异常 if (w + exclusiveCount(acquires) > MAX_COUNT) // 判断是否超过最高写线程数量 throw new Error("Maximum lock count exceeded"); // Reentrant acquire // 设置AQS状态 // 写锁重入, 获得锁成功 setState(c + acquires); return true; } if ( // 判断写锁是否该阻塞, 或者 writerShouldBlock() || // 尝试更改计数失败 !compareAndSetState(c, c + acquires)) // 获得锁失败 return false; // 设置独占线程 // 获得锁成功 setExclusiveOwnerThread(current); return true; }
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
- 说明:此函数用于**获取写锁**:首先会获取state,判断是否为0,若为0,表示此时没有读锁线程,再判断写线程是否应该被阻塞,而**在非公平策略下总是不会被阻塞,在公平策略下会进行判断(判断同步队列中是否有等待时间更长的线程,若存在,则需要被阻塞,否则,无需阻塞**),之后在设置状态state,然后返回true。若state不为0,则表示此时存在读锁或写锁线程,若写锁线程数量为0或者当前线程为独占锁线程,则返回false,表示不成功,否则,判断写锁线程的重入次数是否大于了最大值,若是,则抛出异常,否则,设置状态state,返回true,表示成功。其函数流程图如下:
- ![img](JUC/java-thread-x-readwritelock-3.png)
- tryReleaseShared函数
- ```java
protected final boolean tryReleaseShared(int unused) {
// 获取当前线程
Thread current = Thread.currentThread();
if (firstReader == current) { // 当前线程为第一个读线程
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1) // 读线程占用的资源数为1
firstReader = null;
else // 减少占用的资源
firstReaderHoldCount--;
} else { // 当前线程不为第一个读线程
// 获取缓存的计数器
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
// 获取当前线程对应的计数器
rh = readHolds.get();
// 获取计数
int count = rh.count;
if (count <= 1) { // 计数小于等于1
// 移除
readHolds.remove();
if (count <= 0) // 计数小于等于0,抛出异常
throw unmatchedUnlockException();
}
// 减少计数
--rh.count;
}
for (;;) { // 无限循环
// 获取状态
int c = getState();
// 获取状态
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc)) // 比较并进行设置
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
// 读锁的计数不会影响其它获取读锁线程, 但会影响其它获取写锁线程
// 计数为 0 才是真正释放
return nextc == 0;
}
}说明:此函数表示读锁线程释放锁:首先判断当前线程是否为第一个读线程firstReader,若是,则判断第一个读线程占有的资源数firstReaderHoldCount是否为1,若是,则设置第一个读线程firstReader为空,否则,将第一个读线程占有的资源数firstReaderHoldCount减1;若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者tid不等于当前线程的tid值,则获取当前线程的计数器,如果计数器的计数count小于等于1,则移除当前线程对应的计数器,如果计数器的计数count小于等于0,则抛出异常,之后再减少计数即可。无论何种情况,都会进入无限循环,该循环可以确保成功设置状态state。其流程图如下:
tryAcquireShared函数
private IllegalMonitorStateException unmatchedUnlockException() { return new IllegalMonitorStateException( "attempt to unlock read lock, not locked by current thread"); } // 共享模式下获取资源 protected final int tryAcquireShared(int unused) { /* * Walkthrough: * 1. If write lock held by another thread, fail. * 2. Otherwise, this thread is eligible for * lock wrt state, so ask if it should block * because of queue policy. If not, try * to grant by CASing state and updating count. * Note that step does not check for reentrant * acquires, which is postponed to full version * to avoid having to check hold count in * the more typical non-reentrant case. * 3. If step 2 fails either because thread * apparently not eligible or CAS fails or count * saturated, chain to version with full retry loop. */ // 获取当前线程 Thread current = Thread.currentThread(); // 获取状态 int c = getState(); // 如果是其它线程持有写锁, 获取读锁失败 if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) // 写线程数不为0并且占有资源的不是当前线程 return -1; // 读锁数量 int r = sharedCount(c); if (// 读线程是否应该被阻塞、并且小于最大值、并且比较设置成功 // 读锁不该阻塞(如果老二是写锁,读锁该阻塞), 并且 !readerShouldBlock() && // 小于读锁计数, 并且 r < MAX_COUNT && // 尝试增加计数成功 compareAndSetState(c, c + SHARED_UNIT)) { if (r == 0) { // 读锁数量为0 // 设置第一个读线程 firstReader = current; // 读线程占用的资源数为1 firstReaderHoldCount = 1; } else if (firstReader == current) { // 当前线程为第一个读线程 // 占用资源数加1 firstReaderHoldCount++; } else { // 读锁数量不为0并且不为当前线程 // 获取计数器 HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid // 获取当前线程对应的计数器 cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0) // 计数为0 // 设置 readHolds.set(rh); rh.count++; } return 1; } return fullTryAcquireShared(current); }
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
- 说明:此函数表示**读锁线程获取读锁**。首先判断写锁是否为0并且当前线程不占有独占锁,直接返回;否则,判断读线程是否需要被阻塞并且读锁数量是否小于最大值并且比较设置状态成功,若当前没有读锁,则设置第一个读线程firstReader和firstReaderHoldCount;若当前线程线程为第一个读线程,则增加firstReaderHoldCount;否则,将设置当前线程对应的HoldCounter对象的值。流程图如下:
- ![img](JUC/java-thread-x-readwritelock-5.png)
- fullTryAcquireShared函数
- ```java
// 非公平锁 readerShouldBlock 看 AQS 队列中第一个节点是否是写锁
// true 则该阻塞, false 则不阻塞
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
// 与 tryAcquireShared 功能类似, 但会不断尝试 for (;;) 获取读锁, 执行过程中无阻塞
final int fullTryAcquireShared(Thread current) {
/*
* This code is in part redundant with that in
* tryAcquireShared but is simpler overall by not
* complicating tryAcquireShared with interactions between
* retries and lazily reading hold counts.
*/
HoldCounter rh = null;
for (;;) { // 无限循环
// 获取状态
int c = getState();
if (exclusiveCount(c) != 0) { // 写线程数量不为0
if (getExclusiveOwnerThread() != current) // 不为当前线程
return -1;
// else we hold the exclusive lock; blocking here
// would cause deadlock.
} else if (readerShouldBlock()) { // 写线程数量为0并且读线程被阻塞
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) { // 当前线程为第一个读线程
// assert firstReaderHoldCount > 0;
} else { // 当前线程不为第一个读线程
if (rh == null) { // 计数器不为空
//
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) { // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT) // 读锁数量为最大值,抛出异常
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) { // 比较并且设置成功
if (sharedCount(c) == 0) { // 读线程数量为0
// 设置第一个读线程
firstReader = current;
//
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}说明:在tryAcquireShared函数中,如果下列三个条件不满足:
- 读线程是否应该被阻塞
- 小于最大值
- 比较设置成功
则会进行fullTryAcquireShared函数中,它用来保证相关操作可以成功。其逻辑与tryAcquireShared逻辑类似,不再累赘。
doAcquireShared()函数
private void doAcquireShared(int arg) { // 将当前线程关联到一个 Node 对象上, 模式为共享模式 final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head) { // 再一次尝试获取读锁 int r = tryAcquireShared(arg); // 成功 if (r >= 0) { // ㈠ // r 表示可用资源数, 在这里总是 1 允许传播 //(唤醒 AQS 中下一个 Share 节点) setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } if ( // 是否在获取读锁失败时阻塞(前一个阶段 waitStatus == Node.SIGNAL) shouldParkAfterFailedAcquire(p, node) && // park 当前线程 parkAndCheckInterrupt() ){ interrupted = true; } } } finally { if (failed) cancelAcquire(node); } }
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
而其他内部类的操作基本上都是转化到了对Sync对象的操作,在此不再累赘。
###### 5、类的属性
```java
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = -6992448646407690164L;
// 读锁
private final ReentrantReadWriteLock.ReadLock readerLock;
// 写锁
private final ReentrantReadWriteLock.WriteLock writerLock;
// 同步队列
final Sync sync;
private static final sun.misc.Unsafe UNSAFE;
// 线程ID的偏移地址
private static final long TID_OFFSET;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> tk = Thread.class;
// 获取线程的tid字段的内存地址
TID_OFFSET = UNSAFE.objectFieldOffset
(tk.getDeclaredField("tid"));
} catch (Exception e) {
throw new Error(e);
}
}
}
说明:可以看到ReentrantReadWriteLock属性包括了一个ReentrantReadWriteLock.ReadLock对象,表示读锁;一个ReentrantReadWriteLock.WriteLock对象,表示写锁;一个Sync对象,表示同步队列。
6、类的构造函数
ReentrantReadWriteLock()型构造函数
public ReentrantReadWriteLock() { this(false); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
- 说明:此构造函数会调用另外一个有参构造函数。
- ReentrantReadWriteLock(boolean)型构造函数
- ```java
public ReentrantReadWriteLock(boolean fair) {
// 公平策略或者是非公平策略
sync = fair ? new FairSync() : new NonfairSync();
// 读锁
readerLock = new ReadLock(this);
// 写锁
writerLock = new WriteLock(this);
}说明:可以指定设置公平策略或者非公平策略,并且该构造函数中生成了读锁与写锁两个对象。如果调用的是空参的构造函数,则默认是非公平的策略。
7、核心函数分析
对ReentrantReadWriteLock的操作基本上都转化为了对Sync对象的操作,而Sync的函数已经分析过,不再累赘。
8、图解ReentrantReadWriteLock执行流程
读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个。
t1 w.lock,t2 r.lock:t1线程为写锁,t2线程为读锁(默认为非公平锁)
t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位
t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写锁占据,那么 tryAcquireShared 返回 -1 表示失败
- tryAcquireShared 返回值表示
- -1 表示失败
- 0 表示成功,但后继节点不会继续唤醒
- 正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1
- tryAcquireShared 返回值表示
这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态
t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁
如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park
t3 r.lock,t4 w.lock:t3线程为读锁,t4线程为写锁。这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子(由于t3是读锁,为共享锁,所以状态是Shared,而t4是写锁,为独占锁,所以状态是Ex)这里状态的不同是为了之后的解锁做准备,不同状态的解锁方式不同
t1 w.unlock:t1线程释放了写锁。这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子
接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行
这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一(其中的readerShouldBlock()方法是区别公平锁和非公平锁的关键,非公平锁不阻塞,公平锁就阻塞)(同样的writerShouldBlock()也是区别写锁公平和非公平的关键)
这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点
事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行((setHead(node)之后的代码的意思是,当阻塞队列中有多个连续的读线程时,会传播式地逐一唤醒,if(s.isShared()){doReleaseShared()}这段代码是关键,吧读锁的状态设置成Shared也是为了这里))
这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一
这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点
下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点
t2 r.unlock,t3 r.unlock:t2线程解锁,t3线程解锁:t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零
t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即
之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;;) 这次自己是老二,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束
4、ReentrantReadWriteLock示例
下面给出了一个使用ReentrantReadWriteLock的示例,源代码如下:
1 | import java.util.concurrent.locks.ReentrantReadWriteLock; |
运行结果(某一次):
1 | rt1 trying to lock |
说明:程序中生成了一个ReentrantReadWriteLock对象,并且设置了两个读线程,一个写线程。根据结果,可能存在如下的时序图:
- rt1线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
- 说明:此时,AQS的状态state为2^16 次方,即表示此时读线程数量为1。
- rt2线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
- 说明:此时,AQS的状态state为2 * 2^16次方,即表示此时读线程数量为2。
- wt1线程执行rrwLock.writeLock().lock操作,主要的函数调用如下:
- 说明:此时,在同步队列Sync queue中存在两个结点,并且wt1线程会被禁止运行。
- rt1线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
- 说明:此时,AQS的state为2^16次方,表示还有一个读线程。
- rt2线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
- 说明:当rt2线程执行unlock操作后,AQS的state为0,并且wt1线程将会被unpark,其获得CPU资源就可以运行。
- wt1线程获得CPU资源,继续运行,需要恢复。由于之前acquireQueued函数中的parkAndCheckInterrupt函数中被禁止的,所以,恢复到parkAndCheckInterrupt函数中,主要的函数调用如下:
- 说明:最后,sync queue队列中只有一个结点,并且头结点尾节点均指向它,AQS的state值为1,表示此时有一个写线程。
- wt1执行rrwLock.writeLock().unlock操作,主要的函数调用如下:
- 说明:此时,AQS的state为0,表示没有任何读线程或者写线程了。并且Sync queue结构与上一个状态的结构相同,没有变化。
5、更深入理解
1、什么是锁升降级?
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码如下所示:
1 | public void processData() { |
上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。
2、锁降级中读锁的获取是否必要呢?
答案是必要的。主要是为了==保证数据的可见性==,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
3、RentrantReadWriteLock支不支持锁升级?
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是**==保证数据可见性==,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。**
6、使用读写锁实现一致性缓存(保证缓存与数据库数据一致)
1、缓存更新策略
更新时,是先清缓存还是先更新数据库?
先清缓存:
先更新数据库:
补充一种情况,假设查询线程 A 查询数据时恰好缓存数据由于时间到期失效,或是第一次查询
这种情况的出现几率非常小,见 facebook 论文
2、使用读写锁实现一个简单的按需加载缓存
1 | import java.util.*; |
注意:
- 以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑
- 适合读多写少,如果写操作比较频繁,以上实现性能低
- 没有考虑缓存容量
- 没有考虑缓存过期
- 只适合单机
- 并发性还是低,目前只会用一把锁(其实可以把锁再细化,不同的表用不同的锁)
- 更新方法太过简单粗暴,清空了所有 key(考虑按类型分区或重新设计 key)
- 乐观锁实现:用 CAS 去更新
7、ReentrantReadWriteLock总结
- 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
- 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。
原因:当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。
注意事项:
读锁不支持条件变量
重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待
r.lock(); try { // ... w.lock(); try { // ... } finally{ w.unlock(); } } finally{ r.unlock(); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 重入时降级支持:即持有写锁的情况下去获取读锁
#### 2、对ReentrantReadWriteLock性能再提升——`StampedLock`
##### 1、StampedLock概述
该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是**在使用读锁、写锁时都必须配合【戳】使用**
**加解读锁**:
```java
long stamp = lock.readLock();
lock.unlockRead(stamp);
加解写锁:
1 | long stamp = lock.writeLock(); |
乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。
1 | long stamp = lock.tryOptimisticRead(); // 验戳 |
2、StampedLock示例
提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法
1 |
|
测试 读-读 (乐观读)
1 |
|
输出结果,可以看到实际没有加读锁
1 | 15:58:50.217 c.DataContainerStamped [t1] - optimistic read locking...256 |
测试 读-写 时优化读补加读锁:
1 |
|
输出结果
1 | 15:57:00.219 c.DataContainerStamped [t1] - optimistic read locking...256 |
3、StampedLock是否可以替代ReentrantReadWriteLock
当然是不可以,虽然StampedLock在读写锁上的性能比ReentrantReadWriteLock好,但是它有以下几个缺点:
- StampedLock 不支持条件变量
- StampedLock 不支持可重入
7、线程间通信
线程间通信的模型有两种:==共享内存==和==消息传递==
1、场景
我们来基本一道面试常见的题目来分析:场景——四个线程,两个线程对当前数值加 1,另外两个线程对当前数值减 1,要求用线程间通信。
2、分析
1、关于i++与i–的字节码及其执行流程
i++其实是一个复合操作,包括三步骤:
- 读取i的值。
- 对i加1。
- 将i的值写回内存。
i++的相关字节码指令:
1 | getstatic i // 获取静态变量i的值 |
对于i–也是类似:
1 | getstatic i // 获取静态变量i的值 |
而Java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:(下图只显示了两个线程,分别做自增和自减)
如果是单线程以上 8 行字节码是顺序执行(不会交错)没有问题:
但多线程下这 8 行字节码可能交错运行:
出现负数的情况:
出现正数的情况:
2、临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
3、竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
3、解决方法
使用Lock的方案:
1 | package com.atguigu.lock; |
8、线程间定制化通信
案例介绍:
问题:A 线程打印 5 次 A,B 线程打印 10 次 B,C 线程打印 15 次 C,按照此顺序循环 10 轮。
实现方法:
1 | //第一步 创建资源类 |
9、集合的线程安全
类结构关系:
线程安全集合类可以分为三大类:
- 遗留的线程安全集合如 Hashtable , Vector
- 使用 Collections 装饰的线程安全集合,如:
Collections.synchronizedCollection
Collections.synchronizedList
Collections.synchronizedMap
Collections.synchronizedSet
Collections.synchronizedNavigableMap
Collections.synchronizedNavigableSet
Collections.synchronizedSortedMap
Collections.synchronizedSortedSet
java.util.concurrent.*
重点介绍 java.util.concurrent.*
下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:Blocking
、CopyOnWrite
、Concurrent
Blocking
大部分实现基于锁,并提供用来阻塞的方法CopyOnWrite
之类容器修改开销相对较重Concurrent
类型的容器- 内部很多操作使用 cas 优化,一般可以提供较高吞吐量
- 弱一致性
- 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
- 求大小弱一致性,size 操作未必是 100% 准确
- 读取弱一致性
遍历时如果发生了修改,对于非安全容器来讲,使用
fail-fast
机制也就是让遍历立刻失败,抛出ConcurrentModificationException,不再继续遍历
1、ArrayList不安全
ArrayList的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,ArrayList是不安全的。
示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException
)
1 | public class ThreadDemo4 { |
1 | Exception in thread "27" java.util.ConcurrentModificationException |
解决ArrayList在多线程环境下不安全的问题:
- 方案1:用
Vector
代替ArrayList - 方案2:
Collections.synchronizedList
创建一个同步的ArrayList - 方案3:所以JUC的
CopyOnWriteArrayList
1、方案1:用Vector
代替ArrayList
1 | // Vector解决 |
在Vector底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下Vector是安全的。
但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。
2、方案2:Collections.synchronizedList
创建一个同步的ArrayList
1 | //Collections解决 |
同理这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。
那么有没有好的方法,既解决了ArrayList不安全的问题,又不会对程序的效率造成很大的影响?
答:方案3:所以JUC的CopyOnWriteArrayList
1 | // CopyOnWriteArrayList解决 |
2、JUC的CopyOnWriteArrayList
CopyOnWriteArraySet
是它的马甲 底层实现采用了 写入时拷贝
的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离。
1、BAT大厂的面试问题
- 请先说说非并发集合中Fail-fast机制?
- 再为什么说ArrayList查询快而增删慢?
- 对比ArrayList说说CopyOnWriteArrayList的增删改查实现原理?
- COW基于拷贝
- 再说下弱一致性的迭代器原理是怎么样的?
COWIterator<E>
- CopyOnWriteArrayList为什么并发安全且性能比Vector好?
- CopyOnWriteArrayList有何缺陷,说说其应用场景?
2、CopyOnWriteArrayList源码分析
1、类的继承关系
CopyOnWriteArrayList
- 实现了List接口,List接口定义了对列表的基本操作;
- 同时实现了RandomAccess接口,表示可以随机访问(数组具有随机访问的特性);
- 同时实现了Cloneable接口,表示可克隆;
- 同时也实现了Serializable接口,表示可被序列化。
1 | public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {} |
2、类的内部类——COWIterator类
COWIterator表示迭代器,其也有一个Object类型的数组作为CopyOnWriteArrayList数组的快照,这种快照风格的迭代器方法在创建迭代器时使用了对当时数组状态的引用。此数组在迭代器的生存期内不会更改,因此不可能发生冲突,并且迭代器保证不会抛出 ConcurrentModificationException
。
创建迭代器以后,迭代器就不会反映列表的添加、移除或者更改。在迭代器上进行的元素更改操作(remove、set 和 add)不受支持。这些方法将抛出 UnsupportedOperationException
。
1 | static final class COWIterator<E> implements ListIterator<E> { |
3、类的属性
属性中有一个可重入锁,用来保证线程安全访问,还有一个Object类型的数组,用来存放具体的元素。当然,也使用到了反射机制
和CAS来保证原子性
的修改lock域。
1 | public class CopyOnWriteArrayList<E> |
4、类的构造函数
默认构造函数
public CopyOnWriteArrayList() { // 设置数组 setArray(new Object[0]); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- `CopyOnWriteArrayList(Collection<? extends E>)`型构造函数——该构造函数用于创建一个按 collection 的迭代器返回元素的顺序包含指定 collection 元素的列表。
- ```java
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
if (c.getClass() == CopyOnWriteArrayList.class) // 类型相同
// 获取c集合的数组
elements = ((CopyOnWriteArrayList<?>)c).getArray();
else { // 类型不相同
// 将c集合转化为数组并赋值给elements
elements = c.toArray();
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elements.getClass() != Object[].class) // elements类型不为Object[]类型
// 将elements数组转化为Object[]类型的数组
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
// 设置数组
setArray(elements);
}CopyOnWriteArrayList(Collection<? extends E>)
型构造函数的处理流程如下:- 判断传入的集合c的类型是否为CopyOnWriteArrayList类型,若是,则获取该集合类型的底层数组(Object[]),并且设置当前CopyOnWriteArrayList的数组(Object[]数组),进入步骤③;否则,进入步骤②
- 将传入的集合转化为数组elements,判断elements的类型是否为Object[]类型(toArray方法可能不会返回Object类型的数组),若不是,则将elements转化为Object类型的数组。进入步骤③
- 设置当前CopyOnWriteArrayList的Object[]为elements。
CopyOnWriteArrayList(E[])
型构造函数该构造函数用于创建一个保存给定数组的副本的列表。
public CopyOnWriteArrayList(E[] toCopyIn) { // 将toCopyIn转化为Object[]类型数组,然后设置当前数组 setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class)); }
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
###### 5、核心函数
- copyOf
- add
- addIfAbsent
- set
- remove
对于CopyOnWriteArrayList的函数分析,主要明白**Arrays.copyOf方法**即可理解CopyOnWriteArrayList其他函数的意义。
###### 6、核心函数分析——copyOf
该函数用于**复制指定的数组,截取或用 null 填充(如有必要),以使副本具有指定的长度**。
```java
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
// 确定copy的类型(将newType转化为Object类型,将Object[].class转化为Object类型,判断两者是否相等,若相等,则生成指定长度的Object数组
// 否则,生成指定长度的新类型的数组)
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
// 将original数组从下标0开始,复制长度为(original.length和newLength的较小者),复制到copy数组中(也从下标0开始)
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
7、核心函数分析——add
1 | public boolean add(E e) { |
这里的源码版本是 Java 11,在 Java 1.8 中使用的是可重入锁而不是 synchronized
此函数用于将指定元素添加到此列表的尾部,处理流程如下(写时复制技术)(并发读,独立写)
- 获取锁(保证多线程的安全访问),获取当前的Object数组,获取Object数组的长度为length,进入步骤②。
- 根据Object数组复制一个长度为length+1的Object数组为newElements(此时,newElements[length]为null),进入下一步骤。
- 将下标为length的数组元素newElements[length]设置为元素e,再设置当前Object[]为newElements,释放锁,返回。这样就完成了元素的添加。
其实就是写时复制技术,并发读,独立写
- 读进程读的是原来的版本
- 写进程写的是原来的版本的复制版本
- 在写进程完成好写之后,再将复制的版本与原来的版本进行合并
8、核心函数分析——addIfAbsent
该函数用于添加元素(如果数组中不存在,则添加;否则,不添加,直接返回),可以保证多线程环境下不会重复添加元素。
1 | private boolean addIfAbsent(E e, Object[] snapshot) { |
该函数的流程如下:
- 获取锁,获取当前数组为current,current长度为len,判断数组之前的快照snapshot是否等于当前数组current,若不相等,则进入步骤②;否则,进入步骤④
- 不相等,表示在snapshot与current之间,对数组进行了修改(如进行了add、set、remove等操作),获取长度(snapshot与current之间的较小者),对current进行遍历操作,若遍历过程发现snapshot与current的元素不相等并且current的元素与指定元素相等(可能进行了set操作),进入步骤⑤,否则,进入步骤③
- 在当前数组中索引指定元素,若能够找到,进入步骤⑤,否则,进入步骤④
- 复制当前数组current为newElements,长度为len+1,此时newElements[len]为null。再设置newElements[len]为指定元素e,再设置数组,进入步骤⑤
- 释放锁,返回。
9、核心函数分析——set
此函数用于用指定的元素替代此列表指定位置上的元素,也是基于数组的复制来实现的。
1 | public E set(int index, E element) { |
10、核心函数分析——remove
此函数用于移除此列表指定位置上的元素。
1 | public E remove(int index) { |
处理流程如下:
- 获取锁,获取数组elements,数组长度为length,获取索引的值elements[index],计算需要移动的元素个数(length - index - 1),若个数为0,则表示移除的是数组的最后一个元素,复制elements数组,复制长度为length-1,然后设置数组,进入步骤③;否则,进入步骤②
- 先复制index索引前的元素,再复制index索引后的元素,然后设置数组。
- 释放锁,返回旧值。
3、CopyOnWriteArrayList示例
下面通过一个示例来了解CopyOnWriteArrayList的使用:
在程序中,有一个PutThread线程会每隔50ms就向CopyOnWriteArrayList中添加一个元素,并且两次使用了迭代器,迭代器输出的内容都是生成迭代器时,CopyOnWriteArrayList的Object数组的快照的内容,在迭代的过程中,往CopyOnWriteArrayList中添加元素也不会抛出异常。
1 | import java.util.Iterator; |
运行结果(某一次)
1 | 0 1 2 3 4 5 6 7 8 9 100 |
4、CopyOnWriteArrayList的弱一致性体现
1、get 弱一致性
时间点 | 操作 |
---|---|
1 | Thread-0 getArray() |
2 | Thread-1 getArray() |
3 | Thread-1 setArray(arrayCopy) |
4 | Thread-0 array[index] |
2、迭代器弱一致性
1 | CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(); |
虽然线程t1已经将1
从list中移除,但是迭代器当中迭代的list依旧是旧的list,有包括1
3、关于弱一致性
不要觉得弱一致性就不好
- 数据库的 MVCC 都是弱一致性的表现
- 并发高和一致性是矛盾的,需要权衡
5、更深入理解
1、CopyOnWriteArrayList的缺陷和使用场景
CopyOnWriteArrayList 有几个缺点:
- 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc;
- 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;
CopyOnWriteArrayList 合适==读多写少==的场景,不过这类慎用
因为谁也没法保证CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。
2、CopyOnWriteArrayList为什么并发安全性能比Vector好?
- Vector对单独的add,remove等方法都是在方法上加了synchronized;
- 并且如果一个线程A调用size时,另一个线程B 执行了remove,然后size的值就不是最新的,然后线程A调用remove就会越界(这时就需要再加一个Synchronized)。这样就导致有了双重锁,效率大大降低,何必呢。
- 于是vector废弃了,要用就用CopyOnWriteArrayList 吧。
3、HashMap不安全
HashMap的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,HashMap是不安全的。
示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException
)
1 | public class ThreadDemo4 { |
1 | {22=b7638976, 23=918c6021, 24=254a542e, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7} |
解决HashMap在多线程环境下不安全的问题:
- 方案1:用
HashTable
代替HashMap - 方案2:JUC的
ConcurrentHashMap
方案1:用HashTable
代替HashMap
1 | // 用HashTable代替HashMap |
在HashTable底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下HashTable是安全的。
但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。
那么有没有好的方法,既解决了HashMap不安全的问题,又不会对程序的效率造成很大的影响?
答:方案2:所以JUC的ConcurrentHashMap
1 | // ConcurrentHashMap解决 |
4、JUC的ConcurrentHashMap
1、BAT大厂的面试问题
- 为什么HashTable慢?它的并发度是什么?那么ConcurrentHashMap并发度是什么?
- ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别?JDK1.8解決了JDK1.7中什么问题?
- ConcurrentHashMap JDK1.7实现的原理是什么?
- 分段锁机制
- ConcurrentHashMap JDK1.8实现的原理是什么?
- 数组+链表+红黑树,CAS
- ConcurrentHashMap JDK1.7中Segment数(concurrencyLevel)默认值是多少?为何一旦初始化就不可再扩容?
- ConcurrentHashMap JDK1.7说说其put的机制?
- ConcurrentHashMap JDK1.7是如何扩容的?
- rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容)
- ConcurrentHashMap JDK1.8是如何扩容的?
- tryPresize
- ConcurrentHashMap JDK1.8链表转红黑树的时机是什么?临界值为什么是8?
- ConcurrentHashMap JDK1.8是如何进行数据迁移的?
- transfer
- JDK 7 HashMap 并发死链问题
2、为什么HashTable慢
Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。
3、ConcurrentHashMap - JDK1.7
在JDK1.5~1.7版本,Java使用了分段锁机制
实现ConcurrentHashMap.
简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可实现多线程put操作。
- 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
- 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化
接下来分析JDK1.7版本中ConcurrentHashMap的实现原理。
1、数据结构
整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表“部分”或“一段”的意思,所以很多地方都会将其描述为分段锁
。一般使用“槽”来代表一个 segment。
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrencyLevel
:并行级别、并发数、Segment 数,怎么翻译不重要,重要的是理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
2、初始化
initialCapacity
:初始容量。这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。loadFactor
:负载因子。之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。
1 | public ConcurrentHashMap(int initialCapacity, |
初始化完成,我们得到了一个 Segment 数组。如下图所示:
我们就当是用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:
- Segment 数组长度为 16,不可以扩容
- Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
- 这里初始化了 segment[0],其他位置还是 null,至于为什么要初始化 segment[0],后面的代码会介绍
- 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数和掩码,这两个值马上就会用到
可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好
其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment
例如,根据某一 hash 值求 segment 位置,先将高位向低位移动 this.segmentShift 位
结果再与 this.segmentMask 做位于运算,最终得到 1010 即下标为 10 的 segment
3、put过程分析
先看 put 的主流程,对于其中的一些关键细节操作,后面会进行详细介绍:
1 | public V put(K key, V value) { |
第一层皮很简单,根据 hash 值很快就能找到相应的 Segment,之后就是 Segment 内部的 put 操作了。
Segment 内部是由 数组+链表
组成的。segment 继承了可重入锁(ReentrantLock),它的 put 方法为
1 | final V put(K key, int hash, V value, boolean onlyIfAbsent) { |
整体流程还是比较简单的,由于有独占锁的保护,所以 segment 内部的操作并不复杂。至于这里面的并发问题,我们稍后再进行介绍。
到这里 put 操作就结束了,接下来,我们说一说其中几步关键的操作。
4、初始化槽:ensureSegment
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。
这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。
1 | private Segment<K,V> ensureSegment(int k) { |
总的来说,ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制。
5、获取写入锁:scanAndLockForPut
前面我们看到,在往某个 segment 中 put 的时候,首先会调用
1 | node = tryLock() ? null : scanAndLockForPut(key, hash, value) |
也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。
下面我们来具体分析这个方法中是怎么控制加锁的:
1 | private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { |
这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。
这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node。
6、扩容:rehash
重复一下,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍。
由于扩容发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全
首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值。
1 | // 如果超过了该 segment 的阈值,这个 segment 需要扩容 |
该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。
1 | // 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。 |
这里的扩容比之前的 HashMap 要复杂一些,代码难懂一点。上面有两个挨着的 for 循环,第一个 for 有什么用呢?
仔细一看发现,如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就是了,不需要做任何操作。
我觉得 Doug Lea 的这个想法也是挺有意思的,不过比较坏的情况就是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么这次遍历就有点浪费了。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。
7、get过程分析
相对于 put 来说,get 就很简单了。
- 计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽”
- 槽中也是一个数组,根据 hash 找到数组中具体的位置
- 到这里是链表了,顺着链表进行查找即可
1 | public V get(Object key) { |
8、size计算流程
- 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
- 如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
1 | public int size() { |
9、并发问题分析
现在我们已经说完了 put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。
添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。
- put 操作的线程安全性:
- 初始化槽,这个我们之前就说过了,使用了 CAS 来初始化 Segment 中的数组。
- 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 **get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于
setEntryAt
方法中使用的UNSAFE.putOrderedObject
**。 - 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 ==table 使用了 volatile 关键字==。
- remove 操作的线程安全性:
- remove 操作我们没有分析源码,所以这里说的读者感兴趣的话还是需要到源码中去求实一下的。
- get 操作需要遍历链表,但是 remove 操作会”破坏”链表。
- 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
- 如果 remove 先破坏了一个节点,分两种情况考虑。
- 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
- 如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。
4、ConcurrentHashMap - JDK1.8
在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。
1、数据结构
结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。
重要属性和内部类
1 | // 默认为 0 |
相关的重要方法
1 | // 获取 Node[] 中第 i 个 Node |
2、初始化
1 | // 这构造函数里,什么都不干 |
这个初始化方法有点意思,通过提供初始容量,计算了 sizeCtl:sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。
sizeCtl 这个属性使用的场景很多,不过只要跟着文章的思路来,就不会被它搞晕了。
1 | // 有参构造 |
可以看到实现了懒惰初始化,在构造方法中仅仅计算了 table 的大小,以后在第一次使用时才会真正创建
3、put过程分析
仔细地一行一行代码看下去:(以下数组简称(table),链表简称(bin))
1 | public V put(K key, V value) { |
addCount()函数:
1 | // check 是之前 binCount 的个数(也就是链表的长度) |
这个增加size计数的方法与LongAdder的原理有点像:都是采用的分段累加的思想
它还有一个功能就是:扩容
4、初始化数组:initTable
这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。
初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。
1 | private final Node<K,V>[] initTable() { |
5、链表转红黑树:treeifyBin
前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容。
我们还是进行源码分析吧。
1 | private final void treeifyBin(Node<K,V>[] tab, int index) { |
6、扩容:tryPresize
如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。
这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。
这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍。
1 | // 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了(n << 1) |
这个方法的核心在于 sizeCtl
值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。
所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。
7、数据迁移:transfer
下面这个方法有点长,将原来的 tab 数组的元素迁移到新的 nextTab 数组中。
虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。
此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。
阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride
,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex
的作用。
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。
1 | private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { |
说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制。
这个时候,再回去仔细看 tryPresize 方法可能就会更加清晰一些了。
8、get过程分析
get 方法从来都是最简单的,这里也不例外:
- 计算 hash 值
- 根据 hash 值找到数组对应位置:(n - 1) & h
- 根据该位置处结点性质进行相应查找
- 如果该位置为 null,那么直接返回 null 就可以了
- 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
- 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
- 如果以上 3 条都不满足,那就是链表,进行遍历比对即可
1 | public V get(Object key) { |
简单说一句,此方法的大部分内容都很简单,只有正好碰到扩容的情况,ForwardingNode.find(int h, Object k) 稍微复杂一些,不过在了解了数据迁移的过程后,这个也就不难了,所以限于篇幅这里也不展开说了。
9、size 计算流程
size 计算实际发生在 put,remove 改变集合元素的操作之中
- 没有竞争发生,向 baseCount 累加计数
- 有竞争发生,新建 counterCells,向其中的一个 cell 累加计数
- counterCells 初始有两个 cell
- 如果计数竞争比较激烈,会创建新的 cell 来累加计数
1 | public int size() { |
10、总结
Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)
- 初始化,使用 cas 来保证并发安全,懒惰初始化 table
- 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头
- put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
- get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新 table 进行搜索
- 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
- size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加即可
5、对比总结
HashTable
:使用了synchronized
关键字对put等操作进行加锁;ConcurrentHashMap JDK1.7
:使用分段锁机制
实现;ConcurrentHashMap JDK1.8
:则使用数组+链表+红黑树数据结构和CAS原子操作
实现;
6、正确使用ConcurrentHashMap——computeIfAbsent()方法
示例:单词计数
生成测试数据:
1 | static final String ALPHA = "abcedfghijklmnopqrstuvwxyz"; |
模版代码,模版代码中封装了多线程读取文件的代码
1 | private static <V> void demo(Supplier<Map<String,V>> supplier, BiConsumer<Map<String,V>,List<String>> consumer) { |
你要做的是实现两个参数
- 一是提供一个 map 集合,用来存放每个单词的计数结果,key 为单词,value 为计数
- 二是提供一组操作,保证计数的安全性,会传递 map 集合以及 单词 List
正确结果输出应该是每个单词出现 200 次
1 | {a=200, b=200, c=200, d=200, e=200, f=200, g=200, h=200, i=200, j=200, k=200, l=200, m=200, n=200, o=200, p=200, q=200, r=200, s=200, t=200, u=200, v=200, w=200, x=200, y=200, z=200} |
下面的实现为:
1 | demo( |
有没有问题?请改进
问题:使用HashMap,线程不安全
改进:使用ConcurrentHashMap替换HashMap
运行发现问题:就算加上了ConcurrentHashMap也不能保证线程安全
原因:原因不难发现,ConcurrentHashMap只是保证了单一操作的线程安全,但是单一线程的组合并不保证线程安全。我们可以发现:
Integer counter = map.get(word);
:根据Key(word单词)获取Value(计数)——读操作int newValue = counter == null ? 1 : counter + 1; map.put(word, newValue);
- 如果Key(单词)存在,则计数加1
- 如果Key(单词)不存在,则计数为1
- 在将结果的Key与Value放入Map容器当中——写操作
虽然ConcurrentHashMap能保证单一的读操作或单一读操作的线程安全,但是读操作与写操作的组合并不能保证线程安全
解决方法1:将读操作与写操作一起加入Synchronized(map)同步代码块当中
- 缺点:锁的粒度太大,线程的效率降低
解决方法2:使用ConcurrentHashMap的
computeIfAbsent()
方法注意:
- 累加操作也是需要保证线程安全性,所以使用的是LongAdder累加器来完成累加操作
- 注意不能使用
putIfAbsent
,此方法返回的是上一次的 value,首次调用返回 null
demo( // 创建 map 集合 // 创建 ConcurrentHashMap 对不对? () -> new ConcurrentHashMap<String, LongAdder>(8,0.75f,8), (map, words) -> { for (String word : words) { // 如果缺少一个 key,则计算生成一个 value , 然后将 key value 放入 map // a 0 LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder()); // 执行累加 value.increment(); // 2 /*// 检查 key 有没有 Integer counter = map.get(word); int newValue = counter == null ? 1 : counter + 1; // 没有 则 put map.put(word, newValue);*/ } } );
1
2
3
4
5
6
7
8
9
10
11
12
13
- 解决方法3:使用函数式编程,无需原子变量——使用ConcurrentHashMap的merge方法
- ```java
demo(
() -> new ConcurrentHashMap<String, Integer>(),
(map, words) -> {
for (String word : words) {
// 函数式编程,无需原子变量
map.merge(word, 1, Integer::sum);
}
}
);
7、JDK 7 HashMap 并发死链问题原理
1、JDK 7 HashMap 并发死链问题
JDK 7 HashMap会出现死链问题的原因:JDK 7 HashMap的扩容数组的方法
- JDK 7 HashMap使用的是头插法进行扩容数组的(JDK 8 HashMap使用的是尾插法——“七上八下”)
- 在多线程下,就有可能出现死链问题
- 实际上就是一个线程在扩容时把链表节点倒过来了,而另一个线程在扩容时正好也在前一个节点,就死循环了
下面使用一些测试代码和用debug的模式来验证JDK 7 HashMap 并发死链问题
注意:
- 要在 JDK 7 下运行,否则扩容机制和 hash 的计算方法都变了
- 以下测试代码是精心准备的,不要随便改动
1 | import java.util.HashMap; |
2、死链复现
调试工具使用 idea
在 HashMap 源码 590 行加断点
1 | int newCapacity = newTable.length; |
断点的条件如下,目的是让 HashMap 在扩容为 32 时,并且线程为 Thread-0 或 Thread-1 时停下来
1 | newTable.length==32 && |
断点暂停方式选择 Thread,否则在调试 Thread-0 时,Thread-1 无法恢复运行运行代码,程序在预料的断点位置停了下来,输出
1 | 长度为16时,桶下标为1的key |
接下来进入扩容流程调试
在 HashMap 源码 594 行加断点
1 | Entry<K,V> next = e.next; // 593 |
这是为了观察 e 节点和 next 节点的状态,Thread-0 单步执行到 594 行,再 594 处再添加一个断点,条件为
1 | Thread.currentThread().getName().equals("Thread-0") |
这时可以在 Variables 面板观察到 e 和 next 变量,使用 view as -> Object
查看节点状态
1 | e (1)->(35)->(16)->null |
在 Threads 面板选中 Thread-1 恢复运行,可以看到控制台输出新的内容如下,Thread-1 扩容已完成
1 | newTable[1] (35)->(1)->null |
这时 Thread-0 还停在 594 处, Variables 面板变量的状态已经变化为
1 | e (1)->null |
为什么呢,因为 Thread-1 扩容时链表也是后加入的元素放入链表头,因此链表就倒过来了,但 Thread-1 虽然结果正确,但它结束后 Thread-0 还要继续运行
接下来就可以单步调试(F8)观察死链的产生了
下一轮循环到 594,将 e 搬迁到 newTable 链表头
1 | newTable[1] (35)->(1)->null |
下一轮循环到 594,将 e 搬迁到 newTable 链表头
1 | newTable[1] (35)->(1)->null |
再看看源码
1 | e.next = newTable[1]; |
3、通过JDK 7 HashMap源码分析死链问题
HashMap 的并发死链发生在扩容时
1 | // 将 table 迁移至 newTable |
假设 map 中初始元素是
1 | 原始链表,格式:[下标] (key,next) |
4、小结
- 究其原因,是因为在多线程环境下使用了非线程安全的 map 集合
- JDK 8 虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序)(尾插法),但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)
5、HashSet不安全
同理,HashSet在多线程的环境下也是不安全的。解决方法:JUC的CopyOnWriteArraySet
CopyOnWriteArraySet:对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。
10、JUC并发集合:BlockingQueue接口(阻塞队列)
JUC里的 BlockingQueue
接口表示一个线程安全放入和提取实例的队列。下面将给你演示如何使用这个 BlockingQueue,不会讨论如何在 Java 中实现一个你自己的 BlockingQueue。
1、BAT大厂的面试问题
- 什么是BlockingDeque?
- BlockingQueue大家族有哪些?
- ArrayBlockingQueue, DelayQueue, LinkedBlockingQueue, SynchronousQueue…
- BlockingQueue适合用在什么样的场景?
- BlockingQueue常用的方法?
- BlockingQueue插入方法有哪些?这些方法(
add(o)
,offer(o)
,put(o)
,offer(o, timeout, timeunit)
)的区别是什么? - BlockingDeque 与BlockingQueue有何关系,请对比下它们的方法?
- BlockingDeque适合用在什么样的场景?
- BlockingDeque大家族有哪些?
- BlockingDeque 与BlockingQueue实现例子?
2、BlockingQueue和BlockingDeque
1、BlockingQueue
1、什么是BlockQueue?
BlockingQueue 通常用于==一个线程生产对象,而另外一个线程消费这些对象==的场景。下图是对这个原理的阐述:
线程1往阻塞队列里添加元素,线程2从阻塞队列里移除元素。
- 一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的。
- 如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。
- 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。
2、为什么需要BlockQueue?
- 好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue 都给你一手包办了;
- 在 concurrent 包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。
3、适用场景——经典的“生产者”和 “消费者”模型
在多线程环境中,通过队列可以很容易实现数据共享,比如经典的“生产者”和 “消费者”模型中,通过队列可以很便利地实现两者之间的数据共享。
假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。
但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。
- 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列
- 队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒
2、BlockingQueue的方法
BlockingQueue 具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:
抛异常 | 特定值 | 阻塞 | 超时 | |
---|---|---|---|---|
插入 | add(o) | offer(o) | put(o) | offer(o, timeout, timeunit) |
移除 | remove(o) | poll(o) | take(o) | poll(timeout, timeunit) |
检查 | element(o) | peek(o) |
四组不同的行为方式解释:
- 抛异常:如果试图的操作无法立即执行,抛一个异常。
- 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
- 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
- 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
BlockingQueue 的核心方法:
- 放入数据
- offer(anObject):表示如果可能的话,将 anObject 加到 BlockingQueue 里,即如果 BlockingQueue 可以容纳,则返回 true,否则返回 false。(本方法不阻塞当前执行方法的线程)
- offer(E o, long timeout, TimeUnit unit):可以设定等待的时间,如果在指定的时间内,还不能往队列中加入 BlockingQueue,则返回失败。
- put(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程被阻断直到 BlockingQueue 里面有空间再继续。
- add(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程抛出一个异常:Queue full。
- 获取数据
- poll(time):取走 BlockingQueue 里排在首位的对象,若不能立即取出,则可以等time 参数规定的时间,取不到时返回 null。
- poll(long timeout, TimeUnit unit):从 BlockingQueue 取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回null。
- take():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,阻断进入等待状态直到 BlockingQueue 有新的数据被加入;
- remove():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,抛出一个异常:
NoSuchElementException
- drainTo():一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定
获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。
注意:
- 无法向一个 BlockingQueue 中插入 null。如果你试图插入 null,BlockingQueue 将会抛出一个 NullPointerException。
- 可以访问到 BlockingQueue 中的所有元素,而不仅仅是开始和结束的元素。比如说,你将一个对象放入队列之中以等待处理,但你的应用想要将其取消掉。那么你可以调用诸如 remove(o) 方法来将队列之中的特定对象进行移除。
- 但是这么干效率并不高(译者注:基于队列的数据结构,获取除开始或结束位置的其他对象的效率不会太高),因此你尽量不要用这一类的方法,除非你确实不得不那么做。
3、BlockingDeque
java.util.concurrent 包里的 BlockingDeque 接口表示一个线程安全放入和提取实例的双端队列。
BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。
使用情景:
- 在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque。
- 如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。
BlockingDeque 图解:
4、BlockingDeque的方法
一个 BlockingDeque - 线程在双端队列的两端都可以插入和提取元素。 一个线程生产元素,并把它们插入到队列的任意一端。如果双端队列已满,插入线程将被阻塞,直到一个移除线程从该队列中移出了一个元素。如果双端队列为空,移除线程将被阻塞,直到一个插入线程向该队列插入了一个新元素。
BlockingDeque 具有 4 组不同的方法用于插入、移除以及对双端队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:
抛异常 | 特定值 | 阻塞 | 超时 | |
---|---|---|---|---|
插入 | addFirst(o) | offerFirst(o) | putFirst(o) | offerFirst(o, timeout, timeunit) |
移除 | removeFirst(o) | pollFirst(o) | takeFirst(o) | pollFirst(timeout, timeunit) |
检查 | getFirst(o) | peekFirst(o) |
抛异常 | 特定值 | 阻塞 | 超时 | |
---|---|---|---|---|
插入 | addLast(o) | offerLast(o) | putLast(o) | offerLast(o, timeout, timeunit) |
移除 | removeLast(o) | pollLast(o) | takeLast(o) | pollLast(timeout, timeunit) |
检查 | getLast(o) | peekLast(o) |
四组不同的行为方式解释:
- 抛异常:如果试图的操作无法立即执行,抛一个异常。
- 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
- 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
- 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
5、BlockingQueue和BlockingDeque的关系
BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。
以下是 BlockingDeque 对 BlockingQueue 接口的方法的具体内部实现:
BlockingQueue | BlockingDeque |
---|---|
add() | addLast() |
offer() x 2 | offerLast() x 2 |
put() | putLast() |
remove() | removeFirst() |
poll() x 2 | pollFirst() |
take() | takeFirst() |
element() | getFirst() |
peek() | peekFirst() |
3、BlockingQueue的例子
这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue
实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。
1 | public class BlockingQueueExample { |
以下是 Producer 类。注意它在每次 put() 调用时是为何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。
1 | public class Producer implements Runnable{ |
以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。
1 | public class Consumer implements Runnable{ |
**1、数组阻塞队列——ArrayBlockingQueue
**(常用)
ArrayBlockingQueue 类实现了 BlockingQueue 接口。
ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里。
- 有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。
- 你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注: 因为它是基于数组实现的,也就具有数组的特性:一旦初始化,大小就无法修改)。
- ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
- 除了一个定长数组外,ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。
ArrayBlockingQueue 与 LinkedBlockingQueue 的区别:
ArrayBlockingQueue 在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;
- 按照实现原理来分析,ArrayBlockingQueue 完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea 之所以没这样去做,也许是因为 ArrayBlockingQueue 的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。
ArrayBlockingQueue 和LinkedBlockingQueue 间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node 对象。
- 这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC 的影响还是存在一定的区别。
在创建 ArrayBlockingQueue 时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。
以下是在使用 ArrayBlockingQueue 的时候对其初始化的一个示例:
1 | BlockingQueue queue = new ArrayBlockingQueue(1024); |
以下是使用了 Java 泛型的一个 BlockingQueue 示例。注意其中是如何对 String 元素放入和提取的:
1 | BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1024); |
==一句话总结:ArrayBlockingQueue 是由数组结构组成的有界阻塞队列。==
2、延迟队列——DelayQueue
DelayQueue 实现了 BlockingQueue 接口。
DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口,该接口定义:
1 | public interface Delayed extends Comparable<Delayed> { |
DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉。
传递给 getDelay 方法的 getDelay 实例是一个枚举类型,它表明了将要延迟的时间段。TimeUnit 枚举将会取以下值:
- DAYS——天
- HOURS——时
- INUTES——分钟
- SECONDS——秒
- MILLISECONDS——毫秒
- MICROSECONDS——微秒
- NANOSECONDS——纳秒
正如你所看到的,Delayed 接口也继承了 java.lang.Comparable 接口,这也就意味着 Delayed 对象之间可以进行对比。这个可能在对 DelayQueue 队列中的元素进行排序时有用,因此它们可以根据过期时间进行有序释放。 以下是使用 DelayQueue 的例子:
1 | public class DelayQueueExample { |
DelayedElement 是我所创建的一个 DelayedElement 接口的实现类,它不在 java.util.concurrent 包里。你需要自行创建你自己的 Delayed 接口的实现以使用 DelayQueue 类。
==一句话总结:使用优先级队列实现的延迟无界阻塞队列。==
**3、链阻塞队列——LinkedBlockingQueue
**(常用)
LinkedBlockingQueue 类实现了 BlockingQueue 接口。
LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。
LinkedBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
ArrayBlockingQueue 和 LinkedBlockingQueue 是两个最普通也是最常用的阻塞队列,一般情况下,在处理多线程间的生产者消费者问题,使用这两个类足以。
以下是 LinkedBlockingQueue 的初始化和使用示例代码:
1 | BlockingQueue<String> unbounded = new LinkedBlockingQueue<String>(); |
==一句话总结:由链表结构组成的有界(但大小默认值为integer.MAX_VALUE)阻塞队列。==
4、具有优先级的阻塞队列——PriorityBlockingQueue
PriorityBlockingQueue 类实现了 BlockingQueue 接口。
PriorityBlockingQueue 是一个基于优先级的无界的并发队列。(优先级的判断通过构造函数传入的 Compator 对象来决定)
它使用了和类 java.util.PriorityQueue 一样的排序规则。
- 你无法向这个队列中插入 null 值。
- 所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。
注意:
- PriorityBlockingQueue 对于具有相等优先级(compare() == 0)的元素并不强制任何特定行为。
- 如果你从一个 PriorityBlockingQueue 获得一个 Iterator 的话,该 Iterator 并不能保证它对元素的遍历是以优先级为序的。
- 由于PriorityBlockingQueue是无界的,所以PriorityBlockingQueue 并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。
- 因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。
- 在实现 PriorityBlockingQueue 时,内部控制线程同步的锁采用的是==公平锁==。
以下是使用 PriorityBlockingQueue 的示例:
1 | BlockingQueue queue = new PriorityBlockingQueue(); |
==一句话总结:支持优先级排序的无界阻塞队列。==
5、同步队列——SynchronousQueue
SynchronousQueue 类实现了 BlockingQueue 接口。
SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。 据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。
声明一个 SynchronousQueue 有两种不同的方式——公平模式和非公平模式,它们之间有着不太一样的行为:
- 公平模式:SynchronousQueue 会采用公平锁,并配合一个 FIFO 队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
- 非公平模式(SynchronousQueue 默认):SynchronousQueue 采用非公平锁,同时配合一个 LIFO 队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。
==一句话总结:不存储元素的阻塞队列,也即单个元素的队列。==
6、链阻塞无界队列——LinkedTransferQueue
LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和transfer 方法。
LinkedTransferQueue 采用一种预占模式。意思就是消费者线程取元素时,如果队列不为空,则直接取走数据,若队列为空,那就生成一个节点(节点元素为 null)入队,然后消费者线程被等待在这个节点上,后面生产者线程入队时发现有一个元素为 null 的节点,生产者线程就不入队了,直接就将元素填充到该节点,并唤醒该节点等待的线程,被唤醒的消费者线程取走元素,从调用的方法返回。
==一句话总结:由链表组成的无界阻塞队列。==
4、BlockingDeque 的例子
既然 BlockingDeque 是一个接口,那么你想要使用它的话就得使用它的众多的实现类的其中一个。java.util.concurrent 包提供了以下 BlockingDeque 接口的实现类:LinkedBlockingDeque
。
以下是如何使用 BlockingDeque 方法的一个简短代码示例:
1 | BlockingDeque<String> deque = new LinkedBlockingDeque<String>(); |
链阻塞双端队列——LinkedBlockDeque
LinkedBlockingDeque 类实现了 BlockingDeque 接口。
LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列,即可以从队列的两端插入和移除元素。
deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。
LinkedBlockingDeque 是一个双端队列,在它为空的时候,一个试图从中抽取数据的线程将会阻塞,无论该线程是试图从哪一端抽取数据。
对于一些指定的操作,在插入或者获取队列元素时如果队列状态不允许该操作可能会阻塞住,该线程直到队列状态变更为允许操作,这里的阻塞一般有两种情况:
- 插入元素时:如果当前队列已满将会进入阻塞状态,一直等到队列有空的位置时再讲该元素插入,该操作可以通过设置超时参数,超时后返回 false 表示操作失败,也可以不设置超时参数一直阻塞,中断后抛出 InterruptedException 异常
- 读取元素时:如果当前队列为空会阻塞住直到队列不为空然后返回元素,同样可以通过设置超时参数
以下是 LinkedBlockingDeque 实例化以及使用的示例:
1 | BlockingDeque<String> deque = new LinkedBlockingDeque<String>(); |
==一句话总结:由链表组成的双向阻塞队列==
11、JUC集合:LinkedBlockingQueue详解
1、LinkedBlockingQueue 原理
1、基本的入队出队
1 | public class LinkedBlockingQueue<E> extends AbstractQueue<E> |
初始化链表 last = head = new Node<E>(null);
Dummy 节点用来占位,item 为 null
当一个节点入队 last = last.next = node;
再来一个节点入队 last = last.next = node;
出队
1 | Node<E> h = head; |
h = head
first = h.next
h.next = h
head = first
1 | E x = first.item; |
2、加锁分析
==高明之处==在于用了两把锁和 dummy 节点
- 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
- 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行(锁住的队列的头和尾)
- 消费者与消费者线程仍然串行
- 生产者与生产者线程仍然串行
线程安全分析
- 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
- 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
- 这里就体现了dummy 占位节点的用处了:就算只剩下一个正常的结点,两把锁锁住的依旧是两个对象,没有竞争
- 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
1 | // 用于 put(阻塞) offer(非阻塞) |
put 操作
1 | public void put(E e) throws InterruptedException { |
take 操作
1 | public E take() throws InterruptedException { |
由 put 唤醒 put 是为了避免信号不足
2、性能比较
主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较
- Linked 支持有界,Array 强制有界
- Linked 实现是链表,Array 实现是数组
- Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
- Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
- Linked 两把锁,Array 一把锁
12、JUC集合:ConcurrentLinkedQueue详解
- 一个基于链接节点的无界线程安全队列。此队列按照 ==FIFO(先进先出)原==则对元素进行排序。
- 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素。
- 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
- 当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。
- 此队列不允许使用 null 元素。
ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是
- 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
- dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
- 只是这【锁】使用了 cas 来实现
事实上,ConcurrentLinkedQueue 应用还是非常广泛的
例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用
1、BAT大厂的面试问题
- 要想用线程安全的队列有哪些选择?
- Vector,
Collections.synchronizedList(List<T> list)
, ConcurrentLinkedQueue等
- Vector,
- ConcurrentLinkedQueue实现的数据结构?
- ConcurrentLinkedQueue底层原理?
- 全程无锁(CAS)
- ConcurrentLinkedQueue的核心方法有哪些?
- offer(),poll(),peek(),isEmpty()等队列常用方法
- 说说ConcurrentLinkedQueue的HOPS(延迟更新的策略)的设计?
- ConcurrentLinkedQueue适合什么样的使用场景?
2、ConcurrentLinkedQueue数据结构
通过源码分析可知,ConcurrentLinkedQueue的数据结构与LinkedBlockingQueue的数据结构相同,都是使用的链表结构。
ConcurrentLinkedQueue的数据结构如下:
说明:ConcurrentLinkedQueue采用的链表结构,并且包含有一个头结点和一个尾结点。
3、ConcurrentLinkedQueue源码分析
1、类的继承关系
1 | public class ConcurrentLinkedQueue<E> extends AbstractQueue<E> |
说明:ConcurrentLinkedQueue继承了抽象类AbstractQueue,AbstractQueue定义了对队列的基本操作;同时实现了Queue接口,Queue定义了对队列的基本操作,同时,还实现了Serializable接口,表示可以被序列化。
2、类的内部类
1 | private static class Node<E> { |
说明:Node类表示链表结点,用于存放元素,包含item域和next域,item域表示元素,next域表示下一个结点,**其利用反射机制和CAS机制来更新item域和next域,==保证原子性==**。
3、类的属性
1 | public class ConcurrentLinkedQueue<E> extends AbstractQueue<E> |
说明:属性中包含了head域和tail域,表示链表的头结点和尾结点,同时,ConcurrentLinkedQueue也使用了反射机制和CAS机制来更新头结点和尾结点,==保证原子==性。
4、 类的构造函数
ConcurrentLinkedQueue()
型构造函数public ConcurrentLinkedQueue() { // 初始化头结点与尾结点 head = tail = new Node<E>(null); }
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
- 说明:**该构造函数用于创建一个最初为空的 ConcurrentLinkedQueue,头结点与尾结点指向同一个结点,该结点的item域为null,next域也为null**。
- `ConcurrentLinkedQueue(Collection<? extends E>)`型构造函数
- ```java
public ConcurrentLinkedQueue(Collection<? extends E> c) {
Node<E> h = null, t = null;
for (E e : c) { // 遍历c集合
// 保证元素不为空
checkNotNull(e);
// 新生一个结点
Node<E> newNode = new Node<E>(e);
if (h == null) // 头结点为null
// 赋值头结点与尾结点
h = t = newNode;
else {
// 直接头结点的next域
t.lazySetNext(newNode);
// 重新赋值头结点
t = newNode;
}
}
if (h == null) // 头结点为null
// 新生头结点与尾结点
h = t = new Node<E>(null);
// 赋值头结点
head = h;
// 赋值尾结点
tail = t;
}说明:该构造函数用于创建一个最初包含给定 collection 元素的 ConcurrentLinkedQueue,按照此 collection 迭代器的遍历顺序来添加元素。
5、核心函数分析
1、offer函数
1 | public boolean offer(E e) { |
说明:offer函数用于将指定元素插入此队列的尾部。下面模拟offer函数的操作,队列状态的变化(假设单线程添加元素,连续添加10、20两个元素)。
- 若ConcurrentLinkedQueue的初始状态如上图所示,即队列为空。单线程添加元素,此时,添加元素10,则状态如下所示:
- 如上图所示,添加元素10后,tail没有变化,还是指向之前的结点,继续添加元素20,则状态如下所示:
- 如上图所示,添加元素20后,tail指向了最新添加的结点。
2、poll函数
1 | public E poll() { |
说明:此函数用于获取并移除此队列的头,如果此队列为空,则返回null。下面模拟poll函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,poll两次)。
- 队列初始状态如上图所示,在poll操作后,队列的状态如下图所示:
- 如上图可知,poll操作后,head改变了,并且head所指向的结点的item变为了null。再进行一次poll操作,队列的状态如下图所示:
- 如上图可知,poll操作后,head结点没有变化,只是指示的结点的item域变成了null。
3、remove函数
1 | public boolean remove(Object o) { |
说明:**此函数用于从队列中移除指定元素的单个实例(如果存在)**。其中,会调用到first函数和succ函数,first函数的源码如下:
1 | Node<E> first() { |
说明:first函数用于找到链表中第一个存活的结点。succ函数源码如下:
1 | final Node<E> succ(Node<E> p) { |
说明:succ用于获取结点的下一个结点。如果结点的next域指向自身,则返回head头结点,否则,返回next结点。
下面模拟remove函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,执行remove(10)、remove(20)操作)。
- 如上图所示,为ConcurrentLinkedQueue的初始状态,remove(10)后的状态如下图所示:
- 如上图所示,当执行remove(10)后,head指向了head结点之前指向的结点的下一个结点,并且head结点的item域置为null。继续执行remove(20),状态如下图所示:
- 如上图所示,执行remove(20)后,head与tail指向同一个结点,item域为null。
4、size函数
1 | public int size() { |
说明:此函数用于返回ConcurrenLinkedQueue的大小,从第一个存活的结点(first)开始,往后遍历链表,当结点的item域不为null时,增加计数,之后返回大小。
4、ConcurrentLinkedQueue示例
1 | import java.util.concurrent.ConcurrentLinkedQueue; |
运行结果(某一次):
1 | add 0 |
说明:GetThread线程不会因为ConcurrentLinkedQueue队列为空而等待,而是直接返回null,所以当实现队列不空时,等待时,则需要用户自己实现等待逻辑。
5、再深入理解
1、HOPS(延迟更新的策略)的设计
通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:
tail更新触发时机
:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。head更新触发时机
:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。
并且在更新操作时,源码中会有注释为:hop two nodes at a time
。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个(猜的 😃),从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢?
如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。
2、ConcurrentLinkedQueue适合的场景
ConcurrentLinkedQueue通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考。
13、多线程锁
1、公平锁与非公平锁
1、公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
2、非公平锁
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
3、公平锁与非公平锁
公平和非公平都是排序队列的,但是公平的新创建的线程会排到所有的就绪队列之后,非公平的线程会和就绪队列直接竞争资源,也就是插队。
举个去KFC吃饭的例子(来自敖丙dalao的例子)
- 现在是早餐时间,敖丙想去kfc搞个早餐,发现有很多人了,一过去没多想,就乖乖到队尾排队,这样大家都觉得很公平,先到先得,所以这是公平锁咯。
- 那非公平锁就是,敖丙过去买早餐,发现大家都在排队,但是敖丙这个人有点渣的,就是喜欢插队,那他就直接怼到第一位那去,后面的鸡蛋,米豆都不行,我插队也不敢说什么,只能默默忍受了。
- 但是偶尔,鸡蛋也会崛起,叫我滚到后面排队,我也是欺软怕硬,默默到后面排队,就插队失败了。
4、公平锁与非公平锁的实现——ReentrantLock(具体看一看上文的ReentrantLock)
在上文中介绍了ReentrantLock类,以及ReentrantLock类的三个内部类——Sync
、NonfairSync
、FairSync
。
其中NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。
而NonfairSync实现的就是非公平锁(ReentrantLock的默认实现),FairSync实现的就是公平锁。
公平锁:(FairSync源码)
1 | /** |
仔细看FairSync的源码就能发现,它加了一个hasQueuedPredecessors
的判断,那他判断里面有些什么玩意呢?
1 | public final boolean hasQueuedPredecessors() { |
代码的大概意思也是判断当前的线程是不是位于同步队列的首位,是就是返回true,否就返回false。
非公平锁:(NonfairSync源码)
1 | // 获得锁 |
从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。
5、公平锁与非公平锁的实现过程
非公平锁:
- A线程准备进去获取锁,首先判断了一下state状态,发现是0,所以可以CAS成功,并且修改了当前持有锁的线程为自己。
- 这个时候B线程也过来了,也是一上来先去判断了一下state状态,发现是1,那就CAS失败了,真晦气,只能乖乖去等待队列,等着唤醒了,先去睡一觉吧。
- A持有久了,也有点腻了,准备释放掉锁,给别的仔一个机会,所以改了state状态,抹掉了持有锁线程的痕迹,准备去叫醒B。
- 这个时候有个带绿帽子的仔C过来了,发现state怎么是0啊,果断CAS修改为1,还修改了当前持有锁的线程为自己。
- B线程被A叫醒准备去获取锁,发现state居然是1,CAS就失败了,只能失落的继续回去等待队列,路线还不忘骂A渣男,怎么骗自己,欺骗我的感情。
以上就是一个非公平锁的线程,这样的情况就有可能像B这样的线程长时间无法得到资源,优点就是可能有的线程减少了等待时间,提高了利用率。
公平锁:
- 线A现在想要获得锁,先去判断下state,发现也是0,去看了看队列,自己居然是第一位,果断修改了持有线程为自己。
- 线程B过来了,去判断一下state,嗯哼?居然是state=1,那cas就失败了呀,所以只能乖乖去排队了。
- 线程A暖男来了,持有没多久就释放了,改掉了所有的状态就去唤醒线程B了,这个时候线程C进来了,但是他先判断了下state发现是0,以为有戏,然后去看了看队列,发现前面有人了,作为新时代的良好市民,果断排队去了。
- 线程B得到A的召唤,去判断state了,发现值为0,自己也是队列的第一位,那很香呀,可以得到了。
以上就是一个公平锁的线程,这样的情况就不会出现线程长时间无法得到资源,缺点就是要判断当前的等待队列是否有线程在等待,花费的开销较大,效率不行。
6、深入:公平锁真的公平吗?
公平锁相关代码:
1 | protected final boolean tryAcquire(int acquires) { |
1 | public final boolean hasQueuedPredecessors() { |
1 | private Node enq(final Node node) { |
情景:假设当前有三个线程A、B、C
,分别取调用公平锁的lock.lock()
- 假设线程
A
一马当先,先获取到锁,此时state == 1
。然后线程B
,也来到了tryAcquire
方法- 公平锁与非公平锁的区别就是在
tryAcquire
中会判断是否有先驱节点,也就是方法hasQueuedPredecessors
- 公平锁与非公平锁的区别就是在
- 此时
tail
和head
都null
,所以肯定方法hasQueuedPredecessors
返回false
- 线程
B
回到tryAcquire
中执行cas_state
方法,由于A
还没有释放锁,所以肯定获取不到,最终返回false
,需要加入同步队列。在addWaiter
中,由于tail == null
直接进入enq
方法。 - ①和②便是重点。
情景1:
当线程
B
执行到①,此时head
有值,但是tail
还是为null
此时线程
C
也执行到hasQueuedPredecessors
Node t = null; Node h = new Node(); 此时 h != t && ((s = h.next) == null) 为true
1
2
3
4
5
6
7
8
9
10
11
12
因此线程`C`不能插队,也要加入等待队列。
###### 情景2:
- 当线程`B`执行到②,此时`head`有值,且`head == tail`
- 此时线程`C`也执行到`hasQueuedPredecessors`
- ```java
Node t = h
此时 h != t 为false 短路直接返回
因此线程C
可以插队,去执行cas_state
方法
假设在执行cas
方法之前,线程A
已经释放了锁,那么线程C
就可以插队,先于B
抢到锁。
关于公平锁源码中hasQueuedPredecessors()方法中tail和head赋值顺序问题
如果head
先于tail
赋值
1 | public final boolean hasQueuedPredecessors() { |
总结
ReentrantLock中的公平锁只有在等待队列中存在等待节点(不包括虚节点)的时候,才是真正意义上的公平锁。
2、可重入锁
1、什么是重入锁
通常情况下,锁可以用来控制多线程的访问行为。那对于同一个线程,如果连续两次对同一把锁进行lock,会怎么样了?
对于一般的锁来说,这个线程就会被永远卡死在那边,比如:
1 | void handle() { |
这个特性相当不好用,因为在实际的开发过程中,函数之间的调用关系可能错综复杂,一个不小心就可能在多个不同的函数中,反复调用lock(),这样的话,线程就自己和自己卡死了。
所以,对于希望傻瓜式编程的我们来说,重入锁就是用来解决这个问题的。重入锁使得同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程卡死。因此,如果我们使用的是重入锁,那么上述代码就可以正常工作。你唯一需要保证的,就是unlock()的次数和lock()一样多(否则会造成死锁)。
2、重入锁的实现原理
java当中的重入锁——Lock接口的实现类ReentrantLock。其中最重要的方法——lock()
重入锁内部实现的主要类如下图:
重入锁的核心功能委托给内部类Sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。
实现重入锁的方法很简单,就是基于一个状态变量state。这个变量保存在AbstractQueuedSynchronizer
(AQS)对象中
1 | private volatile int state; |
当这个state==0
时,表示锁是空闲的,大于零表示锁已经被占用, 它的数值表示当前线程重复占用这个锁的次数。因此,lock()的最简单的实现是:
1 | final void lock() { |
下面是acquire() 的实现:
1 | public final void acquire(int arg) { |
3、公平的重入锁与非公平的重入锁
默认情况下,重入锁是不公平的。
那公平锁和非公平锁实现的核心区别在哪里呢?
对于lock()方法代码:
//非公平锁 final void lock() { //上来不管三七二十一,直接抢了再说 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else //抢不到,就进队列慢慢等着 acquire(1); } //公平锁 final void lock() { //直接进队列等着 acquire(1); }
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
- 从上面的代码中也不难看到,非公平锁如果第一次争抢失败,后面的处理和公平锁是一样的,都是进入等待队列慢慢等。
- 对于tryLock()方法代码:
- ```java
//非公平锁
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//上来不管三七二十一,直接抢了再说
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//如果就是当前线程占用了锁,那么就更新一下state,表示重复占用锁的次数
//这是“重入”的关键所在
else if (current == getExclusiveOwnerThread()) {
//我又来了哦~~~(重入)
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
//公平锁
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//先看看有没有别人在等,没有人等我才会去抢,有人在我前面 ,我就不抢啦
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
4、Condition
Condition
可以理解为重入锁的伴生对象。它提供了在重入锁的基础上,进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象,如下所示:
1 | private final Lock lock = new ReentrantLock(); |
那Condition对象怎么用呢?在JDK内部就有一个很好的例子。让我们来看一下ArrayBlockingQueue
吧。
ArrayBlockingQueue是一个队列,你可以把元素塞入队列(enqueue),也可以拿出来take()。但是有一个小小的条件,就是如果队列是空的,那么take()就需要等待,一直等到有元素了,再返回。
那这个功能,怎么实现呢?这就可以使用Condition对象了。
实际在ArrayBlockingQueue中,就维护一个Condition对象:
1 | lock = new ReentrantLock(true); |
这个notEmpty 就是一个Condition对象。它用来通知其他线程,ArrayBlockingQueue是不是空着的。当我们需要拿出一个元素时:
1 | public E take() throws InterruptedException { |
当有元素入队时:
1 | public boolean offer(E e) { |
因此,整个流程如图所示:
5、显示重入锁(lock
)与隐式重入锁(Synchronized
)
- 显示重入锁(
lock
):需要手动的上锁与释放锁的重入锁- 如上文所说,lock的ReentrantLock就是显示重入锁
- 隐式重入锁(
Synchronized
):自动的上锁与释放锁的重入锁- Synchronized的上锁与释放锁是由JVM自动控制的
6、重入锁的使用示例
使用重入锁,实现一个简单的计数器。这个计数器可以保证在多线程环境中,统计数据的精确性,请看下面示例代码:
1 | public class Counter { |
7、可重入锁总结
- 显示重入锁(
lock
)与隐式重入锁(Synchronized
) - 对于同一个线程,重入锁允许你反复获得通一把锁,但是,申请和释放锁的次数必须一致。
- 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
- 重入锁的内部实现是基于CAS操作的
- 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信
- 如果是不可重入锁的话,第一个锁没有解锁就不能操作第二个锁的内容
3、死锁
1、什么是死锁
两个或多个进程在运行过程中,因争夺资源而造成的一种相互等待的现象,当进程处于这种相互等待的状态时,若无外力作用,它们都将无法再向前推进。
2、产生死锁的三大原因
- 竞争可消耗资源
- 竞争不可抢占资源
- 系统中的资源可以分为两类:
- 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
- 另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
- 还有一种资源:临时资源。
- 包括硬件中断、信号、消息、缓冲区内的消息等
- 它可以是可剥夺资源,也可以是不可剥夺资源
- 产生死锁中的竞争资源指的是竞争不可剥夺资源的临时资源
- 系统中的资源可以分为两类:
- 进程运行推进顺序不当
- 若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
- 当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁
3、产生死锁的四大条件
产生死锁的必要条件:
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 循环等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
4、验证是否发生死锁的方法
- jps
- 类似linux的ps -ef
- jstack
- jvm自带的堆栈跟踪工具
- JConsole等工具
5、解决死锁的方法
处理死锁的方法可归结为四种:
- 预防死锁
- 避免死锁
- 检测死锁
- 解除死锁
1、预防死锁
预防死锁:通过破坏产生死锁的四个必要条件中的一个或几个,以避免发生死锁的方法
- 破坏“请求和条件”:
- 必须一次性申请其在整个运行过程中所需的全部资源
- 优点:简单、易行且安全
- 缺点:
- 资源被严重浪费,严重地恶化资源的利用率
- 使进程经常会发生饥饿现象
- 对上面方法的改进:允许一个进程只获得运行初期所需的资源后,便开始运行。进程运行过程中再逐步释放已分配给自己的、且已用完毕的全部资源,然后再请求新的所需资源。
- 必须一次性申请其在整个运行过程中所需的全部资源
- 破坏“不可抢占条件”:
- 当一个已经保存了某些不可抢占资源的进程,提出新的资源请求而不能满足时,它必须释放已经保持的所有资源,待以后需要时在重新申请。(这个方法代价太大,一般不使用这个方法)
- 破坏“循环等待条件”:
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反
对于java来说:
- 以确定的顺序获得锁
- 如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:
- 如果此时把获得锁的时序改成:
- 那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。
- 问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“循环等待”),可能就无法满足要求了,这个时候开发者可以使用
银行家算法
,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生。
- 如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:
- 超时放弃(Lock接口中的tryLock(long time, TimeUnit unit)使用的就是这个方法)
- 当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,
- 然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:
2、避免死锁
预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。
由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。
其中最具有代表性的避免死锁算法是银行家算法
。
银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。
安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。
处在安全状态的进程==一定==不会发生死锁问题,不处在安全状态的进程==可能==发生死锁问题。
3、检测死锁
- 首先为每个进程和每个资源指定一个唯一的号码;
- 然后建立资源分配表和进程等待表。
- 资源分配图 + 死锁定理
4、解除死锁
当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:
- 剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
- 撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态消除为止;
- 所谓代价是指优先级、运行代价、进程的重要性和价值等。
6、死锁代码
1 | /** |
4、活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
1 | import lombok.extern.slf4j.Slf4j; |
解决方法:使两个线程互相错开运行
5、饥饿
很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束。
下面我讲一下我遇到的一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题:
顺序加锁的解决方案:
6、乐观锁(Optimistic Locking)和悲观锁(Pessimistic Lock)
1、悲观锁(Pessimistic Lock)
- 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
- 悲观锁,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
- 之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:
- 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
- Java 里面的同步
synchronized
关键字的实现。
- 悲观锁主要分为共享锁和排他锁:
- 共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
- 排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
- 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
- 说明:
- 悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。
2、乐观锁(Optimistic Locking)
- 乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。
- 乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
- CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
- 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
- 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
- 说明:
- 乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。
乐观锁场景:在线文档
我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。
那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。
服务端要怎么验证是否冲突了呢?通常方案如下:
- 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
- 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。
实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
7、表锁和行锁
表锁和行锁主要是mysql数据库的悬挂知识,这里简单提一下,知道概念就行。
- 表锁:当一个线程在给一张表进行数据操作的时候,会将整一张表都锁起来。在它操作完成之前,其他线程不能对这张表的所有数据进行操作。
- 行锁:一个线程在对一张表的某一行数据进行操作的时候,会将那一行数据锁起来,在它操作完成之前,其他线程不能对这一行数据进行操作,但是对原这张表的其他行数据进行操作是允许的。
8、读锁与写锁
读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。
- 读锁:共享锁。即允许多个线程一起读取某一个资源——“共享读”
- 读锁存在”死锁”问题:线程1和线程2一起读取一张表,这时候线程1如果想要修改(写)这张表的数据,就需要线程2完成读操作后退出;同理,这个时候如果线程2也想修改(写)这张表的数据,那么也要等待线程1读取完后退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
- 注意:如果读锁只读不写的话,就不存在”死锁”问题。
- 写锁:独占锁。即一次只允许一个线程对某一个资源进行写操作(这个时候不存在读线程,也不存在写线程(除非是它自己))——“单独写”
- 写锁存在也”死锁”问题:线程1和线程2对一张表的不同行进行写操作。这个时候线程1想要写线程2操作的行,就需要等待线程2写完毕退出;同理,线程2想要写线程1操作的行,就需要等待线程1写完毕退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
9、自旋锁与自适应自旋锁、偏向锁
在4、关键字:synchronized篇
里有详细说明。这里不在赘述。
14、JUC线程池——FutureTask(未来任务)
1、BAT大厂的面试问题
- FutureTask用来解决什么问题的?为什么会出现?
- FutureTask类结构关系怎么样的?
- FutureTask的线程安全是由什么保证的?
- FutureTask结果返回机制?
- FutureTask内部运行状态的转变?
- FutureTask通常会怎么用?举例说明。
2、FutureTask简介
- FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)**和取消任务(cancel)**等。
- 如果任务尚未完成,获取任务执行结果时将会阻塞。
- **一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)**。
- FutureTask 常用来封装
Callable
和Runnable
,也可以作为一个任务提交到线程池中执行。 - 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。
- FutureTask 的线程安全由
CAS
来保证。
3、FutureTask类关系
可以看到,FutureTask实现了RunnableFuture接口,则RunnableFuture接口继承了Runnable接口和Future接口,所以FutureTask既能当做一个Runnable直接被Thread执行,也能作为Future用来得到Callable的计算结果。
4、FutureTask源码分析
1、Callable接口
Callable是个泛型接口,泛型V就是要call()方法返回的类型。若是不能返回成功,则抛异常。
对比Runnable接口,Runnable不会返回数据也不能抛出异常。
1 | public interface Callable<V> { |
2、Future接口
Future接口代表异步计算的结果,通过Future接口提供的方法可以查看异步计算是否执行完成,或者等待执行结果并获取执行结果,同时还可以取消执行。
Future接口的定义如下:
1 | public interface Future<V> { |
cancel(boolean mayInterruptIfRunning)
:cancel()方法用来取消异步任务的执行。- 如果异步任务已经完成或者已经被取消,或者由于某些原因不能取消,则会返回false。
- 如果任务还没有被执行,则会返回true并且异步任务不会被执行。
- 如果任务已经开始执行了但是还没有执行完成,若mayInterruptIfRunning为true,则会立即中断执行任务的线程并返回true,若mayInterruptIfRunning为false,则会返回true且不会中断任务执行线程。
isCanceled()
:判断任务是否被取消,如果任务在结束(正常执行结束或者执行异常结束)前被取消则返回true,否则返回false。isDone()
:判断任务是否已经完成,如果完成则返回true,否则返回false。- 需要注意的是:任务执行过程中发生异常、任务被取消也属于任务已完成,也会返回true。
get()
:获取任务执行结果,如果任务还没完成则会阻塞等待直到任务执行完成。- 如果任务被取消则会抛出CancellationException异常,如果任务执行过程发生异常则会抛出ExecutionException异常,如果阻塞等待过程中被中断则会抛出InterruptedException异常。
get(long timeout,Timeunit unit)
:带超时时间的get()版本,如果阻塞等待过程中超时则会抛出TimeoutException异常。
3、核心属性
1 | //内部持有的callable任务,运行完毕后置空 |
其中需要注意的是**state
是volatile类型的**,也就是说只要有任何一个线程修改了这个变量,那么其他所有的线程都会知道最新的值。
7种状态具体表示:
NEW
:表示是个新的任务或者还没被执行完的任务。这是初始状态。COMPLETING
:任务已经执行完成或者执行任务的时候发生异常,但是任务执行结果或者异常原因还没有保存到outcome字段(outcome字段用来保存任务执行结果,如果发生异常,则用来保存异常原因)的时候,状态会从NEW变更到COMPLETING。但是这个状态会时间会比较短,属于中间状态。NORMAL
:任务已经执行完成并且任务执行结果已经保存到outcome字段,状态会从COMPLETING转换到NORMAL。这是一个最终态。EXCEPTIONAL
:任务执行发生异常并且异常原因已经保存到outcome字段中后,状态会从COMPLETING转换到EXCEPTIONAL。这是一个最终态。CANCELLED
:任务还没开始执行或者已经开始执行但是还没有执行完成的时候,用户调用了cancel(false)方法取消任务且不中断任务执行线程,这个时候状态会从NEW转化为CANCELLED状态。这是一个最终态。INTERRUPTING
:任务还没开始执行或者已经执行但是还没有执行完成的时候,用户调用了cancel(true)方法取消任务并且要中断任务执行线程但是还没有中断任务执行线程之前,状态会从NEW转化为INTERRUPTING。这是一个中间状态。INTERRUPTED
:**调用interrupt()中断任务执行线程之后状态会从INTERRUPTING转换到INTERRUPTED。这是一个最终态。 **- 有一点需要注意的是,所有值大于COMPLETING的状态都表示任务已经执行完成(任务正常执行完成,任务执行异常或者任务被取消)。
各个状态之间的可能转换关系如下图所示:
4、构造函数
FutureTask(Callable
callable) public FutureTask(Callable<V> callable) { if (callable == null) throw new NullPointerException(); this.callable = callable; this.state = NEW; // ensure visibility of callable }
1
2
3
4
5
6
7
8
9
10
- 这个构造函数会把传入的Callable变量保存在this.callable字段中,该字段定义为`private Callable<V> callable`;**用来保存底层的调用,在被执行完成以后会指向null,接着会初始化state字段为NEW**。
- FutureTask(Runnable runnable, V result)
- ```java
public FutureTask(Runnable runnable, V result) {
this.callable = Executors.callable(runnable, result);
this.state = NEW; // ensure visibility of callable
}这个构造函数会把传入的Runnable封装成一个Callable对象保存在callable字段中,同时如果任务执行成功的话就会返回传入的result。这种情况下如果不需要返回值的话可以传入一个null。
顺带看下Executors.callable()这个方法,这个方法的功能是把Runnable转换成Callable,代码如下:
public static <T> Callable<T> callable(Runnable task, T result) { if (task == null) throw new NullPointerException(); return new RunnableAdapter<T>(task, result); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 可以看到这里采用的是**适配器模式**,调用`RunnableAdapter<T>(task, result)`方法来适配,实现如下:
- ```java
static final class RunnableAdapter<T> implements Callable<T> {
final Runnable task;
final T result;
RunnableAdapter(Runnable task, T result) {
this.task = task;
this.result = result;
}
public T call() {
task.run();
return result;
}
}
这个适配器很简单,就是简单的实现了Callable接口,在call()实现中调用Runnable.run()方法,然后把传入的result作为任务的结果返回。
在new了一个FutureTask对象之后,接下来就是在另一个线程中执行这个Task,无论是通过直接new一个Thread还是通过线程池,执行的都是run()方法,接下来就看看run()方法的实现。
5、核心方法——run()
1 | public void run() { |
说明:
运行任务,如果任务状态为NEW状态,则利用CAS修改为当前线程。执行完毕调用set(result)方法设置执行结果。set(result)源码如下:(使用的也是CAS修改state状态为COMPLETING)
protected void set(V v) { if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) { outcome = v; UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state finishCompletion();//执行完毕,唤醒等待线程 } }
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
- 首先利用cas修改state状态为COMPLETING,设置返回结果,然后使用 lazySet(UNSAFE.putOrderedInt)的方式设置state状态为NORMAL。结果设置完毕后,调用finishCompletion()方法唤醒等待线程,源码如下:
- ```java
private void finishCompletion() {
// assert state > COMPLETING;
for (WaitNode q; (q = waiters) != null;) {
if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {//移除等待线程
for (;;) {//自旋遍历等待线程
Thread t = q.thread;
if (t != null) {
q.thread = null;
LockSupport.unpark(t);//唤醒等待线程
}
WaitNode next = q.next;
if (next == null)
break;
q.next = null; // unlink to help gc
q = next;
}
break;
}
}
//任务完成后调用函数,自定义扩展
done();
callable = null; // to reduce footprint
}
回到run方法,如果在 run 期间被中断,此时需要调用
handlePossibleCancellationInterrupt
方法来处理中断逻辑,确保任何中断(例如cancel(true))只停留在当前run或runAndReset的任务中,源码如下:private void handlePossibleCancellationInterrupt(int s) { //在中断者中断线程之前可能会延迟,所以我们只需要让出CPU时间片自旋等待 if (s == INTERRUPTING) while (state == INTERRUPTING) Thread.yield(); // wait out pending interrupt }
1
2
3
4
5
6
7
8
9
10
11
##### 6、核心方法——get()
```java
//获取执行结果
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
说明:FutureTask 通过get()方法获取任务执行结果。如果任务处于未完成的状态(state <= COMPLETING
),就调用awaitDone方法(后面单独讲解)等待任务完成。任务完成后,通过report方法获取执行结果或抛出执行期间的异常。
report源码如下:
1 | //返回执行结果或抛出异常 |
7、核心方法——awaitDone(boolean timed, long nanos)
1 | private int awaitDone(boolean timed, long nanos) |
说明:awaitDone用于等待任务完成,或任务因为中断或超时而终止。返回任务的完成状态。函数执行逻辑如下:
如果线程被中断,首先清除中断状态,调用removeWaiter移除等待节点,然后抛出InterruptedException。
removeWaiter源码如下:
1 | private void removeWaiter(WaitNode node) { |
- 如果当前状态为结束状态(state>COMPLETING),则根据需要置空等待节点的线程,并返回 Future 状态;
- 如果当前状态为正在完成(COMPLETING),说明此时 Future 还不能做出超时动作,为任务让出CPU执行时间片;
- 如果state为NEW,先新建一个WaitNode,然后CAS修改当前waiters;
- 如果等待超时,则调用removeWaiter移除等待节点,返回任务状态;如果设置了超时时间但是尚未超时,则park阻塞当前线程;
- 其他情况直接阻塞当前线程。
8、核心方法——cancel(boolean mayInterruptIfRunning)
1 | public boolean cancel(boolean mayInterruptIfRunning) { |
说明:尝试取消任务。如果任务已经完成或已经被取消,此操作会失败。
- 如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED。
- 如果当前状态不为NEW,则根据参数mayInterruptIfRunning决定是否在任务运行中也可以中断。中断操作完成后,调用finishCompletion移除并唤醒所有等待线程。
5、FutureTask示例
常用使用方式:
- 第一种方式:
Future
+ExecutorService
- 第二种方式:
FutureTask
+ExecutorService
- 第三种方式:
FutureTask
+Thread
1、Future使用示例
1 | public class FutureDemo { |
2、FutureTask + Thread例子
1 | import java.util.concurrent.*; |
15、JUC强大的辅助类
1、CountDownLatch(减少计数)
CountDownLatch
底层也是由AQS
,用来同步一个或多个任务的常用并发工具类,强制它们等待由其他任务执行的一组操作完成。
1、BAT大厂的面试问题
- 什么是CountDownLatch?
- CountDownLatch底层实现原理?
- CountDownLatch一次可以唤醒几个任务?
- 多个
- CountDownLatch有哪些主要方法?
- await()、countDown()
- CountDownLatch适用于什么场景?
- 写道题:实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束?
- 使用CountDownLatch 代替wait notify 好处。
2、CountDownLatch介绍
从源码可知,其底层是由AQS
提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列:同步队列sync queue 和条件队列condition queue,不同的条件会有不同的条件队列。
CountDownLatch主要用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一
CountDownLatch典型的用法是:将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。
3、CountDownLatch源码分析
1、类的继承关系
CountDownLatch没有显示继承哪个父类或者实现哪个父接口,它底层是AQS是通过内部类Sync来实现的。
1 | public class CountDownLatch {} |
2、类的内部类
CountDownLatch类存在一个内部类Sync,继承自AbstractQueuedSynchronizer,(AQS)其源代码如下:
1 | private static final class Sync extends AbstractQueuedSynchronizer { |
说明:对CountDownLatch方法的调用会转发到对Sync或AQS的方法的调用,所以,AQS对CountDownLatch提供支持。
3、类的属性
CountDownLatch类的内部只有一个Sync类型的属性:
1 | public class CountDownLatch { |
4、类的构造函数
1 | public CountDownLatch(int count) { |
说明:该构造函数可以构造一个用给定计数初始化的CountDownLatch,并且构造函数内完成了sync的初始化,并设置了状态数。
5、核心函数——await函数
此函数将会使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。其源码如下:
1 | public void await() throws InterruptedException { |
说明:由源码可知,对CountDownLatch对象的await的调用会转发为对Sync的acquireSharedInterruptibly(从AQS继承的方法)方法的调用。
acquireSharedInterruptibly源码如下:
public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); }
1
2
3
4
5
6
7
8
9
- 说明:从源码中可知,acquireSharedInterruptibly又调用了CountDownLatch的内部类Sync的tryAcquireShared和AQS的doAcquireSharedInterruptibly函数。
- tryAcquireShared函数的源码如下:
- ```java
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}说明:该函数只是简单的判断AQS的state是否为0,为0则返回1,不为0则返回-1。
doAcquireSharedInterruptibly函数的源码如下:
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException { // 添加节点至等待队列 final Node node = addWaiter(Node.SHARED); boolean failed = true; try { for (;;) { // 无限循环 // 获取node的前驱节点 final Node p = node.predecessor(); if (p == head) { // 前驱节点为头结点 // 试图在共享模式下获取对象状态 int r = tryAcquireShared(arg); if (r >= 0) { // 获取成功 // 设置头结点并进行繁殖 setHeadAndPropagate(node, r); // 设置节点next域 p.next = null; // help GC failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) // 在获取失败后是否需要禁止线程并且进行中断检查 // 抛出异常 throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
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
- 说明:在AQS的doAcquireSharedInterruptibly中可能会再次调用CountDownLatch的内部类Sync的tryAcquireShared方法和AQS的setHeadAndPropagate方法。
- setHeadAndPropagate方法源码如下:
- ```java
private void setHeadAndPropagate(Node node, int propagate) {
// 获取头结点
Node h = head; // Record old head for check below
// 设置头结点
// 设置自己为 head
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
// propagate 表示有共享资源(例如共享读锁或信号量)
// 原 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
// 现在 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
// 获取节点的后继
Node s = node.next;
// 如果是最后一个节点或者是等待共享读锁的节点
if (s == null || s.isShared()) // 后继为空或者为共享模式
// 以共享模式进行释放
doReleaseShared();
}
}说明:该方法设置头结点并且释放头结点后面的满足条件的结点,该方法中可能会调用到AQS的doReleaseShared方法。
AQS的doReleaseShared方法其源码如下:
private void doReleaseShared() { /* * Ensure that a release propagates, even if there are other * in-progress acquires/releases. This proceeds in the usual * way of trying to unparkSuccessor of head if it needs * signal. But if it does not, status is set to PROPAGATE to * ensure that upon release, propagation continues. * Additionally, we must loop in case a new node is added * while we are doing this. Also, unlike other uses of * unparkSuccessor, we need to know if CAS to reset status * fails, if so rechecking. */ // 无限循环 // 如果 head.waitStatus == Node.SIGNAL ==> 0 成功, 下一个节点 unpark // 如果 head.waitStatus == 0 ==> Node.PROPAGATE, 为了解决 bug, 见后面分析 for (;;) { // 保存头结点 Node h = head; // 队列还有节点 if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点 // 获取头结点的等待状态 int ws = h.waitStatus; if (ws == Node.SIGNAL) { // 状态为SIGNAL if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续 continue; // loop to recheck cases // 下一个节点 unpark 如果成功获取读锁 // 并且下下个节点还是 shared, 继续 doReleaseShared // 释放后继结点 unparkSuccessor(h); } else if ( // 如果已经是 0 了,改为 -3,用来解决传播性,见后文信号量 bug 分析 ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续 continue; // loop on failed CAS } if (h == head) // 若头结点改变,继续循环 break; } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 说明:**该方法在共享模式下释放**,具体的流程再之后会通过一个示例给出。
所以,对CountDownLatch的await调用大致会有如下的调用链:
![img](JUC/java-thread-x-countdownlatch-1.png)
说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。
###### 6、核心函数——countDown函数
此函数将递减锁存器的计数,如果计数到达零,则释放所有等待的线程。
```java
public void countDown() {
sync.releaseShared(1);
}
说明:对countDown的调用转换为对Sync对象的releaseShared(从AQS继承而来)方法的调用。
releaseShared源码如下:
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- 说明:**此函数会以共享模式释放对象,并且在函数中会调用到CountDownLatch的tryReleaseShared函数,并且可能会调用AQS的doReleaseShared函数**。
- tryReleaseShared源码如下:
- ```java
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 无限循环
for (;;) {
// 获取状态
int c = getState();
if (c == 0) // 没有被线程占有
return false;
// 下一个状态
int nextc = c-1;
if (compareAndSetState(c, nextc)) // 比较并且设置成功
return nextc == 0;
}
}说明:此函数会试图设置状态来反映共享模式下的一个释放。具体的流程在下面的示例中会进行分析。
AQS的doReleaseShared的源码如下:
private void doReleaseShared() { /* * Ensure that a release propagates, even if there are other * in-progress acquires/releases. This proceeds in the usual * way of trying to unparkSuccessor of head if it needs * signal. But if it does not, status is set to PROPAGATE to * ensure that upon release, propagation continues. * Additionally, we must loop in case a new node is added * while we are doing this. Also, unlike other uses of * unparkSuccessor, we need to know if CAS to reset status * fails, if so rechecking. */ // 无限循环 for (;;) { // 保存头结点 Node h = head; if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点 // 获取头结点的等待状态 int ws = h.waitStatus; if (ws == Node.SIGNAL) { // 状态为SIGNAL if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续 continue; // loop to recheck cases // 释放后继结点 unparkSuccessor(h); } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续 continue; // loop on failed CAS } if (h == head) // 若头结点改变,继续循环 break; } }
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
- 说明:此函数在共享模式下释放资源。
所以,对CountDownLatch的countDown调用大致会有如下的调用链:
![img](JUC/java-thread-x-countdownlatch-2.png)
说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。
##### 4、CountDownLatch示例
下面给出了一个使用CountDownLatch的示例:
```java
import java.util.concurrent.CountDownLatch;
class MyThread extends Thread {
private CountDownLatch countDownLatch;
public MyThread(String name, CountDownLatch countDownLatch) {
super(name);
this.countDownLatch = countDownLatch;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " doing something");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " finish");
countDownLatch.countDown();
}
}
public class CountDownLatchDemo {
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(2);
MyThread t1 = new MyThread("t1", countDownLatch);
MyThread t2 = new MyThread("t2", countDownLatch);
t1.start();
t2.start();
System.out.println("Waiting for t1 thread and t2 thread to finish");
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " continue");
}
}
运行结果(某一次):
1 | Waiting for t1 thread and t2 thread to finish |
说明:本程序首先计数器初始化为2。根据结果,可能会存在如下的一种时序图:
说明:首先main线程会调用await操作,此时main线程会被阻塞,等待被唤醒,之后t1线程执行了countDown操作,最后,t2线程执行了countDown操作,此时main线程就被唤醒了,可以继续运行。下面,进行详细分析:
- main线程执行countDownLatch.await操作,主要调用的函数如下:
- 说明:在最后,main线程就被park了,即禁止运行了。此时Sync queue(同步队列)中有两个节点,AQS的state为2,包含main线程的结点的nextWaiter指向SHARED结点。
- t1线程执行countDownLatch.countDown操作,主要调用的函数如下:
- 说明:此时,Sync queue队列里的结点个数未发生变化,但是此时,AQS的state已经变为1了。
- t2线程执行countDownLatch.countDown操作,主要调用的函数如下:
- 说明:经过调用后,AQS的state为0,并且此时,main线程会被unpark,可以继续运行。当main线程获取cpu资源后,继续运行。
- main线程获取cpu资源,继续运行,由于main线程是在
parkAndCheckInterrupt
函数中被禁止的,所以此时,继续在parkAndCheckInterrupt函数运行。- 说明:main线程恢复,继续在parkAndCheckInterrupt函数中运行,之后又会回到最终达到的状态为:AQS的state为0,并且head与tail指向同一个结点,该节点的nextWaiter域还是指向SHARED结点。
5、更深入理解
1、面试题
实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。
2、使用wait和notify实现:
1 | import java.util.ArrayList; |
1 | t2 启动 |
3、CountDownLatch实现
1 | import java.util.ArrayList; |
2、CyclicBarrier(循环栅栏)
CyclicBarrier底层是基于ReentrantLock
和AbstractQueuedSynchronizer
来实现的,在理解的时候最好和CountDownLatch
放在一起理解。
1、BAT大厂的面试问题
- 什么是CyclicBarrier?
- CyclicBarrier底层实现原理?
- CountDownLatch和CyclicBarrier对比?
- CyclicBarrier的核心函数有哪些?
- CyclicBarrier适用于什么场景?
2、CyclicBarrier简介
- 对于CountDownLatch,其他线程为游戏玩家,比如英雄联盟,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。
- 对于CyclicBarrier,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。
- 注意:CyclicBarrier的计数与线程数最好是一一对应才能达到我们的要求
- 例子:一开始两个任务task1(执行1s)与task2(执行2s),需要循环执行3次,即两对三次总共六次任务,每一对任务执行完毕会执行CyclicBarrier当中的任务task3,所以设置CyclicBarrier的计数为2,对应每一组两个任务
- 如果我们设置线程池的线程个数为2,那么会如我们所想执行——task1 task2 task3 task1 task2 task3 task1 task2 task3
- 如果我们设置线程池的线程个数为3,那么就不会如我们所想执行了,因为一开始会有三个线程线执行任务:task1 task2 task1,而CyclicBarrier的task3会被两个task1执行(因为1 + 1 = 2)结束后执行,执行流程就变成——task1 task1 task3 task2 task1 task3 task2 task2 task3
3、CyclicBarrier源码分析
1、类的继承关系
CyclicBarrier没有显示继承哪个父类或者实现哪个父接口,所有AQS和重入锁不是通过继承实现的,而是通过组合实现的。
1 | public class CyclicBarrier {} |
2、类的内部类
CyclicBarrier类存在一个内部类Generation,每一次使用的CycBarrier可以当成Generation的实例,其源代码如下:
1 | private static class Generation { |
说明:Generation类有一个属性broken,用来表示当前屏障是否被损坏。
3、类的属性
1 | public class CyclicBarrier { |
说明:该属性有一个为ReentrantLock
对象,有一个为Condition
对象,而Condition对象又是基于AQS的,所以,归根到底,底层还是由AQS提供支持。
4、类的构造函数
CyclicBarrier(int, Runnable)型构造函数:
public CyclicBarrier(int parties, Runnable barrierAction) { // 参与的线程数量小于等于0,抛出异常 if (parties <= 0) throw new IllegalArgumentException(); // 设置parties this.parties = parties; // 设置count this.count = parties; // 设置barrierCommand this.barrierCommand = barrierAction; }
1
2
3
4
5
6
7
8
9
10
11
- 说明:该构造函数可以指定关联该CyclicBarrier的线程数量,并且可以指定在所有线程都进入屏障后的执行动作,该执行动作由最后一个进行屏障的线程执行。
- CyclicBarrier(int)型构造函数:
- ```java
public CyclicBarrier(int parties) {
// 调用含有两个参数的构造函数
this(parties, null);
}
说明:该构造函数仅仅执行了关联该CyclicBarrier的线程数量,没有设置执行动作。
5、核心函数——dowait函数
此函数为CyclicBarrier类的核心函数,CyclicBarrier类对外提供的await函数在底层都是调用该类的doawait函数,
await函数源代码如下:
1 | public int await() throws InterruptedException, BrokenBarrierException { |
doawait函数源代码如下:
1 | private int dowait(boolean timed, long nanos) |
说明:dowait方法的逻辑会进行一系列的判断,大致流程如下:
6、核心函数——nextGenneration函数
此函数在所有线程进入屏障后会被调用,即生成下一个版本,所有线程又可以重新进入到屏障中,其源代码如下:
1 | private void nextGeneration() { |
在此函数中会调用AQS的signalAll
方法,即唤醒所有等待线程。
如果所有的线程都在等待此条件,则唤醒所有线程。其源代码如下:
1 | public final void signalAll() { |
说明:此函数判断头结点是否为空,即条件队列是否为空,然后会调用doSignalAll
函数,doSignalAll函数源码如下:
1 | // 全部唤醒 - 等待队列的所有节点转移至 AQS 队列 |
说明:此函数会依次将条件队列中的节点转移到同步队列中,会调用到transferForSignal函数,其源码如下:
1 | // 如果节点状态是取消, 返回 false 表示转移失败, 否则转移成功 |
说明:此函数的作用就是将处于条件队列中的节点转移到同步队列中,并设置结点的状态信息。
其中会调用到enq
函数,其源代码如下:
1 | private Node enq(final Node node) { |
说明:此函数完成了结点插入同步队列的过程,也很好理解。
综合上面的分析可知,newGeneration函数的主要方法的调用如下,之后会通过一个例子详细讲解:
7、breakBarrier函数
此函数的作用是损坏当前屏障,会唤醒所有在屏障中的线程。源代码如下:
1 | private void breakBarrier() { |
说明:可以看到,此函数也调用了AQS的signalAll函数,由signal函数提供支持。
4、CyclicBarrier示例
下面通过一个例子来详解CyclicBarrier的使用和内部工作机制,源代码如下:
1 | import java.util.concurrent.BrokenBarrierException; |
运行结果(某一次):
1 | t1 going to await |
说明:根据结果可知,可能会存在如下的调用时序:
说明:由上图可知,假设t1线程的cb.await是在main线程的cb.await之前,cb.barrierAction动作是由最后一个进入屏障的线程t2执行的。根据时序图,进一步分析出其内部工作流程。
- main(主)线程执行cb.await操作,主要调用的函数如下:
- 说明:由于ReentrantLock的默认采用非公平策略,所以在dowait函数中调用的是ReentrantLock.NonfairSync的lock函数,由于此时AQS的状态是0,表示还没有被任何线程占用,故main线程可以占用,之后在dowait中会调用trip.await函数,最终的结果是条件队列中存放了一个包含main线程的结点,并且被禁止运行了,同时,main线程所拥有的资源也被释放了,可以供其他线程获取。
- t1线程执行cb.await操作,其中假设t1线程的lock.lock操作在main线程释放了资源之后,则其主要调用的函数如下:
- 说明:可以看到,之后condition queue(条件队列)里面有两个节点,包含t1线程的结点插入在队列的尾部,并且t1线程也被禁止了,因为执行了park操作,此时两个线程都被禁止了。
- t2线程执行cb.await操作,其中假设t2线程的lock.lock操作在t1线程释放了资源之后,则其主要调用的函数如下:
- 说明:由上图可知,在t2线程执行await操作后,会直接执行command.run方法,不是重新开启一个线程,而是最后进入屏障的线程执行。同时,会将Condition queue中的所有节点都转移到Sync queue中,并且最后main线程会被unpark,可以继续运行。main线程获取cpu资源,继续运行。
- main线程获取cpu资源,继续运行,下图给出了主要的方法调用:
- 说明:其中,由于main线程是在AQS.CO的wait中被park的,所以恢复时,会继续在该方法中运行。运行过后,t1线程被unpark,它获得cpu资源可以继续运行。
- t1线程获取cpu资源,继续运行,下图给出了主要的方法调用:
- 说明:其中,由于t1线程是在AQS.CO的wait方法中被park,所以恢复时,会继续在该方法中运行。运行过后,Sync queue中保持着一个空节点。头结点与尾节点均指向它。
注意:在线程await过程中中断线程会抛出异常,所有进入屏障的线程都将被释放。至于CyclicBarrier的其他用法,读者可以自行查阅API。
5、新增一个容易理解的例子
场景:收集七龙珠召唤神龙
1 | import java.util.concurrent.BrokenBarrierException; |
某一次执行结果:
1 | 2 星龙珠被收集到了 |
若将主线程的循环从7
改成6
的话,由于只能收集到6颗龙珠,所以不能召唤神龙(其实就是没能达到破坏屏障的条件,所有的线程都在等待)
1 | 2 星龙珠被收集到了 |
6、和CountDownLatch再对比
- CountDownLatch减计数,CyclicBarrier加计数。
- CountDownLatch是一次性的,CyclicBarrier可以重用。
- CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。
3、Semaphore(信号量)
Semaphore底层是基于AbstractQueuedSynchronizer
(AQS)来实现的。Semaphore称为计数信号量,它允许n个任务同时访问某个资源,可以将信号量看做是在向外分发使用资源的许可证,只有成功获取许可证,才能使用资源,用来限制能同时访问共享资源的线程上限。
1、BAT大厂的面试问题
- 什么是Semaphore?
- Semaphore内部原理?
- Semaphore常用方法有哪些?如何实现线程同步和互斥的?
- Semaphore适合用在什么场景?
- 单独使用Semaphore是不会使用到AQS的条件队列?
- Semaphore中申请令牌(acquire)、释放令牌(release)的实现?
- Semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
- Semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
- Semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
- Semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
2、Semaphore源码分析
1、类的继承关系
1 | public class Semaphore implements java.io.Serializable {} |
说明:Semaphore实现了Serializable接口,即可以进行序列化。
2、类的内部类
Semaphore总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:
说明:Semaphore与ReentrantLock的内部类的结构相同,类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。
3、类的内部类——Sync类
Sync类的源码如下:
1 | // 内部类,继承自AQS |
说明:Sync类的属性相对简单,只有一个版本号,Sync类存在如下方法和作用如下:
4、类的内部类——NonfairSync类
NonfairSync类继承了Sync类,表示采用非公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:
1 | static final class NonfairSync extends Sync { |
说明:从tryAcquireShared方法的源码可知,其会调用父类Sync的nonfairTryAcquireShared方法,表示按照非公平策略进行资源的获取。
5、类的内部类——FairSync类
FairSync类继承了Sync类,表示采用公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:
1 | protected int tryAcquireShared(int acquires) { |
说明:从tryAcquireShared方法的源码可知,它使用公平策略来获取资源,它会判断同步队列中是否存在其他的等待节点。
6、类的属性
1 | public class Semaphore implements java.io.Serializable { |
说明:Semaphore自身只有两个属性,最重要的是sync属性,基于Semaphore对象的操作绝大多数都转移到了对sync的操作。
7、类的构造函数
Semaphore(int)型构造函数
public Semaphore(int permits) { sync = new NonfairSync(permits); }
1
2
3
4
5
6
7
8
9
- 说明:该构造函数会创建具有给定的许可数和**非公平的设置的Semaphore**。
- Semaphore(int, boolean)型构造函数
- ```java
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}说明:该构造函数会创建具有给定的许可数和给定的公平设置的Semaphore。
8、核心函数——acquire函数
此方法从信号量获取一个(多个)许可,在提供一个许可前一直将线程阻塞,或者线程被中断,其源码如下:
1 | public void acquire() throws InterruptedException { |
1 | public void acquire(int permits) throws InterruptedException { |
说明:该方法中将会调用Sync对象的acquireSharedInterruptibly
(从AQS继承而来的方法)方法,而acquireSharedInterruptibly方法在上面CountDownLatch中已经进行了分析,在此不再累赘。
最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:
说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。
9、核心函数——release函数
此方法释放一个(多个)许可,将其返回给信号量,源码如下:
1 | public void release() { |
说明:该方法中将会调用Sync对象的releaseShared
(从AQS继承而来的方法)方法,而releaseShared方法在上面CountDownLatch中已经进行了分析,在此不再累赘。
最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:
说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。
10、图解Semaphore的执行流程
加锁解锁流程:
Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一
刚开始,permits(state)为 3,这时 5 个线程来获取资源
假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列park 阻塞
这时 Thread-4 释放了 permits,状态如下
接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态
3、Semaphore示例
1 | import java.util.concurrent.Semaphore; |
运行结果(某一次):
1 | main trying to acquire |
说明:首先,生成一个信号量,信号量有10个许可,然后,main,t1,t2三个线程获取许可运行,根据结果,可能存在如下的一种时序:
说明:如上图所示,首先,main线程执行acquire操作,并且成功获得许可,之后t1线程执行acquire操作,成功获得许可,之后t2执行acquire操作,由于此时许可数量不够,t2线程将会阻塞,直到许可可用。之后t1线程释放许可,main线程释放许可,此时的许可数量可以满足t2线程的要求,所以,此时t2线程会成功获得许可运行,t2运行完成后释放许可。下面进行详细分析:
- main线程执行semaphore.acquire操作。主要的函数调用如下图所示:
- 说明:此时,可以看到只是AQS的state变为了5,main线程并没有被阻塞,可以继续运行。
- t1线程执行semaphore.acquire操作。主要的函数调用如下图所示:
- 说明:此时,可以看到只是AQS的state变为了2,t1线程并没有被阻塞,可以继续运行。
- t2线程执行semaphore.acquire操作。主要的函数调用如下图所示:
- 说明:此时,t2线程获取许可不会成功,之后会导致其被禁止运行,值得注意的是,AQS的state还是为2。
- t1执行semaphore.release操作。主要的函数调用如下图所示:
- 说明:此时,t2线程将会被unpark,并且AQS的state为5,t2获取cpu资源后可以继续运行。
- main线程执行semaphore.release操作。主要的函数调用如下图所示:
- 说明:此时,t2线程还会被unpark,但是不会产生影响,此时,只要t2线程获得CPU资源就可以运行了。此时,AQS的state为10。
- t2获取CPU资源,继续运行,此时t2需要恢复现场,回到parkAndCheckInterrupt函数中,也是在should继续运行。主要的函数调用如下图所示:
- 说明:此时,可以看到,Sync queue中只有一个结点,头结点与尾节点都指向该结点,在setHeadAndPropagate的函数中会设置头结点并且会unpark队列中的其他结点。
- t2线程执行semaphore.release操作。主要的函数调用如下图所示:
- 说明:t2线程经过release后,此时信号量的许可又变为10个了,此时Sync queue中的结点还是没有变化。
4、新增一个容易理解的例子
场景:6辆汽车,停3个车位
1 | import java.util.Random; |
某一次执行结果:
1 | 1 抢到了车位 |
5、Semaphore应用
- 使用Semaphore限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比
Tomcat LimitLatch
的实现) - Semaphore比较适用于资源数与线程数相等的场景
- 用 Semaphore 实现简单连接池(一个线程对应一个数据库连接),对比享元模式下的实现(用wait notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的
1 | import lombok.extern.slf4j.Slf4j; |
6、更深入理解
1、单独使用Semaphore是不会使用到AQS的条件队列的
不同于CyclicBarrier和ReentrantLock,单独使用Semaphore是不会使用到AQS的条件队列的,其实,只有进行await操作才会进入条件队列,其他的都是在同步队列中,只是当前线程会被park。
2、场景问题——semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
答案:拿不到令牌的线程阻塞,不会继续往下运行。
3、场景问题——semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
答案:线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。(这和你一次性申请11个令牌是一样的)
4、场景问题——semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
答案:能,原因是release方法会添加令牌,并不会以初始化的大小为准。
5、场景问题——semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
答案:能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。
具体示例如下,如果不相信的话,可以运行一下下面的demo,在做实验之前,笔者也认为应该是不允许的。。(或许是开发者考虑不周到)
1 | public class TestSemaphore2 { |
4、Phaser(移相器)
Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
1、BAT大厂的面试问题
- Phaser主要用来解决什么问题?
- Phaser与CyclicBarrier和CountDownLatch的区别是什么?
- 如果用CountDownLatch来实现Phaser的功能应该怎么实现?
- Phaser运行机制是什么样的?
- 给一个Phaser使用的示例?
2、Phaser运行机制
1、Registration(注册)
跟其他barrier不同,在phaser上注册的parties会随着时间的变化而变化。任务可以随时注册(使用方法register,bulkRegister注册,或者由构造器确定初始parties),并且在任何抵达点可以随意地撤销注册(方法arriveAndDeregister)。就像大多数基本的同步结构一样,注册和撤销只影响内部count;不会创建更深的内部记录,所以任务不能查询他们是否已经注册。(不过,可以通过继承来实现类似的记录)
2、Synchronization(同步机制)
和CyclicBarrier一样,Phaser也可以重复await。方法arriveAndAwaitAdvance的效果类似CyclicBarrier.await。phaser的每一代都有一个相关的phase number,初始值为0,当所有注册的任务都到达phaser时phase+1,到达最大值(Integer.MAX_VALUE)之后清零。使用phase number可以独立控制 ==到达phaser== 和 ==等待其他线程== 的动作,通过下面两种类型的方法:
- Arrival(到达机制) arrive和arriveAndDeregister方法记录到达状态。这些方法不会阻塞,但是会返回一个相关的arrival phase number;也就是说,phase number用来确定到达状态。当所有任务都到达给定phase时,可以执行一个可选的函数,这个函数通过重写
onAdvance
方法实现,通常可以用来控制终止状态。重写此方法类似于为CyclicBarrier提供一个barrierAction,但比它更灵活。- Waiting(等待机制) awaitAdvance方法需要一个表示arrival phase number的参数,并且在phaser前进到与给定phase不同的phase时返回。和CyclicBarrier不同,即使等待线程已经被中断,awaitAdvance方法也会一直等待。中断状态和超时时间同样可用,但是当任务等待中断或超时后未改变phaser的状态时会遭遇异常。如果有必要,在方法forceTermination之后可以执行这些异常的相关的handler进行恢复操作,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务。
3、Termination(终止机制)
可以用isTerminated方法检查phaser的终止状态。在终止时,所有同步方法立刻返回一个负值。在终止时尝试注册也没有效果。当调用onAdvance返回true时Termination被触发。当deregistration操作使已注册的parties变为0时,onAdvance的默认实现就会返回true。也可以重写onAdvance方法来定义终止动作。forceTermination方法也可以释放等待线程并且允许它们终止。
4、Tiering(分层结构)
Phaser支持分层结构(树状构造)来减少竞争。注册了大量parties的Phaser可能会因为同步竞争消耗很高的成本, 因此可以设置一些子Phaser来共享一个通用的parent。这样的话即使每个操作消耗了更多的开销,但是会提高整体吞吐量。 在一个分层结构的phaser里,子节点phaser的注册和取消注册都通过父节点管理。子节点phaser通过构造或方法register、bulkRegister进行首次注册时,在其父节点上注册。子节点phaser通过调用arriveAndDeregister进行最后一次取消注册时,也在其父节点上取消注册。
5、Monitoring(状态监控)
由于同步方法可能只被已注册的parties调用,所以phaser的当前状态也可能被任何调用者监控。在任何时候,可以通过getRegisteredParties获取parties数,其中getArrivedParties方法返回已经到达当前phase的parties数。当剩余的parties(通过方法getUnarrivedParties获取)到达时,phase进入下一代。这些方法返回的值可能只表示短暂的状态,所以一般来说在同步结构里并没有啥卵用。
3、Phaser源码详解
1、核心参数
1 | private volatile long state; |
state状态说明:Phaser使用一个long型state值来标识内部状态:
- 低0-15位表示未到达parties数;
- 中16-31位表示等待的parties数;
- 中32-62位表示phase当前代;
- 高63位表示当前phaser的终止状态。
注意:子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。这里在后面源码分析的reconcileState方法里会讲解。 Qnode是Phaser定义的内部等待队列,用于在阻塞时记录等待线程及相关信息。实现了ForkJoinPool的一个内部接口ManagedBlocker,上面已经说过,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务(通过内部实现方法isReleasable和block)。
2、函数列表
1 | //构造方法 |
3、方法——register()
1 | //注册一个新的party |
说明:register方法为phaser添加一个新的party,如果onAdvance正在运行,那么这个方法会等待它运行结束再返回结果。如果当前phaser有父节点,并且当前phaser上没有已注册的party,那么就会交给父节点注册。
register和bulkRegister都由doRegister实现,大概流程如下:
如果当前操作不是首次注册,那么直接在当前phaser上更新注册parties数
如果是首次注册,并且当前phaser没有父节点,说明是root节点注册,直接更新phase
如果当前操作是首次注册,并且当前phaser由父节点,则注册操作交由父节点,并更新当前phaser的phase
上面说过,子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。非首次注册时,如果Phaser有父节点,则调用reconcileState()方法解决root节点的phase延迟传递问题, 源码如下:
private long reconcileState() { final Phaser root = this.root; long s = state; if (root != this) { int phase, p; // CAS to root phase with current parties, tripping unarrived while ((phase = (int)(root.state >>> PHASE_SHIFT)) != (int)(s >>> PHASE_SHIFT) && !UNSAFE.compareAndSwapLong (this, stateOffset, s, s = (((long)phase << PHASE_SHIFT) | ((phase < 0) ? (s & COUNTS_MASK) : (((p = (int)s >>> PARTIES_SHIFT) == 0) ? EMPTY : ((s & PARTIES_MASK) | p)))))) s = state; } return s; }
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
- 当root节点的phase已经advance到下一代,但是子节点phaser还没有,这种情况下它们必须通过更新未到达parties数完成它们自己的advance操作(如果parties为0,重置为EMPTY状态)。
- 回到register方法的第一步,如果当前未到达数为0,说明上一代phase正在进行到达操作,此时调用internalAwaitAdvance()方法等待其他任务完成到达操作,源码如下:
- ```java
//阻塞等待phase到下一代
private int internalAwaitAdvance(int phase, QNode node) {
// assert root == this;
releaseWaiters(phase-1); // ensure old queue clean
boolean queued = false; // true when node is enqueued
int lastUnarrived = 0; // to increase spins upon change
int spins = SPINS_PER_ARRIVAL;
long s;
int p;
while ((p = (int)((s = state) >>> PHASE_SHIFT)) == phase) {
if (node == null) { // spinning in noninterruptible mode
int unarrived = (int)s & UNARRIVED_MASK;//未到达数
if (unarrived != lastUnarrived &&
(lastUnarrived = unarrived) < NCPU)
spins += SPINS_PER_ARRIVAL;
boolean interrupted = Thread.interrupted();
if (interrupted || --spins < 0) { // need node to record intr
//使用node记录中断状态
node = new QNode(this, phase, false, false, 0L);
node.wasInterrupted = interrupted;
}
}
else if (node.isReleasable()) // done or aborted
break;
else if (!queued) { // push onto queue
AtomicReference<QNode> head = (phase & 1) == 0 ? evenQ : oddQ;
QNode q = node.next = head.get();
if ((q == null || q.phase == phase) &&
(int)(state >>> PHASE_SHIFT) == phase) // avoid stale enq
queued = head.compareAndSet(q, node);
}
else {
try {
ForkJoinPool.managedBlock(node);//阻塞给定node
} catch (InterruptedException ie) {
node.wasInterrupted = true;
}
}
}
if (node != null) {
if (node.thread != null)
node.thread = null; // avoid need for unpark()
if (node.wasInterrupted && !node.interruptible)
Thread.currentThread().interrupt();
if (p == phase && (p = (int)(state >>> PHASE_SHIFT)) == phase)
return abortWait(phase); // possibly clean up on abort
}
releaseWaiters(phase);
return p;
}
简单介绍下第二个参数node,如果不为空,则说明等待线程需要追踪中断状态或超时状态。以doRegister中的调用为例,不考虑线程争用,internalAwaitAdvance大概流程如下:
- 首先调用releaseWaiters唤醒上一代所有等待线程,确保旧队列中没有遗留的等待线程。
- 循环SPINS_PER_ARRIVAL指定的次数或者当前线程被中断,创建node记录等待线程及相关信息。
- 继续循环调用ForkJoinPool.managedBlock运行被阻塞的任务
- 继续循环,阻塞任务运行成功被释放,跳出循环
- 最后唤醒当前phase的线程
4、方法——arrive()
1 | //使当前线程到达phaser,不等待其他任务到达。返回arrival phase number |
说明:arrive方法手动调整到达数,使当前线程到达phaser。arrive和arriveAndDeregister都调用了doArrive实现,大概流程如下:
- 首先更新state(state - adjust);
- 如果当前不是最后一个未到达的任务,直接返回phase
- 如果当前是最后一个未到达的任务:
- 如果当前是root节点,判断是否需要终止phaser,CAS更新phase,最后释放等待的线程;
- 如果是分层结构,并且已经没有下一代未到达的parties,则交由父节点处理doArrive逻辑,然后更新state为EMPTY。
5、方法——arriveAndAwaitAdvance()
1 | public int arriveAndAwaitAdvance() { |
说明:使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。效果类似于CyclicBarrier.await。大概流程如下:
- 更新state(state - 1);
- 如果未到达数大于1,调用internalAwaitAdvance阻塞等待其他任务到达,返回当前phase
- 如果为分层结构,则交由父节点处理arriveAndAwaitAdvance逻辑
- 如果未到达数<=1,判断phaser终止状态,CAS更新phase到下一代,最后释放等待当前phase的线程,并返回下一代phase。
6、方法——awaitAdvance(int phase)
1 | public int awaitAdvance(int phase) { |
说明:awaitAdvance用于阻塞等待线程到达,直到phase前进到下一代,返回下一代的phase number。方法很简单,不多赘述。awaitAdvanceInterruptibly方法是响应中断版的awaitAdvance,不同之处在于,调用阻塞时会记录线程的中断状态。
5、Exchanger(交换器)
Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换。
1、BAT大厂的面试问题
- Exchanger主要解决什么问题?
- 对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式?
- Exchanger在不同的JDK版本中实现有什么差别?
- Exchanger实现机制?
- Exchanger已经有了slot单节点,为什么会加入arena node数组?什么时候会用到数组?
- arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
- 什么是伪共享,Exchanger中如何体现的?
- Exchanger实现举例
2、Exchanger简介
Exchanger用于进行两个线程之间的数据交换。它提供一个==同步点==,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
3、Exchanger实现机制
1 | for (;;) { |
比如有2条线程A和B,A线程交换数据时,发现slot为空,则将需要交换的数据放在slot中等待其它线程进来交换数据,等线程B进来,读取A设置的数据,然后设置线程B需要交换的数据,然后唤醒A线程,原理就是这么简单。但是当多个线程之间进行交换数据时就会出现问题,所以Exchanger加入了arena数组。
4、Exchanger源码解析
1、内部类——Participant
1 | static final class Participant extends ThreadLocal<Node> { |
Participant的作用是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,说明每个线程具有不同的状态。
2、内部类——Node
1 | static final class Node { .misc.Contended |
在Node定义中有两个变量值得思考:bound
以及collides
。前面提到了数组area是为了避免竞争而产生的,如果系统不存在竞争问题,那么完全没有必要开辟一个高效的arena来徒增系统的复杂性。
- 首先通过单个slot的exchanger来交换数据,当探测到竞争时将安排不同的位置的slot来保存线程Node,并且可以确保没有slot会在同一个缓存行上。
- 如何来判断会有竞争呢?
- CAS替换slot失败,如果失败,则通过记录冲突次数来扩展arena的尺寸,我们在记录冲突的过程中会跟踪“bound”的值,以及会重新计算在bound的值被改变时的冲突次数。
3、核心属性
1 | private final Participant participant; |
**为什么会有
arena数组槽
**?- slot为单个槽,arena为数组槽,他们都是Node类型。
- 在这里可能会感觉到疑惑,slot作为Exchanger交换数据的场景,应该只需要一个就可以了啊?为何还多了一个Participant和数组类型的arena呢?
- 一个slot交换场所原则上来说应该是可以的,但实际情况却不是如此,多个参与者使用同一个交换场所时,会存在严重==伸缩性问题==。既然单个交换场所存在问题,那么我们就安排多个,也就是数组arena。
- 通过数组arena来安排不同的线程使用不同的slot来降低竞争问题,并且可以保证最终一定会成对交换数据。但是**Exchanger不是一来就会生成arena数组来降低竞争,==只有当产生竞争是才会生成arena数组==**。
那么怎么将Node与当前线程绑定呢?
- Participant,Participant 的作用就是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,同时在Node节点中记录在arena中的下标index。
4、构造函数
1 | /** |
初始化participant对象。
5、核心方法——exchange(V x)
等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。
1 | public V exchange(V x) throws InterruptedException { |
这个方法比较好理解:
- arena为数组槽,如果为null,则执行slotExchange()方法,
- 否则判断线程是否中断,如果中断值抛出InterruptedException异常,
- 没有中断则执行arenaExchange()方法。
整套逻辑就是:如果slotExchange(Object item, boolean timed, long ns)方法执行失败了就执行arenaExchange(Object item, boolean timed, long ns)方法,最后返回结果V。
NULL_ITEM
为一个空节点,其实就是一个Object对象而已,slotExchange()为单个slot交换。
6、slotExchange(Object item, boolean timed, long ns)
1 | private final Object slotExchange(Object item, boolean timed, long ns) { |
程序首先通过participant获取当前线程节点Node。检测是否中断,如果中断return null,等待后续抛出InterruptedException异常。
- 如果slot不为null,则进行slot消除,成功直接返回数据V,否则失败,则创建arena消除数组。
- 如果slot为null,但arena不为null,则返回null,进入arenaExchange逻辑。
- 如果slot为null,且arena也为null,则尝试占领该slot,失败重试,成功则跳出循环进入
spin+block
(自旋+阻塞)模式。
在自旋+阻塞模式中,首先取得结束时间和自旋次数。
- 如果match(做releasing操作的线程传递的项)为null,其首先尝试spins+随机次自旋(改自旋使用当前节点中的hash,并改变之)和退让。
- 当自旋数为0后,假如slot发生了改变(slot != p)则重置自旋数并重试。
- 否则
- 假如:当前未中断&arena为null&(当前不是限时版本或者限时版本+当前时间未结束):阻塞或者限时阻塞。
- 假如:当前中断或者arena不为null或者当前为限时版本+时间已经结束:
- 不限时版本:置v为null;
- 限时版本:如果时间结束以及未中断则TIMED_OUT;
- 否则给出null(原因是探测到arena非空或者当前线程中断)。
- match不为空时跳出循环。
7、arenaExchange(Object item, boolean timed, long ns)
此方法被执行时表示多个线程进入交换区交换数据,arena数组已被初始化,此方法中的一些处理方式和slotExchange比较类似,它是通过遍历arena数组找到需要交换的数据。
1 | // timed 为true表示设置了超时时间,ns为>0的值,反之没有设置超时时间 |
首先通过participant取得当前节点Node,然后根据当前节点Node的index去取arena中相对应的节点node。
5、前面提到过arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
1 | arena = new Node[(FULL + 2) << ASHIFT]; |
6、用@sun.misc.Contended来规避伪共享?
伪共享说明:假设一个类的两个相互独立的属性a和b在内存地址上是连续的(比如FIFO队列的头尾指针),那么它们通常会被加载到相同的cpu cache line里面。并发情况下,如果一个线程修改了a,会导致整个cache line失效(包括b),这时另一个线程来读b,就需要从内存里再次加载了,这种多线程频繁修改ab的情况下,虽然a和b看似独立,但它们会互相干扰,非常影响性能。(在原子累加器篇也有伪共享问题的阐述)
我们再看Node节点的定义,在Java 8 中我们是可以利用sun.misc.Contended来规避伪共享的。所以说通过 << ASHIFT方式加上sun.misc.Contended,所以使得任意两个可用Node不会再同一个缓存行中。
1 | static final class Node{ .misc.Contended |
我们再次回到arenaExchange()。取得arena中的node节点后,如果定位的节点q 不为空,且CAS操作成功,则交换数据,返回交换的数据,唤醒等待的线程。
- 如果q等于null且下标在bound & MMASK范围之内,则尝试占领该位置,如果成功,则采用自旋 + 阻塞的方式进行等待交换数据。
- 如果下标不在bound & MMASK范围之内获取由于q不为null但是竞争失败的时候:消除p。加入bound 不等于当前节点的bond(b != p.bound),则更新p.bound = b,collides = 0 ,i = m或者m - 1。如果冲突的次数不到m 获取m 已经为最大值或者修改当前bound的值失败,则通过增加一次collides以及循环递减下标i的值;否则更新当前bound的值成功:我们令i为m+1即为此时最大的下标。最后更新当前index的值。
7、更深入理解
1、SynchronousQueue对比?
Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:
- 线程A通过SynchronousQueue将数据a交给线程B;
- 线程A通过Exchanger和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。
可见,SynchronousQueue是交给一个数据,Exchanger是交换两个数据。
2、不同JDK实现有何差别?
- 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。
- 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量。
8、Exchanger示例
1 | public class Test { |
可以看到,其结果可能如下:
1 | Consumer- 交换前:0 |
16、ThreadPool线程池
1、线程池简介
线程池(英语:thread pool):一种线程使用模式。线程过多会带来调度开销, 进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。
2、线程池的优势
线程池做的工作只要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量, 超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。
3、线程池的主要特点
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的销耗。
- 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会销耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 Executor
,Executors
, ExecutorService
,ThreadPoolExecutor
这几个类:
4、线程池参数说明
corePoolSize
:线程池的核心线程数maximumPoolSize
:能容纳的最大线程数keepAliveTime
:空闲线程存活时间unit
:存活的时间单位workQueue
:存放提交但未执行任务的队列threadFactory
:创建线程的工厂类handler
:等待队列满后的拒绝策略
5、拒绝策略(重点)
线程池中,有三个重要的参数,决定影响了拒绝策略:corePoolSize
- 核心线程数,也即最小的线程数。workQueue
- 阻塞队列 。 maximumPoolSize
-最大线程数。
当提交任务数大于 corePoolSize 的时候,会优先将任务放到 workQueue 阻塞队列中。当阻塞队列饱和后,会扩充线程池中线程数,直到达到maximumPoolSize 最大线程数配置。此时,再多余的任务,则会触发线程池的拒绝策略了。
总结起来,也就是一句话,当提交的任务数大于(workQueue.size() + maximumPoolSize ),就会触发线程池的拒绝策略。
四种拒绝策略:
CallerRunsPolicy
:当触发拒绝策略,只要线程池没有关闭的话,则使用调用线程直接运行任务。一般并发比较小,性能要求不高,不允许失败。但是,由于调用者自己运行任务,如果任务提交速度过快,可能导致程序阻塞,性能效率上必然的损失较大;AbortPolicy
:丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。必须处理好抛出的异常,否则会打断当前的执行流程,影响后续的任务执行。DiscardPolicy
:直接丢弃,其他啥都没有;DiscardOldestPolicy
:当触发拒绝策略,只要线程池没有关闭的话,丢弃阻塞队列 workQueue 中最老的一个任务,并将新任务加入
除了线程池给的四种拒绝策略的实现,其他著名框架也提供了拒绝策略实现:
Dubbo
的实现:在抛出RejectedExecutionException
异常之前会记录日志,并 dump 线程栈信息,方便定位问题Netty
的实现:创建一个新线程来执行任务ActiveMQ
的实现:带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略PinPoint
的实现:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略
6、线程池的种类与创建
newCachedThreadPool——线程池根据需求创建线程,可扩容,遇强则强
作用:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
特点:
- 线程池中数量没有固定,可达到最大值(Interger. MAX_VALUE)
- 线程池中的线程可进行缓存重复利用和回收(回收默认时间为 1 分钟)
- 当线程池中,没有可用线程,会重新创建一个线程
创建方式:
/** * 可缓存线程池 * @return */ public static ExecutorService newCachedThreadPool(){ /** * corePoolSize 线程池的核心线程数 * maximumPoolSize 能容纳的最大线程数 * keepAliveTime 空闲线程存活时间 * unit 存活的时间单位 * workQueue 存放提交但未执行任务的队列 * threadFactory 创建线程的工厂类:可以省略 * handler 等待队列满后的拒绝策略:可以省略 */ return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()); }
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
- 场景:适用于创建一个可无限扩大的线程池,服务器负载压力较轻,执行时间较短,任务多的场景
- newFixedThreadPool——一池N线程
- 作用:创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。
- 特点:
- 线程池中的线程处于一定的量,可以很好的控制线程的并发量
- 线程可以重复被使用,在显示关闭之前,都将一直存在
- 超出一定量的线程被提交时候需在队列中等待
- 创建方式:
- ```java
/**
* 固定长度线程池
* @return
*/
public static ExecutorService newCachedThreadPool(){
/**
* corePoolSize 线程池的核心线程数
* maximumPoolSize 能容纳的最大线程数
* keepAliveTime 空闲线程存活时间
* unit 存活的时间单位
* workQueue 存放提交但未执行任务的队列
* threadFactory 创建线程的工厂类:可以省略
* handler 等待队列满后的拒绝策略:可以省略
*/
return new ThreadPoolExecutor(10,
Integer.MAX_VALUE,
0L,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
场景:适用于可以预测线程数量的业务中,或者服务器负载较重,对线程数有严格限制的场景
newSingleThreadExecutor——一个任务一个任务执行,一池一线程
作用:创建一个使用单个 worker 线程的 Executor,以无界队列方式来运行该线程。(注意,如果因为在关闭前的执行期间出现失败而终止了此单个线程, 那么如果需要,一个新线程将代替它执行后续的任务)。可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。与其他等效的newFixedThreadPool 不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。
特点:线程池中最多执行 1 个线程,之后提交的线程活动将会排在队列中以此执行
创建方式:
/** * 单一线程池 * @return */ public static ExecutorService newCachedThreadPool(){ /** * corePoolSize 线程池的核心线程数 * maximumPoolSize 能容纳的最大线程数 * keepAliveTime 空闲线程存活时间 * unit 存活的时间单位 * workQueue 存放提交但未执行任务的队列 * threadFactory 创建线程的工厂类:可以省略 * handler 等待队列满后的拒绝策略:可以省略 */ return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.SECONDS, new SynchronousQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- 场景:适用于需要保证顺序执行各个任务,并且在任意时间点,不会同时有多个线程的场景
- newScheduleThreadPool(了解)——定时以及周期性执行任务线程池
- 作用:线程池支持定时以及周期性执行任务,创建一个 corePoolSize 为传入参数,最大线程数为整形的最大数的线程池
- 特点:
- 线程池中具有指定数量的线程,即便是空线程也将保留
- 可定时或者延迟执行线程活动
- 创建方式:
- ```java
public static ScheduledExecutorService newScheduledThreadPool
(int corePoolSize, ThreadFactory threadFactory) {
return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
}
场景:适用于需要多个后台线程执行周期任务的场景
newWorkStealingPool——多个任务队列的线程池
jdk1.8 提供的线程池,底层使用的是 ForkJoinPool 实现,创建一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用 cpu 核数的线程来并行执行任务
创建方式:
public static ExecutorService newWorkStealingPool(int parallelism) { /** * parallelism:并行级别,通常默认为 JVM 可用的处理器个数 * factory:用于创建 ForkJoinPool 中使用的线程。 * handler:用于处理工作线程未处理的异常,默认为 null * asyncMode:用于控制 WorkQueue 的工作模式:队列---反队列 */ return new ForkJoinPool(parallelism, ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true); }
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
- 场景:适用于大耗时,可并行执行的场景
#### 7、线程池底层的工作原理
![10-线程池底层工作流程](JUC/10-线程池底层工作流程.png)
1. 在创建了线程池后,线程池中的线程数为零,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
2. 当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:
1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入workQueue 队列;
3. 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务(救急);
4. 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
3. 当一个线程完成任务时,它会从队列中取下一个任务来执行
4. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
1. 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
2. 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
![image-20210728141353731](JUC/image-20210728141353731.png)
#### 8、线程池的注意事项
1. **线程的创建不是在创建线程池的时候创建,而是在执行execute()方法的时候,线程才真正地开始创建**;
2. 项目中创建多线程时,使用常见的三种线程池创建方式,`单一`、`可变`、`定长`。但是它们都有一定问题,原因是 `FixedThreadPool` 和 `SingleThreadExecutor` 底层都是用LinkedBlockingQueue 实现的,这个队列最大长度为 Integer.MAX_VALUE,可能会堆积大量的请求,**容易导致 OOM**。而`CachedThreadPool`和`ScheduledThreadPool`允许创建的线程的数量为Integer.MAX_VALUE,可能会创建大量的线程,**容易导致 OOM**
3. **所以实际生产一般自己通过 ThreadPoolExecutor 的 7 个参数,自定义线程池**。
4. 创建线程池推荐适用 ThreadPoolExecutor 及其 7 个参数手动创建:
- corePoolSize 线程池的核心线程数
- maximumPoolSize 能容纳的最大线程数
- keepAliveTime 空闲线程存活时间
- unit 存活的时间单位
- workQueue 存放提交但未执行任务的队列
- threadFactory 创建线程的工厂类
- handler 等待队列满后的拒绝策略
5. 为什么不允许适用不允许 Executors.的方式手动创建线程池,如下图:
- ![image-20210728141417446](JUC/image-20210728141417446.png)
#### 9、自定义线程池
![image-20210810234602627](JUC/image-20210810234602627.png)
注意:以下的==任务队列==和==拒绝策略的接口==其实不用我们编写,可以使用JUC为我们提供的BlockingQueue,而拒绝策略的话,直接使用lambda表达式实现JUC提供好的拒绝策略接口中的reject方法即可。
##### 1、步骤1:自定义任务队列
```java
class BlockingQueue<T> {
// 1. 任务队列
private Deque<T> queue = new ArrayDeque<>();
// 2. 锁
private ReentrantLock lock = new ReentrantLock();
// 3. 生产者条件变量
private Condition fullWaitSet = lock.newCondition();
// 4. 消费者条件变量
private Condition emptyWaitSet = lock.newCondition();
// 5. 容量
private int capcity;
public BlockingQueue(int capcity) {
this.capcity = capcity;
}
// 带超时阻塞获取
public T poll(long timeout, TimeUnit unit) {
lock.lock();
try {
// 将 timeout 统一转换为 纳秒 (时间统一管理)
long nanos = unit.toNanos(timeout);
while (queue.isEmpty()) {
try {
// 返回值是剩余时间
if (nanos <= 0) {
return null;
}
// 返回值是 等待时间-执行时间
nanos = emptyWaitSet.awaitNanos(nanos);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
T t = queue.removeFirst();
fullWaitSet.signal();
return t;
} finally {
lock.unlock();
}
}
// 阻塞获取
public T take() {
lock.lock();
try {
while (queue.isEmpty()) {
try {
emptyWaitSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
T t = queue.removeFirst();
fullWaitSet.signal();
return t;
} finally {
lock.unlock();
}
}
// 阻塞添加
public void put(T task) {
lock.lock();
try {
while (queue.size() == capcity) {
try {
log.debug("等待加入任务队列 {} ...", task);
fullWaitSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("加入任务队列 {}", task);
queue.addLast(task);
emptyWaitSet.signal();
} finally {
lock.unlock();
}
}
// 带超时时间阻塞添加
public boolean offer(T task, long timeout, TimeUnit timeUnit) {
lock.lock();
try {
long nanos = timeUnit.toNanos(timeout);
while (queue.size() == capcity) {
try {
if(nanos <= 0) {
return false;
}
log.debug("等待加入任务队列 {} ...", task);
nanos = fullWaitSet.awaitNanos(nanos);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("加入任务队列 {}", task);
queue.addLast(task);
emptyWaitSet.signal();
return true;
} finally {
lock.unlock();
}
}
// 返回等待队列的长度
public int size() {
lock.lock();
try {
return queue.size();
} finally {
lock.unlock();
}
}
// 尝试放入阻塞队列,若不能放进阻塞队列,执行拒绝策略
public void tryPut(RejectPolicy<T> rejectPolicy, T task) {
lock.lock();
try {
// 判断队列是否满
if(queue.size() == capcity) {
rejectPolicy.reject(this, task);
} else { // 有空闲
log.debug("加入任务队列 {}", task);
queue.addLast(task);
emptyWaitSet.signal();
}
} finally {
lock.unlock();
}
}
}
2、步骤2:自定义拒绝策略接口
1 | // 拒绝策略 由于只有一个方法,可以使用函数式接口(lambda表达式) |
3、步骤3:自定义线程池
1 | class ThreadPool { |
4、步骤4:测试编写好的自定义线程池
1 | public class TestPool { |
17、ThreadPool线程池——ThreadPoolExecutor
1、BAT大厂的面试问题
- 为什么要有线程池?
- Java是实现和管理线程池有哪些方式?请简单举例如何使用。
- 为什么很多公司不允许使用Executors去创建线程池?那么推荐怎么使用呢?
- ThreadPoolExecutor有哪些核心的配置参数?请简要说明
- ThreadPoolExecutor可以创建哪是哪三种线程池呢?
- 当队列满了并且worker的数量达到maxSize的时候,会怎么样?
- 说说ThreadPoolExecutor有哪些RejectedExecutionHandler策略?默认是什么策略?
- 简要说下线程池的任务执行机制?
- execute –> addWorker –>runworker (getTask)
- 线程池中任务是如何提交的?
- 线程池中任务是如何关闭的?
- 在配置线程池的时候需要考虑哪些配置因素?
- 如何监控线程池的状态?
2、为什么需要线程池
线程池能够对线程进行统一分配,调优和监控:
- 降低资源消耗(线程无限制地创建,然后使用完毕后销毁)
- 提高响应速度(无须创建线程)
- 提高线程的可管理性
3、ThreadPoolExecutor例子
Java是如何实现和管理线程池的?
从JDK 5开始,把工作单元与执行机制分离开来,工作单元包括Runnable和Callable,而执行机制由Executor框架提供。
WorkerThread:
1 | public class WorkerThread implements Runnable { |
SimpleThreadPool:
1 | import java.util.concurrent.ExecutorService; |
程序中我们创建了固定大小为五个工作线程的线程池。然后分配给线程池十个工作,因为线程池大小为五,它将启动五个工作线程先处理五个工作,其他的工作则处于等待状态,一旦有工作完成,空闲下来工作线程就会捡取等待队列里的其他工作进行执行。
这里是以上程序的输出:
1 | pool-1-thread-2 Start. Command = 1 |
输出表明线程池中至始至终只有五个名为 “pool-1-thread-1” 到 “pool-1-thread-5” 的五个线程,这五个线程不随着工作的完成而消亡,会一直存在,并负责执行分配给线程池的任务,直到线程池消亡。
Executors 类提供了使用了 ThreadPoolExecutor 的简单的 ExecutorService 实现,但是 ThreadPoolExecutor 提供的功能远不止于此。我们可以在创建 ThreadPoolExecutor 实例时指定活动线程的数量,我们也可以限制线程池的大小并且创建我们自己的 RejectedExecutionHandler 实现来处理不能适应工作队列的工作。
这里是我们自定义的 RejectedExecutionHandler 接口的实现:
1 | import java.util.concurrent.RejectedExecutionHandler; |
ThreadPoolExecutor 提供了一些方法,我们可以使用这些方法来查询 executor 的当前状态,线程池大小,活动线程数量以及任务数量。因此我是用来一个监控线程在特定的时间间隔内打印 executor 信息。
MyMonitorThread.java:
1 | import java.util.concurrent.ThreadPoolExecutor; |
这里是使用 ThreadPoolExecutor 的线程池实现例子。
WorkerPool.java:
1 | import java.util.concurrent.ArrayBlockingQueue; |
注意在初始化 ThreadPoolExecutor 时,我们保持初始池大小为 2,最大池大小为 4 而工作队列大小为 2。因此如果已经有四个正在执行的任务而此时分配来更多任务的话,工作队列将仅仅保留他们(新任务)中的两个,其他的将会被 RejectedExecutionHandlerImpl 处理。
上面程序的输出可以证实以上观点:
1 | pool-1-thread-1 Start. Command = cmd0 |
注意 executor 的活动任务、完成任务以及所有完成任务,这些数量上的变化。我们可以调用 shutdown() 方法来结束所有提交的任务并终止线程池。
4、ThreadPoolExecutor使用详解
其实java线程池的实现原理很简单,说白了就是一个线程集合workerSet和一个阻塞队列workQueue。当用户向线程池提交一个任务(也就是线程)时,线程池会先将任务放入workQueue中。workerSet中的线程会不断的从workQueue中获取线程然后执行。当workQueue中没有任务的时候,worker就会阻塞,直到队列中有任务了就取出来继续执行。
1、Execute原理
当一个任务提交至线程池之后:
- 线程池首先当前运行的线程数量是否少于corePoolSize。如果是,则创建一个新的工作线程来执行任务。如果都在执行任务,则进入2.
- 判断BlockingQueue是否已经满了,倘若还没有满,则将线程放入BlockingQueue。否则进入3.
- 如果创建一个新的工作线程将使当前运行的线程数量超过maximumPoolSize,则交给RejectedExecutionHandler来处理任务。
当ThreadPoolExecutor创建新线程时,通过CAS来更新线程池的状态ctl。
2、参数
1 | public ThreadPoolExecutor(int corePoolSize, |
corePoolSize
线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize,即使有其他空闲线程能够执行新来的任务,也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()
方法,线程池会提前创建并启动所有核心线程。workQueue
用来保存等待被执行的任务的阻塞队列。在JDK中提供了如下阻塞队列:ArrayBlockingQueue
:基于数组结构的有界阻塞队列,按FIFO排序任务;LinkedBlockingQuene
:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;SynchronousQuene
:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;PriorityBlockingQuene
:具有优先级的无界阻塞队列;
LinkedBlockingQueue
比ArrayBlockingQueue
在插入删除节点性能方面更优,但是二者在put()
, take()
任务的时均需要加锁,SynchronousQueue
使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是Transfer.transfer()
。
maximumPoolSize
线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;当阻塞队列是无界队列,则maximumPoolSize则不起作用,因为无法提交至核心线程池的线程会一直持续地放入workQueue。keepAliveTime
线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用,超过这个时间的空闲线程将被终止; ——针对救急线程unit
keepAliveTime的单位 —— 针对救急线程threadFactory
创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactory
handler
线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:AbortPolicy
:直接抛出异常,默认策略;CallerRunsPolicy
:用调用者所在的线程来执行任务;DiscardOldestPolicy
:丢弃阻塞队列中靠最前的任务,并执行当前任务;DiscardPolicy
:直接丢弃任务;
当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义拒绝策略,如记录日志或持久化存储不能处理的任务。
根据这个构造方法,JDK Executors 类中提供了众多工厂方法来创建各种用途的线程池
3、三种类型
1、newFixedThreadPool
1 | public static ExecutorService newFixedThreadPool(int nThreads) { |
特点:
- 线程池的线程数量达corePoolSize后,即使线程池没有可执行任务时,也不会释放线程。
- 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间
- 阻塞队列是无界的,可以放任意数量的任务
FixedThreadPool的工作队列为无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE),这会导致以下问题:
- 线程池里的线程数量不超过corePoolSize,这导致了maximumPoolSize和keepAliveTime将会是个无用参数
- 由于使用了无界队列,所以FixedThreadPool永远不会拒绝,即饱和策略失效
评价:适用于任务量已知,相对耗时的任务
2、newSingleThreadPool
1 | public static ExecutorService newSingleThreadExecutor() { |
特点:
- 初始化的线程池中只有一个线程,如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行。
由于使用了无界队列,所以SingleThreadPool永远不会拒绝,即饱和策略失效。
使用场景:
- 希望多个任务排队执行。
- 线程数固定为 1,任务数多于 1 时,会放入无界队列排队。
- 任务执行完毕,这唯一的线程也不会被释放。
3、newCachedThreadPool
1 | public static ExecutorService newCachedThreadPool() { |
特点:
- 核心线程数是 0, 最大线程数是
Integer.MAX_VALUE
,救急线程的空闲生存时间是 60s,意味着- 全部都是救急线程(60s 后可以回收)
- 救急线程可以无限创建
- 队列采用了
SynchronousQueue
实现特点是,它没有容量,没有线程来取是放不进去的(一手交钱、一手交货)
线程池的线程数可达到Integer.MAX_VALUE
,即2147483647
,内部使用SynchronousQueue
作为阻塞队列; 和newFixedThreadPool
创建的线程池不同,newCachedThreadPool
在没有任务执行时,当线程的空闲时间超过keepAliveTime
,会自动释放线程资源,当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销; 执行过程与前两种稍微不同:
- 主线程调用SynchronousQueue的offer()方法放入task,倘若此时线程池中有空闲的线程尝试读取 SynchronousQueue的task,即调用了SynchronousQueue的poll(),那么主线程将该task交给空闲线程。否则执行(2)
- 当线程池为空或者没有空闲的线程,则创建新的线程执行任务。
- 执行完任务的线程倘若在60s内仍空闲,则会被终止。因此长时间空闲的CachedThreadPool不会持有任何线程资源。
评价:整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线程。 适合任务数比较密集,但每个任务执行时间较短的情况
4、区别
- 自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作
Executors.newSingleThreadExecutor()
线程个数始终为1,不能修改FinalizableDelegatedExecutorService
应用的是==装饰器模式==,只对外暴露了ExecutorService
接口,因此不能调用ThreadPoolExecutor
中特有的方法
Executors.newFixedThreadPool(1)
初始时为1,以后还可以修改- 对外暴露的是
ThreadPoolExecutor
对象,可以强转后调用 setCorePoolSize 等方法进行修改
- 对外暴露的是
4、关闭线程池
遍历线程池中的所有线程,然后逐个调用线程的interrupt方法来中断线程。
1、关闭方式——shutdown
将线程池里的线程状态设置成SHUTDOWN
状态,然后中断所有没有正在执行任务的线程。
2、关闭方式——shutdownNow
将线程池里的线程状态设置成STOP
状态,然后停止所有正在执行或暂停任务的线程。只要调用这两个关闭方法中的任意一个,isShutDown() 返回true。当所有任务都成功关闭了,isTerminated()返回true。
5、ThreadPoolExecutor源码详解
1、几个关键属性
1 | //这个属性是用来存放 当前运行的worker数量以及线程池状态的 |
2、内部状态
1 | private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); |
其中AtomicInteger变量ctl
的功能非常强大:利用低29位表示线程池中线程数,通过高3位表示线程池的运行状态:
RUNNING
:-1 << COUNT_BITS,即高3位为111
,该状态的线程池会接收新任务,并处理阻塞队列中的任务;SHUTDOWN
:0 << COUNT_BITS,即高3位为000
,该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;STOP
:1 << COUNT_BITS,即高3位为001
,该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;TIDYING
:2 << COUNT_BITS,即高3位为010
,所有任务全执行完毕,活动线程为 0 即将进入终结;TERMINATED
:3 << COUNT_BITS,即高3位为011
,terminated()方法已经执行完成
状态名 | 高3位 | 接收新任务 | 处理阻塞队列任务 | 说明 |
---|---|---|---|---|
RUNNING | 111 | Y | Y | |
SHUTDOWN | 000 | N | Y | 不会接收新任务,但会处理阻塞队列剩余任务 |
STOP | 001 | N | N | 会中断正在执行的任务,并抛弃阻塞队列任务 |
TIDYING | 010 | - | - | 任务全执行完毕,活动线程为 0 即将进入终结 |
TERMINATED | 011 | - | - | 终结状态 |
从数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING
注意:为什么RUNNING为
111
确是最小的?因为计算机都是补码来记录,所以
111
其实是-1
这些信息存储在一个原子变量 ctl 中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作进行赋值
1 | // c 为旧值, ctlOf 返回结果为新值 |
3、任务的执行
execute –> addWorker –>runworker (getTask)
线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。 从Woker类的构造方法实现可以发现:线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。 firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;
1、execute()方法
ThreadPoolExecutor.execute(task)实现了Executor.execute(task)
1 | public void execute(Runnable command) { |
为什么需要double check线程池的状态?
在多线程环境下,线程池的状态时刻在变化,而ctl.get()是非原子操作,很有可能刚获取了线程池状态后线程池状态就改变了。判断是否将command加入workque是线程池之前的状态。倘若没有double check,万一线程池处于非running状态(在多线程环境下很有可能发生),那么command永远不会执行。
2、addWorker方法
从方法execute的实现可以看出:addWorker主要负责创建新的线程并执行任务线程池创建新线程执行任务时,需要 获取全局锁:
1 | private final ReentrantLock mainLock = new ReentrantLock(); |
1 | private boolean addWorker(Runnable firstTask, boolean core) { |
3、Worker类的runworker方法
1 | private final class Worker extends AbstractQueuedSynchronizer implements Runnable{ |
- 继承了AQS类,可以方便的实现工作线程的中止操作;
- 实现了Runnable接口,可以将自身作为一个任务在工作线程中执行;
- 当前提交的任务firstTask作为参数传入Worker的构造方法;
一些属性还有构造方法:
1 | //运行的线程,前面addWorker方法中就是直接通过启动这个线程来启动这个worker |
runWorker方法是线程池的核心:
- 线程启动之后,通过unlock方法释放锁,设置AQS的state为0,表示运行可中断;
- Worker执行firstTask或从workQueue中获取任务:
- 进行加锁操作,保证thread不被其他线程中断(除非线程池被中断)
- 检查线程池状态,倘若线程池处于中断状态,当前线程将中断。
- 执行beforeExecute
- 执行任务的run方法
- 执行afterExecute方法
- 解锁操作
通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;
1 | final void runWorker(Worker w) { |
4、getTask方法
下面来看一下getTask()方法,这里面涉及到keepAliveTime的使用,从这个方法我们可以看出线程池是怎么让超过corePoolSize的那部分worker销毁的。
1 | private Runnable getTask() { |
注意这里一段代码是keepAliveTime起作用的关键:
1 | boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; |
allowCoreThreadTimeOut为false,线程即使空闲也不会被销毁;倘若为ture,在keepAliveTime内仍空闲则会被销毁。
如果线程允许空闲等待而不被销毁timed == false,workQueue.take任务:如果阻塞队列为空,当前线程会被挂起等待;当队列中有任务加入时,线程被唤醒,take方法返回任务,并执行;
如果线程不允许无休止空闲timed == true,workQueue.poll任务:如果在keepAliveTime时间内,阻塞队列还是没有任务,则返回null;
4、任务的提交
1 | // 执行任务 |
- submit任务,等待线程池execute
- 执行FutureTask类的get方法时,会把主线程封装成WaitNode节点并保存在waiters链表中, 并阻塞等待运行结果;
- FutureTask任务执行完成后,通过UNSAFE设置waiters相应的waitNode为null,并通过LockSupport类unpark方法唤醒主线程;
1 | public class Test{ |
在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。
- Callable接口类似于Runnable,只是Runnable没有返回值。
- Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果;
- Future.get方法会导致主线程阻塞,直到Callable任务执行完成;
1、submit方法
AbstractExecutorService.submit()实现了ExecutorService.submit() 可以获取执行完的返回值,而ThreadPoolExecutor 是AbstractExecutorService.submit()的子类,所以submit方法也是ThreadPoolExecutor的方法。
1 | // submit()在ExecutorService中的定义 |
1 | // submit方法在AbstractExecutorService中的实现 |
通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。通过Executor.execute方法提交FutureTask到线程池中等待被执行,最终执行的是FutureTask的run方法;
2、FutureTask对象
public class FutureTask<V> implements RunnableFuture<V>
可以将FutureTask提交至线程池中等待被执行(通过FutureTask的run方法来执行)
内部状态
/* The run state of this task, initially NEW. * ... * Possible state transitions: * NEW -> COMPLETING -> NORMAL * NEW -> COMPLETING -> EXCEPTIONAL * NEW -> CANCELLED * NEW -> INTERRUPTING -> INTERRUPTED */ private volatile int state; private static final int NEW = 0; private static final int COMPLETING = 1; private static final int NORMAL = 2; private static final int EXCEPTIONAL = 3; private static final int CANCELLED = 4; private static final int INTERRUPTING = 5; private static final int INTERRUPTED = 6;
1
2
3
4
5
6
7
8
9
10
11
12
13
- 内部状态的修改通过sun.misc.Unsafe修改
- get方法
```java
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}内部通过awaitDone方法对主线程进行阻塞,具体实现如下:
private int awaitDone(boolean timed, long nanos) throws InterruptedException { final long deadline = timed ? System.nanoTime() + nanos : 0L; WaitNode q = null; boolean queued = false; for (;;) { if (Thread.interrupted()) { removeWaiter(q); throw new InterruptedException(); } int s = state; if (s > COMPLETING) { if (q != null) q.thread = null; return s; } else if (s == COMPLETING) // cannot time out yet Thread.yield(); else if (q == null) q = new WaitNode(); else if (!queued) queued = UNSAFE.compareAndSwapObject(this, waitersOffset,q.next = waiters, q); else if (timed) { nanos = deadline - System.nanoTime(); if (nanos <= 0L) { removeWaiter(q); return state; } LockSupport.parkNanos(this, nanos); } else LockSupport.park(this); } }
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
- 如果主线程被中断,则抛出中断异常;
- 判断FutureTask当前的state,如果大于COMPLETING,说明任务已经执行完成,则直接返回;
- 如果当前state等于COMPLETING,说明任务已经执行完,这时主线程只需通过yield方法让出cpu资源,等待state变成NORMAL;
- 通过WaitNode类封装当前线程,并通过UNSAFE添加到waiters链表;
- 最终通过LockSupport的park或parkNanos挂起线程;
- run方法
- ```java
public void run() {
if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);
}
}FutureTask.run方法是在线程池中被执行的,而非主线程:
- 通过执行Callable任务的call方法;
- 如果call执行成功,则通过set方法保存结果;
- 如果call执行有异常,则通过setException保存异常;
5、任务的关闭
shutdown方法会将线程池的状态设置为SHUTDOWN,线程池进入这个状态后,就拒绝再接受任务,然后会将剩余的任务全部执行完:
1 | /* |
shutdownNow做的比较绝,它先将线程池状态设置为STOP,然后拒绝所有提交的任务。最后中断左右正在运行中的worker,然后清空任务队列。
1 | /* |
6、其他方法
1 | // 不在 RUNNING 状态的线程池,此方法就返回 true |
6、异常的处理
使用线程池创建线程时,如果线程内部发生异常的话,是不会抛出或者在控制台打印异常信息的,所以需要我们对可能出现异常进行异常处理,对于异常的处理有以下几种方法:
线程自己捕捉:线程在代码里对可能出现的异常进行try catch捕捉
ExecutorService pool = Executors.newFixedThreadPool(1); pool.submit(() -> { try { log.debug("task1"); int i = 1 / 0; } catch (Exception e) { log.error("error:", e); } });
1
2
3
4
5
6
7
8
9
10
11
- 通过Future进行结果的返回来判断是否发生异常:
- ```java
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<Boolean> f = pool.submit(() -> {
log.debug("task1");
inti = 1/0;
return true ;
});
Log. debug("result:{}", f.get();
7、更深入理解
1、为什么线程池不允许使用Executors去创建?
线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:
- newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
- newCachedThreadPool和newScheduledThreadPool:主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
1、推荐方式1
首先引入:commons-lang3包
1 | ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1, |
2、推荐方式2
首先引入:com.google.guava包
1 | ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build(); |
3、推荐方式3
spring配置线程池方式:自定义线程工厂bean需要实现ThreadFactory,可参考该接口的其它默认实现类,使用方式直接注入bean调用execute(Runnable task)方法即可。
1 | <bean id="userThreadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor"> |
2、配置线程池需要考虑的因素
从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。
性质不同的任务可用使用不同规模的线程池分开处理:
- CPU密集型:尽可能少的线程,Ncpu+1
- IO密集型:尽可能多的线程,Ncpu*2,比如数据库连接池
- 混合型:CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。
具体也可以参考8、并发的多线程设计模式的7、工作线程模式查看
3、监控线程池的状态
可以使用ThreadPoolExecutor以下方法:
getTaskCount()
Returns the approximate total number of tasks that have ever been scheduled for execution.- 返回计划执行的任务的大致总数。
getCompletedTaskCount()
Returns the approximate total number of tasks that have completed execution.- 返回已完成执行的任务的大致总数
- 返回结果少于getTaskCount()。
getLargestPoolSize()
Returns the largest number of threads that have ever simultaneously been in the pool.- 返回池中同时存在的最大线程数。
- 返回结果小于等于maximumPoolSize
getPoolSize()
Returns the current number of threads in the pool.- 返回池中当前的线程数。
getActiveCount()
Returns the approximate number of threads that are actively executing tasks.- 返回当前正在执行任务的线程的大致数目。
18、ThreadPool线程池——ScheduledThreadPoolExecutor
在『任务调度线程池』功能加入之前,可以使用 java.util.Timer 来实现定时功能,Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务。
如果使用的是ScheduledThreadPoolExecutor
,前一个任务的延迟或异常都不会影响到之后的任务,但是异常信息不会被打印出来。需要我们对异常信息进行处理
在很多业务场景中,我们可能需要周期性的运行某项任务来获取结果,比如周期数据统计,定时发送数据等。在并发包出现之前,Java 早在1.3就提供了 Timer 类(只需要了解,目前已渐渐被 ScheduledThreadPoolExecutor 代替)来适应这些业务场景。随着业务量的不断增大,我们可能需要多个工作线程运行任务来尽可能的增加产品性能,或者是需要更高的灵活性来控制和监控这些周期业务。这些都是 ScheduledThreadPoolExecutor 诞生的必然性。
1、BAT大厂的面试问题
- ScheduledThreadPoolExecutor要解决什么样的问题?
- ScheduledThreadPoolExecutor相比ThreadPoolExecutor有哪些特性?
- ScheduledThreadPoolExecutor有什么样的数据结构,核心内部类和抽象类?
- ScheduledThreadPoolExecutor有哪两个关闭策略?区别是什么?
- ScheduledThreadPoolExecutor中scheduleAtFixedRate 和 scheduleWithFixedDelay区别是什么?
- 为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
- Executors 提供了几种方法来构造 ScheduledThreadPoolExecutor?
2、ScheduledThreadPoolExecutor简介
ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。和 ThreadPoolExecutor 相比,它还具有以下几种特性:
- 使用专门的任务类型——ScheduledFutureTask 来执行周期任务,也可以接收不需要时间调度的任务(这些任务通过 ExecutorService 来执行)。
- 使用专门的存储队列——DelayedWorkQueue 来存储任务,DelayedWorkQueue 是无界延迟队列DelayQueue 的一种。相比ThreadPoolExecutor也简化了执行机制(delayedExecute方法,后面单独分析)。
- 支持可选的run-after-shutdown参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。
3、ScheduledThreadPoolExecutor数据结构
ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor
,ScheduledThreadPoolExecutor 内部构造了两个内部类 ScheduledFutureTask
和 DelayedWorkQueue
:
ScheduledFutureTask
:继承了FutureTask,说明是一个异步运算任务;最上层分别实现了Runnable、Future、Delayed接口,说明它是一个可以延迟执行的异步运算任务。DelayedWorkQueue
:这是 ScheduledThreadPoolExecutor 为存储周期或延迟任务专门定义的一个延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。它内部只允许存储 RunnableScheduledFuture 类型的任务。与 DelayQueue 的不同之处就是它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 是利用了 PriorityQueue 的二叉堆结构)。
4、ScheduledThreadPoolExecutor源码解析
1、内部类ScheduledFutureTask
1、属性
1 | //为相同延时任务提供的顺序编号 |
sequenceNumber
:当两个任务有相同的延迟时间时,按照FIFO
的顺序入队。sequenceNumber 就是为相同延时任务提供的顺序编号。time
:任务可以执行时的时间,==纳秒级==,通过triggerTime
方法计算得出。period
:任务的执行周期时间,==纳秒级==。- 正数表示固定速率执行(为scheduleAtFixedRate提供服务),
- 负数表示固定延迟执行(为scheduleWithFixedDelay提供服务),
- 0表示不重复任务。
outerTask
:重新入队的任务,通过reExecutePeriodic
方法入队重新排序。
2、核心方法run()
1 | public void run() { |
说明:ScheduledFutureTask 的run方法重写了 FutureTask 的版本,以便执行周期任务时重置/重排序任务。任务的执行通过父类 FutureTask 的run实现。
内部有两个针对周期任务的方法:
setNextRunTime()
:用来设置下一次运行的时间,源码如下://设置下一次执行任务的时间 private void setNextRunTime() { long p = period; if (p > 0) //固定速率执行,scheduleAtFixedRate time += p; else time = triggerTime(-p); //固定延迟执行,scheduleWithFixedDelay } //计算固定延迟任务的执行时间 long triggerTime(long delay) { return now() + ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay)); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- `reExecutePeriodic()`:**周期任务重新入队等待下一次执行**,源码如下:
- ```java
//重排序一个周期任务
void reExecutePeriodic(RunnableScheduledFuture<?> task) {
if (canRunInCurrentRunState(true)) {//池关闭后可继续执行
super.getQueue().add(task);//任务入列
//重新检查run-after-shutdown参数,如果不能继续运行就移除队列任务,并取消任务的执行
if (!canRunInCurrentRunState(true) && remove(task))
task.cancel(false);
else
ensurePrestart();//启动一个新的线程等待任务
}
}
reExecutePeriodic与delayedExecute的执行策略一致,只不过reExecutePeriodic不会执行拒绝策略而是直接丢掉任务。
3、cancel方法
1 | public boolean cancel(boolean mayInterruptIfRunning) { |
ScheduledFutureTask.cancel本质上由其父类 FutureTask.cancel 实现。取消任务成功后会根据removeOnCancel参数决定是否从队列中移除此任务。
2、核心属性
1 | //关闭后继续执行已经存在的周期任务 |
continueExistingPeriodicTasksAfterShutdown
和executeExistingDelayedTasksAfterShutdown
是 ScheduledThreadPoolExecutor 定义的run-after-shutdown
参数,用来控制池关闭之后的任务执行逻辑。removeOnCancel
用来控制任务取消后是否从队列中移除。当一个已经提交的周期或延迟任务在运行之前被取消,那么它之后将不会运行。默认配置下,这种已经取消的任务在届期之前不会被移除。 通过这种机制,可以方便检查和监控线程池状态,但也可能导致已经取消的任务无限滞留。为了避免这种情况的发生,我们可以通过setRemoveOnCancelPolicy
方法设置移除策略,把参数removeOnCancel
设为true可以在任务取消后立即从队列中移除。sequencer
是为相同延时的任务提供的顺序编号,保证任务之间的FIFO
顺序。与 ScheduledFutureTask 内部的sequenceNumber参数作用一致。
3、构造函数
首先看下构造函数,ScheduledThreadPoolExecutor 内部有四个构造函数,这里我们只看这个最大构造灵活度的:
1 | public ScheduledThreadPoolExecutor(int corePoolSize, |
构造函数都是通过super调用了ThreadPoolExecutor的构造,并且使用特定等待队列DelayedWorkQueue。
4、核心方法——Schedule
1 | public <V> ScheduledFuture<V> schedule(Callable<V> callable, |
说明:schedule主要用于执行一次性(延迟)任务。函数执行逻辑分两步:
封装 Callable/Runnable
: 首先通过triggerTime
计算任务的延迟执行时间,然后通过ScheduledFutureTask
的构造函数把 Runnable/Callable 任务构造为ScheduledThreadPoolExecutor
可以执行的任务类型,最后调用decorateTask
方法执行用户自定义的逻辑;decorateTask是一个用户可自定义扩展的方法,默认实现下直接返回封装的RunnableScheduledFuture任务,源码如下:protected <V> RunnableScheduledFuture<V> decorateTask( Runnable runnable, RunnableScheduledFuture<V> task) { return task; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- `执行任务`:**通过delayedExecute实现**。下面我们来详细分析:
- ```java
private void delayedExecute(RunnableScheduledFuture<?> task) {
if (isShutdown())
reject(task);//池已关闭,执行拒绝策略
else {
super.getQueue().add(task);//任务入队
if (isShutdown() &&
!canRunInCurrentRunState(task.isPeriodic()) &&//判断run-after-shutdown参数
remove(task))//移除任务
task.cancel(false);
else
ensurePrestart();//启动一个新的线程等待任务
}
}
说明:delayedExecute是执行任务的主方法,方法执行逻辑如下:
如果池已关闭(ctl >= SHUTDOWN),执行任务拒绝策略;
池正在运行,首先把任务入队排序;然后重新检查池的关闭状态,执行如下逻辑:
A
:如果池正在运行,或者 run-after-shutdown 参数值为true,则调用父类方法ensurePrestart启动一个新的线程等待执行任务。ensurePrestart源码如下:void ensurePrestart() { int wc = workerCountOf(ctl.get()); if (wc < corePoolSize) addWorker(null, true); else if (wc == 0) addWorker(null, false); }
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
- ensurePrestart是父类 ThreadPoolExecutor 的方法,用于启动一个新的工作线程等待执行任务,即使corePoolSize为0也会安排一个新线程。
2. `B`:**如果池已经关闭,并且 run-after-shutdown 参数值为false,则执行父类(ThreadPoolExecutor)方法remove移除队列中的指定任务,成功移除后调用ScheduledFutureTask.cancel取消任务**
##### 5、核心方法——scheduleAtFixedRate 和 scheduleWithFixedDelay
```java
/**
* 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
* 之后每隔period执行一次,不等待第一次执行完成就开始计时
*/
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit) {
if (command == null || unit == null)
throw new NullPointerException();
if (period <= 0)
throw new IllegalArgumentException();
//构建RunnableScheduledFuture任务类型
ScheduledFutureTask<Void> sft =
new ScheduledFutureTask<Void>(command,
null,
triggerTime(initialDelay, unit),//计算任务的延迟时间
unit.toNanos(period));//计算任务的执行周期
RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
delayedExecute(t);//执行任务
return t;
}
/**
* 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
* 在第一次执行完之后延迟delay后开始下一次执行
*/
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay,
long delay,
TimeUnit unit) {
if (command == null || unit == null)
throw new NullPointerException();
if (delay <= 0)
throw new IllegalArgumentException();
//构建RunnableScheduledFuture任务类型
ScheduledFutureTask<Void> sft =
new ScheduledFutureTask<Void>(command,
null,
triggerTime(initialDelay, unit),//计算任务的延迟时间
unit.toNanos(-delay));//计算任务的执行周期
RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
delayedExecute(t);//执行任务
return t;
}
说明:scheduleAtFixedRate和scheduleWithFixedDelay方法的逻辑与schedule类似。
注意scheduleAtFixedRate和scheduleWithFixedDelay的区别: 乍一看两个方法一模一样,其实,在unit.toNanos这一行代码中还是有区别的:
- 没错,scheduleAtFixedRate传的是正值,而scheduleWithFixedDelay传的则是负值,这个值就是 ScheduledFutureTask 的period属性。
- 执行效果上也有区别:
- 对于scheduleAtFixedRate来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么将会影响到间隔的时间——间隔时间无效,会等到任务执行完毕在执行下一个任务。
- 而对于scheduleWithFixedDelay来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么间隔的时间会增加——间隔的时间(3s) = 设置的延迟时间(1s) + 代码的执行时间(2s),即:scheduleWithFixedDelay的时间间隔是从上一个任务结束时间来计算的
6、核心方法——shutdown()
1 | public void shutdown() { |
说明:池关闭方法调用了父类ThreadPoolExecutor的shutdown,具体分析见 ThreadPoolExecutor 篇。这里主要介绍以下在shutdown方法中调用的==关闭钩子onShutdown方法==,它的主要作用是在关闭线程池后取消并清除由于关闭策略不应该运行的所有任务,这里主要是根据 run-after-shutdown 参数(continueExistingPeriodicTasksAfterShutdown和executeExistingDelayedTasksAfterShutdown)来决定线程池关闭后是否关闭已经存在的任务。
5、再深入理解
1、为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
例如:
- 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。
- 此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。
2、Executors 提供了哪几种方法来构造 ScheduledThreadPoolExecutor?
newScheduledThreadPool
:可指定核心线程数的线程池。newSingleThreadScheduledExecutor
:只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。
注意:newScheduledThreadPool(1, threadFactory) 不等价于newSingleThreadScheduledExecutor。
- newSingleThreadScheduledExecutor创建的线程池保证内部只有一个线程执行任务,并且线程数不可扩展;
- 而通过newScheduledThreadPool(1, threadFactory)创建的线程池可以通过setCorePoolSize方法来修改核心线程数。
6、ScheduledThreadPoolExecutor应用
需求:让每周四 18:00:00 定时执行任务
1 | import java.time.DayOfWeek; |
19、Tomcat 线程池
1、概述
Tomcat 在哪里用到了线程池呢?——tomcat的连接器部分(Connector)(tomcat还有容器部分——负责servlet规范的)
- LimitLatch 用来限流,可以控制最大连接个数,类似 J.U.C 中的 Semaphore
- Acceptor 只负责【接收新的 socket 连接】
- Poller 只负责监听 socket channel 是否有【可读的 I/O 事件】
- 一旦可读,封装一个任务对象(socketProcessor),提交给 Executor 线程池处理
- Executor 线程池中的工作线程最终负责【处理请求】
Tomcat 线程池扩展了 ThreadPoolExecutor,行为稍有不同
- 如果总线程数达到 maximumPoolSize
- 这时不会立刻抛 RejectedExecutionException 异常
- 而是再次尝试将任务放入队列,如果还失败,才抛出 RejectedExecutionException 异常
2、源码 tomcat-7.0.42
1 | public void execute(Runnable command, long timeout, TimeUnit unit) { |
TaskQueue.java
1 | public boolean force(Runnable o, long timeout, TimeUnit unit) throws InterruptedException { |
3、Connector 配置
配置项 | 默认值 | 说明 |
---|---|---|
acceptorThreadCount | 1 | acceptor 线程数量 |
pollerThreadCount | 1 | poller 线程数量 |
minSpareThreads | 10 | 核心线程数,即 corePoolSize |
maxThreads | 200 | 最大线程数,即 maximumPoolSize |
executor | - | Executor 名称,用来引用下面的 Executor |
4、Executor 线程配置
配置项 | 默认值 | 说明 |
---|---|---|
threadPriority | 5 | 线程优先级 |
daemon | true | 是否守护线程 |
minSpareThreads | 25 | 核心线程数,即 corePoolSize |
maxThreads | 200 | 最大线程数,即 maximumPoolSize |
maxIdleTime | 60000 | 线程生存时间,单位是毫秒,默认值即 1 分钟 |
maxQueueSize | Integer.MAX_VALUE | 队列长度 |
prestartminSpareThreads | false | 核心线程是否在服务器启动时启动 |
20、Fork/Join分支合并框架
ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。
1、BAT大厂的面试问题
- Fork/Join主要用来解决什么样的问题?
- Fork/Join框架是在哪个JDK版本中引入的?
- Fork/Join框架主要包含哪三个模块?模块之间的关系是怎么样的?
- ForkJoinPool类继承关系?
- ForkJoinTask抽象类继承关系?
- 在实际运用中,我们一般都会继承
RecursiveTask
、RecursiveAction
或CountedCompleter
来实现我们的业务需求,而不会直接继承 ForkJoinTask 类。
- 在实际运用中,我们一般都会继承
- 整个Fork/Join 框架的执行流程/运行机制是怎么样的?
- 具体阐述Fork/Join的分治思想和work-stealing 实现方式?
- 有哪些JDK源码中使用了Fork/Join思想?
- 如何使用Executors工具类创建ForkJoinPool?
- 写一个例子:用ForkJoin方式实现1+2+3+…+100000?
- Fork/Join在使用时有哪些注意事项?结合JDK中的斐波那契数列实例具体说明
2、Fork/Join框架简介
Fork/Join框架是Java并发工具包中的一种可以将一个大任务拆分为很多小任务来异步执行的工具,自JDK1.7引入。
1、三个模块及关系
Fork/Join框架主要包含三个模块:
- 任务对象:
ForkJoinTask
(包括RecursiveTask
、RecursiveAction
和CountedCompleter
) - 执行Fork/Join任务的线程:
ForkJoinWorkerThread
- 线程池:
ForkJoinPool
这三者的关系是:ForkJoinPool可以通过池中的ForkJoinWorkerThread来处理ForkJoinTask任务。
1 | // from 《A Java Fork/Join Framework》Dong Lea |
ForkJoinPool 只接收 ForkJoinTask 任务(在实际使用中,也可以接收 Runnable/Callable 任务,但在真正运行时,也会把这些任务封装成 ForkJoinTask 类型的任务),RecursiveTask 是 ForkJoinTask 的子类,是一个可以递归执行的 ForkJoinTask,RecursiveAction 是一个无返回值的 RecursiveTask,CountedCompleter 在任务完成执行后会触发执行一个自定义的钩子函数。
1 | // From JDK 7 doc. Class RecursiveTask<V> |
在实际运用中,我们一般都会继承 RecursiveTask
、RecursiveAction
或 CountedCompleter
来实现我们的业务需求,而不会直接继承 ForkJoinTask 类。
2、核心思想:分治算法(Divide-and-Conquer)
分治算法(Divide-and-Conquer)把任务递归的拆分为各个子任务,这样可以更好的利用系统资源,尽可能的使用所有可用的计算能力来提升应用性能。首先看一下 Fork/Join 框架的任务运行机制:
Fork/Join 框架要完成两件事情:
- Fork:把一个复杂任务进行分拆,大事化小
- Join:把分拆任务的结果进行合并
- 任务分割:首先 Fork/Join 框架需要把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割
- 执行任务并合并结果:分割的子任务分别放到双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都放在另外一个队列里,启动一个线程从队列里取数据,然后合并这些数据。
3、核心思想:work-stealing(工作窃取)算法
work-stealing(工作窃取)算法:线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务(如果不存在就阻塞等待)。这种特性使得 ForkJoinPool 在运行多个可以产生子任务的任务,或者是提交的许多小任务时效率更高。尤其是构建异步模型的 ForkJoinPool 时,对不需要合并(join)的事件类型任务也非常适用。
在 ForkJoinPool 中,线程池中每个工作线程(ForkJoinWorkerThread)都对应一个任务队列(WorkQueue),工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。
具体思路如下:
- 每个线程都有自己的一个WorkQueue,该工作队列是一个双端队列。
- 队列支持三个功能push、pop、poll
- push/pop只能被队列的所有者线程调用,而poll可以被其他线程调用。
- 划分的子任务调用fork时,都会被push到自己的队列中。
- 默认情况下,工作线程从自己的双端队列获出任务并执行。
- 当自己的队列为空时,线程随机从另一个线程的队列末尾调用poll方法窃取任务。
4、Fork/Join 框架的执行流程
上图可以看出ForkJoinPool 中的任务执行分两种:
- 直接通过 FJP 提交的外部任务(external/submissions task),存放在 workQueues 的偶数槽位;
- 通过内部 fork 分割的子任务(Worker task),存放在 workQueues 的奇数槽位。
那Fork/Join 框架的执行流程是什么样的?
3、Fork/Join类关系
1、ForkJoinPool继承关系
内部类介绍:
ForkJoinWorkerThreadFactory
:内部线程工厂接口,用于创建工作线程ForkJoinWorkerThreadDefaultForkJoinWorkerThreadFactory
:ForkJoinWorkerThreadFactory 的默认实现类InnocuousForkJoinWorkerThreadFactory
:实现了 ForkJoinWorkerThreadFactory,无许可线程工厂,当系统变量中有系统安全管理相关属性时,默认使用这个工厂创建工作线程。EmptyTask
:内部占位类,用于替换队列中 join 的任务。ManagedBlocker
:为 ForkJoinPool 中的任务提供扩展管理并行数的接口,一般用在可能会阻塞的任务(如在 Phaser 中用于等待 phase 到下一个generation)。WorkQueue
:ForkJoinPool 的核心数据结构,本质上是work-stealing 模式的双端任务队列,内部存放 ForkJoinTask 对象任务,使用@Contented
注解修饰防止伪共享。- 工作线程在运行中产生新的任务(通常是因为调用了 fork())时,此时可以把 WorkQueue 的数据结构视为一个栈,新的任务会放入栈顶(top 位);工作线程在处理自己工作队列的任务时,按照 LIFO 的顺序。
- 工作线程在处理自己的工作队列同时,会尝试窃取一个任务(可能是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的队列任务),此时可以把 WorkQueue 的数据结构视为一个 FIFO 的队列,窃取的任务位于其他线程的工作队列的队首(base位)。
伪共享状态
:缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
2、ForkJoinTask继承关系
ForkJoinTask 实现了 Future 接口,说明它也是一个可取消的异步运算任务,实际上ForkJoinTask 是 Future 的轻量级实现,主要用在纯粹是计算的函数式任务或者操作完全独立的对象计算任务。fork 是主运行方法,用于异步执行;而 join 方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。 其内部类都比较简单,ExceptionNode 是用于存储任务执行期间的异常信息的单向链表;其余四个类是为 Runnable/Callable 任务提供的适配器类,用于把 Runnable/Callable 转化为 ForkJoinTask 类型的任务(因为 ForkJoinPool 只可以运行 ForkJoinTask 类型的任务)。
4、Fork/Join框架源码解析
分析思路:在对类层次结构有了解以后,我们先看下内部核心参数,然后分析上述流程图。会分4个部分:
- 首先介绍任务的提交流程 - 外部任务(external/submissions task)提交;
- 然后介绍任务的提交流程 - 子任务(Worker task)提交;
- 再分析任务的执行过程(ForkJoinWorkerThread.run()到ForkJoinTask.doExec()这一部分);
- 最后介绍任务的结果获取(ForkJoinTask.join()和ForkJoinTask.invoke())
1、ForkJoinPool
1、核心参数
在后面的源码解析中,我们会看到大量的位运算,这些位运算都是通过我们接下来介绍的一些常量参数来计算的。
例如,如果要更新活跃线程数,使用公式(UC_MASK & (c + AC_UNIT)) | (SP_MASK & c);c 代表当前 ctl,UC_MASK 和 SP_MASK 分别是高位和低位掩码,AC_UNIT 为活跃线程的增量数,使用(UC_MASK & (c + AC_UNIT))就可以计算出高32位,然后再加上低32位(SP_MASK & c),就拼接成了一个新的ctl。
这些运算的可读性很差,看起来有些复杂。在后面源码解析中有位运算的地方我都会加上注释,大家只需要了解它们的作用即可。
ForkJoinPool 与 内部类 WorkQueue 共享的一些常量:
1 | // Constants shared across ForkJoinPool and WorkQueue |
ForkJoinPool 中的相关常量和实例字段:
1 | // 低位和高位掩码 |
说明:ForkJoinPool 的内部状态都是通过一个64位的 long 型 变量ctl来存储,它由四个16位的子域组成:
AC
:正在运行工作线程数减去目标并行度,高16位TC
:总工作线程数减去目标并行度,中高16位SS
:栈顶等待线程的版本计数和状态,中低16位ID
:栈顶 WorkQueue 在池中的索引(poolIndex),低16位
在后面的源码解析中,某些地方也提取了ctl的低32位(sp=(int)ctl)来检查工作线程状态,例如,当sp不为0时说明当前还有空闲工作线程。
2、ForkJoinPool.WoekQueue 中的相关属性
1 | //初始队列容量,2的幂 |
2、ForkJoinTask
核心参数
1 | /** 任务运行状态 */ |
5、Fork/Join框架源码解析
1、构造函数
1 | public ForkJoinPool(int parallelism, |
说明:在 ForkJoinPool 中我们可以自定义四个参数:
parallelism
:并行度,默认为CPU数,最小为1factory
:工作线程工厂;handler
:处理工作线程运行任务时的异常情况类,默认为null;asyncMode
:是否为异步模式,默认为 false。如果为true,表示子任务的执行遵循 FIFO 顺序并且任务不能被合并(join),这种模式适用于工作线程只运行事件类型的异步任务。
在多数场景使用时,如果没有太强的业务需求,我们一般直接使用 ForkJoinPool 中的common池,在JDK1.8之后提供了ForkJoinPool.commonPool()方法可以直接使用common池,来看一下它的构造:
1 | private static ForkJoinPool makeCommonPool() { |
使用common pool的优点就是我们可以通过指定系统参数的方式定义“并行度、线程工厂和异常处理类”;并且它使用的是同步模式,也就是说可以支持任务合并(join)。
2、执行流程——外部任务(external/submissions task)提交
向 ForkJoinPool 提交任务有三种方式:
- invoke()会等待任务计算完毕并返回计算结果;
- execute()是直接向池提交一个任务来异步执行,无返回结果;
- submit()也是异步执行,但是会返回提交的任务,在适当的时候可通过task.get()获取执行结果。
这三种提交方式都都是调用externalPush()方法来完成,所以接下来我们将从externalPush()方法开始逐步分析外部任务的执行过程。
1、externalPush(ForkJoinTask<?> task)
1 | //添加给定任务到submission队列中 |
首先说明一下externalPush和externalSubmit两个方法的联系:它们的作用都是把任务放到队列中等待执行。不同的是,externalSubmit可以说是完整版的externalPush,在任务首次提交时,需要初始化workQueues及其他相关属性,这个初始化操作就是externalSubmit来完成的;而后再向池中提交的任务都是通过简化版的externalSubmit-externalPush来完成。
externalPush的执行流程很简单:
- 首先找到一个随机偶数槽位的 workQueue,
- 然后把任务放入这个 workQueue 的任务数组中,并更新top位。
- 如果队列的剩余任务数小于1,则尝试创建或激活一个工作线程来运行任务(防止在externalSubmit初始化时发生异常导致工作线程创建失败)。
2、externalSubmit(ForkJoinTask<?> task)
1 | //任务提交 |
说明:externalSubmit是externalPush的完整版本,主要用于第一次提交任务时初始化workQueues及相关属性,并且提交给定任务到队列中。具体执行步骤如下:
- 如果池为终止状态(runState<0),调用tryTerminate来终止线程池,并抛出任务拒绝异常;
- 如果尚未初始化,就为 FJP 执行初始化操作:初始化stealCounter、创建workerQueues,然后继续自旋;
- 初始化完成后,执行在externalPush中相同的操作:获取 workQueue,放入指定任务。任务提交成功后调用signalWork方法创建或激活线程;
- 如果在步骤3中获取到的 workQueue 为null,会在这一步中创建一个 workQueue,创建成功继续自旋执行第三步操作;
- 如果非上述情况,或者有线程争用资源导致获取锁失败,就重新获取线程探针值继续自旋。
3、signalWork(WorkQueue[] ws, WorkQueue q)
1 | final void signalWork(WorkQueue[] ws, WorkQueue q) { |
说明:新建或唤醒一个工作线程,在externalPush
、externalSubmit
、workQueue.push
、scan
中调用。如果还有空闲线程,则尝试唤醒索引到的 WorkQueue 的parker线程;如果工作线程过少((ctl & ADD_WORKER) != 0L),则调用tryAddWorker添加一个新的工作线程。
4、tryAddWorker(long c)
1 | private void tryAddWorker(long c) { |
说明:尝试添加一个新的工作线程,首先更新ctl中的工作线程数,然后调用createWorker()创建工作线程。
5、createWorker()
1 | private boolean createWorker() { |
说明:createWorker首先通过线程工厂创一个新的ForkJoinWorkerThread,然后启动这个工作线程(wt.start())。如果期间发生异常,调用deregisterWorker处理线程创建失败的逻辑(deregisterWorker在后面再详细说明)。
ForkJoinWorkerThread 的构造函数如下:
1 | protected ForkJoinWorkerThread(ForkJoinPool pool) { |
可以看到 ForkJoinWorkerThread 在构造时首先调用父类 Thread 的方法,然后为工作线程注册pool和workQueue,而workQueue的注册任务由ForkJoinPool.registerWorker来完成。
6、registerWorker()
1 | final WorkQueue registerWorker(ForkJoinWorkerThread wt) { |
说明:registerWorker是 ForkJoinWorkerThread 构造器的回调函数,用于创建和记录工作线程的 WorkQueue。比较简单,就不多赘述了。注意在此为工作线程创建的 WorkQueue 是放在奇数索引的(代码行: i = ((s << 1) | 1) & m;)
7、小结
OK,外部任务的提交流程就先讲到这里。在createWorker()中启动工作线程后(wt.start()),当为线程分配到CPU执行时间片之后会运行 ForkJoinWorkerThread 的run方法开启线程来执行任务。工作线程执行任务的流程我们在讲完内部任务提交之后会统一讲解。
3、执行流程:子任务(Worker task)提交
子任务的提交相对比较简单,由任务的fork()方法完成。通过上面的流程图可以看到任务被分割(fork)之后调用了ForkJoinPool.WorkQueue.push()方法直接把任务放到队列中等待被执行。
1、ForkJoinTask.fork()
1 | public final ForkJoinTask<V> fork() { |
说明:如果当前线程是 Worker 线程,说明当前任务是fork分割的子任务,通过ForkJoinPool.workQueue.push()方法直接把任务放到自己的等待队列中;否则调用ForkJoinPool.externalPush()提交到一个随机的等待队列中(外部任务)。
2、ForkJoiPool.WorkQueue.push()
1 | final void push(ForkJoinTask<?> task) { |
说明:首先把任务放入等待队列并更新top位;如果当前 WorkQueue 为新建的等待队列(top-base<=1),则调用signalWork方法为当前 WorkQueue 新建或唤醒一个工作线程;如果 WorkQueue 中的任务数组容量过小,则调用growArray()方法对其进行==两倍==扩容,growArray()方法源码如下:
1 | final ForkJoinTask<?>[] growArray() { |
3、小结
到此,两种任务的提交流程都已经解析完毕,下一节我们来一起看看任务提交之后是如何被运行的。
4、执行流程:任务执行
回到我们开始时的流程图,在ForkJoinPool .createWorker()方法中创建工作线程后,会启动工作线程,系统为工作线程分配到CPU执行时间片之后会执行 ForkJoinWorkerThread 的run()方法正式开始执行任务。
1、ForkJoinWorkerThread.run()
1 | public void run() { |
说明:方法很简单,在工作线程运行前后会调用自定义钩子函数(onStart
和onTermination
),任务的运行则是调用了ForkJoinPool.runWorker()。如果全部任务执行完毕或者期间遭遇异常,则通过ForkJoinPool.deregisterWorker关闭工作线程并处理异常信息(deregisterWorker方法我们后面会详细讲解)。
2、ForkJoinPool.runWorker(WorkerQueue w)
1 | final void runWorker(WorkQueue w) { |
说明:runWorker是 ForkJoinWorkerThread 的主运行方法,用来依次执行当前工作线程中的任务。
函数流程很简单:调用scan方法依次获取任务,然后调用WorkQueue .runTask运行任务;如果未扫描到任务,则调用awaitWork等待,直到工作线程/线程池终止或等待超时。
3、ForkJoinPool.scan(WorkQueue w, int r)
1 | private ForkJoinTask<?> scan(WorkQueue w, int r) { |
说明:扫描并尝试偷取一个任务。使用w.hint进行随机索引 WorkQueue,也就是说并不一定会执行当前 WorkQueue 中的任务,而是偷取别的Worker的任务来执行。
函数的大概执行流程如下:
- 取随机位置的一个 WorkQueue;
- 获取base位的 ForkJoinTask,成功取到后更新base位并返回任务;如果取到的 WorkQueue 中任务数大于1,则调用signalWork创建或唤醒其他工作线程;
- 如果当前工作线程处于不活跃状态(INACTIVE),则调用tryRelease尝试唤醒栈顶工作线程来执行。
tryRelease源码如下:
1 | private boolean tryRelease(long c, WorkQueue v, long inc) { |
- 如果base位任务为空或发生偏移,则对索引位进行随机移位,然后重新扫描;
- 如果扫描整个workQueues之后没有获取到任务,则设置当前工作线程为INACTIVE状态;然后重置checkSum,再次扫描一圈之后如果还没有任务则跳出循环返回null。
4、ForkJoinPool.awaitWork(WorkQueue w, int r)
1 | private boolean awaitWork(WorkQueue w, int r) { |
说明:回到runWorker方法,如果scan方法未扫描到任务,会调用awaitWork等待获取任务。函数的具体执行流程大家看源码,这里简单说一下:
- 在等待获取任务期间,如果工作线程或线程池已经终止则直接返回false。
- 如果当前无 active 线程,尝试终止线程池并返回false,如果终止失败并且当前是最后一个等待的 Worker,就阻塞指定的时间(IDLE_TIMEOUT);
- 等到届期或被唤醒后如果发现自己是scanning(scanState >= 0)状态,说明已经等到任务,跳出等待返回true继续 scan,否则的更新ctl并返回false。
5、WorkQueue.runTask()
1 | final void runTask(ForkJoinTask<?> task) { |
说明:在scan方法扫描到任务之后,调用WorkQueue.runTask()来执行获取到的任务,大概流程如下:
- 标记scanState为正在执行状态;
- 更新currentSteal为当前获取到的任务并执行它,任务的执行调用了ForkJoinTask.doExec()方法,
ForkJoinTask.doExec()方法的源码如下:
1 | //ForkJoinTask.doExec() |
调用execLocalTasks依次执行当前WorkerQueue中的任务,源码如下:
1 | //执行并移除所有本地任务 |
- 更新偷取任务数;
- 还原scanState并执行钩子函数。
6、ForkJoinPool.deregisterWorker(ForkJoinWorkerThread wt, Throwable ex)
1 | final void deregisterWorker(ForkJoinWorkerThread wt, Throwable ex) { |
说明:deregisterWorker方法用于工作线程运行完毕之后终止线程或处理工作线程异常,主要就是清除已关闭的工作线程或回滚创建线程之前的操作,并把传入的异常抛给 ForkJoinTask 来处理。
7、小结
以上我们对任务的执行流程进行了说明,后面我们将继续介绍任务的结果获取(join/invoke)。
5、获取任务结果——ForkJoinTask.join()/ForkJoinTask.invoke()
join()
//合并任务结果 public final V join() { int s; if ((s = doJoin() & DONE_MASK) != NORMAL) reportException(s); return getRawResult(); } //join, get, quietlyJoin的主实现方法 private int doJoin() { int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w; return (s = status) < 0 ? s : ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ? (w = (wt = (ForkJoinWorkerThread)t).workQueue). tryUnpush(this) && (s = doExec()) < 0 ? s : wt.pool.awaitJoin(w, this, 0L) : externalAwaitDone(); } final int doExec() { int s; boolean completed; if ((s = status) >= 0) { try { completed = exec(); } catch (Throwable rex) { return setExceptionalCompletion(rex); } if (completed) s = setCompletion(NORMAL); } return s; }
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
- 它首先调用 doJoin 方法,通过 doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有 4 种: ==已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)==
- 如果任务状态是已完成,则直接返回任务结果。
- 如果任务状态是被取消,则直接抛出 CancellationException
- 如果任务状态是抛出异常,则直接抛出对应的异常
- 在 doJoin()方法流程如下:
1. 首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;
2. 如果没有执行完,则从任务数组里取出任务并执行。
3. 如果任务顺利执行完成,则设置任务状态为 NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL。
- invoke()
- ```java
//执行任务,并等待任务完成并返回结果
public final V invoke() {
int s;
if ((s = doInvoke() & DONE_MASK) != NORMAL)
reportException(s);
return getRawResult();
}
//invoke, quietlyInvoke的主实现方法
private int doInvoke() {
int s; Thread t; ForkJoinWorkerThread wt;
return (s = doExec()) < 0 ? s :
((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
(wt = (ForkJoinWorkerThread)t).pool.
awaitJoin(wt.workQueue, this, 0L) :
externalAwaitDone();
}
说明:join()方法一把是在任务fork()之后调用,用来获取(或者叫“合并”)任务的执行结果。
ForkJoinTask的join()和invoke()方法都可以用来获取任务的执行结果(另外还有get方法也是调用了doJoin来获取任务结果,但是会响应运行时异常),它们对外部提交任务的执行方式一致,都是通过externalAwaitDone方法等待执行结果。
不同的是invoke()方法会直接执行当前任务;而join()方法则是在当前任务在队列 top 位时(通过tryUnpush方法判断)才能执行,如果当前任务不在 top 位或者任务执行失败调用ForkJoinPool.awaitJoin方法帮助执行或阻塞当前 join 任务。(所以在官方文档中建议了我们对ForkJoinTask任务的调用顺序,一对 fork-join操作一般按照如下顺序调用:a.fork(); b.fork(); b.join(); a.join();
。因为任务 b 是后面进入队列,也就是说它是在栈顶的(top 位),在它fork()之后直接调用join()就可以直接执行而不会调用ForkJoinPool.awaitJoin方法去等待。)
在这些方法中,join()相对比较全面,所以之后的讲解我们将从join()开始逐步向下分析,首先看一下join()的执行流程:
后面的源码分析中,我们首先讲解比较简单的外部 join 任务(externalAwaitDone),然后再讲解内部 join 任务(从ForkJoinPool.awaitJoin()开始)。
1、ForkJoinTask.externalAwaitDone()
1 | private int externalAwaitDone() { |
说明:如果当前join为外部调用,则调用此方法执行任务,如果任务执行失败就进入等待。方法本身是很简单的,需要注意的是对不同的任务类型分两种情况:
- 如果我们的任务为 CountedCompleter 类型的任务,则调用externalHelpComplete方法来执行任务。
- 其他类型的 ForkJoinTask 任务调用tryExternalUnpush来执行
tryExternalUnpush的源码如下:
1 | //为外部提交者提供 tryUnpush 功能(给定任务在top位时弹出任务) |
tryExternalUnpush的作用就是判断当前任务是否在top位,如果是则弹出任务,然后在externalAwaitDone中调用doExec()执行任务。
2、ForkJoinPool.awaitJoin()
1 | final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) { |
说明:如果当前 join 任务不在Worker等待队列的top位,或者任务执行失败,调用此方法来帮助执行或阻塞当前 join 的任务。
函数执行流程如下:
- 由于每次调用awaitJoin都会优先执行当前join的任务,所以首先会更新currentJoin为当前join任务;
- 进入自旋:
- 首先检查任务是否已经完成(通过task.status < 0判断),如果给定任务执行完毕|取消|异常,则跳出循环返回执行状态s;
- 如果是 CountedCompleter 任务类型,调用helpComplete方法来完成join操作(后面笔者会开新篇来专门讲解CountedCompleter,本篇暂时不做详细解析);
- 非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务;
- 如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务);
- 再次判断任务是否执行完毕(task.status < 0),如果任务执行失败,计算一个等待时间准备进行补偿操作;
- 调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。在执行补偿期间,如果发现资源争用|池处于unstable状态|当前Worker已终止,则调用ForkJoinTask.internalWait()方法等待指定的时间,任务唤醒之后继续自旋。
ForkJoinTask.internalWait()源码如下:
1 | final void internalWait(long timeout) { |
在awaitJoin中,我们总共调用了三个比较复杂的方法:tryRemoveAndExec
、helpStealer
和tryCompensate
,下面我们依次讲解。
3、WorkQueue.tryRemoveAndExec(ForkJoinTask<?> task)
非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务:
1 | final boolean tryRemoveAndExec(ForkJoinTask<?> task) { |
说明:从top位开始自旋向下找到给定任务,如果找到把它从当前 Worker 的任务队列中移除并执行它。
注意返回的参数:如果任务队列为空或者任务未执行完毕返回true;任务执行完毕返回false。
4、ForkJoinPool.helpStealer(WorkQueue w, ForkJoinTask<?> task)
如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务):
1 | private void helpStealer(WorkQueue w, ForkJoinTask<?> task) { |
说明:如果队列为空或任务执行失败,说明任务可能被偷,调用此方法来帮助偷取者执行任务。
基本思想是:偷取者帮助我执行任务,我去帮助偷取者执行它的任务。 函数执行流程如下:
- 循环定位偷取者,由于Worker是在奇数索引位,所以每次会跳两个索引位。
- 定位到偷取者之后,更新调用者 WorkQueue 的hint为偷取者的索引,方便下次定位;
- 定位到偷取者后,开始帮助偷取者执行任务。从偷取者的base索引开始,每次偷取一个任务执行。
- 在帮助偷取者执行任务后,如果调用者发现本身已经有任务(w.top != top),则依次弹出自己的任务(LIFO顺序)并执行(也就是说自己偷自己的任务执行)。
5、ForkJoinPool.tryCompensate(WorkQueue w)
调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。
1 | //执行补偿操作: 尝试缩减活动线程量,可能释放或创建一个补偿线程来准备阻塞 |
说明:具体的执行看源码及注释,这里我们简单总结一下需要和不需要补偿的几种情况:
- 需要补偿 :
- 调用者队列不为空,并且有空闲工作线程,这种情况会唤醒空闲线程(调用tryRelease方法)
- 池尚未停止,活跃线程数不足,这时会新建一个工作线程(调用createWorker方法)
- 不需要补偿 :
- 调用者已终止或池处于不稳定状态
- 总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空
6、Fork/Join的陷阱与注意事项
使用Fork/Join框架时,需要注意一些陷阱, 在下面 斐波那契数列
例子中你将看到示例。
1、避免不必要的fork()
划分成两个子任务后,不要同时调用两个子任务的fork()方法。
表面上看上去两个子任务都fork(),然后join()两次似乎更自然。但事实证明,直接调用compute()效率更高。因为直接调用子任务的compute()方法实际上就是在当前的工作线程进行了计算(线程重用),这比“将子任务提交到工作队列,线程又从工作队列中拿任务”快得多。
当一个大任务被划分成两个以上的子任务时,尽可能使用前面说到的三个衍生的
invokeAll
方法,因为使用它们能避免不必要的fork()。
2、注意fork()、compute()、join()的顺序
为了两个任务并行,三个方法的调用顺序需要万分注意。
1 | right.fork(); // 计算右边的任务 |
如果我们写成:
1 | left.fork(); // 计算完左边的任务 |
或者:
1 | long rightAns = right.compute(); // 计算完右边的任务 |
3、选择合适的子任务粒度
选择划分子任务的粒度(顺序执行的阈值)很重要,因为使用Fork/Join框架并不一定比顺序执行任务的效率高:如果任务太大,则无法提高并行的吞吐量;如果任务太小,子任务的调度开销可能会大于并行计算的性能提升,我们还要考虑创建子任务、fork()子任务、线程调度以及合并子任务处理结果的耗时以及相应的内存消耗。
官方文档给出的粗略经验是:任务应该执行100~10000
个基本的计算步骤。决定子任务的粒度的最好办法是实践,通过实际测试结果来确定这个阈值才是“上上策”。
和其他Java代码一样,Fork/Join框架测试时需要“预热”或者说执行几遍才会被JIT(Just-in-time)编译器优化,所以测试性能之前跑几遍程序很重要。
4、避免重量级任务划分与结果合并
Fork/Join的很多使用场景都用到数组或者List等数据结构,子任务在某个分区中运行,最典型的例子如并行排序和并行查找。拆分子任务以及合并处理结果的时候,应该尽量避免System.arraycopy
这样耗时耗空间的操作,从而最小化任务的处理开销。
7、再深入理解
1、有哪些JDK源码中使用了Fork/Join思想
我们常用的数组工具类 Arrays 在JDK 8之后新增的==并行排序方法(parallelSort)==就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的==函数式方法(如forEach等)==也有运用。
2、使用Executors工具类创建ForkJoinPool
Java8在Executors工具类中新增了两个工厂方法:
1 | // parallelism定义并行级别 |
3、关于Fork/Join异常处理
Java的受检异常机制一直饱受诟病,所以在ForkJoinTask的invoke()、join()方法及其衍生方法中都没有像get()方法那样抛出个ExecutionException的受检异常。
所以你可以在ForkJoinTask中看到内部把受检异常转换成了运行时异常。
1 | static void rethrow(Throwable ex) { |
==关于Java你不知道的10件事==中已经指出,JVM实际并不关心这个异常是受检异常还是运行时异常,受检异常这东西完全是给Java编译器用的:用于警告程序员这里有个异常没有处理。
但不可否认的是invoke、join()仍可能会抛出运行时异常,所以ForkJoinTask还提供了两个不提取结果和异常的方法quietlyInvoke()、quietlyJoin(),这两个方法允许你在所有任务完成后对结果和异常进行处理。
使用quitelyInvoke()
和quietlyJoin()
时可以配合isCompletedAbnormally()
和isCompletedNormally()
方法使用。
ForkJoinTask 在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以 ForkJoinTask 提供了 isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过 ForkJoinTask 的getException 方法获取异常。
getException 方法返回 Throwable 对象,如果任务被取消了则返回 CancellationException。如果任务没有完成或者没有抛出异常则返回 null。
8、一些Fork/Join例子
1、采用Fork/Join来异步计算1+2+3+……+10000的结果
1 | public class Test { |
执行结果:
1 | ForkJoinPool-1-worker-1 开始执行: 1-625 |
2、实现斐波那契数列
斐波那契数列: 1、1、2、3、5、8、13、21、34、…… 公式 : F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)
1 | public static void main(String[] args) { |
当然你也可以两个任务都fork,要注意的是两个任务都fork的情况,必须按照f1.fork(),f2.fork(), f2.join(),f1.join()
这样的顺序,不然有性能问题,详见上面注意事项中的说明。
官方API文档是这样写到的,所以平日用invokeAll就好了。invokeAll会把传入的任务的第一个交给当前线程来执行,其他的任务都fork加入工作队列,这样等于利用当前线程也执行任务了。
1 | { |
21、CompletableFuture异步回调
1、CompletableFuture 简介
CompletableFuture 在 Java 里面被用于异步编程,异步通常意味着非阻塞, 可以使得我们的任务单独运行在与主线程分离的其他线程中,并且通过回调可以在主线程中得到异步任务的执行状态,是否完成,和是否异常等信息。
CompletableFuture 实现了 Future, CompletionStage 接口:
- 实现了 Future 接口就可以兼容现在有线程池框架
- 而 CompletionStage 接口才是异步编程的接口抽象,里面定义多种异步方法
通过这两者集合,从而打造出了强大的CompletableFuture 类。
2、Future 与 CompletableFuture
Futrue 在 Java 里面,通常用来表示一个异步任务的引用,比如我们将任务提交到线程池里面,然后我们会得到一个 Futrue,在 Future 里面有 isDone 方法来 判断任务是否处理结束,还有 get 方法可以一直阻塞直到任务结束然后获取结果,但整体来说这种方式,还是同步的,因为需要客户端不断阻塞等待或者不断轮询才能知道任务是否完成。
3、Future 的主要缺点
- 不支持手动完成
- 我提交了一个任务,但是执行太慢了,我通过其他路径已经获取到了任务结果, 现在没法把这个任务结果通知到正在执行的线程,所以必须主动取消或者一直等待它执行完成
- 不支持进一步的非阻塞调用
- 通过 Future 的 get 方法会一直阻塞到任务完成,但是想在获取任务之后执行额外的任务,因为 Future 不支持回调函数,所以无法实现这个功能
- 不支持链式调用
- 对于 Future 的执行结果,我们想继续传到下一个 Future 处理使用,从而形成一个链式的 pipline 调用,这在 Future 中是没法实现的。
- 不支持多个 Future 合并
- 比如我们有 10 个 Future 并行执行,我们想在所有的 Future 运行完毕之后, 执行某些函数,是没法通过 Future 实现的。
- 不支持异常处理
- Future 的 API 没有任何的异常处理的 api,所以在异步运行时,如果出了问题是不好定位的。
4、CompletableFuture的使用
1、CompletableFuture 入门
场景:主线程里面创建一个 CompletableFuture,然后主线程调用 get 方法会阻塞,最后我们在一个子线程中使其终止。
1 | public class CompletableFutureTest { |
结果:
1 | A子线程开始干活 |
2、没有返回值的同步任务
1 | //没有返回值的同步任务 |
同步任务调用runAsync()方法,使用get()方法获取
3、有返回值的异步任务
1 | //异步调用和同步调用 |
异步调用使用supplyAsync()方法,可以通过whenComplete()获取。
其中supplyAsync()方法的参数是一个Supplier类,而Supplier类是一个函数式接口,可以使用lambda表达式
1 | public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) { |
其中whenComplete()的两个参数:
- t:用来接收异步调用中正常返回的结果,此时的u返回null
- u:用来接收异步调用过程中出现的异常,此时的t返回null
关于一些函数式接口的接口类:
supplier
:提供者,特点:无中生有 :() -> 结果function
:函数,特点:一个参数一个结果 :(参数) -> 结果BiFunction
:两个参数一个结果 :(参数1,参数2) -> 结果
consumer
:消费者,特点:一个参数没结果:(参数) -> voidBiConsumer
:两个参数,没有结果:(参数1,参数2) -> void
4、线程依赖
当一个线程依赖另一个线程时,可以使用 thenApply 方法来把这两个线程串行化。
1 | public class CompletableFutureTest { |
1 | 主线程开始 |
5、消费处理结果
thenAccept 消费处理结果,接收任务的处理结果,并消费处理,无返回结果。
1 | public class CompletableFutureTest { |
1 | 主线程开始 |
6、异常处理
exceptionally 异常处理,出现异常时触发
1 | public class CompletableFutureTest { |
结果:
1 | 主线程开始 |
handle 类似于 thenAccept/thenRun 方法,是最后一步的处理调用,但是同时可以处理异常
1 | public class CompletableFutureTest { |
结果:
1 | 主线程开始 |
7、结果合并
thenCompose 合并两个有依赖关系的 CompletableFutures 的执行结果
1 | public class CompletableFutureTest { |
结果:
1 | 主线程开始 |
thenCombine 合并两个没有依赖关系的 CompletableFutures 任务
1 | public class CompletableFutureTest { |
结果:
1 | 主线程开始 |
8、合并多个任务的结果allOf 与anyOf
allOf:一系列独立的future 任务,等其所有的任务执行完后做一些事情
1 | public class CompletableFutureTest { |
1 | 主线程开始 |
anyOf:只要在多个future 里面有一个返回,整个任务就可以结束,而不需要等到每一个 future 结束
1 | public class CompletableFutureTest { |
结果:
1 | 主线程开始 |
22、Java 并发 - ThreadLocal详解
ThreadLocal是通过线程隔离的方式防止任务在共享资源上产生冲突,线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程都创建不同的存储。
1、BAT大厂的面试问题
- 什么是ThreadLocal?用来解决什么问题的?
- 说说你对ThreadLocal的理解
- ThreadLocal是如何实现线程隔离的?
- 为什么ThreadLocal会造成内存泄露?如何解决?
- 还有哪些使用ThreadLocal的应用场景?
2、ThreadLocal简介
我们在==Java 并发 - 并发理论基础==总结过线程安全(是指广义上的共享资源访问安全性,因为线程隔离是通过副本保证本线程访问资源安全性,它不保证线程之间还存在共享关系的狭义上的安全性)的解决思路:
- 互斥同步:
synchronized
和ReentrantLock
- 非阻塞同步:
CAS
,AtomicXXXX
- 无同步方案:
栈封闭
,本地存储(Thread Local)
,可重入代码
这个章节将详细的讲讲 本地存储(Thread Local)。官网的解释是这样的:
his class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID) 该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。
总结而言:ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类;当使用ThreadLocal来维护变量时,ThreadLocal会为每个线程创建单独的变量副本,避免因多线程操作共享变量而导致的数据不一致的情况。
3、ThreadLocal理解
提到ThreadLocal被提到应用最多的是session管理和数据库链接管理,这里以数据访问为例来理解ThreadLocal:
如下数据库管理类在单线程使用是没有任何问题的:
1 | class ConnectionManager { |
很显然,在多线程中使用会存在线程安全问题:
- 第一,这里面的2个方法都没有进行同步,很可能在openConnection方法中会多次创建connect;
- 第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用closeConnection关闭链接。
为了解决上述线程安全的问题,第一考虑:互斥同步
你可能会说,将这段代码的两个方法进行同步处理,并且在调用connect的地方需要进行同步处理,比如用Synchronized
或者ReentrantLock互斥锁
。
这里再抛出一个问题:这地方到底需不需要将connect变量进行共享?
事实上,是不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。即改后的代码可以这样:
1 | class ConnectionManager { |
这样处理确实也没有任何问题,由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题。
但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。由于在方法中需要频繁地开启和关闭数据库连接,这样不仅严重影响程序执行效率,还可能导致服务器压力巨大。
这时候ThreadLocal登场了
那么这种情况下使用ThreadLocal是再适合不过的了,因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。下面就是网上出现最多的例子:
1 | import java.sql.Connection; |
再注意下ThreadLocal的修饰符
ThreaLocal的JDK文档中说明:ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread。如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。
但是要注意,虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大。
4、ThreadLocal原理
1、如何实现线程隔离
主要是用到了Thread对象中的一个ThreadLocalMap
类型的变量threadLocals
,负责存储当前线程的关于Connection的对象,dbConnectionLocal(以上述例子中为例) 这个变量为Key,以新建的Connection对象为Value;这样的话,线程第一次读取的时候如果不存在就会调用ThreadLocal的initialValue方法创建一个Connection对象并且返回。
具体关于为线程分配变量副本的代码如下:
1 | public T get() { |
- 首先获取当前线程对象t,然后从线程t中获取到ThreadLocalMap的成员属性threadLocals
- 如果当前线程的threadLocals已经初始化(即不为null) 并且存在以当前ThreadLocal对象为Key的值,则直接返回当前线程要获取的对象(本例中为Connection);
- 如果当前线程的threadLocals已经初始化(即不为null)但是不存在以当前ThreadLocal对象为Key的的对象,那么重新创建一个Connection对象,并且添加到当前线程的threadLocals Map中,并返回;
- 如果当前线程的threadLocals属性还没有被初始化,则重新创建一个ThreadLocalMap对象,并且创建一个Connection对象并添加到ThreadLocalMap对象中并返回。
如果存在则直接返回很好理解,那么对于如何初始化的代码又是怎样的呢?
1 | private T setInitialValue() { |
- 首先调用我们上面写的重载过后的initialValue方法,产生一个Connection对象
- 继续查看当前线程的threadLocals是不是空的,如果ThreadLocalMap已被初始化,那么直接将产生的对象添加到ThreadLocalMap中,如果没有初始化,则创建并添加对象到其中;
同时,ThreadLocal还提供了直接操作Thread对象中的threadLocals的方法:
1 | public void set(T value) { |
这样我们也可以不实现initialValue,将初始化工作放到DBConnectionFactory的getConnection方法中:
1 | public Connection getConnection() { |
那么我们看过代码之后就很清晰的知道了为什么ThreadLocal能够实现变量的多线程隔离了;其实就是用了Map的数据结构给当前线程缓存了,要使用的时候就从本线程的threadLocals对象中获取就可以了,key就是当前线程;
当然了在当前线程下获取当前线程里面的Map里面的对象并操作肯定没有线程并发问题了,当然能做到变量的线程间隔离了;
现在我们知道了ThreadLocal到底是什么了,又知道了如何使用ThreadLocal以及其基本实现原理了,是不是就可以结束了呢?其实还有一个问题就是ThreadLocalMap是个什么对象,为什么要用这个对象呢?
2、ThreadLocalMap对象是什么
本质上来讲,它就是一个Map,但是这个ThreadLocalMap与我们平时见到的Map有点不一样:
- 它没有实现Map接口;
- 它没有public的方法,最多有一个default的构造方法,因为这个ThreadLocalMap的方法仅仅在ThreadLocal类中调用,属于静态内部类;
- ThreadLocalMap的Entry实现继承了WeakReference<ThreadLocal<?>>
- 该方法仅仅用了一个Entry数组来存储Key、Value;Entry并不是链表形式,而是每个bucket里面仅仅放一个Entry;
要了解ThreadLocalMap的实现,我们先从入口开始,就是往该Map中添加一个值:
1 | private void set(ThreadLocal<?> key, Object value) { |
先进行简单的分析,对该代码表层意思进行解读:
- 看下当前threadLocal的在数组中的索引位置 比如:
i = 2
,看i = 2
位置上面的元素(Entry)的Key
是否等于threadLocal 这个 Key,如果等于就很好说了,直接将该位置上面的Entry的Value替换成最新的就可以了; - 如果当前位置上面的 Entry 的 Key为空,说明ThreadLocal对象已经被回收了,那么就调用
replaceStaleEntry
- 如果清理完无用条目(ThreadLocal被回收的条目)、并且数组中的数据大小 > 阈值的时候对当前的Table进行重新哈希,所以,该HashMap是处理冲突检测的机制是向后移位,清除过期条目 最终找到合适的位置;
了解完Set方法,后面就是Get方法了:
1 | private Entry getEntry(ThreadLocal<?> key) { |
先找到ThreadLocal的索引位置,如果索引位置处的entry不为空并且键与threadLocal是同一个对象,则直接返回;否则去后面的索引位置继续查找。
5、ThreadLocal造成内存泄漏的问题
网上有这样一个例子:
1 | import java.util.concurrent.LinkedBlockingQueue; |
如果用线程池来操作ThreadLocal 对象确实会造成内存泄露,因为对于线程池里面不会销毁的线程,里面总会存在着<ThreadLocal, LocalVariable>
的强引用,因为final static 修饰的 ThreadLocal 并不会释放,而ThreadLocalMap 对于 Key 虽然是弱引用,但是强引用不会释放,弱引用当然也会一直有值,同时创建的LocalVariable对象也不会释放,就造成了内存泄露;
如果LocalVariable对象不是一个大对象的话,其实泄露的并不严重,泄露的内存 = 核心线程数 * LocalVariable
对象的大小;
所以,为了避免出现内存泄露的情况,ThreadLocal提供了一个清除线程中对象的方法,即 remove
,其实内部实现就是调用 ThreadLocalMap 的remove方法:
1 | private void remove(ThreadLocal<?> key) { |
找到Key对应的Entry,并且清除Entry的Key(ThreadLocal)置空,随后清除过期的Entry即可避免内存泄露。
6、再看ThreadLocal应用场景
1、每个线程维护了一个“序列号”
再回想上文说的,如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。
每个线程维护了一个“序列号”:
1 | public class SerialNum { |
2、Session的管理
1 | private static final ThreadLocal threadSession = new ThreadLocal(); |
3、在线程内部创建ThreadLocal
还有一种用法是在线程类内部创建ThreadLocal,基本步骤如下:
- 在多线程的类(如ThreadDemo类)中,创建一个ThreadLocal对象threadXxx,用来保存线程间需要隔离处理的对象xxx。
- 在ThreadDemo类中,创建一个获取要隔离访问的数据的方法getXxx(),在方法中判断,若ThreadLocal对象为null时候,应该new()一个隔离访问类型的对象,并强制转换为要应用的类型。
- 在ThreadDemo类的run()方法中,通过调用getXxx()方法获取要操作的数据,这样可以保证每个线程对应一个数据对象,在任何时刻都操作的是这个对象。
1 | public class ThreadLocalTest implements Runnable{ |
4、java开发手册中推荐的ThreadLocal
看看阿里巴巴 java 开发手册中推荐的 ThreadLocal 的用法:
1 | import java.text.DateFormat; |
然后我们再要用到 DateFormat 对象的地方,这样调用:
1 | DateUtils.df.get().format(new Date()); |
23、补充:阿姆达尔定律
阿姆达尔定律可以用来计算处理器平行运算之后效率提升的能力。阿姆达尔定律因Gene Amdal 在1967年提出这个定律而得名。绝大多数使用并行或并发系统的开发者有一种并发或并行可能会带来提速的感觉,甚至不知道阿姆达尔定律。不管怎样,了解阿姆达尔定律还是有用的。
我会首先以算术的方式介绍阿姆达尔定律定律,然后再用图表演示一下。
1、阿姆达尔定律定义
一个程序(或者一个算法)可以按照是否可以被并行化分为下面两个部分:
- 可以被并行化的部分
- 不可以被并行化的部分
假设一个程序处理磁盘上的文件。这个程序的一小部分用来扫描路径和在内存中创建文件目录。做完这些后,每个文件交个一个单独的线程去处理。扫描路径和创建文件目录的部分不可以被并行化,不过处理文件的过程可以。
程序串行(非并行)执行的总时间我们记为T。时间T包括不可以被并行和可以被并行部分的时间。不可以被并行的部分我们记为B。那么可以被并行的部分就是T-B。下面的列表总结了这些定义:
- T = 串行执行的总时间
- B = 不可以并行的总时间
- T-B = 并行部分的总时间
从上面可以得出:T = B + (T – B)
首先,这个看起来可能有一点奇怪,程序的可并行部分在上面这个公式中并没有自己的标识。然而,由于这个公式中可并行可以用总时间T 和 B(不可并行部分)表示出来,这个公式实际上已经从概念上得到了简化,也即是指以这种方式减少了变量的个数。
T-B 是可并行化的部分,以并行的方式执行可以提高程序的运行速度。可以提速多少取决于有多少线程或者多少个CPU来执行。线程或者CPU的个数我们记为N。可并行化部分被执行的最快时间可以通过下面的公式计算出来:(T – B ) / N
或者通过这种方式 (1 / N) * (T – B)
。维基中使用的是第二种方式。
根据阿姆达尔定律,当一个程序的可并行部分使用N个线程或CPU执行时,执行的总时间为:T(N) = B + ( T – B ) / N
T(N)指的是在并行因子为N时的总执行时间。因此,T(1)就执行在并行因子为1时程序的总执行时间。使用T(1)代替T,阿姆达尔定律定律看起来像这样:T(N) = B + (T(1) – B) / N
表达的意思都是是一样的。
2、一个计算例子
为了更好的理解阿姆达尔定律,让我们来看一个计算的例子。执行一个程序的总时间设为1,程序的不可并行化占40%,按总时间1计算,就是0.4,可并行部分就是1 – 0.4 = 0.6。
在并行因子为2的情况下,程序的执行时间将会是:
1 | T(2) = 0.4 + ( 1 - 0.4 ) / 2 |
在并行因子为5的情况下,程序的执行时间将会是:
1 | T(5) = 0.4 + ( 1 - 0.4 ) / 5 |
3、阿姆达尔定律图示
为了更好地理解阿姆达尔定律,我会尝试演示这个定律是如何诞生的。
首先,一个程序可以被分割为两部分,一部分为不可并行部分B,一部分为可并行部分1 – B。如下图:
在顶部被带有分割线的那条直线代表总时间 T(1)。
下面你可以看到在并行因子为2的情况下的执行时间:
并行因子为3的情况:
4、优化算法
从阿姆达尔定律可以看出,程序的可并行化部分可以通过使用更多的硬件(更多的线程或CPU)运行更快。对于不可并行化的部分,只能通过优化代码来达到提速的目的。因此,你可以通过优化不可并行化部分来提高你的程序的运行速度和并行能力。你可以对不可并行化在算法上做一点改动,如果有可能,你也可以把一些移到可并行化放的部分。
优化串行分量
如果你优化一个程序的串行化部分,你也可以使用阿姆达尔定律来计算程序优化后的执行时间。如果不可并行部分通过一个因子O来优化,那么阿姆达尔定律看起来就像这样:
1 | T(O, N) = B / O + (1 - B / O) / N |
记住,现在程序的不可并行化部分占了B / O
的时间,所以,可并行化部分就占了1 - B / O
的时间。
如果B为0.1,O为2,N为5,计算看起来就像这样:
1 | T(2,5) = 0.4 / 2 + (1 - 0.4 / 2) / 5 |
5、运行时间 vs. 加速
到目前为止,我们只用阿姆达尔定律计算了一个程序或算法在优化后或者并行化后的执行时间。我们也可以使用阿姆达尔定律计算加速比(speedup),也就是经过优化后或者串行化后的程序或算法比原来快了多少。
如果旧版本的程序或算法的执行时间为T,那么增速比就是:
1 | Speedup = T / T(O , N); |
为了计算执行时间,我们常常把T设为1,加速比为原来时间的一个分数。公式大致像下面这样:
1 | Speedup = 1 / T(O,N) |
如果我们使用阿姆达尔定律来代替T(O,N),我们可以得到下面的公式:
1 | Speedup = 1 / ( B / O + (1 - B / O) / N) |
如果B = 0.4, O = 2, N = 5, 计算变成下面这样:
1 | Speedup = 1 / ( 0.4 / 2 + (1 - 0.4 / 2) / 5) |
上面的计算结果可以看出,如果你通过一个因子2来优化不可并行化部分,一个因子5来并行化可并行化部分,这个程序或算法的最新优化版本最多可以比原来的版本快2.77777倍。
6、测量,不要仅是计算
虽然阿姆达尔定律允许你并行化一个算法的理论加速比,但是不要过度依赖这样的计算。在实际场景中,当你优化或并行化一个算法时,可以有很多的因子可以被考虑进来。
内存的速度,CPU缓存,磁盘,网卡等可能都是一个限制因子。如果一个算法的最新版本是并行化的,但是导致了大量的CPU缓存浪费,你可能不会再使用x N个CPU来获得x N的期望加速。如果你的内存总线(memory bus),磁盘,网卡或者网络连接都处于高负载状态,也是一样的情况。
我们的建议是,使用阿姆达尔定律定律来指导我们优化程序,而不是用来测量优化带来的实际加速比。记住,有时候一个高度串行化的算法胜过一个并行化的算法,因为串行化版本不需要进行协调管理(上下文切换),而且一个单个的CPU在底层硬件工作(CPU管道、CPU缓存等)上的一致性可能更好。
8、并发的相关多线程设计模式
1、两阶段终止(Two Phase Termination)
在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。
1、错误思路
- 使用线程对象的 stop() 方法停止线程
- stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁,可能会造成死锁问题
- 使用 System.exit(int) 方法停止线程
- 目的仅是停止一个线程,但这种做法会让整个程序都停止
2、正常做法——两阶段终止模式(interrupt实现)
1、实现流程图
2、实现方法
interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行
- 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
- 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
3、代码
1 | public class TestTPT{ |
执行结果:
1 | 11:49:42.915 c.TwoPhaseTermination [监控线程] - 执行监控记录 |
两个细节:
- 线程被打断时分为两种情况
- 情况1:线程在sleep时被打断,此时线程会抛出InterruptedException: sleep interrupted异常进入catch模块,不会清除打断标记,也就是说isInterrupted()返回false,所以需要在catch模块当中重置打断标记
- 情况2:线程在正常执行被打断,此时线程的打断标记不会被清除,即isInterrupted()返回true,在下一次的判断中进入if块执行break;语句退出死循环
- 线程使用的是isInterrupted()用来判断打断标记是否为true,即有没有被打断过。其实还有一个方法可以用来判断有没有被打断过,那就是interrupted()
- isInterrupted():判断当前线程是否被打断,不会清除==打断标记==
- interrupted():判断当前线程是否被打断,是一个静态方法,会清除==打断标记==
3、正常做法——两阶段终止模式(volatile实现)
1 | public class TestTPT{ |
执行结果:
1 | 17:08:21.970 c.TwoPhaseTermination [监控线程] - 执行监控记录 |
2、犹豫模式(Balking)——同步模式
1、定义
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回
2、实现
以上面两阶段终止模式的例子,当调用了多次tpt.start;就会创建多个监控线程,其实这是错误的,监控线程只需要一个就够了,在第二次创建监控线程的时候应该直接返回。
1 | import lombok.extern.slf4j.Slf4j; |
3、犹豫Balking模式还经常用来实现线程安全的单例
1 | public final class Singleton { |
对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。
3、保护性暂停(Guarded Suspension)——同步模式
1、定义
保护性暂停(Guarded Suspension)用在一个线程等待另一个线程的执行结果
要点:
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
- JDK 中,join 的实现、Future 的实现,采用的就是此模式
- 因为要等待另一方的结果,因此归类到同步模式
2、实现
1 | class GuardedObject { |
3、应用
一个线程等待另一个线程的执行结果
1 | public static void main(String[] args) { |
1 | public class Downloader { |
结果:
1 | 14:42:07.731 c.Test20 [t1] - 等待结果 |
4、带超时版 GuardedObjec
如果要控制超时时间呢?
1 | // 增加超时效果 |
使用:
1 | public static void main(String[] args) { |
如果线程t2睡眠1s,那么get没有超时,可以获得Object对象:
1 | 15:51:04.932 c.Test20 [Thread-1] - begin |
如果线程t2睡眠3s,那么get超时,不能获得Object对象:
1 | 15:52:07.993 c.Test20 [t2] - begin |
测试虚假唤醒问题:把t2线程complete传入null(线程t2睡眠1s):(如果代码wait传入的是timeout而不是waitTime,这里的等待时间为3s(虚假唤醒1s,加设置的2s = 总共3s),而不是设置的2s)
1 | 15:52:11.975 c.Test20 [t2] - begin |
5、扩展:多任务版 GuardedObject
代码:
图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员
如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。
注意:这里的结果等待者和结果产生者是一一对应的,所以采用的是保护性暂停模式,如果不是一一对应的话,使用的是生产者消费者模式。
1 | import cn.itcast.n2.util.Sleeper; |
6、使用保护性暂停的好处
- 如果使用的是一个线程(A)使用join来等待另外一个线程(B)的结果的话,如果线程B给线程A结果,但是线程A还不能接收,线程B就不能往下运行,必须等待线程A接收结果之后才能往下运行。
- 如果使用的是保护性暂停模式的话,线程B在结束下载以后还能往下运行代码,没必要等待线程A接收结果
- 因为join是线程结束才返回,但是阻塞的线程只需要那个response有值,凭什么要去等另一个线程全部执行完
- 使用join的话,两线程交互的结果只能设置成全局的,而使用保护性暂停模式,可以把等待的结果设置成局部的(如示例当中的list)
4、生产者消费者模式(Producer Consumer)——异步模式
1、定义
要点:
- 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
- 消费队列可以用来平衡生产和消费的线程资源
- 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
- 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
- JDK 中各种阻塞队列,采用的就是这种模式
2、实现
1 | // 消息类 不设置set方法,加上整个类被final修饰,保证没有其他方法去修改Message里面的值 |
3、应用
1 | public static void main(String[] args) { |
结果:
1 | 11:52:21.949 c.MessageQueue [生产者2] - 已生产消息Message{id=2, value=值2} |
5、顺序控制(Sequence Control)——同步模式
1、固定运行顺序
比如,必须先 2 后 1 打印
1、wait notify 版
代码:
1 | // 用来同步的对象 |
结果:
1 | 15:55:40.793 c.Test25[t2] - 2 |
实际上使用ReentrantLock的await与signal方法与上面类似,这里不在展示。
2、Park Unpark 版
可以看到,实现上很麻烦:
- 首先,需要保证先 wait 再 notify,否则 wait 线程永远得不到唤醒。因此使用了『运行标记』来判断该不该 wait
- 第二,如果有些干扰线程错误地 notify 了 wait 线程,条件不满足时还要重新等待,使用了 while 循环来解决此问题(虚假唤醒问题)
- 最后,唤醒对象上的 wait 线程需要使用 notifyAll,因为『同步对象』上的等待线程可能不止一个
可以使用 LockSupport 类的 park 和 unpark 来简化上面的题目:代码:
1 | import lombok.extern.slf4j.Slf4j; |
结果:
1 | 16:02:56.652 c.Test26[t2] - 2 |
park 和 unpark 方法比较灵活,他俩谁先调用,谁后调用无所谓。并且是以线程为单位进行『暂停』和『恢复』,不需要『同步对象』和『运行标记』
2、交替输出
线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现(与线程间定制化通信有区别)
1、wait notify 版
需要借助等待标记来知道下一个唤醒的线程
1 | import lombok.extern.slf4j.Slf4j; |
2、Lock 条件变量版
Lock就不需要借助等待标记,但是需要主线程来启动
1 | import sun.rmi.runtime.Log; |
注意:该实现没有考虑 a,b,c 线程都就绪再开始
3、Park Unpark 版
依旧需要主线程来启动
1 | import lombok.extern.slf4j.Slf4j; |
6、享元模式(Flyweight pattern)
1、简介
定义 英文名称:Flyweight pattern
. 当需要重用数量有限的同一类对象时
比如说String,为了保证String不可变性,String在进行操作的时候经常使用的方法是:保护性拷贝。这种方式有个缺点:当拷贝的内容相当大的时候,这个时候对系统的性能以及内存的状态是非常不利的,这个时候就需要使用享元模式了
wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects
2、体现
1、包装类
在JDK中 Boolean
,Byte
,Short
,Integer
,Long
,Character
等包装类提供了 valueOf
方法,例如 Long 的valueOf **==会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象==**,大于这个范围,才会新建 Long 对象:
1 | public static Long valueOf(long l) { |
LongCache的初始化:
1 | private static class LongCache { |
注意:
- Byte, Short, Long 缓存的范围都是 -128~127
- Character 缓存的范围是 0~127
- Integer的默认范围是 -128~127
- 最小值不能变
- 但最大值可以通过调整虚拟机参数
-Djava.lang.Integer.IntegerCache.high
来改变
- Boolean 缓存了 TRUE 和 FALSE
2、String 串池
在JVM的StringTable具体说明
3、BigDecimal BigInteger
注意:BigDecimal BigInteger的单个方法是线程安全的,但是方法之间组合组合不一定是线程安全的(有时候使用AutomicIntrger等等原子类来保证它们在组合下的线程安全)
3、DIY
例如:一个线上商城应用,QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。 这时预先创建好一批连接,放入连接池。一次请求到达后,从连接池获取连接,使用完毕后再还回连接池,这样既节约了连接的创建和关闭时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。
1 | import lombok.extern.slf4j.Slf4j; |
测试结果:
1 | 15:26:48.211 c.Pool [THread-3] - wait... |
以上实现没有考虑:
- 连接的动态增长与收缩
- 连接保活(可用性检测)
- 等待超时处理
- 分布式 hash
对于关系型数据库,有比较成熟的连接池实现,例如c3p0, druid等 对于更通用的对象池,可以考虑使用apache commons pool(redis使用),例如redis连接池可以参考jedis中关于连接池的实现。
7、工作线程模式(Worker Thread)——异步模式
1、定义
让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现就是线程池,也体现了经典设计模式中的享元模式。
例如,海底捞的服务员(线程),轮流处理每位客人的点餐(任务),如果为每位客人都配一名专属的服务员,那么成本就太高了(对比另一种多线程设计模式:Thread-Per-Message
)
注意:不同任务类型应该使用不同的线程池,这样能够避免饥饿,并能提升效率
例如,如果一个餐馆的工人既要招呼客人(任务类型A),又要到后厨做菜(任务类型B)显然效率不咋地,分成服务员(线程池A)与厨师(线程池B)更为合理,当然你能想到更细致的分工
2、饥饿
固定大小线程池会有饥饿现象:
- 两个工人是同一个线程池中的两个线程
- 他们要做的事情是:为客人点餐和到后厨做菜,这是两个阶段的工作
- 客人点餐:必须先点完餐,等菜做好,上菜,在此期间处理点餐的工人必须等待
- 后厨做菜:没啥说的,做就是了
- 比如工人A 处理了点餐任务,接下来它要等着 工人B 把菜做好,然后上菜,他俩也配合的蛮好
- 但现在同时来了两个客人,这个时候工人A 和工人B 都去处理点餐了,这时没人做饭了,饥饿
1 | import lombok.extern.slf4j.Slf4j; |
输出:
1 | 15:28:41.386 c.TestDeadLock [pool-1-thread-1] - 处理点餐... |
解决方法:可以增加线程池的大小,不过不是根本解决方案,还是前面提到的,不同的任务类型,采用不同的线程池,例如:
1 | import lombok.extern.slf4j.Slf4j; |
输出:
1 | 15:33:14.925 c.TestDeadLock [pool-1-thread-1] - 处理点餐... |
3、创建多少线程池合适
- 过小会导致程序不能充分地利用系统资源、容易导致饥饿
- 过大会导致更多的线程上下文切换,占用更多内存
1、CPU 密集型运算
通常采用 cpu 核数 + 1
能够实现最优的 CPU 利用率,**+1 是保证当线程由于页缺失故障(操作系统)或其它原因导致暂停时,额外的这个线程就能顶上去,保证 CPU 时钟周期不被浪费**
2、I/O 密集型运算
CPU 不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用 CPU 资源,但当你执行 I/O 操作时、远程RPC 调用时,包括进行数据库操作时,这时候 CPU 就闲下来了,你可以利用多线程提高它的利用率。
经验公式:线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间
例如 4 核 CPU 计算时间是 50% ,其它等待时间是 50%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 50% = 8
例如 4 核 CPU 计算时间是 10% ,其它等待时间是 90%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 10% = 40
4、自定义线程池
具体参考7、JUC当中的15、ThreadPool线程池的9、自定义线程池
8、不可变(Immutability)模式
如果对象一旦被创建,状态就不会再发生任何变化,并且只允许存在只读方法,这个对象就是不可变对象。利用不可变对象解决并发问题的模式,就是不可变模式。快速实现具备不可变性的类时,将类设置成final,类内的所有属性设置成final,只暴露只读方法即可。
经常用到的String对象和各种基础类型的包装类,比如,Long,Integer都具备不可变性。更进一步,基本数据类型的包装类都用到了享元模式(Flyweight Pattern),即在JVM启动时,创建一个对象池,当创建包装类型的对象时,首先查找对象池是否存在,如果不存在,才会创建新对象,并将其放入对象池中。比如,Long对象就默认缓存了[-128,127]之间的对象。几乎所有用到了享元模式的对象,比如,包装类对象,都不适合做锁,因为看上去是私有的这些对象,其实是共用的,会导致并发问题。
但是在使用不可变模式时,一定要搞清楚特定不可变对象的边界在哪里。比如,一个final类C的final成员变量a,当a的内部存在非final的其他对象时,并且C中存在着get_a的public接口,那么C就不是线程安全的。
9、Copy-on-Write模式
Copy-on-Write模式适用于对数据的实时性不敏感,读多写少且对读性能要求极为苛刻的小数据场景。
具体的实现也很简单,当数据需要修改时,先复制一份出来,在复制的数据上进行修改,并发读还是在旧的数据上,当数据修改完成后,再将老数据替换为修改后的新数据即可。但需要注意的是,当发生并发写时,可以使用CAS的策略来完成。
10、线程本地存储
Java语言提供ThreadLocal实现避免共享,即每个线程拥有自己的一份数据,线程之间没有竞争关系。
它的具体实现原理有点反直觉,因为ThreadLocal本质上仅仅是一个代理工具类,真正的数据存储在Thread类中。即,当ThreadLocal.get()获取线程本地数据时,通过Thread.currentThread().threadLocals来获取线程内真正的本地对象进行操作。
这种设计方式,从业务上看,线程的本地数据存在线程内部显然更合理,更重要的是,这样做不容易产生内存泄漏,因为线程本地对象和线程同生命周期,当线程被gc时,其数据也同样可以被gc掉。
但需要注意的是,在线程池的场景中,因为线程池中的线程通常与进程是同生共死的,即使线程本地变量的生命周期已经结束了,但因为该线程池尚未被释放,数据也是无法被回收的。因此,在这种场景下,ThreadLocal方案要小心使用。
11、Thread-Per-Message
现实世界中,很多事情需要委托他人办理,同样的场景,在并发编程领域,就是Thread-Per_message模式,简而言之,就是由一个线程接收任务,并发的为每一个收到的任务分配一个独立线程,这是最简单的分工方法,实现起来也非常简单。
线程在Java中是成本非常高的对象,本质上并不适合高并发场景。但是,换个角度思考,语言,工具和框架本身应该是帮助我们更敏捷的实现稳定可靠的方案,Thread-Per-Message是一种最简单的分工方法,Java语言支持不了,显然是Java语言本身的问题。
在Go语言中,存在一种轻量级线程,即协程的方案。在协程的架构下,Thread-Per-Message模式就完全没有问题了。
参考文档
ReentrantLock中的公平锁可能并不是真正意义上的公平
本文主要参考自泰迪的bagwell的https://www.jianshu.com/p/32a15ef2f1bf和https://www.jianshu.com/p/6a14d0b54b8d,在此基础上参考了如下文章
推荐阅读ForkJoinPool的作者Doug Lea的一篇文章《A Java Fork/Join Framework》英文原文地址