【JAVA并发学习二】Java内存模型

 2019-12-10 16:17  阅读(865)
文章分类:Java Core

在并发编程中,需要处理两个关键问题:线程之间如何通信线程之间如何同步(这里的线程是指并发执行的活动实体)。Java在Java内存模型上解决了这两个问题

一 原子性、可见性、有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性、有序性这3个特征展开的,对这三个概念的理解非常重要

1.1 原子性

原子性Atomicity就是一个操作是不可以被分割的,这个操作要不全部执行完,要不就不执行。由Java内存模型来直接保证的原子性变量操作包括:

  • read、load、assign、use、store、write,我们大致可以认为基本数据类型的访问读写是具备原子性的(long和double除外)
  • lock和unlock,尽管虚拟机并没有把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个指令反应到java代码中就是synchronized

1.2 可见性

可见性Visibility是指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java中volatile、synchronized、final可以实现可见性:

  • volatile变量的特殊规则保证了新值能够立即同步到主内存,以及每次访问前都要从主内存刷新
  • synchronized的可见性通过“对一个变量执行unlock操作之前,必须先把此变量同不会主内存中”这条规则获得的
  • final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去,那在其他线程中就能看到final字段的值

1.3 有序性

有序性可以总结为:“如果在本地线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的”。前半句指的是“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“本地内存和主内存同步延迟”现象。Java提供了几种方式保证线程间操作的有序性:

  • volatile:通过内存屏障实现对指令重排序的禁止
  • synchronized:通过“一个变量在同步一个时刻只允许一个线程对其进行lock操作”来实现的

1.4 总结

可以发现对于上面三种特性,synchronized都可以满足,因为synchronized的“万能”,这也使得开发中对synchronized的使用有些泛滥,导致对性能的影响。所以最新版本的JVM虚拟机锁进行了优化

二 重排序

2.1 重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。也就是说,程序执行的顺序可能和你写的顺序不同。重排序分为3种:

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

其中第一种属于编译器排序,后两种属于处理器重排序

重排序的目的在于:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器在进行重排序时,肯定要保证重排序后的程序能够正确的运行且运行结果不变,那是通过什么样的规则来判断是否可以进行重排序?主要是数据依赖性as-if-serial这两个规则进行判断的。

2.2 数据依赖性

如果两行代码间存在数据依赖性,那么进行重排序后运行结果就会被改变,这是就不能进行重排序操作(两行代码交换执行顺序)。如:

a = 1;
    b = a + 1;

2.3 as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

遵守as-if-serial语义的编译器、处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。asif-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

2.4 重排序对多线程的影响

通过上面的介绍,可以知道是否可以进行重排序的判断依据都是对于单线程说的,所以在多线程的情况下,重排序可能会破坏多线程程序的语义

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果

三 Java内存模型

Java内存模型Java Memory Model简称JMM,下面都以JMM相称

3.1 JMM基础

开篇的地方提到过并发编程的两个关键问题是:线程间如何通信及线程间同步。

通信是指线程之间以何种机制来交换信息。在命令式编程中有两种方式:共享内存和消息传递。在共享内存的并发模型中,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信。在消息传递并发模型中,线程之间没有公共状态,需要进行显式的消息通信

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存的并发模型中必须通过显式的方式

Java并发模型采用的是共享内存的方式进行。

3.2 JMM抽象结构

java中所有实例域静态域数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数存储在每个线程的虚拟栈的栈帧的局部变量表中,不会在线程间共享

Java线程间的通信通过JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。JMM模型如下图
20191210001708\_1.png

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

当线程A和线程B通信时,A先更改x的值,然后将x更新后的值写入主内存的共享变量中,B再从主内存中读取x的值,完成A到B的通信。过程如下图

20191210001708\_2.png

3.2 主内存和本地内存交互

主内存和本地内存的交互指:一个变量从主内存拷贝到本地内存,从工作内存同步回主内存的过程。Java内存模型通过8中操作来完成,而且这八种操作都是原子的、不可分割的(double、long类型除外,因为是64位的数据)

  • read:作用于主内存的变量,把一个变量从主内存读取出来
  • load:作用于工作内存的变量,把read得到的值放入到工作内存的变量副本中
  • use:作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎
  • assign:作用于工作内存的变量,把从执行引擎得到的值赋给工作内存中的变量
  • store:作用于工作内存的变量,把工作内存中变量值传入主内存中
  • write:作用于主内存的变量,把store得到的值写入主内存中变量中

3.3 先行发生happens-before

happens-before是JMM最核心的概念。如果JMM中所有的有序性都仅仅靠volatile和synchronized来完成,那有一些操作会很复杂,但是我们在编写Java并发代码的时候并没有感受到这一点,这是因为“并行发生”原则的存在。对应Java程序员来说,理解happens-before是理解JMM的关键

happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,但两个存在happens-before关系的操作不一定需要操作可见。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

以下规则会产生happens-before关系:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
  • start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

在进行JMM设计时,需要考虑两个关键因素:

  • 对于程序员:希望内存模型易于理解、易于编程,提供足够强的可见性保证
  • 对于编译器和处理器:希望内存模型对他们的束缚越少越好,这样他们就可以做尽可能多的优化来提高性能

这两个因素是相互对立的,在真正设计JMM时,把happens-before对重排序的限制分为两类:会改变程序执行结果的重排序、不会改变程序执行结果的重排序。不影响执行结果的都允许进行重排序操作

20191210001708\_3.png

JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证

JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如

double pi = 3.14;              //A
    double r = 1.2;                //B
    double area = pi * r * r;      //C

按照happens-before规则会生成三条规则:

  • A happens-before B
  • B happens-before C
  • A happens-before C

其中第一个就属于不必要的,后面两个就属于必需的。这时JVM就允许对A进行重排序

3.4 JMM的内存可见性保证

按程序类型,Java程序的内存可见性保证可以分为下列3类。

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 【JAVA并发学习二】Java内存模型

相关推荐