Java发生在保证之前

时间:2020-01-09 10:35:51  来源:igfitidea点击:

Java是在保证发生之前发生的,它是一组规则,这些规则控制如何允许Java VM和CPU重新排序指令以提高性能。保证之前发生的情况使线程有可能依赖于何时将变量值同步到主内存或者从主内存同步,以及哪些其他变量已同步。 Java的出现是在保证以"易失性"变量和从同步块内部访问的变量为中心的情况下发生的。

该Java发生在担保之前的教程将提到Java volatile和Java同步声明提供的担保之前的发生,但是在本教程中,我不会解释这些声明的所有内容。这些术语在这里更详细地介绍:Java volatile教程Java同步教程。

指令重新排序

如果指令彼此不依赖,则现代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条指令,并且一旦完成第一条指令,CPU就可以开始执行第4条指令。

a = b + c

l = m + n
y = x + z

d = a + e

如我们所见,对指令重新排序可以增加CPU中指令的并行执行。增加并行化意味着提高性能。

只要程序的语义不变,就可以对Java VM和CPU进行指令重新排序。最终结果必须与指令按照源代码中列出的确切顺序执行一样。

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

指令重排序在多线程,多CPU系统中提出了一些挑战。我将尝试通过一个代码示例来说明这些问题。请记住,该示例是专门为说明这些问题而构造的。因此,该代码示例绝不是任何推荐!

想象一下,两个线程协同工作,以尽可能快的速度在屏幕上绘制框架。一帧生成框架,另一线程在屏幕上绘制该框架。

这两个线程需要通过某种通信机制来交换帧。在下面的代码示例中,我创建了一个名为FrameExchanger的Java类的通信机制示例。

框架生成线程尽可能快地生成框架。框架绘制线程将尽快绘制那些框架。

有时,生产者线程可能会在绘制线程有时间绘制它们之前生成2帧。在这种情况下,仅应绘制最新的帧。我们不希望绘图线程落后于生产线程。如果生产者线程在绘制前一帧之前已准备好新帧,则只需用新帧覆盖前一帧。换句话说,前一帧被"丢弃"。

有时,绘制线程可能会绘制框架,并准备在生产线程生成新框架之前绘制新框架。在这种情况下,我们希望绘图框架等待新的框架。没有理由浪费CPU和GPU资源重新绘制与刚才绘制的框架完全相同的框架!屏幕不会因此改变,用户也不会看到任何新东西。

FrameExchanger计算存储的帧数和获取的帧数,因此我们可以感觉到丢弃了多少帧。

下面是FrameExchanger的代码。注意:框架类定义被忽略。为了了解FrameExchanger的工作原理,此类的外观并不重要。生产线程将连续调用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,如果Java VM或者CPU确定有利,可以对指令重新排序。但是,请想象一下,如果对指令重新排序会发生什么情况,如下所示:

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

请注意,在将"框架"字段分配为引用新的框架对象之前,如何将" hasNewFrame"字段设置为" true"。这就是说,如果绘图线程在takeFrame()方法的while循环中等待,则绘图线程可以退出while循环,并获取旧的Frame对象。这将导致重新绘制旧框架,从而浪费资源。

显然,在这种情况下,重新绘制旧框架不会使应用程序崩溃或者发生故障。这样只会浪费CPU和GPU资源。但是,在其他情况下,此类指令重新排序可能会使应用程序出现故障。

Java易失性可见性保证

Java关键字volatile为何时写入和读取volatile变量提供了一些可见性保证,从而导致变量值与主内存之间的同步。与主内存之间的这种同步使该值对其他线程可见。因此,术语"可见性保证"。

在本节中,我将简要介绍Java易失性可见性保证,并说明指令重新排序如何破坏易失性可见性保证。这就是为什么我们还要在保证之前发生Java易失性,以对指令重新排序施加一些限制,以便使易失性可见性保证不会因指令重新排序而被破坏。

Java volatile写入可见性保证

当我们写入Java的volatile变量时,可以确保将值直接写入主存储器。此外,线程可见的所有写入volatile变量的变量也将同步到主内存。

为了说明Java易失性写可见性保证,请看以下示例:

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

本示例包含对非易失性变量的两次写入,以及对易失性变量的一次写入。该示例未明确显示哪个变量被声明为volatile,因此要清楚一点,想象一下名为" volatileVarC"的变量(实际上是字段)被声明为" volatile"。

当上面示例中的第三条指令写入易失性变量volatileVarC时,这两个非易失性变量的值也将同步到主存储器,因为在写入易失性变量时线程可以看到这些变量。

Java volatile可读性保证

当我们读取Java" volatile"的值时,可以确保直接从内存中读取该值。此外,读取易失性变量的线程可见的所有变量也将从主存储器中刷新其值。

为了说明Java volatile可读性保证,请看以下示例:

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

注意,第一条指令是读取" volatile"变量(" other.volatileVarC")的。当从主存储器读入" other.volatileVarC"时,也会从主存储器读入" other.nonVolatileB"和" other.nonVolatileA"。

Java波动发生在保证之前

