面试官:谈谈你对volatile的理解?
当用volatile去申明一个变量时,就等于告诉虚拟机,这个变量极有可能会被某些程序或者线程修改,为了确保这个变量修改后,应用程序范围内的所有线程都能知道这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点。最简单的一种方法就是加入volatile关键字。
volatile是Java虚拟机提供的轻量级的同步机制,volatile有三大特性:
- 保证可见性
- 不保证原子性
- 禁止指令重排
如何理解它的三大特性,要先了解JMM。
一、JMM——Java内存模型
JMM是Java内存模型,也就是Java Memory Model,简称JMM,JMM定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:
上面提到了两个概念:主内存 和 工作内存
- 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存
- 工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中。当同时有三个线程同时访问 student中的age变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝。
JMM内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。
二、如何保证可见性?
首先举一个例子证明volatile的可见性?
import java.util.concurrent.TimeUnit; /** * 假设是主物理内存 */ class MyData { int number = 0; public void addTo60() { this.number = 60; } } /** * 验证volatile的可见性 * 1. 假设int number = 0, number变量之前没有添加volatile关键字修饰 */ public class VolatileDemo { public static void main(String args []) { // 资源类 MyData myData = new MyData(); // AAA线程 实现了Runnable接口的,lambda表达式 new Thread(() -> { System.out.println(Thread.currentThread().getName() + "\t come in"); // 线程睡眠3秒,假设在进行运算 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } // 修改number的值 myData.addTo60(); // 输出修改后的值 System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number); }, "AAA").start(); while(myData.number == 0) { // main线程就一直在这里等待循环,直到number的值不等于零 } // 最后输出这句话,看是否跳出了上一个循环 System.out.println(Thread.currentThread().getName() + "\t mission is over"); } }
输出结果:
最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性。
(2)添加volatile修饰时
/** * 假设是主物理内存 */ class MyData volatile int number = 0; //添加了volatile修饰 public void addTo60() { this.number = 60; } }
输出结果:
主线程也执行完毕了,说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。
volatile如何保证可见性?
为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术。
在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。
为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。
MESI协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
总线嗅探技术有哪些缺点?
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及Syschonized都是需要根据实际场景的。
三、Volatile不保证原子性
什么是原子性?
不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。数据库也经常提到事务具备原子性。
首先举一个例子证明volatile不保证原子性?
为了测试volatile是否保证原子性,我们创建了20个线程,然后每个线程分别循环1000次,来调用number++的方法。
MyData myData = new MyData(); // 创建10个线程,线程里面进行1000次循环 for (int i = 0; i < 20; i++) { new Thread(() -> { // 里面 for (int j = 0; j < 1000; j++) { myData.addPlusPlus(); } }, String.valueOf(i)).start(); }最后通过 Thread.activeCount(),来感知20个线程是否执行完毕,这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程。
// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值 while(Thread.activeCount() > 2) { // yield表示不执行 Thread.yield(); }然后在线程执行完毕后,我们在查看number的值,假设volatile保证原子性的话,那么最后输出的值应该是20 * 1000 = 20000,完整代码如下所示:
import java.util.concurrent.TimeUnit; /** * 假设是主物理内存 */ class MyData { volatile int number = 0; public void addTo60() { this.number = 60; } /** * 注意,此时number 前面是加了volatile修饰 */ public void addPlusPlus() { number ++; } } /** * 验证volatile不保证原子性 */ public class VolatileDemo { public static void main(String args []) { MyData myData = new MyData(); // 创建10个线程,线程里面进行1000次循环 for (int i = 0; i < 20; i++) { new Thread(() -> { // 里面 for (int j = 0; j < 1000; j++) { myData.addPlusPlus(); } }, String.valueOf(i)).start(); } // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值 // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程 while(Thread.activeCount() > 2) { // yield表示不执行 Thread.yield(); } // 查看最终的值 // 假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000 System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number); } }
最终结果我们会发现,number输出的值并没有20000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性
第一次输出结果:
第二次输出结果:
第三次输出结果:
为什么输出结果不是20000?
线程1和线程2同时修改各自工作空间的值,由于可见性,需要将修改的值写入主内存,多个线程出现同时写入的情况,线程1写的时候,线程2也在写入,导致其中一个线程被挂起,出现了数据的丢失。假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行加1、写入操作,这就造成了其他线程没有接受到主内存number的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000。
如何解决原子性的问题?
(1)在方法上加入 synchronized
public synchronized void addPlusPlus() { number ++; }
运行结果:
我们能够发现引入synchronized关键字后,保证了该方法每次只能够一个线程进行访问和操作,最终输出的结果也就为20000。
(2)AtomicInteger
上面的方法引入synchronized,虽然能够保证原子性,但是为了解决number++,而引入重量级的同步机制,大材小用。除了引用synchronized关键字外,还可以使用JUC下面的原子包装类,即刚刚的int类型的number,可以使用AtomicInteger来代替。
/** * 创建一个原子Integer包装类,默认为0 */ AtomicInteger atomicInteger = new AtomicInteger(); public void addAtomic() { // 相当于 atomicInter ++ atomicInteger.getAndIncrement(); }然后同理,继续刚刚的操作:
// 创建10个线程,线程里面进行1000次循环 for (int i = 0; i < 20; i++) { new Thread(() -> { // 里面 for (int j = 0; j < 1000; j++) { myData.addPlusPlus(); myData.addAtomic(); } }, String.valueOf(i)).start(); }最后输出:
// 假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000 System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number); System.out.println(Thread.currentThread().getName() + "\t finally atomicNumber value: " + myData.atomicInteger);
四、Volatile禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
单线程环境里面确保最终执行结果和代码顺序的结果一致
处理器在进行重排序时,必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
举一个指令重排的例子?
public void mySort() { int x = 11; int y = 12; x = x + 5; y = x * x; }
按照正常单线程环境,执行顺序是 1 2 3 4
但是在多线程环境下,可能出现以下的顺序:
- 2 1 3 4
- 1 3 2 4
上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样,但是指令重排也是有限制的,即不会出现4321的顺序,因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行。
第二个例子:
public class ResortSeqDemo { int a= 0; boolean flag = false; public void method01() { a = 1; flag = true; } public void method02() { if(flag) { a = a + 5; System.out.println("reValue:" + a); } } }我们按照正常的顺序,分别调用method01() 和 method02() 那么,最终输出就是 a = 6。但是如果在多线程环境下,因为方法1 和 方法2,他们之间不能存在数据依赖的问题,因此原先的顺序可能是:
a = 1; flag = true; a = a + 5; System.out.println("reValue:" + a);但是在经过编译器,指令,或者内存的重排后,可能会出现这样的情况:
flag = true; a = a + 5; System.out.println("reValue:" + a); a = 1;
也就是先执行 flag = true后,另外一个线程马上调用方法2,满足 flag的判断,最终让a + 5,结果为5,这样同样出现了数据不一致的问题。
为什么会出现这个结果:多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
这样就需要通过volatile来修饰,来保证线程安全性。
Volatile针对指令重排做了啥?
Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象。
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
在Volatile的写和读的时候,加入屏障,防止出现指令重排,线程安全获得保证。
全部评论
(3) 回帖