volatile 关键字详解

来源:互联网 发布:淘宝电击棍 编辑:程序博客网 时间:2024/06/04 19:28

volatile 关键字详解

介绍

  今天我们的主题是 volatile 关键字,与 synchronized 关键字一样,它也可用于同步,但它较轻量级,下面将会给大家讲解它的具体原理。这篇文章是在假定大家熟悉 Java 内存模型的设计的基础上来写的,若对 JMM 不熟悉,可以查看我写的这篇文章 Java 内存模型

volatile 关键字

  volatile 关键字与 synchronized 关键字一样,都可用于同步,但前者是较轻量级的同步操作,而后者是较重量级的同步操作。

对于 volatile 关键字,它有两个主要的功能
1. 保证内存可见性
2. 禁止指令重排序

保证内存可见性

  前面在讲解 Java 内存模型时,我们知道数据要先从主内存读取到线程的工作内存中,才能进行访问,若修改则需要将新值保存回主内存。在多线程的环境中,由于读取读出操作发生的不确定性,线程的操作可能不能立即对其他线程可见,即不能保证可见性,因此则需要进行同步来保证内存的可见性。
  前面也说到,Java 提供了 volatile 和 synchronized 这两个关键字给我们用于同步,对于 synchronized 内置锁大家应该都很熟悉,对于它的实现原理之后再整理另外的文章讲解,因为今天的主角是 volatile。

下面将从以下问题来讲解 volatile 保证内存可见性的原理。

volatile 如何保证内存可见性

  前面也说到对于共享变量,存在读取和读出操作,可能读取到工作内存时,共享变量就被修改了,此时工作内存保存的是一个过时的值;在工作内存修改了共享变量,未及时保存到主内存,则主内存的值也是一个过时的值。
  因此,Java 推出了 volatile 关键字,它规定对于 volatile 变量,每次使用时都需要重新从主内存中读取最新的值到工作内存;当线程修改了共享变量时,需要立即保存回主内存,这样可以及时让修改对其他线程可见,保证内存可见性。

使用 volatile 关键字就能解决线程安全问题?
   从 volatile 关键字的定义可知,每次读取都会从主内存获取最新值,修改后立刻保存回主内存,当使用 volatile 变量运算时,看起来好像挺安全,因为每次都能保证最新的值,但是其实不然。

看如下例子

public class VolatileTest {    volatile int value = 0;    public static void main(String[] args) {        VolatileTest test = new VolatileTest();        Thread[] threads = new Thread[20];        for(int i = 0; i < 20; i++) {            threads[i] = new Thread(new Runnable() {                @Override                public void run() {                    for(int i = 0; i < 1000; i++) {                        test.value++;                    }                }            });            threads[i].start();        }        while(Thread.activeCount() > 1) {            Thread.currentThread().yield();        }        System.out.println(test.value);    }}

  在上述例子中,开启了 20 个线程执行 value++,但是最终输出的结果并不一定是 20000,也可能会比它小。从上面可以看到 value 是使用 volatile 关键字定义的,但仍然会出现数据不一致的问题。
  造成这样的原因,主要由于大多数运算并不是原子操作,比如一个 ++ 运算,可能就由三条指令组成:“把value值复制进寄存器,执行加 1 操作,再将寄存器的值赋给value”,因此在执行指令的间隙中,仍可能会切换到其他线程执行,切换回来后,继续执行后续指令,导致使用的还是旧值。因此,要记住 volatile 关键字不能保证并发下一定是安全的。

禁止指令重排序

  Java 内存模型允许 CPU 将多条指令不按程序规定的顺序分开发送给相应的电路单元,只需要保证指令处理后程序能够得出正确的执行结果,即保证程序串行化,这在一定程度提高了程序的运行速率。
  但其存在局限性,它仅仅保证本地线程看起来是有序的,但对其他线程来说是无序的,因此当线程 B 的执行过程需要依赖线程 A 执行顺序,则会导致不可意料的结果。

看如下例子:

