概述

目前CPU的处理速度与内存的读写速度不在一个数量级,所以需要在CPU和内存之间加上缓存,来进行提速,这样就呈现出了一种CPU-寄存器-缓存-主存的访问结

这种结构在单CPU时期运行得很好,但是当一台计算机中引入了多个CPU,就出现缓存之间数据一致性的问题

针对这个问题,出现了缓存一致性协议,主要就是为了解决多个CPU缓存之间的同步问题,CPU缓存一致性协议有很多。

Java内存模型

屏蔽了各种硬件和操作系统的内存访问差异,实现了让Java程序能够在各种硬件平台下都能够按照预期的方法来运行

每个工作线程都有自己独占的本地内存,本地内存中存储的是私有变量以及共享变量的副本,使用一定机制来控制本地内存和主存之间读写数据的同步问题。

更加具体一点,我们将工作线程和本地内存具象为thread stack,将主存具象为heap。‘

在Thread stack中有两种类型的变量

  • 原始类型变量,比如(int,char等)存储在线程上
  • 对象类型变量,引用(指针)本身存储在线程栈上,引用指向对象存储在堆上。

堆中存储对象本身,持有该对象引用的线程就能够访问该对象了

Java线程模型中Thread stack和Heap都是对物理内存的一种抽象,这样开发者只需要关心自己写的程序使用到了thread stack和heap,而不用关心更下层的寄存器,CPU缓存,主存。

  • JVM规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致性的内存访问效果
  • java多线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型建立的,Java线程内存模型是标准化的,屏蔽掉了底层不同的计算机的区别
  • 不同线程获工作内存中的资源,其实是主存中的副本
  • JMM即Java Memory Model,它定义了主存(线程共有)、工作内存(线程私有)抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等
  • 本身是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范,通过这组规定定义了程序中(尤其是多线程)各个变量的读写访问并决定一个线程对共享变量的写入何时以及如何变成另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性

缓存一致性协议

多个cpu从主内存读取同一个数据到自己的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同不会内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效

缓存加锁

缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写会导致其他处理器的缓存失效

JMM体现在以下几个方面

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受cpu缓存的影响
  • 有序性:保证指令不会受cpu指令并行优化的影响

JMM八大原子性操作

  • read(读取):从主内存读取数据
  • load(载入):将内存读取到的数据写入工作内存
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值重新复制到工作内存中
  • store(存储):将工作内存数据写入主内存
  • write(写入):将store过去的变量赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,表示为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后,其他线程可以锁定该变量

Volatile关键字

当一个变量被定义为volatile之后,他将具备两项特征

特征1:保证此变量对所有线程的可见性

这里的可见性,指的是,当一条线程修改类这在线程变量的值,新值对于其他线程来说是立即得知的。普通变量是不行的,普通变量的值在线程间的传递需要经过主内存来完成

一定要注意的是,它只能保证可见性,如果对他进行运算,除非是原子操作,否则还是需要使用注入synchronizedLock来保证原子性

退不出的循环

main线程里对run变量的修改对于t线程不可见,导致t线程无法停止(循环没代码不停下,有代码就会停下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package commonMethod.JMM;

import lombok.extern.slf4j.Slf4j;

/**
* @Author: WereAsh
* @Date:2023-10-28 21:31
**/
@Slf4j(topic = "c.VisibleTest")
public class VisibleTest {
static boolean run=true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (run) {
// try {
// Thread.sleep(500);
// log.debug("{} is running",Thread.currentThread().getName());
// } catch (InterruptedException e) {
// throw new RuntimeException(e);
// }
}
},"t1").start();
Thread.sleep(1000);
log.debug("{} wanna stop t1",Thread.currentThread().getName());
run=false;

}
}

为什么呢?

  1. 初始状态,t线程刚开始从主内存读取了run的值到工作内存
  2. 因为t线程要频繁从内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存,减少对主存中run的访问,提高效率
  3. 1秒之后,main线程修改了run的值,并同步至主存,而t1是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

