【JAVA基础-多线程】- 深入理解volatile关键字

  • 并发编程的三个概念
  • Java内存模型JMM
  • volatile实战例子(原子性,有序性,可见性)

并发编程的三个概念

首先我们了解下并发编程三个重要的概念:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性

即程序执行的顺序按照代码的先后顺序执行。(可能发生指令重排导致代码执行先后顺序发生变化)

Java 内存模型

Java内存模型规定所有的变量都是存在主存当中每个线程都有自己的工作内存线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

Java中的原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

Java中的可见性

对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性

有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

volatile 实战

volatile关键字修饰的成员变量具有两大特性:

  • 保证了该成员变量在不同线程之间的可见性
  • 禁止对该成员变量进行重排序,也就保证了其有序性
  • 但是volatile修饰的成员变量并不具有原子性,在并发下对它的修改是线程不安全的。

1. 可见性 实战

通过对JMM的了解,我们都知道线程对主内存中共享变量的修改首先会从主内存获取值的拷贝,然后保存到线程的工作内存中。接着在工作内存中对值进行修改,最终刷回主内存。由于不同线程拥有各自的工作内存,所以它们对某个共享变量值的修改在没有刷回主内存的时候只对自己可见

举个例子,假如有两个线程,其中一个线程用于修改共享变量value,另一个线程用于获取修改后的value:

public class VolatileTest {

    private static int INT_VALUE = 0;
    private final static int LIMIT = 5;

    public static void main(String[] args) {

        new Thread(() -> {
            int value = INT_VALUE;
            while (value < LIMIT) {
                if (value != INT_VALUE) {
                    System.out.println("获取更新后的值:" + INT_VALUE);
                    value = INT_VALUE;
                }
            }
        }, "reader").start();

        new Thread(() -> {
            int value = INT_VALUE;
            while (value < LIMIT) {
                System.out.println("将值更新为: " + ++value);
                INT_VALUE = value;
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "writer").start();

    }
}

执行结果:
image.png

由于在不同的线程中,线程writer对INT_VALUE的改变并不知情,所以线程reader中INT_VALUE的值固定,只会执行一次System.out.println

修改上面的例子,将INIT_VALUE成员变量使用volatile关键字修饰:

public class VolatileTest {

    private volatile static int INT_VALUE = 0;
    private final static int LIMIT = 5;

    public static void main(String[] args) {

        new Thread(() -> {
            int value = INT_VALUE;
            while (value < LIMIT) {
                if (value != INT_VALUE) {
                    System.out.println("获取更新后的值:" + INT_VALUE);
                    value = INT_VALUE;
                }
            }
        }, "reader").start();

        new Thread(() -> {
            int value = INT_VALUE;
            while (value < LIMIT) {
                System.out.println("将值更新为: " + ++value);
                INT_VALUE = value;
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "writer").start();

    }
}

执行结果:
image.png

2. 有序性 实战

来看一个线程不安全的单例实现(双检索)

/**
 * 双检索(DCL式).
 */
public class Singleton03 {

    private static Singleton03 singleton03;

    // 私有构造方法
    private Singleton03() {
    }

    // 同步该方法获取单例对象
    public static Singleton03 getInstance() {
        // 但对象为空时同步这个方法
        if (singleton03 == null) {
            synchronized (Singleton03.class) {
                // 再判断是否为空
                if (singleton03 == null) {
                    // 为空的话就创建对象
                    singleton03 = new Singleton03();
                }
            }
        }
        return singleton03;
    }
}

上面的例子虽然加了同步锁,但是在多线程下并不是线程安全的。instance = new SingletonTest()在实际执行的时候会被拆分为以下三个步骤:

  1. 分配存储SingletonTest对象的内存空间;
  2. 初始化SingletonTest对象;
  3. 将instance指向刚刚分配的内存空间。

通过JMM的学习我们都知道,在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,因为第2步和第3步并没有依赖关系,所以可能发生重排序,排序后的步骤为:

  1. 分配存储SingletonTest对象的内存空间;
  2. 将instance指向刚刚分配的内存空间;
  3. 初始化SingletonTest对象。

经过重排序后,上面的例子在多线程下就会出现问题。假如现在有两个线程A和B同时调用SingletonTest#getInstance,线程A执行到了代码的instance = new SingletonTest(),已经完成了对象内存空间的分配并将instance指向了该内存空间,线程B执行到了if (instance == null) ,发现instance并不是null(因为已经指向了内存空间),所以就直接返回instance了。但是线程A并还没有执行初始化SingletonTest操作,所以实际线程B拿到的SingletonTest实例是空的,那么线程B后续对SingletonTest操控将抛出空指针异常。

要让上面的例子是线程安全的,只需要用volatile修饰单例对象即可:

public class SingletonTest {
    // 私有化构造方法,让外部无法通过new来创建对象
    private SingletonTest() {
    }

    // 单例对象
    private volatile static SingletonTest instance = null;

    // 静态工厂方法
    public static SingletonTest getInstance() {
        if (instance == null) { // 双重检索
            synchronized (SingletonTest.class) { // 同步锁
                instance = new SingletonTest();
            }
        }
        return instance;
    }
}

因为通过volatile修饰的成员变量会添加内存屏障来阻止JVM进行指令重排优化。

3. 原子性 线程不安全性 实战

举个递增的例子:

public class VolatileTest2 {

    private static int value = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> IntStream.range(0, 500).forEach(i -> value++));
        Thread thread2 = new Thread(() -> IntStream.range(0, 500).forEach(i -> value++));

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(value);
    }
}

运行结果并不是所想象的,带又随机性,很可能小于1000;

volatile可以保证修改的值能够马上更新到主内存,其他线程也会捕捉到被修改后的值,那么为什么不能保证原子性呢?

因为在Java中,只有对基本类型的赋值和修改才是原子性的,而对共享变量的修改并不是原子性的。通过JMM内存交互协议我们可以知道,一个线程修改共享变量的值需要经过下面这些步骤:

  1. 线程从主内存中读取(read)共享变量的值,然后载入(load)到线程的工作内存中的变量;
  2. 使用(use)工作内存变量的值,执行加减操作,然后将修改后的值赋值(assign)给工作内存中的变量;
  3. 将工作内存中修改后的变量的值存储(store)到主内存中,并执行写入(write)操作。

所以上面的例子中,可能出现下面这种情况:

thread1和thread2同时获取了value的值,比如为100。thread1执行了+1操作,然后写回主内存,这个时候thread2刚好执行完use操作(+1),准备执行assign(将+1后的值写回工作内存对应的变量中)操作。虽然这时候thread2工作内存中value值的拷贝无效了(因为volatile的特性),但是thread2已经执行完+1操作了,它并不需要再从主内存中获取value的值,所以thread2可以顺利地将+1后的值赋值给工作内存中的变量,然后刷回主存。这就是为什么上面的累加结果可能会小于1000的原因。

要让上面的例子是线程安全的话可以加同步锁,或者使用atomic类。

public class VolatileTest2 {

    private volatile static int value = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> IntStream.range(0, 500).forEach(i -> {
            synchronized (VolatileTest2.class) {
                value++;
            }
        }));
        Thread thread2 = new Thread(() -> IntStream.range(0, 500).forEach(i -> {
            synchronized (VolatileTest.class) {
                value++;
            }
        }));

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(value);
    }
}

执行结果:1000

参考文献:
https://www.cnblogs.com/guanghe/p/9206635.html
https://mrbird.cc/volatile.html

你可能感兴趣的:(【JAVA基础-多线程】- 深入理解volatile关键字)