JAVA 内存模型JMM
从计算机的硬件存储说起
计算机存储结构,从本地硬盘到主内存再到CPU缓存,也就是从硬盘到内存再到CPU,对应的程序运行的一般操作是查数据到内存,再让CPU计算。
CPU 运行并不是直接操作内存而是先把内存里的数据读到缓存,而内存的读和写操作会造成不一致的问题
JVM规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致性的内存访问效果
什么是JMM
概述
JMM 是Java 内存模型 Java Memory Model,简称 JMM ,本身是一种抽象的概念,实际并不存在
。它描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程
)各个变量(包括实例字段,静态字段和构成数组对象的元素)的读写访问方式,并决定一个线程对共享变量的写入以及如何编程对另一个线程可见。
关键技术点是围绕多线程的原子性、可见性和有序性展开
JMM能做什么?
- 通过JMM来实现线程和主内存之间的抽象关系
- 屏蔽各个硬件平台和操作系统的内存访问差异实现让Java程序在各个平台下都能达到一致性的内存访问效果
JMM中几个重要的概念
- 主内存(Main Memory):主内存是所有线程共享的内存区域,其中包含了所有的变量。在主内存中,所有的变量都有唯一的地址标识
- 工作内存(Working Memory):工作内存是每个线程私有的内存区域,它保存了线程运行中所需要用到的变量副本;线程对变量的操作都是在工作内存中进行,不对主内存进行操作
- 内存交互(Memory Access):线程之间通过主内存进行数据的传递,当一个线程需要修改一个变量的值时,首先将变量从主内存复制到自己的工作内存中;当一个线程修改一个变量的值后,需要将更新后的值刷新回主内存。
JMM 规范下三大特性
JMM(Java Memory Model) 三大特性分别是原子性、可见性、有序性。这三个特性是JMM为了保证并发程序的正确性而设计和实现的
-
原子性(Atomicity):指一个操作是不可被打断的,即多线程环境下,操作不能被其他线程干扰。
JMM保证了基本类型(除了long和double)的读取和赋值操作是原子性的
。对于32位的基本类型(如int)的读取和赋值操作,JMM会将其作为原子操作进行处理,即一个线程对该变量的读取或赋值操作不会被其他线程中断,这是因为32位的操作可以在一次读取或写入CPU寄存器的操作中完成。但是:对于64位的long和double类型的读取和赋值操作,JMM并不能保证其原子性
,原因在于long和double类型的操作是由两个32位的操作组成的,如果在这两个32位的操作之间发生了线程切换,就可能导致读取或赋值的结果不一致。需要注意的是,虽然对一个32位基本类型的读取和写入操作是原子性的,但如果涉及到多个步骤,比如先读取再修改再写入,这些操作之间就不能保证原子性
。对于这种情况,可以使用synchronized关键字或者java.util.concurrent.atomic包中提供的原子类来保证原子性 -
可见性(Visibility):是指当一个线程对共享变量的修改对其他线程是可见的。在多线程程序中,如果一个线程修改了某个共享变量的值,其他线程必须能够及时地看到这个修改。否则,就可能会出现数据不一致的问题。为了保证可见性,Java采用了“
先写后读
”的策略。即一个线程对共享变量的修改必须先写入主内存,然后其他线程再从主内存中读取最新的值。为了保证可见性,开发者可以使用volatile关键字,该关键字可以确保一个变量的修改对其他线程是可见的。 -
有序性(Ordering):JMM规定了多线程环境中指令的执行顺序,包括处理器重排序、编译器重排序和指令级重排序等。JMM通过禁止一些类型的重排序来保证多线程程序的正确性。
什么是指令重排序
对于一个线程的执行代码而言,我们总是习惯性的认为代码的执行是从上到下,有序执行。但是为了提升性能,
编译器和处理器通常会对指令序列进行重新排序
。
Java规范规定JVM线程内部维持顺序话语义:只要程序的最终执行结果与它顺序话执行结果相等,那么指令的执行顺序可以和代码顺序不一致,此过程叫指令重排序
。指令重排序的优缺点
- 优点:
- 提升程序运行效率:JVM能根据处理器的特性(CPU多级缓存系统、多核处理器等)适当对机器指令进行重排序。使机器指令更符合CPU的执行特性,最大限度的发挥机器性能,提高程序运行效率。
- 减少依赖延迟:通过重排指令的执行顺序,可以减少指令之间的数据依赖关系,从而减少因依赖延迟而导致的等待时间,进一步提高执行效率
- 提高内存局部性:通过指令重排,可以将访问相邻数据的指令放置在一起执行,从而提高程序的内存局部性,减少对内存的频繁访问,提高缓存命中率
- 缺点
- 可能引入数据依赖违反:处理器在进行重排序时必须考虑指令之间数据的依赖性,指令重排需要在保持程序语义不变的前提下进行,但有时候由于复杂的数据依赖关系,重排可能导致原本正确的程序出现错误结果
- 可能破坏代码执行顺序:
指令重排可以保证串行语义一致,但不能保证多线程下语义一致。
对于依赖于指令执行顺序的代码(如多线程同步),指令重排可能会导致代码执行顺序不符合预期,引发并发相关的问题(比如可能产生“脏读”)因为多线程环境下线程交替执行,由于编译优化重排的存在,可能出现乱序现象,多个线程使用的变量能否保证一致性是无法确定的
,会导致结果无法预测- 可能引入性能下降:指令重排需要编译器和处理器进行复杂的分析和优化,但并非所有程序都能从指令重排中获益,某些特定的代码结构可能会因为重排而导致性能下降。
JMM规范下多线程对变量的读写过程
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值,到自己的工作内存
- 加锁和解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定的所有变量都存储在主内存,主内存是共享的内存区域,所有线程都可以访问。但是:
线程对变量的操作(读取、赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着从主内存中的拷贝过来的变量父辈,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。其简单的访问过程如下图
JMM规范下多线程并发先行发生原则 happens-before
概述
定义:Java内存模型(JMM)通过定义一组规则来确定程序运行不同操作之间的顺序关系,确保程序的行为是符合预期的,从而解决多个线程共享内存空间的据竞争和线程安全性的问题
在JMM中如果一个操作的执行结果需要对另一个操作可见或者指令重排序,那么这两个操作之间必须存在先行发生原则(happens-before) ,逻辑上的先后关系
例如,在代码中,如果一个线程执行了一个写操作(如int x = 10),那么在后续读取这个变量值的操作(如int y = x)之前,必须存在先行发生原则。这意味着写操作必须在读取操作之前完成,以确保读取操作能够获取到正确的值10 。如果不存在先行发生原则,那么 y=x 的这个操作就不能保证一定读的到 10 这个值, 这就是happens-before原则的示例----------->包含可见性和有序性的约束
先行发生原则说明
如果Java内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么有很多操作将变得非常常麻烦,但是在编写 java 并发代码的时候我并不会察觉到一点。
我们没有时时、处处、次次
,添加 volatile 和 synchronized来完成程序,这是因为 Java 语言中JMM原则下,有一个 “先行发生” (happens-before)的原则限制和规矩
,给你理好了规矩。
这个原则非常重要:它是判断数据是否存在竞争,线程是否安全非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子 解决并发环境下两个操作之间是否可能存在冲突的所有问题
,而不需要陷入 Java 内存模型晦涩难懂的底层编译原理之中
happens-before总原则
-
一个操作 happens-before 另一个操作,那么第一个操纵的执行结果对第二个操作可见,而且第一个操作的顺序排在第二个操作之前
-
两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则指定的顺序来执行,如果重排之后的执行结果与按照 happens-before关系来执行的结果一致,那么这种重排序并不非法
happens-before 规则
- 程序次序规则(Program Order Rule):一个线程内,按照代码的顺序,写在前面的操作先行发生于后面的操作,也就是说前一个操作的结果可以被后续的操作获取(保证语义串行性,按照代码顺序执行)。比如前一个操作把变量x赋值为1,那后面一个操作肯定能知道x已经变成了1
- 锁定规则(Lock Rule):一个unLock操作先行发生于后面对同一个锁的lock操作(后面指时间上的先后)。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的后面同样指时间上的先后
- 传递规则(Transitive):如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作
- 线程中断规则(Thread Interruption Rule):
-
- 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
-
- 可以通过Thread.interrupted()检测到是否发生中断
- 也就是说你要先调用interrupt()方法设置过中断标志位,我才能检测到中断发生
-
- 线程终止规则(Thread Termination Rule):线程中的所有操作都优先发生于对此线程的终止检测,我们可以通过isAlive()等手段检测线程是否已经终止执行
- 对象终止规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始------->对象没有完成初始化之前,是不能调用finalized()方法的
案例说明
初始案例演示:
private int value =0; public int getValue(){ return value; } public int setValue(){ return ++value; }
问题描述:假设存在线程A和B,线程A 先于(时间上的先后)调用了 setValue()方法,然后线程B调用了同一个对象的 getValue() 方法,那么线程B收到的返回值是什么?
答案:不确定
从 happens-before规则分析(本次的案例与规则5、6、7、8 无关) 1、由于两个方法由不同线程调用,不满足一个线程的条件,不满足次序规则 2、两个方法都没有用锁,不满足锁定规则 3、变量都没有使用volatile修饰,不满足volatile规则 4、只有两个线程操作,不满足传递规则 综上:无法通过happens-before规则推导出线程A happens-before 线程B,虽然可以确定时间上线程A 优于线程B,但是无法确定B获得的结果是什么,所以这段代码不是线程安全的
如何修复
- 把getter/setter方法都定义为synchronized方法------->不好,重量锁,并发性下降
private int value =0; public synchronized int getValue(){ return value; } public synchronized int setValue(){ return ++value; }
- 把Value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景
/** * 利用volatile保证读取操作的可见性, * 利用synchronized保证符合操作的原子性结合使用锁和volatile变量来减少同步的开销 */ private volatile int value =0; public int getValue(){ return value; } public synchronized int setValue(){ return ++value; }
happens-before 总结
-
在Java语言里面,Happens-before的语义本质上是一种可见性
-
A happens-before B ,意味着A发生过的事情对B而言是可见的,无论A事件和B事件是否发生在同一线程里
-
JVM的设计分为两部分:
- 一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解happens-before规则,就可以编写并发安全的程序了
- 另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束从而提升性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序,我们只要关注前者就好了,也就是理解happens-before规则即可,其他繁杂的内容由JMM规范结合操作系统给我们搞定,我们只写好代码即可