之前看的抓心挠肝,多看几遍混熟了一点~ 赶紧码下来~
volatile 是什么
volatile 是一个类型修饰符,只能修饰共享变量
volatile 什么用
看这个单词的意思,不稳定的,易变的,可以看出,专治这些易变易产生问题数据,嘎嘎
1、程序并发执行过程中,对共享变量的修改其他线程可见,即数据可见性、一致性
2、防止指令重排导致程序异常
先来做个知识储备和普及~
共享变量
共享变量指的是并发中,不同线程访问主存中同一数据的变量,主存是给所有CPU共享的,大家伙都要用这个变量,那它就是共享变量,
我们都知道每个线程工作时有自己的一份工作内存, 这个工作内存指的就是CPU寄存器和高速缓存(L1、L2、L3),高速缓存下面介绍,CPU组成结构如下图:
下图,可以看出,并发情况下,每个线程分配一个cpu,每个cpu里有一个高速缓存,缓存里存放cpu要执行的指令和数据,cpu只和本缓存打交道(CPU和内存之间是没有管脚的),缓存再和主存(内存)通过总线做数据同步,
高速缓存
当然上面组成结构也是一步步演变过来的,缓存的出现是为了解决CPU处理速度和主存IO速度差异过大问题,CPU执行完一个指令一直在等内存IO结束再去执行下一行指令,效率低,所以,CPU高速缓存的出现大大提高了CPU使用性能,
下表可以看到使用缓存速度的提升
名称 | IO速度 |
---|---|
内存 | 几十 - 几百时钟周期 |
磁盘 | 几千万个时钟周期 |
L1 - 一级高速缓存 | 1 - 2时钟周期 |
L2 - 二级高速缓存 | 几十时钟周期 |
L2 - 三级高速缓存 | 高端CPU才有 |
L1 -> L2 -> L3 级缓存,
技术难度 递减,
制造成本 递减,
存储容量 递增,
CPU 查找数据顺序 L1 -> L2 -> L3 级缓存,找到即 缓存命中,
找不到即缓存未命中,未命中则根据缓存/内存映射 去主存中将整个缓存行数据同步到L1缓存
L1- 一级缓存 分为 :
d-cache 数据高速缓存 - 存CPU运行所需数据
i-cache 指令高速缓存 - 存CPU要执行的指令
RAM && ROM
既然说到内存,内存就是计算组成5大部件(运算器、存储器、控制器、IO设备)的存储器嘛,
存储器顾名思义,就是存数据的,分为RAM和ROM
RAM - Random-Access Memory 随机访问存储器,就是我们常说的内存条,存放运行时临时数据,与CPU直接交换数据,随时读写,访问速度快,关机就没了
RAM 分为
SRAM - 静态随机存储 - 用户高速缓冲存储器 - CPU中缓存,COMS+晶体管,无需刷新,功耗低,速度快
DRAM- 动态随机存储 - 用做计算机主存 - 内存条,电容存储+晶体管,需定时刷新,功耗大
ROM - Read-Only Memory 只读存储器 ,就是硬盘,可以持久化数据的
并发编程3大特性
上面说了那么多,总结下来就是,针对并发(多线程)场景,每个线程一个工作内存,线程间数据不共享,想改主存里共享数据的时候不能偷摸的,要告诉其他人,修改操作所有人可见
1、原子性
2、可见性 - 多线程共同访问共享变量时,有线程修改共享变量,其它线程需立即可获取到最新数据
3、有序性 - 程序按代码顺序执行,
为提升性能,编译器和处理器执行指令进行优化,对无数据依赖的指令行进行重排序,即指令重排
指令重排 - 并发问题
先来看一下经典的Double - Check 单例模式,问题来了,它是线程安全的吗,NO,
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
上答案,问题出在singleton = new Singleton();
,new对象是类的6大加载场景(自己查去)之一,类加载机制 加载 -> 连接(验证 -> 准备 -> 解析) -> 初始化
大概归为三个指令:
1.分配内存 memory = allocate()
2.初始化对象 instance(memory)
3. instace = memory 直接引用替换符号引用,栈中变量指向堆内存空间
当进行了指令重排,2,3顺序调换, 1->3 -> 2
那么就会出现这么一种情况:
当一个线程获取锁,判断实例singleton 为空,开始创建对象,执行指令1-分配内存,3-替换符号引用
Duang~ 此时另一个线程请求走到if (singleton == null)
第一个判空,发现 诶,有引用,不为空,Duang~,直接返回return singleton;
,那这个对象去使用,But~ 还没初始化呢,啥也没有,光有一块空地,这一使用肯定空指针异常了啊,so ~ 不安全
这个时候volatile就登场了,只要加上volatile稍微修饰一下,编译器和处理器就不会对指令进行重排,就不会有以上问题了。
volatile 支持可见性、有序性
volatile 只是修饰了一下共享变量,它是怎么做到有序性 - 防止指令重排的呢,以及线程间共享变量的可见性呢
诶,JVM 写volatile修饰的变量时,会生成一个Lock前缀的指令,这个指令就做了这个事
可见性
先来看可见性,volatile修饰后做了三件事:
1、volatile修饰的共享变量在CPU缓存中被修改会强制立即写入主存
2、volatile修饰的共享变量修改时会使其它线程工作内存(CPU中L1或L2缓存)中缓存行失效
3、由于其它线程缓存行失效,再次使用该变量时会去主存读取最新数据
以上三件事,达到数据修改可见性,就是谁一改,别人都知道
Lock指令怎么做到的呢?
Lock指令一看就是个上锁的动作啊,当一个线程要修改共享变量值时,会发送一个独占该变量的请求,其它线程有个“嗅探”技术,一直监控传输再总线上的数据,发现这个请求的数据,诶? 是共享变量,就会将本缓存中该数据所在缓存行置为失效,lock指令请求成功就会独占变量,别人都失效了,你随便改吧,然后锁缓存总线,改完写到本缓存(L1 、L2),然后立马从缓存回写到主存,然后释放锁,其它线程再用就去主存拉最新的
有个MESI - 缓存一致性原则,来保证每个CPU中缓存行做这事
状态转换:大概了解吧
有序性 - 禁止指令重排
有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障
屏障嘛,就是挡住了,屏障前后顺序不能颠倒,但是前后内部顺序还是可以重排的
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
例如上面代码,语句1 2 没有数据依赖,可能会重排,如果重排了,线程1先inited = true; //语句2
,
线程2 直接就走doSomethingwithconfig(context);
,context还没初始化,就报空指针了,inited 用volatile修饰后,前后顺序就不能重排了
下面这个例子也挺好,就是flag被volatile修饰后,前后顺序不能重排,但是语句1和2,语句3和4还是可以自由重排的。
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
总结:
所以volatile 的神奇是JVM 约定好了,谁懂volatile修饰的变量需要先过我这关,执行Lock前缀指令,lock指令又回去约束CPU ,这个变量不是你自己的,修改要公之于众,其它处理器一直“嗅探”,哦? 有人动了,那我这个失效了,再用我就拿新的 - 可见性
在编译器和处理器执行指令优化时放一道屏障,拦住,前后顺序谁也过不去 - 有序性
但是不保证原子性,synchronized 抱枕原子性、可见性、有序性,但它是整个程序独占锁啊,性能低,volatile 轻量级锁,只锁了变量。