LOCK前缀指令篇(一):Java 之深入浅出解读 volatile

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Jin_Kwok/article/details/82897947

Java 之深入浅出解读 volatile

目录

 

一、基本概念回顾

二、CPU与内存

三、Lock前缀指令

四、Volatile保证可见性的实现原理

五、Volatile禁止指令重排序


一、基本概念回顾


在进入正文之前,首先回顾一下Java 内存模型中的一些基本概念:可见性、原子性和有序性。

可见性:

  可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

  可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。

  在 Java 中 volatile、synchronized 和 final 实现可见性。

原子性:

  原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

  在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。

有序性:

  Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。


二、CPU与内存

如下图所示,一个简化的CPU与内存的关系图。CPU在对主内存中的共享变量进行操作的时候,并不是直接操作主内存,那样速度太慢了,而是将主内存中的变量 “拷贝” 到缓存中(严格的说,计算机的缓存分为1、2、3级缓存,这里简化为缓存),在高速缓存中执行操作完毕后,再回写到主内存中。

不难想见,多CPU、多线程的环境下,如果共享变量没有采用锁机制,那么,多个线程并发操作主内存的共享变量(如 int value=0),由于变量在线程间是不可见的,可能出现以下情形(举例):

  1. A、B线程分别将变量value从主内存读到各自的缓存中,此时,对于两个线程来说,value=0,缓存与主内存一致;
  2. 线程A对变量value进行写操作,如value=10,完成后回写到主内存,线程B中value仍然为0,主内存与缓存不一致;
  3. 线程B完成对value的写操作,回写主内存,覆盖掉线程A写入的值;


三、Lock前缀指令

关于LOCK指令,Intel手册的解释如下:

Causes the processor’s LOCK# signal to be asserted during execution of the accompanying instruction (turns the instruction into an atomic instruction). In a multiprocessor environment, the LOCK# signal insures that the processor has exclusive use of any shared memory while the signal is asserted.

其意为:LOCK指令会使紧跟在其后面的指令变成 原子操作(atomic instruction)。暂时的锁一下总线,指令执行完了,总线就解锁了!!!

  • LOCK前缀指令

LOCK 指令是一个汇编层面的指令,在一些特殊的场景下,作为前缀加在以下汇编指令之前,保证操作的原子性,这种指令被称为 “LOCK前缀指令”。

ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG.

LOCK前缀导致处理器在执行指令时会置上LOCK#信号,于是该指令就被作为一个原子指令(atomic instruction)执行。在多处理器环境下,置上LOCK#信号可以确保任何一个处理器能独占使用任何共享内存。

  • 锁总线

LOCK总线封锁信号,三态输出,低电平有效。LOCK有效时表示CPU不允许其它总线主控者占用总线(CPU与内存等硬件之前的通信需要经过总线)。这个信号由软件设置,当前指令前加上LOCK前缀时,则在执行这条指令期间LOCK保持有效,阻止其它主控者使用总线。说白了就是LOCK前缀只保证对当前指令要访问的内存互斥。

换言之,在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存(通过锁住总线,避免其它处理器访问共享内存),不过成本较高。

  • 锁缓存

在Pentium4、Inter Xeon和P6系列以及之后的处理器中,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。

在所有的 X86 CPU 上都具有锁定一个特定内存地址的能力,当这个特定内存地址被锁定后,它就可以阻止其它的系统总线读取或修改这个内存地址。这种能力是通过 LOCK 指令前缀再加上具体操作(如ADD)的汇编指令来实现的。当使用 LOCK 指令前缀时,它会使 CPU 宣告一个 LOCK# 信号,这样就能确保在多处理器系统或多线程竞争的环境下互斥地使用这个内存地址。当指令执行完毕,这个锁定动作也就会消失。

处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如CPU A嗅探到CPU B打算写内存地址,且这个地址处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。


四、Volatile保证可见性的实现原理

关于volatile原理,首先要明白JVM的内存模型:共享变量是存在主内存中的,各个线程若要操作共享变量,则需创建一个共享变量的拷贝,这个拷贝存在于线程私有的内存中,操作完成后,再回写到主内存中。很明显,这个过程会导致不同线程中共享变量不一致。因此,Java引入volatile关键字来保障共享变量在线程间的“可见性”——volatile修饰的变量的所有写操作都能立刻反应到其它线程中。

  • volatile如何保证可见性?

对volatile修饰的变量进行写操作(赋值),在生成汇编代码中,会有如下的情形:

// 写操作
0x01a3de1d: movb $0×0,0×1104800(%esi);
// 内存屏障
0x01a3de24: lock addl $0×0,(%esp);

其中,赋值后会再执行一个“lock addl $0×0,(%esp)”操作,这个操作相当于一个内存屏障(Memory Barrier),指令重排时,不能把后面的指令重排到内存屏障之前的位置,如果是单核CPU访问,则不需要内存屏障;但如果是多核CPU访问同一块内存,则需用内存屏障保障一致性。

多处理器、多线程环境下,若某个线程对声明了volatile的变量进行写操作,JVM会向处理器发送一条LOCK前缀的指令,将这个变量所在缓存行的数据写回主内存,LOCK前缀指令通过 “锁缓存” 可以确保回写主内存的操作是原子性的。但是,其它处理器的缓存中存储的仍然是 “旧值” ,并不能保证可见性,因此,还要借助缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是否过期,当处理器发现自己缓存行对应的内存地址被修改时,就会设置当前缓存行为无效,需要对数据进行修改的时候会重新从主内存中加载。如此,便保证了可见性。

  • volatile不能保证原子性!

为什么不能保证原子性呢?对于volatile修饰的变量,LOCK前缀指令保证的是其写操作和回写主内存的操作是原子性的。什么是写操作?如:value=10,对变量value进行写,这是实实在在的写,是一个原子操作。

但是,“value++;”是单纯的写操作吗?不是!value++的时候会先将value赋值给另外一个临时变量(设为tmp),tmp属于工作内存的局部变量表,再将tmp返回到缓存,缓存再返回到主存,这里需要一些寄存器运算的知识。形象一些,可以把value++拆分成以下伪代码:

int tmp = value;    //1
tmp = tmp + 1;      //2
value = tmp;        //3

很明显,对于变量value而言,最后一步:value=temp才是真正的写操作,LOCK前缀指令可以保证写操作和回写主内存的操作是原子性的。而前面两步并没有对value进行任何写操作,JVM不会做出反应,这就是为什么volatile不能保证原子性的根本原因。

有Java多线程编程经验的读者应该清楚,以下代码执行的结果是不稳定的,并且结果都是<=10000的,其根因就是value++并非原子性,volatile无能为力。

public class App
{
    private static volatile int value = 0;

    public static void main(String[] args)
    {

        for (int i = 0; i < 10; i++)
        {
            new MyThread().start();
        }

        System.out.println("value = " + value);

    }

    static class MyThread extends Thread
    {
        @Override
        public void run()
        {
            for (int i = 0; i < 1000; i++)
            {
                value++;
            }
        }
    }
}

五、Volatile禁止指令重排序

volatile关键字禁止指令重排序有两层意思(不完全禁止):

  • 程序执行到volatile变量的读或写时,在其前面的操作肯定全部已经执行完毕,且结果已经对后面的操作可见;在其后面的操作肯定还没有执行
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

lock前缀指令相当于一个内存屏障(也称内存栅栏),内存屏障主要提供3个功能:

  1. 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

参考文章:

1.https://www.jianshu.com/p/9abb4a23ab05

猜你喜欢

转载自blog.csdn.net/Jin_Kwok/article/details/82897947