深入刨析volatile关键词

2020-06-07  本文已影响0人  机器学习架构

[toc]



摘要

本文主要涉及Java中的volatile,将从volatile的作用开启,再分析volatile实现的从而深刻立即理解volatile的作用;最后通过《volatile DCL单例需不需要加volatile?》这样一个问题结束volatile的温习;

volatile的作用

我在前几篇的文章编程语言&性能优化已经提到了volatile的作用;

image
概括一下就是:
  1. 线程可见
  2. 防止指令重排

volatile如何解决线程可见?

private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    new Thread(() - > {
        while (flag) {
          // do nothing
        }
        log.info("here");
    }, "name").start();
    Thread.sleep(1000);
    flag = false;
}

CPU Cache

CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:

它的工作简要原理如下:

为了充分发挥CPU的计算性能和吞吐量,现代CPU引入了一级缓存(一级数据缓存Data Cache,D-Cache和一级指令缓存InstructionCache,I-Cache)、二级缓存和三级缓存,结构如下图所示: cpu三级缓存

CPU Cache & 主内存

关系

当系统运行时,CPU执行计算的过程如下

单核处理,问题不是很大,但是在多核的情况下,问题就来了;


问题

eg:

缓存一致性协议

为了解决这一问题,CPU制造商规定了一个缓存一致性协议。

每个CPU都有一级缓存,但是,我们却无法保证每个CPU的一级缓存数据都是一样的。 所以同一个程序,CPU进行切换的时候,切换前和切换后的数据可能会有不一致的情况。那么这个就是一个很大的问题了。 如何保证各个CPU缓存中的数据是一致的。就是CPU的缓存一致性问题。

如何解决呢?

  1. 总线锁

一种处理一致性问题的办法是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。
用锁,那么性能问题就来了,所以出现了MESI;

  1. MESI

MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:
M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用了。

CPU的读取遵循下面几点,来保证CPU的效率

生成汇编参考文章: 从汇编看Volatile的内存屏障

Java代码如下

public class VolatileTest {
    private static volatile Integer flag = 0;
    public static void main(String[] args) {
        flag++;
    }
}

生成汇编

flag汇编:
  0x00000001156e7d65: movb   $0x0,(%rsi,%rbx,1)
  0x00000001156e7d69: lock addl $0x0,(%rsp)     ;*putstatic flag
                                                ; - com.yangsc.juc.VolatileTest::main@16 (line 21)
--- 
flag2汇编:
  0x00000001156e7e58: mov    0x38(%rsp),%rbx
  0x00000001156e7e5d: movb   $0x0,(%rdi,%rbx,1)  ;*putstatic flag2
                                                ; - com.yangsc.juc.VolatileTest::main@38 (line 22)                                             

有volatile修饰的共享变量进行写操作时会多出第二行汇编代码,该句代码的意思是对原值加零,其中相加指令addl前有lock修饰。通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。
    Lock前缀指令导致在执行指令期间,声言处理器的LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占任何共享内存(因为它会锁住总线,导致其他CPU不能访问总线,也就不能访问系统内存,在Intel486和Pentium处理器中都是这种策略)。但是,在最近的处理器里,LOCK# 信号一般不锁总线,而是锁缓存,因为锁总线开销的比较大。在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK# 信号。相反,它会锁定这块区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上的处理器缓存的内存区域数据。

  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
    IA-32处理器和Intel 64处理器使用MESI控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强行执行缓存行填充。

volatile如何解决指令重排序?

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分一下3种:源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行的指令

public class VolatileTest2 {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(() -> {
                shortWait(100);
                a = 1;
                x = b;
            });

            Thread two = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start();
            two.start();
            one.join();
            two.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                log.info(result);
                break;
            } else {
                log.info(result);
            }
        }
    }
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}
    private static volatile int x = 0, y = 0;
    private static volatile int a = 0, b = 0;
在这里插入图片描述

volatile 字节码标记

当我们对一个变量用volatile修饰时,字节码中会标记为volatile,后续交由虚拟机处理;


在这里插入图片描述

volatile 虚拟机规范

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。内存屏障可以被分为以下几种类型

hotspot实现方法,通过查资料了解到,大致有两种方式实现:

LOCK 用于在多处理器中执行指令时对共享内存的独占使用。 它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。 另外还提供了有序的指令无法越过这个内存屏障的作用。
这个跟上一篇《温故知新-多线程-深入刨析CAS》提到的CAS也是一样的原理,都是用了锁;


至此,volatile的实现原理也都讲完了,来看一下简单的应用

volatile DCL单例需不需要加volatile?

public class SingletonDemo {
    public static final SingletonDemo instance = new SingletonDemo();
    private SingletonDemo(){
    }
    public static SingletonDemo getInstance(){
        return instance;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(()->{
                SingletonDemo singletonDemo = SingletonDemo.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}
public class SingletonDemo1 {
    public static SingletonDemo1 instance;

    private SingletonDemo1() {
    }

    public static synchronized SingletonDemo1 getInstance() {
        if (instance == null) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            instance = new SingletonDemo1();
        }
        return instance;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletonDemo1 singletonDemo = SingletonDemo1.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}
public class SingletonDemo2 {
    public static SingletonDemo2 instance;

    private SingletonDemo2() {
    }

    public static  SingletonDemo2 getInstance() {
        if (instance == null) {
            synchronized(SingletonDemo2.class){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                instance = new SingletonDemo2();
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletonDemo2 singletonDemo = SingletonDemo2.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}
public class SingletonDemo3 {
    public static SingletonDemo3 instance;

    private SingletonDemo3() {
    }

    public static SingletonDemo3 getInstance() {
        if (instance == null) {
            synchronized(SingletonDemo3.class){
                if (instance == null) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    instance = new SingletonDemo3();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletonDemo3 singletonDemo = SingletonDemo3.getInstance();
                log.info(singletonDemo);
            }).start();
        }
    }
}

回答这个问题:volatile DCL单例需不需要加volatile?我们需要知道new一个对象的过程,借助idea插件:jclasslib is a bytecode viewer看一下new 一个对象的字节码;
代码如下:

public class ObjectLayout {
    public static void main(String[] args) {
        Object o = new Object();
    }
}
在这里插入图片描述

从图中可以看到,new一个对象包括以下几个步骤:

  1. new:在内存中new 一个对象
  2. dup:一个伪指令
  3. invokespecial:构造方法,初始化相关value;
  4. astore_1 将栈帧指向这个对象

根据我们上面文章所讲讲到的知识,可能会发生指令重排,如果3和4步骤发生指令重排,那么就有可能拿到了一个半初始化的对象;
以SingletonDemo3举例,可能会产生步骤1不为null,但是2还没进行,线程直接取走了一个半初始化的对象,这问题可能就会很严重了;

在这里插入图片描述

所以需要使用volatile修饰单例,防止指令重排 public static volatile SingletonDemo3 instance;

当然,单例的写法还有其它,比如枚举类等等,这不在本文的讨论的范畴,可以搜索其它文章了解更多的单例写法;

参考

Java volatile 关键字底层实现原理解析
理解CPU Cache
KVM之CPU虚拟化
从汇编看Volatile的内存屏障
就是要你懂Java中volatile关键字实现原理
JVM内存模型、指令重排、内存屏障概念解析


你的鼓励也是我创作的动力

打赏地址

上一篇 下一篇

猜你喜欢

热点阅读