Java 多线程与并发(二):内存模型

爱⌒轻易说出口 提交于 2021-02-09 15:33:39

前言

在并发变成中,我们需要关注两个问题:

  1. 线程之间如何通信。
  2. 线程之间如何同步。

线程之间通信指的是线程之间如何交换信息。线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。 线程之间的同步是指用于控制不同线程之间操作发生相对顺序的机制。 在共享内存的并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java 的并发采用的共享内存模型,Java 线程之间的通信总是隐式进行的,整个通信过程对程序猿完全透明。 所以如果想学好 Java 并发,就必须要理解 Java 的内存模型。通过内存模型我们可以知道线程如何修改共享变量的值,以及如何同步的访问共享变量。

内存模型

了解 JVM 的 Java 内存模型以及结构对于我们在多线程开发时有很大帮助。了解线程安全的虚拟机底层运作原理以及虚拟机实现高效并发所采取的一些列锁优化措施是我们开发高效和安全代码的基础。

通过硬件类比 Java 内存模型

计算机的存储设备(内存,磁盘)和处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存和处理器之间的缓冲。 将运算所需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后在从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。 这时会有缓存一致性问题,在多处理器系统中,每个处理器都有自己的高速缓存,他们又共享同一主内存,会有可能导致各自的缓存数据不一致的问题。为了解决这个问题,需要根据一些读写协议来操作,比如 MSI、MESI、MOSI、Synapse 等等。

处理器优化和指令重排序

上面说过在 CPU 和主存之间增加缓存,在多线程环境下会有缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入的代码进行乱序处理,这就是处理器优化。 除了现在很多的流行的处理器会对代码进行优化乱序处理,很多变成语言的编译器也会有类似的优化,比如:Java 虚拟机的即时编译器(JIT)也会做指令重排序。

可想而知,如果任由处理器优化和编译器对指令重排的化,就可能导致各种各样的问题。

由硬件模型抽象出来的问题

在讨论并发编程时,我们一定会讨论原子性``可见性``有序性。 其实原子性问题,可见性问题和有序性问题是人们定义出来的。这些抽象问题的底层就是缓存一致性问题,处理器优化问题和指令重排序问题。

原子性 是指一个操作中的 CPU 是不可以在中途暂停然后再调度,既不可以被中断操作,要不执行完成,要不就不执行。

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

有序性 是指程序执行的顺序严格按照代码的先后顺序执行。

引出内存模型

缓存一致性问题,处理器优化的指令重排问题是硬件不断升级导致的。有没有什么机制可以很好的解决上面的问题哪? 为了保证并发编程可以满足原子性,可见性和有序性。有一个重要概念,那就是内存模型,它定义了共享内存系统中多线程程序读写操作的行为规范。

通过这些规则来规范对内存的读写操作,从而保证执行的正确性。 内存模型解决并发问题主要采用两种方式:限制处理器优化和内存屏障。

Java 内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型( Java Memory Model )来屏蔽掉各种硬件和操作系统的内存访问差异,让 Java 在各种平台下都能达到一致的并发效果。

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量是指实例字段,静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为后者是线程私有的,不会被共享。

Java 内存模型规定了所有变量都是存储在主内存(Main Memory)中。每条线程还有自己的工作内存,工作内存中保存了被改线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。线程间的通信要通过工作内存进行。工作内存中更改的变量会不定时刷新到主存中。

通过对比发现,JMM 与硬件模型的变量更改、数据共享、内存刷新以及架构都非常相似。

Java 内存模型抽象

Java 中,所有**实例域,静态域和数组元素存储在堆内存中,堆内存是线程共享的。**局部变量,方法定义参数和异常处理器参数存在栈中,不会在线程之间共享,也不会有内存可见性的问题,不受内存模型影响。

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

线程 A 与线程 B 之间如果要通信的化,就必须经历两个步骤:

  1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中。
  2. 线程 B 到主内存中去读取线程 A 之前已经更新过的共享变量。
重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。 重排序分为三种类型:

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

JMM 的解决方案:

  1. 对于编译器重排序,JMM 的编译器重排序规则会禁止特定类型的编译器重排序。
  2. 对于处理器重排序,JMM 的处理器排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障执行,通过内存屏障指令来禁止特定类型的处理器重排序。
happens-before

Java 内存模型中使用 happen-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么两个操作之间必须要存在 happen-before 关系。 两个操作可以在一个线程之内,也可以在不同线程。 重要的 happen-before 规则如下:

程序顺序规则:一个线程中的每个操作 happens-before 于该线程中的任意后续操作。

监视器锁规则:对一个监视器锁的解锁 happens- before 于随后对这个监视器锁的加锁。

volatile变量规则:对一个volatile域的写 happens- before 于任意后续对这个volatile域的读。

传递性规则:如果A happens- before B,且B happens- before C,那么A happens- before C。

所有的 happens-before 规则都是 JMM 禁止指令重排序的体现。不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

Java 内存模型的实现

Java 中提供了一系列和并发处理相关的关键字,比如 synchronized,volatile,final 等等。Java 内存模型除了定义规范,还提供了这些关键字,这些关键字封装了底层实现,提供给开发者使用。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!