JUC JMM Java 内存模型
文章目录
计算机存储系统
存储系统是计算机的重要组成部分之一。存储系统提供写入和读出计算机工作需要的信息(程序和数据)的能力,实现计算机的信息记忆功能。现代计算机系统中常采用寄存器、高速缓存、主存、外存的多级存储体系结构。借用一张网络图片如下:
如图:其中越顶端的越靠近CPU,存储器的速度越快、容量越小、相应的价格越高。
- CPU 寄存器:寄存器是 CPU 的组成部分,是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。(在 JVM 中,寄存器是线程私有的,每个线程使用的局部变量是存储在寄存器中,且相互不影响)
- 高速缓存(L1、L2、L3)
- L1 缓存分成两种,一种是指令缓存,一种是数据缓存。L2 缓存和 L3 缓存不分指令和数据。
- L1 和 L2 缓存在每一个 CPU 核中,L3 则是所有 CPU 核心共享的内存。
- L1、L2、L3 的越离CPU近就越小,速度也越快,越离 CPU 远,速度也越慢。
再往后面就是内存,内存的后面就是硬盘以及一些外接存储设备等。这些设备的存储速度:
- L1 的存取速度:4 个CPU时钟周期
- L2 的存取速度:11 个CPU时钟周期
- L3 的存取速度:39 个CPU时钟周期
- RAM内存的存取速度 :107 个CPU时钟周期
打开我们电脑的任务管理器,选择 CPU 就能查看 L1、L2、L3 缓存的大小
我们编写的 Java 代码中的多线程共享变量(就是存储在堆内存的变量:如 对象的字段、static 静态变量等都是多线程共享),数据就从内存向上,先到 L3,再到 L2,再到 L1,最后到寄存器进行 CPU 计算。由于每个存储设备效率不一样,比如寄存器已经进行了 10 次计算,L1 缓存才存取一次数据,这就会导致一个比较复杂的问题:缓存一致性问题。
高速缓冲存储器一致性
达到如下情况表示缓存是一致的
- 任何进程所发出的缓存操作被存储器所观察到的顺序必须与进程发出操作的顺序相同;
- 每个读操作所返回的值必须是最后一次对该存储位置的写操作的值。
如果不满足以上情况,在每次运算之后,不同的进程可能会看到不同的值,这就是缓存一致性问题。
说了大半天,终于要绕回我们 Java 了
JMM(Java Memory Model)
JMM是一种抽象的概念,它规定了Java 程序中多线程并发访问共享内存的方式和规则,保证了多线程程序在不同平台上的正确性和一致性。
JMM 只是一个规范,JMM 要实现屏蔽各种硬件和操作系统的访问差异,需要依托于 JVM 。所以我们可以认为 JMM 是 JVM 的一部分。
可见性
指当一个线程修改了某一个共享变量的值,其他线程是否立即知道该变更,JMM 规定了所有的变量都在主内存中
- 共享变量存储在主内存
- 每个线程有其私有的工作内存
- 线程执行时,从主内存读取共享变量的副本到工作内存进行计算(Load)
- 线程计算完成后,将计算后的副本写回主内存(Save)
- 线程间的通信是通过这些副本来实现的(但由于这些线程读写的时机并不相同,就可能出现数据不一致的情况)
如果一个共享变量初始值为 a = 10,有 A、B、C 三个线程并发执行,假设A、B、C 线程依次修改完成 a 变量,其操作示意图如下:
这样线程 A、B 对 a 变量的操作就被覆盖了
可见性的代码示例:
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
@Slf4j
public class JMMTest {
private static boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
while (true){
// 如果这里添加打印,flag 也会对该线程可见
// 因为 println 方法中有 Synchronized 代码块
// System.out.println();
if(!flag){
break;
}
}
},"t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("修改前");
flag = false;
log.debug("修改后");
}
}
- 可以通过 volatile 关键字来解决此问题(volatile 关键字只能解决可见性问题,并不保证原子性和有序性。)
- 也可以在线程中添加 synchronized 关键字为其加锁也可以达到目的( synchronized 关键字既可保证可见性,也可以保证原子性,但 synchronized 关键字是重量级锁)
- synchronized 关键字不能阻止指令重排,但它保证了只有一个线程能操作 synchronized 块中的共享变量,如果这个共享变量不会出现在 synchronized 块外,被其他线程使用, synchronized 关键字也可以保证有序性的结果
原子性
是指同一个操作不可打断,在多线程情况下,操作不能被其他线程锁干扰。(类比数据库的事务的原子性)
有序性
有序性保证了一个线程在执行过程中,每个操作都按照一定的顺序进行。它通过禁止指令重排和设置内存屏障来实现有序性。
指令重排
指令重排是指编译器在不改变程序执行结果的前提下,对指令进行重新排列,以提高性能。也就是说我们写的代码是 1234 行,但指令重排后真正执行的顺序为:3421。但此过程遵循指令之间的依赖关系,比如第 1 行为变量定义,第二行为变量的使用,那么第二行就不能被重排为第一行。
在单线程情况下,指令重排可以提高性能,且没有问题出现,但多线程情况下可能会导致程序出现错误。所以在多线程情况下,我们会采用禁止指令重排和设置内存屏障的方法来处理这类问题。
Happens-Before 原则
指一个线程的写操作,对另一个线程的读操作可见,那么这两个线程之间遵循 Happens-Before 原则。
- 次序性规则:一个线程内,写在前面的操作先于写在后面的操作执行。
- 锁定规则:一个锁的 unlok 先于 lock 方法执行(就是同一个锁必须解锁后才能上锁)
- volatile变量规则:对 volatile 变量的写操作先于读操作执行
- 传递性规则:如果 A 线程先于 B 线程执行,B 线程先于 C 线程执行,那么 A 线程会先于 C 线程执行。
- 线程启动规则:线程的 start 方法先于线程内容执行。
- 线程中断规则:线程中断发生后才能检测到中断
- 线程终止规则:线程中所有操作都先于线程终止执行
- 线程终结规则:一个对象的初始化方法先于其 finalize 方法执行
总结起来就是 Happens-Before 原则就是程序执行时,必须遵循一定的客观顺序(就是要吃饭就得先张嘴,客观的顺序,不能违背客观规律)。
volatile 关键字
如我们在 JMM 可见性中的示例,volatile 关键字可解决可见性问题,用于保证某个共享变量被某个线程操作后,其操作后的结果对其他线程立即可见(重新从主内存获取更新后的值到副本),我们在上面的 JMM 可见性中的示例就是 volatile 保证可见性的一个示例
volatile 保证可见性
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
@Slf4j
public class VolatileTest1 {
private static volatile boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
while (true){
// 如果这里添加打印,flag 也会对该线程可见
// System.out.println();
if(!flag){
break;
}
}
},"t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("修改前");
flag = false;
log.debug("修改后");
}
}
volatile 不能保证原子性
volatile 关键字不能保证原子性,也就是说 volatile 无法达到替换锁(Lock、Synchronized)的目的。但如果只有一个线程写,其他线程都是读的情况下,volatile 关键字可以达到我们想要的效果。所以我们常用 volatile 来保证共享数据对其他线程可见,用锁来保证共享数据到写的一致性(原子性)。
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
@Slf4j
public class VolatileTest1 {
public static void main(String[] args) {
Container1 c1 = new Container1();
Container2 c2 = new Container2();
for(int i = 0; i < 10; i++) {
new Thread(() -> {
for(int j = 0; j < 1000; j++){
c1.add();
c2.add();
}
}, "t"+i).start();
}
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(c1.getNum());
System.out.println(c2.getNum());
}
}
@Getter
class Container1{
private int num;
public synchronized void add(){
num++;
}
}
@Getter
class Container2{
private volatile int num;
public void add(){
num++;
}
}
运行结果:
10000
9859 //<-- 此值不确定,但肯定比10000小(原因就是 Volatile 不能保证原子性)
volatile 禁用指令重排(保证有序性)
class Test2Container{
private int num;
private boolean flag;
public void write(){
num = 10;
flag = true; // 如果指令重排,此指令可能在 num = 10 之前执行
}
public void read(){
if(flag){
System.out.println(num);// 如果指令重排,此处可能出现 0
}
}
}
使用 volatile 禁用指令重排
@Getter
class Test2Container{
private int num;
private volatile boolean flag;
public void write(){
num = 10;
flag = true; // Volatile 写之后加入写屏障,
// 写内存屏障会确保在此次写操作之后所有的读操作都会被更新,从而保证其他线程能够立即感知到修改的值。
}
public void read(){
// 读内存屏障会确保在此次读操作之前所有的写入操作都会被完成,从而保证读取到的值是最新的
if(flag){ // // Volatile 读之前加入读屏障
System.out.println(num);
}
}
}
内存屏障(Memory Barrier)
读写屏障的实现机制是通过在指令之间插入内存屏障(Memory Barrier)来禁止指令重排。内存屏障是一种同步机制,它会强制让编译器和处理器按照指定的顺序执行指令。
- 写屏障
对 Volatile 变量的写指令后会加入写屏障,写内存屏障会确保在此次写操作之后所有的读操作都会被更新,从而保证其他线程能够立即感知到修改的值。
- 读屏障
对 Volatile 变量的读指令前会加入读屏障,读内存屏障会确保在此次读操作之前所有的写入操作都会被完成,从而保证读取到的值是最新的
其实还有一种 4 种屏障的说法,但它们的理解上晦涩难懂,不如读写屏障这样解释得清楚。如果非要较这个真,可以自行搜索搜索。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!