Java volatile发生在保证为挥发性变量周围的指令重新排序设置一些限制之前。为了说明为什么需要这种保证,让我们从本教程前面的部分修改FrameExchanger类,以将hasNewFrame变量声明为volatile:

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也将同步到主内存。另外,每当绘图线程在takeFrame()方法内的while循环中读取hasNewFrame变量时,也会从主存储器中刷新frameframesStoredCount。此时,即使是framesTakenCount也将从主内存中进行更新。

想象一下,如果Java VM重新对storeFrame()方法中的指令进行了排序,如下所示:

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

现在,当执行第一条指令时(因为" hasNewFrame"是易失性的)," framesStoredCount"和" frame"字段将被同步到主存储器!

这意味着执行takeFrame()方法的绘图线程可能会在将新值分配给frame变量之前退出while循环。即使生产线程已将新值分配给frame变量,也无法保证此值已同步到主内存,因此对于绘制线程是可见的!

在保证写入易失性变量之前发生

如我们所见,在storeFrame()方法中对指令进行重新排序可能会使应用程序出现故障。这是在保证出现之前在易失性写入发生之前对限制对易失性变量的写入进行哪种指令重新排序的限制:

确保对非易失性或者易失性变量的写操作发生在对易失性变量的写操作之前,然后再写入该易失性变量。

storeFrame()方法的情况下,这意味着两个最后的写指令不能重新排序以在最后一次写hasNewFrame之后发生,因为hasNewFrame是一个易变的变量。

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

前两条指令未写入易失性变量,因此Java VM可以自由地对其重新排序。因此,此重新排序是允许的:

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

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

在保证读取易变变量之前发生

在保证读取易变变量之前,Java中的易变变量会发生类似的情况。只是方向相反:

易失性变量的读取将在随后的易失性和非易失性变量的任何后续读取之前发生。

当我说方向与写操作不同时,是指对于易失性写,在写之前的所有指令都将在易失性写之前保留。对于易失性读取,易失性读取之后的所有读取将在易失性读取之后保留。

看下面的例子:

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

指令2和3都必须保留在第一条指令之后,因为第一条指令读取的是易失性变量。换句话说,易失性变量的读取保证在非易失性变量的两次后续读取之前进行。

最后两条指令可以在它们之间自由地重新排序,而不会违反在保证第一条指令中的易读性之前发生的情况。因此,此重新排序是允许的:

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

由于易读性保证,当从主存储器中读取" this.volatileVarA"时,所有其他变量在那时对线程也是可见的。因此," this.nonVolatileVarB"和" this.nonVolatileVarC"也同时从主存储器中读取。这意味着,读取" volatileVarA"的线程也可以依赖" nonVolatileVarB"和" nonVolatileVarC"来更新主内存。

如果最后两条指令中的任何一条要在第一条易失性读取指令上方重新排序,则该指令在执行时的保证将不会成立。这就是为什么以后的读取不能重新排序以出现在volatile变量读取上方的原因。

关于takeFrame()方法,对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同步可见性保证

Javasynchronized块提供的可见性保证与Javavolatile变量相似。我将简要解释Java同步可见性保证。

Java同步条目可见性保证

当线程进入"同步"块时,线程可见的所有变量将从主内存中刷新。

Java同步退出可见性保证

当线程退出" synchronized"块时,该线程可见的所有变量都被写回到主存储器中。

Java同步可见性示例

看一下这个ValueExchanger类:

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()的线程进入同步块时,将从主存储器中重新读取所有变量。这就是为什么将此同步块放置在方法的开头,以确保在读取变量之前从主内存中刷新所有变量的原因。

Java同步发生在保证之前

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

Java同步块开始发生在保证之前

Java同步块的开头提供了可见性保证(在本教程前面已提到),当线程进入同步块时,该线程可见的所有变量都将被读入(刷新)主存储器。

为了维持这种保证,必须对指令重新排序进行一系列限制。为了说明原因,我将使用前面显示的ValueExchanger的get()方法:

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

如我们所见,方法开始处的同步块将保证从主存储器刷新(读入)所有变量" this.valC"," this.valB"和" this.valA"。以下对这些变量的读取将使用最新值。

为此,不能对变量的所有读取进行重新排序,以使其出现在同步块的开始之前。如果对变量的读取进行了重新排序,使其出现在同步块的开始之前,则将无法保证从主存储器中刷新变量值。以下未经许可的指令重新排序就是这种情况:

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

Java同步块结束发生在保证之前

同步块的末尾提供了可见性保证,即当线程退出同步块时,所有更改的变量都将写回到主存储器。

为了维持这种保证,必须对指令重新排序进行一系列限制。为了说明原因,我将使用前面显示的ValueExchanger的set()方法:

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

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

如我们所见,方法末尾的同步块将确保所有已更改的变量" this.valA"," this.valB"和" this.valC"将被写回到(刷新)到主存储器中。当调用set()的线程退出同步块时。

为此,对变量的所有写操作都不能重新排序以出现在同步块结束之后。如果对变量的写入被重新排序以使其出现在同步块的末尾之后,我们将无法保证将变量值写回到主存储器中。在以下未经许可的指令重新排序中就是这种情况:

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