秒懂Java并发之volatile关键字引发的思考

导读:本篇文章讲解 秒懂Java并发之volatile关键字引发的思考,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

版权申明】非商业目的注明出处可自由转载
博文地址:https://blog.csdn.net/ShuSheng0007/article/details/85165773
出自:shusheng007

概述

写这个关键字的文章已经很多了,我是在重温《深入理解Java虚拟机》时觉的应该记录一下。首先我们应该明确在Java中 volatile 关键字涉及到的领域,换句话说,大家在谈论什么问题的时候会涉及到这个关键字。相信首次接触这个关键字的同学内心是崩溃的,首先可以肯定的是大部分中国人不认识这个单词,那么连望文生义的机会都没有了,我就是其中一员。其实当我们在谈论Java并发编程的时候,就会想到这哥们儿,在此希望可以以通俗的语言描述一下 volatile 身边的故事。

并发(Concurrency )

计算机发展到今天的地步,并发早已司空见惯,但是很多程序员对其的理解是极其浅薄和有限的,我曾经不止一次的问过面试者,我们为什么需要并发编程,多线程可以带给我们的好处,很少有令人满意的答复。
我们需要并发的场景非常多:
第一种情况:尽力压榨CPU的运算能力
第二种情况:对于GUI程序并发可以提高UI的响应度,提升用户体验
第三种情况:服务端同时对多个客户端提供服务
等等
并发系统其实非常复杂,只是离普通程序员比较远,目前关于并发系统的理论研究主要有两大定律比较受学术界认可:阿姆达尔定律 与古斯塔夫森定律。

硬件物理架构

要讲volatile 关键字就不得不提Java内存模型,由于Java的内存模型与计算机的物理架构有很好的类比性,所以我们有必要先简单介绍一下计算机的硬件架构。为了可以充分利用CPU的性能了,需要让计算机并发执行多个任务,但是这些任务不可能只通过CPU就能完成,期间必然伴随着读写主内存的步骤。然而CPU的运算速度与内存的读写速度是差着几个数量级的,所以对于CPU来说,读写内存简直是慢的不能忍,所以我们就在CPU与主内存之间加入了高速缓存,现代的PC机一般都是三级缓存。
在这里插入图片描述
图片来源

引入高速缓存是缓解了CPU与内存速度差问题,但是也引入了缓存一致性问题(Cache Coherence)。因为每个CPU都有自己的高速缓存,而他们又共用同一主内存,所以当各个CPU的高速缓存数据不一致的时候,同步回主内存时使用谁的值呢?所以这是需要一套每个CPU在访问缓存时候都遵循的一套协议的。

以上的缓存问题对应到Java虚拟机上就是Java内存模型要解决的问题。

此外,为了执行效率,CPU还会对代码乱序执行(Out-Of-Order Execution),这对应到Java虚拟机上为指令重排(Instruction Reorder)

Java内存模型

Java内存模型(Java Memory Model)是Java虚拟机所定义的一套抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果,其主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

在java虚拟机中,每个线程都一份自己的工作内存,存储着被该线程使用到的变量的主内存副本份拷贝。所有的线程只能操作自己工作内存中的数据,然后同步到主内存中,不能直接读写主内存,。不同线程之间也不能直接访问对方的工作内存,线程之间变量的数据共享必须通过主内存,线程、主内存与工作内存三者交互关系见下图。
在这里插入图片描述

正是存在这样的内存模型,所以多线程并发就会存在原子性,可见性,有序性的问题。

Volatile

关键字Volatile是Java虚拟机提供的最轻量级的同步机制。Volatile 修饰的变量在并发编程中有两个作用

  1. 可见性
    使用volatile修饰的变量V,其值被线程A改变后,线程B立刻可以读取到最新值 ,普通变量是无法保证这一点的。那么volatile 是如何保证这一点的呢?
    因为线程A每次修改在工作内存中的变量V的值后都必须立刻将其同步到主内存中,而线程B每次从自己的工作内存中读取变量V的值的时候必须先从主内存刷新最新的值。
  2. 禁止指令重排优化
    Java虚拟机为了提升执行效率,会执行编译期指令重排优化。编译器只会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作顺序与程序代码中的执行顺序一致。这在单线程中是毫无问题的,但是多线程中就会发生混乱。如果从线程A内看自己的内部执行顺序永远是顺序的,但是从线程A看线程B的执行顺序就永远是乱序的。

