上一篇文章说了 CAS 原理,其中说到了 Atomic* 类,他们实现原子操作的机制就依靠了 volatile 的内存可见性特性。如果还不了解 CAS 和 Atomic*,建议看一下我们说的 CAS 自旋锁是什么

并发的三个特性

首先说我们如果要使用 volatile 了,那肯定是在多线程并发的环境下。我们常说的并发场景下有三个重要特性:原子性、可见性、有序性。只有在满足了这三个特性,才能保证并发程序正确执行,否则就会出现各种各样的问题。

原子性,上篇文章说到的 CAS 和 Atomic* 类,可以保证简单操作的原子性,对于一些负责的操作,可以使用synchronized 或各种锁来实现。

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

有序性,程序执行的顺序按照代码的先后顺序执行,禁止进行指令重排序。看似理所当然的事情,其实并不是这样,指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。

而 volatile 做实现了两个特性,可见性和有序性。所以说在多线程环境中,需要保证这两个特性的功能,可以使用 volatile 关键字。

volatile 是如何保证可见性的

说到可见性,就要了解一下计算机的处理器和主存了。因为多线程,不管有多少个线程,最后还是要在计算机处理器中进行的,现在的计算机基本都是多核的,甚至有的机器是多处理器的。我们看一下多处理器的结构图:

这是两个处理器,四核的 CPU。一个处理器对应一个物理插槽,多处理器间通过QPI总线相连。一个处理器包含多个核,一个处理器间的多核共享L3 Cache。一个核包含寄存器、L1 Cache、L2 Cache。

在程序执行的过程中,一定要涉及到数据的读和写。而我们都知道,虽然内存的访问速度已经很快了,但是比起CPU执行指令的速度来,还是差的很远的,因此,在内核中,增加了L1、L2、L3 三级缓存,这样一来,当程序运行的时候,先将所需要的数据从主存复制一份到所在核的缓存中,运算完成后,再写入主存中。下图是 CPU 访问数据的示意图,由寄存器到高速缓存再到主存甚至硬盘的速度是越来越慢的。

了解了 CPU 结构之后,我们来看一下程序执行的具体过程,拿一个简单的自增操作举例。

i=i+1;

执行这条语句的时候,在某个核上运行的某线程将 i 的值拷贝一个副本到此核所在的缓存中,当运算执行完成后,再回写到主存中去。如果是多线程环境下,每一个线程都会在所运行的核上的高速缓存区有一个对应的工作内存,也就是每一个线程都有自己的私有工作缓存区,用来存放运算需要的副本数据。那么,我们再来看这个 i+1 的问题,假设 i 的初始值为0,有两个线程同时执行这条语句,每个线程执行都需要三个步骤:

1、从主存读取 i 值到线程工作内存,也就是对应的内核高速缓存区;

2、计算 i+1 的值;

3、将结果值写回主存中;

建设两个线程各执行 10,000 次后,我们预期的值应该是 20,000 才对,可惜很遗憾,i 的值总是小于 20,000 的 。导致这个问题的其中一个原因就是缓存一致性问题,对于这个例子来说,一旦某个线程的缓存副本做了修改,其他线程的缓存副本应该立即失效才对。

而使用了 volatile 关键字后,会有如下效果:

1、每次对变量的修改,都会引起处理器缓存(工作内存)写回到主存;

2、一个工作内存回写到主存会导致其他线程的处理器缓存(工作内存)无效。

因为 volatile 保证内存可见性,其实是用到了 CPU 保证缓存一致性的 MESI 协议。MESI 协议内容较多,这里就不做说明,请各位同学自己去查询一下吧。总之用了 volatile 关键字,当某线程对 volatile 变量的修改会立即回写到主存中,并且导致其他线程的缓存行失效,强制其他线程再使用变量时,需要从主存中读取。

那么我们把上面的 i 变量用 volatile 修饰后,再次执行,每个线程执行 10,000 次。很遗憾,还是小于 20,000 的。这是为什么呢?

