这世界上没有优秀的理念,只有脚踏实地的结果 。
前言:
本篇文章基于JMM模型及内存的八大原子操作,如果有同学对这些内容不太熟悉可以看我的上一个文章 《 Java内存模型(JMM)与八大原子操作详解》
并发编程三大特性:原子性、可见性、有序性。
一、volatile概述:
volatile是一个关键字,它能保证变量在多线程之间的可见性,禁止CPU执行时进行指令重排操作(内存屏障)从而能保证有序执性,但是它并不能保证原子性。
今天我用几个例子证明及讲述一下以上几个问题。。如果有说的的不对的地方还请大家及时评论指出,一起进步!!
二、volatile实现可见性
1、代码片段:
package com.demo_maven.demo_maven.VolatileDemo;
public class Demo1 {
//定义一个共享变量
private static boolean flag;
public static void main(String[] args) {
System.out.println("开启main线程。。。");
Thread t1 = new Thread(() -> {
while (!flag) {
//System.out.println("值未改变,当前值为:" + flag);
}
System.out.println("线程:" + Thread.currentThread().getName() + "感知到了flag值的变化,当前值为:" + flag);
}, "t1");
t1.start();
//主线程睡眠100毫秒
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t2 = new Thread(() -> {
flag = true;
System.out.println("线程:" + Thread.currentThread().getName() + "将值改为:true");
}, "t2");
t2.start();
}
}
上面代码很简单 定义一个共享变量(此时没有加volatile修饰),开启两个线程,一个线程修改变量的值,另外一个线程一直去循环获取修改后的值。
执行结果:
当我执行这段代码的时候就会发现,程序一直在执行没有结束,线程 T1一直没有获取到线程T2修改后的值。(其实一直等下去线程T1也是能获取到最新的flag值,不过不知道要等到猴年马月,我没有继续等哈哈)
加上volatile关键字之后再执行刚才的代码。
//定义一个共享变量
private volatile static boolean flag;
在让我们看一下执行结果。。。
很显然T2将 flag值 false--->true,T1立即就获取到了更新后的值。
2、结论:
通过上述例子,我们会发现当变量没有被volatile 关键字修饰的时候,多线程对变量的操作结果很难被其他线程发现。当变量被volatile关键字修饰以后,多线程操作变量的结果会瞬间被其他线程发现,这也就是本节第二个知识点:volatile支持可见性。
3、实现原理:
1、首先我们先要了解一个概念,什么是缓存一致性协议?
自我总结:
现在计算机的CPU一般都是多核,CPU主要与主内存打交道,但是他们之间传输速度又不是很快,为了减少与主内存的交互次数,我们又引入了cpu多级缓存(L1,L2,L3)。那么和缓存一致性协议有什么关系呢?打个比方:比如我现在有个双核有三级缓存的CPU,cpu0从主内存获取到了变量a=0放到了自己的缓存中,cpu1此时也获取了变量a=0放入了缓存,cpu0修改了a=1并且写回了主内存,如果此时cpu1也要去操作变量a,但是发现自己缓存中a的值与主内存的值不一致,如果继续使用内存中的值执行,那么肯定会出问题,那么我们怎么办,所以出现了缓存一致性协议,它保证了cpu各个缓存中数据变化了也能保持数据的一致性。
接着看。。。。
计算机发展史:
在以前技术不够牛逼的时候,我们通过对bus总线(CPU与内存之间通信的桥梁)加锁【bus总线锁】来实现缓存一致性,但是由于总线锁是将整个总线加锁,当有cpu。。第一个加锁成功后,就将总线纳为己有不再让其他cpu与其通信,单核cpu的时候还好,但是随着技术越来越厉害,cpu炸裂了变成了多核,那么问题就来了,多核cpu运行时就会发现效率非常低,因为互相之间要进行等待,不能够发挥cpu多核优势。后人一看这样不行呀,辛辛苦苦搞出来的多核cpu没发挥出应有的效率,所以又吭哧吭哧搞出了另外一种缓存一致性协议——MESI,当然现在还有其它协议例如:MOESI,但MESI是主流,所以我们接下来重点说一下MESI缓存一致性协议。
2、MESI协议四种状态:
状态 | 描述 |
M(Modified 修改) | 表示缓存行数据被修改了,并且没有更新至主内存。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。简单的可理解为缓存行数据独占被修改且未同步 |
E(Exclusive 独占) | 表示缓存行数据是独占的。处于这一状态的数据,只有在本CPU中有缓存,其它CPU中没有缓存该数据,且其数据没有修改与主内存中一致。简单的可理解为缓存行数据独占且未被修改 |
S(Shared 共享) | 表示缓存行数据是共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致 |
I(Invalid 无效) | 表示缓存行数据是无效的。本CPU中的这份缓存已经无效。 |
3、接下来我们先来看一幅图:
图1:
执行过程大概划分为14个步骤,我划分的比较细。。。。
分为三种情况:
1、只有一个cpu更改变量值
第1步:线程A将number=0 读取到总线;
第2、3步:线程A将number=0 加载到了自己的工作内存中,此时number变量没有其它持有者,所以状态为E(独占);
第4步:此时线程B也将number=0 读取到了总线;
第5步:线程A所在的cpu1通过总线嗅探机制感知到了其他cpu对变量number进行了加载,此时将线程A中的持有状态从E改为S(共享)。因为此刻number被多个cpu使用;
第6、7步:线程B所将number=0将在到了自己的主内存,此时的状态为S(共享);
第8,9步:线程A对number=0进行了计算操作最后得到新的值number=1;
第10步:线程A将计算后的number=1更新到了自己的工作内存中,并且将状态由S改为M(修改);
第11、12步:线程A将number=1通过store同步到主内存,以便后续进行write操作;
第13步:线程B所在的cpu2通过总线嗅探机制感知到了number变量的变化,并且将自己内存中number=0的状态从 S 改为I(失效);
第14步:线程A通过write将最新的值写入了主内存,此时主内存中number的值为1。
2、多cpu同时修改变量值
主要区别的地方是第9/10步骤,大家想一下,第一种情况是只有线程A修改了变量,线程B并没有对变量做任何操作。如果线程A和线程B同时都对number进行计算操作呢?它们怎样将计算后的结果更新到内存的?
其实当遇到这种情况的时候,会通过总线裁决来处理谁先将结果写回主内存。
3、缓存对象超过了缓存行的大小
上述我们说的将变量加载到缓存中,其实是加载到了缓存的缓存行(cache line),缓存行是最小的存储区块,大小为64byte,当某个对象存储超过缓存行的大小的时候,MESI将会升级为bus总线锁来保证缓存一致性协议。
三:volatile禁止指令重排
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
硬件层的内存屏障
Intel硬件提供了一系列的内存屏障,主要有:
1. lfence,是一种Load Barrier 读屏障
2. sfence, 是一种Store Barrier 写屏障
3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力
4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:
屏障类型 | 指令示例 | 说明 |
LoadLoad | Load1; LoadLoad; Load2 | 保证load1的读取操作在load2及后续读取操作之前执行 |
StoreStore | Store1; StoreStore; Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1; LoadStore; Store2 | 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束 |
StoreLoad | Store1; StoreLoad; Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(上述我们已经讲过了)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子,如下:
1、代码片段:
public class SingleTest {
private static SingleTest instance;
private SingleTest () {}
public static SingleTest getInstance() {
if (instance == null) {
synchronized (SingleTest.class) {
if (instance == null) {
instance = new SingleTest();
}
}
}
return instance;
}
}
上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
因为instance = new SingleTest();可以分为以下3步完成
memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null
由于步骤1和步骤2间可能会重排序,如下:
memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。
//禁止指令重排优化
private volatile static DoubleCheckLock instance;
下图是JMM针对编译器制定的volatile重排序规则表。
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
四:volatile不支持原子性
1、代码片段:
package com.example.demo.volatile_demo;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo2 {
//定义一个对象
private static int number = 0;
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
number++;
}
}
});
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(number);
}
}
上面这段代码也是很简单的一段代码,相信大家都能看明白。。按照正常情况输出结果都应该为 :10*10000=1000000。
接下来让我们执行一下看看效果,第一次执行变量number没有添加volatile关键字修饰,我们执行多次发现最终结果依次为:
第1次执行结果值为:98109
第2次执行结果值为:91758
第3次执行结果值为:79331
第4次执行结果值为:67574
第5次执行结果值为:97578
第6次执行结果值为:91923
第7次执行结果值为:82238
第8次执行结果值为:96059
第9次执行结果值为:93955
以上结果我们发现结果都小于100000,与我们预期不同,难道是因为没有使用volatile吗?是不是加了它就能保证原子性,输出结果就是100000了?
第二次执行,这次我们使用volatile来修饰number,执行结果如下:
//定义一个对象,加上volatile关键字
private volatile static int number = 0;
第1次执行结果值为:69813
第2次执行结果值为:95399
第3次执行结果值为:77176
第4次执行结果值为:86733
第5次执行结果值为:97129
第6次执行结果值为:90584
第7次执行结果值为:90409
第8次执行结果值为:89029
第9次执行结果值为:86720
以上结果依旧是每次都小于100000,那就说明变量用不用volatile修饰,在并发的场景中都不能保证原子性(number++是多步组成,并不是原子操作,此处存在并发安全问题,也就不能保证原子性)。
再看一段代码:
public class Demo2 {
//定义一个对象
private volatile static int number = 0;
private static Object object = new Object();
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
//我们使用synchronized给下面的代码加上一个同步锁
synchronized (object) {
for (int j = 0; j < 10000; j++) {
number++;
}
}
}
});
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(number);
}
}
执行多次我们再看一下效果。。。。
第1次执行结果值为:100000
第2次执行结果值为:100000
第3次执行结果值为:100000
第4次执行结果值为:100000
第5次执行结果值为:100000
第6次执行结果值为:100000
第7次执行结果值为:100000
第8次执行结果值为:100000
第9次执行结果值为:100000
大家会发现不管我们执行多少次最终的结果就是100000,这也正是我们期望的值。看代码我们发现我们使用到了Synchronized,它是干什么的?
Synchronized实现了线程同步锁机制,它能保证原子性。Synchronized是JVM的内置锁 ,类似的还可以使用ReentrantLock进行加锁。它们之间有什么区别,我们该怎么选择,后续我也会单独一些篇关于这两种锁的文章。
2、结论:
volatile不能保证原子性,要想保证原子性我们要使用锁机制。
3、原理:
这里说为什么不能保证原子性操作,我们可以结合上面的图1来看