java多线程概念
并行与并发
- 并行
并行是指多个任务同时在跑,是真正地同时运行。 - 并发
并发通常是指多个任务交替使用CPU,同一时刻还是只有一个任务在跑。
总结:
套用知乎上一个形象比喻如下:
- 你吃饭吃到一半,电话来了,你一直吃完了才能去接电话,说明你既不支持并发也不支持并行。
- 你吃饭吃到一半,电话来了,你接完后继续吃饭,说明你支持并发。
- 你吃饭吃到一半,电话来了,你一边接电话一边吃饭,说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定同时。并行的关键是你有同时处理多个任务的能力,并行与并发之间的关键点就是是否能【同时】。
进程和线程
- 进程
进程是程序资源的基本单位 - 线程
线程是CPU执行的基本单位
线程状态
线程运行状态CPU缓存
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度之间的矛盾。因为CPU的运算速度要比内存的读写速度快得多。
一次主存的访问,可能需要几十个到几百个时钟周期。
一次一级缓存的访问,可能只需要几个时钟周期。
一次二级缓存的访问,可能需要几十个时钟周期。
针对速度上的差异,CPU可能需要花费很长时间去等待数据到来或者把数据写入内存。基于此,现代CPU多数读取数据都不会直接访问内存,而是从缓存中去读取,CPU缓存是位于CPU与内存之间临时存储器,它的容量较小但读写速度却比内存快得多,CPU优先从缓存中去读取,读取不到再到内存中读取。缓存同样有优先级,优先从一级缓存中读取,再到二级缓存中读取,再到三级缓存中。一级缓存、二级缓存、三级缓存它们的读写速度依次递减,价格也依次递减,因此存储容量依次递增。注意缓存中存放的只是内存中的一小部分数据,这部分数据是短时间内CPU即将访问的。
按照读写速度以及与CPU紧密结合程度,CPU缓存可分为以下三种
-
一级缓存 简称L1 Cache,紧靠CPU内核,是与CPU联系最为紧密高速缓存
-
二级缓存 简称L2 Cache
-
三级缓存 简称L3 Cache
image.png
当系统运行时,CPU执行的流程简单地概括为以下几个步骤:
-
加载程序以及数据到内存中
-
加载程序指令以及数据到CPU缓存中
-
CPU执行指令将结果写到高速缓存中
-
将高速缓存中数据写到内存中
CPU -> CPU缓存 ->内存读取数据之间的关系如下图所示
CPU高速缓存图
可以想象如下场景:
-
核0读取一个字节到缓存中,那么它相邻字节必然也会被读入核0缓存中
-
核3也读取同样的字节到自己所在缓存中,此时核0与核3缓存中有相同字节的数据
-
核0修改了那个字节的数据,然后写回到自己的缓存中,但并没有写回内存中
-
核3此时去访问该字节的数据,由于核0并未将该字节的数据写回到内存中,故此时将导致核0与核3数据的不同步
为了解决上述场景的问题,就有了如下缓存一致性协议:
缓存一致性协议
每个CPU都有一级缓存,但是我们却没有办法保证每个CPU一级缓存的数据都是一样的。所以,同一个应用程序,CPU进行切换的时候,切换前与切换后的数据可能会不一样。那么怎么保证CPU缓存数据是一致,就是CPU缓存一致性问题。
总线锁
一种处理一致性问题的办法是使用总线锁(Bus Locking)。当CPU对其缓存的数据进行操作时,往总线中发送一个Lock信号,这个时候所有CPU收到这个信号之后,就不操作自己缓存中对应的数据了。当操作结束,释放锁之后,所有的CPU就会去内存中获取数据。但是用总线锁的方式,会导致CPU性能下降。因此出现了如下维护缓存一致性的方式,MESI。
MESI
MESI是保持一致性协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种方式。
- M:Modify(修改缓存)当前CPU缓存已经被修改,即与内存中数据不一致了。
- E:Exclusive(独占缓存)当前CPU缓存数据与内存中的一致,且其它CPU没有可用的缓存数据。
- S:Share(共享缓存)和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一地址的共享缓存段。
- I:Invalid(无效缓存)说明CPU中的缓存已经不能再使用了。
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的 读写操作,而且也监听(snoop)其它 Cache 的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状 态 CPU 只能从主存中读取数据。
CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状 态的写,需要将其他 CPU 中缓存行置为无效才可写 使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概 可以抽象成下面这样的结构。从而达到缓存一致性效果。
缓存一致性抽象图
为了避免阻塞带来的资源浪费。在 cpu 中引入 了 Store Bufferes(存储缓存) 和 Invalidate Queue(无效队列)。
CPU0 写入共享数据时,直接把数据写入到 store bufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。
当收到其他所有 CPU 发送了 invalidate ACK消息时,再将 store bufferes 中的数据数据存储至 cache 中。最后再从本地Cache同步到主内存。
缓存锁解决总线锁的逻辑
但是 cpu 中引入 Store Bufferes 优化存在两个问题:
- 第⑥、⑦步骤中,由于Invalidate消息进入队列后就给CPU-0返回了响应,不能保证第⑦步骤一定完成。
- 引入了 Store Bufferes 后,处理器会先尝试从 Store Bufferes 中读取值,如果 Store Bufferes 中有数据,则直接从Store Bufferes 中读取,否则就再从本地Cache中读取,从Store Bufferes读取数据存在脏读。
CPU 层面的内存屏障
- 内存屏障就是将 Store Bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性
Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。 - Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。
JMM
并发编程导致可见性问题的根本原因是缓存及重排序。 而JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM模型图
JMM 抽象模型分为主内存、工作内存(本地内存);
- 主内存:是所有线程 共享的,一般是实例对象、静态字段、数组对象等存储在 堆内存中的变量。
- 工作内存(本地内存):是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。
JMM 是如何解决可见性有序性问题的?
JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final;
JMM 如何解决顺序一致性问题?
-
重排序问题
从源代码到最终执行的指令,可能会经过三种重排序:
三种重排序
编译器重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
JMM 层面的内存屏障
1、为什么会有内存屏障?
- CPU的高速缓存会缓存主存中的数据,缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
- 用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障。
2、内存屏障是什么?
硬件层的内存屏障分为两种:Load Barrier (读屏障) 和 Store Barrier(写屏障)及 Full Barrier(全屏障) 是读屏障和写屏障的合集。
- 内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 写屏障:强制把写缓冲区/高速缓存中的脏数据等写回主内存,读屏障:将缓冲区/高速缓存中相应的数据失效。
3、java内存屏障?
java的内存屏障通常所谓的四种即LoadLoad(LL),StoreStore(SS),LoadStore(LS),StoreLoad(SL)实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
- LoadLoad(LL)屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore(SS)屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore(LS)屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad(SL)屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
4、volatile语义中的内存屏障?
volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
- 在每个volatile写操作前插入StoreStore(SS)屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作前插入LoadLoad(LL)屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了轻量锁的特性。
5、final语义中的内存屏障?
对于final域,编译器和CPU会遵循两个排序规则:
1、新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
2、初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)
总之上面规则的意思可以这样理解:必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
1、写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
2、读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
3、X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。
HappenBefore原则
HappenBefore解决的是可见性问题
定义:前一个操作的结果对于后续操作是可见的。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在 happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
JMM 中有哪些方法建立 happen-before 规则:
- 1、as-if-serial 规则(程序顺序执行):单个线程中的代码顺序不管怎么重排序,对于结果来说是不变的。
- 2、volatile 变量规则,对于 volatile 修饰的变量的写的操作, 一定 happen-before 后续对于 volatile 变量的读操作;
- 3、监视器锁规则(monitor lock rule):对一个监视器的解锁,happens-before于随后对这个监视器的加锁。
- 4、传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C。
- 5、start 规则:如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作。
- 6、join 规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。