java并发编程(4) 有序性-happens -before


前言

happens - before 是JMM 最核心的概念。对于Java程序员来说,理解 happens - before 是理解 JMM 的关键。文章参考《Java并发编程的艺术》这本书,这篇文章和 volatile ,以及 指令重排序 一起看效果更好。


1. JMM 的设计

首先从 JMM 的设计意图出发。从 JMM 设计者的角度出发,在设计 JMM 内存模型的时候需要考虑以下两点:

  • 从程序员的角度,程序员希望这种内存模型是易于理解和使用的。这就需要一个强内存模型
  • 从编译器和处理器的角度来看,编译器和处理器希望内存模型对它们所作的束缚更小,定义的规则不要太多,保证性能的同时也可以处理安全性。所以它们需要一个弱内存模型

所以,一方面要为程序提供足够的内存可见性保证,一方面又要对编译器和处理器的限制要小,下面就来看 JSR - 133 是如何实现这一目标,下面以一段程序为例:

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

上面这段程序中存在三个 happens - before 关系:

  1. A happens - before B
  2. B happens - before C
  3. A happens - before C

在上面这三个关系中,2 和 3 是必须的,1 是非必须的。从前面的文章也讲过了,JMM 不对会具有数据依赖性的两个操作进行指令重排序。而对于没有数据依赖性的 1 和 2 是可以进行重排序的,所以,JMM 把 happens - before 要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序(有数据依赖的)
  • 不会改变程序执行结果的重排序(没有数据依赖的)

而 JMM 对于这两种类型的重排序做了不同的处理:

  • 对于第一种有数据依赖的重排序,JMM 要求编译器和处理器禁止这种重排序
  • 读于第二种没有数据依赖的重排序,JMM 要求编译器和处理器不做处理
    在这里插入图片描述

从上面可以看出:

  • JMM 的 happens - before 规则不但简单,而且也向程序员提供了足够强的内存可见性保证,但是其中有些内存可见性保证其实不一定存在,比如 A happens - before B,因为 JMM 其实并没有禁止这种指令重排序,所以 A happens - before B 其实有可能不一定存在。
  • JMM 对于编译器和处理器的优化已经尽可能少了。从上面的分析可以看到,JMM 其实在遵循一个规则:单线程下或者已经同步的多线程下(synchronized,volatile等),只要不改变程序的执行结果,编译器和处理器想要怎么优化都可以。
    • 比如对于 Synchronized 的优化,编译器在进行编译的时候如果发现临界区的代码块中执行的代码是不存在多线程问题的,比如有一个方法,这个方法中只对局部变量进行赋值操作,那么这个方法就算使用了 synchronized 来修饰方法,其实和普通方法没有什么区别,局部变量不受多线程影响。所以编译器会把这个方法进行优化,主动去除 synchronized ,那么运行的速度和没有锁的运行速度是一样的。
    • 而对于volatile 变量,如果编译器分析后认定这个变量只会被单线程访问,不存在多线程的可见性问题,那么编译器就会把 volatile 变量当作普通变量进行处理。
    • 这些优化在不改变程序原来的执行结果的基础上,提高了程序运行的效率。



2. happens - before 的定义

JSR - 133 使用 happens - before 的概念来指定两个操作之间的执行顺序。而这两个操作可以是单个线程的,也可以是不同线程之间的。因此,JSR - 133 使用 happens - before 关系来向程序员提供跨线程的内存可见性保证,比如线程 A 中对变量 m 的写操作和线程 B 中对变量 m 的读操作之间会存在 happens - before 规则,这样就可以保证一个线程的操作对另一个线程的操作可见。下面是对 happens - before 的定义

  • 如果一个操作 happens - before 另一个操作,那么另一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens - before 关系,并不意味这 Java 平台必须要按照 happens - before 规定的顺序来执行。只要重排序之后的结果是不改变的,那么 JMM 是默认不对这种重排序处理的

上面的 1 是 JMM 对程序员的承诺。 Java 内存模型向程序员保证,A 操作的结果对 B 可见,且 A 的执行顺序排在 B 之前,当然这只是 Java 内存模型向程序员做出的保证,而实际上的效果有可能不相同,但是从结果上来看是相同的,而程序员要搞懂内部的结构还是需要深入底层去看 JMM 内存模型。

上面的 2 是 JMM 对编译器和处理器重排序的约束。 JMM 一直在遵循一个最基本的原则:只要不改变程序的执行结果(单线程和同步的多线程),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员只需要关系的是程序执行时的语义不能被改变(就是执行结果不能被改变)。 所以 happens - before 和 as - if - serial 的语义时一样的。

  • as - if - serial 语义保证单线程内程序的执行结果不被改变。注意多线程下 as - if - serial 是无效的。happens - before 关系规则保证正确同步的多线程程序的执行结果不被改变
  • as - if - serial语义让编写单线程的程序员认为:单线程程序是按照程序的顺序来执行的。happens - before 关系让编写正确同步的多线程程序的程序员认为:正确同步的多线程程序是按照 happens - before 指定的顺序来执行的。



3. happens - before 规则

所有规则

