面试系列----谈谈对volatile的理解
0.什么是volatile?
volatile是java虚拟机提供的轻量级的同步机制,可以保证可见性,不保证原子性,禁止指令重排。
首先要先了解一下JMM(java内存模型)
JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念 并不真实存在
,它描述的是一组规则或规范通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式.
JMM关于同步规定:
1.线程解锁前,必须把共享变量的值刷新回主内存
2.线程加锁前,必须读取主内存的最新值到自己的工作内存
3.加锁解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝
,因此不同的线程无法访问对方的工作内存,此案成间的通讯(传值) 必须通过主内存来完成,其简要访问过程如下图:
JMM具有可见性,原子性,和有序性。
-
可见性:通过前面对JMM的介绍,我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的.这就可能存在一个线程AAA修改了共享变量X的值还未写回主内存中时 ,另外一个线程BBB又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.
-
原子性:不可分割,完整性,也即某个线程正在走某个具体业务时,中间不可以被分割,需要整体的完整,要么成功要么失败
-
有序性:计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一把分为以下3中
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致.
处理器在进行重新排序是必须要考虑指令之间的数据依赖性多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测
1.volatile特点
保证可见性
代码展示:
class MyData{
int number = 0;
public void changeData(){
this.number = 60;
}
public void addNumber(){
number++;
}
}
public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
new Thread(() ->{
System.out.println(Thread.currentThread().getName()+"\t change number "+myData.number);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.changeData();
System.out.println(Thread.currentThread().getName()+"\t change number "+myData.number);
},"AA").start();
while (myData.number == 0){
}
System.out.println(Thread.currentThread().getName()+"\t int main change number "+myData.number);
}
上面这个程序是一直在执行的不会停下来的,因为没加volatile的number是不可见的,也就是main线程并不知道有线程已经将number修改,导致main线程一直在while循环中,若解决上面的问题那么在number前加上volatile就可以了。
不保证原子性
class MyData{
volatile int number = 0;
public void changeData(){
this.number = 60;
}
public void addNumber(){
number++;
}
}
public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addNumber();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount() >2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t int main change number "+myData.number);
}
运行结果为:
main int main change number 19980
main int main change number 18630
main int main change number 20000
main int main change number 19649
每一次的结果都是不一样的,这就是因为volatile并不能保证资源的原子性,在底层运行中,number++会拆分成三个指令,执行getfield拿到原始number,执行iadd进行加1操作,执行putfiled写把累加的值写回。可能会出现两个线程将数据拿到,两个线程都做了加1操作,并写回。这就导致数据变少的原因。
解决方案:
方案一:在addNumber()方法上加synchronized这样就可以解决了,但是这样会将整个方法都被锁住,显然有点小题大做了。
方案二:采用AtomicInteger类原子类,以保证原子性,代码如下:
class MyData{
volatile int number = 0;
public void changeData(){
this.number = 60;
}
public synchronized void addNumber(){
number++;
}
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomicInteger(){
atomicInteger.getAndIncrement();
}
}
public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
//myData.addNumber();
myData.addAtomicInteger();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount() >2){
Thread.yield();
}
//System.out.println(Thread.currentThread().getName()+"\t int main change number "+myData.number);
System.out.println(Thread.currentThread().getName()+"\t atomicInteger main change number "+myData.atomicInteger.get());
}
禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,特别在多线程环境下,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测。所以用volatile禁止指令重排。
总结:工作内存和主内存同步延迟现象导致的可见性的问题,可以使用synchronized或volatile解决,他们都可以使一个线程修改后的变量随其他线程可见。
对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile另一个作用就是禁止重排优化
2.volatile的用处
简单的在单例模式中会出现线程不安全,通常的做法就是加锁
public class SingletonDemo {
private static SingletonDemo instance = null;
public SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 构造方法");
}
public static SingletonDemo getInstance(){
if (instance == null){
synchronized (SingletonDemo.class){
if (instance == null){
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() ->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
}
加上锁以后,大部分时候结果是正确的。但是还是会出现问题。
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实例未必完成初始化,也就造成了线程安全问题.
=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null 但对象还没有初始化完.
instance(memory);//2.初始化对象
但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性
所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题.
转载:https://blog.csdn.net/Balance_1/article/details/105280465