public class VolatileTest {    int x = 0, y = 0;    int a = 0, b = 0;    public static void main(String[] args) throws InterruptedException {        while (true) {            VolatileTest test = new VolatileTest();            Thread one = new Thread(new Runnable() {                public void run() {                    test.a = 1;                    test.x = test.b;                }            });            Thread other = new Thread(new Runnable() {                public void run() {                    test.b = 1;                    test.y = test.a;                }            });            one.start();            other.start();            one.join();            other.join();            System.out.println("(" + test.x + " ," + test.y + ")");            if (test.x == 0 && test.y == 0)                break;        }    } }

  从上面例子,通过分析线程交替调度行为,我们不难看出输出结果可能为 (1 , 0)、(0 , 1) 和 (1 , 1),但实际上输出结果还可能为 (0 , 0)。从程序的次序来分析,不应该会存在 (0 , 0) 这种情况,因为至少有一个赋值语句会执行。
  但是由于指令重排序优化,y = a 和 x = b 可能会被优化到赋值语句前执行,因此则会出现 (0 , 0) 这种情况了。因此为了避免由于指令重排序而带来的错误问题,对于volatile 变量的读取访问,Java 内存模型会通过插入一个内存屏障来禁止指令重排序。

使用场景

  文章讲到现在,对于 volatile 变量规则已经大致介绍完,现在剩下最后一个问题:我们应该什么时候使用 volatile 变量?

定义状态变量

  前面已经说过 volatile 是 JMM 提供的一种轻量级的同步操作,它的第一个作用是能够保证内存可见性,它适用于定义用布尔类型存放的状态变量,不适用于要依赖变量的当前值进行操作的情况。

看如下例子:

class ObjectPool {    public volatile boolean start = false;    public volatile boolean stop = false;    public void run() {        while(!stop) {            if(!start) {                initPool();            }            // 执行任务        }        destoryPool();    }}

  可应用于对象池的状态变量,可根据状态的当前值判断对象池的状态从而进行对应的操作,上述例子只是随便写写,要实现一个完整的对象池并没有那么粗陋。

解决 DCL 问题

  相信使用过单例模式的朋友都应该知道在 JDK 1.5 前,使用 DCL 方式创建单例对象还是会存在引用问题,我们先来看一个 DCL 的单例模式的例子。

public class Singleton {    private static Singleton singleton;    private Singleton(){}    public static Singleton getInstance(){        if(singleton == null){                            // 1            synchronized (Singleton.class){               // 2                if(singleton == null){                    // 3                    singleton = new Singleton();          // 4                }            }        }        return singleton;                                 // 5    }}

  在该段代码中,因为当第一个用户调用 getInstance 时,先执行代码 1,此时 singleton 为 null,则会进入到代码 2,此时通过内置锁锁住了 Singleton 的类对象,这样其他线程就算通过了代码 1,也会阻塞在了代码 2 处,当从代码 2 处解除阻塞状态,此时单例对象已经构造好,因此会返回一个正确的单例对象。但这段看似正确的程序,居然偶尔会出现问题,那么到底什么情况下会出现问题呢?

我们先来回忆一下创建一个对象的过程:

1. 为对象分配存储空间2. 调用对象的初始化方法3. 将对象的地址赋值给对应的引用

  如果按照这个顺序来执行,程序是没有问题的,但是由于存在指令重排序,动作 3 可能会比动作 2 先执行。
  因此当第一个用户调用 getInstance 时,一直运行到代码 4,此时当当前线程先执行了 1,3,那么当前 singleton 对象不为 null,但此对象是还未进行初始化的;若在这个时刻,第二个用户来了,它执行代码 1 时,发现当前 singleton 不为 null,因此返回了这个还未初始化的 singleton 对象,因此后续的运行会使程序出现问题。
注:synchronized 只会阻塞尝试获取同一个内置锁的线程,因为代码 1 到代码 5 并没有加锁,因此该过程不会被阻塞

  因此,为了解决 DCL 问题,可以使用 volatile 关键字定义 singleton 对象从而禁止指令重排序,但 volatile 变量的存取访问会有一定的性能下降,因此单例模式的解决也可以使用 IoDH 的方式来解决,具体可查看我的这篇博文static 关键字,里面有介绍如何使用静态内部类完美实现单例模式。

总结

  对于 volatile 关键字的讲解就到这里结束了,本文主要通过从 volatile 变量的访问规则和作用来说明 volatile 的原理,它有两个最主要的作用:保证内存可见性和禁止指令重排序。
  另外根据 volatile 的特点,它主要在一下场景使用:为了保证内存可见性,可定义 volatile 类型的状态变量进行状态 ,禁止指令重排序也可用于解决单例模式中 DCL 引用出错问题等等。

1 0
原创粉丝点击