解决方法

volatile

  • 它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量直接操作主存

或者synchronized加锁对run进行修改

注意

  • synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低
  • 如果在示例代码的死循环中加入了System.out.println()会发现即使不加volatile修饰符,线程t也能正确看到对run变量的修改了
  • volatile变量在各个线程的工作内存是不存在不一的情况的,但是从物理存储来看可能存在不一致的,但是由于每次使用前都要先刷新,执行引擎看不到不一致的情况,所以可以认为不存在不一致问题

特征2:禁止指令重排

从硬件架构上讲,指令重排是指处理器采用了允许将多条指令不按程序规定的顺序分别发送给各个相应的电路单元进行处理。但是,不是所有指令任意重拍,处理器都能够正确处理,有依赖关系的当然不会

禁止指令重排的本质

普通变量只能保证在该方法执行的该过程中所有依赖赋值结果的地方能获取正确的值,而不能保证变量赋值操作与程序代码中的执行顺序一致

加上volatile和不加volatile的汇编代码来看,加上volatile,赋值会多执行一个lock add1$0x0,(%esp)操作,这个操作相当于内存屏蔽(Memory Barrier),指重排序时不能把后面的指令重排序到内存屏蔽的位置

只有一个处理器访问内存时,不需要内存屏蔽。如果有两个或者更多处理器访问同一块内存,且其中有一个在观察一个,就需要内存屏蔽来保证一致性

可见性、原子性和有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和并发性这三个特征来建立的

原子性

我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性

操作系统做任务切换,可以发生在任何一条CPU指令执行完时,需要注意的是高级语言一条指令CPU需要多条来执行,例如i++,就需要三条CPU指令

内存模型如何解决原子性

Java内存模型如何处理原子性?

首先它提供了六个原子操作,包括readloadassignusestorewrite。正因如此,基本数据类型的访问、读写都是原子性的。

如果需要更到范围的原子性操作,Java内存模型也提供了lockunlock操作来满足

lockunlock虽然斌没有执行开发给用户,但是提供了两个更高层次的字节码指令,monitorentermonitoreixt来隐式地使用这两个操作。这两个字节码指令反映到Java代码就是同步块,synchronized关键字

可见性

一个线程对共享变量地修改,另外一个线程能够立刻看到,我们称为可见性

在多核CPU时代,每个CPU都有自己的缓存,当对共享变量进行操作时,该变量不能及时通知到其他CPU,以至于写入内存的结果错误,这就是缓存的可见性问题

有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序,例如以下代码

1
2
3
4
static int i;
static int j;
i=0;
j=0;

可以看出,至于是先执行i还是执行j,对最终的结果不会产生影响。所以以上代码在真正执行时,既可以是

1
2
3
4
5
6
i=0;
j=0;

//也可以是
j=0;
i=0;

这种特性称之为指令重排,多线程下指令重排会影响正确性。那么为什么要有指令重排这项优化呢?

指令重排原理

遵循as-if-serial与happens-before原则

as-if-serial语义

  • 不管怎么重排序,单线程程序的执行结果都不能被改变。编译器、runtime和处理器必须遵守as-if-serial语义
  • 为了遵守as-if-serial语义,编译器和处理器不会对存在数据以来的操作做重排序

happens-before原则

某些代码必须发生在某些代码之前

CPU可以同一时间可以并发执行指令

比如

1
2
3
4
5
6
7
8
9
10
i=0;
j=0;
sout(j);
//那么我就可以
j=0;
i=0;
sout(j);
//这样i和sout就可以同一时间执行,缩小运行时间
volatile int i=0;
//可以保证 i=0;之前的代码不进行重排序

加上volatile可以避免指令的重排序

如何保证可见性
  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存中
1
2
3
4
5
6
7
volatile int i=0;
int j=0;
public void method1(){
j=0;
i=0;
//写屏障
}
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
1
2
3
//读屏障
System.out.println(i);
System.out.println(j);