使用场景

利用可见性特性的例子:

  • 运算结果并不依赖变量的当前值,或者可以保证只有单一线程修改变量的值,可以有多个线程读取变量的值。
public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
  • 变量不需要与其他的状态变量共同参与不变约束

例如下面的场景,可以多个线程访问调用shutdown(),均可以保证doWork立即停止工作。如果变量shutdownRequested不使用volatile 关键字修饰,则在并发访问shutdown()时不能保证doWork立即停止工作。

volatile boolean shutdownRequested;
//volatile boolean shutdownRequested2;
...
public void shutdown() { 
      shutdownRequested = true;
   }

public void doWork() { 
   while (!shutdownRequested) { 
       // do stuff
   }
}

利用阻止指令重排的例子

一个比较突出的例子就是在JDK1.5版本之后使用volatile 实现Java版的双锁检查单例,由于指令重排优化的存在,在JDK 1.5之前在Java中是无法实现线程安全的双锁检查单例模式的。

class Singleton{
    private volatile static Singleton instance = null;     
    private Singleton() {         
    }     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
 }

其实,在JDK1.5之后,即使可以使用双锁检查来实现单例我们也不应该优先使用,我们应该优先使用枚举来实现单例模式,见如下代码。

public enum Singleton{
    INSTANCE;
}

在此我想对为什么要使用双锁检查做一个说明,因为我以前完全不明白这样做的目的,稍微有点跑题,不感兴趣的就可以略过这一段了。

非线程安全单例创建模式

1.代码清单1

class Singleton{
	  private static Singleton instance;
	  private Singleton()  {
	  }	
	  public static Singleton getInstance()  {
	    if (instance == null)                       //1
	           instance = new Singleton();   //2
	    return instance;                            //3
	  }
}

为什么说它是非线程安全的呢?假设现在有两个线程1,2按照下面的步骤来构建此类的实例。

1线程 1 调用 getInstance() 方法,此时 instance 在 //1 处为 null。
2线程 1 进入 if 代码块,但在执行 //2 处的代码行时被线程2抢占 。
3线程 2 调用 getInstance() 方法,此时instance 在 //1 处仍然为 null。
4线程 2 进入 if 代码块并创建一个新的 Singleton 对象并在 //2 处将变量 instance 分配给这个新对象。
5线程 2 在 //3 处返回 Singleton 对象引用。
6线程 1 在它停止的地方再次启动,并执行 //2 代码行,这导致创建另一个 Singleton 对象。
7线程 1 在 //3 处返回另一个Singleton 对象引用。
这样两个线程各创建了一个实例对象,破坏了单例模式

2.代码清单2

public static synchronized Singleton getInstance(){
  if (instance == null)                   //1
      instance = new Singleton();  //2
  return instance;                        //3
}

为了解决并发问题,我们需要对getInstance()方法进行同步,这样是安全的,但是有一个弊端。我们每次调用这个方法都会有同步开销,而事实上我们只有在第一次调用这个方法时候才需要同步,以后就都不需要同步了,需要同步的代码仅仅是执行实例化的那句instance = new Singleton();,因而代码可优化为下面的清单3。

3.代码清单3

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

但是清单3就会遇到和清单1一样的问题了,多线程并发时可能产生多个实例,于是双锁检查就出现了

4.代码清单4

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

双锁检查在理论上是完美的,但是就是因为指令重排优化会导致其失败,至于为什么失败需要分析汇编代码,有兴趣的同学可以查找相关资料自己实践。

总结

教授Volatile关键字的文章已经很多了,我不想再继续重复下去。其实理解volatile 应该首先立即Java的内存模型,一旦理解了Java的内存模型,volatile就是小菜一碟了。正所谓厚积才能薄发,我们应该注重原理性的知识积淀。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/14765.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!