在计算机硬件结构中,为了平衡cpu和内存之间由于速度带来的差距,cpu中引入了cache作为处理器与内存之间的缓冲。在多核的处理器中,每个核都有属于自己的cache,这就带来了cache一致性的问题。前面提到的MESI协议就是用于处理cache一致性问题的一个协议,它将cache的内容分成几个状态,并要求每个核监听总线上传来的其他核发出的事件,根据这些外部事件以及自身操作cache的内部事件来维护cache的内容和状态,以达到cache一致性。但MESI协议中特定的优化有时会导致cache中存在临时的不一致的数据,所以引入了内存屏障来规避这个问题。
即使有cache的存在,当处理器等待cache的载入时仍然会浪费时间。所以处理器会在当前指令因等待数据阻塞时尝试执行其他不依赖这个数据的指令,来尽可能提高处理速度,这称为乱序执行。处理器会保证乱序执行的结果与顺序执行的结果一致,但仅在当前处理器范围内。如果有其他任务的计算依赖当前任务的中间结果,就有可能出现不符合预期的结果,这个问题同样可以通过内存屏障来规避。
java虚拟机规范中定义了java自身的内存模型,通过这个内存模型来屏蔽不同的操作系统和硬件带来的差异,达到各个平台运行效果一致的目标。java内存模型规定所有的变量都存储在主内存中,每个线程有自己的工作内存,线程在访问变量时都直接从工作内存中访问,而不能访问主内存。一个线程不能访问其他的线程的工作内存,线程之间的变量传递都需要经过主内存来完成。这里的线程、工作内存和主内存有有点类似计算机硬件结构中的处理器、cache和内存的关系。此外,java虚拟机中的即时编译中也有类似指令重排序的优化。
在java中有一个用于实现单例模式的方式,叫做“双成例检查”。双成例检查利用了synchronized和volatile关键词保证了在并发执行的情况下单例模式的正确性。但是在jdk1.5以前(不包括1.5)的版本是存在问题的,其中具体的原因就是volatile关键词底层实现在jdk1.5才完全正确。
根据volatile的特性,如果一个变量被标记为volatile,那么它将获得两个额外的属性:
在jdk1.5之前的版本,volatile并没有禁止指令重排序的作用,所以即使把变量声明为volatile也会存在volatile变量前后的代码重排序的情况,这也是在jdk1.5之前不能使用双成例检查来实现单例的原因。
前面提到内存屏障能够避免cache中存在过期数据以及避免乱序执行,而volatile自身也是通过内存屏障来实现上述的2个特性的。
内存屏障通常分为几个级别:读写(保证屏障前的读写操作都早于屏障后的读写操作)、读(只保证读操作)以及写(只保证写操作)。不同体系结构的硬件对内存屏障的实现都不一样,比如在x86中内存屏障的指令是:
而当我们把实际的java字节码反汇编成汇编指令时,可以看到并没有这几个屏障,而是在写入volatile变量之后添加一条 lock addl $0, 0 (%esp)
指令。lock指令的作用是可以使当前处理器的cache内容被写入内存,同时使其他处理器的cache失效,这种操作相当于将本线程的工作内存的内容同步到主内存,也就保证了可见性。而在指令重排序的角度,由于lock指令之前的操作的结果都同步到了内存,也就相当于lock之前的操作都已经完成,这样就相当于“屏障后边的操作无法穿越到屏障前面”的效果。
可以看到,lock实际上具备了内存屏障的语义,那lock具体的作用是什么呢。lock是一个指令前缀,在它后面的指令会保证原子执行。其实现方式就是在指令执行期间设置处理器的 LOCK#
信号,这样就能确保处理器能够互斥的操作内存(通过锁定总线来实现),当指令执行完毕之后 LOCK#信号
会自动取消。从intel奔腾Pro处理器开始,当要锁定的内存地址已经被加载到cache时,会直接锁定对应的cache而不是设置 LOCK#信号
。
也就是说,volatile的实现中通过lock前缀+一条空的指令来锁定cache,实现了可见性和禁止重排序的功能。至于为什么要用 addl $0, 0 (%esp)
配合lock前缀是因为lock前缀只支持内存操作类的指令,所以不能直接用lock前缀加空指令nop。