说到volatile关键字,第一反应就是三点:
- 保证可见性
- 不保证原子性
- 禁止指令重排
概括:它是java虚拟机提供的一个轻量级的同步机制,基本上遵守了jmm的规范,它保证可见性,不保证原子性,保证有序性。
下面将一一展开学习volatile关键字的使用及原理:
可见性
JMM:java内存模型
JMM(Java内存模型Java Memory Model)本身是一种抽象的概念 并不真实存在,它描述的是一组规则或规范通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式.
JMM关于同步规定:
1.线程解锁前,必须把共享变量的值刷新回主内存
2.线程加锁前,必须读取主内存的最新值到自己的工作内存
3.加锁解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在**主内存,**主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,线程间的通讯(传值) 必须通过主内存来完成,其简要访问过程如下图:
通过上图,可以一句话总结可见性,可见性就是指:某一个线程将内存中共享变量修改后并将主内存中的变量更新,但是其他线程如果没有被通知的话,使用的仍然是更新前变量的副本。这就造成了可见性的问题。如果一个线程修改了共享变量,可以第一时间通知其他线程修改各自内存中的值,那么就可以保证可见性。
下面是volatile保证可见性的示例:
public static void seekByVolatile(){
Data data = new Data();
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
data.changeNum();
System.out.println("线程A已经将data.num修改为10");
}, "A").start();
while(data.num == 0){
//直到main线程里面的num不为0时,才跳出循环
}
System.out.println("结束,且main中num为"+ data.num);
}
class Data{
volatile int num = 0;
public void changeNum(){
this.num = 10;
}
}
运行seekByVolatile()
:
当Data类内的num加了volatile关键字时,输出为
线程A已经将data.num修改为10
结束,且main中num为10
可以知道main线程对其他线程的变量修改有可见性。
如果没有加上volatile:
线程A已经将data.num修改为10
//......main线程一直未结束
可以知道没有volatile确实保证了可见性。
原子性
volatile不保证原子性就是指在多个线程对数据进行写操作的时候,先改变完数据的线程会因为可见性而通知其他线程更改自身内存中的数据,从而使自身的运行被打断,无法将自身线程工作的数据转存到总内存中。
jmm的原子性就是要保证每个线程内的运行操作数据的过程要么同时成功,要么同时失败。
例子如下,20个线程,每个线程将Data类中的num加一千次1,最后的结果应该是20000:
public static void plusByVolatile(){
Data data = new Data();
ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 20; i++) {
service.execute(() ->{
//原子性体现为,在一个线程加这一千次的时候,这个线程加的1000执行要么都成功,要么都失败,并且不能让其他线程干扰。
for (int j = 0; j < 1000; j++) {
data.plusPlus();
}
});
}
//等到其他线程都执行完,才执行main
// while(Thread.activeCount() > 2){
// Thread.yield();
// }
service.shutdown();
// System.out.println("最终的num为" + data.num);
System.out.println("最终的num为" + data.atomicInteger);
}
class Data{
volatile int num = 0;
AtomicInteger atomicInteger = new AtomicInteger();
public void plusPlus(){
// num++;
atomicInteger.getAndIncrement();
}
}
如果,仅仅加了volatile关键字,那么每次运行的结果都不会到20000.
例子中已经给出了解决方案:
如何解决原子性问题?
1.synchronized(不再赘述)
2.应用atomic中的atomicInteger类(保证原子性)配合volatile进行修改。
禁止指令重排
指令重排:因为java多线程环境下,默认会有指令重排的现象存在:
比如:
public void mySort(){
int x=11;//语句1
int y=12;//语句2
x=x+5;//语句3
y=x*x;//语句4
}
上面语句执行的顺序经过重排后可能会变成:
1234(正常)
2134
1324
问题:
请问语句4 可以重排后变成第一条码?
不可以,存在数据的依赖性,x和y没有初始化,没办法排到第一个
下面再举一个例子:
如果多线程情况下, 1、2可能出现指令重排。正常情况下方法2打印a为6,但是重排后,flag=true排到了前面,另一个线程拿到flag打印a为5。
在哪些地方用到过volatile?
1.读写锁中手写缓存demo //TODO
2.DCL单例模式(禁止指令重排的应用)
之前写过的优化好的synchronized单例模式:
class Singleton{
private Singleton(){
}
//加上volatile关键字!!!!!!!防止指令重排
private static volatile Singleton singleton = null;
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();//因为指令重排,线程B运行到这里但是还没有初始化完成。有引用但是没有对象。
}
}
}
return instance;//线程A执行到这里,有概率返回的是null。
}
}
这个代码其实也是有问题的,不能解决指令重排问题!但是概率很低。
DCL(双端检锁) 机制不一定线程安全,原因是有指令重排的存在,加入volatile可以禁止指令重排
原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化.
instance=new SingletonDem(); 可以分为以下步骤(伪代码)
memory=allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null
步骤2和步骤3不存在数据依赖关系.而且无论重排前还是重排后程序执行的结果在单线程中并没有改变,因此这种重排优化是允许的.
memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null 但对象还没有初始化完.
instance(memory);//2.初始化对象
但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性
所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题.
所以面试官要让写线程安全的单例模式的时候,单例对象的声明最好要加上volatile关键字,这样比较严谨.