文章目录
一、主要作用
- 保证可见性
- 禁止指令重排
二、可见性
1. 是什么
可见性是由于多cpu而导致的缓存不一致问题
而JVM中用volatie来消除了它,可看图
2. 原理
volatile底层做了什么事情呢,其实就是volatile在赋值操作时,其后会执行一个指令,即内存屏障的功效,它有两个作用,其一是使得本cpu的高速缓存的该值会刷新到主内存,同时,使得其他cpu的高速缓存中的该值无效。这样便会在别的cpu使用时再去读取主内存的值,因此便做到了可见性,把缓存不一致性问题给消除掉了。其二是保证有依赖关系的变量操作
依赖关系代码示例:
// a = a * 2 依赖于 a = a + 10
a = a + 10 ;
a = a * 2 ;
b = b + 10
要将变量的值刷新会主内存时,必须保证之前所有的操作都已经执行完。
如a = a * 2 想刷新会主内存,在此之前必须保证先做了 a = a + 10 。
2.指令重排
2.1 是什么?
处理器会对代码进行指令重排以提升效率。
我们来看下什么是指令重排
参考代码
int a = 3 ;
a = a + 10 ;
// 它依赖于前面的操作,所以,重排不能够将它放在 a = a + 10之前进行
a = a * 2 ;
int b = 4 ;
int c = 5 ;
代码顺序是这样依次赋值,但是在实际中,可能会被cpu打乱顺序进行赋值。例如先执行了 int c = 5 ,当然这说的是没有依赖的代码指令之间的先后顺序。
2.2 指令重排在多线程存在问题
指令重排在一个线程当中是没问题,不会影响最终结果。
但是指令重排在多线程中就会出现问题,
指令重排的影响:
Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized = false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
// 指令重排时,它可能会放在最开始执行,然后
initialized = true;
//假设以下代码在线程B中执行
//等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();
我们来看下指令重排可能会发生什么事:
指令重排是保证单线程中是正常的。
// 指令重排时,它可能会放在最开始执行
initialized = true;
// 然后进行了配置准备
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
// 配置准备好后,进行一些业务处理
doSomethingWithConfig();
单线程中,上面进行了指令重排是没问题的,但是放在多线程中,
它的优先执行,B线程的判断逻辑看到了,执行了对应的逻辑,可是,A线程其实没有准备好配置呢。
2.3 原理
在volatile的写操作即赋值时,会在指令把修改同步到内存时,保证之前所有的操作都已经完成,这样便形成了指令重排序无法越过内存屏障的效果。
3 使用示例
3.1 single = new 对象() 并非原子操作 && 双重锁校验 && 解决指令重排
下面我们将会通过双重锁校验来说明new对象的过程和可能出现指令重排的现象,并演示用volatile来解决:
3.1.1 代码准备
/**
* 我们这里来分析下为什么双重锁校验会需要volatile
*/
public class 单例模式的应用_双重锁校验 {
private static volatile 单例模式的应用_双重锁校验 single ;
private 单例模式的应用_双重锁校验(){}
public static 单例模式的应用_双重锁校验 getSingle(){
if(single == null){
synchronized (单例模式的应用_双重锁校验.class){
if (single == null){
single = new 单例模式的应用_双重锁校验();
}
}
}
return single;
}
}
3.1.2 获取字节码指令 && 分析
通过这个命令可以获取到JVM字节码指令集:
javap -c 单例模式的应用_双重锁校验.class > 单例模式的应用_双重锁校验.txt
得到:
Compiled from "单例模式的应用.java"
public class com.example.demo.primary.volatile学习.单例模式的应用 {
public com.example.demo.primary.volatile学习.单例模式的应用 getSingle();
Code:
0: getstatic #2 // Field single:Lcom/example/demo/primary/volatile学习/单例模式的应用;
3: ifnonnull 37
6: ldc #3 // class com/example/demo/primary/volatile学习/单例模式的应用
8: dup
9: astore_1
// 我们着重关注锁住的这段代码
10: monitorenter
11: getstatic #2 // Field single:Lcom/example/demo/primary/volatile学习/单例模式的应用;
14: ifnonnull 27
// 以下就是new对象时的指令集
17: new #3 // class com/example/demo/primary/volatile学习/单例模式的应用
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field single:Lcom/example/demo/primary/volatile学习/单例模式的应用;
27: aload_1
28: monitorexit
29: goto 37
32: astore_2
33: aload_1
34: monitorexit
35: aload_2
36: athrow
37: getstatic #2 // Field single:Lcom/example/demo/primary/volatile学习/单例模式的应用;
40: areturn
Exception table:
from to target type
11 29 32 any
32 35 32 any
}
我们着重关注下创建对象的指令集:
// 1.创建对象,分配内存
17: new #3
// 2.操作数栈准备
20: dup
// 3.实例初始化,即<init>执行
21: invokespecial #4 // Method "<init>":()V
// 4.将对象赋值给引用变量
24: putstatic #2
// 5. 压入栈顶
27: aload_1
以上我们着重来看1,3,4三个步骤,由于3,和 4 都依赖于1,因此指令重排时必定在其后,而3,4没有依赖关系,可能有顺序变动。
线程一执行:
public class 单例模式的应用_双重锁校验 {
private static volatile 单例模式的应用_双重锁校验 single ;
private 单例模式的应用_双重锁校验(){}
public static 单例模式的应用_双重锁校验 getSingle(){
if(single == null){
synchronized (单例模式的应用_双重锁校验.class){
if (single == null){
// 假设当前线程一执行到了这里,由于这里对应多个指令
// 假设指令重排后的顺序时1,4,3
single = new 单例模式的应用_双重锁校验();
}
}
}
return single;
}
}
假设当线程一执行了1,4后,即先将创建出来的对象进行了赋值给引用变量,由于这个引用变量是共享资源,操作系统的cpu时间片假设在此时分给了线程二。而线程二拿了single判断不为null后要进行使用,由于线程一已经将对象赋值给了single,因此,线程二就会拿到还没有初始化的single,因此,就会出现问题。
3.2 volatile 不能做到线程安全
提示:volatile是可以做到指令重排和缓存一致的可见性。但是依然不是线程安全的,我们通过一个 i++ 来演示。
3.2.1 代码准备 && 分析
/**
* volatile变量自增运算测试 **@author zzm
*/
public class Volatile是线程不安全的 {
public static volatile int race = 0;
public static void increase() {
race++;
System.out.println(race+"++");
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
System.out.println("----------->");
for (int i = 0; i < THREADS_COUNT; i++) {
new Thread(
()->{
for (int j = 0; j < 10000; j++) {
increase();
}
}
).start();
}
//等待所有累加线程都结束
int count = 0 ;
while ( (count = Thread.activeCount()) > 2 ){
System.out.println("当前活跃线程数:"+count);
Thread.yield();
}
System.out.println(race);
}
}
执行结果总会小于200000,因为虽然volatile可以保证缓存一致,但是各cpu同一时刻从自己的工作内存中拿到 race ,假设都是100,因为 i ++ 不是原子操作,当从工作区拿到栈顶时,都是100,肯定有的线程先执行完,例如是101,然后刷新了其他工作内存,但是,由于其他cpu的栈是线程私有的,且之前已经读取过了值,为100,压栈成功了,因此,此刻其他cpu执行完后还是101,并刷新了主内存,这样就导致丢失更新,导致最终结果小于20 0000 。而如果将 读取和 加一整体变为原子操作,便可避免这个问题。
总结
提示:volatile的性能和优化过后的synchronized没有差别,因此,这个不是考虑选谁的一个因素。