通过上篇文章【多线程与并发】Java并发理论基础我们了解到:Java内存模型是一种虚拟机规范,用于屏蔽掉各种硬件和操作系统的内存访问差异。并从两个维度去理解了它。那么本篇文章就近距离的来认识下Java内存模型。
从堆栈说起
JVM内部使用的Java内存模型在线程栈和堆之间划分内存。 此图从逻辑角度说明了Java内存模型:- 线程栈内包含正在执行的每个方法的所有局部变量,线程的访问是私有的,每个线程只能访问自己栈。
- 当局部变量是基本类型(boolean,byte,short,char,int,long,float,double)时,它完全保留在线程栈上。
- 当局部变量是对象的引用时,引用(局部变量)存储在线程栈中,对象本身存储在堆上。
- 对象的成员变量与对象本身一起存储在堆(Heap)上,不论成员变量是基本数据类型还是对象引用类型。
- 静态成员变量跟随着类定义一起也存放在堆上。
- 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。
- 当一个线程可以访问一个对象时,它也可以访问该对象的成员变量。
-
如果两个线程同时调用同一个对象上的同一个方法,那它们都可以访问该对象的成员变量,但是每一个线程都拥有这个成员变量的副本。
此图能够直观地反映上述关于堆栈的几点说明。
硬件内存结构
现代硬件内存架构与内部Java内存模型略有不同。
先来看一张现代计算机硬件架构图:通过上图我们来对硬件内存结构进行详细说明:
-
CPU:
- 现代计算机通常是有2个或多个CPU。
- 其中一些CPU还可能是多核。在这种现代计算机上,可以同时运行多个线程。
- 每个CPU都能够在任何给定时间运行一个线程。这就意味着,如果Java应用程序是多线程的,线程是可能同时运行。
-
寄存器:
- 每个CPU基本上都包含一组在CPU内存中的寄存器。
- CPU访问这些寄存器要比访问主存储器快的多。
-
高速缓存存储器:
- 每个CPU还可以具有CPU高速缓存存储器层。
- 由于计算机内存与CPU之间的运算速度有着巨大差距,现代计算机为了解决这一问题,在内存和CPU之间加入了一层读写速度尽可能接近CPU运算速度的高速缓存来最为缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行。当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢地内存读写了。
- CPU访问速度上:CPU内部寄存器 > 高速缓存存储器 > 内存
- CPU可能有一个或者多个缓存层(级别1和级别2)(要了解Java内存模型如何与内存交互,这一点并不重要,重要的是要知道CPU可以有某种缓存存储层)。
-
内存:
- 计算机还包含主存储区(RAM),所有CPU都可以访问主内存。
- 主存储区通常比CPU的高速缓存存储器大得多,同时访问速度也就较慢。
通常,当CPU需要访问主存储器时,它会将部分主存储器读入其CPU缓存。甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。当CPU需要讲结果写回主存储器时,他会将值从其内部寄存器刷新到高速缓冲存储器,并在某个时间点将值刷入主存储器。
硬件内存结构&CPU普及导致的问题
缓存一致性问题
如上所述,Java内存模型和硬件内存架构是不同的,硬件内存架构不区分线程栈和堆。在硬件上,线程栈和堆都位于主存储器中。线程栈和堆的一部分有时可能存在于CPU高速缓存和内部寄存器中。如下图:
当对象和变量可以存储在计算机的各种不同的存储区域中时,可能会出现某些问题(统称为:缓存一致性问题),两个主要问题:
线程更新(写入)到共享变量的可见性
读写共享变量时的竞态条件
对象共享后的可见性:
如果两个或多个线程共享一个对象,而没有正确使用volatile声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。
举例说明:共享对象最初存储在主存储器中。 然后,在CPU上运行的线程将共享对象读入其CPU缓存中。 它在那里对共享对象进行了更改。 只要CPU缓存尚未刷新回主内存,共享对象的更改版本对于在其他CPU上运行的线程是不可见的。 这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中。
竞态条件:
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能会出现竞态。
举例说明:如果线程A和线程B将共享变量count读入到各自CPU缓存中(不同的CPU缓存),而后,线程A将count加1,线程B也做了同样的事。count被增加了两次,每个CPU缓存中一次,如果这些增加操作被顺序的执行,则变量count应该增加两次并将原始值 +2写会主存储器。但是,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议open in new window)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
指令重排
除了缓存一致性问题,还存在另外一种硬件问题,也比较重要:为了使 CPU 内部的运算单元能够尽量被充分利用,处理器可能会对输入的字节码指令进行重排序处理,也就是处理器优化。除了 CPU 之外,很多编程语言的编译器也会有类似的优化,比如 Java虚拟机的即时编译器(JIT)也会做指令重排。在上一篇文章【多线程与并发】Java并发理论基础 #有序性:重排序引起 这节中对指令重排有详细说明,可自行查阅,这里就不在赘述。
Java内存模型(JMM)
什么是Java内存模型
Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》open in new window 。
对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
为什么需要Java内存模型
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
在并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。比如上面所提到的,为此,JMM 抽象了 happens-before 原则(在上一篇文章【多线程与并发】Java并发理论基础 有详细说明,可自行查阅,本篇文章会稍作补充说明)来解决这个指令重排序问题。
Java内存模型通俗来说就是定义了一些规范来解决上面这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如
volatile
、synchronized
、各种Lock
)即可开发出并发安全的程序。
Java内存模型的抽象
JMM抽象了线程和主内存之间的关系:
线程之间的共享变量存储在内存(Main Memory)中。
每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译优化。本地内存中存储了该线程以读/写共享变量的副本。
从更低的层次来说,主内存就是硬件的内存,为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
Java 内存模型的抽象示意图如下:
从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
lock(锁定): 作用于主内存中的变量,将他标记为一个线程独享变量。
unlock(解锁): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
不允许read和load、store和write操作之一单独出现
不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
happens-before原则
happens-before 设计思想
happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文《Time,Clocks and the Ordering of Events in a Distributed System》open in new window。在这篇论文中,Leslie Lamport 提出了逻辑时钟open in new window的概念,这也成了第一个逻辑时钟算法 。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。
JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
JMM 设计思想的示意图:
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序,否则不允许重排序。
举例说明:计算圆面积。
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面计算圆的面积的示例代码存在三个 happens- before 关系:
A happens- before B
B happens- before C
A happens- before C
虽然 A happens-before B,但对 A 和 B 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 A 和 B 必须是在 C 执行之前,也就是说 A和B happens-before C 。
happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程中。
happens-before 规则
关于happens-before规则可在【多线程与并发】Java并发理论基础 中自行查阅。
happens-before 与 JMM 的关系
happens-before 与 JMM 的关系如下图所示:
如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。
再看并发编程三要素
可自行查阅上篇文章【多线程与并发】Java并发理论基础 #第二个理解维度:可见性,有序性,原子性 这一节,会不会有种豁然开朗的感觉呢?
参考: