设为首页 加入收藏

TOP

面试必问的 volatile,你了解多少?(一)
2018-01-30 12:42:06 】 浏览:359
Tags:面试 volatile 了解 多少

前言

Java中volatile这个热门的关键字,在面试中经常会被提及,在各种技术交流群中也经常被讨论,但似乎讨论不出一个完美的结果,带着种种疑惑,准备从JVM、C++、汇编的角度重新梳理一遍。

volatile的两大特性:禁止重排序、内存可见性,这两个概念,不太清楚的同学可以看这篇文章 -> java volatile关键字解惑

概念是知道了,但还是很迷糊,它们到底是如何实现的?

本文会涉及到一些汇编方面的内容,如果多看几遍,应该能看懂。

重排序

为了理解重排序,先看一段简单的代码

public class VolatileTest {

    int a = 0;
    int b = 0;

    public void set() {
        a = 1;
        b = 1;
    }

    public void loop() {
        while (b == 0) continue;
        if (a == 1) {
            System.out.println("i'm here");
        } else {
            System.out.println("what's wrong");
        }
    }
}

VolatileTest类有两个方法,分别是set()和loop(),假设线程B执行loop方法,线程A执行set方法,会得到什么结果?

答案是不确定,因为这里涉及到了编译器的重排序和CPU指令的重排序。

编译器重排序

编译器在不改变单线程语义的前提下,为了提高程序的运行速度,可以对字节码指令进行重新排序,所以代码中a、b的赋值顺序,被编译之后可能就变成了先设置b,再设置a。

因为对于线程A来说,先设置哪个,都不影响自身的结果。

CPU指令重排序

CPU指令重排序又是怎么回事?
在深入理解之前,先看看x86的cpu缓存结构。

1、各种寄存器,用来存储本地变量和函数参数,访问一次需要1cycle,耗时小于1ns;
2、L1 Cache,一级缓存,本地core的缓存,分成32K的数据缓存L1d和32k指令缓存L1i,访问L1需要3cycles,耗时大约1ns;
3、L2 Cache,二级缓存,本地core的缓存,被设计为L1缓存与共享的L3缓存之间的缓冲,大小为256K,访问L2需要12cycles,耗时大约3ns;
4、L3 Cache,三级缓存,在同插槽的所有core共享L3缓存,分为多个2M的段,访问L3需要38cycles,耗时大约12ns;

当然了,还有平时熟知的DRAM,访问内存一般需要65ns,所以CPU访问一次内存和缓存比较起来显得很慢。

对于不同插槽的CPU,L1和L2的数据并不共享,一般通过MESI协议保证Cache的一致性,但需要付出代价。

在MESI协议中,每个Cache line有4种状态,分别是:

1、M(Modified)
这行数据有效,但是被修改了,和内存中的数据不一致,数据只存在于本Cache中

2、E(Exclusive)
这行数据有效,和内存中的数据一致,数据只存在于本Cache中

3、S(Shared)
这行数据有效,和内存中的数据一致,数据分布在很多Cache中

4、I(Invalid)
这行数据无效

每个Core的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,假如有4个Core:
1、Core1从内存中加载了变量X,值为10,这时Core1中缓存变量X的cache line的状态是E;
2、Core2也从内存中加载了变量X,这时Core1和Core2缓存变量X的cache line状态转化成S;
3、Core3也从内存中加载了变量X,然后把X设置成了20,这时Core3中缓存变量X的cache line状态转化成M,其它Core对应的cache line变成I(无效)

当然了,不同的处理器内部细节也是不一样的,比如Intel的core i7处理器使用从MESI中演化出的MESIF协议,F(Forward)从Share中演化而来,一个cache line如果是F状态,可以把数据直接传给其它内核,这里就不纠结了。

CPU在cache line状态的转化期间是阻塞的,经过长时间的优化,在寄存器和L1缓存之间添加了LoadBuffer、StoreBuffer来降低阻塞时间,LoadBuffer、StoreBuffer,合称排序缓冲(Memoryordering Buffers (MOB)),Load缓冲64长度,store缓冲36长度,Buffer与L1进行数据传输时,CPU无须等待。

1、CPU执行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操作,稍后再处理这个读请求的结果。
2、CPU执行store写数据时,把数据写到StoreBuffer中,待到某个适合的时间点,把StoreBuffer的数据刷到主存中。

因为StoreBuffer的存在,CPU在写数据时,真实数据并不会立即表现到内存中,所以对于其它CPU是不可见的;同样的道理,LoadBuffer中的请求也无法拿到其它CPU设置的最新数据;

由于StoreBuffer和LoadBuffer是异步执行的,所以在外面看来,先写后读,还是先读后写,没有严格的固定顺序。

内存可见性如何实现

从上面的分析可以看出,其实是CPU执行load、store数据时的异步性,造成了不同CPU之间的内存不可见,那么如何做到CPU在load的时候可以拿到最新数据呢?

设置volatile变量

写一段简单的java代码,声明一个volatile变量,并赋值

public class VolatileTest {

    static volatile int i;

    public static void main(String[] args){
        i = 10;
    }
}

这段代码本身没什么意义,只是想看看加了volatile之后,编译出来的字节码有什么不同,执行 javap -verbose VolatileTest 之后,结果如下:

让人很失望,没有找类似关键字synchronize编译之后的字节码指令(monitorenter、monitorexit),volatile编译之后的赋值指令putstatic没有什么不同,唯一不同是变量i的修饰flags多了一个ACC_VOLATILE标识。

不过,我觉得可以从这个标识入手,先全局搜下ACC_VOLATILE,无从下手的时候,先看看关键字在哪里被使用了,果然在accessFlags.hpp文件中找到类似的名字。

通过is_volatile()可以判断一个变量是否被volatile修饰,然后再全局搜”is_volatile”被使用的地方,最后在bytecodeInterpreter.cpp文件中,找到putstatic字节码指令的解释器实现,里面有is_volatile()方法。

当然了,在正常执行时,并不会走这段逻辑,都是直接执行字节码对应的机器码指令,这段代码可以在debug的时候使用,不过最终逻辑是一样的。

其中cache变量是java代码中变量i在常量池缓存中的一个实例,因为变量i被volatile修饰,所以cache-&g

首页 上一页 1 2 3 下一页 尾页 1/3/3
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇通向架构师的道路(第二天)之 ap.. 下一篇通向架构师的道路(第四天)之 To..

最新文章

热门文章

Hot 文章

Python

C 语言

C++基础

大数据基础

linux编程基础

C/C++面试题目