在什么情况下空的同步块可以实现正确的线程语义?

发布于 2024-07-15 06:57:37 字数 507 浏览 6 评论 0原文

我正在查看关于我的代码库的 Findbugs 报告,触发的模式之一是空 < code>synchronzied 块(即 synchronized (var) {})。 文档说

空同步块要多得多 微妙且难以正确使用 大多数人都认得,而且空虚 同步块几乎从来都不是 比不那么做作的更好的解决方案 解决方案。

就我而言,发生这种情况是因为块的内容已被注释掉,但 synchronized 语句仍然存在。 在什么情况下,空的同步块可以实现正确的线程语义?

I was looking through a Findbugs report on my code base and one of the patterns that was triggered was for an empty synchronzied block (i.e. synchronized (var) {}). The documentation says:

Empty synchronized blocks are far more
subtle and hard to use correctly than
most people recognize, and empty
synchronized blocks are almost never a
better solution than less contrived
solutions.

In my case it occurred because the contents of the block had been commented out, but the synchronized statement was still there. In what situations could an empty synchronized block achieve correct threading semantics?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(5

水水月牙 2024-07-22 06:57:37

空的同步块将等待,直到没有其他人使用该监视器。

这可能就是您想要的,但是因为您没有保护同步块中的后续代码,所以没有什么可以阻止其他人在运行后续代码时修改您正在等待的内容。 这几乎从来都不是你想要的。

An empty synchronized block will wait until nobody else is using that monitor.

That may be what you want, but because you haven't protected the subsequent code in the synchronized block, nothing is stopping somebody else from modifying what ever it was you were waiting for while you run the subsequent code. That's almost never what you want.

苦妄 2024-07-22 06:57:37

我在第一节和第二节中解释了空的同步块如何“实现正确的线程”。 我将在第三节中解释它如何成为“更好的解决方案”。 我在第四节也是最后一节中通过示例展示了它如何“微妙且难以正确使用”。

1. 正确的线程

在什么情况下,空的 synchronized 块可以启用正确的线程?

考虑一个例子。

import static java.lang.System.exit;

class Example { // Incorrect, might never exit

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                toExit = true; }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                if( toExit ) exit( 0 ); }})
            .start(); }

    boolean toExit; }

上面的代码不正确。 运行时可能会隔离线程 A 对布尔变量 toExit 的更改,从而有效地对 B 隐藏它,然后 B 就会永远循环。

它可以通过引入空的同步块来纠正,如下所示。

import static java.lang.System.exit;

class Example { // Corrected

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                toExit = true;
                synchronized( o ) {} }}) // Force exposure of the change
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {} // Seek exposed changes
                if( toExit ) exit( 0 ); }})
            .start(); }

    static final Object o = new Object();

    boolean toExit; }

2. 正确性的基础

空的synchronized块如何使代码正确?

Java 内存模型保证“监视器 m 上的解锁操作与 m 上的所有后续锁定操作同步”,从而发生在这些操作之前 (§17.4.4)。 因此,A 的 synchronized 块尾部的监视器 o解锁发生在其最终的锁定之前位于 B 的 synchronized 块的头部。 由于 A 对变量的写入先于其解锁,而 B 的锁定先于读取,因此保证扩展到写入和读取操作:写入发生在读取之前< /em>.

现在,“[如果]一个操作发生在另一个操作之前,则第一个操作对第二个操作可见并且在第二个操作之前排序”(§17.4.5)。 正是这种可见性保证使得代码在内存模型方面是正确的。

3. 比较实用性

空的synchronized 块如何成为比替代方案更好的解决方案?

与非空“同步”块的比较

一种替代方案是非空“同步”块。 非空同步块有两个作用:a)它提供上一节中描述的排序和可见性保证,有效地强制在同一监视器上同步的所有线程之间公开内存更改; b) 它使块内的代码在这些线程中有效地原子化; 该代码的执行不会与其他块同步代码的执行交错​​。

空的synchronized 块仅执行上述 (a) 操作。 在仅需要 (a) 且 (b) 可能产生大量成本的情况下,空的同步块可能是更好的解决方案。

与“易失性”修饰符

另一种选择是附加到特定变量声明的易失性修饰符,从而强制公开其更改。 空的 synchronized 块的不同之处在于,它不应用于任何特定变量,而是应用于所有变量。 在大量变量发生需要公开的更改的情况下,空的同步块可能是更好的解决方案。

