Java happens-before-guarantee规则

本文翻译自http://tutorials.jenkov.com/java-concurrency/java-happens-before-guarantee.html,人工翻译,仅供学习交流。

Java happens-before-guarantee规则

Java happens-before-guarantee是一组规则,它决定了如何允许Java VM和CPU重新排列指令以提高性能。当一个变量值与主存同步或从主存同步时, happens-before-guarantee使得线程可以依赖同时同步的其他变量。Java happens-before-guarantee的中心是volatile变量和从同步块中的变量的访问。
本文将会提到Java volatile和Java synchronized提供的happens-before-guarantee,我不会解释所有相关的内容。这些术语在这里有更详细的介绍:

指令重排

如果指令不相互依赖,现代CPU并行执行指令。例如,以下两个指令并不相互依赖,因此可以并行执行:

a = b + c

d = e + f

下面的两条指令不易并行执行,因为第二条指令依赖于第一条指令的结果:

a = b + c
d = a + e

假设上面的两个指令是一个更大的指令集的一部分,如下所示:

a = b + c
d = a + e

l = m + n
y = x + z

指令可以像下面这样重新排序。这样CPU就可以并行执行至少前3条指令,只要第一个指令完成,它就可以开始执行第四个指令。

a = b + c

l = m + n
y = x + z

d = a + e

重新排序指令可以提高CPU中指令的并行执行速度。提高执行速度就是提升整体性能。
只要程序的语义不改变,Java虚拟机和CPU可以进行指令重新排序。只要这些指令是按照源代码中列出的顺序准确执行的,最终结果肯定是一样的。

多CPU计算机中的指令重排问题

指令重新排序在多线程和多CPU系统中将会有一些挑战,我将通过代码示例来说明这些问题。请记住,该示例只是为说明问题而专门构造的,代码示例是不推荐的。
假设两个线程协作,以尽可能快的速度在屏幕上绘制帧。一个线程产生帧,一个线程绘制帧。
两个线程需要通过某种通信机制交换帧。在下面的代码示例中,创建了一个通信机制的示例——FrameExchanger的Java类。
帧生产线程尽可能快地生成帧。画帧线程会以最快的速度画出这些帧。在画帧线程把帧画出来之前,生产者线程可能产生2帧,这种情况下,应该绘制最新的帧。我们不希望画帧线程落后于帧生产线程。在绘制前一帧之前,如果生产者线程有一个新的帧准备好,之前的帧会被新帧覆盖。换句话说,前一帧被“删除”了。
在帧生产线程产生一个新帧之前,画帧线程可能绘制好一个帧,并准备绘制一个新的帧。在这种情况下,我们希望绘图线程等待新帧。我们没有理由浪费CPU和GPU资源去重新绘制刚刚绘制过的相同帧。屏幕不会因此而改变,用户也不会从中看到任何新内容。
在FrameExchanger计算存储的帧数和取出的帧数,这样我们就可以知道掉了多少帧了。
下面是frameexchange的代码。注意:Frame类定义被省略了。为了理解frameexchange的工作原理,这个类是什么样并不重要。生成线程将持续调用storeFrame(),绘画线程将持续调用takeFrame()。

public class FrameExchanger  {

    private long framesStoredCount = 0:
    private long framesTakenCount  = 0;

    private boolean hasNewFrame = false;

    private Frame frame = null;

        // called by Frame producing thread
    public void storeFrame(Frame frame) {
        this.frame = frame;
        this.framesStoredCount++;
        this.hasNewFrame = true;
    }

        // called by Frame drawing thread
    public Frame takeFrame() {
        while( !hasNewFrame) {
            //busy wait until new frame arrives
        }

        Frame newFrame = this.frame;
        this.framesTakenCount++;
        this.hasNewFrame = false;
        return newFrame;
    }

}

请注意,storeFrame()方法中的三个指令似乎并不依赖于彼此。这意味着,如果Java VM或CPU认为指令重排是有利的,可以重新排序指令。但是,想象一下如果这些指令重新排序会发生什么,就像这样:

    public void storeFrame(Frame frame) {
        this.hasNewFrame = true;
        this.framesStoredCount++;
        this.frame = frame;
    }

在frame字段被指定为引用新的frame对象之前,字段hasNewFrame将被设置为true的。如果画帧线程在takeFrame()方法的while-loop中等待,画帧线程可以退出while-loop,取旧的Frame对象。这将导致对旧帧的重新绘制,导致资源的浪费。
在这种特殊情况下,重新绘制旧帧不会导致应用程序崩溃或出错。它只会浪费CPU和GPU资源。在其他情况下,这样的指令重新排序可能会使应用程序出现故障。

Java volatile可见性保证

Java volatile关键字为写入和读取的时候提供了一些可见性保证,volatile变量会导致变量值与主存之间的同步。这种与主存之间的同步使得这个值对其他线程可见。因此有了可见性保证这一术语。
在本节中,我将简要介绍Java volatile可见性保证,并解释指令重新排序可能会破坏volatile的可见性保证。这就是我们为什么会有Java volatile happens-before-guarantee,在指令重排时设置一些限制,避免volatile可见性保证不会破坏。

