在并发问题中,
还有一个比较关键的问题就是指令重排序
(在不改变程序语义的前提下,CPU为了更高效地利用缓存或寄存器),
但这同时会带来一些并发问题,造成一些数据不完整,不一致等问题,
因此JMM(Java Memory Model)就规范了一些Happen-Before的偏序关系,
以避免这些并发问题。
-
指令重排序
重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。
-
编译器重排序
编译源代码时,编译器依据对上下文的分析,对指令进行重排序。
-
运行期重排序
CPU在执行命令过程中,对指令进行重排序。
-
详细的重排序讲述这里可以有。
-
Happen-Before
Happen-Before指的是两个操作之前的一种偏序关系,比如,操作A happen-before 操作B,
那么操作A对于操作B是可见的(即操作A产生的影响会作用于操作B,如共享变量更新,消息发送,方法调用等),记作hb(A, B)。
-
比如,下面的代码:
操作1, 2, 3分别在线程T1, T2, T3中执行,若有偏序关系hb(1, 2),即T1的操作1对T2的操作2是可见的,
那么,T2的操作2执行后,变量j的值一定为1(此时T3的操作3还未执行),但若T3的操作3发生在操作1,2之间,
就不能确定最终i的值是多少,因为操作3,2并没有偏序关系,因此操作3的结果有可能影响操作2,也有可能不会。
-
JMM中的happen-before
-
JMM中已经规范出一些典型的hb关系,摘自周志明的<<深入理解Java虚拟机>>331页,也可查看官方JLS:
1. 程序次序规则: 同一线程内,前面的动作hb后面的动作。
2. 管程锁定规则: 对于同一个锁,unlock()操作hb下一次lock()操作。
3. volatile变量规则: volatile变量的写操作hb之后的读操作。
4. 线程启动规则: 同一线程的start()方法hb其他方法。
5. 线程终止规则: 同一线程的任何方法都hb对此线程的终止检测。如Thread.join(),Thread.isAlive()等。
6. 线程中断规则: 对线程interrupt()方法的调用hb
发生于被中断线程的代码检测到中断事件发生。
7. 对象终结规则: 一个对象的初始化完成hb该对象的finalize()方法。
8. 传递性: A操作hb操作B,操作Bhb操作C,那么A操作hb操作C。
-
DCL(Double Checking Locked)问题
-
对于hb关系的理解,DCL算是一个比较典型的问题,
它被广泛运用于多线程环境中的懒加载,但在没额外同步约束下,是不能可靠地运行的。如下面的代码片段:
-
在多线程环境中,上面的代码有可能会实例化两个Helper实例,因此需要必要的同步操作:
-
上面的代码在每次getHelper()都进行加锁,
Double-Checked-Locking则尝试去掉这样的加锁方式:
-
然而,这段代码在优化型编译器或共享的内存处理器上可能不会正常工作。
-
为何上面的代码不能正常工作?
-
上面的代码不能正常工作的原因很多,有一些比较明显,而有一次可能就比较难发现。
-
第一个原因
最明显的原因是Helper对象的构造函数执行操作
和helper字段的赋值操作有可能被重排序。
因此,调用getHelper()方法的线程虽然看到helper对象为非null,
但仅看到了helper对象各字段的默认值,而没有看到构造函数执行后的helper对象。
如果编译器内联地调用构造函数,只要构造函数不抛出异常或者进行同步操作,
Helper对象的构造函数执行操作
和helper字段的赋值操作就可以自由排序,
即使编译器不排序这些写操作,在多处理器系统中也有可能进行排序。
-
比如,在赛门铁克(名字略感亲切)的JIT编译器(使用基于句柄的对象分配系统)中,下面的代码就不能正常工作:
-
编译后的指令为:
-
可见,singletons[i].reference的分配动作发生在Singleton构造函数调用之前。针对上述问题,进行一些修复后:
这里将Helper对象的构造过程放在内部锁中,
目的就是阻止
Helper对象初始化操作和
helper字段被赋值操作被重排序。
然而,上面的代码仍然不能正常工作,同步规则并不会以那种方式工作,
monitorexit字节码指令的规则(如,释放锁)是,
在monitor被释放前,monitorexit之前的动作必须被执行,但却没有规则说,
monitorexit之后的动作不可能在monitor被释放前被执行,
也就是说,对于编译器而言,移动helper = h;声明完全是合理的。
很多处理器都提供这种单向内存屏障,若将请求锁的语义改变为完全内存屏障将带来性能损失。
虽然可以强制使用完全内存屏障,但这效率性能极低,并且一旦JMM被修改,很有可能不能正常工作,
所以不要这么做。然而即便在初始化helper字段时,使用了完全内存屏障,仍然不能正常工作。
因为,CPU都有自己本地的内存缓存副本,在某些处理器上,即便写操作通过内存屏障直接写到主内存中,
读操作也必须需要执行缓存一致性指令,否则读操作将从CPU缓存中读取,
比如Alpha处理器。
-
使用静态变量
-
可以将单例定义为某个类的静态字段,Java会保证直到静态字段被引用,才会对字段进行初始化,
任何访问该字段的线程将能看到对该字段初始化的写操作,如:
-
使用ThreadLocal
-
可以通过ThreadLocal来存放单例的引用,解决DCL问题,如:
-
但需注意ThreadLocal再不同jdk版本的性能不同。
-
使用volitale关键字
-
jdk1.5之后有了volitale关键字,如之前提到的,其能保证变量的任何写操作对之后的读操作可见,并且禁止重排序,
所以可以使用volitale来解决DCL问题:
-
本文参考如下:
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
http://www.cs.umd.edu/~pugh/java/memoryModel/BidirectionalMemoryBarrier.html
http://www.cs.umd.edu/~pugh/java/memoryModel/AlphaReordering.html
http://blog.csdn.net/ns_code/article/details/17348313
<<深入理解Java虚拟机>>