再学JMM Happens-Before原则

JMM就使用happens-before的概念来阐述多线程之间的内存可见性

1. 为什么就会存在可见性问题?

相对于内存,CPU的速度是极高的,如果CPU需要存取数据时都直接与内存打交道,在存取过程中,CPU将一直空闲,这是一种极大的浪费,所以,现代的CPU里都有很多寄存器,多级cache,他们比内存的存取速度高多了某个线程执行时,内存中的一份数据,会存在于该线程的工作存储中(working memory,是cache和寄存器的一个抽象,这个解释源于《Concurrent Programming in Java: Design Principles and Patterns, Second Edition》§2.2.7,原文:Every thread is defined to have a working memory (an abstraction of caches and registers) in which to store values. 有不少人觉得working memory是内存的某个部分,这可能是有些译作将working memory译为工作内存的缘故,为避免混淆,这里称其为工作存储,每个线程都有自己的工作存储),并在某个特定时候回写到内存。单线程时,这没有问题,如果是多线程要同时访问同一个变量呢?内存中一个变量会存在于多个工作存储中,线程1修改了变量a的值什么时候对线程2可见?此外,编译器或运行时为了效率可以在允许的时候对指令进行重排序,重排序后的执行顺序就与代码不一致了,这样线程2读取某个变量的时候线程1可能还没有进行写入操作呢,虽然代码顺序上写操作是在前面的。这就是可见性问题的由来。

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

2. happens-before原则定义

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则规定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

3. happens-before原则规则

  • 程序次序规则
    一个线程内,按代码顺序,在前面的操作先行发生于在后面的操作;

一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。

  • 锁定规则
    一个unLock操作先行发生于后面对同一个锁的lock操作;

如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。

  • volatile变量规则
    对一个变量的写操作先行发生于后面对这个变量的读操作;

如果线程1写入了volatile变量v(这里和后续的“变量”都指的是对象的字段、类字段和数组元素),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。

  • 传递规则
    如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

体现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C

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

假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。

  • 线程中断规则
    对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

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

线程t1写入的所有变量(所有action都与那个join有happens-before关系,当然也包括线程t1终止前的最后一个action了,最后一个action及之前的所有写入操作,所以是所有变量),在任意其它线程t2调用t1.join()成功返回后,都对t2可见。

  • 对象终结规则
    一个对象的初始化完成先行发生于它的finalize()方法的开始。

4. 推导出其他满足happens-before的规则

  • 将一个元素放入一个线程安全队列的操作Happens-Before从队列中取出这个元素的操作
  • 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
  • 在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
  • 释放Semaphore许可的操作Happens-Before获得许可操作
  • Future表示的任务的所有操作Happens-Before Future#get()操作
  • 向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作

如果两个操作不存在上述(前面8条 + 后面6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。

5. 简单的例子

private int i = 0;
 
public void write(int j ){
    i = j;
}
 
public int read(){
    return i;
}

(1) 我们约定线程A执行write(),线程B执行read(),且线程A优先于线程B执行,那么线程B获得结果是什么?;我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8 + 推导的6条可以忽略,因为他们和这段代码毫无关系):

  • 由于两个方法是由不同的线程调用,所以肯定不满足程序次序规则;
  • 两个方法都没有使用锁,所以不满足锁定规则;
  • 变量i不是用volatile修饰的,所以volatile变量规则不满足;
  • 传递规则肯定不满足;
    (2) 我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但是就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?满足规则2、3任一即可。
    (3) 修复代码
  • 满足规则2
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Demo {
    private int i = 0;

    private Lock lock = new ReentrantLock();

    public void write(int j) {
        try {
            lock.lock();
            i = j;
        } finally {
            lock.unlock();
        }
    }

    public int read() {
        try {
            lock.lock();
            return i;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        Demo demo = new Demo();
        Thread t1 = new Thread(() -> {
            demo.write(200);
        }, "线程t1");

        Thread t2 = new Thread(() -> {
            try {
                // 睡眠1秒,让线程t1先执行write, 然后线程t2才执行read
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int readValue = demo.read();
            System.out.println(Thread.currentThread().getName() + " 读取到值: " + readValue);
        }, "线程t2");

        t1.start();
        t2.start();
    }
}
结果:
线程t2 读取到值: 200
  • 满足规则3
package com.example.myproject.happensBefore;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Demo2 {
    private volatile int i = 0;

    public void write(int j) {
        i = j;
    }

    public int read() {
        return i;
    }

    public static void main(String[] args) {
        Demo2 demo = new Demo2();
        Thread t1 = new Thread(() -> {
            demo.write(200);
        }, "线程t1");

        Thread t2 = new Thread(() -> {
            try {
                // 睡眠1秒,让线程t1先执行write, 然后线程t2才执行read
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int readValue = demo.read();
            System.out.println(Thread.currentThread().getName() + " 读取到值: " + readValue);
        }, "线程t2");

        t1.start();
        t2.start();
    }
}

结果:
线程t2 读取到值: 200

参考文献


  1. 死磕 Java 并发 :Java 内存模型之 happens-before
  2. happens-before俗解

参考书籍

1.《Java Memory Model》、
2.《Java Concurrency in Practice》、
3.《Concurrent Programming in Java: Design Principles and Patterns》

你可能感兴趣的:(再学JMM Happens-Before原则)