Java 使用 happen-before 规则实现共享变量的同步操做

前言

熟悉 Java 并发编程的都知道,JMM(Java 内存模型) 中的 happen-before(简称 hb)规则,该规则定义了 Java 多线程操做的有序性和可见性,防止了编译器重排序对程序结果的影响。java

按照官方的说法:编程

当一个变量被多个线程读取而且至少被一个线程写入时,若是读操做和写操做没有 HB 关系,则会产生数据竞争问题。小程序

要想保证操做 B 的线程看到操做 A 的结果(不管 AB 是否在一个线程),那么在 AB 之间必须知足 HB 原则,若是没有,将有可能致使重排序。缓存

当缺乏 HB 关系时,就可能出现重排序问题。安全

HB 有哪些规则?

这个你们都很是熟悉了应该,大部分书籍和文章都会介绍,这里稍微回顾一下:多线程

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操做先行发生于书写在后面的操做;
  2. 锁定规则:在监视器锁上的解锁操做必须在同一个监视器上的加锁操做以前执行。
  3. volatile变量规则:对一个变量的写操做先行发生于后面对这个变量的读操做;
  4. 传递规则:若是操做A先行发生于操做B,而操做B又先行发生于操做C,则能够得出操做A先行发生于操做C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个动做;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中全部的操做都先行发生于线程的终止检测,咱们能够经过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

其中,传递规则我加粗了,这个规则相当重要。如何熟练的使用传递规则是实现同步的关键并发

而后,再换个角度解释 HB:当一个操做 A HB 操做 B,那么,操做 A 对共享变量的操做结果对操做 B 都是可见的。app

同时,若是 操做 B HB 操做 C,那么,操做 A 对共享变量的操做结果对操做 B 都是可见的。ui

而实现可见性的原理则是 cache protocol 和 memory barrier。经过缓存一致性协议和内存屏障实现可见性。spa

如何实现同步?

在 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 修饰也是能够的。

咱们分析一下,楼主画了一个图:

咱们分析这个图:

  1. 首先,红色和黄色表示不一样的线程操做。
  2. 红色线程对 num 变量作 ++,而后修改了 volatile 变量,这个是符合 程序次序规则的。也就是 1 HB 2.
  3. 红色线程对 volatile 的写 HB 黄色线程对 volatile 的读,也就是 2 HB 3.
  4. 黄色线程读取 volatile 变量,而后对 num 变量作 ++,符合 程序次序规则,也就是 3 HB 4.
  5. 根据传递性规则,1 确定 HB 4. 因此,1 的修改对 4来讲都是可见的。

注意:HB 规则保证上一个操做的结果对下一个操做都是可见的。

因此,上面的小程序中,线程 A 对 num 的修改,线程 B 是彻底感知的 —— 即便 num 没有使用 volatile 修饰。

这样,咱们就借助 HB 原则实现了对一个变量的同步操做,也就是在多线程环境中,保证了并发修改共享变量的安全性。而且没有对这个变量使用 Java 的原语:volatile 和 synchronized 和 CAS(假设算的话)。

这可能看起来不安全(实际上安全),也好像不太容易理解。由于这一切都是 HB 底层的 cache protocol 和 memory barrier 实现的。

其余规则实现同步

  1. 利用线程终结规则实现:
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();
  }
  1. 利用线程 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 规则的组合,能够实现对普通共享变量的正确使用。

相关文章
相关标签/搜索