拨开荷叶行,寻梦已然成。仙女莲花里,翩翩白鹭情。
IMG-LOGO
主页 文章列表 根据happens-before法则借助同步

根据happens-before法则借助同步

白鹭 - 2022-01-23 2501 0 0

在文章的开始,我们先来看一段代码以及他的执行情况:

public class PossibleRecording{
    static int x = 0, y = 0;
    static int a = 0, b = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                a = 1;
                y = b;
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                x = a;
            }
        });

        threadOne.start();
        threadTwo.start();
        threadOne.join();
        threadTwo.join();
        System.out.println("( " + x + " , " + y + " )");
    }
}

执行结果:
( 0 , 1 )   
( 1 , 0 )
( 1 , 1 )
( 0 , 0 )

对于上面这一段及其简单的代码,可以很简单的想到程序是如何打印( 0 , 1 ) 或 ( 1 , 0 ) 或 ( 1 , 1 ) 的,执行绪One可以在执行绪Two开始前完成,执行绪Two也可以在执行绪One开始前完成,又或者他们可以交替完成,但是奇怪的是,程序竟然可以打印( 0 , 0 ),下图展示了一种打印(0 , 0)的可能(由于每个执行绪中的动作都没有依赖其他执行绪的资料流,因此这些动作可以乱序执行):

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序,存储器级的重排序会让程序的行为变得不可预期,而同步就抑制了编译器、运行时和硬件对存盘操作的各种方式的重排序,否则这些重排序将会破坏JMM提供的可见性保证,JMM确保在不同的编译器和不同的处理器平台上,通过插入特定型别的Memory Barrier来禁止特定型别的编译器重排序和处理器重排序,为上层提供一致的可见性保证,

那么在正确使用同步、锁的情况下,执行绪One修改了变量a的值何时对执行绪Two可见呢?我们无法就所有场景来规定某个执行绪修改的变量何时对其他执行绪可见,但是我们可以指定某些规则,这个规则就是happens-before,从JDK 5开始,JMM就是用happens-before的概念来阐述多执行绪之间的存储器可见性,

happens-before原则的定义如下:

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

happens-before法则包括:

  • 程序次序法则:执行绪中的每个动作A都 happens-before 于该执行绪中的每一个动作B,其中在程序中,所有的动作B都出现在动作A之后
  • 监视器锁法则:对一个监视器锁的解锁 happens-before 于每一个后续对同一监视器锁的枷锁
  • volatile变量法则: 对 volatile 域的写入操作 happens-before 于每一个后续对同一个域的读操作
  • 执行绪启动法则: 在一个执行绪里,对Thread.start() 方法的呼叫会 happens-before 于每一个启动执行绪中的动作
  • 执行绪终结法则: 县城中的任何动作都 happens-before 于其他执行绪检测到这个执行绪已经终结、或者动Thread.join()的呼叫中成功回传,或者Thread.isAlive()回传false
  • 中断法则:一个执行绪呼叫另一个执行绪的 interrupt happens-before与被中断的执行绪发现中断(通过抛出InterruptedException例外,或者呼叫isInterrupted和 interrupted)
  • 终结法则: 一个物件的建构式的结束 happens-before 于这个物件 finalizer 的开始
  • 传递性: 如果A happens-before 于B,且B happens-before 于C,则A happens-before 于 C

当一个变量被多个执行绪读取,且至少被一个执行绪写入时,如果读写操作并未依照排序,就会产生资料竞争,一个正确同步的执行绪是没有资料竞争的程序,加锁解锁对volatile变量的读写启动一个执行绪以及检测执行绪是否结束这样的操作均是同步动作,

FutureTask原始码解读

接下来看看FutureTask中是如何巧妙运用happens-before法则的,

在FutureTask中最重要的变量就是上图中标记出来的两个,

  • state:是一个volatile修饰的变量,用于表示当前task的状态
  • outcome:用于get()回传的正常结果,也可能是例外

注意看outcome后面的注释,在jdk原始码中很少有这样的注释,一旦有这样的注释,那肯定是非常重要的,

理论上讲,outcome会被多个执行绪访问,其中应该是一个执行绪可以读写,其他的执行绪都只能读,那这种情况下,为啥不加上volatile呢?加上volatile的好处就是可以让outcome和state变量被修改后,其他执行绪可以立刻感知到,但作者为啥不加上volatile呢?

在整个类中,与outcome变量的写入操作,只有这两个地方:

protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
        finishCompletion();
    }
}

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
        finishCompletion();
    }
}

与outcome有关的读取操作,即get操作:

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

public V get(long timeout, TimeUnit unit)
    throws InterruptedException, ExecutionException, TimeoutException {
    if (unit == null)
        throw new NullPointerException();
    int s = state;
    if (s <= COMPLETING &&
        (s = awaitDone(true, unit.toNanos(timeout))) <= COMPLETING)
        throw new TimeoutException();
    return report(s);
}

接下来我们把目光集中到这三个方法上:set(),get(),report()

我们把get()和report()合并到一起,将多余的代码去掉,如下:

public V get() {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    Object x = outcome;
    if (s == NORMAL);
        return (V)x;
}

从上面可以看出,当state为NORMAL的时候,回传outcome,

再来看看set()方法:

protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
        finishCompletion();
    }
}

第二行,通过UNSAFE的cas操作将状态从NEW状态改为COMPLETING,cas设定成功之后,进入if方法里面,然后给outcome设定值,第四行,将state的状态设定为NORMAL状态,从备注中可以看到这是一个最终状态,那从NEW状态到NORMAL状态,中间有一个稍纵即逝的状态-COMPLETING,从get方法中可以看到,如果state的状态小于等于COMPLETING(即为NEW状态)时,就是当前执行绪没有抢到CPU的执行时间,进入等到状态,

我们把get()和set()的伪代码放在一起:

首先你读到标号为4的地方,读到的值是NORMAL,那么说明标号为3的地方一定已经执行过了,因为state是volatile修饰过的,根据happens-before关系:volatile变量法则:对 volatile 域的写入操作 happens-before 于每一个后续对同一个域的读操作,所以我们可以得出标号3的代码先于标号4的代码执行,

而又根据程序次序规则,即:

在一个执行绪内,按照控制流顺序,书写在前面的操作先行于书写在后面的操作,注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、回圈等结构,

可以得出:2 happens-before 3 happens-before 4 happens-before 5;

又根据传递性的规则,即:

传递性: 如果A happens-before 于B,且B happens-before 于C,则A happens-before 于 C

可以得出,2 happens-before 5,而2就是对outcome变量的写入,5是对outcome变量的读取,所以,虽然outcome的变量没有加volatile,但是他是通过被volatile修饰的state变量,借助了变量的happens-before关系,完成了同步的操作(即写入先于读取),

参考文章:

推荐一个公众号:【why技术】 https://mp.weixin.qq.com/s/1SjOChRD0a241UCsBEAfCA

https://www.cmsblogs.com/?p=2102

https://blog.csdn.net/xixi_haha123/article/details/81155796

本文著作权归Charon和博客园共有,原创文章,欢迎转载,但未经作者同意必须保留此段宣告,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利,
标签:

0 评论

发表评论

您的电子邮件地址不会被公开。 必填的字段已做标记 *