继之前讲多的Synchronized
和volatile
关键字,本篇文章会再深入从硬件级别带你去了解其特性。
之前文章也有提到过:
Synchronized既保证了原子性也保证了可见性、可重入(自己不停地加锁)volatile主要是保证线程可见性,禁止指令重排序
文章参考:
对线面试官 - Synchronize Volatile | 通俗易懂的白话文讲解其原理实现
面试官:你知道为什么volatile无法保证原子性。只可能保证可见性和有序性?
派大星:针对于Volatile关键字对原子性的保障在Java里是很有限的,我觉得几乎可以忽略不计。比如在32位的Java虚拟机里面,对long、double变量的赋值写不是原子性的,此时可以通过给变量加Volatile关键字来保证在32位Java虚拟机里面对long、double的赋值写是原子性的。相反int i = 0
原子性是Java语言规范(比如甲骨文)就规定了。
面试官:不错,那么为什么long、double在32位Java虚拟机里面的简单赋值操作不是原子性的。
派大星:所有变量的简单赋值操作,Java语言规范都给你保证原子性的。但是例外的是long和double在32位虚拟机里面的简单赋值操作不是原子性的。因为long和double是64位的
。如果多线程情况下同时并发执行long = 30 ,由于long是64位的,就会导致有的线程在修改l的高32位,有的线程在修改long的低32位,多线程并发给long类型的变量进行赋值操作,在32位虚拟机是有问题的。产生的结果导致 a 的值不是30,可能是-3333334430,也就是乱码一样的数字,因为高低32位赋值错了,就导致二进制转换为十进制之后是一个很奇怪的数字。
面试官:可以从硬件级别的谈一下可见性问题吗?或者说硬件级别为什么会有可见性问题?
派大星:好的。简单可从下面几种情况分析
- 每个处理器都有自己的一个寄存器(register),所以多个处理器各自运行一个线程的时候,会将主内存中的某个变量副本给加载到寄存器里面,然后对其进行更新。这样就会导致各个线程没法看到其它处理器里的变量值修改了。所以就引发了可见性的第一个问题:
寄存器级别的变量副本的更新,无法让其它处理器看到
- 还有一个问题是,处理器运行的线程对变量的操作针对的都是写缓存(
store buffer
),并不是直接更新主内存,所以很可能导致一个线程更新了变量,但是仅仅只是在写缓存中进行了更新,并没有直接更新到主内存中去。这个时候其他线程也就无法读取到它的写缓冲区里面的变量值的。所以也导致了可见性的问题 - 每一个处理器都有自己的高速缓存,处理器运行的线程对变量的操作可能更新到写缓冲器里面,也可能更新到高速缓存中去或者是主内存中。但是其它处理器还是从自己的高速缓存或者写缓冲器中读取的变量值,此时还是读取的旧值,非新值。
面试官:既然硬件级别是有可见性问题的,那么是如何解决的呢?
派大星:硬件级别要想实现可见性,其中一个方法就是通过MESI协议
。这个MESI协议在之前的文章也有提过但是并没有展开说。根据具体底层硬件的不同 ,MESI协议的实现也是有些区别的。
面试官:可以简单说说MESI的实现方式吗?
派大星:可以的,MESI协议实现如下:就是一个处理器将另外一个处理器的高速缓存中的更新后的数据拿到自己的高速缓存中来更新一下,这样彼此之间的缓存就实现了同步,然后各个处理器之间的线程看到的变量数据也就是一样的了。当然为了实现MESI协议,其中是有两个配套的专业机制的。flush 处理器缓存
、refresh处理器缓存
- 首先说一下
flush处理器缓存
**其目的是把自己更新的变量值刷新到高速缓存里(或主内存中去)**,并且它还会发送一个消息到总线(bus)
,通知其它处理器某个变量值被它更新了。这样才可让其它的处理器从自己的高速缓存(主内存)里读取到更新的值。 - 其次就是
refresh处理器缓存
,它的目的是处理器中的线程在读取一个变量的时候,如果发现总线(bus)
嗅探中有消息说其他处理器的线程更新了该变量的值,则必须从其它处理器的高速缓存(或主内存)中读取到这个最新的值,并更新到自己的高速缓存中去。
总的来说,为了保证可见性,在底层是通过MESI协议
、flush处理器缓存
和refresh处理器缓存
,这一套机制来保障的。并且其实内存屏障的使用,在底层硬件级别的原理,其实就是在执行flush
和refresh
面试官:那这次就先聊到这里吧,后续咱们可以再从硬件级别聊聊指令重排序的问题。
派大星:好的。