【并发编程】Java并发编程:volatile关键字

1.  内存模型

  若一个变量在多线程环境下同时操作,则可能出现结果不一致的情况。这就是常说的缓存不一致性问题。
  解决缓存不一致问题,通常有两个解决方案:
  • 通过在总线加LOCK#锁的方式
  因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。但是由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
  • 通过缓存一致性协议
  最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

2.  并发编程中的三个问题

  • 原子性问题
    原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 可见性问题
    可见性:即多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性问题
    有序性:即程序执行的顺序按照代码的先后顺序执行。
1 int a = 1;
2 int b = 2;
3 a = a + b;
4 b = a * a;
  JVM在真正执行这四行代码时不一定会按照上述定义的顺序执行,因为可能发生指令重排序。
  指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
  指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
  要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

3.  Java内存模型

  在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
  • 原子性
  在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
  • 可见性
  对于可见性,Java提供了volatile关键字来保证可见性。
  当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
  而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
  另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
  • 有序性
  在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
  在Java里面,可以通过volatile关键字来保证一定的有序性。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
  另外,Java内存模型具备一些先天的有序性,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before(先行发生) 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
  happens-before原则(先行发生原则):
a. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
b. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
c. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
d. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
e. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
f. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
g. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
h. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

4.  volatile关键字

  • volatile关键字的两层语义
  一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
  a. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  b. 禁止进行指令重排序。
  • volatile不一定保证原子性
  当变量的值由自身的上一个决定时,如n=n+1、n++ 等,volatile关键字将失效,只有当变量的值和自身上一个值无关时对该变量的操作才是原子级别的,如n = m + 1,这个就是原子级别的。
  • volatile保证有序性
  在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
  volatile关键字禁止指令重排序有两层意思:
  a. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行。。。
  b. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
  • volatile的原理和实现机制
  前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。
  下面这段话摘自《深入理解Java虚拟机》:
  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
a. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
b. 它会强制将对缓存的修改操作立即写入主存。
c. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

5.  volatile关键字使用场景

  synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代 synchronized关键字的,因为volatile关键字无法保证操作的原子性。
通常来说,使用volatile必须具备以下2个条件:
  a. 对变量的写操作不依赖于当前值。
  b. 该变量没有包含在具有其他变量的不变式中。
使用场景:
  a. 状态标记
  b. 双重检查(double check)
posted @ 2020-05-01 15:58  C3Stones  阅读(207)  评论(0编辑  收藏  举报