JUC面试(一)——JUCJMMvolatile 1.0
JUC&JMM
JMM
-
JUC(java.util.concurrent)
- 进程和线程
- 进程:后台运行的程序(我们打开的一个软件,就是进程),资源分配单位
- 线程:轻量级的进程,并且一个进程包含多个线程(同在一个软件内,同时运行窗口,就是线程),CPU调度单位
- 并发和并行
- 并发:一段时间内同时访问某个东西,就是并发
- 并行:一起做某些事情,就是并行
- 进程和线程
-
JUC下的三个包
并发,gouc
- java.util.concurrent
- java.util.concurrent.atomic
- java.util.concurrent.locks
- java.util.concurrent
概念&特性
JVM:java虚拟机
JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值,到自己的工作内存
- 加锁和解锁是同一把锁
JMM的三大特性,volatile只保证了两个,即可见性和有序性,不满足原子性
- 可见性
- 原子性
- 有序性
volatile
vɒlətaɪ
Volatile是Java虚拟机提供的轻量级
的同步机制(三大特性)
- 保证可见性
- 不保证原子性
- 禁止指令重排(指令不可插队)
Volatile内存交互原子操作:
read(读取):从主内存读取数据
load(载入):将主内存读取到的数据写入工作内存
use(使用):从工作内存读取数据来计算
assign(赋值):将计算好的值重新赋值到工作内存中
store(存储):将工作内存数据写入主内存
write(写入):将store过去的变量值赋值给主内存中的变量
lock(锁定):将主内存变量加锁,标识为线程独占状态
unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
例子:
线程修改共享变量initFlag,起始为false。
volatile是c语言开发的,底层汇编
volatile源码是访问主内存使用lock,unlock
内存屏障
为什么会有内存屏障
- 每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
- 用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。
内存屏障是什么
- 硬件层的内存屏障分为两种:
Load Barrier
和Store Barrier
即读屏障和写屏障。 - 内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
- 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
- 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
java内存屏障
- java的内存屏障通常所谓的四种即
LoadLoad
,StoreStore
,LoadStore
,StoreLoad
实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。 - LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
volatile语义中的内存屏障
- volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
- 由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
final语义中的内存屏障
- 对于final域,编译器和CPU会遵循两个排序规则:
- 新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
- 初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)
- 总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
- 写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
- 读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
- X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。
JMM模型
JMM 全称是 Java Memory Model. 什么是 JMM 呢? 通过前面的分析发现,导致可见性问题的根本原因是缓存 以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存 以及禁止重排序的方法。所以它最核心的价值在于解决可 见性和有序性。
它解决了 CPU 多级缓存、处理器优化、指令重排序 导致的内存访问问题,保证了并发场景下的可见性。
需要注意
JMM 并没有限制执行引擎使用处理器的寄 存器或者高速缓存来提升指令执行速度,也没有限制编译 器对指令进行重排序,也就是说在 JMM 中,也会存在缓存 一致性问题和指令重排序问题。只是 JMM 把底层的问题抽 象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令, 以及限制编译器的重排序来解决并发问题
JMM 抽象模型分为主内存、工作内存;主内存是所有线程 共享的,一般是实例对象、静态字段、数组对象等存储在 堆内存中的变量。工作内存是每个线程独占的,线程对变 量的所有操作都必须在工作内存中进行,不能直接读写主 内存中的变量,线程之间的共享变量值的传递都是基于主 内存来完成
Java 内存模型底层实现可以简单的认为:通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层 体系架构,将这些内存屏障替换成具体的 CPU 指令。对 于编译器而言,内存屏障将限制它所能做的重排序优化。 而对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作 前后各插入一些内存.
JMM 是如何解决可见性有序性问题的
简单来说,JMM 提供了一些禁用缓存以及进制重排序的方 法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final.等.
从源代码到最终执行的指令,可能会经过三种重排序。
- 编译器的重排序,JMM 提供了禁止特定类型的编译器重排 序。
- 处理器重排序,JMM 会要求编译器生成指令时,会插入内 存屏障来禁止处理器重排序
MESI缓存一致性协议
现在的处理器都是多核处理器,并且每个核都带有多个缓存(指令缓存和数据缓存,见下图)。为什么需要缓存呢,这是因为CPU访问内存的速度比较慢,所以在CPU和内存之间加了个缓存以提高访问速度。既然每个核都有缓存,那么假设两个核或者多个核同时访问同一个变量时这些缓存是如何进行同步的呢(缓存细分为一个个缓存行,有点像JMM模型),这就有了MESI协议。
缓存行的四个状态:
MESI中每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid)。下面我们介绍一下这四个状态分别代表什么意思。
M:代表该缓存行中的内容被修改了,并且该缓存行只被缓存在该CPU中。这个状态的缓存行中的数据和内存中的不一样,在未来的某个时刻它会被写入到内存中(当其他CPU要读取该缓存行的内容时。或者其他CPU要修改该缓存对应的内存中的内容时(个人理解CPU要修改该内存时先要读取到缓存中再进行修改),这样的话和读取缓存中的内容其实是一个道理)。
E:E代表该缓存行对应内存中的内容只被该CPU缓存,其他CPU没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容和内存中的内容一致。该缓存可以在任何其他CPU读取该缓存对应内存中的内容时变成S状态。或者本地处理器写该缓存就会变成M状态。
S:该状态意味着数据不止存在本地CPU缓存中,还存在别的CPU的缓存中。这个状态的数据和内存中的数据是一致的。当有一个CPU修改该缓存行对应的内存的内容时会使该缓存行变成 I 状态。
I:代表该缓存行中的内容时无效的。
EMSI状态转移图:
local read和local write分别代表本地CPU读写。remote read和remote write分别代表其他CPU读写。建议首次看EMSI内容的可以自己把下面这个表格写下来(我自己就是这么做的),这样理解会深一点。
当前状态 | 事件 | 行为 | 下一个状态 |
---|---|---|---|
I(invalid) | local read | 1.如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变为E2.如果其他处理器中有这份数据,且缓存行状态为M,则先把缓存行中的内容写回到内存。本地cache再从内存读取数据,这时两个cache的状态都变为S3.如果其他缓存行中有这份数据,并且其他缓存行的状态为S或E,则本地cache从内存中取数据,并且这些缓存行的状态变为S | E或S |
local write | 1.先从内存中取数据,如果其他缓存中有这份数据,且状态为M,则先将数据更新到内存再读取(个人认为顺序是这样的,其他CPU的缓存内容更新到内存中并且被本地cache读取时,两个cache状态都变为S,然后再写时把其他CPU的状态变为I,自己的变为M)2.如果其他缓存中有这份数据,且状态为E或S,那么其他缓存行的状态变为I | M | |
remote read | remote read不影响本地cache的状态 | I | |
remote write | remote read不影响本地cache的状态 | I | |
E(exclusive) | local read | 状态不变 | E |
local write | 状态变为M | M | |
remote read | 数据和其他核共享,状态变为S | S | |
remote write | 其他CPU修改了数据,状态变为I | I | |
S(shared) | local read | 不影响状态 | S |
local write | 其他CPU的cache状态变为I,本地cache状态变为M | M | |
remote read | 不影响状态 | S | |
remote write | 本地cache状态变为I,修改内容的CPU的cache状态变为M | I | |
M(modified) | local read | 状态不变 | M |
local write | 状态不变 | M | |
remote read | 先把cache中的数据写到内存中,其他CPU的cache再读取,状态都变为S | S | |
remote write | 先把cache中的数据写到内存中,其他CPU的cache再读取并修改后,本地cache状态变为I。修改的那个cache状态变为M | I |
保证可见性
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存
,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:
数据传输速率:硬盘 < 内存 < < cache < CPU
上面提到了两个概念:主内存 和 工作内存
- 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存
- 工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中
- 当同时有三个线程同时访问 student中的age变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝
即:JMM内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。
代码验证
但我们对于成员变量没有添加任何修饰时,是无法感知其它线程修改后的值
/**
* Volatile Java虚拟机提供的轻量级同步机制
*
* 可见性(及时通知)
* 不保证原子性
* 禁止指令重排
*
* @author: wzq
* @create: 2020-03-09-15:58
*/
import java.util.concurrent.TimeUnit;
/**
* 假设是主物理内存
*/
class MyData {
/*volatile*/ int number = 0;
public void addTo60() {
this.number = 60;
}
}
/**
* 验证volatile的可见性
* 1. 假设int number = 0, number变量之前没有添加volatile关键字修饰
*/
public class VolatileDemo {
public static void main(String[] args) {
// 资源类
MyData myData = new MyData();
// AAA线程 实现了Runnable接口的,lambda表达式
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 线程睡眠3秒,假设在进行运算
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改number的值
myData.addTo60();
// 输出修改后的值
System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
}, "AAA").start();
while(myData.number == 0) {
// main线程就一直在这里等待循环,直到number的值不等于零
}
// 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
// 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
System.out.println(Thread.currentThread().getName() + "\t mission is over");
/**
* 最后输出结果:
* AAA come in
* AAA update number value:60
* 最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性
*/
}
}
未加volatile,number一直为0,最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性,死循环:
加了volatile修改成员变量,可见性,AAA线程改值,JMM通知机制,主线程获取到最新number值:
主线程也执行完毕了,说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。