Happens-Before规则详解

hresh 623 0

Happens-Before规则详解

在《Java并发编程Bug的源头》一节中提到编译优化会带来有序性问题,具体来说就是 JIT 编译器会进行指令重排序(Instruction Reorder)优化。优化措施引发的有序性问题,Java 语言肯定会注意到,所以就引入了 Happens-Before(先行发生) 原则,它是 JMM 最核心的概念,在 JMM 章节中提到了如何保证可见性和有序性,都和该原则有关联。

对应 Java 程序员来说,理解 Happens-before 是理解 JMM 的关键。这个原则非常重要, 它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一并解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。

JMM的设计

现在就来看看“先行发生”原则指的是什么。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们通过一个简单的案例来进行

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上述代码用来计算圆的面积,存在3个 happens-before 关系,如下。

  • A happens-before B
  • B happens-before C
  • A happens-before C

在3个 happens-before 关系中,2和3是必需的,但1是不必要的。因此,JMM 把 happens-before 要求禁止的重排序分为了下面两类。

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略,如下。

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

综合来看,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除,我们之前学习 JIT 编译器逃逸分析时有提到。

Happens-Before 规则

下面是 Java 内存模型下一些“天然的”先行发生关系, 这些先行发生关系无须任何同步器协助就已经存在, 可以在编码中直接使用。 如果两个操作之间的关系不在此列, 并且无法从下列规则推导出来, 则它们就没有顺序性保障, 虚拟机可以对它们随意地进行重排序。

1、程序次序规则:在一个线程中,前面的操作 Happens-Before 于后续的任意操作。

2、volatile变量规则:对一个 volatile 变量的写操作 Happens-Before 于对这个 volatile 变量的读操作。

3、传递性规则:A Happens-Before B,B Happens-Before C,那么 A Happens-Before C。

4、管程锁定规则:synchronized 是 Java 对管程的实现,隐式加锁、释放锁,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

关于管程的介绍:在操作系统中,管程的定义如下: 管程是由一组数据以及定义在这组数据之上的对该组数据操作的操作组成的软件模块,称之为管程。 基本特性: 1. 局部于管程的数据只能被局部于管程内的过程所访问。 2. 一个进程只有通过调用管程内的过程才能进入管程访问共享数据 3. 每次仅允许一个进程在管程中执行某个内部过程。 注意:由于管程是一个语言的成分,所以管程的互斥访问完全由编译程序在编译时自动添加,无需程序员关注。

而在 Java 中,管程指的就是 synchronized,synchronized 是 Java 里对管程的实现。

管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁

5、线程启动规则:Thread 对象的 start()方法先行发生于此线程的每一个动作。

主线程A启动子线程B后,子线程的 start()操作 Happens-Before于子操作中的任意操作,即子线程 B 能够看到主线程在启动子线程 B 前的操作。

Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();

在上述代码中,main 线程启动子线程B后,B线程的 start()操作 Happens-Before于B线程操作中的任意操作,即线程 B 能够看到主线程在启动线程 B 前的操作。

6、线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66

7、线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生。

public class InterruptedSleepingTest {

  public static void main(String[] args) throws InterruptedException {
    InterruptedSleepingThread thread = new InterruptedSleepingThread();
    thread.start();

    // 10s后执行中断操作
    Thread.sleep(10000);
    thread.interrupt();
  }
}

class InterruptedSleepingThread extends Thread {

  @Override
  public void run() {
    doAPseudoHeavyWeightJob();
  }

  private void doAPseudoHeavyWeightJob() {
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
      // You are kidding me
      System.out.println(i + " " + i * 2);
      // Let me sleep <evil grin>
      if (Thread.currentThread().isInterrupted()) {
        System.out.println("Thread interrupted\n Exiting...");
        break;
      } else {
        sleepBabySleep();
      }
    }
  }

  protected void sleepBabySleep() {
    try {
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      //当主线程中的interrupt方法执行之后,才会抛出
      Thread.currentThread().interrupt();
    }
  }
}

执行结果为:

0 0
1 2
2 4
3 6
4 8
5 10
6 12
7 14
8 16
9 18
10 20
Thread interrupted
 Exiting...

关于线程 interrupt 方法的详细讲解,可以参考这篇文章

8、对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。

public class ObjectHappensTest {

  public int num;
  public String name;

  public ObjectHappensTest(int num, String name) {
    System.out.println("可以多次执行构造方法");
    this.num = num;
    this.name = name;
  }

  @Override
  protected void finalize() throws Throwable {
    System.out.println("进入finalize方法,只会执行一次");
    super.finalize();
  }

  public static void main(String[] args) throws InterruptedException {
    ObjectHappensTest obj;
//    obj = new ObjectHappensTest(30, "constructor");
    obj = null;
    System.gc();

    Thread.sleep(2000);
  }
}

执行上述代码,什么也没有输出;如果取消注释,则会打印如下结果:

可以多次执行构造方法
进入finalize方法,只会执行一次

证实了对象终结规则:一个对象在被垃圾回收之前必须已经进过初始化,垃圾回收不可能也不能去回收一个根本不存在的对象

扩展

如何保证一个共享变量的可见性?

1、保证共享变量的可见性,使用volatile关键字修饰即可,不管是针对该共享变量加 volatile,还是通过传递性来保证可见性,都算是 volatile 的功效。

2、保证共享变量是private,访问变量使用set/get方法,使用synchronized对两个方法加锁,此种方法不仅保证了可见性,也保证了线程安全

3、如果变量类型为 int,使用原子变量,例如:AtomicInteger等

4、利用线程的 join()方法或 start()方法

参考文献

《Java并发编程的艺术》

《深入理解Java虚拟机》

发表评论 取消回复
表情 图片 链接 代码

分享