2个特性

  1. 1、可见性
  2. 当修改volatile变量时,会给cpu发送一个信号告诉其他cpu这个变量已修改,当其他cpu调用这个变量时,
  3. 就会先检查是否有收到修改该变量的信号,有则重新从内存中读取。volatile是无锁的,类似于乐观锁的机制。
  4. 2、禁止指令重排序
  5. 指令重排序是为了提高执行效率。举例:
  6. 炒菜和做饭。正常的顺序是先做饭,饭煮好了以后开始洗菜、切菜,切菜之后开始炒菜。这样效率很慢。
  7. 那么开始对指令重排序,我可以在煮饭的时候,同时去把菜洗了、切好,然后炒菜或者等饭好了再炒来提高效率。
  8. 这是现代cpu优化的一个机制。这就使cpu的执行是乱序的。虽然最后的结果一致,但是在执行中,是乱序的。
  9. 但是有些操作,是不允许它乱序的。

对象的创建过程

class Obj {
    int i = 8;
}

Obj obj = new Obj();

分为三步(简化,用于描述volatile):
1、new出这个对象,在堆区分配内存,成员变量i赋默认值为0,注意还不是8呢。
2、调用构造方法,此时i才设为初始值为8。
3、建立关联。栈里的引用和真正的对象。

DCL单例(Double Check Lock)双检锁到底需不需要volatile?

DCL必须要加volatile
举例:
第一个线程new了这个对象,里面的成员变量值是0。
这时发生了指令重排序,本该是先调用构造方法,为成员变量赋初始值。
但发生指令重排序会先建立关联,后调用构造方法。
那么建立关联了,就说明这个对象不为null了,只不过指向的对象是半初始化状态。
这时当有其他线程进入在双检锁if判断的时候已经不为null,不为null就又返回一个对象,那么就不是单例了。

底层原理

字节码层级的实现

//字节码(.class文件)层级的实现
它给变量加了一个标记,也就是给内存区域加了个标记,也就是在这块内存进行读写的时候,需要特殊处理。
不能在这块内存读写时发生重排序。
Access flags: 0x0040[volatile]

Hotspot(JVM)层级实现

在jvm虚拟机规范里,它规定了4种内存屏障。屏障的含义就是禁止上下两个指令的重排序。
因为上一条指令与下一条指令中间有一层内存屏障。

StoreStoreBarrier        LoadLoadBarrier
volatile写操作                volatile读操作
StoreLoadBarrier        LoadStoreBarrier

就是读和写的排序组合:阻止指令重排序就不会产生乱序的问题。
StoreStoreBarrier:2个Store,代表了2个写指令,它表示2条写指令不能进行重排。那么其他的都同理了。
LoadLoadBarrier:2个Load,代表了2个读指令。它表示2条读指令不能进行重拍。
StoreLoadBarrier:它表示写|读指令不重排。
LoadStoreBarrier:它表示读|写指令不重排。

操作系统及硬件层面实现

image.png

CPU多级缓存
常用的图上7个。塔尖最快,向下越来越慢。

什么是寄存器?
寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,
它们可用来暂存指令、数据和地址。在中央处理器的控制部件中,包含的寄存器有
指令寄存器(IR)和程序计数器(PC)。在中央处理器的算术及逻辑部件中,寄存器有累加器(ACC)。

CPU高速缓存(L1,L2,L3)用于减少处理器访问内存所需平均时间的部件。
在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。
其容量远小于内存,但速度却可以接近处理器的频率。当处理器发出内存访问请求时,
会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;
如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。
这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。
有效利用这种局部性,缓存可以达到极高的命中率。
在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。
但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。

L0寄存器最快,离CPU核心最近的。速度是1cycle(1个时间周期)
接着依次是L1,L2,L3,内存,本地磁盘,远程存储。
L1速度约3-4cycle(1纳秒)
L2速度约10cycle(3纳秒)
L0,L1,L2位于CPU内部,速度很快。
L3可以认为和L4主存一样,理解成主存里的一种缓存,位于内存上的一种缓存。
L3的速度约40-45cycle(15纳秒)
L4主存的速度约60-80纳秒。那么cpu和主存的差距大概是100倍的关系了。
L5磁盘,L6远程文件就很好理解了。
越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。
从上至下,每一层都可以看做是更下一层的缓存
即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;
每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。
当多核cpu情况下,有缓存了,就会发生数据不一致的问题了。
假设2个cpu,同时从主存读数据x,一层一层读取从L4依次到L3,L2,L1,L0后开始计算,
假设第一个cpu把数据x改成了1,第二个cpu把数据x改成了2,那么就会产生不一致的状态。
他们往主存写回的时候,怎么保持一致。计算时如果必须要保证x数据一致该怎么办。
第一个cpu改了x,必须让其他cpu马上能看到,其他cpu想使用数据x,必须用改过之后最新的。

