内存可见性
先上一个示例:
package com.fen.dou.stu.fenbushisuo;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
public class Test {
// static ConcurrentHashMap map = new ConcurrentHashMap();
static HashMap map = new HashMap();
// static volatile HashMap map = new HashMap();
public static void main(String[] args) {
new Thread(()->{
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put("a","a");
},"A").start();
new Thread(()->{
while (true){
String ssss = (String) map.get("a");
if(ssss != null){
System.out.println("-----------------------"+Thread.currentThread().getName());
break;
}
}
},"B").start();
new Thread(()->{
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
while (true){
String ssss = (String) map.get("a");
if(ssss != null){
System.out.println("-----------------------"+Thread.currentThread().getName());
break;
}
}
},"C").start();
}
}
这个代码最终会打印-----------------------C
有人会感觉疑惑,线程C执行的就比线程B执行多了一个Thread.sleep(3000);,为什么线程C会打印? 这是因为JMM中有Happens-Before规则,看代码
线程A休眠2s,线程C休眠3s,则线程C在线程A之后执行,则 A Happens-Before C,所以C能及时 得到 A 修改的值,但是这个值存在主内存吗?如果存在主内存,线程B一直在while循环为什么取不到?
大家的疑惑是不是越来越深?对,其实由于Happens-Before规则,其 线程A 修改的值,已经刷新到了主内存,但是线程B的工作内存中一直在使用 map对象的修改数据之前的副本,这个副本的数据是过期的,
所以这就存在了可见性的问题,那怎么能让线程B,及时的读到主内存中的值呢,加volatile,加volatile为什么可以呢?因为编译时会给操作volatile的值加locks,加locks来保证什么呢?,缓存一致性协议
那就来讲讲缓存一致性协议:
缓存一致性协议大致是这样的,如果一旦CPU修改了volatile修饰的map对象的值,其他cpu缓存中的对象都无效,需要从主内存里面重新去加载
所以在线程B 执行这个时候String ssss = (String) map.get("a");放到CPU上去执行的时候,前期 map一直没有修改,所以是旧数据,但是一旦被别的线程修改了,并且刷新到主内存了,这个map就是过期的对象了,CPU会重新去主内存加载最新的值,这就达到了内存可见性
但是讲到这里大家一定还会有疑惑,为什么线程A,线程B,线程C 的引用都是一样的,为什么还是会不一样?
因为线程执行最终会给CPU去执行,CPU就会根据对象的引用去主内存中加载数据,把加载的数据放到L1,L2,L3等缓存中,如果下次执行,如果缓存中有,会直接从缓存里面去取,当线程A修改完数据之后,L1/2/3里面的数据都是过期,根据缓存一致性协议,如果缓存里面的数据的过期的,其会直接取主内存中取。现在应该明白了把
现在应该理解我们平时在开发过程中,如果没有涉及共享变量的操作,我们是无需去管线程并发的安全问题,因为,每个线程都在自己的工作空间内执行,不会相互影响
那缓存一致性协议底层到底什么实现的呢?
来看下支持缓存一致性协议的MESI协议
MESI协议:是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:
M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
I:无效的。本CPU中的这份缓存已经无效。
一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
存在两种不能使用缓存一致性的情况
1、共享对象超过一个缓存行的大小,跨多个缓存行,那一个缓存行多大呢?
看只有64字节
这里说下指令缓存,JVM执行引擎会把字节码转化成机器码,也就是汇编指令,CPU会加载需要执行的机器码放到指令缓存中
2、CPU不支持缓存一致性协议
如果不能使用缓存一致性,需要怎么做呢?加总线锁,注意这里总线锁,只是锁住总线里面 被volatile对象在主内存中引用不能被调用,也就是说所有的CPU只能有一个CPU能通过总线从内存中读/写数据、
这样也就会造成性能问题。
有序性
有序性,就是禁止指令重排,在修改的共享变量读写前后加内屏屏障,禁止编译器,解析器重排
指令重排发生的地方有两个:
1、java编译成字节码文件class的时候
2、执行引擎把class文件中的字节码指令转化成机器码的时候
【懒汉式-加双重校验锁&防止指令重排序的懒汉式】
public class LazyshiTest {
public static void main(String[] args) {
LazyshiSington lazyshiSington1 = LazyshiSington.getInstance();
LazyshiSington lazyshiSington2 = LazyshiSington.getInstance();
System.out.println(lazyshiSington1);
System.out.println(lazyshiSington2);
System.out.println(lazyshiSington1 == lazyshiSington2);
}
}
class LazyshiSington{
private volatile static LazyshiSington lazyshiSington = null; // volatile 禁止指令码重排序
private LazyshiSington(){
if(lazyshiSington != null){
throw new RuntimeException("已经存在对象"); //防止反射通过构造器创建对象
}
}
public static LazyshiSington getInstance(){
if(lazyshiSington == null){ //a
synchronized(LazyshiSington.class){ //b
if(lazyshiSington == null){ //双重检测 //c
lazyshiSington = new LazyshiSington(); //d
}
}
}
return lazyshiSington;
}
}
在回答这个问题之前你需要知道的知识点
instance=new LazyshiSington();这个语句不是一个原子操作,编译后会多条字节码指令:
步骤1.为new出来的对象开辟内存空间
步骤2.初始化,执行构造器方法的逻辑代码片段
步骤3.完成instance引用的赋值操作,将其指向刚刚开辟的内存地址
可能场景-线程t1,t2均到达 代码b处
这个时候假若线程t1获得锁,t2处于阻塞状态,直到t1 依次执行代码a,b,c,d,并且在释放锁之前会将对变量instance的修改刷新到主存当中,保证当其他线程再进入的时候,在主存中读取到的就是最新的变量内容了。
t1释放锁之后,t2获得锁,根据重新从主内存拿到的变量instance值判断不为null,则直接跳过代码d的执行,即线程2只执行了代码a,b,c就释放掉了锁。
结论:这个场景下线程t1,t2会拿到了一个完整的instance所以是不存在问题的。
真正的问题场景-线程t1执行到代码d处,线程t2执行到代码a处
线程t1执行到代码d处时,在没有加volatile关键字修饰instance时是存在指令重排序的问题的,假若代码d的执行顺序是步骤1、步骤3、步骤2。
在线程t1执行完成步骤3,还没有执行步骤2时,线程t2执行到代码a处,对instance进行判断是否为null,发现不为null则直接返回使用(但此时instance是不一个不为null的但是没有初始化完成的对象)
结论:这个场景下线程t1是没有问题的会得到一个完整的instance,但是t2会提前拿到了一个不完整的instance是存在问题的,所以需要加上volatile来禁止这个语句instance=new LazyshiSington();进行指令重排序。