天天看点

Java并发编程(九)-Java内存模型(JMM)Java内存模型基础主内存工作内存内存交互操作并发编程特性:原子性、可见性、有序性指令重排序(重要)happens-before-先行先发生原则(重要)

Java内存模型

  • Java内存模型基础
    • 并发编程模型的两个关键问题
  • 主内存
  • 工作内存
  • 内存交互操作
    • 内存交互操作条件
    • long、double类型变量的特殊规则
  • 并发编程特性:原子性、可见性、有序性
    • 原子性
    • 可见性
    • 有序性
  • 指令重排序(重要)
    • 数据依赖性
    • 内存屏障类型
    • as-if-serial
  • happens-before-先行先发生原则(重要)

Java内存模型基础

并发编程模型的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步

通信 是指 线程之间 以何种机制 来 交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

  • 在共享内存的并发模型里,线程之间 共享 程序(数据或者叫变量)的公共状态,通过 写-读(两个动词) 内存中的公共状态 进行隐式通信
  • 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信

同步 是指 程序中 用于控制 不同线程间操作发生 的 相对顺序的机制

  • 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行
  • 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明

Java线程之间的通信由Java内存模型-JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见

主内存

所有的变量都存储在这里,这里的变量包括:实例字段、静态字段、构成数组对象的元素

不包括局部变量和方法参数

工作内存

每个线程私有,保存了 被当前线程使用到 的 变量 是 主内存副本拷贝,线程对变量的读取、赋值都必须在工作内存中进行

不同线程之间无法访问对方工作内存中的变量

线程之间变量值的传递均需要通过主内存来完成

内存交互操作

Java内存模型定义了以下八种操作来完成

  • lock(锁定):作用于主内存的变量,把一个变量 标识为 一条线程独占状态
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量 释放出来,释放后的变量 才可以 被其他线程锁定
  • read(读取):作用于主内存变量,把一个变量值 从主内存 传输到 线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把 read操作从主内存中得到的变量值 放入 工作内存 的 变量副本中
  • use(使用):作用于工作内存的变量,把 工作内存中的一个变量值 传递给 执行引擎,每当虚拟机遇到一个 需要使用变量的值 的字节码指令时 将会执行这个操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值 赋值给 工作内存的变量,每当虚拟机遇到一个 给变量赋值的字节码指令时 执行这个操作
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值 传送到 主内存中,以便随后的write的操作
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值 传送到 主内存的变量中

内存交互操作条件

  • 如果要把一个变量 从主内存中 复制 到工作内存,就需要按顺序地执行read和load操作,如果把变量从 工作内存中 同步回 主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行
  • 不允许read和load、store和write操作之一 单独出现
  • 不允许一个线程丢弃它的最近assign-赋值操作,即变量 在工作内存中 改变了之后 必须同步到 主内存中
  • 不允许一个线程无原因地(没有发生过任何assign-赋值操作)把数据从工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load-载入或assign-赋值)的变量。即就是对一个变量实施use-使用和store-存储操作之前,必须先执行过了load-载入和assign-赋值操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前 需要重新执行 load-载入或assign-赋值操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

long、double类型变量的特殊规则

  • 虚拟机允许 将 没有被volatile修饰的 64位数据的 读写操作 划分为两次32位操作来进行
  • 如果有多个线程 共享一个 并未声明volatile的long/double变量,并且同时对它进行读取/修改操作,那么某些线程可能会读取到一个 既非原值,也不是其他线程修改值的代表了“半个变量”的数值

并发编程特性:原子性、可见性、有序性

