面试中如何回答volitile关键字

一,什么是JMM?

JMM是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。值得注意的是,JMM是一个抽象概念,不是真实存在的。
JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行。

面试中如何回答volitile关键字
 

二,讲讲缓存不一致的问题。

使用工作内存和主存,虽然加快的速度,但是也带来了一些问题。

比如


  1. i = i + 1;
假设i初值为0,当只有一个线程执行它时,结果肯定得到1,当两个线程执行时,会得到结果2吗?这倒不一定了。可能存在这种情况:


  1. 线程1 load i from 主存 // i = 0
  2. i + 1 // i = 1
  3. 线程2 load i from主存 // 因为线程1还没将i的值写回主存,所以i还是0
  4. i + 1 //i = 1
  5. 线程1: save i to 主存
  6. 线程2 save i to 主存

如果两个线程按照上面的执行流程,那么i最后的值居然是1了。如果最后的写回生效的慢,你再读取i的值,都可能是0,这就是缓存不一致问题。
JMM主要就是围绕着如何在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,通过解决这三个问题,可以解除缓存不一致的问题。而volatile跟可见性和有序性都有关。


三,volatile关键字有什么作用?

1、保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。也可以简单的说,保证了不同线程对该变量操作的内存可见性。
2、禁止指令重排序优化。


四,讲讲三个特性,原子性、可见性和有序性。

解释原子性。Java中,对基本数据类型的读取和赋值操作是原子性操作,所谓原子性操作就是指这些操作是不可中断的,要做一定做完,要么就没有执行。比如:

  1. i = 2;
  2. j = i;
  3. i++;
  4. i = i + 1

上面4个操作中,i=2是读取操作,必定是原子性操作。

j=i分为两步,一是读取i的值,然后再赋值给j,这就是2步操作了,称不上原子操作。

i++i = i + 1其实是等效的,读取i的值,加1,再写回主存,那就是3步操作了。所以上面的举例中,最后的值可能出现多种情况,就是因为满足不了原子性。

这么说来,只有简单的读取,赋值是原子操作,还只能是用数字赋值,用变量的话还多了一步读取变量值的操作。有个例外是,虚拟机规范中允许对64位数据类型(long和double),分为2次32为的操作来处理,但是最新JDK实现还是实现了原子操作的。

JMM只实现了基本的原子性,像上面i++那样的操作,必须借助于synchronizedLock来保证整块代码的原子性了。线程在释放锁之前,必然会把i的值刷回到主存的。

解释内存可见性。
可见性指的是当一个线程修改了某个共享变量的值,其他线程能够马上得知这个修改的值。Java就是利用volatile来提供可见性的。
当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值。而普通变量则不能保证这一点。
其实通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。
解释有序性。JMM是允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即不管怎么重排序(单线程的情况下),程序的执行结果不能改变。比如下面的程序段:

  1. double pi = 3.14; //A
  2. double r = 1; //B
  3. double s= pi * r * r;//C

上面的语句,可以按照A->B->C执行,结果为3.14,但是也可以按照B->A->C的顺序执行,因为A、B是两句独立的语句,而C则依赖于A、B,所以A、B可以重排序,但是C却不能排到A、B的前面。JMM保证了重排序不会影响到单线程的执行,但是在多线程中却容易出问题。

比如以下代码。

  1. int a = 0;
  2. bool flag = false;
  3. public void write() {
  4. a = 2; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a;//4
  10. }
  11. }
如果我们启动两个线程,1线程执行write方法,2线程执行multiply方法,由于指令重排序,最后的结果却不一定是4。
面试中如何回答volitile关键字
1线程先执行flag=true的操作,随后线程2切入执行,最终a的值变为2。
如果我们给flag加上volatile来修饰,就可以避免指令重排序。
需要知道的是,JMM具备一些先天的有序性,即不需要通过任何手段就可以保证的有序性,通常称为happens-before原则。关于这个,可以参考
http://ifeve.com/easy-happens-before/
五,volatile能否保证内存可见性?
可以,如果一个变量声明成是volatile的,那么当我读变量时,总是能读到它的最新值,这里最新值是指不管其它哪个线程对该变量做了写操作,都会立刻被更新到主存里,我也能从主存里读到这个刚写入的值。也就是说volatile关键字可以保证可见性。
六,volatile能否保证有序性?
可以,依然可以用上面的代码来说明。

  1. int a = 0;
  2. bool flag = false;
  3. public void write() {
  4. a = 2; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a;//4
  10. }
  11. }

