线程的安全性问题
大家都知道,线程会存在安全性问题,那接下来我们从原理层面去了解线程为什么会存在安全性问题,并且我们应该怎么去解决这类的问题。
其实线程安全问题可以总结为: 原子性
、可见性
、有序性
这几个问题,我们搞懂了这几个问题并且知道怎么解决,那么多线程安全性问题也就不是问题了。
常见的解决安全性问题方案:
原子性: Synchronized、AtomicXXX、Lock (CAS)
可见性: Synchroinzed、volatile
有序性: Synchroinzed、volatile => happens-before模型
1. CPU 高速缓存
线程是 CPU 调度的最小单元,线程涉及的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中
。
高速缓存从下到上越接近 CPU 速度越快,同时容量也越小。现在大部分的处理 器都有二级或者三级缓存,从下到上依次为 L3 cache
, L2 cache
, L1 cache
缓
存又可以分为指令缓存和数据缓存,指令缓存用来缓存程序的代码,数据缓存
用来缓存程序的数据
L1 Cache
: 一级缓存,本地 core 的缓存,分成 32K 的数据缓存 L1d 和 32k 指 令缓存 L1i,访问 L1 需要 3cycles,耗时大约 1ns;
L2 Cache
:二级缓存,本地 core 的缓存,被设计为 L1 缓存与共享的 L3 缓存 之间的缓冲,大小为 256K,访问 L2 需要 12cycles,耗时大约 3ns;
L3 Cache
:三级缓存,在同插槽的所有 core 共享 L3 缓存,分为多个 2M 的 段,访问 L3 需要 38cycles,耗时大约 12ns;
2. 缓存一致性问题
CPU-0
读取主存的数据,缓存到 CPU-0 的高速缓存中,CPU-1
也做了同样的事 情,而 CPU-1 把 count 的值修改成了 2,并且同步到 CPU-1 的高速缓存,但
是这个修改以后的值并没有写入到主存中,CPU-0 访问该字节,由于缓存没有 更新,所以仍然是之前的值,就会导致数据不一致的问题
引发这个问题的原因是因为`多核心 CPU 情况下存在指令并行执行,而各个 CPU 核心之间的数据不共享从而导致缓存一致性问题`,为了解决这个问题, CPU 生产厂商提供了相应的解决方案
-
总线锁 当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信 号。其他处理器的请求将会被阻塞,那么该处理器可以
独占共享内存
。总线锁 相当于把 CPU 和内存之间的通信锁住了,所以这种方式会导致 CPU 的性能下降,所以P6 系列以后的处理器,出现了另外一种方式,就是缓存锁
。 -
缓存锁 如果缓存在处理器缓存行中的内存区域在 LOCK 操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明 LOCK 信号,而是修改内部的缓存地址,然后通过
缓存一致性机制
来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效
。 所以如果声明了 CPU 的锁机制,会生成一个 LOCK 指令,会产生两个作用
- Lock 前缀指令会引起引起处理器缓存回写到内存,在 P6 以后的处理器中, LOCK 信号一般不锁总线,而是锁缓存
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
- 缓存一致性协议
处理器上有一套完整的协议,来保证 Cache 的一致性,比较经典的应该就是
MESI 协议
了,它的方法是在 CPU 缓存中保存一个标记位,这个标记为有四种状态M(Modified)
修改缓存,当前 CPU 缓存已经被修改,表示已经和内存中的 数据不一致了I(Invalid)
失效缓存,说明 CPU 的缓存已经不能使用了-
E(Exclusive)
独占缓存,当前cpu的缓存和内存中数据保持一直,而且其他处理器没有缓存该数据总线锁
S(Shared)
共享缓存,数据和内存中数据一致,并且该数据存在多个 cpu 缓存中每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读 写操作,嗅探(snooping)协议
- CPU 的读取会遵循几个原则
- 如果缓存的状态是 I,那么就从内存中读取,否则直接从缓存读取
- 如果缓存处于 M 或者 E 的 CPU 嗅探到其他 CPU 有读的操作,就把自己的缓 存写入到内存,并把自己的状态设置为 S
- 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓 存状态变为 MC
-
CPU 的优化执行 除了增加高速缓存以为,为了更充分利用处理器内内部的运算单元,处理器可能会对输入的代码进行
乱序执行优化
,处理器会在计算之后将乱序执行的结果充足,保证该结果与顺序执行的结果一致,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,这个是处理器的优化执行
;还有一个就是编程语言的编译器
也会有类似的优化,比如做指令重排
来提升性能。 - 并发编程的问题
前面说的和硬件有关的概念你可能听得有点蒙,还不知道他到底和软件有啥关系,其实原子性、可见性、有序性问题,是我们抽象出来的概念,他们的核心本质就是刚刚提到的`缓存一致性问题、处理器优化问题导致的指令重排序问题`。比如缓存一致性就导致可见性问题、处理器的乱序执行会导致原子性问题、指令重排会导致有序性问题。为了解决这些问题,所以在 JVM 中引入了JMM 的 概念
- 内存模型
JMM
内存模型定义了共享内存系统中多线程程序读写操作行为的规范,来屏蔽各种 硬件和操作系统的内存访问差异,来实现 Java 程序在各个平台下都能达到一致 的内存访问效果。
Java 内存模型的主要目标是定义程序中各个变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量(这里的变量,指的是共享变量,也就是实例对象、静态字段、数组对象等存储在堆内存中的变量。而对于局部变量这类的,属于线程私有,不会被共享)这类的底层细节
。
通过这些规则来规范对内存的读写操作
,从而保证指令执行的正确性。 它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下 的可见性、原子性和有序性,。
内存模型解决并发问题主要采用两种方式:`限制处理器优化`和`使用内存屏障`
Java 内存模型定义了线程和内存的交互方式,在 JMM 抽象模型中,分为主内存
、工作内存
。主内存是所有线程共享的,工作内存是每个线程独有的。线程对变量的所有操作(读取、赋值)都必须在工作内存
中进行,不能直接读写主内存
中的变量。并且不同的线程之间无法访问对方工作内存中的变量,线程间的变量值的传递都需要通过主内存来完成,他们三者的交互关系如下: 8个操作
:Lock、Read、Load、Use、Assign、Store、Write、Unlock
Use <--- Load <--- Read Lock
Java线程 --> 高速缓存(变量副本) --> (Save Load) --> 主内存变量
Java线程 --> 高速缓存(变量副本) --> (Save Load) --> 主内存变量
Java线程 --> 高速缓存(变量副本) --> (Save Load) --> 主内存变量
Java线程 --> 高速缓存(变量副本) --> (Save Load) --> 主内存变量
Assign ---> Store ---> Write Unlock
所以,总的来说,JMM 是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。
参考: