原子性、可见性、有序性解决方案

   日期:2020-07-13     浏览:80    评论:0    
核心提示:原子性、可见性、有序性解决方案(一)原子性原子性是指:一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在Java中当我们讨论一个操作具有原子性问题是一般就是指这个操作会被线程的随机调度打断。JMM对原子性的保证大概分以下几种类型:java自带原子性、synchronized、Lock锁、原子操作类(CAS)。下面我们来一个一个细说。1. java自带原子性在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,但是long和double类型是64位,在32位JV_可见性

原子性、可见性、有序性解决方案

(一)原子性

原子性是指:一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在Java中当我们讨论一个操作具有原子性问题是一般就是指这个操作会被线程的随机调度打断。

JMM对原子性的保证大概分以下几种类型:java自带原子性、synchronized、Lock锁、原子操作类(CAS)。下面我们来一个一个细说。

1. java自带原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,但是long和double类型是64位,在32位JVM中会将64位数据的读写操作分成两次32位来处理,所以long和double在32位JVM中是非原子操作,也就是说在并发访问时是非线程安全的。

尤其要注意,这里我们讲的仅仅是读取和赋值两种操作具有原子性。这里的赋值仅仅指具体数值的赋值,而不包括变量给变量赋值。另外,组合操作(例如++和–操作)也同样不具有原子性。我们可以看几个经典例子:

a = true;  // 原子性
a = 5;     // 原子性
a = b;     // 非原子性,分两步完成,第一步加载b的值,第二步将b赋值给a
a = b + 2; // 非原子性,分三步完成
a++;      // 非原子性,分三步完成

了解汇编语言的朋友很容易理解下面三个例子为什么不能一步完成。不了解汇编语言的朋友记住即可,这个地方非常关键。

2. synchronized

synchronized可以保证操作结果的原子性(注意这里的描述)。synchronized保证原子性的原理也很简单,因为synchronized可以防止多个线程并发执行同一段代码。

方法加了synchronized后,当一个线程没执行完这个方法前,其他线程是不能执行这段代码的。其实我们发现synchronized并不能将代码变成原子性操作,代码在执行过程中还是有可能被中断的,但是,即使被中断了其他线程也不能乘机突然进入临界区执行这段代码,当之前被中断的线程继续执行直到结束时得到的结果还是正确的。

因此,synchronized对原子性问题的保证是从最终结果上来保证的,也就是说它只保证最终的结果正确,中间操作的是否被打断没法保证。

3. Lock锁

Lock锁的原理与synchronized基本一致,因此不再赘述。

4. 原子操作类(CAS)

JDK提供了很多原子操作类来保证操作的原子性。原子操作类的底层是使用CAS机制的,这个机制对原子性的保证和synchronized有本质的区别。CAS机制保证了整个赋值操作是原子的不能被打断的,而synchronized只能保证代码最后执行结果的正确性,也就是说synchronized能消除原子性问题对代码最后执行结果的影响,但原子操作类(CAS)是真正保证了操作的原子性。

(二)可见性

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程在工作内存中保存的值是主内存中值的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存,等到线程对变量操作完毕之后会将变量的最新值刷新回到主内存。

但是何时刷新这个最新值又是随机的。所以就有可能一个线程已经将一个共享变量更新了,但是还没刷新回主内存,那么这时其他对这个变量进行读写的线程就看不到这个最新值。还有一种可能就是虽然修改线程已经将最新值刷新到主内存中去了,但是读线程的工作内存中副本的缓存值还没过期,那么读线程还是会使用这个副本值,而不是主内存中的最新值。这个就是多CPU多线程编程环境下的可见性问题。

JMM针对可见性问题提出了下面几种解决方案:volatile、synchronized、Lock锁、原子操作类(CAS),下面一个个细说。

1. volatile

