深入理解JMM以及并发三大特性
并发和并行
并发和并行的目标都是最大化利用CPU
并行
多核处理器
同一时刻有多条指令在多核处理器下同时执行
无论从微观还是宏观,二者都是一起执行的
并发
多线程同步(线程之间存在的依赖关系)、互斥(共享资源只能某个线程独享)、分工(把大任务拆分成小任务,如ForkJoin)
单核处理器
同一时刻只有一条指令执行
,但是多个进程指令被快速的轮换执行
,使得宏观上具有多个进程同时执行的效果,但是微观上并不是同时执行的,只是把时间段分成若干段,使多个进程快速交替的执行
问题
: 并发解决什么问题
注意
: 并行在多核处理器系统中存在,而并发在单核和多核处理器中都存在
并发的三大特性
并发的三大特性
可见性
: 当一个线程去修改某个共享变量的时候,其他线程能够立刻看到修改后的值
有序性
: 程序执行的顺序按照代码的先后顺序执行
原子性
: 一个或多个操作,要么全部执行成功,要么全部执行失败
出现线程安全问题的原因
volatile禁止指令重排序
内存屏障
synchronized关键字
Juc下的Lock
happens-before规则
synchronized关键字
volatile关键字
Juc下的Lock
final关键字
内存屏障
JDK的Atomic原子类
synchronized关键字
Juc下的Lock
通过CAS
线程切换带来的原子性问题,如何保证原子性
缓存导致的可见性问题,如何保证可见性
编译优化带来的有序性问题,如何保证有序性
问题
: Thread.yield()能保证可见性吗
能,会释放时间片,上下文切换
注意
: while(true)优先级比较高
,即便使用Thread.yield()释放CPU时间片,也会认为while优先级最高,导致while的效果看起来一直占用CPU时间片,这样有种饥饿状态
,如果核数有限,优先级比较低的线程抢占不到时间片,就可能会因为饥饿状态导致死锁。因为优先级比较低的线程有可能加了lock
锁
as-if-serial和happens-before规则
as-if-serial: 不管怎么重排序,单线程的执行结果不能被改变,编译器和处理器不会对存在数据依赖关系的操作进行重排序
happens-before: 在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作必须存在happens-before规则,这个规则是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性
JMM内存模型
计算机计算过程
详细链接: https://www.processon.com/view/link/61eff59063768967ff07037c
我们的程序代码在磁盘里,当运行时会加载到内存中去,然后通过总线加载到高速缓存,当我们想要计算某个值时,会通过运算逻辑单元进行计算,而需要进行计算的值会从寄存器中拿,如果寄存器中找不到,会去高速缓存找,如果高速缓存中没有就通过总线去内存中找,找到了再给两个缓存进行赋值,对于计算后的值会放入寄存器等待使用.
JMM定义
什么是JMM
JMM用于屏蔽各种硬件和操作系统内存之间访问差异
,来实现让java程序在各种平台下都能打成一致的并发效果
JMM是一种共享内存模型,告诉我们Java线程之间如何进行通信的
JMM就是围绕并发三大特性来展开
的
内存交互操作
详细链接: https://www.processon.com/view/link/628b81cb1efad466f34a73c2
我们共享变量是放在主存中的,然后每个线程都会先从主存中read
出来,然后再把这个共享变量load
到自己的本地内存的变量副本中,然后线程会use
这个本地内存中的变量副本,对于某个线程去修改共享变量的时候,会先把新值assign
给本地内存的变量副本,然后再将变量副本store
到主存中,最后再把它write
给主存中的共享变量
问题
: 什么时候会刷新主存
不会立马刷新缓存,而是在某个时间点,线程不用这个值,回收之前肯定会刷新主存
问题
: 对于内存交互的过程中出现缓存不一致的问题
在多核处理器的情况下,每个处理器都会有对应的本地内存变量副本,也就是说共享数据可能有多个副本
,而每个线程对自己的缓存副本的修改并不能保证其他线程可见
缓存一致性协议(MESI)
详细链接: https://www.processon.com/view/link/61f0ff4c1e085338f8fd94e9
对于解决多线程之间缓存不一致的问题
,通过MESI可以有效解决
MESI协议是一个基于写失效的缓存一致性协议
,支持回写
M(已修改)
E(独占)
S(共享)
I(无效)
第一个线程从主存中加载共享数据到自己的本地内存中此时处于独占状态
,当第二个线程加载同一个共享数据到自己的本地内存的时候,会将两边的状态改为共享状态
,当第一个线程想去修改共享数据的时候,此时会处于修改状态
,并且立即刷回主存
,第二个线程感知到修改,就会把自己本地内存中的缓存副本也就是处于了无效状态
,这样就会重新从主存中读取最新的值
MESI
问题
: 解决一致性的办法
缓存行锁定
: 线程修改自己本地内存的变量副本的时候,会先缓存行锁定再将新值刷回主存,如果遇到缓存行锁定失效的时候会使用总线锁定串行执行
总线锁定
: 会用于缓存一致性失效的场景,比如跨缓存行以及以前的处理器没有实现缓存一致性协议,会通过直接锁总线串行执行,用lock前缀指令配合lock#信号量实现
volatile关键字
volatile的特性
对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性(禁止指令重排序
)
底层实现
JVM源码层面通过C++关键字volatile
的特性禁止指令重排序保障了有序性
对任意单个volatile变量的读写具有原子性,但是对于复合操作比如++、创建对象等不具备原子性
在32位机器下,long和double类型变量由于寄存器一次只能放32位
,需要分高低位进行操作
,所以要保证原子性,需要加volatile
在64位机器下,long和dounle类型能一次性放入64位,所以不加volatile也能保证原子性
对一个volatile变量的读,总能看到任意线程对这个volatile变量最后的写入
底层实现
X86环境下,JVM层面通过lock前缀指令让缓存行锁定,将新值刷回主存,然后这个指令还会触发MESI协议,其中就有让缓存副本失效的功能,这样线程就会重新从主存中读取新值到缓存副本中
可见性
原子性
有序性
volatile伪共享问题
填充缓存行
使用@Contended注解,并且开启JVM对应参数
对于伪共享问题,就是当多个变量被volatile修饰了
,由于它们在同一个缓存行,缓存行的大小是64字节
,如果其中一个volatile变量触发了缓存失效,可能导致整个缓存行失效,这样就出现了伪共享问题
为了避免伪共享,我们可以采取填充缓存行
,让每个volatile修饰的变量分别存放在不同的缓存行,
这样就保证某个volatile变量导致的缓存行失效不会影响其他的缓存行
如果多核的线程操作同一个缓存行的不同变量数据,那么就会出现频繁的缓存行失效的问题
,即使在代码层面看这两个线程操作的数据之间完全没有关系,这种不合理的资源竞争情况就是伪共享
问题
: 如何解决伪共享问题
问题
: 避免伪共享解决方案
lock前缀指令的作用
某个线程持有缓存行锁的时候,会将主存刷回主存,此时其他线程会嗅探到了改变
,会先让自己的本地内存中的变量副本失效,然后重新从主存中读取新值到本地内存中使用
保证可见性
结合缓存行锁定以及触发MESI协议保证了线程之间的可见性
当线程持有缓存行锁,会将新值刷回主存
比如在单核处理器下,cmpxchgl可以保证原子性
,但是多核处理器下保证不了所以CAS底层在多核处理器下会对cmpxchgl加lock前缀保证原子性
确保后续指令的原子性
缓存行锁定
触发MESI协议
原文:https://juejin.cn/post/7100955856073654303