多线程系列(四) -volatile关键字使用详解

打印 上一主题 下一主题

主题 887|帖子 887|积分 2661

一、简介

在上篇文章中,我们介绍到在多线程环境下,如果编程不当,可能会出现程序运行结果混乱的问题。
出现这个原因主要是,JMM 中主内存和线程工作内存的数据不一致,以及多个线程执行时无序,共同导致的结果。

同时也提到引入synchronized同步锁,可以保证线程同步,让多个线程依次排队执行被synchronized修饰的方法或者方法块,使程序的运行结果与预期一致。
不可否认,采用synchronized同步锁确实可以保证线程安全,但是它对服务性能的消耗也很大,synchronized是一个独占式的同步锁,比如当多个线程尝试获取锁时,其中一个线程获取到锁之后,未获取到锁的线程会不断的尝试获取锁,而不会发生中断,当冲突严重的时候,线程会直接进入阻塞状态,不能再干别的活。
为了实现线程之间更加方便的访问共享变量,Java 编程语言还提供了另一种同步机制:volatile域变量,在某些场景下使用它会更加方便。
一般来说,被volatile修饰的变量,可以保证所有线程看到这个变量都是同一个值,同时它不会引起线程上下文的切换和调度,相比synchronized,volatile更加的轻量化。
比较官方的解释,volatile修饰变量有以下几个作用:

  • 1.保证变量的可见性,不保证原子性
    当用volatile修饰一个变量时,JMM 会把当前线程本地内存中的变量强制刷新到主内存中去,这个写操作也会导致其他线程中被volatile修饰的变量缓存无效,然后从主内存中获取最新的值
  • 2.禁止指令重排
    正常情况下,编译器和处理器为了优化程序执行性能会对指令序列进行重排序,当然是在不影响程序结果的前提下。volatile能够在一定程度上禁止 JVM 进行指令重排。
从概念上感觉比较难理解,下面我们结合几个例子,一起来看看它的具体应用。
二、volatile 使用详解

我们先看一个例子。
  1. public class DataEntity {
  2.     private boolean isRunning = true;
  3.     public void addCount(){
  4.         System.out.println("线程运行开始....");
  5.         while (isRunning){ }
  6.         System.out.println("线程运行结束....");
  7.     }
  8.     public boolean isRunning() {
  9.         return isRunning;
  10.     }
  11.     public void setRunning(boolean running) {
  12.         isRunning = running;
  13.     }
  14. }
复制代码
  1. public class MyThread extends Thread {
  2.     private DataEntity entity;
  3.     public MyThread(DataEntity entity) {
  4.         this.entity = entity;
  5.     }
  6.     @Override
  7.     public void run() {
  8.         entity.addCount();
  9.     }
  10. }
复制代码
  1. public class MyThreadTest {
  2.     public static void main(String[] args) throws InterruptedException {
  3.         // 初始化数据实体
  4.         DataEntity entity = new DataEntity();
  5.         MyThread threadA = new MyThread(entity);
  6.         threadA.start();
  7.         // 主线程阻塞1秒
  8.         Thread.sleep(1000);
  9.         // 将运行状态设置为false
  10.         entity.setRunning(false);
  11.     }
  12. }
复制代码
运行结果如下:

从实际运行结果来看,程序进入死循环状态,虽然最后一行手动设置了entity.setRunning(false),但是没有起到任何的作用。
原因其实也很简单,虽然主线程main将isRunning变量设置为false,但是线程threadA 里面的isRunning变量还是true,两个线程看到的数据不一致。
假如在isRunning变量上,加一个volatile关键字,我们再来看看运行效果。
  1. /**
  2. * 在 isRunning 变量上加一个 volatile 关键字
  3. */
  4. private volatile boolean isRunning = true;
复制代码
运行结果如下:

程序运行后自动结束。
说明当主线程main将isRunning变量设置为false时,线程threadA 里面的isRunning值也随着发生变化。
说明被volatile修饰的变量,在多线程环境下,可以保证所有线程看到这个变量都是同一个值。
三、volatile 不适用的场景

对于某些场景下,volatile可能并不适用,我们还是先看一个例子。
  1. public class DataEntity {
  2.     private volatile int count = 0;
  3.     public void addCount(){
  4.         for (int i = 0; i < 100000; i++) {
  5.             count++;
  6.         }
  7.     }
  8.     public int getCount() {
  9.         return count;
  10.     }
  11. }
复制代码
  1. public class MyThreadTest {
  2.     public static void main(String[] args) throws InterruptedException {
  3.         // 初始化数据实体
  4.         DataEntity entity = new DataEntity();
  5.         // 初始化5个线程计数器
  6.         CountDownLatch latch = new CountDownLatch(5);
  7.         // 采用多线程进行操作
  8.         for (int i = 0; i < 5; i++) {
  9.             new Thread(new Runnable() {
  10.                 @Override
  11.                 public void run() {
  12.                     entity.addCount();
  13.                     //线程运行完毕减1
  14.                     latch.countDown();
  15.                 }
  16.             }).start();
  17.         }
  18.         // 等待以上线程执行完毕,再获取结果
  19.         latch.await();
  20.         System.out.println("result: " + entity.getCount());
  21.     }
  22. }
复制代码
运行结果如下:
  1. 第一次运行:result: 340464
  2. 第二次运行:result: 318342
  3. 第三次运行:result: 305957
