欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

聊聊并发:(五)线程安全与同步之volatile分析

程序员文章站 2022-05-04 10:10:16
...

前言

在上一篇中,我们了解了synchronized关键字的使用,我们知道synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁。而在Java中还提供了另外一个关键字volatile,它可以说是Java虚拟机提供的最轻量级的同步机制,本文,我们就来一起了解一下volatile的使用。

用法

volatile是Java中的一个关键字,在多线程的场景下可能会常常用到,与synchronized不同的是,volatile只可以修饰变量,不可以使用在方法上,其使用方式比较简单:

public static volatile String str = "ABC";

这样,我们就创建了一个volatile的变量。

作用

volatile可以理解为轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

怎么理解上面这段话呢?我们来看一个例子:

/**
 * Created by xuanguangyao on 2018/8/15.
 */
public class VolatileDemo {

    public static volatile boolean terminate = false;

    public static void main(String[] agrs) throws Exception {

        CountDownLatch countDownLatch = new CountDownLatch(10);

        for (int i = 0; i < 10; i++) {
            Executor executor = Executors.newCachedThreadPool();
            executor.execute(() -> {
                countDownLatch.countDown();
                while (!terminate) {
                    System.out.println("current thread is : " + Thread.currentThread().getName());
                    try {
                        Thread.sleep(500);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }

        countDownLatch.await();
        System.out.println("准备停止全部线程的循环操作");
        terminate = true;
    }
}

执行结果:

current thread is : pool-1-thread-1
current thread is : pool-5-thread-1
current thread is : pool-4-thread-1
current thread is : pool-3-thread-1
current thread is : pool-7-thread-1
current thread is : pool-2-thread-1
current thread is : pool-8-thread-1
current thread is : pool-6-thread-1
current thread is : pool-9-thread-1
current thread is : pool-10-thread-1
准备停止全部线程的循环操作

上面的例子中,我们启动了10个线程,同时使用了一个计数器,直到10个线程全部开始执行,主线程会一直阻塞,当全部线程开始执行后,主线程将循环标志位置为true,全部线程的循环将停止,将terminate变量修饰为volatile的作用就是当其发生修改时,所有线程会立刻读取到其最新的值。

原理

通过上面的描述,我们可以粗浅的理解到,volatile的主要作用是多处理器多线程场景的开发中保证了共享变量的“可见性”。而其背后的机制还是非常复杂的。

在理解volatile的实现机制之前,我们先了解一下内存模型的概念。

实现机制

计算机在运行程序时,每条指令都是在CPU中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有CPU中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了CPU高速缓存。CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。

有了CPU高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。在程序运行中,会将运行所需要的数据复制一份到CPU高速缓存中,在进行运算时CPU不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后才会将数据刷新到主存中。举一个简单的例子:

i++;

当线程运行这段代码时,首先会从主存中读取i( i = 1),然后复制一份到CPU高速缓存中,然后CPU执行 + 1 (2)的操作,然后将数据(2)写入到告诉缓存中,最后刷新到主存中。其实这样做在单线程中是没有问题的,有问题的是在多线程中。如下:

假如有两个线程A、B都执行这个操作(i++),按照我们正常的逻辑思维主存中的i值应该=3,但事实是这样么?分析如下:

两个线程从主存中读取i的值(1)到各自的高速缓存中,然后线程A执行+1操作并将结果写入高速缓存中,最后写入主存中,此时主存i==2,线程B做同样的操作,主存中的i仍然=2。所以最终结果为2并不是3。这种现象就是缓存一致性问题

那么volatile是如何来保证可见性的呢?有volatile变量修饰的共享变量进行写操作的时候会多一行汇编代码,

0x01a3de24: lock addl $0x0,(%esp);

通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情。

  • 将当前处理器缓存行的数据会写回到系统内存。
  • 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。

聊聊并发:(五)线程安全与同步之volatile分析

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

通过这个机制,使用volatile修饰的变量,使得每个线程都能获得该变量的最新值。

volatile禁止指令重排序

经过上面的分析,我们已经知道了volatile变量可以通过缓存一致性协议保证每个线程都能获得最新值。

volatile的另一个作用就是禁止指令重排序。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,即在单线程环境下,如果执行两条指令的顺序不会影响其执行结果,处理器可以对指令进行重排序,即happen-before原则,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。这里我们暂时不过多介绍happen-before的机制,后续的篇幅中我们会进行讲解。

指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:

  • 同一个线程中的,前面的操作happen-before后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
  • 监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)
  • 对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)
  • 线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)
  • 线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
  • 如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。

这里我们主要看volatile规则,对volatile变量的写操作happen-before后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:

能否可以重排序 第二个操作 第二个操作 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

“NO”表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

1、、StoreStore 屏障
执行顺序:Store1—>StoreStore—>Store2
确保Store2以及后续Store指令执行前,Store1操作的数据对其它处理器可见。

2、StoreLoad 屏障
执行顺序: Store1—>StoreLoad—>Load2
确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。

3、LoadLoad 屏障
执行顺序:Load1—>Loadload—>Load2
确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据。

4、LoadStore 屏障
执行顺序: Load1—>LoadStore—>Store2
确保Store2和后续Store指令执行前,可以访问到Load1加载的数据。

结语

本文介绍了volatile的实现机制,总体来说,volatile的实现机制还是非常复杂的,总体来说,volatile是并发编程中的一种优化,在某些场景下可以代替Synchronized。但是,volatile的不能完全取代Synchronized的位置,只有在一些特殊的场景下,才能适用volatile。总的来说,必须同时满足下面两个条件才能保证在并发环境的线程安全:

1、对变量的写操作不依赖于当前值。

2、该变量没有包含在具有其他变量的不变式中。

注意如果有多线程操作变量的自增操作时,不要使用volatile进行修饰,应该使用AtomicInteger、AtomicLong等原子性类进行操作。

参考文章:
http://www.cnblogs.com/paddix/p/5428507.html
http://www.importnew.com/23520.html
https://juejin.im/post/5ae9b41b518825670b33e6c4#heading-2



更多Java干货文章请关注我的个人微信公众号:老宣与你聊Java
聊聊并发:(五)线程安全与同步之volatile分析