浅析 Java volatile 变量

1.Java内存模型(Java Memory Model)

      Java内存模型(JMM),不同于Java运行时数据区,JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取数据这样的底层细节。JMM规定了所有的变量都存储在主内存中,但每个线程还有自己的工作内存(CPU内存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,工作内存是线程之间独立的,线程之间变量值的传递均需要通过主内存来完成。

2. volatile关键字

    volatile 是 Java 中的一个关键字。

    当一个变量被定义为volatile之后,就可以保证此变量对所有线程的可见性,即当一个线程修改了此变量的值的时候,变量新的值对于其他线程来说是可以立即得知的。可以理解成:对volatile变量所有的写操作都能立刻被其他线程得知。但是这并不代表基于volatile变量的运算在并发下是安全的,因为volatile只能保证内存可见性,却没有保证对变量操作的原子性。

2.1 强制线程直接从内存中读写线程

    volatile 关键字的典型使用场景是在多线程环境下,多个线程共享变量,由于这些变量会缓存在 CPU 的缓存中,为了避免出现内存一致性错误而采用 volatile 关键字。考虑下面这个生产者/消费者的例子,我们每次生成/消费一个元素:

public class ProducerConsumer {

        private int value = 0;
        private boolean hasValue = false;

        //生产者
        public void produce(int value) {
            while (hasValue) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("Producing " + value + " as the next consumable");
            this.value = value;
            hasValue = true;
        }

        //消费者
        public int consume() {
            while (!hasValue) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            int value = this.value;
            hasValue = false;
            System.out.println("Consumed " + value);
            return value;
        }
    }

     在上面的类中,produce 方法通过存储参数来生成一个新的值,然后将 hasValue 设置为 true。while 循环检测标识变量(hasValue)是否 true,true 表示一个新的值没有被消费,要求当前线程睡眠(sleep),该睡眠一直循环直到标识变量 hasValue 变为 false,只有在新的值被 consume 方法消费完成后才能变为 false。如果没有有效的新值,consume 方法要求当前睡眠,当一个 produce 方法生成一个新值时,睡眠循环终止,并改变标识变量的值。

     现在想象有两个线程在使用这个类的对象,一个生成值(写线程),另个一个消费值(读线程)。通过下面的测试来解释这种方式:

public class ProducerConsumerTest {
        
        public void testProduceConsume() throws InterruptedException {
            ProducerConsumer producerConsumer = new ProducerConsumer();

            //生产线程
            Thread productThread = new Thread(new Runnable() {
                @Override public void run() {
                    for(int i = 0; i < 10; i++){
                        producerConsumer.produce(i);
                    }
                }
            });

            //消费线程
            Thread consumeThread = new Thread(new Runnable() {
                @Override public void run() {
                    for(int i = 0; i < 10; i++){
                        producerConsumer.consume();
                    }
                }
            });

            productThread.start();
            consumeThread.start();
            productThread.join();
            consumeThread.join();
        }
    }

      这个例子大部分时候都能输出期望的结果,但是也有概率会出现死锁!

     现在,假设在我们的测试中有两个线程运行在不同的 CPU 上,并且其中的有一个缓存了标识变量(或者两个都缓存了)。现在考虑如下的执行顺序:

     (1) 写线程生成一个值,并将 hasValue 设置为 true。但是只更新缓存中的值,而不是主内存。

     (2) 读线程尝试消费一个值,但是它的缓存副本中 hasValue 被设置为 false,所以即使写线程生产了一个新的值,也不能被消费,因为读线程无法跳出睡眠循环(hasValue 的值为 false)。

     (3) 因为读线程不能消费新生成的值,所以写线程也不能继续,因为标识变量没有设置回 false,因此写线程阻塞在睡眠循环中。

     (4) 这样,就产生了死锁!

     这种情况只有在 hasValue 同步到所有缓存才能改变,这完全依赖于底层的操作系统。那怎么解决这个问题? volatile 怎么会适合这个例子?如果我们将 hasValue 标示为 volatile,我就能确定这种死锁就不会再发生。

private volatile boolean hasValue = false;

      volatile 变量强制线程每次读取的时候都直接从主内存中读取,同时,每次写 volatile 变量的时候也要立即刷新主内存中的值。如果线程决定缓存变量,就需要每次读写的时候都与主内存进行同步。做这个改变之后,我们再来考虑前面导致死锁的执行步骤:

    (1) 写线程生成一个值,并将 hasValue 设置为 true,这次直接更新主内存中的值(即使这个变量被缓存了)。

    (2) 读线程尝试消费一个值,先检查 hasValue 的值,每次读取都强制直接从主内存中获取值,所以能获取到写线程改变后的值。

    (3) 读线程消费完生成的值后,重新设置标识变量的值,这个新的值也会同步到主内存(如果这个值被缓存了,缓存的副本也会更新)。

    (4) 写线程获每次都是从主内存中取这个改变了的值,这样就能继续生成新的值。

2.2  建立 happens-before 关系

     happens-before 关系是程序语句之间的排序保证,这能确保任何内存的写,对其他语句都是可见的。当写一个 volatile 变量时,随后对该变量读时会创建一个 happens-before 关系。所以,所有在 volatile 变量写操作之前完成的写操作,将会对随后该 volatile 变量读操作之后的所有语句可见。看下面这个例子:

public class TestHappensBefore {

    // 变量定义
    private static int first = 1;
    private static int second = 2;
    private static int third = 3;
    private static  boolean hasValue = false;

    public static void main(String[] args) {

        //线程 1 顺序的写操作
        Thread thread1 = new Thread(new Runnable() {
            @Override public void run() {    
                first = 5;
                second = 6;
                third = 7;
                hasValue = true;
            }
        });

        //线程 2 顺序的读操作
        Thread thread2 = new Thread(new Runnable() {
            @Override public void run() {   
                System.out.println("Flag is set to : " + hasValue);
                System.out.println("First: " + first);  // will print 5 打印 5
                System.out.println("Second: " + second); // will print 6 打印 6
                System.out.println("Third: " + third);  // will print 7 打印 7
            }
        });

        thread1.start();
        thread2.start();
    }
}

      当线程 1改变 hasValue 的值时,它不仅仅是刷新这个改变的值到主存,也会引起前面三个值的写(之前任何的写操作)刷新到主存。结果,当线程 2访问这三个变量的时候,就可以访问到被线程 1 写入的值,即使这些变量之前被缓存(这些缓存的副本都会被更新)。这就是为什么我们不需要像第一个示例一样将变量标示为 volatile 。因为我们的写操作在访问 hasValue 之前,读操作在 hasValue 的读之后,它会自动与主内存同步。

2.3 消除编译器的优化重排

     JVM 因它的程序优化机制而闻名。有时对程序语句的重排序可以大幅度提高性能,并且不会改变程序的输出结果。使用volatile变量可以禁止JIT编译器进行指令重排序优化,这里使用单例模式来举个例子:

public class Singleton_1 {

    private static Singleton_1 instance = null;
    private Singleton_1() {
    }
    public static Singleton_1 getInstacne() {
        /*
         * 这种实现进行了两次instance==null的判断,这便是单例模式的双检锁。
         * 第一次检查是说如果对象实例已经被创建了,则直接返回,不需要再进入同步代码。
         * 否则就开始同步线程,进入临界区后,进行的第二次检查是说:
         * 如果被同步的线程有一个创建了对象实例, 其它的线程就不必再创建实例了。
         */
        if (instance == null) {
            synchronized (Singleton_1.class) {
                if (instance == null) {
                    /*
                     * 仍然存在的问题:下面这句代码并不是一个原子操作,JVM在执行这行代码时,会分解成如下的操作:
                     * 1.给instance分配内存,在栈中分配并初始化为null
                     * 2.调用Singleton_1的构造函数,生成对象实例,在堆中分配 
                     * 3.把instance指向在堆中分配的对象
                     * 由于指令重排序优化,执行顺序可能会变成1,3,2,
                     * 那么当一个线程执行完1,3之后,被另一个线程抢占,
                     * 这时instance已经不是null了,就会直接返回。
                     * 然而2还没有执行过,也就是说这个对象实例还没有初始化过。
                     */
                    instance = new Singleton_1();
                }
            }
        }
        return instance;
    }

    public class Singleton_2 {

        /*
         * 为了避免JIT编译器对代码的指令重排序优化,可以使用volatile关键字,
         * 通过这个关键字还可以使该变量不会在多个线程中存在副本,
         * 变量可以看作是直接从主内存中读取,相当于实现了一个轻量级的锁。
         */
        private volatile static Singleton_2 instance = null;
        private Singleton_2() {
        }

        public static Singleton_2 getInstacne() {
            if (instance == null) {
                synchronized (Singleton_2.class) {
                    if (instance == null) {
                        instance = new Singleton_2();
                    }
                }
            }
            return instance;
        }
    }

  变量在有了volatile修饰之后,对变量的修改会有一个内存屏障的保护,使得后面的指令不能被重排序到内存屏障之前的位置。volalite变量的读性能与普通变量类似,但是写性能要低一些,因为它需要插入内存屏障指令来保证处理器不会发生乱序执行。即便如此,大多数场景下volatile的总开销仍然要比锁低,所以volatile的语义能满足需求时候,选择volatile要优于使用锁。

2.4 不能保证操作原子性

  当多个线程读写同一个变量时,仅仅靠 volatile 是不足以保证一致性的,考虑下面这个 UnsafeCounter 类以及测试例子:

public class UnsafeCounter {

    private volatile int counter;
    public void inc() {
        counter++;
    }
    public void dec() {
        counter--;
    }
    public int get() {
        return counter;
    }
}

public class TestUnsafeCounter {

    public void testUnsafeCounter() throws InterruptedException {
        UnsafeCounter unsafeCounter = new UnsafeCounter();

        //线程1-级数增加
        Thread thread1 = new Thread(new Runnable() {
            @Override public void run() {
                for(int i = 0; i < 100; i++){
                    unsafeCounter.inc();
                }
            }
        });

        //线程2-计数减少
        Thread thread2 = new Thread(new Runnable() {
            @Override public void run() {
                for(int i = 0; i < 100; i++){
                    unsafeCounter.dec();
                }
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println( unsafeCounter.get());
    }
}

  这段代码具有非常好的自说明性。一个线程增加计数器,另一个线程将计数器减少同样次数。运行这个测试,期望的结果是计数器的值为 0,但这无法得到保证。大部分时候是 0,但有的时候是 -1, -2, 1, 2 等,任何位于[-5, 5]之间的整数都有可能。

  为什么会发生这种情况?这是因为对计数器的递增和递减操作都不是原子的——它们不是一次完成的。这两种操作都由多个步骤组成,这些步骤可能相互交叉。递增操作如下:①读取计数器的值; ②加 1;③将新的值写回计数器。同理,递减操作的过程如下:①读取计数器的值;② 减 1;③将新的值写回计数器。

  现在我们考虑一下如下的执行步骤,来解释期望结果不为0的原因。

  (1) 第一个线程从主存中读取计数器的值,初始值是 0,然后加 1。
  (2) 第二个线程也从主存中读取计数器的值,它读取到的值也是 0,然后进行减 1 操作。
  (3) 第一线程将新的计数器的值写回内存,将值设置为 1。
  (4) 第二个线程也将新的值写回内存,将值设置为 -1。

  怎么防止这类事件的发生?常用的又两种办法,第一种是用同步(synchronized) 的方式,第二种使用原子操作(AtomicInteger),代码如下:

// 同步方式
public class SynchronizedCounter {
  private int counter;
  public synchronized void inc() {
    counter++;
  }
  public synchronized void dec() {
    counter--;
  }
  public synchronized int get() {
    return counter;
  }
}

//原子操作
public class AtomicCounter {
  private AtomicInteger atomicInteger = new AtomicInteger();
  public void inc() {
    atomicInteger.incrementAndGet();
  }
  public void dec() {
    atomicInteger.decrementAndGet();
  }
  public int get() {
    return atomicInteger.intValue();
  }
}

 3.volatile vs synchronized

  (1) volatile本质是在告诉jvm当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住.

  (2) volatile仅能使用在变量级别,synchronized则可以使用在变量,方法.

  (3) volatile仅能实现变量的修改可见性,但不具备原子特性,而synchronized则可以保证变量的修改可见性和原子性.

    (4) volatile不会造成线程的阻塞,而synchronized可能会造成线程的阻塞.

    (5) volatile标记的变量不会被编译器优化,而synchronized标记的变量可以被编译器优化.

  volatile本质是在告诉JVM当前变量在寄存器中的值是不确定的,需要从主存中读取。可以实现synchronized的部分效果,但当n=n+1,n++等时,volatile关键字将失效,不能起到像synchronized一样的线程同步的效果。

参考:

1.http://www.importnew.com/17149.html#rd?sukey=fc78a68049a14bb205497ca0859de837a4d3f0bdc5b2f96237ade994697c2a3c7cf1db7a12bbd88710c8a50e5b561286

2.http://www.importnew.com/16127.html

3.http://www.cnblogs.com/aigongsi/archive/2012/04/01/2429166.html

4.http://www.blogjava.net/hello-yun/archive/2012/12/01/392334.html

转载于:https://www.cnblogs.com/yql8/p/5022637.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值