image.png

上面展示的图显示现代处理器基本都是多核,并且每个cpu都有自己独立的cache,
不同cpu共享主内存,然后不同cpu通过总线互联,
cpu -> cache -> memory 访问速度成大数量级递减,cpu最快,cache慢一点,memory更慢。

使用hsdis观察汇编码
lock指令xxx执行xxx指令的时候保证对内存区域加锁

windows上,volatile的底层实现:
lock addl指令来实现。

linux上,volatile的底层实现:
MESI(Modified Exclusive Shared Or Invalid)(缓存一致性协议)是一种保持一致性的协议
它的方法是在CPU缓存中保存一个两位的状态"tag"标记位,这个"tag"附着在缓存行的物理地址或者数据后,
cpu从内存中加载数据到自己的cache,当不同的cpu都加载了同样的内存数据的时候,
并且对数据进行操作的时候,需要维护数据在不同的cache中的一致性视图就需要MESI协议,
cache里面的缓存行有四种状态分别是Modified,Exclusive,Shared,Invalid(MESI协议)。
这四种状态仅仅是标识出当前在cache里面的缓存行的数据是处于一个什么样的状态。
M:Modify(修改的),修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了
E:Exclusive(独占的),独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据
S:Share(共享的),共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段
I:Invalid(无效的),失效缓存,这个说明CPU中的缓存已经不能使用了
由于所有CPUs必须维护缓存行中的数据一致性视图,因此缓存一致性协议提供消息以调整系统缓存行的运行。

MESI在intel(因特尔)CPU的实现:
linux系统有序性保障:X86CPU内存屏障
sfence:在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
ifence:在ifence指令前的读操作当必须在ifence指令后的读操作前完成。
mfence:在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。

保证可见性

Java内存模型
image.png

Java内存模型分为主内存和工作内存。
主内存是对所有线程所共享的,此外每个线程有自己的工作内存,工作内存不共享,为线程独享。
线程在工作时,从主内存中拷贝所需变量到自己的工作内存中。
线程对变量的所有操作,都必须在工作内存中进行,不能直接操作主存中的变量,也不能直接访问其他线程的工作内存。
线程间变量值的传递需要通过主内存进行,何时将工作内存中的变量同步到主内存,由 JVM 控制。
基于此种内存模型,在多线程中会产生脏读,即读到非最新的数据。
譬如,有1个共享变量:
int i = 0;
线程A和线程B同时执行以下操作:
i++;
我们期望的结果为 2,但实际结果可能为 1 也可能是 2。
执行过程:
首先从主内存中拷贝 变量i 到自己的工作内存,对工作内存中的 变量i 副本进行 +1 操作,将 i 的最新值写入到主内存中。
当 2 个线程同时执行上述代码时,可能存在以下一种情况:线程A从主存中读取了 变量i 到工作内存中,
并对 i 进行 +1 操作。在线程A将最新值 i=1 写入到主存前,此时线程B从主存中读取了 变量i,此时 i 仍为 0。
线程A、B分别将操作后的 变量i 的值同步到主存,最终在主存中 i = 1。

在上述例子中,线程A和B的工作内存是相互隔离、不可访问的,即不可见。

那么volatile的可见性指当一个线程修改共享变量,其他线程下次读取到的将是该共享变量的最新值。
上面说线程的工作内存对其他线程是隔离的,那么如何保证其他线程读到的是最新值呢?
事实上,当一个共享变量用 volatile 关键字修饰时,它会保证修改的值会被立即更新到主存中,
同时其他线程的工作内存中该共享变量的缓存将失效,当线程下次读取该变量时,将强制主存中读取最新值。

在解释的清晰一点,如上图:
主内存和工作内存之间的交互分为8个原子操作分别是:实际上这里已经被废弃了,了解即可。
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。”

如下代码:
int volatile a = 0;
Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        a++;
    }
});
thread.start();
1、只有对a的操作动作是load时,下一个执行的动作才可以是use,这两个互为充分必要条件。(这个就可以保证每次对a的操作都是主内存最新的值);
2、只有上一个对a的操作是assign时,下一个动作才可以是load,这两个条件互为充分必要条件,(这个条件可以保证在工作内存操作a,都会同步到主内存);