Java volatile写可见性保证

当写入一个Java volatile 变量时,值是被直接写回主存中。对volatile变量进行写操作的线程可见的所有变量都会被同步到主存中。
为了展示Java volatile写可见性保证,下面有个示例:

this.nonVolatileVarA = 34;
this.nonVolatileVarB = new String("Text");
this.volatileVarC    = 300;

这个例子包含了对volatile变量的两次写入,一次写入一个volatile变量。这个例子没有显示哪个变量被声明为volatile,因此,为了更清楚地说明,假设一个名为volatile的变量(实际上是字段)被声明为volatile。
当上面例子中的第三条指令写如volatile变量VolatileVarC时,这两个非易失性变量的值也将同步到主存。因为这些变量在写入volatile变量时对线程是可见的。

Java volatile读可见性保证

当您读取Java volatile的值时,该值保证直接从内存中读取。读取volatile变量的线程的所有变量也会从主存中刷新它们的值。为了说明Java volatile的读可见性保证,请看下面的例子:

c = other.volatileVarC;
b = other.nonVolatileB;
a = other.nonVolatileA;

注意第一条指令是读取volatile变量(other.volatileVarC)。当other.volatileVarC从主存中读取,other.nonVolatileB和other.nonVolatileA也可以从主存中读取。

Java volatile happens-before-guarantee规则

Java volitile happens-before-guarantee规则对volatile变量的指令重新排序设置一些限制。为了说明这个规则是必要的,让我们修改本教程前面的FrameExchanger类,使其具有hasNewFrame变量:

public class FrameExchanger  {

    private long framesStoredCount = 0:
    private long framesTakenCount  = 0;

    private volatile boolean hasNewFrame = false;

    private Frame frame = null;

        // called by Frame producing thread
    public void storeFrame(Frame frame) {
        this.frame = frame;
        this.framesStoredCount++;
        this.hasNewFrame = true;
    }

        // called by Frame drawing thread
    public Frame takeFrame() {
        while( !hasNewFrame) {
            //busy wait until new frame arrives
        }

        Frame newFrame = this.frame;
        this.framesTakenCount++;
        this.hasNewFrame = false;
        return newFrame;
    }

}

现在,当hasNewFrame变量设置为true时,frame和frameStoredCount也将同步到主存。此外,每次画帧线程读取内部while循环中的hasNewFrame变量时,frame和framesStoredCount也将从主存中刷新。即使是framesTakenCount也会从主存中更新。
如果Java VM重新排序了storeFrame()方法中的指令,就像这样:

        // called by Frame producing thread
    public void storeFrame(Frame frame) {
        this.hasNewFrame = true;
        this.framesStoredCount++;
        this.frame = frame;
    }

当第一个指令被执行时(因为hasNewFrame是volatile的),也就是在将新值赋给它们之前,现在framesStoredCount和frame字段将同步到主存。在将新值赋给frame变量之前,执行takeFrame()方法的画帧线程可能会退出while-loop。即使产生线程给frame变量赋了一个新值,不能保证这个值已经同步到主存,所以它对于画帧线程是可见的!

写入volatile变量Happens-before-guarantee规则

正如上文,在storeFrame()方法中重新排序指令可能会使应用程序出现故障。这就是需要对允许对volatile变量进行写操作的指令重新排序进行限制的原因了。
在写入一个volatile变量之前,保证其他的非volatile和volatile变量会被提前写入。
因为hasNewFrame是一个volatile变量,在storeFrame()方法中两个前面的指令不能重新排序在写hasNewFrame之后。

     // called by Frame producing thread
    public void storeFrame(Frame frame) {
        this.frame = frame;
        this.framesStoredCount++;
        this.hasNewFrame = true;  // hasNewFrame is volatile
    }

前面的两个指令不是volatile变量,所以JVM可随意重排。就像下面:

        // called by Frame producing thread
    public void storeFrame(Frame frame) {
        this.framesStoredCount++;
        this.frame = frame;
        this.hasNewFrame = true;  // hasNewFrame is volatile
    }

只要frame变量仍然在hasNewFrame变量之前被写入,这种重新排序不会破坏takeFrame()方法中的代码,整个程序仍然按照预期工作。

读取volatile变量Happens-before-guarantee规则

对于读取volatile变量,volatile变量也有一系列的Happens-before-guarantee规则。只是方向相反:
在读取非volatile和volatile变量之前,将会首先读取volatile变量。
我说的方向相反指的是对于volatile写操作,之前的所有写操作指令都在它之前。对于volatile读操作,所有的读操作都是在它之后。
看看下面的例子:

int a = this.volatileVarA;
int b = this.nonVolatileVarB;
int c = this.nonVolatileVarC;

指令2和3必须在第一个指令后执行,因为第一个指令读取一个volatile变量。也就是说,在读取两个非volatile变量之前,首先会读取volatile变量。
指令2和3可以随意重排,只要是在指令1之后执行。因此,这种重新排序是允许的:

