JMM
- 它并不是真实存在的,它描述的是一组规范或者说是规则。
- 通过这组规范定义了程序中的各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。
|
JMM内存模型图示 |
JMM关于同步的规定
- 线程解锁前:必须把共享变量的值刷新回主存。
- 线程加锁前:必须读取主存中最新的共享变量的值到自己的工作内存中。
- 加锁和解锁的锁是同一把锁。
主内存
- 相当于堆内存。
- 这个主内存不是JAVA虚拟机的而是硬件上的内存条的内存。
工作内存
- 相当于栈内存。
- 但是他有指向堆内存的指针。
原子性
- 即一个操作是不可中断的,和事务类似。
- JAVA中:
- 所有引用类型的复制操作。
- 除了long和double类型意外的基本类型赋值操作(32位)。
- java.concurrent.Atomic.* 包中所有类的一切操作。
可见性
- 并发环境下,一个线程修改了一个共享资源,其它线程能够立马就知道这个变量被修改过了。
- 可见性指每次读取值的时候可见,通过ABA问题可以推断,它的通知时间为一次原子操作之后,才会重新读取主内存中的值来保持可见性。
- 可见性是一个综合性问题:
- 内存的读写操作不会立即执行,而是会先进入一个硬件队列等待,当然,我们也可以通过设置 volatile 来修改直接写进内存,而不是写入缓存。
- 还有指令重排以及编译器优化。
有序性
- JMM 是允许编译器和处理器对指令进行重新排序的,但不管怎么重新排序,程序允许的结果是不能改变的!
- 指令重排的前提是串行语意的一致性,不能保证多线程的语意也一致。
- 不能重排的指令:
- volatile 规则:volatile 修饰的变量的写比读先发生,这保证了 volatile 变量的可见性。
- 传递性:A -> B -> C,A 必定先于 C 发生。
- 线程的 start 方法先于它的每一个操作。
- 线程的所有操作都在线程的终结之前。
- 线程的中断比被中断线程的代码先进行。
- 对象的构造函数执行和结束都在 finalize 方法之前。
- 锁规则:解锁必然发生在加锁之前。
内存屏障
什么是内存屏障
- 内存屏障,又称内存栅栏,是一个CPU指令,它可以保证特定操作的执行顺序。
作用
- 保持特定操作的执行顺序。
- 保证某些变量的内存可见性,刷新CPU缓存,强制刷出各种CPU的缓存数据到主内存中,所以任何当前CPU上的线程都可以拿到最新的数据。
- volatile关键字就是利用该特性实现的内存可见性。
如何作用
- 编译器和CPU都可以对指令进行重排序。
- 我们在指令之间插入一条内存屏障则会告诉编译器和CPU,此时不管什么指令都不能和这条内存屏障指令进行重排序。
- 通俗来讲就是通过插入内存屏障来禁止CPU或编译器对内存屏障前后的指令进行重排序优化。
作用场景
|
作用场景 |
场景一:对volatile变量进行写操作
- 对volatile变量进行写操作。
- 会在写操作后面插入一条 store 屏障指令,将工作内存中的共享变量值刷新回到主内存。
场景二:对volatile变量进行读操作
- 对volatile变量进行写操作。
- 会在读操作之前加入一条 load 屏障指令,从内存中读取共享变量。
重排序
- JAVA的源代码到最终实际执行的指令顺序,会经历以下三种重排序。
- 指令重排序只会保证串行语义的执行的一致性(单线程下),并不关系多线程并发状态下的语义一致性。
编译器优化的重排序
- 是指编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 处理器在进行重排序的过程中必须要考虑指令之间的数据依赖性。
- 多线程环境中线程交替执行,由于编译器优化重排指令的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
int a = 0;
int b = 0;
// 重排之后可能变成,他们之间没有数据依赖性,所以谁先谁后没问题。
int b = 0;
int a = 0;
int a = 0;
int b = 0;
int c = a + b;
// a 和 b 之间没有数据依赖性,但是 c 依赖于 a 和 b。
// 重排之后 c 永远是在 a 和 b 之后被执行的。
int b = 0;
int a = 0;
int c = a + b;
- 编译器指令重排是一回事,可能线程之间切换也会有不同的情况产生,我们不讨论这个。
指令级并行的重排序
- 现代处理器采用 ILP(指令级并行技术)来进行多条指令重叠执行。
- 如果数据之间没有依赖性,那么处理器可以改变语句对应的机器指令的执行顺序
内存系统的重排序
- 由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
volatile
什么是volatile
- volatile是Java虚拟机提供的一种轻量级的同步机制。
- 三大特性:
- 保证了可见性,但不保证原子性,禁止指令重排序。
- 从主内存同步工作内存。
- 满足了JMM规范的可见性,有序性,但是不保证原子性。
它是如何实现可见性和有序性的?
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器都能执行指令重排优化,所以如果在指令间插入一条**内存栅栏(Memory Barrier)则会告诉编译器和CPU,不管什么指令都不能和这条内存栅栏(Memory Barrier)**指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本,所以 volatile 通过这个特性实现的可见性。
什么情况下使用
不必要的编译器重排序情况
- 线程之间变量有数据依赖性。
|
不必要的编译器重排序情况下的案例 |
- 从上述例子我们可以看出,如果我们就想要编译重排之前的效果,但是编译重排之后数据结果不一致。
- 这时我们需要使用 volatile 关键字禁止指令重排,以确保在并发环境下发生结果不一致的情况。
验证volatile关键字的可见性案例
测试内容
- 资源类
class MyData {
private int number = 0;
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
}
class MyDataVolatile {
private volatile int number = 0;
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
}
- 主线程
package JUC;
import java.util.concurrent.TimeUnit;
/**
* @author zhaolimin
* @date 2021/11/12
* @apiNote 测试volatile可见性。
*/
public class VolatileDemo01 {
/**
* 可以保证可见性,及时通知其它线程物理内存的值被修改。
*/
public static void visibility(){
/*
假如 number 没有被 volatile 关键字修饰。
*/
// 新建资源类
MyData myData = new MyData();
MyDataVolatile myDataVolatile = new MyDataVolatile();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 线程创建后进入让当前线程等待三秒钟,这样做确保其它线程也可以取到最初的共享资源的值。
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myDataVolatile.setNumber(10);
System.out.println(Thread.currentThread().getName()+ "\t update number succeed to " + myDataVolatile.getNumber());
}, "Thread-A").start();
// 第二个线程我们就不 new 新的了,就用 main 线程。
// 如果共享资源没被更改就一直循环等待。
while (myDataVolatile.getNumber() == 0) {
// 这里加上 sout 语句的话也会刷新 number 的值。
//System.out.println(Thread.currentThread().getName()+"\t checking。。。");
//System.out.println("number is :" + myData.getNumber());
}
System.out.println(Thread.currentThread().getName() + "\tcheck succeed!" + "number is : " + myDataVolatile.getNumber());
}
public static void main(String[] args) {
visibility();
}
}
验证volatile不保证原子性的案例
测试内容
- 资源类
// 虚拟机读取过程:MyTest01.java -》 MyTest01.class -> JVM字节码
class MyTest01 {
// 添加了 volatile 关键字。
private volatile int number = 0;
public int getNumber() {
return number;
}
public void autoIncreased() {
// ++操作是不能保证原子性的,但是JUC中有对应的数据类型可以保证++操作的原子性。
number ++;
}
}
- 主线程
package JUC;
/**
* @author zhaolimin
* @date 2021/11/12
* @apiNote volatile 原子性测试。
*/
public class VolatileDemo02 {
public static void main(String[] args) {
// 资源类
MyTest01 myTest01 = new MyTest01();
// 20个线程
for (int i = 0; i < 20; i++) {
// 每个线程对 number 资源进行 ++ 操作1000次
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myTest01.autoIncreased();
}
}, String.valueOf(i)).start();
}
// 正常情况,如果确保了原子性,那么,我们的 number 最终的值应该是 1000 * 20 = 20000 才对
// 我们在等上面的循环执行完成之后我们再用 main 线程查看此时 number 的数量是不是和预期结果相等
// 默认单线程情况下,后台只有两个线程,一个是主线程,一个是GC线程。
while (Thread.activeCount() > 2) {
Thread.yield(); // 挂起线程。
}
System.out.println(Thread.currentThread().getName() + " 最终测试结果 number = " + myTest01.getNumber());
}
}
- 测试结果并不一定是预期的 20000 ,而是随机的数。
造成原因
- 就拿上面的案例来说。
|
图示 |
- 1、所有线程获得 number 的一份副本。
- 2、所有线程都进行了 ++ 操作,此时 number 在线程的各自工作内存中值都为 1。
- 3、线程 A 将其工作内存中的 number 的值写入主存的过程中突然被挂起。
- 4、此时 线程 B 获得 CPU 调度,进行了主存回写,主存中 number 此时就为1了。
- 5、此时 线程 A 解除了挂起状态。
- 6、由于 volatile 的禁止指令重排序的原因。
- 7、导致虽然 number 此时对于其它线程是可见的,但是此时不能进行指令重排序,重新读取主内存中最新的值去重新进行 ++ 操作。
- 8、当执行**++**的过程中, 即使知道变量被修改也只能硬着头皮执行完++的全部步骤, 不能中断下来重头读取。
- 9、主内存中 number 在线程 A 写回操作后,本该为 2 ,可是由于指令重排序,此时是将 number 的值覆盖为了 线程 A 工作内存中的值。
- 10、此时的操作可以看作,正确执行后应该是 number = number + 1;现在错误执行后是 number = 1。
对 ++ 操作 的字节码解析
-
这里面每一个字节码都是原子操作,但是一个 ++ 操作对应4个字节码操作,对于这4个字节码层面,它不可保证原子性。
-
可能某个线程到 putfield 的时候就被挂起了,然后禁止指令重排序,所以即使在当前线程被唤醒后通过可见性得知了主内存中值被更改,但是此时也不能掉头去重写读取再写回了,因为禁止指令重排序。
|
图示 |
解决方法
方法一
- 加 synchronize 关键字,但是不建议杀鸡用宰牛刀。
方法二
- 用 JUC 下的原子类包中的 AtomicInteger 来替换基本数据类型 int 的使用。
- 改动后的资源类
class MyTest01 {
// 添加了 volatile 关键字
private volatile int number = 0;
public int getNumber() {
return number;
}
public void autoIncreased() {
// ++操作是不能保证原子性的,但是JUC中有对应的数据类型可以保证++操作的原子性
number ++;
}
// 它的源码中默认的 value 是被 volatile 修饰过的,所以我们在这里不用修饰
AtomicInteger atomicInteger = new AtomicInteger();
public void autoIncreasedAtomicInteger() {
// 原子类类似 i++ 的方法
// 相当于atomicInteger.getAndAdd(1);
atomicInteger.getAndIncrement();
}
}
- 主线程
package JUC;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author zhaolimin
* @date 2021/11/12
* @apiNote volatile 原子性测试。
*/
public class VolatileDemo02 {
public static void main(String[] args) {
// 资源类
MyTest01 myTest01 = new MyTest01();
// 20个线程
for (int i = 0; i < 20; i++) {
// 每个线程对 number 资源进行 ++ 操作1000次
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myTest01.autoIncreased();
myTest01.autoIncreasedAtomicInteger();
}
}, String.valueOf(i)).start();
}
// 正常情况,如果确保了原子性,那么,我们的 number 最终的值应该是 1000 * 20 = 20000 才对
// 我们在等上面的循环执行完成之后我们再用 main 线程查看此时 number 的数量是不是和预期结果相等
// 默认单线程情况下,后台只有两个线程,一个是主线程,一个是GC线程。
while (Thread.activeCount() > 2) {
Thread.yield(); // 挂起线程。
}
System.out.println(Thread.currentThread().getName() + " 基本类型int,最终测试结果 number = " + myTest01.getNumber());
System.out.println(Thread.currentThread().getName() + " 原子类包装AtomicInteger,最终测试结果 number = " + myTest01.atomicInteger);
}
}
方法三
- CAS操作来控制。
线程安全性获得保证
- 工作内存与主内存之间同步存在延迟导致的可见性问题。
- 我们可以使用 synchronized 或 volatile 关键字解决,他们都会使一个线程修改变量过后,该变量立即对其它线程可见。
- 对于指令重排导致的可见性和有序性问题。
- 我们可以使用 volatile 关键字解决,volatile 关键字可以禁止指令重排序,不是实现指令重排序。
单例模式在并发环境下可能出现的问题
标准懒汉式单例模式
- 会出现多个实例,违背了单例模式原则。
package JUC;
/**
* @author zhaolimin
* @date 2021/11/12
* @apiNote 单例模式在并发环境下可能出现的问题。
*/
// 先写一个懒汉式单例模式。
public class SingletonDemo01 {
private static SingletonDemo01 singletonDemo01 = null;
private SingletonDemo01() {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo01()");
}
private static SingletonDemo01 getSingletonDemo01(){
if (singletonDemo01 == null) {
singletonDemo01 = new SingletonDemo01();
}
return singletonDemo01;
}
public static void main(String[] args) {
// 并发环境
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo01.getSingletonDemo01();
}, String.valueOf(i)).start();
}
}
}
解决方法
- 对象创建的过程会出现指令重排,可能导致多线程读取到的对象为半初始化的对象。
- 先分配了内存地址,但是此时初始化并没有完成就将对象返回了。
- 我们得防止创建实例的时候指令重排,从而让别的线程拿到了不完整的实例。
- 因为 new 不是原子操作,一般是三步:
- 分配内存空间 -> 初始化对象 -> 设置指针将对象指向内存地址。
- 第二步与第三步是不存在数据依赖关系,所以可以打乱在单线程情况下不会有什么大的问题,但是在并发环境下可能就会出现问题。
添加 synchronized 关键字
- 在 getSingletonDemo01 方法上添加 synchronized 关键字同步。
- 但是性能低。
- 没解决重排序的问题。
private static synchronized SingletonDemo01 getSingletonDemo01(){
if (singletonDemo01 == null) {
singletonDemo01 = new SingletonDemo01();
}
return singletonDemo01;
}
DCL模式
- Double Check Lock 双重检测锁模式。
- 进来和判断前,进来和进来后,分别判断两次。
- 因为指令重排序问题,不保证百分百的正确性。
private static SingletonDemo01 getSingletonDemo01(){
if (singletonDemo01 == null) {
synchronized (SingletonDemo01.class) {
// 防止创建2次实例,这里是单例模式
if (singletonDemo01 == null) {
singletonDemo01 = new SingletonDemo01();
}
}
}
return singletonDemo01;
}
DCL模式+volatile
- DCL模式已经将问题范围缩小到指令重排上。
- 所以我们加一个 volatile 关键字在它的变量,上来禁止指令重排,来保证并发环境下的语义一致性。
private static SingletonDemo01 getSingletonDemo01(){
if (singletonDemo01 == null) {
synchronized (SingletonDemo01.class) {
if (singletonDemo01 == null) {
singletonDemo01 = new SingletonDemo01();
}
}
}
return singletonDemo01;
}
码云仓库同步笔记,可自取欢迎各位star指正:https://gitee.com/noblegasesgoo/notes
如果出错希望评论区大佬互相讨论指正,维护社区健康大家一起出一份力,不能有容忍错误知识。
—————————————————————— 爱你们的 noblegasesgoo