复制代码
理论上使用 5 个线程分别执行了100000自增,我们预期的结果应该是5*100000=500000,从实际的运行结果可以看出,与预期不一致。
这是因为volatile的作用其实是有限的,它只能保证多个线程之间看到的共享变量值是最新的,但是无法保证多个线程操作共享变量时依次有序,无法保证原子性操作
上面的例子中count++不是一个原子性操作,在处理器看来,其实一共做了三个步骤的操作:读取数据对数据加 1回写数据,在多线程随机执行情况下,输出结果不能达到预期值。
如果想要实现与预期一致的结果,有以下三种方案可选。
方案一:采用synchronized同步锁
  1. public class DataEntityC2 {
  2.     private int count = 0;
  3.     /**
  4.      * 采用 synchronized 同步锁,可以实现多个线程执行方法时串行
  5.      */
  6.     public synchronized void addCount(){
  7.         for (int i = 0; i < 100000; i++) {
  8.             count++;
  9.         }
  10.     }
  11.     public int getCount() {
  12.         return count;
  13.     }
  14. }
复制代码
方案二:采用Lock锁
  1. public class DataEntityC2 {
  2.     private int count = 0;
  3.     private Lock lock = new ReentrantLock();
  4.     /**
  5.      * 采用 Lock 锁,可以实现多个线程执行方法时串行
  6.      */
  7.     public void addCount(){
  8.         for (int i = 0; i < 100000; i++) {
  9.             lock.lock();
  10.             try {
  11.                 count++;
  12.             } finally {
  13.                 lock.unlock();
  14.             }
  15.         }
  16.     }
  17.     public int getCount() {
  18.         return count;
  19.     }
  20. }
复制代码
方案三:采用JUC包中的原子操作类
  1. public class DataEntity {
  2.     private AtomicInteger inc = new AtomicInteger();
  3.     /**
  4.      * 采用原子操作类,原子操作类是通过CAS循环的方式来保证操作原子性
  5.      */
  6.     public void addCount(){
  7.         for (int i = 0; i < 100000; i++) {
  8.             inc.getAndIncrement();
  9.         }
  10.     }
  11.     public int getCount() {
  12.         return inc.get();
  13.     }
  14. }
复制代码
以上三种方案,都可以实现程序的运行结果与预期一致!
四、volatile 的原理

通过以上的例子介绍,相信大家对volatile关键字的作用有了一些认识。
volatile修饰的变量,可以保证变量在内存中的可见性,但是无法保证原子性操作。
关于原子性、可见性和有序性的定义,这三个特性主要从多线程编程安全角度总结出来的一些基本要素,也是并发编程的三大核心基础,在上篇文章中有所提到过,这里不再重复讲了。
在 JVM 底层,volatile是通过采用“内存屏障”来实现内存可见性和禁止指令重排。观察不加入volatile和加入volatile关键字所生成的汇编代码发现,加入volatile关键字的代码会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,可以提供以下 3 个功能。

  • 1.它确保指令重排序时,不会把后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面,禁止处理器对影响程序执行结果的指令进行重排
  • 2.它会强制将缓存的修改操作立刻写入主存,保证内存变量可见
  • 3.如果是写操作,它会导致其它 CPU 中对应的行缓存无效,目的是让其他线程中被volatile修饰的变量缓存无效,然后从主内存中获取最新的值
五、单例模式中的双重检锁为什么要加 volatile?

在上篇文章中,我们提到过单例设计模式中的双重校验锁实现。
  1. public class Singleton {  
  2.     private volatile static Singleton singleton;  
  3.    
  4.     private Singleton (){}  
  5.    
  6.     public static Singleton getSingleton() {  
  7.         if (singleton == null) {  //第一行
  8.             synchronized (Singleton.class) {  //第二行
  9.                 if (singleton == null) {  //第三行
  10.                     singleton = new Singleton();  //第四行
  11.                 }  
  12.             }  
  13.         }  
  14.         return singleton;  //第五行
  15.     }  
  16. }
复制代码
synchronized可以保证原子性、可见性和有序性,为什么变量singleton还需要加volatile关键字呢?
之所以需要加volatile关键字的原因是:问题出在第一行代码不在同步代码块之类,可能出现这个对象地址不为空,但是内容为空
以初始化一个Singleton singleton = new Singleton();为例,JVM 会分三个步骤完成:
  1. a. memory = allocate() //分配内存
  2. b. ctorInstanc(memory) //初始化对象
  3. c. instance = memory   //设置instance指向刚分配的地址
复制代码
上面的代码在编译运行时可能会出现重排序,因为b和c无逻辑关联,执行的顺序是a -> b -> c或者a -> c -> b,在多线程的环境下可能会出现问题。
分析过程如下:

  • 1.线程 A 执行到第四行代码时,线程 B 进来执行第一行代码
  • 2.假设线程 A 在执行过程中发生了指令重排序,先执行了a和c,没有执行b
  • 3.由于线程 A 执行了c导致instance指向了一段地址,此时线程 B 检查singleton发现不为null,会直接跳转到第五行代码,返回一个未初始化的对象,导致程序会出现报错
  • 4.因此需要在singleton变量上加一个volatile关键字,当线程 A 执行完毕b操作之后,会变量强制刷新到主内存中,此时线程 B 也可以拿到最新的对象
这就是为啥双重检锁模式中,singleton变量为啥要加一个volatile关键字的原因。
采用双重检锁的方式,可以显著的提升并发查询的效率。
六、小结

本篇文章主要围绕volatile关键字的用途、使用方式和一些坑点,做了一个简单的知识总结,内容难免有所遗漏,欢迎网友留言指出!
七、参考

1、老鼠只爱大米 - Java volatile关键字总结

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

盛世宏图

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表