多核并发缓存架构
缓存一致性问题
基于高速缓存的存储系统交互很好的解决了处理器与内存速度的矛盾,但是也为计算机系统带来更高的复杂度,因为引入了一个新问题:缓存一致性。
在多处理器的系统中(或者单处理器多核的系统),每个处理器(每个核)都有自己的高速缓存,而它们有共享同一主内存(Main Memory)。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。
Java 内存模型
Java 内存模型的组成
主内存
Java 内存模型规定了所有变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。
工作内存
每条线程都有自己的工作内存(Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存中的共享变量的副本拷贝。
工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。 Java 内存模型抽象示意图如下:
3、JVM 内存操作的并发问题
结合前面介绍的物理机的处理器处理内存的问题,可以类比总结出 JVM 内存操作的问题,下面介绍的 Java 内存模型的执行处理将围绕解决这两个问题展开。
工作内存数据一致性
各个线程操作数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将导致各自的共享变量副本不一致,如果真的发生这种情况,数据同步回主内存以谁的副本数据为准?
Java 内存模型主要通过一系列的数据同步协议、规则来保证数据的一致性,后面再详细介绍。
指令重排序优化
Java 中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。
重排序分为两类: 编译期重排序和运行期重排序,分别对应编译时和运行时环境。
同样的,指令重排序不是随意重排序,它需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果。 即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。
通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。 - 存在数据依赖关系的不允许重排序。
多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同,后面再展开 Java 内存模型如何解决这种情况。
三、Java 内存间的交互操作
在理解 Java 内存模型的系列协议、特殊规则之前,我们先理解 Java 中内存间的交互操作。
1、交互操作流程
为了更好理解内存的交互操作,以线程通信为例,我们看看具体如何进行线程间值的同步:
线程 1 和线程 2 都有主内存中共享变量 x 的副本,初始时,这 3 个内存中 x 的值都为 0。
线程 1 中更新 x 的值为 1 之后同步到线程 2 主要涉及两个步骤:
- 线程 1 把线程工作内存中更新过的 x 的值刷新到主内存中。
- 线程 2 到主内存中读取线程 1 之前已更新过的 x 变量。
从整体上看,这两个步骤是线程 1 在向线程 2 发消息,这个通信过程必须经过主内存。
JMM 通过控制主内存与每个线程本地内存之间的交互,来为各个线程提供共享变量的可见性。
2、内存交互的基本操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了下面 8 种操作来完成。
虚拟机实现时必须保证下面介绍的每种操作都是原子的,不可再分的(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外)。
8 种基本操作,如下图:
- lock (锁定) , 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock (解锁) , 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read (读取) , 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load (载入) , 作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use (使用) , 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。
- assign (赋值) , 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store (存储) , 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。
- write (写入) , 作用于主内存的变量,它把 Store 操作从工作内存中得到的变量的值放入主内存的变量中。
四、Java 内存模型运行规则
1、内存交互基本操作的 3 个特性
在介绍内存交互的具体的 8 种基本操作之前,有必要先介绍一下操作的 3 个特性。
Java 内存模型是围绕着在并发过程中如何处理这 3 个特性来建立的,这里先给出定义和基本实现的简单介绍,后面会逐步展开分析。
原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter
和monitorexit
。在synchronized的实现原理文章中,介绍过,这两个字节码,在Java中对应的关键字就是synchronized
。
因此,在Java中可以使用synchronized
来保证方法和代码块内的操作是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile
关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile
来保证多线程操作时变量的可见性。
除了volatile
,Java中的synchronized
和final
两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。
有序性
在Java中,可以使用synchronized
和volatile
来保证多线程之间操作的有序性。实现方式有所区别:
volatile
关键字会禁止指令重排。synchronized
关键字保证同一时刻只允许一条线程操作。
好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized
关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized
的原因。
但是synchronized
是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。
Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立。
归根究底,是为实现共享变量的在多个线程的工作内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行。