此外,易失性修饰符强制公开对变量的每个单独写入,从而在所有线程中公开每个写入。 空的同步块在暴露时间(仅当块执行时)和范围(仅对于在同一监视器上同步的线程)方面都不同。 在时间和范围范围更窄的情况下可能会带来显着的成本效益,因此空的同步块可能是更好的解决方案。

4.重新审视正确性

并发编程是困难的。 因此,空的同步块可能“微妙且难以正确使用”也就不足为奇了。 下面的示例显示了一种滥用它们的方法(由 Holger 提到)。

import static java.lang.System.exit;

class Example { // Incorrect, might exit with a value of 0 instead of 1

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                exitValue = 1;
                toExit = true;
                synchronized( o ) {} }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {}
                if( toExit ) exit( exitValue ); }})
            .start(); }

    static final Object o = new Object();

    int exitValue;

    boolean toExit; }

线程 B 的语句“if( toExit ) exit( exitValue )”假设两个变量之间存在同步,但代码并不保证这一点。 假设 B 碰巧在 A 写入 toExitexitValue 之后,但在后续执行两个 synchronized 语句之前(A 然后 B 的)。 那么 B 看到的可能是第一个变量的更新值 (true) 以及第二个变量的 un 更新值 (零),导致它退出错误的值。

纠正代码的一种方法是通过最终字段的调解。

import static java.lang.System.exit;

class Example { // Corrected

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) if( !state.toExit() ) {
                state = new State( /*toExit*/true, /*exitValue*/1 );
                synchronized( o ) {} }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {}
                State state = this.state; /* Local cache.  It might seem
                  unnecessary when `state` is known to change once only,
                  but see § Subtleties in the text. */
                if( state.toExit ) exit( state.exitValue ); }})
            .start(); }

    static final Object o = new Object();

    static record State( boolean toExit, int exitValue ) {}

    State state = new State( /*toExit*/false, /*exitValue*/0 ); }

修改后的代码是正确的,因为 Java 内存模型保证,当 B 读取 A 写入的 state 新值时,它将看到最终字段 toExit 完全初始化的值> 和 exitValue,两者在 State 的声明中都是隐式最终的。 “只有在对象完全初始化后才能看到对该对象的引用的线程保证看到该对象的最终字段的正确初始化值。” (§17.5)

对于这项技术的通用性至关重要(尽管与本示例无关),规范继续说道:“它还将看到由那些最终字段引用的任何对象或数组的版本,这些版本至少是最新的正如最后的字段一样。 因此,同步的保证深入到数据结构中。

当已知 state 仅更改一次时,线程 B 对 state 变量的本地缓存(上面的示例)可能似乎没有必要 虽然它有其原始值,但语句“if( state.toExit ) exit( state.exitValue )”将短路并仅读取一次; 否则它将有其最终值并且保证在两次读取之间不会改变。 但正如霍尔格指出的那样,并没有这样的保证。

考虑一下如果我们不考虑缓存会发生什么。

new Thread( () -> { // B
    for( ;; ) {
        synchronized( o ) {}
     // State state = this.state;
    //// not required when it’s known to change once only
        if( state.toExit ) exit( state.exitValue ); }})
    .start(); }

“只要程序的所有结果执行产生可以由内存模型预测的结果,实现就可以自由地生成它喜欢的任何代码。 这为实现者提供了很大的自由来执行无数的代码转换,包括重新排序操作和删除不必要的同步。 (§17.4)

因此,看到“if( state.toExit ) exit( state.exitValue )”位于同步块之外,并且 state 是一个非易失性变量,以下转换将是有效的。

new Thread( () -> { // B
    for( ;; ) {
        synchronized( o ) {}
     // State state = this.state;
    //// not required when it’s known to change once only
        State s = state;
        if( state.toExit ) exit( s.exitValue ); }})
    .start(); }

这实际上可能就是代码的执行方式。 然后,第一次读取 state(进入 s)可能会产生其原始值,而下一次读取会产生其最终值,导致程序意外退出,值为 0而不是 1。

How an empty synchronized block can ‘achieve correct threading’ I explain in sections one and two. How it can be a ‘better solution’ I explain in section three. How it can nevertheless be ‘subtle and hard to use correctly’ I show by example in the fourth and final section.

1. Correct threading

In what situations might an empty synchronized block enable correct threading?

Consider an example.

import static java.lang.System.exit;

class Example { // Incorrect, might never exit

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                toExit = true; }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                if( toExit ) exit( 0 ); }})
            .start(); }

    boolean toExit; }

