简述 volatile 特性以及原理

  • 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一内存位置取到不同的值。
  • 编译器可以改变指令执行的顺序以使吞吐量最大化,这种顺序上的变化不会改变代码的语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值
    volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile那么编译器和虚拟机就知道该域是可能被另一并并发线程发现并更新的。

摘自《JAVA核心技术 卷一》

从上面的引用大概可以看出Volatile的作用。
从引用中也看出 Volatile 关键字带来很重要的特性有两个:

  • 保证可见性
  • 禁止进行指令重排序

看一个例子

    private boolean isDone = false;

    private HashMap<String,String> map = null;

    public class T1 implements Runnable {

        @Override
        public void run() {
            while (true) {
                System.out.println("still running");
                try {
                    TimeUnit.MILLISECONDS.sleep(200);
                } catch (Exception e) {
                }
                if (isDone) {
                    System.out.println(map.get("hello"));
                    System.out.println("this job is done");
                    break;
                }
            }
        }
    }

    public class T2 implements Runnable {

        @Override
        public void run() {
            map = new HashMap<String,String>();
            map.put("hello","world");
            System.out.println("Finished");
            isDone = true;
        }
    }


    public static void main(String[] args) {
        Volatile_1 volatile_1 = new Volatile_1();
        T1 t1 = volatile_1.new T1();
        T2 t2 = volatile_1.new T2();
        Thread thread1 = new Thread(t1);
        Thread thread2 = new Thread(t2);
        thread1.start();
        thread2.start();
    }

例子中有两个线程thread1thread2,一个公共容器map
thread1作用是监听参数一个参数isDone,一旦isDone变为true那么,表示thread2是不是已经把map创建好并且将需要的值填充进去。这个时候thread1会继续往后运行取出需要的数据并打印。
这个小例子每一次运行都能正常打印出结果,看似没问题,其实隐藏着问题。

  1. 可见性可能造成的问题

    注意代码中定义属性 isDone是一个普通的私有属性,没有用volatile修饰,这样其实一直有一个隐患。因为在实际运行的时候,线程会拷贝一份现有的参数到高速缓存以以保证效率,之后再将修改之后的数据回写到主存中。这个过程粗略的分为:

    1. 读取属性
    2. 拷贝一份到线程内存
    3. 完成方法内的创建对象以及赋值
    4. 将最终的数据回写到主存

    四个操作一次进行,不间断,这样状态其实是相对稳定的,但是假如我们的程序的thread2程序运行到了 3 刚好结束但是此时线程被阻塞了,一直不会将最终的结果写回主存,则此时thread1将一直处于循环等待中不会结束。这样其实是造成了thread1一直在无法结束。但是将属性加上volatile可以保证线程在对被修饰过的参数进行修改之后马上回写到主存。即:
    每次读取前必须先从主内存刷新最新的值,每次写入后必须立即同步回主内存当中
    一个例子:

import java.util.concurrent.TimeUnit;

/**
 * @author wanggg
 * @description
 * @create 2018-09-04 下午2:49
 **/

public class Volatile_4 {
    // 加了volatile 如预期所想结束
    //private static volatile boolean isDone = false;

    // 没有volatile t2 死循环
    private static  boolean isDone = false;

    public static void main(String [] args){
        Volatile_4 v = new Volatile_4();

        //改变isDone
        Thread t1 = new Thread(()->{
            try {
                TimeUnit.MILLISECONDS.sleep(2000);
            }catch (Exception e){
            }
            Volatile_4.isDone = true;
            System.out.println("t1 Done");
        });

        //改变isDone
        Thread t2 = new Thread(()->{
            while(true) {
                if(Volatile_4.isDone){
                    System.out.println("t2 Done");
                    break;
                }
            }
        });

        t2.start();
        t1.start();
    }
}
  1. 重排序造成的问题

    对于线程内部来说指令重排并不会有任何影响,因为过程中的数据并不重要也不影响最后的结果。但是对于存在另一个线程的情况下就不一样。
    同样是上面的例子,对于thread2来说一行代码代表一个操作则有四个操作需要执行:

    1. 创建对象并赋值给map
    2. 向map中put一个键值对
    3. 输出一个Finished信息
    4. 将isDone修改为true

    这里就需要关注一下操作发生的实际可能的顺序,首先,1 操作肯定发生在 2 操作之前,这两个操作可合并一起考虑 接下来的输出不会造成任何属性的改变也可以不考虑或者并在 1、2 中一起考虑,这样就成了 1、2、3 为一个整体,最后的赋值操作,对于线程内部也没什么影响,但是对于同样需要监控这几个属性的thread1来说就不是了。假如出现这样的情况 4 的操作发生于1和2前面,并且回写到了主存,此时刚好thread2取到了这个数据,这个时候thread2就直接认为thread1已经结束并且主备好了数据,所以thread2直接去取了数据并尝试输出,此时就会出新空指针异常,这并不是我们所预期的结果。在这里同样加上volatile关键字修饰可以解决这样的问题。原因是volatile关键字禁止指令重排序有两层意思:

    • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行
    • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行

关于原子性:
volatile仅仅只能保证所修饰属性的可见性和相对的有序性,并不能保证原子性。
例子:

    public volatile int num = 0;

    public void increase() {
        num++;
    }

    public static void main(String[] args) {
        final Volatile_3 test = new Volatile_3();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    test.increase();
            }).start();
        }
        //所有线程都结束
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(test.num);
    }

最终输出的结果并不是每次都是预期的10000。

为什么会有这样的现象?其实很简单,假如 thread1thread2 是其中两个线程。thread1读取了此时 num = 1,这个时候thread1在执行修改操作之前变成了阻塞状态,然后thread2 来读取了数据,此时也是读取到num = 1,然后thread2执行了累加操作,并且将数据回写,这个时候thread1被唤醒,继续执行,这个时候thread1依旧认为num = 1,然后执行了累加并且回写了数据,此时主存的数据将会是2 而不是预期的3。

由此可以看出来volatile仅仅保证了最新的修改能被每一个线程知道但是不能保证每一次只有一个线程来修改值。即并不能保证原子性。想保证原子性还是需要以来一些加锁的操作,比如用sychronized修饰累加的方法等。

猜你喜欢

转载自blog.csdn.net/CrazyHSF/article/details/81369658