👉来源 Bilibili 尚硅谷周阳老师学习视频:尚硅谷Java大厂面试题第二季
1_Volatile 和 JMM 内存模型的可见性
- JUC(java.util.concurrent)
- 进程和线程
- 进程:后台运行的程序(我们打开的一个软件,就是进程)
- 线程:轻量级的进程,并且一个进程包含多个线程(同在一个软件内,同时运行窗口,就是线程)
- 并发和并行
- 并发:同时访问某个东西,就是并发
- 并行:一起做某些事情,就是并行
- 进程和线程
- JUC 下的三个包
- java.util.concurrent
- java.util.concurrent.atomic
- java.util.concurrent.locks
- java.util.concurrent
谈谈你对 Volatile 的理解
volatile 在日常的单线程环境是应用不到的
volatile 是 Java 虚拟机提供的 轻 量 级 \color{green}{轻量级} 轻量级 【乞丐版 synchronized】的同步机制。
volatile 修饰的变量具有三种特性:
- 保证可见性
- 不保证原子性【原子性:完整性,不可缺性,中间不可以被分割,要么成功,要么失败】
- 禁止指令重排序【计算机底层实现是:会在其前后加内存屏障,禁止内存屏障前后的指令进行重排序优化】
那你能否写一个 Demo 验证一下可见性 ?
class Demo {
public static void main(String[] args) {
//资源类
Date date = new Date();
new Thread(() ->{
System.out.println(Thread.currentThread().getName() + "线程开始执行");
// 线程睡眠3秒
try {
TimeUnit.SECONDS.sleep(3);
date.setNumber();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A").start();
//模拟线程B:一直在这里等待循环,直到 number 的值不等于零
while (date.number == 0){
}
//只要变量的值被修改,就会执行下面的语句
System.out.println(Thread.currentThread().getName() + "执行结束");
}
}
class Date{
//volatile 保证可见性
volatile int number;
public void setNumber(){
number = 60;
}
}
详细过程就是:
- 线程 a 从主内存读取 共享变量 到对应的工作内存
- 对共享变量进行更改
- 线程 b 读取共享变量的值到对应的工作内存
- 线程 a 将修改后的值刷新到主内存,失效其他线程对 共享变量的副本
- 线程 b 对共享变量进行操作时,发现已经失效,重新从主内存读取最新值,放入到对应工作内存。
你能否写个 Demo 验证一下 不保证原子性?
public class Demo2 {
public static void main(String[] args) {
Date2 date2 = new Date2();
//开启20个线程
for(int i = 0;i < 20;i++){
new Thread(() -> {
//每个线程执行1000次++操作
for (int j = 0;j < 1000;j++){
date2.setNumberPlus();
}
},String.valueOf(i)).start();
}
//让20个线程全部执行完
while (Thread.activeCount() > 2){ //main + GC
//礼让线程
Thread.yield();
}
//查看最终结果
System.out.println(date2.number);
}
}
class Date2{
volatile int number;
public void setNumberPlus(){
//让其自增
number++;
}
}
详细过程是:
-
假设现在共享变量的值是 100 ,线程 A 需要对变量进行自增 1,首先它从主内存中读取变量值,由于 CPU 切换关系,此时切换到 B线程;
-
B 线程也从主内存中读取变量值,此时读取到的变量值还是 100,然后在自己的工作内存中进行了 + 1 操作,但是还未刷新回主内存;
-
此时,CPU 又切换到了 A线程,由于 B 线程还未将工作内存中的值刷新回主内存,因此 A 线程中的值还是 100,A 线程对工作内存中的变量进行 + 1 操作;
-
线程 B 刷新 新的值 101 到主内存 ;
-
线程 A 刷新 新的值 101 到主内存;
结果就是:两次 +1 操作,却只进行了 1 次修改
那如何才能保证原子性呢 ?
方式1:使用 synchronized 【大材小用】
方式2:使用 JUC 下的 AtomicInteger 原子类【底层是基于 CAS】
public class Demo3 {
public static void main(String[] args) {
Date3 date3 = new Date3();
//开启20个线程
for(int i = 0;i < 20;i++){
new Thread(() -> {
//每个线程执行1000次++操作
for (int j = 0;j < 1000;j++){
date3.setAtomic();
}
},String.valueOf(i)).start();
}
//让20个线程全部执行完
while (Thread.activeCount() > 2){ //主线程 + GC
Thread.yield();//礼让线程
}
//查看最终结果
System.out.println(date3.number);
}
}
class Date3{
//创建一个原子 Integer 包装类,默认为0
AtomicInteger number = new AtomicInteger();
public void setAtomic(){
//相当于 atomicInter ++
number.getAndIncrement();
}
}
什么是指令重排序?如果不重排会有什么问题?你能否写一个禁止指令重排序的 Demo ?
为了提高性能,
单线程环境里面确保最终执行结果和代码顺序的结果一致 。
处理器在进行重排序时,必须要考虑指令之间的
数据依赖性
但是,当多线程交替执行时,由于编译器优化重排,两个线程在使用的变量能否保住一致性是无法确定的,结果无法预测 。
class Date4{
private volatile int a; //使用 volatile 禁止指令重排序
private volatile boolean flag;
public void set(){
a = 5;
flag = true;
}
public void print(){
while (flag){
a = a + 1;
System.out.println("打印成功" + a);
}
}
}
volatile 针对指令重排做了啥
volatile 实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个 CPU 指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器 和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
。 内存屏障另外一个作用是刷新出各种 CPU 的缓存数,因此任何 CPU 上的线程都能读取到这些数据的最新版本。
也就是过在 volatile 的写 和 读的时候,加入屏障,防止出现指令重排的!!!
那么你在什么场景下有使用到 volatile 呢 ?
单例模式中【双重检查机制[DCL]】:
public class Singleton6 {
//2.提供静态变量保存实例对象
private volatile static Singleton6 INSTANCE;
//1.私有化构造器
private Singleton6(){}
//3.提供获取对象的方法
public static Singleton6 getInstance(){
//第一重检查:针对很多个线程同时想要创建对象的情况
if(INSTANCE == null){
//同步代码块锁定
synchronized (Singleton6.class){
//第二重锁检查(针对比如A,B两个线程都为null,第一个线程创建完对象,第二个等待锁的线程拿到锁的情况)
if(INSTANCE == null){
INSTANCE = new Singleton6();
}
}
}
return INSTANCE;
}
}
请你说说为什么要在这里加上 volatile 呢?
因为创建对象分为 3 步:
- 分配内存空间;
- 初始化对象
- 设置实例执行刚分配的内存地址【正常流程走:instance ! = null】
但是,由于这 3 步不存在数据依赖关系 ,所以可能进行重排序优化,造成下列现象:
- 分配内存空间
- 设置实例执行刚分配的内存地址【instance ! = null 有名无实,初始化并未完成!】
- 初始化对象
所有当另一条线程访问 instance 时 不为null,但是 instance实例化未必已经完成,也就造成线程安全问题!
JMM 是什么
JMM (Java 内存模型)是一种抽象的概念 并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素,不包括局部变量和方法参数,这是线程私有的,不存在竞争关系)的访问方式。
-
具体的 JMM 规定如下:
- 所有 共享变量 储存于 主内存 中;
- 每条线程拥有自己的工作内存,保存了被线程使用的变量的副本拷贝;
- 线程对变量的所有操作(读,写)都必须在自己的 工作内存 中完成,而不能直接读写 主内存 中的变量;
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存中转来完成
数据传输速率:硬盘 < 内存 < < cache < CPU
两个概念:主内存 和 工作内存
-
主内存:就是计算机的内存,也就是经常提到的 8G 内存,16G 内存
-
工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中
- 当同时有三个线程同时访问 student 中的 age 变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝 。
- 当同时有三个线程同时访问 student 中的 age 变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝 。
即:JMM 内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。
缓存一致性
为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术
在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。
为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有 MSI、MESI 等等。
MESI
当 CPU 写数据时,如果发现操作的变量是共享变量,即在其它 CPU中 也存在该变量的副本,会发出信号通知其它 CPU 将该内存变量的缓存行设置为无效,因此当其它 CPU 读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
总线嗅探
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
总线风暴
总线嗅探技术有哪些缺点?
由于 volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用 volatile 关键字,至于什么时候使用 volatile、什么时候用锁以及 Syschonized 都是需要根据实际场景的。
线程安全获得保证
工作内存与主内存同步延迟现象导致的可见性问题
- 可通过 synchronized 或 volatile 关键字解决,他们都可以使一个线程修改后的变量立即对其它线程可见 。
对于指令重排导致的可见性问题和有序性问题
- 可以使用 volatile 关键字解决,因为 volatile 关键字的另一个作用就是禁止重排序优化 。