文章目录
一、JMM(Java 内存模型)
JMM(Java 内存模型) 主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。JMM并不真实存在,它是一种规范,规定了程序中变量在内存中的访问方式。
二、主内存和本地内存
Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
主内存: 所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
本地内存: 每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
三、JMM中的八大原子操作
JMM抽象了线程和主内存之间的关系,定义了以下八种操作来完成主内存和工作内存之间的交互:
• lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
• unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
• read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
• load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
• use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
• assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
• store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
• write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
四、缓存不一致问题
4.1 CPU高速缓存
CPU缓存模型:
CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache,这里不做讨论,并不常见
CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
4.2 缓存一致性协议(MESI)
那么问题来了,当多个线程同时执行时怎么保证获取到的共享变量数据是同步的呢?
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
缓存一致性协议(MESI)
多个cpu从主内存读取同一个数据到各自的高速缓存.当其中某个cpu修改了缓存里的数据,该数据会马上同步回主存,其他cpu通过总线嗅探机制可以感知到数据的变化而将自己缓存里的数据失效
五、JMM三大特性
3.1 原子性
一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。在 Java 中,可以借助synchronized
、各种 Lock
以及各种原子类实现原子性。synchronized
和各种 Lock
可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile
或者final
关键字)来保证原子操作。
并发编程详解中已有介绍过
volatile
关键字不多做赘述
3.2 可见性
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。在 Java 中,可以借助synchronized
、volatile
以及各种 Lock
实现可见性。如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
3.3 有序性
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
在 Java 中,volatile
关键字可以禁止指令进行重排序优化。
指令重排序
说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序 。为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。
什么是指令重排序?
简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。常见的指令重排序有下面 2 种情况:
- 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器 指令的执行顺序。
另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence) 是一种 CPU指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
六、happens-before原则
6.1 为什么需要 happens-before 原则?
happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
- 如果一个操作 happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。两个操作之间存在happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。
- 如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
6.2 happens-before 常见规则有哪些?谈谈你的理解?
程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
解锁规则:解锁 happens-before 于加锁;
volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。