The code above is incorrect. The runtime might isolate thread A’s change to the boolean variable toExit, effectively hiding it from B, which would then loop forever.

It can be corrected by introducing empty synchronized blocks, as follows.

import static java.lang.System.exit;

class Example { // Corrected

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                toExit = true;
                synchronized( o ) {} }}) // Force exposure of the change
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {} // Seek exposed changes
                if( toExit ) exit( 0 ); }})
            .start(); }

    static final Object o = new Object();

    boolean toExit; }

2. Basis for correctness

How do the empty synchronized blocks make the code correct?

The Java memory model guarantees that an ‘unlock action on monitor m synchronizes-with all subsequent lock actions on m’ and thereby happens-before those actions (§17.4.4). So the unlock of monitor o at the tail of A’s synchronized block happens-before its eventual lock at the head of B’s synchronized block. And because A’s write to the variable precedes its unlock and B’s lock precedes its read, the guarantee extends to the write and read operations: write happens-before read.

Now, ‘[if] one action happens-before another, then the first is visible to and ordered before the second’ (§17.4.5). It is this visibility guarantee that makes the code correct in terms of the memory model.

3. Comparative utility

How might an empty synchronized block be a better solution than the alternatives?

Versus a non-empty `synchronized` block

One alternative is a non-empty synchronized block. A non-empty synchronized block does two things: a) it provides the ordering and visibility guarantee described in the previous section, effectively forcing the exposure of memory changes across all threads that synchronize on the same monitor; and b) it makes the code within the block effectively atomic among those threads; the execution of that code will not be interleaved with the execution of other block-synchronized code.

An empty synchronized block does only (a) above. In situations where (a) alone is required and (b) could have significant costs, the empty synchronized block might be a better solution.

Versus a `volatile` modifier

Another alternative is a volatile modifier attached to the declaration of a particular variable, thereby forcing exposure of its changes. An empty synchronized block differs in applying not to any particular variable, but to all of them. In situations where a wide range of variables have changes that need exposing, the empty synchronized block might be a better solution.

Moreover a volatile modifier forces exposure of each separate write to the variable, exposing each across all threads. An empty synchronized block differs both in the timing of the exposure (only when the block executes) and in its extent (only to threads that synchronize on the same monitor). In situations where a narrower focus of timing and extent could have significant cost benefits, the empty synchronized block might be a better solution for that reason.

4. Correctness revisited

Concurrent programming is difficult. So it should come as no surprise that empty synchronized blocks can be ‘subtle and hard to use correctly’. One way to misuse them (mentioned by Holger) is shown in the example below.

import static java.lang.System.exit;

class Example { // Incorrect, might exit with a value of 0 instead of 1

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) {
                exitValue = 1;
                toExit = true;
                synchronized( o ) {} }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {}
                if( toExit ) exit( exitValue ); }})
            .start(); }

    static final Object o = new Object();

    int exitValue;

    boolean toExit; }

Thread B’s statement “if( toExit ) exit( exitValue )” assumes a synchrony between the two variables that the code does not warrant. Suppose B happens to read toExit and exitValue after they’re written by A, yet before the subsequent execution of both synchronized statements (A’s then B’s). Then what B sees may be the updated value of the first variable (true) together with the un-updated value of the second (zero), causing it to exit with the wrong value.

One way to correct the code is through the mediation of final fields.

import static java.lang.System.exit;

class Example { // Corrected

    public static void main( String[] _args ) { new Example().enter(); }

    void enter() {
        new Thread( () -> { // A
            for( ;; ) if( !state.toExit() ) {
                state = new State( /*toExit*/true, /*exitValue*/1 );
                synchronized( o ) {} }})
            .start();
        new Thread( () -> { // B
            for( ;; ) {
                synchronized( o ) {}
                State state = this.state; /* Local cache.  It might seem
                  unnecessary when `state` is known to change once only,
                  but see § Subtleties in the text. */
                if( state.toExit ) exit( state.exitValue ); }})
            .start(); }

    static final Object o = new Object();

    static record State( boolean toExit, int exitValue ) {}

    State state = new State( /*toExit*/false, /*exitValue*/0 ); }

The revised code is correct because the Java memory model guarantees that, when B reads the new value of state written by A, it will see the fully initialized values of the final fields toExit and exitValue, both being implicitly final in the declaration of State. ‘A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object's final fields.’ (§17.5)