《JSR - 133:Java Memory Model and Thread Specification》定义了下面的happens - before 规则:

  1. 程序顺序规则:一个线程中的每个操作,happens - before 于该程序的任意后续操作。换句话说,就是线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见,比如下面的diamagnetic,线程 t1 对于变量 x 的写,在释放锁后,线程 t2 可以读取到 x = 10
    static int x;
        static Object m = new Object();
        new Thread(()->{
            synchronized(m) {
                x = 10;
            }
        },"t1").start();
        new Thread(()->{
            synchronized(m) {
                System.out.println(x);
            }
        },"t2").start();
  1. 监视器规则:对一个锁的解锁,happens - before 于随后对这个锁的加锁。这里看上面的例子,线程 t1 的解锁是在线程 t2 的加锁之前的。

  2. volatile 变量规则:对一个 volatile 域的写,happens - before 于任意后续对这个 volatile 域的读。下面的代码中,线程 t1 对于 volatile 变量的写 happens - before 线程 t2 对于 volatile 的读。在 t1 对于 volatile 写后,线程 t2 就可以同步获取到 x = 10

    volatile static int x;
    new Thread(()->{
     x = 10;
    },"t1").start();
    new Thread(()->{
     System.out.println(x);
    },"t2").start();
  1. 传递性,如果 A happens - before B, B happens - before C,那么 A happens - before C
  2. start() 规则:如果线程 A 执行操作 ThreadB.start()(启动线程B),那么线程 A 的 ThreadB.start() 操作 happens - before 线程 B 中的任意操作。下面代码中主线程启动了线程 t2,这时候主线程中对 x = 10 的操作 happens - before 线程 t2 中对 x 的读。此时的线程 t2 应该是可以读出 x = 10的
	static int x;
	x = 10;
	new Thread(()->{
	 System.out.println(x);
	},"t2").start();
  1. join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens- before 于线程 A 从 Thread.join() 操作成功返回。下面这段代码,主线程调用了 t1.join() 等待了 t1 执行完所有的代码主线程才继续往下运行,所以主线程最后可以读到线程 t1 中对于 x = 10 的赋值。
	static int x;
	Thread t1 = new Thread(()->{
	 x = 10;
	},"t1");
	t1.start();
	t1.join();
	System.out.println(x);
  1. 第七条是黑马视频中提到的:线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或t2.isInterrupted)
static int x;
public static void main(String[] args) {
     Thread t2 = new Thread(()->{
         while(true) {
             if(Thread.currentThread().isInterrupted()) {
                 System.out.println(x);
                 break;
             }
         }
     },"t2");
     t2.start();
     new Thread(()->{
         //t1打断t2之后赋值为10,主线程得知 t2 被打断之后对于x可以读出10
         sleep(1);
         x = 10;
         t2.interrupt();
     },"t1").start();
     while(!t2.isInterrupted()) {
         Thread.yield();
     }
     System.out.println(x);	//10
 }
  1. 第八条也是黑马给出的:对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。你可以理解为在创建不同类型的数据的时候就已经有一个默认值了,其他线程是可以读取到这个默认值的。



说明

下面针对1、3、4来具体说说这三种情况的 happens - before图

  1. 下面是 volatile 写-读建立的 happens-bedore 关系图,注意,图中的黑色是传递关系。里面的 1 happens-before 3 可以看作不存在,只当作有 1 happens-before 4 就可以了。
    在这里插入图片描述

    • 1 happens-before 2 和 3 happens-before 4 是由程序顺序规则产生的。由于编译器和处理器都要遵循 as-if-serial 语义,也就是说,as-if-serial 语义保证了程序顺序规则。因此,可以把程序顺序规则看作是对 as-if-serial 语义的封装。关于 as-if-serial 语义,可以去看前面给出的重排序的文章。
    • 2 happens-before 3 是由 volatile 规则产生的。这就是前面的 volatile 变量规则。
    • 1 happens-before 4 是传递性规则产生的。这里的传递性是由 volatile 的内存屏障插入策略和 volatile 额编译器重排序规则共同来保证的。具体就是保证里线程 t2 的读操作不会重排序到线程 t1 之前,注意这里如何不使用 volatile 修饰,由于存在 控制依赖的关系,就有可能会发生 4 重排序到 1 之前的后果。在重排序中已经测试了,大概 2000 万次的执行中会有 1000 次这样的情况出现。

  1. 下面来看 start() 规则。假设线程 A 在执行过程中执行了 ThreadB.start() 来启动线程 B,而同时线程 A 已经修改了一些共享变量,线程 B 开始读取这些变量,下面是对应的 happens - before 关系图,由于线程 A 没有指定是重新创建的线程,所以这里可以理解为主线程修改了共享变量之后启动了线程B,这时候线程B可以获取到这些共享变量最新的值。
    在这里插入图片描述

    • 在图中 1 happens-before 2 由程序顺序规则产生,而 2 happens - before 4 由 start() 规则产生,根据传递性,有 1 happens - before 4,这就证明了,线程 A 在执行 ThreadB.start() 之前对共享变量的修改,对于线程B 而言是可见的。
    • 为什么 start 有这种作用呢?我们深入源码来看看 start 方法 ==> public synchronized void start() {},从这里就能很明显看出来了。start 方法使用了 synchronized 来修饰。之前的文章说过,synchronized 这个字具有 volatile 的效果,可以保证线程修改的值具有可见性。所以这里当一个线程修改了共享变量的值之后,再启动另一个线程,就会把修改的值刷新会主存,其他线程在启动的时候再从主存中获取数据。

  1. join() 规则。假设线程 A 在执行的过程中,通过执行 ThreadB.join() 来等待线程 B 中止,而线程 B 修改了一些共享变量,那么此时线程 A 从 ThreadB.join() 返回后可以读到这些变量。
    在这里插入图片描述

    • 在图中,2 happens - before 4 由 join() 规则产生,4 happens - before 5 由程序顺序规则产生。根据程序传递性规则,将有 2 happens-before 5.这意味着,线程 A 执行操作 ThreadB.join() 并成功返回之后,线程 B 中的任意操作都将对线程 A 可见。
    • 如果回到源码去看,会发现 join 其实也是 synchronized 修饰的,线程 B 对于共享变量的写是可以被其他线程所看到的。
      在这里插入图片描述





如有错误,欢迎指出!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值