volatile 利用 CPU 的 MESI 协议确实保证了可见性。但是,注意了,volatile 并没有保证操作的原子性,因为这个自增操作是分三步的,假设线程 1 从主存中读取了 i 值,假设是 10 ,并且此时发生了阻塞,但是还没有对i进行修改,此时线程 2 也从主存中读取了 i 值,这时这两个线程读取的 i 值是一样的,都是 10 ,然后线程 2 对 i 进行了加 1 操作,并立即写回主存中。此时,根据 MESI 协议,线程 1 的工作内存对应的缓存行会被置为无效状态,没错。但是,请注意,线程 1 早已经将 i 值从主存中拷贝过了,现在只要执行加 1 操作和写回主存的操作了。而这两个线程都是在 10 的基础上加 1 ,然后又写回主存中,所以最后主存的值只是 11 ,而不是预期的 12 。

所以说,使用 volatile 可以保证内存可见性,但无法保证原子性,如果还需要原子性,可以参考,之前的这篇文章。

volatile 是如何保证有序性的

Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

如下是 happens-before 的8条原则,摘自 《深入理解Java虚拟机》。

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作;
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始;

这里主要说一下 volatile 关键字的规则,举一个著名的单例模式中的双重检查的例子:

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

如果 instance 不用 volatile 修饰,可能产生什么结果呢,假设有两个线程在调用 getInstance() 方法,线程 1 执行步骤 step1 ,发现 instance 为 null ,然后同步锁住 Singleton 类,接着再次判断 instance 是否为 null ,发现仍然是 null,然后执行 step 3 ,开始实例化 Singleton 。而在实例化的过程中,线程 2 走到 step 1,有可能发现 instance 不为空,但是此时 instance 有可能还没有完全初始化。

什么意思呢,对象在初始化的时候分三个步骤,用下面的伪代码表示:

memory = allocate();  //1. 分配对象的内存空间
ctorInstance(memory); //2. 初始化对象
instance = memory; //3. 设置 instance 指向对象的内存空间

因为步骤 2 和步骤 3 需要依赖步骤 1,而步骤 2 和 步骤 3 并没有依赖关系,所以这两条语句有可能会发生指令重排,也就是或有可能步骤 3 在步骤 2 的之前执行。在这种情况下,步骤 3 执行了,但是步骤 2 还没有执行,也就是说 instance 实例还没有初始化完毕,正好,在此刻,线程 2 判断 instance 不为 null,所以就直接返回了 instance 实例,但是,这个时候 instance 其实是一个不完全的对象,所以,在使用的时候就会出现问题。

而使用 volatile 关键字,也就是使用了 “对一个 volatile修饰的变量的写,happens-before于任意后续对该变量的读” 这一原则,对应到上面的初始化过程,步骤2 和 3 都是对 instance 的写,所以一定发生于后面对 instance 的读,也就是不会出现返回不完全初始化的 instance 这种可能。

JVM 底层是通过一个叫做“内存屏障”的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。

最后

通过 volatile 关键字,我们了解了一下并发编程中的可见性和有序性,当然只是简单的了解。更深入的了解,还得靠各位同学自己去钻研。如果感觉还是有点作用的话,欢迎点个推荐。

相关文章

我们说的 CAS 自旋锁是什么

欢迎加入 Java 交流群,更欢迎关注微信公众号

