Loading

深入理解Volitile关键字

Volatile

概念:JVM提供的一个轻量级的同步机制

作用:

  1. 防止JVM对 Long/Double 等64位的非原子性协议进行的 误操作(读取半个数据);
  2. 可以使某一个变量对所有的线程立即可见(某一个线程如果修改了工作内存中的变量副本,那么加上Volatile关键字之后,该变量就会立即同步到其他线程的工作内存当中)。
  3. 禁止指令 "重排序" 优化。

前面两点在之前的稳文章中都有提到,下面我们来看什么是指令"重排序"。看指令重排序之前,首先要理解什么是原子性!

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

  • 原子性

    num = 10; 就是一个原子操作,这段代码在程序的底层就这么一句话,不会再拆开了!

  • 非原子性

    int num = 10; 如果现在先定义变量,再赋值。这个操作就是非原子性的。

    这段代码在程序底层会拆分成两步,这两步已经不能再拆分了,所以是原子性的。

    1、int num;

    2、num = 10;

重排序

为了性能优化,编译器和处理器会进行指令重排序。排序的对象就是 原子性 操作!

比如上面的例子,int num = 10不是原子性操作。所以程序会在底层将它变成 int num 和 num = 10,把它变成原子性后在进行重排序。

下面通过一个例子理解重排序,有一个直观的映像:

int a = 10;		//1  int a; a= 10;
int b;			//2
b = 20;			//3
int c = a * b;	//4

重排序不会影响单线程的执行规则,因此以上程序在经过重排序后,可能的执行过程为1234或者2314,1234就是按照上面正常的执行流程,2314为

int b;			//2
b = 20;			//3
int a = 10;		//1
int c = a * b;	//4

在单例模式的实现上有一种双重检验锁定的方式(Double-checked Locking)。代码如下:

/**
 * @author leizige
 */
public class Singleton {

    private Singleton(){

    }

    public  static Singleton instance = null;

    public static Singleton getInstance() {
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    /* 不是一个原子性操作 */
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

以上代码在并发环境中会出现问题,原因是 instance = new Singleton()不是一个原子性操作,在执行过程中会拆分为一下几步:

  1. JVM会为 instance 分配内存地址以及内存空间。
  2. 在执行时通过构造方法实例化对象。
  3. 将 instance 指向在第一步分配好的内存地址 。

根据我们前面重排序的知识,以上代码在真正执行时可能是 1、2、3,也可能是 1、3、2。

如果在多线程环境下,使用1、3、2可能会出现问题:

假设线程A刚执行了1、3步骤,但还没有执行2,此时 instance 已经指向了JVM分配的内存地址。如果现在线程B进入 if(instance == null) ,会直接拿到 instance 的对象(此instance是刚才线程A并没有new的对象)。这时拿到的 instance 对象是null,如果直接使用必然会报错!

解决方案就是添加 Volatile 关键字来禁止 程序使用1、3、2的重排序顺序。

public volatile static Singleton instance = null;

Volatile 是否能保证变量的原子性、 线程安全

不能!

下面通过一段代码来验证一下:

/**
 * @author leizige
 */
public class TestVolatile {

    private volatile static int num = 0;

    public static void main(String[] args) throws InterruptedException {

        /**
         * 每个线程num++300次,100个线程在线程安全时,结果应该为300w;
         */
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 30000; j++) {
                    /* 不是一个原子性操作 */
                    num++;
                }
            }).start();
        }

        /**
         * 这里不能直接打印num,代码当中开了100个线程,main也是一个线程。
         * 假如100个线程,每个线程执行需要5ms
         * 但是从main方法开始到打印num,可能只需要花2ms
         * 如果main线程执行完,子线程还没执行完,所以会发生错误
         * 所以需要先暂停1s,让子线程执行完
         */
        Thread.sleep(1000);

        System.err.println(num);
    }

}

以上代码执行结果与预期的300w不符,下面我们分析一下线程不安全的原因:

其实造成原因的代码还是 num++,这句代码不是一个原子性操作。

num++ 等价与 num = num +1;

num = num +1 还可以拆分为以下两步:

  1. num + 1;
  2. num = 第一步的结果;

假设两个线程在执行时通过执行 num + 1;(假设此时num的值为10)

线程A执行 10 +1 = 11;

线程B执行 10 +1 = 11;

正常执行完线程A和B之后num的值应该为12,在并发环境下可能出现两个线程同时+1,就造成了漏加的情况,所以结果与预期不符合。

如何将 num 变成原子性的呢,只要使用 java.util.concurrent.atomic包下的 AtomicInteger。该类能够保证原子性的核心是因为提供了compareAndSet()方法,该方法提供了 CAS算法(无锁算法)。

/**
 * @author leizige
 */
public class TestVolatile {

    //    private volatile static int num = 0;
    private static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {

        /**
         * 每个线程num++300次,100个线程在线程安全时,结果应该为300w;
         */
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 30000; j++) {
//                    num++
                    /* 一个原子性操作 */
                    num.incrementAndGet();
                }
            }).start();
        }

        System.err.println(num);
    }

}
If you’re going to reuse code, you need to understand that code!
posted @ 2020-12-20 21:31  不颓废青年  阅读(160)  评论(0编辑  收藏  举报