int a = this.volatileVarA;
int c = this.nonVolatileVarC;
int b = this.nonVolatileVarB;

由于volatile读可见性保证,当this.volatileVarA从主存中读取,所有其他的变量此时对线程都是可见。因此,this.nonVolatileVarC和this.nonVolatileVarB也在同一时间从主存读入。读取volatileVarA的线程可以认为nonVolatileVarB和nonVolatileVarC是最新的。
如果最后两条指令中的任何一条重新排序在第一条volatile读指令之上,在该指令被执行时,对该指令的保证不会成立。这就是后面读取不能重新排序以显示在volatile变量的读取之上的原因。
关于takeFrame()方法,对volatile变量的第一次读取是在while循环中读取hasNewFrame字段。对volatile变量的第一次读取是在while循环中读取hasNewFrame字段。在这种特殊情况下,将任何其他读操作移到while循环之上也会破坏代码的语义,所以这些重新排序是不被允许的。

        // called by Frame drawing thread
    public Frame takeFrame() {
        while( !hasNewFrame) {
            //busy wait until new frame arrives
        }

        Frame newFrame = this.frame;
        this.framesTakenCount++;
        this.hasNewFrame = false;
        return newFrame;
    }

Java同步可见性保证

Java同步块提供了类似于Java volatile 变量的可见性保证。我将简要解释Java同步可见性保证。

Java同步进入可见性保证

当一个线程进入一个同步块时,所有对线程可见的变量都从主存中刷新。

Java同步退出可见性保证

当一个线程退出一个同步块时,所有对线程可见的变量都被写回主存。

Java同步可见性示例

看看ValueExchange类:

public class ValueExchanger {
    private int valA;
    private int valB;
    private int valC;

    public void set(Values v) {
        this.valA = v.valA;
        this.valB = v.valB;

        synchronized(this) {
            this.valC = v.valC;
        }
    }

    public void get(Values v) {
        synchronized(this) {
            v.valC = this.valC;
        }
        v.valB = this.valB;
        v.valA = this.valA;
    }
}

注意set()和get()方法内部的两个同步块。请注意块是为何放在两个方法的最后和第一个位置的。
在set()方法中,方法末尾的同步块强制所有变量在更新后同步到主存。当线程退出同步时,将变量值刷新到主存。这就是它在方法中被放在最后的原因——确保所有更新的变量值都被刷新到主存。
在get()方法中,同步块被放置在方法的开头。当调用get()的线程进入synchronized块时,所有变量都从主存中重新读取。这就是为什么这个同步块被放在方法的开头——确保所有变量在读取之前都从主存中刷新。

Java Synchronized happens-before-guarantee规则

Java同步块在保证之前提供了两个事件:一个保证与同步块的开始有关,另一个保证与同步块的结束有关。我将在下面的部分中介绍这两个方面

Java同步块开始happens-before-guarantee规则

Java同步块的开始提供了可见性保证(在前面的教程中)。当一个线程进入一个同步块,所有对线程可见的变量都将从主存中读取(刷新)。为了能够维持这一保证,对指令重新排序的一系列限制是必要的。为了说明原因,我将使用前面显示的ValueExchange的get()方法:

    public void get(Values v) {
        synchronized(this) {
            v.valC = this.valC;
        }
        v.valB = this.valB;
        v.valA = this.valA;
    }

如您所见,同步块位于方法的开头将保证所有的变量this.valA,this.valB和this.valC从主存中刷新(读入),接下来对这些变量的读取将使用最新的值。
在同步块开始之前,变量的任何读取都不能重新排序。如果一个变量在同步块开始之前重排,您将失去从主存中刷新变量值的保证。这就是下面的情况,不允许对指令重新排序:

    public void get(Values v) {
        v.valB = this.valB;
        v.valA = this.valA;
        synchronized(this) {
            v.valC = this.valC;
        }
    }
Java同步块结束happens-before-guarantee规则

当线程退出同步块时,同步块的结束提供了可见性保证,所有改变的变量将被写回主存。为了能够维持这一保证,对指令重新排序的一系列限制是必要的。为了说明原因,我将使用前面显示的ValueExchange的set()方法:

    public void set(Values v) {
        this.valA = v.valA;
        this.valB = v.valB;

        synchronized(this) {
            this.valC = v.valC;
        }
    }

当调用set()的线程退出synchronized块时,方法末尾的同步块将保证所有的改变的变量this.valA,this.valB和this.valC都将被写回(刷新)到主存中。
在同步块结束之后,变量的任何写入都不能重新排序。如果对变量的写入在同步块结束之后重新排序,您将失去将变量值写回主存的保证。这就是下面的情况,不允许对指令进行重新排序:

    public void set(Values v) {
        synchronized(this) {
            this.valC = v.valC;
        }
        this.valA = v.valA;
        this.valB = v.valB;
    }
下一节:Java同步代码块
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值