Java 开发, volatile 你必须了解一下的更多相关文章

  1. Java开发手册

    <Java开发手册> 基本信息 作者: 桂颖    谷涛    出版社:电子工业出版社 ISBN:9787121209161 上架时间:2013-8-12 出版日期:2013 年7月 开本 ...

  2. Java中Volatile的作用

    Java中Volatile的作用 看了几篇博客,发现没搞懂.可是简单来说,就是在我们的多线程开发中.我们用Volatile关键字来限定某个变量或者属性时,线程在每次使用变量的时候.都会读取变量改动后的 ...

  3. 阿里巴巴Java开发手册快速学习

    Java作为一门名副其实的工业级语言,语法友好,学习简单,大规模的应用给代码质量的管控带来了困难,特别是团队开发中,开发过程中的规范会直接影响最终项目的稳定性. 善医者“未有形而除之”,提高工程健壮性 ...

  4. 《阿里巴巴Java开发手册(正式版》读记

    前几天,阿里巴巴发布了<阿里巴巴Java开发手册(正式版>,第一时间下载阅读了一番. 不同于一般大厂内部的代码规范,阿里巴巴的这本Java开发手册,可谓包罗万象,几乎日常Java开发中方方 ...

  5. 知名互联网公司校招 Java 开发岗面试知识点解析

    天之道,损有余而补不足,是故虚胜实,不足胜有余. 本文作者在一年之内参加过多场面试,应聘岗位均为 Java 开发方向.在不断的面试中,分类总结了 Java 开发岗位面试中的一些知识点. 主要包括以下几 ...

  6. Java开发岗面试知识点解析

    本文作者参加过多场面试,应聘岗位均为 Java 开发方向.在不断的面试中,分类总结了 Java 开发岗位面试中的一些知识点. 主要包括以下几个部分: Java 基础知识点 Java 常见集合 高并发编 ...

  7. Java开发知识之Java编程基础

    Java开发知识之Java编程基础 一丶Java的基础语法 每个语言都有自己的语法规范.例如C++ 入口点是main. 我们按照特定格式编写即可. Java也不例外. Java程序的语法规范就是 Ja ...

  8. 阿里巴巴Java开发规范手册

      Java开发手册 版本号 制定团队 更新日期 备  注 1.0.0 阿里巴巴集团技术部 2016.12.7 首次向Java业界公开 一.编程规约 (一) 命名规约 1.   [强制]所有编程相关命 ...

  9. 各大互联网公司java开发面试常问问题

    本人是做java开发的,这是我参加58,搜狐,搜狗,新浪微博,百度,腾讯文学,网易以及其他一些小的创业型公司的面试常被问的问题,当然有重复,弄清楚这些,相信面试会轻松许多. 1. junit用法,be ...

  10. java开发-问题清单

    本人是做Java开发的,这是我参加58,搜狐,搜狗,新浪微博,百度,腾讯文学,网易以及其他一些小的创业型公司的面试常被问的问题,当然有重复,弄清楚这些 1. junit用法,before,before ...

随机推荐

  1. C语言之linux内核--BCD码转二进制与二进制转BCD码(笔试经典)

    在分析代码之前,我们先来了解一下,BCD码和二进制到底区别在哪? 学习过计算机原理的和数字电子技术这两门课的都会知道这两个到底是什么含义,也有的同学学过了,考过了,过了一段时间又忘记了,今天,我们通过 ...

  2. Libevent库学习笔记

    Libevent是一个事件触发的网络库,适用于windows.linux.bsd等多种平台,Libevent在底层select.pool.kqueue和epoll等机制基础上,封装出一致的事件接口.可 ...

  3. Win10家庭版中的SQL2005无法远程连接

    最近公司重新更换了电脑,电脑自事Win10家庭版本.在安装开发工具中发现有不少的问题,如无法安装SQL Server 2005,无法安装VS2013等.最终通过网上寻找安装SQL Server 200 ...

  4. MQ队列管理器搭建(二)

    MQ级联方式使用场景 使用场景:     如上图所示,Application1与Application2要进行通信或者消息互换,使用MQ中间件作为中介.上图中,Application1与Applica ...

  5. Spring Boot 多模块与 Maven 私有仓库

    前言 系统复杂了,抽离单一职责的模块几乎是必须的:若需维护多个项目,抽离公用包上传私有仓库管理也几乎是必须的.其优点无需赘述,以下将记录操作过程. 1. 多模块拆分 在.NET 中由于其统一性,实现上 ...

  6. 你必须知道的261个Java语言问题

    1. Java语言的运行机制: Java既不是编译型语言也不是解释型语言,它是编译型和解释型语言的结合体.首先采用通用的java编译器将Java源程序编译成为与平台无关的字节码文件(class文件), ...

  7. 2MySQL Server 系统架构

    2.2MySQL Server 系统架构 总的来说,MySQL 可以看成是二层架构,第一层我们通常叫做SQL Layer,在MySQL 数据库系统处理底层数据之前的所有工作都是在这一层完成的,包括权限 ...

  8. tomcat启动非常慢;连接oracle数据库失败,jdbc错误日志提示connection reset;测试主机间网络互通及数据库端口都正常

      [判断确认:这时候大家可能要去检查一下/dev/random 这个设备档案.可以用cat /dev/random 来看它的内容,如果你发现他一直没显示任何内容﹝可能是乱码数字之类的﹞,那就是它出问 ...

  9. CSS 弹性容器

    该文章为英文原文译文及一些自己的拙见墙裂推荐读原文浏览原文请戳这里 : CSS-STRICKS 弹性布局 (Flexbox Layout) 什么是弹性布局 Flexbox Layout 模块旨在提供一 ...

  10. DjangoRestFramework的外键反向关系序列化的一个问题

    先用文档中的样例: Models定义: class Album(models.Model): album_name = models.CharField(max_length=100) artist ...