前言
熟悉 java 并发编程的都知道,jmm(java 内存模型) 中的 happen-before(简称 hb)规则,该规则定义了 java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响。
java语言中有一个“先行发生”(happen—before)的规则,它是java内存模型中定义的两项操作之间的偏序关系,如果操作a先行发生于操作b,其意思就是说,在发生操作b之前,操作a产生的影响都能被操作b观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个原则特别重要,它是判断数据是否存在竞争、线程是否安全的主要依据。
按照官方的说法:
当一个变量被多个线程读取并且至少被一个线程写入时,如果读操作和写操作没有 hb 关系,则会产生数据竞争问题。
要想保证操作 b 的线程看到操作 a 的结果(无论 a 和 b 是否在一个线程),那么在 a 和 b 之间必须满足 hb 原则,如果没有,将有可能导致重排序。
当缺少 hb 关系时,就可能出现重排序问题。
hb 有哪些规则?
这个大家都非常熟悉了应该,大部分书籍和文章都会介绍,这里稍微回顾一下:
其中,传递规则我加粗了,这个规则至关重要。如何熟练的使用传递规则是实现同步的关键。
然后,再换个角度解释 hb:当一个操作 a hb 操作 b,那么,操作 a 对共享变量的操作结果对操作 b 都是可见的。
同时,如果 操作 b hb 操作 c,那么,操作 a 对共享变量的操作结果对操作 b 都是可见的。
而实现可见性的原理则是 cache protocol 和 memory barrier。通过缓存一致性协议和内存屏障实现可见性。
如何实现同步?
在 doug lea 著作 《java concurrency in practice》中,有下面的描述:
书中提到:通过组合 hb 的一些规则,可以实现对某个未被锁保护变量的可见性。
但由于这个技术对语句的顺序很敏感,因此容易出错。
楼主接下来,将演示如何通过 volatile 规则和程序次序规则实现对一个变量同步。
来一个熟悉的例子:
class threadprintdemo { static int num = 0; static volatile boolean flag = false; public static void main(string[] args) { thread t1 = new thread(() -> { for (; 100 > num; ) { if (!flag && (num == 0 || ++num % 2 == 0)) { system.out.println(num); flag = true; } } } ); thread t2 = new thread(() -> { for (; 100 > num; ) { if (flag && (++num % 2 != 0)) { system.out.println(num); flag = false; } } } ); t1.start(); t2.start(); } }
这段代码的作用是两个线程间隔打印出 0 - 100 的数字。
熟悉并发编程的同学肯定要说了,这个 num 变量没有使用 volatile,会有可见性问题,即:t1 线程更新了 num,t2 线程无法感知。
哈哈,楼主刚开始也是这么认为的,但最近通过研究 hb 规则,我发现,去掉 num 的 volatile 修饰也是可以的。
我们分析一下,楼主画了一个图:
我们分析这个图:
注意:hb 规则保证上一个操作的结果对下一个操作都是可见的。
所以,上面的小程序中,线程 a 对 num 的修改,线程 b 是完全感知的 —— 即使 num 没有使用 volatile 修饰。
这样,我们就借助 hb 原则实现了对一个变量的同步操作,也就是在多线程环境中,保证了并发修改共享变量的安全性。并且没有对这个变量使用 java 的原语:volatile 和 synchronized 和 cas(假设算的话)。
这可能看起来不安全(实际上安全),也好像不太容易理解。因为这一切都是 hb 底层的 cache protocol 和 memory barrier 实现的。
其他规则实现同步
利用线程终结规则实现:
static int a = 1; public static void main(string[] args) { thread tb = new thread(() -> { a = 2; }); thread ta = new thread(() -> { try { tb.join(); } catch (interruptedexception e) { //no } system.out.println(a); }); ta.start(); tb.start(); }
利用线程 start 规则实现:
static int a = 1; public static void main(string[] args) { thread tb = new thread(() -> { system.out.println(a); }); thread ta = new thread(() -> { tb.start(); a = 2; }); ta.start(); }
这两个操作,也可以保证变量 a 的可见性。
确实有点颠覆之前的观念。之前的观念中,如果一个变量没有被 volatile 修饰或 final 修饰,那么他在多线程下的读写肯定是不安全的 —— 因为会有缓存,导致读取到的不是最新的。
然而,通过借助 hb,我们可以实现。
总结
虽然本文标题是通过 happen-before 实现对共享变量的同步操作,但主要目的还是更深刻的理解 happen-before,理解他的 happen-before 概念其实就是保证多线程环境中,上一个操作对下一个操作的有序性和操作结果的可见性。
同时,通过灵活的使用传递性规则,再对规则进行组合,就可以将两个线程进行同步 —— 实现指定的共享变量不使用原语也可以保证可见性。虽然这好像不是很易读,但也是一种尝试。
关于如何组合使用规则实现同步,doug lea 在 juc 中给出了实践。
例如老版本的 futuretask 的内部类 sync(已消失),通过 tryreleaseshared 方法修改 volatile 变量,tryacquireshared 读取 volatile 变量,这是利用了 volatile 规则;
通过在 tryreleaseshared 之前设置非 volatile 的 result 变量,然后在 tryacquireshared 之后读取 result 变量,这是利用了程序次序规则。
从而保证 result 变量的可见性。和我们的第一个例子类似:利用程序次序规则和 volatile 规则实现普通变量可见性。
而 doug lea 自己也说了,这个“借助”技术非常容易出错,要谨慎使用。但在某些情况下,这种“借助”是非常合理的。
实际上,blockingqueue 也是“借助”了 happen-before 的规则。还记得 unlock 规则吗?当 unlock 发生后,内部元素一定是可见的。
而类库中还有其他的操作也“借助”了 happen-before 原则:并发容器,countdownlatch,semaphore,future,executor,cyclicbarrier,exchanger 等。
总而言之,言而总之:
happen-before 原则是 jmm 的核心所在,只有满足了 hb 原则才能保证有序性和可见性,否则编译器将会对代码重排序。hb 甚至将 lock 和 volatile 也定义了规则。
通过适当的对 hb 规则的组合,可以实现对普通共享变量的正确使用。
好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对移动技术网的支持。
如对本文有疑问, 点击进行留言回复!!
解决idea中出现“illegal character U+200B” 问题
荐 为什么加了@Transactional注解,事务没有回滚?
Attribute ‘sklearn.linear_model._logistic.LogisticRegression.multi_class‘ must be explicitly set to
Java/Python实现 LeetCode剑指Offer 14-I.剪绳子(动态规划)
网友评论