什么是JMM模型
Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描 述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构 成数组对象的元素)的访问方式。JMM屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。
JMM是围绕原子性,可见性,有序性展开,那么什么是原子性,可见性,有序性?
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
多线程中可能出现的问题:
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。
可见性
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境中可就不一定了,由于线程对共享变量的操作都是线程 拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享 变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象 就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程 序轮序执行的问题,从而也就导致可见性问题。
有序性
程序执行的顺序按照代码的先后顺序执行。
我们总是认为代码的执行是按顺序依次执行的,这样 的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。
多CPU多级缓存会导致缓存一致性问题,CPU时间片会导致原子性问题,指令重排会出现有序性问题,所以,为了保证并满足并发编程中的原子性、可见性、有序性,就产生了一个重要的概念————内存模型
一个变量如何从主内存拷贝到内存,如何从工作内存到主内存之间的同步实现细节,Java内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的 变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中, 以便随后的load动作使用
- load(加载):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作 内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存 的变量
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作
- write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送 到主内存的变量中
如果把一个变量从主内存中复制到工作内存中,就需要按顺序的执行read和load操作,如果需要把工作内存中的变量同步回主内存中,则需要按照顺序的执行store、write操作,但是Java内存模型只要求上述操作必须按照顺序执行,没有要求必须连续执行。
JMM示意图
如上图所示,必须按照 read — load — use 顺序执行,但是不一定会连续执行。
同步规则
1、不允许一个线程无原因的(没有发生任何assign操作)把数据从工作内存同步回主内存中。
2、一个新变量只能在主内存中产生,不允许工作内存中直接使用一个未被初始化的(没有被load或者assign)变量。即:对一个变量use或者store之前,必须先自行load或者assign操作。
3、一个变量在同一时刻只允许被一个线程lock,但lock操作可以被同一个线程重复执行多次,多次执行lock操作后,必须执行相同次数的unlock,变量才会被解锁。即:lock和unlock必须成对出现。
4、如果对一个变量执行lock时,将会清空工作内存中此变量的值,在执行引擎使用此变量之前,需要重新load或者assign操作重新初始化变量的值。
5、如果一个变量事先没有被lock操作,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程lock的变量。
6、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中,即:必须执行store和write操作。
JMM如何解决原子性&可见性&有序性问题
- 原子性问题
在Java中,提供了两个高级的字节码执行:monitorenter 和 monitorexit来保证原子性。在Java中可以通过synchronized 和 Lock 实现原子性。synchronized 和 lock 可以保证任何时候只有一个线程访问该代码块。
- 可见性问题
volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized 和 Lock 也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
- 有序性问题
在Java里面,可以通过volatile关键字来保证一定的代码执行的有序性(在编译器编译时,会加上#Lock的字节码保证编译顺序)。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就 保证了有序性。
到这里我们可以发现,好像 synchronized 和 Lock 是万能的,他们可以同时满足以上三种特性,所以就导致了现在很多人滥用synchronized 和 lock的原因。但是synchronized比较影响性能,虽然编译器提供了很多种锁优化技术,但还是不建议过度使用。
总结:
JMM是一种规范,用来解决多线程通过共享内存进行通信时,与存在的本地内存中数据不一致,编译器会对代码进行指令重排、处理器会乱序执行代码等带来的问题,目的就是保证并发场景中原子性,可见性,有序性。
volitale
volatile 是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用
1、保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
2、禁止指令重排序优化。
volitale的可见性
关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中。
private volatile boolean initFlag = false;
private static Object object = new Object();
public void refresh(){
this.initFlag = true;
String threadName = Thread.currentThread().getName();
System.out.println("线程:"+threadName+": 修改共享变量initFlag");
}
public void load(){
String threadName = Thread.currentThread().getName();
int i = 0;
// 程序在此空跑,由于 volatile 修饰 initFlag,所以会一直嗅探其他线程对 initFlag 的修改
while (!initFlag){
/*
synchronized (object){
i++;
}
System.out.println(i);
*/
}
System.out.println("线程:"+threadName+" 当前线程嗅探到initFlag的状态值改变");
}
public static void main(String[] args) {
TestThread testThread = new TestThread();
Thread threadA = new Thread(()->{
testThread.refresh();
},"threadA");
Thread threadB = new Thread(()->{
testThread.load();
},"threadB");
threadB.start();
try {
Thread.sleep(2000);
}catch (Exception e){
e.printStackTrace();
}
threadA.start();
}
上面的示例可以看到,一共创建两个线程,线程A(threadA)去修改initFlag的值,线程B(threadB)在等其他线程修改initFlag后,跳出自旋(while (!initFlag)
),线程A改变initFlag属性之后,线程B马上感知到。
volitale无法保证原子性
public static volatile int i = 0;
public static void increase(){
i++;
}
在并发场景下,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时 调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是 先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一 个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一 个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使 用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法 后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就 完全可以省去volatile修饰变量。
volatile禁止重排优化
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,下面主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行 顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译 器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器 和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏 障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出 各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之, volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL
public static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
// 第一次判断
if(instance == null){
// 同步代码块
synchronized (DoubleCheckLock.class){
if(instance == null){
// 多线程环境下可能会出现问题的地方
instance = new DoubleCheckLock();
}
}
}
return instance;
}
上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
因为instance = new DoubleCheckLock();可以分为以下3步完成:
1、分配队形内存空间。
2、初始化对象。
3、设置instance指向刚分配的内存地址。此时instance! =null
由于步骤1和步骤2间可能会重排序,如下:
1、分配对象内存空间
3、设置instance指向刚分配的内存地址,此时instance! =null,但是对象还没有初始化完成!
2、初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单 线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一 致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null 时,由于instance实例未必已初始化完成,也就造成了线程安全问题。
那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。
// 禁止指令重排优化
public volatile static DoubleCheckLock instance;
为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。 下图是JMM针对编译器制定的volatile重排序规则表。
是否能重排 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通读/写 | volitale读 | volitale写 |
普通读/写 | NO | ||
volitale读 | NO | NO | NO |
volitale写 | NO | NO |
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上图可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
-
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
volatile写插入内存屏障后生成的指令序列示意图
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主 内存。
这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免 volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在 一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方 法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略: 在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad 屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写 之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
网友评论