03、锁与volatile的内存语义

前言

在前面的文章中已经提到过volatile关键字的底层实现原理:处理器的LOCK指令会使得其他处理器将缓存刷新到内存中(确切说是主存)以及会把其他处理器的缓存设置为无效。这里的内存语义则说的是在JMM中的实现,那么为什么要理解volatile和锁在JMM中的内存语义呢?主要原因是这部分内容是与程序开发息息相关的,所以在高并发量的系统中,如果对这块知识的了解欠缺的话将无法设计出优雅支持高并发的系统(之前广被吐槽的12306,现在勉强能够支持千万级别的访问量了)。

volatile的内存语义

简而言之,volatile关键字具有以下两个特性:

1、 可见性对一个volatile变量的读,总是能看到(任意线程)对这个变量最后的写入;
2、 原子性对任意单个volatile变量的读/写具有原子性,但是类似volatile++这样的操作是不具有原子性的;

之前看过一篇文章,将volatile关键字从硬件讲到软件,再从软件讲到JMM,然后从JMM讲到volatile关键字,整个过程显得特别复杂,这里仅仅站在程序员的角度,对volatile关键字在JMM的语义进行说明。上面这个过程的成立时需要一个称为happens-before的原则支持的。

happens-before原则可以概括为三点:

1、 程序顺序规则:就是每个操作都按照在程序中顺序执行的;
2、 监视器锁规则:锁的释放之后就是锁的获取;
3、 volatile变量规则:volatile写操作之后才是任意对这个volatile的读;
4、 传递性规则:这个好理解;

这个原则保证了volatile特性的成立。现在问题是,虽然volatile有上面的特性,但是这个与我们程序员有什么关系呢?由于添加volatile关键字修饰一个变量的时候都是一个被共享的变量,那么任意对该共享变量的操作何时对其他线程可见(也就是内存可见性)是我们比较关心的,首先可以从JMM的角度说明volatile:volatile的写-读与锁的释放-获取有相同的效果。比如如下的代码:

package com.ddkk.primer.thread;

public class VolatileExample {
   
     

    int a = 0;                          //普通变量
    volatile boolean flag = false;      //共享变量

    //写线程
    public void writer(){
        a = 1;                          //1.普通写
        flag = true;                    //2.volatile写
    }

    //读线程
    public void reader(){
        if(flag){                       //3.volatile读
            int i = a;                  //4.普通读
        }
    }
}

根据happens-before原则,具有以下的happens-before关系:

1、 操作1happens-befoe操作2;操作3happens-before操作4;
2、 操作2happens-before操作3;
3、 操作1happens-before操作4;

上面的这些关系有什么作用呢?其中第一点和第三点比较好理解,关键是第二点,为什么操作2会 happens-before 操作3呢?根据前面重排序的知识,编译器和处理器会对指令进行重排序(在保证正确性的前提下),但是如果一个变量被声明为volatile,那么JMM会插入内存屏障指令来防止重排序,插入内存屏障指令就保证了指令之前的volatile变量与后面的普通读/写发生重排序,所以明白这点就可以理解为什么操作2 happens-before 操作3了。根据前面处理器对volatile的处理,这里讲JMM的volatile语义做一个小结:

1、 执行volatile写的时候,JMM会把该线程对应的本地内存(并不是实际存在的,也称为TLB,线程本地缓冲区)刷新到主内存中这个过程可以理解为线程1(执行写方法的线程)向接下来要读取这个变量的线程2(执行读方法的线程)发送了一条消息
2、 执行volatile读的时候,JMM会把该线程对应的本地内存设置为无效线程接下来将直接从主内存中读取共享变量的值这个过程可以理解为线程2接收到了线程1发送的消息

上面提到了内存屏障指令,volatile的语义在JMM中实际上与处理器的重排序规则有些类似,下面是volatile的重排序规则表:

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

第一列表示的是第一个操作,NO的意思是不允许进行重排序。上面的表格有点丑,Markdown的语法不支持跨列(鸡肋啊)。我们可以从上面的表格中得到三个结论:

1、 当第一个操作为volatile读的时候,不管第二操作是什么,都不允许进行重排序;
2、 当第二个操作是volatile写的时候,不管第一个操作是什么,都不允许进行重排序;
3、 当第一个操作是volatile写的时候,如果第二个操作也是volatile的操作,那么不允许进行从排序;

上面的结论怎么理解呢?根据上篇博文中JMM的内存屏障指令,以第一条结论为例,我们可以这么理解:如果volatile读和下面的普通写或读发生了重排序将会读到错误的值。

下面的问题是在volatile读或者写之间该使用什么内存屏障指令呢?具体是这样的:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。这个屏障可以禁止上面的普通写和下面的volatile写发生重排序
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。该屏障可以防止上面的volatile写与下面的volatile读或写操作发生重排序
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。该屏障可以禁止下面所有的普通读操作与上面的volatile读发生重排序
  • 在每个volatile读后面插入一个LoadStore屏障,该屏障可以禁止下面所有的普通写和上面的volatile读发生重排序

这里需要注意的是,处理器会根据具体的情况省略不必要的内存屏障。所以比如当有连续多个volatile读的时候,可能会省略一个LoadStore屏障。

锁的内存语义

锁是实现同步的重要手段,虽然volatile关键字很给力,但是volatile只能保证对单个volatile变量读或写的操作具有原子性,诸如复杂操作或者多个volatile变量的操作就不能保证了。而锁的互斥执行的特性可以确保对整个临界区的代码都具有原子性。

锁中的happens-before关系与volatile中的happens-before关系大致是一样的,现在考虑锁的获取和锁的释放在JMM中的实现,了解volatile之后,可以把锁的获取与释放的内存语义简要概括为以下几句话:

  • 线程释放一个锁,JMM就会把该线程对应的本地内存中共享变量刷新到主内存中。这个过程可以理解为该线程向接下来需要获取这个锁的线程发送一条消息
  • 另一个线程获得该锁,JMM就会把该线程对应的本地内存设置为无效。这个过程可以理解为该线程接收到了之前释放该锁的线程发送的消息

锁的内存语义的实现与可重入锁相关,可以简要总结锁的内存语义的实现包括以下两种方式:

  • 利用volatile变量的内存语义
  • 利用CAS附带的volatile语义