0%

可见性之volatile关键字

可见性之volatile关键字

什么是可见性

cpu在执行代码的时候,为了减少变量访问的时间消耗可能会将代码中访问的变量值缓存到该CPU的缓存区,所以在相应代码再次访问某个变量时,读到的值可能来自于缓存区而不是主存,同样,代码对这些缓存过的变量值的修改也可能只是被写入到缓存区,而没有回写到主存,且每个CPU都有自己的缓存区,因此一个CPU缓存区中的内容对于其他CPU是不可见的

可见性就是一个线程对于共享变量的修改能够及时的被其他线程看到

共享变量是指如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

在java规范中指出:为了获得更快的速度,允许线程保存共享变量的私有拷贝,而且只有在线程进入或者离开同步代码块时才会将私有拷贝与共享内存中的原始值进行比较

可见性的实现方式

  • synchronized(保证原子性和可见性) synchronized关键字
  • volatile(只保证可见性),如果变量已在synchronized代码块中,或者为常量时,没有必要使用volatile修饰,比synchronized更快,不会引起线程上下文的切换和调度

先举一个例子,来看一下不使用volatile的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class TestVolatile {

public static void main(String[] args) {
ThreadDemo runnable = new ThreadDemo();
new Thread(runnable).start();

while (true){
// 不会停止 一直在while循环
if(runnable.isFlag()){
System.out.println("----------");
break;
}
}
}

}

class ThreadDemo implements Runnable{

private boolean flag;
@Override
public void run() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;

System.out.println(Thread.currentThread().getName()+"---flag:"+flag);
}

public boolean isFlag() {
return flag;
}

public void setFlag(boolean flag) {
this.flag = flag;
}
}

结果

1
Thread-0---flag:true

在这里主线程一直获取不到子线程中flag的状态修改为true了,一直在while循环中不出来,这是为什么呢

现象解读

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1或L2)后在进行操作,但是缓存不知道何时会写到内存

对于共享数据,在每个线程中都会缓存一份数据,

对于上面的例子来说就是 main线程在线程内部缓存了一份flag=false Thread-0线程也在线程内部缓存了一份flag=false,然后Thread-0线程将flag改为true之后同步到主存中,但是main线程的while循环使用的底层的循环机制,效率特别高,根本就没有从主存中重新去获取一下新的数据,线程内的缓存数据没有更新,导致一直在while循环中。

而这种问题就是内存不可见导致的,可以使用synchronized来解决,

synchronized在获取锁前后也要保证数据的一致性,获取锁 读内存屏障 释放锁 写内存屏障

所以可以在while循环中加上synchronized关键字来解决

但是加锁效率太低,所以一般情况下使用volatile关键字解决该问题,对于flag关键字加上volatile来修饰

1
private volatile boolean flag;

volatile具有内存可见性的作用

volatile的作用

  • 内存可见性,volatile修饰的变量不会被缓存在寄存器中,每次都是从主存中读取,对于其他线程全部可见

  • 禁止指令重排序(CPU的缓存一致性协议),保证了对于volatile修饰的变量会按照代码顺序执行

在单例懒汉式中双重检测时就使用了volatile禁止指令重排序

在实例化对象时,JVM分为三步

1、申请分配对象内存空间 memory=allocate()

2、对象初始化,成员变量初始化 instance(memory)

3、设置instance指向刚分配的内存地址,此时instance != null instance = memory ,堆地址给栈

如果指令重排的话就会导致不是按照先后顺序进行执行,如1->3->2,其实内部使用的属性还未初始化

为什么要有指令重排序

指令重排序可以提高性能,每一个指令包含多个步骤,每个步骤可能使用不同的硬件,在不影响结果的情况下,可以进行指令执行顺序的调整,减少了停顿,提高了CPU的处理能力,但是会造成乱序问题

指令重排分为三种

  • 编译器优化重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令并行重排:使用指令集并行技术将多条指令重叠执行,在不存在数据依赖性的前提下,可以改变语句对应的机器指令的执行顺序
  • 内存系统重排:由于处理器使用缓存和读写缓冲区,使得加载和存储操作看上去可能是在乱序执行

volatile的执行过程

在写一个volatile变量时,JMM会把线程对应的本地内存中的共享变量值刷新到主内存。

当读一个volatile变量时,JMM会把线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量

何时使用

  • 写入变量值不依赖变量的当前值时,因为volatile不保证原子性
  • 读写变量时没有加锁,因为加锁已经保证了可见性,就不需要把变量声明为volatile

内存屏障

内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。java编译器也会根据内存屏障的规则禁止重排序

分为几个类型

  • 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关键字会将变量写操作前加一个Release屏障,以保证写操作不会进行指令重排,在写操作后加一个Store屏障,以保证写完数据之后立刻刷新到主内存;

  • 在读操作前加一个Load屏障,以保证读到的数据是最新的,在读操作之后加一个Acquire屏障,禁止读操作后的任何读写操作会跟读操作指令重排

Acquire屏障=LoadLoad屏障+LoadStore屏障

Release屏障=StoreLoad屏障+StoreStore屏障

volatile和synchronized的区别

  • volatile是告诉JVM当前变量在寄存器中的值是不准确的,需要从主存读取;synchronized则是使得只有当前线程可以访问该变量,其他线程阻塞住,保证了同一时刻只有一个线程在操作变量
  • volatile仅能进行修饰变量;synchronized可以修饰方法和代码块
  • volatile只能实现可见性,不能保证原子性;synchronized能保证可见性和原子性
  • volatile不会造成线程阻塞;synchronized会造成线程阻塞
  • volatile标记的变量禁止了指令重排;synchronized没有禁止指令重排

欢迎关注我的其它发布渠道