1 速度不对等
Cpu的速度比cpu之间的互联性能及cpu试图要访问的内存性能,都要快上几个数量级
现代处理器基本都是多核,并且每个cpu都有自己独立的cache,不同cpu共享主内存,然后不同cpu通过总线互联,cpu -> cache -> memory 访问速度成大数量级递减,cpu最快,cache慢一点,memory更慢。
2 MESI协议
cpu从内存中加载数据到自己的cache,当不同的cpu都加载了同样的内存数据的时候,并且对数据进行操作的时候,需要维护数据在不同的cache 中的一致性视图就需要MESI协议,cache里面的缓存行有四种状态分别是Modified,Exclusive,Shared,Invalid。协议在每一个缓存行中维护 一个两位的状态“tag”, 这个“tag”附着在缓存行的物理地址或者数据后 ,标识着缓存行的状态
•Modified 修改的
•Exclusive 独占的
•Shared 共享的
•Invalid 无效的
Modified: 处于“modified”状态的缓存行是由于相应的 CPU 最近进行了内存存储。并 且相应的内存确保没有在其他 CPU 的缓存中出现。因此,“modified”状态的缓 存行可以被认为被 CPU 所“owned”。由于缓存保存了最新的数据,因此缓存最 终有责任将数据写回到内存,并且也应当为其他缓存提供数据,必须在当前缓存 缓存其他数据之前完成这些事情。
Exclusive: 状态非常类似于“modified”状态,唯一的例外是缓存行还没 有被相应的 CPU 修改,这表示缓存行中的数据及内存中的数据都是最新的。但 是,由于 CPU 能够在任何时刻将数据保存到该行,而不考虑其他 CPU,处于 exclusive 状态也可以认为被相应的 CPU 所“owned”。也就是说,由于内存 中的值是最新的,该行可以直接丢弃而不用回写到内存,也可以为其他缓存提供 数据。
Shared: 处于“shared”状态的缓存行可能被复制到至少一个其他 CPU 缓存中,这样 在没有得到其他 CPU 的许可时,不能向缓存行存储数据。由于“exclusive”状 态下,内存中的值是最新的,因此可以不用向内存回写值而直接丢弃缓存中的值, 或者向其他 CPU 提供值。
Invalid: 处于“invalid”状态的行是空的,换句话说,它没有保存任何有效数据。当 新数据进入缓存时,它替换一个处于“invalid”状态的缓存行。这个方法是比较 好的,因为替换其他状态的缓存行将引起大量的 cache miss。
如果上面的文字你都认真看完了,可能有点绕,不过没关系,我们只需要了解这四种状态仅仅是标识出当前在cache里面的缓存行的数据是处于一个什么样的状态,并且下面会简单的介绍通过发送MESI消息来改变这种缓存行的状态
缓存一致性消息:
由于所有 CPUs 必须维护缓存行中的数据一致性视图,因此缓存一致性协议 提供消息以调整系统缓存行的运行。
MESI 协议消息 :
Read:“read”消息包含缓存行需要读的物理地址。
Read Response:“read response”消息包含较早前的“read”消息的数据。 这个“read response”消息可能由内存或者其他缓存提供。例如,如果 一个缓存请求一个处于“modified”状态的数据,则缓存必须提供“read response”消息。
Invalidate“invalidate”消息包含要使无效的缓存行的物理地址。其他的 缓存必须从它们的缓存中移除相应的数据并且响应此消息。
Invalidate Acknowledge:一个接收到“invalidate”消息的 CPU 必须在移 除指定数据后响应一个“invalidate acknowledge”消息。
Read Invalidate:“read invalidate”消息包含要缓存行读取的物理地址。 同时指示其他缓存移除数据。因此,它包含一个“read”和一个 “invalidate”。“read invalidate”也需要“read response”以及“invalidate acknowledge”消息集。
Writeback:“writeback”消息包含要回写到内存的地址和数据。(并且也 许会“snooped”其他 CPUs 的缓存)。这个消息允许缓存在必要时换出 “modified”状态的数据以腾出空间。
ok,我们不用太纠结这些消息,毕竟它看上去实在太枯燥了,我们只需要了解它大概的用途,说了这么多,下面内存屏障要出马了
Volatile——以DCL失效谈内存屏障用来禁止指令重排序的原理
volatile关键字具有两重语义即:
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
禁止进行指令重排序。
第一个好理解,也就是说每次修改都立即更新到主内存,那么禁止重排序这个在网上更多的解释是说使用了内存屏障,使得前后的指令无法进行重排序。(关于volatile详解)
那么问题来了,什么是内存屏障? volatile是怎么实现的? 这么实现为什么就能禁止重排了?带着三个问题我们往下看。
什么是内存屏障?
内存屏障是硬件层提供的保障一致性的能力的一系列方法,注意一系列,所以内存屏障不止一种
lfence,是一种Load Barrier 读屏障
sfence, 是一种Store Barrier 写屏障
mfence, 是一种全能型的屏障,具备ifence和sfence的能力
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
前面三种不是本文重点,看看了解下就好,本文重点是第四种,因为在X86平台上volatile是用Lock前缀就是使用的第四种。
首先内存屏障有两种作用:
阻止屏障两边的指令重排序
强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
而Lock前缀是这样实现的
它先对总线/缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。
在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。Lock后的写操作会让其他CPU相关的cache失效,从而从新从内存加载最新的数据,这个是通过缓存一致性协议做的。
那么这个lock前缀在JIT汇编代码中是啥样的?怎么操作的?我们来举例看看
从DCL失效来看lock的作用
下面是一段标准的DCL单例代码
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if(null == instance) {
synchronized (Singleton.class) {
if(null == instance) {
instance = new Singleton();
}
}
}
return instance;
} } 首先回顾一下DCL失效,在DCL中当执行 Instance = new Singleton()的时候看起来是一句代码执行,但是在虚拟机里面不是这样的,他大概在虚拟机里执行了三件事
给Singleton的实例分配内存(此时还没有指向mInstance)
调用构造函数,初始化成员
将mInstance对象指向分配的内存空间
在jdk1.5之前java内存模型中cache和寄存器到主内存的回写顺序规定,还有java编译器允许乱序执行,所以执行顺序可能不一致有的是1-2-3有的是1-3-2,在多线程中很可能线程A做了1步和第3步还没来得及做第二步的时候,就被切换到B线程,B线程发现第三步已经执行了,所以直接拿来用了,但是这个时候是错误的,因为第二步没有执行,成员未被初始化,这就是DCL失效。
那么我们来看看在JIT汇编代码中mInstance = new Singleton()是怎样执行的:
生成汇编码是 lock addl $0x0, (%rsp), 在写操作(putstatic instance)之前使用了lock前缀,锁住了总线和对应的地址,这样其他的CPU写和读都要等待锁的释放。当写完成后,释放锁,把缓存刷新到主内存。
结合DCL失效说就是,之所以DCL失效就是因为初始化成员还没执行就先执行了指向分配的内存,这样我们的实例已经不为null了,就导致后面的线程可能拿到没初始化的实例。而加了 volatile之后,volatile在最后加了lock前缀,把前面的步骤锁住了,这样如果你前面的步骤没做完,是无法执行最后一步刷新到内存的,换句话说只要执行到最后一步lock,必定前面的操作都完成了。那么即使我们完成前面两步或者三步了,还没执行最后一步lock,或者前面一步执行了就切换线程2了,线程2在判断的时候也会判断实例为空,进而由线程2完成后面的所有操作。当写完成后,释放锁,把缓存刷新到主内存。
综上所述:lock的作用就是,保证前面的Instance = new Singleton()完全完成后,才通过lock将Instance的值 更新到内存。也由于lock其他线程中的Instance的值都失效了。所以这时其他线程读到的Instance的值都是初始化成功后的实例。
注意
这里我们就可以看到此内存屏障只保证lock前后的顺序不颠倒,但是并没有保证前面的所有顺序都是要顺序执行的,比如我有1 2 3 4 5 6 7步,而lock在4步,那么前面123是可以乱序的,只要123乱序执行的结果和顺序执行是一样的,后面的567也是一样可以乱序的,但是整体上我们是顺序的,把123看成一个整体,4是一个整体 567又是一个整体,所以整体上我们的顺序的执行的,也达到了看起来禁止重排的效果
所以其实内存屏障禁止重排就是:利用lock把lock前面的“整体”锁住,当前面的完成了之后lock后面的“整体”才能完成,当写完成后,释放锁,把缓存刷新到主内存。
总结
好好理解好上面的“整体”,我们不难发现其实禁止重排也只是相对而言的,虚拟机这样做,其实也是为了效率考虑,因为只锁一部分整体让其有序就能达到目的的话,就没必要让每一步都有序,因为这样太影响优化了,指令重排在优化性能上的作用是很大的。
synchronized 实现原理与内存屏障
我们知道线程安全问题的产生前提是多个线程并发访问共享变量、共享资源(以下统称为共享数据)。于是,我们很容易想到保障线程安全的方法将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。锁(Lock)就是利用这种思路以保障线程安全的线程同步机制。
按照上述思路,锁可以理解为对共享数据进行保护的许可证。对于同一个许可证所保护的共享数据而言,任何线程访问这些共享数据前必须先持有该许可证。一个线程只有在持有许可证的情况下才能够对这些共享数据进行访问;并且,一个许可证一次只能够被一个线程持有;许可证的持有线程在其结束对这些共享数据的访问后必须让出(释放)其持有的许可证,以便其他线程能够对这些共享数据进行访问。
一个线程在访问共享数据前必须申请相应的锁(许可证),线程的这个动作被称为锁的获得(Acquire)。一个线程获得某个锁( 持有许可证 ),我们就称该线程为相应锁的持有线程 ( 线程持有许可证 ),一个锁一次只能被一个线程持有。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放 ( Release ) 相应的锁。锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区( Critical Section )。因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。
锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有。因此,这种锁被称为排他锁或者互斥锁 ( Mutex )。这种锁的实现方式代表了锁的基本原理,如图所示。
按照Java虚拟机对锁的实现方式划分,Java 平台中的锁包括内部锁( Intrinsic Lock )和显式锁 ( Explicit Lock )。内部锁是通过synchronized关键字实现的;显式锁是通过java.concurrent.locks.Lock接口的实现类(如 java.concurrent.locks.ReentrantLock 类 ) 实现的。
锁的作用
锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。
锁是通过互斥保障原子性的。所谓互斥 ( Mutual Exclusion ),就是指一个锁一次只能被一个线程持有。因此一个线程持有一个锁的时候,其他线程无法获得该锁,而只能等待其释放该锁后再申请。这就保证了临界区代码一次只能够被一个线程执行。因此,一个线程执行临界区期间没有其他线程能够访问相应的共享数据,这使得临界区代码所执行的操作自然而然地具有不可分割的特性,即具备了原子性。
从互斥的角度来看,锁其实是将多个线程对共享数据的访问由本来的并发( 未使用锁的情况下 )改为串行( 使用锁之后 )。因此,虽然实现并发是多线程编程的目标,但是这种并发往往是并发中带有串行的局部并发。这好比公路维修使得多股车道在某处被合并成一股小车道,从而使原本在多股车道上并驾齐驱的车辆不得不“鱼贯而行”。
我们知道,可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在Java平台中,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在执行临界区代码前( 获得锁之后 ) 可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被“推送” 到该线程执行处理器的高速缓存中,从而对读线程可同步。因此,锁能够保障可见性。
锁能够保障有序性。写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行,即读线程对这些操作的感知顺序与源代码顺序一致。这是暂且对原子性和可见性的保障的结果。设写线程在临界区中更新了b 、c 和 flag 这 3 个共享变量,如下代码片段所示 :
由于锁对可见性的保障,写线程在临界区中对上述任何一个共享变量所做的更新都对读线程可见。并且,由于临界区内的操作具有原子性,因此写线程对上述共事变量的更新会同时对读线程可见,即在读线程看来这些变量就像是在同一刻被更新的。因此读线程并无法(也没有必要)区分写线程实际上是以什么顺序更新上述变量的,这意味着读线程可以认为写线程是依照源代码顺序更新上述共享变量的,即有序性得以保障。
尽管锁能够保障有序性,但是这并不意味着临界区内的内存操作不能够被重排序。临界区内的任意两个操作依然可以在临界区之内被重排序(即不会重排到临界区之外)。由于临界区内的操作具有的原子性,写线程在临界区内对各个共享数据的更新同时对读线程可见,因此这种重排序并不会对其他线程产生影响。
在理解,以及使用锁保证线程安全的时候,需要注意锁对可见性、原子性和有序性的保障是有条件的,我们要同时保证以下两点得以满足。
• 这些线程在访问同一组共享数据的时候必须使用同一个锁。
• 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据而没有对其进行更新的话,也需要在读取时持有相应的锁。
上述任意一个条件未满足都会使原子性、可见性和有序性没有保障。可见,我们说锁能够保护共享数据其实是一种“协议” 的结果,这个协议就是任何访问该共享数据的写线程、 读线程都要满足上述条件。只要有任何一个线程没有遵守这个协议实际上就被打破,从而无法保障线程安全。这就好比交通规则( “协议” ) 要靠人人都遵守才能保障交通安全一样。
Java平台中的任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器 ( Monitor ) 或者内部锁 ( Intrinsic Lock )。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。
内部锁是通过synchronized关键字实现的。synchronized 关键字可以用来修饰方法以及代码块( 花括号 “ { } ” 包裹的代码 )。
synchronized关键字修饰的方法就被称为同步方法( Synchronized Method )。synchronized 修饰的静态方法就被称为同步静态方法,synchronized 修饰的实例方法就被称为同步实例方法。同步方法的整个方法体就是一个临界区。
synchronized关键字所引导的代码块就是临界区。锁句柄是一个对象的引用(它或者能够返回对象的表达式)。例如,锁句柄可以填写为this关键字( 表示当前对象 )。习惯上我们也直接称锁句柄为锁。锁句柄对应的监视器就被称为相应同步块的引导锁。相应地,我们称呼相应的同步块为该锁引导的同步块。
作为锁句柄的变量通常采用final修饰。这是因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竞态。有鉴于此,通常我们会使用 private 修饰作为锁句柄的变量。
线程在执行临界区代码的时候必须持有该临界区的引导锁。一个线程执行到同步块(同步方法也可看作同步块)时必须先申请该同步块的引导锁,只有申请成功(获得)该锁的线程才能够执行相应的临界区。一个线程执行完临界区代码后引导该临界区的锁就会被自动释放。在这个过程中,线程对内部锁的申请与释放的动作由Java虚拟机负责代为实施,这也正是 synchronized 实现的锁被称为内部锁的原因。
内部锁的使用并不会导致锁世漏。这是因为Java编译器 ( javac ) 在将同步块代码编译为字节码的时候,对临界区中可能抛出的而程序代码中又未捕获的异常进行了特殊( 代为 )处理,这使得临界区的代码即使抛出异常也不会妨碍内部锁的释放。
内部锁的调度
Java虚拟机会为每个内部锁分配一个入口集 ( Entry Set ),用于记录等待获得相应内部锁的线程。多个线程申请同一个锁的时候,只有一个申请者能够成为该锁的持有线程( 即申请锁的操作成功 ),而其他申请者的申请操作会失败。这些申请失败的线程并不会抛出异常,而是会被暂停( 生命周期状态变为 BLOCKED ) 并被存入相应锁的入口集中等待再次申请锁的机会 。入口集中的线程就被称为相应内部锁的等待线程。当这些线程申请的锁被其持有线程释放的时候,该锁的入口集中的一个任意线程会被Java虚拟机唤醒,从而得到再次申请锁的机会。由于Java 虚拟机对内部锁的调度仅支持非公平调度,被唤醒的等待线程占用处理器运行时可能还有其他新的活跃线程 ( 处于RUNNABLE 状态,且未进入过入口集 ) 与该线程抢占这个被释放锁,因此被唤醒的线程不一定就能成为该锁的持有线程。另外,Java 虚拟机如何从一个锁的入口集中选择一个等待线程,作为下一个可以参与再次申请相应锁的线程,这个细节与 Java 虚拟机的具体实现有关:这个被选中的线程有可能是入口集中等待时间最长的线程,也可能是等待时间最短的线程,或者完全是随机的一个线程。因此,我们不能依赖这个具体的选择算法。
前文我们讲解锁是如何保证可见性的时候提到了线程获得和释放锁时所分别执行的两个动作:刷新处理器缓存和冲刷处理器缓存。对于同一个锁所保护的共享数据而言,前一个动作保证了该锁的当前持有线程能够读取到前一个持有线程对这些数据所做的更新,后一个动作保证了该锁的持有线程对这些数据所做的更新对该锁的后续持有线程可见。那么,这两个动作是如何实现的呢?弄清楚这个问题有助于我们学习和掌握包括锁在内的所有Java线程同步机制 。
Java虚拟机底层实际上是借助内存屏障( Memory Barrier ,也称 Fence )来实现上述两个动作的。内存屏障是对一类仅针对内存读、写操作指令 ( Instruction ) 的跨处理器架构 ( 比如 x86 、ARM )的比较底层的抽象( 或者称呼 )。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。它在指令序列 ( 如指令 1 ;指令2 ;指令3 )中就像是一堵墙 ( 因此被称为屏障 )一样使其两侧 ( 之前和之后 )的指令无法“穿越”它 ( 一旦穿越了就是重排序了 )。但是,为了实现禁止重排序的功能,这些指令也往往具有一个副作用刷新处理器缓存、冲刷处理器缓存,从而保证可见性。不同微架构的处理器所提供的这样的指令是不同的,并且出于不同的目的使用的相应指令也是不同的。例如对于 “写-写” ( 写后写 ) 操作,如果仅仅是为了防止 ( 禁止 ) 重排序而对可见性保障没有要求,那么在x86架构的处理器下使用空操作就可以了( 因为 x86处理器不会对 “写-写” 操作进行重排序 )。而如果对可见性有要求(比如前一个写操作的结果要在后一个写操作执行前对其他处理器可见),那么在x86 处理器下需要使用LOCK 前缀指令或者sfence 指令、mfence 指令;在 ARM 处理器下则需要使用 DMB 指令。
按照内存屏障所起的作用来划分,将内存屏障划分为以下几种。
按照可见性保障来划分。内存屏障可分为加载屏障(Load Barrier)和存储屏障(Store Barrier)。加载屏障的作用是刷新处理器缓存,存储屏障的作用冲刷处理器缓存。Java虚拟机会在 MonitorExit ( 释放锁 ) 对应的机器码指令之后插入一个存储屏障,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的。相应地,Java 虚拟机会在 MonitorEnter ( 申请锁 ) 对应的机器码指令之后临界区开始之前的地方插入一个加载屏障,这使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的高速缓存中。因此,可见性的保障是通过写线程和读线程成对地使用存储屏障和加载屏障实现的。
按照有序性保障来划分,内存屏障可以分为获取屏障(Acquire Barrier)和释放屏障 ( Release Barrier )。获 取 屏 障 的 使 用 方 式 是 在 一 个 读 操 作 ( 包括 Read-Modify-Write 以及普通的读操作 )之后插入该内存屏障,其作用是禁止该读操作与其后的任何读写操作之间进行重排序,这相当于在进行后续操作之前先要获得相应共享数据的所有权 ( 这也是该屏障的名称来源 )。释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序。这相当于在对相应共享数据操作结束后释放所有权( 这也是该屏障的名称来源 )。 Java虚拟机会在 MonitorEnter( 它包含了读操作 ) 对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束之后 MonitorExit ( 它包含了写操作 ) 对应的机器码指令之前的地方插入一个释放屏障。因此,这两种屏障就像是三明治的两层面包片把火腿夹住一样把临界区中的代码(指令序列)包括起来,如图所示。
由于获取屏障禁止了临界区中的任何读、写操作被重排序到临界区之前的可能性。而释放屏障又禁止了临界区中的任何读、写操作被重排序到临界区之后的可能性。因此临界区内的任何读、写操作都无法被重排序到临界区之外。在锁的排他性的作用下,这使得临界区中执行的操作序列具有原子性。因此,写线程在临界区中对各个共享变量所做的更新会同时对读线程可见,即在卖线程看来各个共享变量就像是“一下子” 被更新的,于是这些线程无从 ( 也无必要 ) 区分这些共享变量是以何种顺序被更新的。这使得写线程在临界区中执行的操作自然而然地具有有序性读线程对这些操作的感知顺序与源代码顺序一致。
可见,锁对有序性的保障是通过写线程和读线程配对使用释放屏障与加载屏障实现的。
synchronized 与 volatile 原理 —— 内存屏障的重要实践
单例模式的双重校验锁的实现:
第一种:
1
2
3
4
5
6
7
8
private static Singleton _instance;
public static synchronized Singleton getInstance() {
if (_instance == null) {
_instance = new Singleton();
}
return _instance;
}
在 static 方法上加 synchronized,等同于将整个类锁住。每当通过此静态方法得到该对象时,就需要同步。
如果是实例方法(不是 static),那个 synchronized 锁只会对同一个对象多次调用该方法才会同步,不同的对象(实例)调用则不保证同步性。
1
2
3
if (_instance == null) {
_instance = new Singleton();
}
第二种:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
//volatile 防止延迟初始化
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { //判断是否已有单例生成
synchronized (Singleton.class) { //获取单例的方法是static方法,所以锁的是 .class对象
if (instance == null) //判断 是否在第一重判断与 synchronized 语句之间的状态,已经被另一个 synchronized 块 赋值
instance = new Singleton();//instance为volatile,现在没问题了
}
}
return instance;
} }
判断是否有单例生成并不需要同步锁,只有在第一次单例类实例创建时需要同步锁。并且在同步锁中赋值时,还需再检验一次。
这里使用 volatile 的目的是:避免重排序。直接原因也就是 instance = new Singleton(); 初始化(初始化本身是原子操作)一个对象并使另一个引用指向他 这个过程可分为多个步骤:
注意:这里的 synchronized 不像第一种是直接在整个方法上添加的,而是在内部的代码块上添加的,也就是说该方法的第一重判断是不包括在 synchronized 里面的,并且返回语句也不在 synchronized 中。当线程一按照1243 的执行顺序,首次访问到 步骤 4时。线程二异步执行到第一重判断时,它判断不为空,获取到了一个未初始化好的内存。线程一继续往下执行,它获取到了一个真实的初始化的对象。
概念:Jvm 中的 重排序、主存、原子操作
拓展:
线程安全:多条线程同时工作的情况下,通过运用线程锁,原子操作等方法避免多条线程因为同时访问同一快内存造成的数据错误或冲突。
原子性:解决的是某一操作不会被线程调度机制打断,中间不会有任何context switch (切 换到另一个线程)。即保证当前为原子操作。
有序性:解决的是 cpu 进行指令重排序
可见性:解决的是工作内存/寄存器 对主存的不可见
内存屏障:为了解决写缓冲器和无效化队列带来的有序性和可见性问题,我们引入了内存屏障。内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将写缓冲器的值写入高速缓存,清空无效队列,从而“附带”的保障了可见性。
八 种原子操作:
lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
四种基本内存屏障:
LoadLoad屏障:
对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:
对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被执行前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:
对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
内存屏障分类
按照可见性保障来划分
内存屏障可分为:加载屏障(Load Barrier)和存储屏障(Store Barrier)。
加载屏障:StoreLoad屏障可充当加载屏障,作用是使用load 原子操作,刷新处理器缓存,即清空无效化队列,使处理器在读取共享变量时,先从主内存或其他处理器的高速缓存中读取相应变量,更新到自己的缓存中
存储屏障:StoreLoad屏障可充当存储屏障,作用是使用 store 原子操作,冲刷处理器缓存,即将写缓冲器内容写入高速缓存中,使处理器对共享变量的更新写入高速缓存或者主内存中
这两个屏障一起保证了数据在多处理器之间是可见的。
按照有序性保障来划分
内存屏障分为:获取屏障(Acquire Barrier)和释放屏障(Release Barrier)。
获取屏障:相当于LoadLoad屏障与LoadStore屏障的组合。在读操作后插入,禁止该读操作与其后的任何读写操作发生重排序;
释放屏障:相当于LoadStore屏障与StoreStore屏障的组合。在一个写操作之前插入,禁止该写操作与其前面的任何读写操作发生重排序。
这两个屏障一起保证了临界区中的任何读写操作不可能被重排序到临界区之外。
synchronized编译成字节码后,是通过monitorenter(lock原子操作抽象而来)和 monitorexit(unlock原子操作抽象而来)两个指令实现的,具体过程如下:
可以发现,synchronized底层通过获取屏障和释放屏障的配对使用保证有序性,加载屏障和存储屏障的配对使用保正可见性。最后又通过锁的排他性保障了原子性与线程安全。
与 synchronized 类似,volatile 也是通过内存屏障来保证有序性与可见性,过程如下:
读操作:
写操作:
经过对比,可以发现 volatile 少了两个指令 monitorenter 与 monitorexit 用来保证原子性与线程安全。
注意:
一、原子性与线程安全的理解
1> 对于基本数据类型的“简单操作”,除了 long 与 double 外都具有原子性。因为 long 与 double 的读取和写入被 Jvm 分离成 2个slot 操作来进行,可以使用 volatile 来保证简单的赋值与返回操作的原子性。这是 volatile 的特殊用法,其基本用法是保证可见性和有序性。
2> 原子类扩展了“简单操作”的范围,增加对额外一些操作的原子性。
3> 原子操作并不能确保线程安全,虽然保证部分操作的原子性,但是对于大部分情况下仍然需要同步控制。
二、数组与对象实例中的 volatile
针对的是引用,其含义是对象获数组的地址具有可见性,但是数组或对象内部的成员改变不具备可见性。这一点跟变量中 final 数组/对象 的用法是类似的,限定是引用地址。
关于 synchronized 的知识点
下列说法不正确的是()
A.当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。
B.当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块。
C.当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问不会被阻塞。
D.当一个线程访问object的一个synchronized(this)同步代码块时,它就获得了这个object的对象锁。结果,其它线程对该object对象所有同步代码部分的访问都被暂时阻塞。
答案:C,当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问将会被阻塞。
volatile实现原理(内存屏障、缓存一致协议–Lock前缀指令–写缓存、高速缓存、主存)
volatile是“轻量级”synchronized,保证了共享变量的“可见性”(JMM确保所有线程看到这个变量的值是一致的),使用和执行成本比synchronized低,因为它不会引起线程上下文切换和调度。
缓存一致性问题:
一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
解决方法(都是在硬件上实现的):
通过在总线加LOCK#锁的方式
在总线上加锁,会导致其他CPU无法访问主存,效率降低
缓存一致性协议
最出名的是Intel的MESI协议,该协议保证了每个缓存中使用的共享变量的副本是一致的。其思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
共享变量写回主存流程
处理器在执行内存写操作的时候,往往是先将数据写入其写缓冲器中,而不是直接写入高速缓存。一个处理器上的写缓冲器中的内容无法被其他处理器所读取,因此写线程必须确保其对volatile变量所做的更新以及其更新volatile变量前对其他共享变量所做的更新到达该处理器的高速缓存(而不是仍然停留在写缓冲器中)。这样,写线程的这些更新通过缓存一致性协议被其他处理器上的线程所读取才成为可能。为此,Java虚拟机(JIT编译器)会在volatile变量写操作之后插入一个StoreLoad内存屏障。这个内存屏障的其中一个作用就是将其执行处理器的写缓冲器中的当前内容写入高速缓存,并将高速缓存的内容写回主存。
同时,由于无效化队列(暂存无效化消息的硬件,使得修改了某个共享变量之后借以通知其他处理器其对共享变量的更新,以便其他处理器能够将其高速缓存中的相应缓存行置为无效)的存在,处理器从其高速缓存中读取到的共享变量值可能是过时的。因此,为了确保读线程能够读取到写线程对共享变量所做的更新(包括volatile变量),读线程的执行处理器必须在读取volatile变量前确保无效化队列中内容被应用到该处理器的高速缓存中,即根据无效化队列中的内容将该处理器中相应的缓存行设置为无效,从而使写线程对共享变量所做的更新能够被反映到该处理器的高速缓存上。
若对声明了volatile的变量进行写操作,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存航的数据写会主存。但是,就算回到主存,还要保证其他处理器的缓存是一致的。每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是否过期了,当处理器发现自己缓存行对应的内存地址被修改时,就会设置当前缓存行为无效,需要对数据进行修改的时候会重新从主存中加载。
被volatile修饰的含义
保证变量的可见性
禁止指令重排序
为何volatile不保证原子性
原子性即要么做要么不做,举个例子
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
只有语句1是原子性操作,其他三个都不是,因为是复合操作,讲讲最典型的自增操作,自增分为三步骤:读取变量的原始值、+1操作、写入主存,也就是说,这三个子操作可能会割开执行:
假如volatile修饰的变量x原本为10,现线程A和B同时进行x++
线程A对变量进行自增,取出变量,阻塞
线程B对变量进行自增,取出变量,由于线程A仅仅取出变量,没有对变量进行操作,因此不会造成线程B中缓存变量x的缓存行无效,进行x++后x变为11,写入内存
线程A因为已经读取出来,已经过了取变量这一步,此时会直接进行x++,x为11,写入内存
也就是说,简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的互相赋值不是原子操作)才是原子操作,JMM仅保证了基本读取和赋值是原子操作,如果要实现更大范围的原子性操作,可以通过锁机制
针对以上的x++可能会有一些歧义,因为线程B修改过变量x,返回线程A的时候缓存无效,应该修改,这是没错的,但需要注意这里是缓存,然而x++的时候会先将x赋值给另外一个临时变量(设为tmp),tmp属于工作内存的局部变量表,再将tmp返回到缓存,缓存再返回到主存,这里需要一些寄存器运算的知识。形象一些,可以把x++拆分成以下
int tmp = x; //1
tmp = tmp + 1; //2
x = tmp; //3
当线程B调回线程A的时候,x会发生改变,但是tmp不变,因为已经执行完了第一步。
保证原子性可以通过锁(synchronized或者Lock)或者AtomicInteger的getAndIncrement()
AtomicInteger其实是利用CAS(Compare And Swap)实现原子操作,CAS是利用处理器提供的CMPXCHG指令实现的,处理器执行这个指令是一个原子操作
volatile在一定程度上保证有序性
volatile关键字禁止指令重排序有两层意思(不完全禁止):
当程序执行到volatile变量的读或写时,在其前面的操作肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
举个例子
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
volatile实现的两条原则(保证可见性和禁止指令重排序)
Lock前缀指令会引起处理器缓存回写到内存。
lock前缀指令相当于一个内存屏障(也称内存栅栏),内存屏障主要提供3个功能:
确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据;
如果是写操作,它会导致其他CPU中对应的缓存行无效。
一个处理器的缓存回写到内存会导致其他处理器的缓存失效。
处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如CPU A嗅探到CPU B打算写内存地址,且这个地址处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
volatile写和volatile读的内存语义
线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
使用volatile的场景
必须具备以下两个条件(其实就是先保证原子性):
对变量的写不依赖当前值(比如++操作)
该变量没有包含在具有其他变量的不等式中
比如:
状态标记(while(flag){})
double check(单例模式)
参考:
《java并发编程的艺术》
http://www.cnblogs.com/dolphin0520/p/3920373.html#!comments
内存屏障(Memory Barriers/Fences) - 并发编程中最基础的一项技术
我们经常都听到并发编程,但很多人都被其高大上的感觉迷惑而停留在知道听说这一层面,下面我们就来讨论并发编程中最基础的一项技术:内存屏障或内存栅栏,也就是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术。
CPU使用了很多优化技术来实现一个目标:CPU执行单元的速度要远超主存访问速度。CPU避免内存访问延迟最常见的技术是将指令管道化,然后尽量重排这些管道的执行以最大化利用缓存,从而把因为缓存未命中引起的延迟降到最小。
当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。例如,在一个循环里,如果循环体内没用到这个计数器,循环的计数器什么时候更新(在循环开始,中间还是最后)并不重要。编译器和CPU可以自由的重排指令以最佳的利用CPU,只要下一次循环前更新该计数器即可。并且在循环执行中,这个变量可能一直存在寄存器上,并没有被推到缓存或主存,这样这个变量对其他CPU来说一直都是不可见的。
CPU核内部包含了多个执行单元。例如,INTEL CPU包含了6个执行单元,可以组合进行算术运算,逻辑条件判断及内存操作。每个执行单元可以执行上述任务的某种组合。这些执行单元是并行执行的,这样指令也就是在并行执行。但如果站在另一个CPU角度看,这也就产生了程序顺序的另一种不确定性。
最后,当一个缓存失效发生时,CPU可以先假设一个内存载入的值并根据这个假设值继续执行,直到内存载入返回确切的值。
内存屏障(Memory Barriers/Fences) - 并发编程中最基础的一项技术_第1张图片
代码顺序并不是真正的执行顺序,只要有空间提高性能,CPU和编译器可以进行各种优化。缓存和主存的读取会利用load, store和write-combining缓冲区来缓冲和重排。这些缓冲区是查找速度很快的关联队列,当一个后来发生的load需要读取上一个 store的值,而该值还没有到达缓存,查找是必需的,上图描绘的是一个简化的现代多核CPU,从上图可以看出执行单元可以利用本地寄存器和缓冲区来管理 和缓存子系统的交互。
在多线程环境里需要使用某种技术来使程序结果尽快可见。这篇文章里我不会涉及到 Cache Conherence 的概念。请先假定一个事实:一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有的共享数据同步并保持一致。这个使内存数据对CPU核可见 的技术被称为内存屏障或内存栅栏。
内存屏障提供了两个功能。首先,它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;其次它们可以实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。
大多数的内存屏障都是复杂的话题。在不同的CPU架构上内存屏障的实现非常不一样。相对来说Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。因为x86处理器是在多线程编程中最常见的,下面我尽量用x86的架构来阐述。
Store Barrier
Store屏障,是x86的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。这会使得程序状态对其它CPU可见,这样其它CPU可以根据需要介入。一个实际的好例子是Disruptor中的BatchEventProcessor。当序列Sequence被一个消费者更新时,其它消费者(Consumers)和生产者(Producers)知道该消费者的进度,因此可以采取合适的动作。所以屏障之前发生的内存更新都可见了。
private volatile long sequence = RingBuffer.INITIAL_CURSOR_VALUE;
// from inside the run() method
T event = null;
long nextSequence = sequence.get() + 1L;
while (running)
{
try
{
final long availableSequence = barrier.waitFor(nextSequence);
while (nextSequence <= availableSequence)
{
event = ringBuffer.get(nextSequence);
boolean endOfBatch = nextSequence == availableSequence;
eventHandler.onEvent(event, nextSequence, endOfBatch);
nextSequence++;
}
sequence.set(nextSequence - 1L);
// store barrier inserted here !!!
}
catch (final Exception ex)
{
exceptionHandler.handle(ex, nextSequence, event);
sequence.set(nextSequence);
// store barrier inserted here !!!
nextSequence++;
}
}
Load Barrier
Load屏障,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该 load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。这使得从其它CPU暴露出来的程序状态对该 CPU可见,这之后CPU可以进行后续处理。一个好例子是上面的BatchEventProcessor的sequence对象是放在屏障后被生产者或消 费者使用。
Full Barrier
Full屏障,是x86上的”mfence“指令,复合了load和save屏障的功能。
Java内存模型
Java内存模型中volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。
原子指令和Software Locks
原子指令,如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。
内存屏障的性能影响
内存屏障阻碍了CPU采用优化技术来降低内存操作延迟,必须考虑因此带来的性能损失。为了达到最佳性能,最好是把要解决的问题模块化,这样处理器可 以按单元执行任务,然后在任务单元的边界放上所有需要的内存屏障。采用这个方法可以让处理器不受限的执行一个任务单元。合理的内存屏障组合还有一个好处 是:缓冲区在第一次被刷后开销会减少,因为再填充改缓冲区不需要额外工作了。
https://www.zhihu.com/question/351434327
http://www.elecfans.com/d/748012.html
在高并发模型中,无是面对物理机SMP系统模型,还是面对像JVM的虚拟机多线程并发内存模型,指令重排(编译器、运行时)和内存屏障都是非常重要的概念,因此,搞清楚这些概念和原理很重要。否则,你很难搞清楚哪些操作是在并发先绝对安全的?哪些是相对安全的?哪些并发同步手段性能最低?valotile的二层语义分别是什么?等等。
本来打算自己写一篇有关JVM内存模型的博文,后来整理资料的时候偶然发现一篇很好的相关文章(出自美团点评团队),个人感觉这篇文章写得比较全面,最起码概念层的东西讲清楚了,遂转载给大家。原文地址:http://tech.meituan.com/java-memory-reordering.html
一、什么是重排序
请先看这样一段代码
复制代码
1 public class PossibleReordering {
2 static int x = 0, y = 0;
3 static int a = 0, b = 0;
4
5 public static void main(String[] args) throws InterruptedException {
6 Thread one = new Thread(new Runnable() {
7 public void run() {
8 a = 1;
9 x = b;
10 }
11 });
12
13 Thread other = new Thread(new Runnable() {
14 public void run() {
15 b = 1;
16 y = a;
17 }
18 });
19 one.start();other.start();
20 one.join();other.join();
21 System.out.println(“(” + x + “,” + y + “)”);
22 }
复制代码
很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。
然而,这段代码的执行结果也可能是(0,0). 因为,在实际运行时,代码指令可能并不是严格按照代码语句顺序执行的。得到(0,0)结果的语句执行过程,如下图所示。值得注意的是,a=1和x=b这两个语句的赋值操作的顺序被颠倒了,或者说,发生了指令“重排序”(reordering)。(事实上,输出了这一结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出,详见后文)
对重排序现象不太了解的开发者可能会对这种现象感到吃惊,但是,笔者开发环境下做的一个小实验证实了这一结果。
实验代码是构造一个循环,反复执行上面的实例代码,直到出现a=0且b=0的输出为止。实验结果说明,循环执行到第13830次时输出了(0,0)。
大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待3。通过乱序执行的技术,处理器可以大大提高执行效率。
除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。
二、as-if-serial语义
As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。
比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。
int a = 1;
int b = 2;
int c = a + b;
将上面的代码编译成Java字节码或生成机器指令,可视为展开成了以下几步动作(实际可能会省略或添加某些步骤)。
对a赋值1
对b赋值2
取a的值
取b的值
将取到两个值相加后存入c
在上面5个动作中,动作1可能会和动作2、4重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序,动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系,一旦重排,as-if-serial语义便无法保证。
为保证as-if-serial语义,Java异常处理机制也会为重排序做一些特殊处理。例如在下面的代码中,y = 0 / 0可能会被重排序在x = 2之前执行,为了保证最终不致于输出x = 1的错误结果,JIT在重排序时会在catch语句中插入错误代偿代码,将x赋值为2,将程序恢复到发生异常时应有的状态。这种做法的确将异常捕捉的逻辑变得复杂了,但是JIT的优化的原则是,尽力优化正常运行下的代码逻辑,哪怕以catch块逻辑变得复杂为代价,毕竟,进入catch块内是一种“异常”情况的表现。
复制代码
1 public class Reordering {
2 public static void main(String[] args) {
3 int x, y;
4 x = 1;
5 try {
6 x = 2;
7 y = 0 / 0;
8 } catch (Exception e) {
9 } finally {
10 System.out.println(“x = “ + x);
11 }
12 }
13 }
复制代码
三、内存访问重排序与内存可见性
计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。其模型如下图所示。
在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。
有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。
这种内存可见性问题也会导致章节一中示例代码即便在没有发生指令重排序的情况下的执行结果也还是(0, 0)。
四、内存访问重排序与Java内存模型
Java的目标是成为一门平台无关性的语言,即Write once, run anywhere. 但是不同硬件环境下指令重排序的规则不尽相同。例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。从Java 5开始,Java内存模型成为Java语言规范的一部分。
根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。
程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C
Happens-before关系只是对Java内存模型的一种近似性的描述,它并不够严谨,但便于日常程序开发参考使用,关于更严谨的Java内存模型的定义和描述,请阅读JSR-133原文或Java语言规范章节17.4。
除此之外,Java内存模型对volatile和final的语义做了扩展。对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(的前提是没有this引用溢出)。
Java内存模型关于重排序的规定,总结后如下表所示。
表中“第二项操作”的含义是指,第一项操作之后的所有指定操作。如,普通读不能与其之后的所有volatile写重排序。另外,JMM也规定了上述volatile和同步块的规则尽适用于存在多线程访问的情景。例如,若编译器(这里的编译器也包括JIT,下同)证明了一个volatile变量只能被单线程访问,那么就可能会把它做为普通变量来处理。
留白的单元格代表允许在不违反Java基本语义的情况下重排序。例如,编译器不会对对同一内存地址的读和写操作重排序,但是允许对不同地址的读和写操作重排序。
除此之外,为了保证final的新增语义。JSR-133对于final变量的重排序也做了限制。
构建方法内部的final成员变量的存储,并且,假如final成员变量本身是一个引用的话,这个final成员变量可以引用到的一切存储操作,都不能与构建方法外的将当期构建对象赋值于多线程共享变量的存储操作重排序。例如对于如下语句
x.finalField = v; … ;构建方法边界sharedRef = x;
v.afield = 1; x.finalField = v; … ; 构建方法边界sharedRef = x;
这两条语句中,构建方法边界前后的指令都不能重排序。
初始读取共享对象与初始读取该共享对象的final成员变量之间不能重排序。例如对于如下语句
x = sharedRef; … ; i = x.finalField;
前后两句语句之间不会发生重排序。由于这两句语句有数据依赖关系,编译器本身就不会对它们重排序,但确实有一些处理器会对这种情况重排序,因此特别制定了这一规则。
五、内存屏障
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。 在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
为了实现上一章中讨论的JSR-133的规定,Java编译器会这样使用内存屏障。
为了保证final字段的特殊语义,也会在下面的语句加入内存屏障。
x.finalField = v; StoreStore; sharedRef = x;
六、Intel 64/IA-32架构下的内存访问重排序
Intel 64和IA-32是我们较常用的硬件环境,相对于其它处理器而言,它们拥有一种较严格的重排序规则。Pentium 4以后的Intel 64或IA-32处理的重排序规则如下。9
在单CPU系统中
读操作不与其它读操作重排序。
写操作不与其之前的写操作重排序。
写内存操作不与其它写操作重排序,但有以下几种例外
CLFLUSH的写操作
带有non-temporal move指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)的streaming写入。
字符串操作
读操作可能会与其之前的写不同位置的写操作重排序,但不与其之前的写相同位置的写操作重排序。
读和写操作不与I/O指令,带锁的指令或序列化指令重排序。
读操作不能重排序到LFENCE和MFENCE之前。
写操作不能重排序到LFENCE、SFENCE和MFENCE之前。
LFENCE不能重排序到读操作之前。
SFENCE不能重排序到写之前。
MFENCE不能重排序到读或写操作之前。
在多处理器系统中
各自处理器内部遵循单处理器的重排序规则。
单处理器的写操作对所有处理器可见是同时的。
各自处理器的写操作不会重排序。
内存重排序遵守因果性(causality)(内存重排序遵守传递可见性)。
任何写操作对于执行这些写操作的处理器之外的处理器来看都是一致的。
带锁指令是顺序执行的。
值得注意的是,对于Java编译器而言,Intel 64/IA-32架构下处理器不需要LoadLoad、LoadStore、StoreStore屏障,因为不会发生需要这三种屏障的重排序。
七、一例Intel 64/IA-32架构下的代码性能优化
现在有这样一个场景,一个容器可以放一个东西,容器支持create方法来创建一个新的东西并放到容器里,支持get方法取到这个容器里的东西。我们可以较容易地写出下面的代码。
复制代码
1 public class Container {
2 public static class SomeThing {
3 private int status;
4
5 public SomeThing() {
6 status = 1;
7 }
8
9 public int getStatus() {
10 return status;
11 }
12 }
13
14 private SomeThing object;
15
16 public void create() {
17 object = new SomeThing();
18 }
19
20 public SomeThing get() {
21 while (object == null) {
22 Thread.yield(); //不加这句话可能会在此出现无限循环
23 }
24 return object;
25 }
26 }
复制代码
在单线程场景下,这段代码执行起来是没有问题的。但是在多线程并发场景下,由不同的线程create和get东西,这段代码是有问题的。问题的原因与普通的双重检查锁定单例模式(Double Checked Locking, DCL)10类似,即SomeThing的构建与将指向构建中的SomeThing引用赋值到object变量这两者可能会发生重排序。导致get中返回一个正被构建中的不完整的SomeThing对象实例。为了解决这一问题,通常的办法是使用volatile修饰object字段。这种方法避免了重排序,保证了内存可见性,摒弃比使用同步块导致的性能损失更小。但是,假如使用场景对object的内存可见性并不敏感的话(不要求一个线程写入了object,object的新值立即对下一个读取的线程可见),在Intel 64/IA-32环境下,有更好的解决方案。
根据上一章的内容,我们知道Intel 64/IA-32下写操作之间不会发生重排序,即在处理器中,构建SomeThing对象与赋值到object这两个操作之间的顺序性是可以保证的。这样看起来,仅仅使用volatile来避免重排序是多此一举的。但是,Java编译器却可能生成重排序后的指令。但令人高兴的是,Oracle的JDK中提供了Unsafe. putOrderedObject,Unsafe. putOrderedInt,Unsafe. putOrderedLong这三个方法,JDK会在执行这三个方法时插入StoreStore内存屏障,避免发生写操作重排序。而在Intel 64/IA-32架构下,StoreStore屏障并不需要,Java编译器会将StoreStore屏障去除。比起写入volatile变量之后执行StoreLoad屏障的巨大开销,采用这种方法除了避免重排序而带来的性能损失以外,不会带来其它的性能开销。
我们将做一个小实验来比较二者的性能差异。一种是使用volatile修饰object成员变量。
复制代码
1 public class Container {
2 public static class SomeThing {
3 private int status;
4
5 public SomeThing() {
6 status = 1;
7 }
8
9 public int getStatus() {
10 return status;
11 }
12 }
13
14 private volatile SomeThing object;
15
16 public void create() {
17 object = new SomeThing();
18 }
19
20 public SomeThing get() {
21 while (object == null) {
22 Thread.yield(); //不加这句话可能会在此出现无限循环
23 }
24 return object;
25 }
26 }
复制代码
一种是利用Unsafe. putOrderedObject在避免在适当的位置发生重排序。
复制代码
1 public class Container {
2 public static class SomeThing {
3 private int status;
4
5 public SomeThing() {
6 status = 1;
7 }
8
9 public int getStatus() {
10 return status;
11 }
12 }
13
14 private SomeThing object;
15
16 private Object value;
17 private static final Unsafe unsafe = getUnsafe();
18 private static final long valueOffset;
19 static {
20 try {
21 valueOffset = unsafe.objectFieldOffset(Container.class.getDeclaredField(“value”));
22 } catch (Exception ex) { throw new Error(ex); }
23 }
24
25 public void create() {
26 SomeThing temp = new SomeThing();
27 unsafe.putOrderedObject(this, valueOffset, null); //将value赋null值只是一项无用操作,实际利用的是这条语句的内存屏障
28 object = temp;
29 }
30
31 public SomeThing get() {
32 while (object == null) {
33 Thread.yield();
34 }
35 return object;
36 }
37
38
39 public static Unsafe getUnsafe() {
40 try {
41 Field f = Unsafe.class.getDeclaredField(“theUnsafe”);
42 f.setAccessible(true);
43 return (Unsafe)f.get(null);
44 } catch (Exception e) {
45 }
46 return null;
47 }
48 }
复制代码
由于直接调用Unsafe.getUnsafe()需要配置JRE获取较高权限,我们利用反射获取Unsafe中的theUnsafe来取得Unsafe的可用实例。
unsafe.putOrderedObject(this, valueOffset, null)
这句仅仅是为了借用这句话功能的防止写重排序,除此之外无其它作用。
利用下面的代码分别测试两种方案的实际运行时间。在运行时开启-server和 -XX:CompileThreshold=1以模拟生产环境下长时间运行后的JIT优化效果。
复制代码
1 public static void main(String[] args) throws InterruptedException {
2 final int THREADS_COUNT = 20;
3 final int LOOP_COUNT = 100000;
4
5 long sum = 0;
6 long min = Integer.MAX_VALUE;
7 long max = 0;
8 for(int n = 0;n <= 100;n++) {
9 final Container basket = new Container();
10 List
11 List
12 for (int i = 0; i < THREADS_COUNT; i++) {
13 putThreads.add(new Thread() {
14 @Override
15 public void run() {
16 for (int j = 0; j < LOOP_COUNT; j++) {
17 basket.create();
18 }
19 }
20 });
21 takeThreads.add(new Thread() {
22 @Override
23 public void run() {
24 for (int j = 0; j < LOOP_COUNT; j++) {
25 basket.get().getStatus();
26 }
27 }
28 });
29 }
30 long start = System.nanoTime();
31 for (int i = 0; i < THREADS_COUNT; i++) {
32 takeThreads.get(i).start();
33 putThreads.get(i).start();
34 }
35 for (int i = 0; i < THREADS_COUNT; i++) {
36 takeThreads.get(i).join();
37 putThreads.get(i).join();
38 }
39 long end = System.nanoTime();
40 long period = end - start;
41 if(n == 0) {
42 continue; //由于JIT的编译,第一次执行需要更多时间,将此时间不计入统计
43 }
44 sum += (period);
45 System.out.println(period);
46 if(period < min) {
47 min = period;
48 }
49 if(period > max) {
50 max = period;
51 }
52 }
53 System.out.println("Average : " + sum / 100);
54 System.out.println("Max : " + max);
55 System.out.println("Min : " + min);
56 }
复制代码
在笔者的计算机上运行测试,采用volatile方案的运行结果如下
Average : 62535770
Max : 82515000
Min : 45161000
采用unsafe.putOrderedObject方案的运行结果如下
Average : 50746230
Max : 68999000
Min : 38038000
从结果看出,unsafe.putOrderedObject方案比volatile方案平均耗时减少18.9%,最大耗时减少16.4%,最小耗时减少15.8%.另外,即使在其它会发生写写重排序的处理器中,由于StoreStore屏障的性能损耗小于StoreLoad屏障,采用这一方法也是一种可行的方案。但值得再次注意的是,这一方案不是对volatile语义的等价替换,而是在特定场景下做的特殊优化,它仅避免了写写重排序,但不保证内存可见性。
###附1 复现重排序现象实验代码
复制代码
1 public class Test {
2 private static int x = 0, y = 0;
3 private static int a = 0, b =0;
4
5 public static void main(String[] args) throws InterruptedException {
6 int i = 0;
7 for(;;) {
8 i++;
9 x = 0; y = 0;
10 a = 0; b = 0;
11 Thread one = new Thread(new Runnable() {
12 public void run() {
13 //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
14 shortWait(100000);
15 a = 1;
16 x = b;
17 }
18 });
19
20 Thread other = new Thread(new Runnable() {
21 public void run() {
22 b = 1;
23 y = a;
24 }
25 });
26 one.start();other.start();
27 one.join();other.join();
28 String result = “第” + i + “次 (“ + x + “,” + y + “)”;
29 if(x == 0 && y == 0) {
30 System.err.println(result);
31 break;
32 } else {
33 System.out.println(result);
34 }
35 }
36 }
37
38
39 public static void shortWait(long interval){
40 long start = System.nanoTime();
41 long end;
42 do{
43 end = System.nanoTime();
44 }while(start + interval >= end);
45 }
46 }