【并发】深入理解JMM&并发三大特性(一)
【遗留问题】之前的程序什么时候会跳出循环?线程什么时候会刷主存?
【并发】深入理解JMM&并发三大特性(一)
今天是2022.11.16,在此之前我已经学习完了图灵课堂MySQL的课程,也是想这篇文章一样用CSDN博客的形式来记录这些知识点。
在并发中,JMM在大多数人眼中,它是整个Java并发编程中最难的部分也是最重要的部分!Guide上面有相应的描述:
JMM(Java 内存模型)详解 (javaguide.cn)https://javaguide.cn/java/concurrent/jmm.html
下面我讲详细介绍该内容~~~
一、并发和并行
1. 并行(parallel)
指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
2. 并发(concurrency)
指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行在多处理器系统中存在。而并发可以在单处理器和多处理器系统中都存在。
并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)
3. 并发三大特性
可见性、原子性和有序性!!!
这就是并发的3个特性,它们同样也是并发编程Bug的源头!!!在并发编程中出现BUG,十有八九是这3个特性处理不当所导致的。
(1)可见性(本章重点!)
当一个线程修改了共享变量的值,其他线程能够看到修改的值。
Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值。
这种依赖主内存作为传递媒介的方法来实现可见性的。
如何保证有序性?
- 通过 volatile 关键字保证可见性。
- 通过 内存屏障保证可见性。
- 通过 synchronized 关键字保证可见性。
- 通过 Lock保证可见性。(JUC下面的)
- 通过 final 关键字保证可见性
我们可以先来看一个例子:
我们这里开了A、B两个线程,A线程调了 load(),B线程调了refresh()。
换句话说就是,A会进入一个 while(flag) 的死循环,B试图修改flag,让其退出循环!
public class VisibilityTest {
private boolean flag = true;
private int count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (flag) {
//TODO 业务逻辑
count++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
Thread.sleep(1000);
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
}
运行结果:
但是,结果却和我们预想的不一样!这是因为 flag这个变量,在上述的环境中并不满足可见性!即线程B修改flag,对线程A来说,是不可见的!
如何解决???
我们可以使用 volatile关键字去修饰 flag 或 count;
可以在业务逻辑那一块调用 storeFence() 方法,开启内存屏障;
可以在业务逻辑中使用synchronized关键字;
也可以在业务中使用 System.out.println() 语句(里面有调synchronized);
还可以在业务中调用 Thread.yield() ,让其发生上下文切换......
具体这是为什么呢?我会在下文中解释清楚!因为这一块涉及到JMM内存模型、JVM的内存指令,我们只有先搞清楚这些东西,才能将这可见性问题讲明白!
(2)原子性(有后续)
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。
在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。
如何保证原子性?
- 通过 synchronized 关键字保证原子性。
- 通过 Lock保证原子性(JUC)。
- 通过 CAS保证原子性。
(3)有序性(有后续)
即程序执行的顺序按照代码的先后顺序执行。
JVM 存在指令重排,所以存在有序性问题。(后面再讲!)
如何保证有序性?
- 通过 volatile 关键字保证可见性。
- 通过 内存屏障保证可见性。
- 通过 synchronized 关键字保证有序性。
- 通过 Lock 保证有序性。
二、Java内存模型(JMM)
1. 什么是JMM?
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。
在JMM中规定了,一个线程 如何和何时 可以看到由其他线程修改过后的共享变量的值?以及如何同步的访问共享变量?
在JMM模型中,线程A 和 线程B 它们在逻辑上都有各自独立的内存空间,里面存放的共享变量是不能直接通信的,必须要经过“主内存”!
2. JMM与硬件内存架构的关系
Java内存模型与硬件架构之间存在差异。
对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。
如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系
所以,我们上面讲的主内存、本地内存,都是一个逻辑概念!
3. 内存交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把 一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
4. JMM的内存可见性保证
按程序类型,Java程序的内存可见性保证可以分为下列3类:
(1)单线程程序
单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
(2)正确同步的多线程程序
正确同步的多线程程序的执行将具有顺序一致性
程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
(3)未同步/未正确同步的多线程程序
JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。
未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序
- 顺序一致性模型保证对所有的内存读/写操作都具有原子性,而JMM不保证对64位的long型和double型变量的写操作具有原子性(32位处理器)
【了解即可】JVM在32位处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。
这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性
三、深入解析volatile
1. volatile的特性
- 可见性:对一个 volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。(不是说++操作哦!)
- 有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障 来禁止 指令重排序来保障有序性。
2. volatile修饰的变量 - 关于写/读操作理解
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中重新读取共享变量。
3. volatile可见性实现原理
(1)JMM内存交互层面实现
volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的!
就是说,修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。
(2)硬件层面实现
通过 lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”。
缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
(3)在JVM(hotspot)的实现
JVM中的字节码解释器(bytecodeInterpreter),用C++实现了JVM指令
bytecodeInterpreter.cpp
先判断是否是volatile,然后会为其选择合适的OS,最后调 storeload(),再到 fence()
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
if (os::is_MP()) {
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
x86处理器中利用lock前缀指令实现类似内存屏障的效果。
lock前缀指令的作用!!!
(1)确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销
(2)LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
(3)LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。
【遗留问题】之前的程序什么时候会跳出循环?线程什么时候会刷主存?
我们现在可以说了,程序什么时候会条出这个循环?之前提到了JMM内存模型,当一个线程中的本地内存里的变量“过期”的时候,那么线程将会重新到“主内存”里面去 read/load 新的值!
看下面的程序,我们通过调 shortWait(1000000) 来模拟业务执行时间。
while (flag) {
// TODO 业务逻辑
count++;
// 模拟业务执行多久
shortWait(1000000); // 1ms
}
......
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
当循环里面执行时间比较长时,缓存中的变量可能过期,就会重新到“主内存”里面去 read/load
当本地内存(缓存)被淘汰之后,可见性就可以得到保证!(淘汰了,就需要从主内存中重新加载)所以,我们解决可见性问题的核心,就是想办法将本地内存中的值淘汰,让其从主内存中重新加载!!!
Thread.yield();
释放时间片!会导致上下文切换,之后就要重新加载上下文,JMM就会对比主内存与本地内存中的值是否一致,不一致就会从主内存中重新加载!
private volatile boolean flag = true;
或
private volatile int count = 0;
volatile的底层会去调storeload()、fence() 里面有用到 lock前缀指令
lock前缀指令不是内存屏障指令,但是它具有一样的功能,它可以使缓存失效!
UnsafeFactory.getUnsafe().storeFence();
这个是开启内存屏障,它和上述的volatile类似,都会调到storeload(),fence()
System.out.println(count);
public void println(int x) {
synchronized (this) {
print(x);
newLine();
}
}
在 synchronized 里面要保证一些可见性,所以它里面也会调内存屏障!所以原因和上述的一样!
private Integer count = 0;
在Integer里面的这个value用了final修饰!
private final int value;
public Integer(int value) {
this.value = value;
}
用了final,也会从主内存中去重新加载!
总结:Java中的可见性如何保证?
最本质的其实只有两种!
(1)通过调storeLoad,用lock前缀指令,开启内存屏障!
(2)Thread.yield() 上下文切换