一.要解决的问题
1.可见性(多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。)
多线程中若一个资源被多个线程同时访问和修改,在不进行处理的情况下,可见性就会被破坏
二.可见性问题简述
1.缓存一致性问题
操作系统中为了解决cpu、内存、IO设备的处理速度不匹配问题,CPU层面添加了高速缓存。但因为高速缓存的存在,在多CPU 种,每个线程可能会运行在不同的 CPU 内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题
2.总线锁和缓存一致性协议
为了解决缓存不一致的问题,在 CPU 层面做了很多事情, 主要提供了两种解决办法
总线锁: 简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的
缓存锁:我们只需保证对于被多个 CPU 缓存的同一份数据是一致的就行。所以引入了缓存锁来控制锁的保护粒度,它核心机制是基于缓存一致性协议来实现的)
缓存一致性协议:
状态 | 描述 | 监听任务和处理 |
---|---|---|
M 修改 (Modified) | 该缓存行数据被修改了,只缓存在当前CPU缓存中(缓存的数据和主内存中的数据不一致) | 时刻监听所有试图读该缓存行相对应主存的操作,读数据的操作必须在写操作(缓存将该缓存行写回主存并将状态变成S(共享)状态)之前被延迟执行。 |
E 独享、互斥 (Exclusive) | 数据只缓存在当前CPU缓存中(其他缓存没有,数据和内存中的数据一致)并且没有被修改。 | 缓存行也必须监听其它缓存读该缓存行相对应主存的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S 共享 (Shared) | 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I 无效 (Invalid) | 缓存已经失效 | 无 |
例 初试状态 :x=2 只存在内存中
1)CPU0读取x, 在CPU0缓存行中,此时状态记为(0E)
2)CPU1,请求读x, x=2 存于CPU1中,状态为(0S 1S)
3)CPU0要修改x=3, 先令CPU1 缓存无效 状态为(0M 1I)
4)将x=3 写回内存中 (0E)
3.Store Bufferes
Store Bufferes是一个写的缓冲,对于上述描述的步骤三,CPU0可以先把写入的操作先存储到Store Bufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起CPU1缓存行的失效。而同步来说CPU0可以不用阻塞等CPU1的Acknowledgement,继续往下执行其他指令,当收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。
4.指令重排序
描述: Store Bufferes 解决了CPU0阻塞等待的问题,提高了性能。但也带来了另一个问题,指令重排序。
场景:初始状态:cpu0 a=2(s) cpu1 a=2 (s)
此时cpu0 修改 a=3 应该不会阻塞等待,a=3被放入Store Bufferes中,等待接收CPU1 失效后的Ack确认
然后CPU0 添加 b=1, 指令被放入Store Bufferes中, 由于状态 b (E),不需要其他缓存ACK, b=1可能会在 a=2之前被执行。指令就被重排序了
解决: 内存屏障(memory barrier):将store bufferes 中的指令写入到内存中,从而使得其他访问同一共享内存的线程的可见性。 不同的操作系统内存屏障的指令不一样。以X86的指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)
(1)Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
(2)Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
(3)Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
三. JMM
1.定义
Java内存模型(Java Memory Model)主要是为了规定了线程和内存之间的一些关系。定义在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节,通过这些规则来规范对内存的读写操作从而保证指令的正确性,解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有变量都储存在主存中,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量,对于所有线程都是共享的。
每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
2.JMM是如何解决可见性和有序性问题的
简单来说,JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。比如 volatile、synchronized、final;
为了提高程序的执行性能,编译器和处理器都会对指令做重排序, 从源代码到最终执行的指令,可能会经过三种重排序。编译器的重排序和CPU 的重排序的原则一样,会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
3. volatile 关键字基本原理
编译器会 对 volatile 关键修饰的变量加一个 acc_volatile 标志, JVM 根据操作系统调用不同的内存屏障指令。在volatile关键字的读写操作前后插入一些内存屏障
4.JMM中的happen-before 规则
在 JMM 中,如果一个操作执行的结果需要另一个操作为前提, 那么这两个操作必须要存在happens-before 关系,即表示的是前一个操作的结果对于后续操作是可见的,。这两个操作可以是同一个线程,也可以是不同的线程.
public class App {
int a =0;
volatile boolean flag=false;
public void write(){
a= 1; //------步骤1
flag = true; //-------步骤2
}
public void read(){
if(flag){ //-------步骤3
int i =a //-------步骤4
}
}
}
a.程序顺序规则
一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是单个线程中的代码顺序不管怎么变,对于结果来说是不变的;如果两个指令存在依赖关系,不允许重排序 ( 步骤3 happens-before 步骤4,)
b.volatile 变量规则,遵从下表
普通变量的读/写 一定happen-before后续 volatile 变量的写操作 (步骤1 happens-before 步骤2 )
对于 volatile 修饰的变量的写的操作, 一定happen-before 后续对于volatile 变量的读操作; (步骤2 happens-before 步骤3 )
c.传递性规则,
1 happens-before 2; 2happens- before 3, 3happens- before 4; 那么传递性规则表示: 1 happens-before 4;
d. start 规则
如果线程 A 执行操作 ThreadB.start(), 那么ThreadB.start()之前的操作 happens-before 线程 B 中的任意操作
e.Join规则
如果线程 A 执行操作ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens- before 线程A Join 之后的所有操作
f. 监视器锁的规则
对一个锁的解锁,happens-before 于随后对这个锁的加锁