Crucial to the general utility of this technique (though irrelevant in the present example), the specification goes on to say: ‘It will also see versions of any object or array referenced by those final fields that are at least as up-to-date as the final fields are.’ So the guarantee of synchrony extends deeply into data structures.

Subtleties

Local caching of the state variable by thread B (example above) might seem unnecessary when state is known to change once only. While it has its original value, the statement “if( state.toExit ) exit( state.exitValue )” will short circuit and read it once only; otherwise it will have its final value and be guaranteed not to change between the two reads. But as Holger points out, there is no such guarantee.

Consider what could happen if we leave the caching out.

new Thread( () -> { // B
    for( ;; ) {
        synchronized( o ) {}
     // State state = this.state;
    //// not required when it’s known to change once only
        if( state.toExit ) exit( state.exitValue ); }})
    .start(); }

‘An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model. This provides a great deal of freedom for the implementor to perform a myriad of code transformations, including the reordering of actions and removal of unnecessary synchronization.’ (§17.4)

Seeing therefore that “if( state.toExit ) exit( state.exitValue )” lies outside of the synchronized block, and that state is a non-volatile variable, the following transformation would be valid.

new Thread( () -> { // B
    for( ;; ) {
        synchronized( o ) {}
     // State state = this.state;
    //// not required when it’s known to change once only
        State s = state;
        if( state.toExit ) exit( s.exitValue ); }})
    .start(); }

This might actually be how the code executes. Then the first read of state (into s) might yield its original value, while the next read yields its final value, causing the program to exit unexpectedly with a value of 0 instead of 1.

寄人书 2024-07-22 06:57:37

过去的情况是,规范暗示发生了某些内存屏障操作。 然而,规范现在已经改变,原始规范从未正确实施。 它可用于等待另一个线程释放锁,但协调另一个线程已经获取锁会很棘手。

It used to be the case that the specification implied certain memory barrier operations occurred. However, the spec has now changed and the original spec was never implemented correctly. It may be used to wait for another thread to release the lock, but coordinating that the other thread has already acquired the lock would be tricky.

孤芳又自赏 2024-07-22 06:57:37

同步的作用不仅仅是等待,虽然不优雅的编码也可以达到所需的效果。

来自 http://www.javaperformancetuning.com/news/qotm030.shtml

  1. 线程获取对象 this 的监视器上的锁(假设监视器已解锁,否则线程将等待直到监视器解锁)。
  2. 线程内存刷新其所有变量,即它的所有变量都从“主”内存中有效读取(JVM 可以使用脏集来优化它,以便仅刷新“脏”变量,但从概念上讲,这是相同的. 请参阅 Java 语言规范第 17.9 节)。
  3. 执行代码块(在本例中将返回值设置为 i3 的当前值,该值可能刚刚从“主”内存重置)。
  4. (对变量的任何更改现在通常都会写入“主”内存,但对于 geti3() 我们没有任何更改。)
  5. 线程释放对象 this 监视器上的锁。

Synchronizing does a little bit more than just waiting, while inelegant coding this could achieve the effect required.

From http://www.javaperformancetuning.com/news/qotm030.shtml

  1. The thread acquires the lock on the monitor for object this (assuming the monitor is unlocked, otherwise the thread waits until the monitor is unlocked).
  2. The thread memory flushes all its variables, i.e. it has all of its variables effectively read from "main" memory (JVMs can use dirty sets to optimize this so that only "dirty" variables are flushed, but conceptually this is the same. See section 17.9 of the Java language specification).
  3. The code block is executed (in this case setting the return value to the current value of i3, which may have just been reset from "main" memory).
  4. (Any changes to variables would normally now be written out to "main" memory, but for geti3() we have no changes.)
  5. The thread releases the lock on the monitor for object this.
柠栀 2024-07-22 06:57:37

要深入了解 Java 的内存模型,请观看 Google 的“编程语言高级主题”系列中的视频:
http://www.youtube.com/watch?v=1FX4zco0ziY

它给出了关于编译器可以(通常在理论上,但有时在实践中)对代码执行的操作的非常好的概述。 对于任何认真的 Java 程序员来说都是必备的东西!

For an in depth look into Java's memory model, have a look at this video from Google's 'Advanced topics in programming languages' series:
http://www.youtube.com/watch?v=1FX4zco0ziY

It gives a really nice overview of what the compiler can (often in theory, but sometimes in practice) do to your code. Essential stuff for any serious Java programmer!

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文