关于解决共享变量的可见性的手段,除了使用笨重的锁以外,Java还提供了一种弱形式的同步--------volatile关键字。
volatile作用:
确保对一个变量的修改对其他线程马上可见。当我们使用这个关键字修饰一个变量的时候,线程在写入变量的时候不会把这个变量的值缓存在寄存器或者其他地方,会直接把值写回主内存中(什么是主内存看这里)。其他线程在读取这个变量的值也是一样,每次都是直接从主内存读取,而不会从它的工作内存中读取。它还会禁止指令重排,保证有序性。
这里介绍一下指令重排:在程序运行的时候,代码实际上不一定是按我们所写的代码顺序去执行的,编译器和处理器会针对已有的代码,在保证串行语义一致的前提下合理重新给我们的代码指令重新排序以提高运行速度,这时候指令会出现重排。因为保证了串行语义一致性,因此在单线程下我们的代码运行是没有问题的,但是在多个线程并行运行的时候,就会出现一些问题。
重排序分为三种类型:
(1)编译器优化的重排序:编译器在不改变单线程的程序语义的前提下,可以重新安排语句执行顺序
(2)指令级并行的重排序:处理其采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理其可以改变语句对应机器指令的执行顺序(数据依赖性,如:int a = 1; int b = a; 这里变量b依赖于变量a)
(3)内存系统的重排序:由于处理器使用缓存和读/写缓冲区,,这使得加载和存储操作看起来可能是在乱序执行的。
这三种重排序依次进行,最后才会得到真正的执行指令序列,(1)属于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序;(2)(3)属于处理器重排序,JMM会要求Java编译器在生成指令序列的时候插入特定的内存屏障来禁止特定的处理器重排序。
举个例子:这里实例化一只猫
Cat cat = new Cat() 这个操作过程实际分为三步:
(1)在堆内存中开辟内存空间
(2)在堆内存中实例化Cat里面的各个参数
(3)把对象指向堆内存空间
但是由于java程序指令重排序,因此存在乱序执行,所以如果线程A执行上述过程,可能步骤2还没执行就执行了步骤3,此时如果切换到了线程B,因为执行完了步骤3,所以cat已经是非空的了,这时候如果线程B判断cat不为空,拿来使用,但是因为cat的参数没有实例化完成,这时候就会出现异常。
这时候如果加上来了volatile变量就能保证这个new Cat的过程是不能进行指令重排的,也就是步骤1、2、3依次执行,这样就不会导致对象已经非空, 但是还没实例化完成。进而避免了异常的发生。
这里介绍补充哪些指令不能重排:Happen-Before规则
1、程序顺序规则:一个线程内保证语义的串行性
2、volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性
3、传递性:A先于B,B先于C,那么A比定先于C
4、线程的start方法先于它的每一个动作
5、线程的所有操作先于线程的终结
6、线程的中断( interrupt() )先于被中断的线程代码
7、对象的构造函数执行、结束优先与finalize()方法
内存语义:
当线程写入了volatile变量的时候,也就是把工作内存的变量直接写入了主内存;当线程读取volatile值的时候就相当于进入了同步代码块,会先清除工作内存中变量值,然后从主内存中获取最新值。
实现原理:
前置知识:有volatile修饰的共享变量进行写操作的时候会多出两行汇编代码,其中加入了一个Lock前缀指令,这个指令在多核处理器下会引发两件事情:
1、将当前处理器缓存行的数据写回到系统内存。
2、这个写回内存的操作会使其他CPU里面缓存了该内存地址的数据无效
因为处理器为了提高处理速度,是不直接跟内存通信的,而是先将内存里面的数据读取到内部缓存里面,然后再进行操作,但是操作完不知道什么时候会写回内存。如果声明了volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀指令,将这个变量所在的缓存行数据写回到系统内存。但是如果其他处理器缓存值还是旧的话,还会会出现问题。因此,在多处理器下,为了保证处理器缓存一致,处理器硬件是实现了缓存一致性协议的,每个处理器会嗅探在总线上传播的数据来检测自己的缓存是不是过期了,如果发现自己的缓存行对应的内存地址被修改,就会将当前处理其的缓存行设置为无效状态,重新到系统内存里读取到缓存中。
有了这些前置知识,我们其实就能理解volatile的实现原则的了:
1、Lock前缀指令会引起处理器缓存回写内存。在执行这部分Lock指令的时候的时候,Lock#信号通过锁住缓存并写回内存中,并通过缓存一致性机制确保修改的原子性(即:缓存锁定)
2、一个处理器的缓存回写到内存会导致其他处理器的缓存失效。这里是处理器自己通过嗅探技术实现的,用来保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保存一致。如果一个处理器嗅探到别的处理器打算写内存地址,那么它就会把自身的缓存行无效,下次访问相同的内存行的时候,强制执行缓存填充(即重新从系统内存读取这个部分缓存内无效的数据)
volatile优化:通过追加字节的方式进行性能优化。
在LinkedTransferQueue中,该类开发者通过使用很多个4字节的引用补充到64字节,来确保头尾节点不会加载到同一个缓存行,因为64位处理器的缓存行一行是缓存64个字节的,而且不支持部分填充行。
与synchronized的区别:(关于synchronized可见我的这篇博客)
在解决共享变量的可见性上,volatile和synchronized是一样的,但是volatile并不保证原子性。而synchronized是保证原子性的。volatile不会引起线程的阻塞,没有带来线程调度的性能损耗。
使用场景(一般满足两点):
1.要写入的变量值不依赖变量的当前值。因为如果依赖的话,操作将会是“先读取值,然后计算,再写入“,这样属于三步操作,而且这三步不是原子性的,而volatile不保证原子性
2.读写变量值时没有加锁。因为加锁后就保证了内存可见性,所以没有必要用了。
具体场景:
1.保证通知的实时性(下面用作一个状态变量,保证其实时可见性)
public class Volatile {
//如果不用volatile会导致,当setStatus更改完status值后,在刷新回主存的过程中,
//因为这时别的线程不可见,导致错误地再执行了几次。
private volatile boolean status = true;
public void shutDown(){
while (status){
//do something
System.out.println("状态正常,继续进行工作");
}
}
/**
* 修改状态,保证修改后,其他线程马上可见
*/
public void setStatus(){
status = false;
}
}
2.限定对象实例化后返回引用(这里演示单例模式)
public class God {
// 这里防止程序没有实例化对象前就返回引用
private volatile static God god;
private God(){}
//如果不加volatile可能出现God未被实例化完成,
//jvm就通过优化指令排序,返回了没有实例化完成的引用,
//这时候如果别的线程进入,就会出现 god != null ,但是里面的god是没有完成实例化的
//导致程序出现问题
public static God getInstance(){
if (god == null){
synchronized(God.class){
if (god == null){
god = new God(); }
}
}
return god;
}
}
3.用于降低读-写锁的开销
//这里读取value使用volatile修饰
//值修改完成后完成后,读方法立即可见,因此不需要在读方法前加锁
//从而减少了锁竞争
private volatile int value;
public int getValue(){
return value;
}
public synchronized void setValue(int value){
this.value = value;
}