(内容有部分借鉴其他文章)
JMM模型
被volatile修饰的共享变量,具有了以下特性:
1 .可见性 2 .有序性 3. 非线程安全,非原子操作
1 可见性:当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主内存,在刷新主内存时使用汇编指令Lock,避免未完成写操作时就被其他线程读取。总线会通知所有CPU放弃工作内存中的值。当其它线程再次需要使用该变量时,会去主内存中读取新值。而普通变量则不能保证立即去读取主内存新值,而且会有很长时间的延迟。(*只有再次需要读取时才会读主内存,已经读过的不会更新,看3)
可见性的实时性对比在视频中段位置:https://www.bilibili.com/video/av33688545?p=20
其实通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存。取锁时会从主内存取值。但是synchronized和Lock会挂起线程,开销都更大。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存,并通知所有线程(可见性相当于释放锁)
当读一个volatile变量时,JMM会把该线程对应的工作内存置为无效,线程接下来将从主内存中读取共享变量。(可见性相当于取得锁)
注意:volatile 修饰 数组/Object 时,只有在数组/Object的变量引用发生变化时才内存可见,而数组/Object中的元素/属性被改变时没有内存可见性
例如:volatile int[] a={1,2,3 } ; a[1]=5;//无可见性 a={6,7,8};//有可见性 ;实际上 volatile只是关注变量a的变化,而不关心其引用的数组的变化
可见性模型:happen before规则
从可见性角度看,写之前的操作(包含写),对之后读的线程都是可见的
2 有序性:https://www.bilibili.com/video/BV1eE411V7Lm?p=2
volatile生成内存屏障,此变量前后的代码在JVM行时可能会被重排序,但是前后代码重拍排序时不会越过volatile变量(可解决单例模式中双重检查锁的BUG)
重排序举例:以下代码是有可能打印出"haha"的,因为a()中两条命令在同一线程执行中互相不影响,所以可能排序
//伪代码
x=1;
y=1;
public void a(){ //线程1 执行
x=5;
y=6;
}
public void b(){ //线程2 执行
if (y==6)&&(x==1){
print("haha");
}
}
有序性举例:下面的代码编译重排序后执行顺序有可能是 :1-2-3-4或2-1-3-4 ,但是1/2绝不会再3之后,4也不会在3之前 。
int a = 0;
int b = 0;
int c = 0;
volatile bool flag = false;
public void write() {
a = 2; //1
b = 2; //2
flag = true; //3
c = 1; //4
}
volatile是通过内存屏障来来禁止指令重排的。
内存屏障(Memory Barrier)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。下表描述了和volatile有关的指令重排禁止行为:
从上表我们可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
3 非线程安全,非原子性
不能保证原子性,要是说能保证,也只是对单个volatile变量的读/写具有原子性,但是对于类似volatile++这样的复合操作就无能为力了。
例如:无锁状态下, A/B线程都已经将 volatile int a=0 取得到自己工作内存中,然后都执行自增操作,就可能会出现问题。
因为,即使其中一个线程先完成 a++操作,并把a的值回写到主内存中。但是另一个线程已经完成了读取操作,它不会再读取了,所以还是会按照a=0执行自增
所以volatile变量的写操作一般配合CAS执行,来保证原子性,类似乐观锁思路。这样就可以同时拥有线程安全的三大特性,又避免了线程挂起。JDK8源码中很多并发策略都是通过 for循环+volatile+CAS实现无锁化。
1 取出volatile变量值
2 操作
3 CAS 比较其当前值是否还等于自己之前拿到的值,若相等则赋新值。比较赋值的复合操作通过CAS底层实现原子性,相当于各个线程串行化执行CAS。
单例双重检查锁 例子:
我们都知道一个经典的懒加载方式的单例模式:
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) {
synchronzied(Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
现在考虑重排序后,发生了以下这种调用:
Time | Thread A | Thread B |
---|---|---|
t1 | A1 检查到instance为空 | |
t2 | A2 获取锁 | |
t3 | A3 再次检查到instance为空 | |
t4 | A4 进入synchronzied代码块,为instance对象分配内存空间 | |
t5 | A5 将instance变量指向内存空间(重排序后,在A6前暴露引用) | |
t6 | B1 检查到instance变量不为空 | |
t7 | B2 访问instance(对象还未初始化或半初始化) | |
t8 | A6 初始化instance对象 |
看似简单的一段赋值语句:instance = new Singleton();,其实JVM内部已经转换为多条指令:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
但是经过重排序后如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化
ctorInstance(memory); //2:初始化对象
可以看到指令重排之后,instance变量指向分配好的内存放在了前面,而这段内存的初始化被排在了后面,在线程A初始化完成这段内存之前,
线程B虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程B获得instance对象进行使用就可能发生错误。(synchronzied是可以保证原子性、可见性、有序性的,但是B线程并没有进入synchronzied代码块,所以会出现有序性问题)
解决方法:private static volatile Singleton instance = null;