关于volatile的那些事

一:写在前面的话:

本篇文章大的方面来说会有两个:基础知识铺垫以及重点着墨点。闲言碎语就到这里了,下面进入正题。在进入本片文章的重点volatile之前先让我们来了解Java内存模型。


二:Java内存模型


1、java内存模型说明了某个线程的内存操作在哪些情况下对其他线程是可见的,从抽象的角度看,JMM定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以供读写共享变量的副本。本地内存是JMM抽象的一个概念,并不是真实存在的。它涵盖了缓存、写缓存区、寄存器以及其他的硬件和编译器优化。java内存模型的抽象示意如下:


Java内存分为本地内存和主内存:

主内存存储全局变量:所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享,存在内存可见性问题

本地内存存储局部变量:方法定义参数和异常处理器参数不会在线程之间共享,它们没有内存可见性问题,也不受内存模型的影响。

注:可见性是指线程访问共享变量是否为最新值。


2、下面让我们一起来看看和JVM内存模型息息相关的几个概念

2.1、指令重排序

在执行程序时,为了提高性能,编译器和处理器通常会对指令做重排序。重排序分为三种类型:

a、编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

b、指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

c、内存系统的重排序:处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。


2.2、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。


2.3、as-if-serial语义

不管怎么排序,单线程程序的执行结果不能被改变。编译器、runtime、处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义使单线程程序程序员无需担心重排序会干扰他们,也无需担心内存可见性的问题。因为处理器和编译器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。


2.5、happens-before

在JMM中,如果一个操作的执行结果对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。常见的与开发人员密切相关的happens-before如下:

a、程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

b、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

c、volatile变量规则:对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读

d、传递性:如果A  happens-before  B,且B  happens-before  C,那么A  happens-before  C。


下面对上述四个规则做如下解释:

2.5.1、程序顺序规则:举例说明



这段代码看起来是有序执行的,A->B->C,也就是这条规则所说的书写在前面的操作先行发生在书写后面的操作。但是JMM可能会对程序代码进行指令重排序,B->A->C,虽然进行了重排序,但是执行的结果与程序顺序执行的结果一致。因为c是依赖于A和B,JMM不会发生下面的重排序:C->B->A,C->A-B,因为这样程序的结果将会发生改变。这个列子也很好的说明了数据依赖性和as-if-serial语义。


2.5.2、监视器锁规则、传递性二者放在一起举例说明代码如下:


根据happens-before规则,这个程序建立的happens-before关系可以分为如下几类:

1、根据程序次序规则:1 happens-before 2,2  happens-before  3,4 happens-before 5,5 happens-before 6

2、根据监视器锁规则:3  happens-before 4

3、根据传递性:2 happens-before 5,1  happens-before 6

图形化显示如下:


上述列子表明线程B获取锁必须要在线程A释放同一个锁之后发生。也就是说同一个锁如果处于被锁定的状态,那么必须先对锁进行了释放操作,后面才能进行加锁的动作。


2.5.3、volatile变量规则:示列代码如下:


根据happens-before规则,这个程序建立的happens-before关系可以分为如下几类:

1、根据程序次序规则:1 happens-before 2,3  happens-before  4

2、根据volatile变量规则:2  happens-before 3

3、根据传递性:1 happens-before 4

图形化显示如下:



上述示列说明:A线程先去写一个volatile变量,然后B线程去进行读取该volatile变量,那么写入操作肯定会先行发生于读操作。

注:两个操作之间具有happens-before  关系,并不意味着前一个操作必须要在后一个操作之前执行。happens-before仅仅要求前一个操作的执行结果对后一个操作可见并且前一个操作按顺序排在第二个操作之前。


2.6、顺序一致性内存模型

顺序一致性内存模型有两大特性:

a、一个线程中的所有操作必须按照程序的顺序来执行

b、不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序,在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见JMM对在正确同步的多线程程序,该程序的执行结果与在顺序一致性模型中的执行结果相同。

不同点是:在JMM中,临界区内的代码可以做重排序,这种重排序没有改变程序的执行结果,反而提高了执行的效率。


三:深入理解volatile关键字

1、在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁,volatile是轻量级的synchronized,它在并发场景下保证了共享变量的可见性。


2、如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。


3、lock前缀指令在多核处理器下会发生下面两件事情:

a、将当期处理器缓存行的数据写回到系统内存

b、这个写回操作会使在其他cpu里缓存了该内存地址的数据无效


4、volatile变量自身具有如下的特性:

a、对一个volatile变量的读,总能看到任意线程对这个volatile变量最后的写入。

b、对任意单个volatile变量的读写具有原子性,但类似于volatile++这种复合操作不具有原子性。

这里着重解释下b点,很多人会认为volatile变量的所有操作都是具有原子性的,ps:我当时也是这么认为的,示列代码如下:

执行结果:


这里的结果为:13148,如果i++的操作是线程安全的,那么预期的结果应该为20000,说明volatile++存在并发问题,volatile++,有一次读一次写,任意的读操作在JSR-133内存模型中都必须具有原子性。这也说明了volatile不具有锁的特性。


5、volatile的内存语义

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

下面来让我们看个例子:


假设线程A首先执行write方法,线程B随后执行read方法,初始两个线程的本地内存中的flag和a都是初始状态,下图是线程A执行volatile变量写后的共享变量状态示意图:



线程A在写flag变量后,本地内存A被线程A更新过的两个共享变量的值被刷新到主存中,此时本地内存A和主内存中的共享变量值是一致的。


5.2 volatile读的内存语义

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

下图是线程B读取一个volatile变量后,共享变量的状态示意图:


5.3    volatile写和读内存语义总结:

a.线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。


b·线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。


c·线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。


6、volatile的应用场景

6.1、加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,当且仅当满足以下所有条件时,才应该使用volatile变量:

a、对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值

b、该变量不会与其他状态变量一起纳入不变性条件中

c、在访问变量时不需要加锁


6.2、下面列举几个开发中常用的应用场景

6.2.1、单例模式:一个基于双重检查的懒加载的单例模式


我们先来看看instance = new Singleton()的语义:

1.分配对象的内存空间

2.初始化对象

3.将instance对象指向刚分配的内存空间,对象的初始化全部完成


如果没有加volatile 修饰instance会发生什么?


假设上述代码中没有用volatile 修饰instance,如果instance为空,则加锁,只有一个线程进入同步块完成对象的初始化,然后instance不为空,那么后续的所有线程获取instance都不用加锁,从而提升了性能。但是对象赋值的操作步骤可能会存在重排序,即当前线程的步骤4执行到一半,其它线程如果进来执行到步骤1,instance已经不为null,因此将会读取到一个没有初始化完成的对象。但如果将instance用volatile来修饰,就完全不一样了,对instance的写入操作将会变成一个原子操作,没有初始化完,就不会被刷新到主存中。


6.2.2:状态标记量:用于终止线程


更多的应用可参考:Java Concurrency in Practice


参考文章:

https://www.ibm.com/developerworks/cn/java/j-jtp06197.html

https://www.cnblogs.com/tangyanbo/p/6538488.html

http://blog.csdn.net/suifeng3051/article/details/52611310

https://www.jianshu.com/p/7798161d7472

java并发编程的艺术、Java Concurrency in Practice


猜你喜欢

转载自blog.csdn.net/rui920418/article/details/80498389