Volatile使用原理及作用

  1. Volatile可见性

①基本概念:线程之间的可见性,一个线程修改的状态对另一个线程时可见的,也就是一个线程修改的结果,另一个线程马上就能看到。

②实现原理:

cpu緩存

按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存

二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半

三级缓存:简称L3 Cache,部分高端CPU才有

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

CPU–>CPU缓存–>主内存数据读取之间的关系

Volatile使用原理及作用

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

Volatile使用原理及作用

多核cpu思考一下情况:

①核0从主存中读取数据a到核0的缓存,同时核3也做了同样的事情

②核0修改了数据a,修改后的a写入了核0的缓存, 但是没有写回主存

③核3使用数据的时候,使用的是旧的a,出现问题

Cpu厂商:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。

lock指令的操作:

有volatile变量修饰的共享变量,进行写操作时,会多出一个lock代码,而该lock前缀在多核处理器下会引发两件事情。

①将当前处理器缓存行的数据写回系统内存

②写回操作使得在其他cpu缓存的该内存地址无效。

将当前处理器缓存行的数据写回系统内存

在修改内存操作时,使用LOCK前缀去调用加锁的读-修改-写操作

(1)在Pentium和早期的IA-32处理器中,LOCK前缀会使处理器执行当前指令时产生一个LOCK#信号,这种总是引起显式总线锁定出现

(2)在Pentium4、Inter Xeon和P6系列处理器中,如果内存访问有高速缓存且只影响一个单独的高速缓存行,那么操作中就会调用高速缓存锁,而系统总线和系统内存中的实际区域内不会被锁定。如果内存访问没有高速缓存且/或它跨越了高速缓存行的边界,那么这个处理器就会产生LOCK#信号,独占总线锁。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking)

 

写回操作使得在其他cpu缓存的该内存地址无效

多核处理器系统中进行写操作的时候,处理器能够嗅探到其他处理器正在访问系统内存和它们的内部缓存。嗅探技术能够保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上一致,即检测到其他处理器正在写内存地址,就会将自己的相关缓存行失效,重新从系统内存中把数据读到处理器缓存

从lock回看volatile

Volatile使用原理及作用

工作内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每个线程的工作内存也可以简单理解为CPU寄存器和高速缓存

那么当写两条线程Thread-A与Thread-B同时操作主存中的一个volatile变量i时,Thread-A写了变量i,那么:

  1. Thread-A发出LOCK#指令
  2. 发出的LOCK#指令锁总线(或锁缓存行),Thread-A向主存回写最新修改的i,同时让Thread-B高速缓存中的缓存行内容失效

Thread-B读取变量i,那么:

Thread-B发现对应地址的缓存行被锁了,等待锁的释放,等到ThreadA写入操作结束,释放锁,缓存一致性协议会保证它读取到最新的主存值。

  1. 禁止指令重排

①基本概念:

指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度,指令重排包括编译时重排序和运行时重排序。

例如:

double r = 2.1; //(1)

double pi= 3.14; //(2)

double area = p * r * r; //(3)

虽然代码语句的定义顺序为1-2-3,但是计算顺序1-2-3与2-1-3对结果并没有影响,所以编译时和运行时可以根据需要对1,2语句进行重排序

 

②指令重排带来的问题

如果一个操作不是原子的,就会给JVM留下重拍的机会

例如:

Thread1{

sum = count();

inited = true;

}

Thread2{

If(inited){

    func(sum);

}

}

如果Thread1中的指令发生重排,那么Thread2中可能拿到一个未被初始化或者初始化未完成的sum变量,从而引发程序错误

 

Volatile在双重检查加锁(DCL)的单例中的使用

public class Singleton {

    public static volatile Singleton singleton;

    /**

     * 构造函数私有,禁止外部实例化

     */

    private Singleton() {};

    public static Singleton getInstance() {

        if (singleton == null) {

            synchronized (singleton) {

                if (singleton == null) {

                    singleton = new Singleton();

                }

            }

        }

        return singleton;

    }

}

 

实例化一个对象其实可以分为三个步骤:

  (1)分配内存空间。

  (2)初始化对象。

  (3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  (1)分配内存空间。

  (2)将内存空间的地址赋值给对应的引用。

  (3)初始化对象

  如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

 

 

 

③禁止指令重排的原理

volatile关键字提供内存屏障的方式来防止指令被重排,编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

Volatile使用原理及作用

  1. 适用场景

(1)volatile是轻量级同步机制。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,是一种比synchronized关键字更轻量级的同步机制。

(2)volatile**无法同时保证内存可见性和原子性。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性**。

(3)volatile不能修饰写入操作依赖当前值的变量。声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

(4)当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile;

(5)volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。

 

  1. Volatile的线程安全性

下面用i++的例子进行分析,再讲线程安全之前,理解一下i=i++的内存执行过程

2 public static void main(String[] args){

3     int i = 234;

4     i = i++;

}

编译后的字节码文件

0: sipush 234//将常量234压入操作数栈

3: istore_1//将操作数栈出栈,值赋值给局部变量区的1号位置即i

4: iload_1//然后将变量1的i的值,压入操作数栈

5: iinc 1, 1//将局部变量区的一号变量i数值上加1

8: istore_1//将操作数栈出栈,值赋值给局部变量区的1号位置

 

使用局部变量区和操作数栈进行分析

局部变量区

0: sipush 234

3: istore_1

4: iload_1

5: iinc 1, 1

8: istore_1

1号变量i

234

234

234+1

234

操作数栈

234

出栈

234

234

出栈

 

所以i=i++的值是不会发生变化的。

 

Volatile只能保证变量的可见性,无法保证对变量的操作的原子性。

i++的执行过程其实包含三个步骤

①从内存中读取i当前的值

②局部变量区变量i加1

③把修改后的值刷新到内存中

这三个步骤不是原子性操作,volatile只能保证步骤一和步骤三的改变立即可见,但是无法决定步骤二,当多线程同时执行的时候,所出现的交叉修改,所以无法保证线程安全性。