原子性

  • 原子性指一个操作的完整性,就是在一个操作中cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行
  • 由Java内存模型来直接保证的 原子性变量操作包括:read、load、assign、use、store、write
  • 两个原子性的操作结合在一起未必还是原子性的,比如i++
  • 简单的 读取与赋值操作 是 原子性的,将 一个变量 赋给 另外一个变量 的 操作不是原子性的,因为后者包含两个步骤:读取一个变量的值 和 修改另一个变量的值。虽然两步分别都是原子类型的操作,但是合在一起就不是原子操作了。x=10是原子性的;y=x、y++、z=z+1都不是原子性的
  • volatile关键字不保证数据的原子性
  • 如果想要使得某些代码片段具备原子性,需要使用关键字synchronized,或者JUC包中的Lock
  • 自JDK1.5版本起,如果想要使得

    int

    等类型自增操作具备原子性,可以使用JUC包下的原子封装类型java.util.concurrent.atomic.*

可见性

  • 可见性指当一个线程修改了共享变量后,其他线程能够立即得知这个修改

Java提供了以下三种方式来保证可见性:

  • 使用关键字volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作 会直接在 主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致 当前线程 在工作内存中的共享资源失效,所以必须从主内存中再次获取)。对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中
  • volatile通过加入 内存屏障 和 禁止重排序优化 来实现可见性
  • synchronized和final也可以实现 变量可见性。 synchronized是指对一个变量执行unlock之前,必须先把此变量同步回主内存中。final是指 被final修饰的字段 一旦在构造器中初始化完成,并且构造器没有把this传递出去,那在其他线程中就可以看到final字段的值
  • 通过JUC提供的显式锁Lock也能够保证可见性,Lock的lock方法能够保证在同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放(Lock的unlock方法)之前会将对变量的修改刷新到主内存当中

有序性

  • 有序性是指程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序
  • 如果在本线程内观察,所有操作都是有序的;如果在一个线程中 观察 另外一个线程,所有的操作都是无序的
  • 在JMM中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。可以通过volatile、synchronized、Lock保证有序性。synchronized在有序性上的表现是指一个变量在同一个时刻只允许一条线程对其进行lock-加锁操作

指令重排序(重要)

一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

在多个线程需要依赖同一个资源的时候,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性

重排序分3种类型:

  • 编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序,现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使得 加载 和 存储操作 看上去 可能是在乱序执行
  • 指令级并行的重排序 和 内存系统的重排序 属于 处理器重排序。JMM的处理器重排序规则 会要求 Java编译器 在生成 指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过 内存屏障指令 来禁止 特定类型的处理器重排序

数据依赖性

  • 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
  • 数据依赖分为3种类型:写后读 写后写 读后写
  • 编译器和处理器在重排序时,会遵守 数据依赖性,编译器和处理器 不会改变 存在数据依赖关系的两个操作 的 执行顺序
  • 数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

内存屏障类型

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1的数据的装载 优先于 Load2及所有后续装载指令的装载
StoreStoreBarriers Store1;StoreStore;Store2 确保Store1数据 对其他处理器可见(刷新到内存)优先于 Store2及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据的装载 优先于 Store2及所有后续存储指令的存储
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1的数据对其他处理器可见(刷新到内存)优先于 Load2及所有后续的装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

as-if-serial

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

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

happens-before-先行先发生原则(重要)

前一个操作的结果可以被后续操作获取

因为JVM会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性

  • 程序的顺序性规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作,准确的说,应该是控制流顺序 而不是 程序代码顺序,因为要考虑分支、循环等结构
  • 管程锁定规则:一个unlock操作 先行发生于 后面 对 同一个锁 的 lock操作,这里必须强调的是同一个锁,“后面”指的是时间上的先后顺序
  • volatile变量规则:对一个volatile变量的写操作 先行发生于 后面 对这个变量的读操作,“后面”同样是时间上的先后顺序
  • 线程启动规则:Thread对象的start()方法 先行发生于 此线程的每一个动作
  • 线程终止规则:线程中的所有操作都 先行发生于 对此线程的终止检测,可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段 检测到 线程已终止执行
  • 线程中断规则:对线程interrupt()方法的调用 先行发生于 被中断线程的代码 检测到 中断事件的发生,可以通过Thread.interruptd()方法检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于 它的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么就可以认为操作A先行发生于操作C

继续阅读