假设线程1先执行write操作,线程2再执行multiply操作,由于线程1是在工作内存里把flag赋值为1,不一定立刻写回主存,所以线程2执行时,multiply再从主存读flag值,仍然可能为false,那么括号里的语句将不会执行。

给flag加上volatile修饰。

  1. int a = 0;
  2. volatile bool flag = false;
  3. public void write() {
  4. a = 2; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a;//4
  10. }
  11. }
线程1先执行write,线程2再执行multiply。根据happens-before原则,这个过程会满足以下3类规则:
程序顺序规则:1 happens-before 2;   3 happens-before 4; (volatile限制了指令重排序,所以1 在2 之前执行)
volatile规则:2 happens-before 3
传递性规则:1 happens-before 4
七,volatile能否保证原子性?
不能。volatile只是对单个volatile变量的读/写具有原子性,但是我们都知道i++操作是一个复合操作,这个变量如果用volatile修饰,也不能使i++变成一个原子性操作。
比如以下代码。

  1. public class Test {
  2. public volatile int inc = 0;
  3. public void increase() {
  4. inc++;
  5. }
  6. public static void main(String[] args) {
  7. final Test test = new Test();
  8. for(int i=0;i<10;i++){
  9. new Thread(){
  10. public void run() {
  11. for(int j=0;j<1000;j++)
  12. test.increase();
  13. };
  14. }.start();
  15. }
  16. while(Thread.activeCount()>2) //保证前面的线程都执行完
  17. Thread.yield();
  18. System.out.println(test.inc);
  19. }

现在我们进行10000次inc++的操作,如果多次运行程序,可以发现,结果有时候并不是10000,虽然volatile修饰保证了inc变量的内存可见性,而inc++是一个复合操作,包括读取inc的值,对其自增,然后再写回主存。
假设线程A读取inc值为10时,线程A被暂时阻塞,这时候线程B读取inc值也为10,线程B对inc进行自增操作,并把结果写回主存,这时候才轮到线程A对inc进行自增操作,然后写回主存,11又被写了一次。两个线程对inc++操作,但是结果并不是+2。
虽然volatile使线程的本地内存缓存无效,但是线程A读取inc时,线程B还没有对inc进行修改,也就是说缓存无效的认定条件是,线程进行读取操作时,必须是本地内存的值无效,才会从主存刷新值。


八,写一下volatile的一些常见用法。

①用volatile修饰一个状态量。

  1. int a = 0;
  2. volatile bool flag = false;
  3. public void write() {
  4. a = 2; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a;//4
  10. }
  11. }
单例模式的实现,典型的双重检查锁定
先写一个没有用volatile修饰的uniqueInstance

  1. public class Singleton {
  2. private static Singleton uniqueInstance;
  3. private Singleton(){
  4. }
  5. public static Singleton getInstance(){
  6. if(uniqueInstance == null){ //#1
  7. synchronized(Singleton.class){ //#2
  8. if(uniqueInstance == null){ //#3
  9. uniqueInstance = new Singleton(); //#4
  10. System.out.println(Thread.currentThread().getName() + ": uniqueInstance is initalized..."); //#5.1
  11. } else {
  12. System.out.println(Thread.currentThread().getName() + ": uniqueInstance is not null now..."); //#5.2
  13. }
  14. }
  15. }
  16. return uniqueInstance;
  17. }
  18. }


  1. public class TestSingleton {
  2. public static void main(final String[] args) throws InterruptedException {
  3. for (int i = 1; i <= 100000; i++) {
  4. final Thread t1 = new Thread(new ThreadSingleton());
  5. t1.setName("thread" + i);
  6. t1.start();
  7. }
  8. }
  9. public static class ThreadSingleton implements Runnable {
  10. @Override
  11. public void run() {
  12. Singleton.getInstance();
  13. }
  14. }
  15. }

  1. uniqueInstance = new Singleton(); //#4
在这句代码中,其实不是一个原子性操作。

事实上在 JVM 中这句话大概做了下面 3 件事情:


1.给 instance 分配内存

2.调用 Singleton 的构造函数来初始化成员变量

3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。


但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。



参考:
https://www.cnblogs.com/damonhuang/p/5431866.html