Java- 技术专题 - 同步可见性的查缺补漏
synchronized
synchronized 是比较原始的同步手段,它本质上是一个独占排他锁且可重入的非公平锁。
当一个线程尝试获取它的时候,可能会被阻塞住(Blocked),所以高并发场景下性能存在一些问题。
某些场景下,使用 synchronized 关键字和 volatile 是等价的:
写入变量值时候不依赖变量的当前值,或者能够保证只有一个线程修改变量值。
写入的变量值不依赖其他变量的参与。
读取变量值时候不能因为其他原因进行加锁。
加锁可以同时保证可见性和原子性,而 volatile 只保证变量值的可见性。
AtomicInteger/AtomicLong
原子类型比锁更加轻巧,比如 AtomicInteger/AtomicLong 分别就代表了整型变量和长整型变量。
在它们的实现中,实际上分别使用的 volatile int/volatile long 保存了真正的值。因此,也是通过 volatile 来保证对于单个变量的读写可见性和有序性的。
提供了原子性的自增自减操作。比如 incrementAndGet 方法,相对于 synchronized 的好处是:它们不会导致线程的挂起和重新调度(内核态和用户态的切换),在其内部使用的是 CAS 非阻塞无锁算法。
CAS
所谓的 CAS 全程为 CompareAndSet。直译过来就是比较并设置。
这个操作需要接受三个参数:
内存位置
旧的预期值
新值
这个操作的做法就是看指定内存位置的值符不符合旧的预期值,如果符合的话就将它替换成新值。它对应的是处理器提供的一个原子性指令 - CMPXCHG /LOCK CMPXCHG。
比如 AtomicLong 的自增操作:
我们考虑两个线程 T1 和 T2,同时执行到了上述 Step 1 处,都拿到了 current 值为 1。然后通过 Step 2 之后,current 在两个线程中都被设置为 2。
紧接着,来到 Step 3。假设线程 T1 先执行,此时符合 CompareAndSet 的设置规则,因此内存位置对应的值被设置成 2,线程 T1 设置成功。
当线程 T2 执行的时候,由于它预期 current 为 1,但是实际上已经变成了 2,所以 CompareAndSet 执行不成功,进入到下一轮的 for 循环中,此时拿到最新的 current 值为 2,如果没有其它线程感染的话,再次执行 CompareAndSet 的时候就能够通过,current 值被更新为 3。
CAS 的工作主要依赖于两点:
无限循环,需要消耗部分 CPU 性能
CPU 原子指令 CompareAndSet
虽然它需要耗费一定的 CPU Cycle,但是相比锁而言还是有其优势,比如它能够避免线程阻塞引起的上下文切换和调度。这两类操作的量级明显是不一样的,CAS 更轻量一些。
总结
volatile 变量的读/写操作是原子性的。因为从内存屏障的角度来看,对 volatile 变量的单纯读写操作确实没有任何疑问。由于其中掺杂了一个自增的 CPU 内部操作,就造成这个复合操作不再保有原子性。
保证 volatile++这类操作的原子性,比如 synchronized 或 AtomicInteger/AtomicLong 原子类。
如何实现同步?
在 Doug Lea 著作 《Java Concurrency in Practice》中,有下面的描述:
书中提到:通过组合 hb 的一些规则,可以实现对某个未被锁保护变量的可见性。但由于这个技术对语句的顺序很敏感,因此容易出错。演示如何通过 volatile 规则和程序次序规则实现对一个变量同步。
来一个熟悉的例子:
这段代码的作用是两个线程间隔打印出 0 - 100 的数字。
熟悉并发编程的同学肯定要说了,这个 num 变量没有使用 volatile,会有可见性问题,即:t1 线程更新了 num,t2 线程无法感知。但最近通过研究 HB 规则,我发现,去掉 num 的 volatile 修饰也是可以的。
分析一下:
首先,红色和黄色表示不同的线程操作。
红色线程对 num 变量做 ++,然后修改了 volatile 变量,这个是符合程序次序规则的。也就是 1HB 2.
红色线程对 volatile 的写 HB 黄色线程对 volatile 的读,也就是 2 HB 3.
黄色线程读取 volatile 变量,然后对 num 变量做 ++,符合程序次序规则,也就是 3 HB 4.
根据传递性规则,1 肯定 HB 4. 所以,1 的修改对 4 来说都是可见的。
注意:HB 规则保证上一个操作的结果对下一个操作都是可见的。
上面的小程序中,线程 A 对 num 的修改,线程 B 是完全感知的 —— 即使 num 没有使用 volatile 修饰。
借助 HB 原则实现了对一个变量的同步操作,也就是在多线程环境中,保证了并发修改共享变量的安全性。并且没有对这个变量使用 Java 的原语:volatile 和 synchronized 和 CAS(假设算的话)。
这可能看起来不安全(实际上安全),也好像不太容易理解。因为这一切都是 HB 底层的 cache protocol 和 memory barrier 实现的。
其他规则实现同步
利用线程终结规则实现:
join()的机制进行控制两个线程的串行同步运作
利用线程 start 规则实现:
Thread 的 start()方法一定前于执行 run()方法之前。
这两个操作,也可以保证变量 a 的可见性。
确实有点颠覆之前的观念。之前的观念中,如果一个变量没有被 volatile 修饰或 final 修饰,那么他在多线程下的读写肯定是不安全的 —— 因为会有缓存,导致读取到的不是最新的。
然而,通过借助 HB,我们可以实现。
总结
虽然本文标题是通过 happen-before 实现对共享变量的同步操作,但主要目的还是更深刻的理解 happen-before,理解他的 happen-before 概念其实就是保证多线程环境中,上一个操作对下一个操作的有序性和操作结果的可见性。
同时,通过灵活的使用传递性规则,再对规则进行组合,就可以将两个线程进行同步 —— 实现指定的共享变量不使用原语也可以保证可见性。虽然这好像不是很易读,但也是一种尝试。
例如老版本的 FutureTask 的内部类 Sync(已消失),通过 tryReleaseShared 方法修改 volatile 变量,tryAcquireShared 读取 volatile 变量,这是利用了 volatile 规则;
通过在 tryReleaseShared 之前设置非 volatile 的 result 变量,然后在 tryAcquireShared 之后读取 result 变量,这是利用了程序次序规则。
从而保证 result 变量的可见性。和我们的第一个例子类似:利用程序次序规则和 volatile 规则实现普通变量可见性。
实际上,BlockingQueue 也是“借助”了 happen-before 的规则。还记得 unlock 规则吗?当 unlock 发生后,内部元素一定是可见的。
而类库中还有其他的操作也“借助”了 happen-before 原则:并发容器,CountDownLatch,Semaphore,Future,Executor,CyclicBarrier,Exchanger 等。
总而言之,言而总之
happen-before 原则是 JMM 的核心所在,只有满足了 hb 原则才能保证有序性和可见性,否则编译器将会对代码重排序。hb 甚至将 lock 和 volatile 也定义了规则。通过适当的对 hb 规则的组合,可以实现对普通共享变量的正确使用。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/f60d4bac9286528eb576d9c3f】。文章转载请联系作者。
评论