一、什么是JMM
Java 内存模型(Java Memory Model 简称JMM)是一种抽象的概念,并不真实存在,指一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。
因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。
结构:
JVM运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(栈空间),存储当前线程私有的数据。而Java内存模型规定所有的变量都存储在主内存中,包括实例变量,静态变量(但不包括局部变量和方法参数)。
主内存被所有线程共享,但线程对变量的操作都在各自的工作内存中进行。
首先将变量从主内存拷贝到各自的工作内存中,再对变量进行操作,操作完成再将变量从工作内存写回到主内存,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。图解如下:
每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是 Java 内存模型定义的线程基本工作方式。
二、JMM定义了什么?
▶ 原子性
一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。
思考:以下代码保证原子性吗?
int i = 2; // 基本类型赋值操作——原子性
int j = i; // 先读取i的值,再赋值到j,两步操作,不能保证原子性。
i++; // 先读取i的值,再+1,两步操作,不能保证原子性。
▶ 可见性
一个线程修改共享变量的值,其他线程能够立即知道被修改了。
Java利用volatile关键字来提供可见性。 被volatile修饰的变量,修改后会立刻刷新到主内存,当其它线程读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。
除了volatile关键字之外,final和synchronized也能实现可见性。
synchronized原理:在执行完,解锁之前,必须将共享变量同步到主内存中。
final:一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。
▶ 有序性
程序按照代码的先后顺序执行。 编译器为了优化性能,有时会改变程序中语句的先后顺序。 例如程序中: “a=6;b=7;” 编译器优化后可能变成 “b=7;a=6;” 但不影响程序的最终结果
在Java中,可使用synchronized或volatile保证多线程之间操作的有序性。
- volatile:使用内存屏障达到禁止指令重排序,以保证有序性。
- synchronized:一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。
三、8种内存交互
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
- lock (锁定):作用于主内存中的变量,把变量标识为线程独占的状态。
- read (读取):作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
- load (加载):作用于工作内存的变量,把read操作主存的变量放入到工作内存的变量副本中。
- use (使用):作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign (赋值):作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
- store (存储):作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
- write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
- unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
JMM 对于这8种指令的使用,制定了如下规则:
- 不允许read 和 load、store和write操作之一单独出现,即使用了read必须按顺序使用load
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有发生过assign操作的数据从工作内存同步回主内存中
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化(load或assign)的变量,就是对变量实施use、store之前,必须先执行load和assign操作
- 一个变量同一时刻只允许一个线程能对其lock,但可以被同一条线程重复lock多次,多次lock必须执行相同次数的unlock才能解锁(lock和unlock必须成对出现)
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
- 如果一个变量未被lock,就不能对其进行unlock,也不能unlock一个被其它线程锁住的变量
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存(执行store和write操作)
volatile关键字
volatile是Java提供的轻量级的同步机制
- 保证可见性
- 不保证原子性
- 禁止指令重排
1、保证可见性
一个线程修改共享变量的值,其他线程能够立即知道被修改了。
| 代码示例:编写一段程序,修改num的值
public class volatileDemo {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
// 1. 创建一个线程,判断num=0就一直循环
new Thread(()->{
while (num == 0){
}
}).start();
TimeUnit.SECONDS.sleep(1); // 休眠一秒,让上述线程充分有时间启动
num=1;
System.out.println(num);
}
}
执行结果:num修改了1,但是线程中的while循环还处于一直运行中
如果num 采用volatile修饰,则程序在休眠一秒后结束运行
// 加上volatile修饰变量
private volatile static int num = 0;
原因分析:volatile修饰的变量,当一个线程改变了该变量的值,其他线程是立即可见的。普通变量则需要重新读取才能获得最新值。流程如下:
2、不保证原子性
一个操作是不可分割,不可中断的。(线程A在执行任务时,不能被打扰也不能被分割)
| 代码示例:编写一段程序,执行num++
public class volatileDemo2 {
private volatile static int num = 0;
static void add(){
num++;
}
public static void main(String[] args) throws InterruptedException {
// 1.开启20个线程,执行add操作
for (int i = 1; i <= 20; i++) {
// 2.每个线程执行累加操作1000次
new Thread(()->{
for (int j = 1; j <= 1000; j++) {
add();
}
}).start();
}
// 如果存活线程>2,表示还有其他线程还在执行(因为Java默认有 main和gc 2个线程)
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+":"+num);
}
}
每次的执行结果都不一样,正确的值应该是:20000
原因分析:通过 javap - c xx.class命令查看字节码文件的add()操作
num++不是原子性操作,会当作三步:
- 获取num的值
- 执行num++
- 赋新值给num
在多线程情况下,线程A在获取到变量num=1时,可能线程B已经执行到了第二步++操作,等线程B执行第三部赋值操作成功后,而线程A此时继续执行代码num++,赋值了num=2的操作,2个线程重复赋值num=2,最终导致结果不一致
注意:要保证原子性可使用 lock锁、synchronized给count++这段代码上锁、或者使用JUC包下的atomic原子类操作
3、禁止指令重排
为了使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率,只要程序的最终结果与它顺序化情况的结果相等,则指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。
———计算机并不一定是按照你写的代码顺序执行的。
重排序的种类分为三种:
1)编译器优化:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去是在乱序执行。
volatile 禁止指令重排的原理:在被volatile修饰的语句前后,各增加一道屏障,屏障前的语句在保证与顺序化的结果一致的情况下,指令可以进行重新排序(即代码的顺序与执行的顺序不一致),屏障后的语句也是如此!由于设置了内存屏障,可以保证避免指令重排的产生!
四、总结
关于JMM的一些同步约定:
1. 线程解锁前,必须把工作变量立刻刷回主内存中
2. 线程加锁前,必须读取主内存中最新的值到工作内存中
3. 加锁和解锁是同一把锁