文章目录
- JMM
- CPU缓存模型
- 指令重排序
- 什么是 JMM?为什么需要 JMM?
- 并发编程的三个重要特性
- 原子性
- 可见性
- 有序性
- volatile关键字
- 如何保证变量的可见性?
- 如何禁止指令重排序?
- volatile 可以保证原子性么?
- volatile可以保证线程安全吗?
JMM
JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
对共享变量的可见性问题主要是由于内存缓存不一致造成的,由于不同操作系统对于缓存一致性的处理方式不一样,我们的Java需要制定一个统一的标准来屏蔽操作系统和硬件的操作细节
CPU缓存模型
CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
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。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。
操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
指令重排序
为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。
常见的指令重排序有下面 2 种情况:
- 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。
- 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
- 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
什么是 JMM?为什么需要 JMM?
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外(不同线程对一个变量的可见性),其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
并发编程的三个重要特性
原子性
一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
在 Java 中,可以借助synchronized
、各种 Lock
以及各种原子类实现原子性。
synchronized
和各种 Lock
可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile
或者final
关键字)来保证原子操作。
可见性
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
在 Java 中,可以借助synchronized
、volatile
以及各种 Lock
实现可见性。
如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
有序性
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
我们上面讲重排序的时候也提到过:
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
在 Java 中,volatile
关键字可以禁止指令进行重排序优化。
volatile关键字
volatile 是 Java 虚拟机提供的轻量级的同步机制(三大特性)
- 保证可见性
- 保证有序性(禁止指令重排)
- 不保证原子性
如何保证变量的可见性?
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
JMM(Java 内存模型)
JMM(Java 内存模型)强制在主存中进行读取
如何禁止指令重排序?
在 Java 中,volatile
关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
volatile关键字在Java中主要通过内存屏障来禁止特定类型的指令重排序。
-
1)写-写(Write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
假设一个场景,有两个变量
x
和y
,其中y
是volatile
变量,有两个线程同时对这两个变量进行赋值操作。public class WriteWriteBarrierExample {private int x;private volatile int y;public static void main(String[] args) throws InterruptedException {WriteWriteBarrierExample example = new WriteWriteBarrierExample();Thread thread1 = new Thread(() -> {example.x = 10;example.y = 20;});Thread thread2 = new Thread(() -> {System.out.println("x: " + example.x + ", y: " + example.y);});thread1.start();thread2.start();thread1.join();thread2.join();}}
在没有写屏障的情况下,编译器或处理器可能会对赋值操作进行重排序,导致
thread2
可能先看到y
被赋值为 20,然后才看到x
被赋值为 10。而使用了写屏障后,会确保在对y
进行写操作之前,x
的写操作一定已经完成。 -
2)读-写(Read-Write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读之前执行,保证了读取到的数据是最新的。
考虑一个场景,有一个
volatile
变量z
和一个普通变量w
,一个线程读取z
后再读取w
,另一个线程修改w
和z
。public class ReadWriteBarrierExample {private int w;private volatile int z;public static void main(String[] args) throws InterruptedException {ReadWriteBarrierExample example = new ReadWriteBarrierExample();Thread readerThread = new Thread(() -> {int tempZ = example.z;int tempW = example.w;System.out.println("z: " + tempZ + ", w: " + tempW);});Thread writerThread = new Thread(() -> {example.w = 30;example.z = 40;});readerThread.start();writerThread.start();readerThread.join();writerThread.join();}}
如果没有读屏障,可能会出现先读取到
w
的新值,然后再读取到z
的旧值的情况。有了读屏障后,确保在读取z
之后的普通读操作(读取w
)不会被提前到读取z
之前执行。 -
3)写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前。
假设有一个
volatile
变量a
和一个普通变量b
,一个线程先写a
再写b
,另一个线程先读b
再读a
。public class WriteReadBarrierExample {private int b;private volatile int a;public static void main(String[] args) throws InterruptedException {WriteReadBarrierExample example = new WriteReadBarrierExample();Thread writerThread = new Thread(() -> {example.a = 50;example.b = 60;});Thread readerThread = new Thread(() -> {int tempB = example.b;int tempA = example.a;System.out.println("b: " + tempB + ", a: " + tempA);});writerThread.start();readerThread.start();writerThread.join();readerThread.join();}}
没有写 - 读屏障时,可能出现
readerThread
先读取到b
的新值,然后读取到a
的旧值的情况。有了这个屏障后,确保写操作(对a
和b
的写)在readerThread
对b
的读之前完成,并且readerThread
对a
的读不会被重排序到对b
的读之前。
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”
双重校验锁实现对象单例(线程安全):
public class Singleton {private volatile static Singleton uniqueInstance;private Singleton() {}public static Singleton getUniqueInstance() {//先判断对象是否已经实例过,没有实例化过才进入加锁代码if (uniqueInstance == null) {//类对象加锁synchronized (Singleton.class) {if (uniqueInstance == null) {uniqueInstance = new Singleton();}}}return uniqueInstance;}
}
uniqueInstance
采用 volatile
关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
volatile 可以保证原子性么?
volatile
关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。
我们通过下面的代码即可证明:
/*** 微信搜 JavaGuide 回复"面试突击"即可免费领取个人原创的 Java 面试手册** @author Guide哥* @date 2022/08/03 13:40**/
public class VolatileAtomicityDemo {public volatile static int inc = 0;public void increase() {inc++;}public static void main(String[] args) throws InterruptedException {ExecutorService threadPool = Executors.newFixedThreadPool(5);VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();for (int i = 0; i < 5; i++) {threadPool.execute(() -> {for (int j = 0; j < 500; j++) {volatileAtomicityDemo.increase();}});}// 等待1.5秒,保证上面程序执行完成Thread.sleep(1500);System.out.println(inc);threadPool.shutdown();}
}
正常情况下,运行上面的代码理应输出 2500
。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500
。
为什么会出现这种情况呢?不是说好了,volatile
可以保证变量的可见性嘛!
也就是说,如果 volatile
能保证 inc++
操作的原子性的话。每个线程中对 inc
变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。
很多人会误认为自增操作 inc++
是原子性的,实际上,inc++
其实是一个复合操作,包括三步:
- 读取 inc 的值。
- 对 inc 加 1。
- 将 inc 的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
- 线程 1 对
inc
进行读取操作之后,还未对其进行修改。线程 2 又读取了inc
的值并对其进行修改(+1),再将inc
的值写回内存。 - 线程 2 操作完毕后,线程 1 对
inc
的值进行修改(+1),再将inc
的值写回内存。
这也就导致两个线程分别对 inc
进行了一次自增操作后,inc
实际上只增加了 1。
其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者AtomicInteger
都可以。
使用 synchronized
改进:
public synchronized void increase() {inc++;
}
使用 AtomicInteger
改进:
public AtomicInteger inc = new AtomicInteger();public void increase() {inc.getAndIncrement();
}
使用 ReentrantLock
改进:
Lock lock = new ReentrantLock();
public void increase() {lock.lock();try {inc++;} finally {lock.unlock();}
}
volatile可以保证线程安全吗?
volatile关键字可以保证可见性,但不能保证原子性,因此不能完全保证线程安全。volatile关键字用于修饰变量,当一个线程修改了volatile修饰的变量的值,其他线程能够立即看到最新的值,从而避免了线程之间的数据不一致。
但是,volatile并不能解决多线程并发下的复合操作问题,比如i++这种操作不是原子操作,如果多个线程同时对i进行自增操作,volatile不能保证线程安全。对于复合操作,需要使用synchronized关键字或者Lock来保证原子性和线程安全。