我们可以看一下volatile究竟做了什么。使用volatile修饰一个共享变量可以达到如下的效果(内存语义):

  1. 一旦线程对这个共享变量的副本做了修改,会立马刷新最新值到主内存中去
  2. 一旦线程对这个共享变量的副本做了修改,其他线程中对这个共享变量拷贝的副本值会失效,其他线程如果需要对这个共享变量进行读写,必须重新从主内存中加载

volatile底层使用的是内存屏障来保证可见性的。我们先来了解一下内存屏障。

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,在写操作之后、读操作之前可以插入内存屏障。

我们可以从上面一大段定义中抽出两条要点来对应volatile的内存语义:内存屏障之前的写操作都必须立马刷新回主内存、内存屏障之后的读操作都必须从主内存中读取最新值。这也就是volatile保证可见性的基本原理。

2. synchronized

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。这样就保证了可见性。

从这里我们发现,实际上锁具有和volatile一致的内存语义,所以使用synchronized也可以实现共享变量的可见性。

3. Lock锁

Lock锁的原理与synchronized基本一致,因此不再赘述。

4. 原子操作类(CAS)

使用原子操作类也可以保证共享变量操作的可见性。原子操作类底层使用的是CAS机制。Java中CAS机制每次都会从主内存中获取最新值进行compare,比较一致之后才会将新值set到主内存中去。而且这个整个操作是一个原子操作。所以CAS操作每次拿到的都是主内存中的最新值,每次set的值也会立即写到主内存中。

(三)有序性

为什么会出现有序性问题,其根源就是指令重排。指令重排是指编译器和处理器在不影响代码单线程执行结果的前提下,对源代码的指令进行重新排序执行。这种重排序执行是一种优化手段,目的是为了处理器内部的运算单元能尽量被充分利用,提升程序的整体运行效率。

重排序分为以下几种:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

处理器为了提升程序的性能,可以对程序进行重排序。但是必须满足重排序之后的代码在单线程环境下执行的结果不能改变(很关键),这个原则也就是我们常说的as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

但是,as-if-serial只能保证单线程情况下结果保持不变,在多线程情况下就无法保证。因此,多线程下的有序性问题可能会导致最终的结果发生无法预测的变化,这是一个非常严重的问题。

因此,JMM使用了四种方式来确保有序性:happens-before原则、synchronized、Lock锁、volatile,下面我们一一细说:

1. happens-before原则

我们先来看一下《java并发编程的艺术》中的定义:

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens- before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the f irst is visible toand ordered before the second)

下面是Java内存模型一些自带的先行发生关系(摘自《java并发编程的艺术》)这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。 如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序:

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。 准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、 循环等结构。
  2. 监视器锁规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、 Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

如果不能满足happens-before原则,就需要使用synchronized机制和volatile机制机制来保证有序性。

2. synchronized

synchronized保证有序性的方法非常简单粗暴,但是这也就带来了更大的资源浪费。synchronized语义表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。因此,synchronized语义就要求线程在访问读写共享变量时只能“串行”执行,因此synchronized具有有序性。

3. Lock锁

Lock锁的原理与synchronized基本一致,因此不再赘述。

4. volatile

volatile的底层是使用内存屏障来保证有序性的。若用volatile修饰共享变量,在JVM底层volatile是采用“内存屏障”来实现禁止特定类型的处理器重排序。加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏)。内存屏障可以保证:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

(四)总结


我们把上述要点总结一下,实际上主要就是三个关键点:volatile无法保证原子性、synchronized只能保证原子性问题不影响最终结果但无法真正保证原子性、原子类无法保证有序性。

2020年7月11日

 
打赏
 本文转载自:网络 
所有权利归属于原作者,如文章来源标示错误或侵犯了您的权利请联系微信13520258486
更多>最近资讯中心
更多>最新资讯中心
0相关评论

推荐图文
推荐资讯中心
点击排行
最新信息
新手指南
采购商服务
供应商服务
交易安全
关注我们
手机网站:
新浪微博:
微信关注:

13520258486

周一至周五 9:00-18:00
(其他时间联系在线客服)

24小时在线客服