写点什么

Java- 技术专题 - 同步可见性的查缺补漏

发布于: 2021 年 04 月 27 日
Java-技术专题-同步可见性的查缺补漏

synchronized


synchronized 是比较原始的同步手段,它本质上是一个独占排他锁且可重入的非公平锁。

当一个线程尝试获取它的时候,可能会被阻塞住(Blocked),所以高并发场景下性能存在一些问题。


某些场景下,使用 synchronized 关键字和 volatile 是等价的:

  • 写入变量值时候不依赖变量的当前值,或者能够保证只有一个线程修改变量值

  • 写入的变量值不依赖其他变量的参与

  • 读取变量值时候不能因为其他原因进行加锁

  • 加锁可以同时保证可见性和原子性,而 volatile 只保证变量值的可见性。

AtomicInteger/AtomicLong

  1. 原子类型比锁更加轻巧,比如 AtomicInteger/AtomicLong 分别就代表了整型变量和长整型变量。

  2. 在它们的实现中,实际上分别使用的 volatile int/volatile long 保存了真正的值。因此,也是通过 volatile 来保证对于单个变量的读写可见性和有序性的。

  3. 提供了原子性的自增自减操作。比如 incrementAndGet 方法,相对于 synchronized 的好处是:它们不会导致线程的挂起和重新调度(内核态和用户态的切换,在其内部使用的是 CAS 非阻塞无锁算法。

CAS

所谓的 CAS 全程为 CompareAndSet。直译过来就是比较并设置。

这个操作需要接受三个参数:

  • 内存位置

  • 旧的预期值

  • 新值

这个操作的做法就是看指定内存位置的值符不符合旧的预期值,如果符合的话就将它替换成新值。它对应的是处理器提供的一个原子性指令 - CMPXCHG /LOCK CMPXCHG


比如 AtomicLong 的自增操作:

public final long incrementAndGet() {   for(;;) {    long current = get();    // Step 1    long next = current +1;    // Step 2    if(compareAndSet(current, next))       // Step 3       return next;    }   public final boolean compareAndSet(long expect,long update{   return unsafe.compareAndSwapLong(this, valueOffset, expect, update);}
复制代码


  1. 我们考虑两个线程 T1 和 T2,同时执行到了上述 Step 1 处,都拿到了 current 值为 1。然后通过 Step 2 之后,current 在两个线程中都被设置为 2。

  2. 紧接着,来到 Step 3。假设线程 T1 先执行,此时符合 CompareAndSet 的设置规则,因此内存位置对应的值被设置成 2,线程 T1 设置成功。

  3. 当线程 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 的数字。

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(); }}
复制代码

熟悉并发编程的同学肯定要说了,这个 num 变量没有使用 volatile,会有可见性问题,即:t1 线程更新了 num,t2 线程无法感知。但最近通过研究 HB 规则,我发现,去掉 num 的 volatile 修饰也是可以的。

分析一下:

首先,红色和黄色表示不同的线程操作。

  1. 红色线程对 num 变量做 ++,然后修改了 volatile 变量,这个是符合程序次序规则的。也就是 1HB 2.

  2. 红色线程对 volatile 的写 HB 黄色线程对 volatile 的读,也就是 2 HB 3.

  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 规则的组合,可以实现对普通共享变量的正确使用。


发布于: 2021 年 04 月 27 日阅读数: 24
用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
Java-技术专题-同步可见性的查缺补漏