根据happens-before法则借助同步
admin
撰写于 2022年 01月 22 日

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

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

根据happens-before法则借助同步的

根据happens-before法则借助同步

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

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

根据happens-before法则借助同步的

赞 (0)

猜您想看

评论区(暂无评论)

这里空空如也,快来评论吧~

我要评论