了解并发内存模型(JMM)和 Volatile
Posted XeonYu
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了了解并发内存模型(JMM)和 Volatile相关的知识,希望对你有一定的参考价值。
上一篇:
了解JVM中的GC
我们都知道,多个线程同时操作一个数据会有并发问题,那为什么会出现并先发问题呢,产生并发问题的原因是什么呢?
产生并发问题的原因
一般产生并发问题无外乎都跟以下三种特性相关
- 原子性
- 可见性
- 有序性
原子性:
比较好理解,多个线程在执行同一个任务时,其中一个线程在执行的中途不能切到其他线程再去执行该方法,不然就会出现数据不正确的问题。比如很经典的多线程下的卖票问题。这种问题我们可以通过加锁实现数据同步来解决。
下面我们来看看可见性问题
并发中的可见性问题
我们先来看一段代码:
class JMMDemo {
private boolean flag = true;
public void print() {
System.out.println("print 开始执行");
while (flag) {
}
System.out.println("flag的值被更改为了false");
}
public void changeFlag() {
System.out.println("changeFlag 开始执行");
this.flag = false;
System.out.println("flag = " + flag);
}
}
main方法:
public static void main(String[] args) {
JMMDemo jmmDemo = new JMMDemo();
new Thread(jmmDemo::print, "线程1").start();
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(jmmDemo::changeFlag, "线程2").start();
}
可以看到,代码很简单,就是起了两个线程,其中一个线程调print方法,另一个线程调用 changeFlag方更改flag的值。
正常来讲,我们期望的结果是线程2执行完changeFlag后,线程1就会执行完毕并打印出flag的值。
下面我们来运行看下结果:
可以看到,结果并不符合我们的预期,程序一直处于运行状态,print方法中的flag值打印语句并没有执行。
那这是为什么呢?
在之前的文章中,我们知道每个线程中都有自己私有的栈,变量的取值赋值等逻辑都是在线程私有的操作数栈中完成的。
虽然我们上面代码中两个线程是对同一个对象的同一个变量进行操作。
但是,线程操作共享变量时并不是直接对主内存中的变量进行操作,而是将变量拷贝一份到线程内部的工作内存中,操作的是自己工作内存中的数据,完成之后才会刷回到主内存中,且就算刷回到主内存中,其他线程对这个过程也是感知不到的。
因此,就算线程2把值改了,并刷回到主内存中,线程1拿到的值实际上还是true,因为线程1并不知道主内存的值被改变了,这就产生了并发问题中的可见性问题
CPU 中的缓存
我们在购买CPU的时候,通常会看几个参数,比如制程、核心数、线程数、频率等。如下图
可以看到,其中还有一个缓存以及总线速度。
那这个缓存是用来干什么的呢?
直接看百度百科关于 CPU缓存 的解释吧,有视频解释的很形象。
简单概括来讲就是 cpu的执行速度是远远快于内存的,如果直接对内存进行操作,就会严重拉低cpu的执行速度,而cpu缓存的出现就是为了解决这一问题。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不需要访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
大体结构图示如下:
这里画的比较简单,实际上缓存还分为一级缓存,二级缓存,三级缓存等,这里就不多介绍了,想深入了解的话看上面的百度百科即可。
JMM
Java Memory Model:Java 内存模型
首先说概念:
JMM是一种规范,跟CPU缓存模型类似,JMM是基于CPU缓存模型建立的,主要是把CPU缓存和主内存抽象成工作内存和主内存,这样我们就无需关心是什么型号的CPU,该CPU的缓存结构是怎样的。
我们只需要关心主内存和工作内存即可。
关于JMM主内存和工作内存,我们需要注意以下几点
- 所有变量实际上都是存储在主内存中的,各个线程中的工作内存只是保存了主内存变量的副本。
- 线程是不能直接操作主内存的中的变量,操作的是自己工作内存中的副本变量,操作完成后再同步到主内存
- 线程之间的变量是相互独立的,互相不可见,要想同步数据必须要通过主内存做中转。
JMM中定义的内存操作有以下8种:
操作 | 作用 |
---|---|
read(读取) | 从主内存中读取值 |
load(载入) | 将从主内存中读取的值加载到工作内存中,生成一个副本 |
use(使用) | 使用工作内存中的值 |
assign(赋值) | 对工作内存中的值进行赋值 |
store(存储) | 将工作内存的值存储到主内存中 |
write(写入) | 将store的变量值赋值给主内存中的变量 |
lock(加锁) | 对主内存中的变量进行加锁,标记为线程独占状态 |
unlock(解锁) | 对主内存中的变量进行解锁,解锁后其他线程可以进行锁定 |
由于线程之间数据的同步需要通过主内存中转来完成,所以上面那个例子产生的问题就是因为各个线程之间可见性问题导致的。
线程2把值改了,但是线程1并不知道值被更改了,所以结果跟我们的预期不符合。
大致如下图所示:
那我们怎么解决这种可见性问题呢?
Volatile
Volatile是Java内置的一个关键字,提供一种轻量的同步机制。
他有以下3个特性
- 保证可见性
- 不保证原子性
- 禁止指令重排
我们可以通过 Volatile 解决上述代码的可见性问题。
代码如下:
class JMMDemo {
private volatile boolean flag = true;
public void print() {
System.out.println("print 开始执行");
while (flag) {
}
System.out.println("flag的值被更改为了false");
}
public void changeFlag() {
System.out.println("changeFlag 开始执行");
this.flag = false;
System.out.println("flag = " + flag);
}
}
我们只需要给flag变量加一个volatile 关键字修饰即可。
运行效果如下:
可以看到,此时程序就符合预期了。
大致原理就是Volatile修饰的比变量会在值被修改后立马刷新回主内存,并且通过总线来通知其他线程,其他线程在收到通知后,会先将自己工作内存的变量标记为失效,在下次使用改变量的时候重新从主内存中读取一下,这样,就保证了各个线程之间的数据同步了。
同时,Volatile也可以解决有序性问题,防止指令重排。
比如比较常见的双重锁校验的单例模式,有比较小的可能性存在半初始化的问题,原因就是因为指令重排导致的,我们通过给变量加上Volatile关键字,就相当于给变量加上了内存栅栏,该变量就不会被进行指令重排了。
下面我们来看一段kotlin的代码:
在kotlin中,我们可以使用by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED)
来实现线程安全的懒汉式单例。
/*线程安全的懒汉式单例*/
companion object {
val instance: RetrofitFactory by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
RetrofitFactory()
}
}
这种写法实际上就跟java中的双重校验的单例一样,我们来看下kotlin是如何封装的。
通过下图可以看到,也是使用了Volatile关键字进行了修饰。
所以,在使用kotlin时,我们无需再手动添加Volatile关键字了。
好了,到这里我们就对JMM以及Volatile有了一个简单的认识,也明白的并发问题产生的根本原因了。
如果你觉得本文对你有帮助,麻烦动动手指顶一下,可以帮助到更多的开发者,如果文中有什么错误的地方,还望指正,转载请注明转自喻志强的博客 ,谢谢!
以上是关于了解并发内存模型(JMM)和 Volatile的主要内容,如果未能解决你的问题,请参考以下文章