【问题标题】:visibility of immutable object after publication发布后不可变对象的可见性
【发布时间】:2011-10-12 00:45:17
【问题描述】:

我有一个不可变对象,它封装在类中,是全局状态。

假设我有 2 个线程获得这个状态,用它执行 myMethod(state)。让我们说thread1首先完成。它修改全局状态调用 GlobalStateCache.updateState(state, newArgs);

GlobalStateCache {
   MyImmutableState state = MyImmutableState.newInstance(null, null);

   public void updateState(State currentState, Args newArgs){
      state = MyImmutableState.newInstance(currentState, newArgs);
   }
}

所以thread1会更新缓存状态,然后thread2做同样的事情,它会覆盖状态(不考虑从thread1更新的状态)

我在实践中搜索了 google、java 规范并阅读了 java 并发,但这显然没有指定。 我的主要问题是不可变状态对象值对已经读取不可变状态的线程是否可见。我认为它不会看到更改的状态,只有在更新后读取才会看到。

所以我不明白什么时候使用不可变对象?这是否取决于我在处理我看到的最新状态并且不需要更新状态期间是否可以同时修改?

【问题讨论】:

  • MyImmutableState state 必须是易变的。

标签: java multithreading immutability


【解决方案1】:

如果我理解您的问题,不变性似乎与此处无关。您只是在询问线程是否会看到对共享对象的更新。

[Edit] 在交换 cmets 之后,我现在看到您还需要在执行某些操作时保留对共享单例状态的引用,然后设置状态以反映该操作。

和以前一样,好消息是提供此意志也必然会解决您的内存一致性问题。

您不必定义单独的同步 getStateupdateState 方法,而是必须在不被中断的情况下执行所有三个操作:getStateyourActionupdateState

我可以看到三种方法:

1) 在 GlobalStateCache 中的单个同步方法中执行所有三个步骤。 在 GlobalStateCache 中定义一个原子 doActionAndUpdateState 方法,当然在您的 state 单例上同步,这将采用函子对象做你的动作。

2) getStateupdateState 作为单独的调用,并更改 updateState 以确保状态自获取后未更改。 定义 getState 和 @987654332 @ 在 GlobalStateCache 中。 checkAndUpdateState 将采用从 getState 获得的原始状态调用者,并且必须能够检查自您获得后状态是否已更改。如果它已经发生了变化,您需要做一些事情让调用者知道他们可能需要恢复他们的操作(取决于您的用例)。

3) 在 GlobalStateCache 中定义一个getStateWithLock 方法。这意味着您还需要确保调用者释放他们的锁。我会创建一个显式的releaseStateLock 方法,并让您的updateState 方法调用它。

其中,我建议不要使用第 3 条,因为如果出现某些类型的错误,它会让您很容易锁定该状态。我也建议(虽然不太强烈)反对#2,因为它会在状态发生变化的情况下产生复杂性:你只是放弃行动吗?你重试吗?它必须(可以)恢复吗?我支持 #1:单一同步原子方法,它看起来像这样:

public interface DimitarActionFunctor {
  public void performAction();
}
GlobalStateCache {    
  private MyImmutableState state = MyImmutableState.newInstance(null, null);     
    public MyImmutableState getState {     
      synchronized(state) {
        return state;    
      }
    }     
    public void doActionAndUpdateState(DimitarActionFunctor functor, State currentState, Args newArgs){       
      synchronized(state) {
         functor.performAction();
         state = MyImmutableState.newInstance(currentState, newArgs);    
      }
    } 
  } 
} 

Caller 然后为动作构造一个函子(DimitarActionFunctor 的一个实例),并调用 doActionAndUpdateState。当然,如果动作需要数据,您必须定义函子接口以将该数据作为参数。

我再次指出这个问题,不是为了实际的差异,而是为了它们在内存一致性方面的工作方式:Difference between volatile and synchronized in Java

【讨论】:

  • 感谢您的回答,我知道有什么区别。我知道确保不一致状态的最佳方法是同步或原子引用。但问题是不同的。书中说“不可变对象可以被任何线程安全地使用而无需额外的同步,即使不使用同步来发布它们。”
  • 不可变对象总是安全发布的。我想知道如果线程修改状态,在另一个线程开始使用该状态之后会发生什么。
  • 如果同步所有可以改变状态的方法,就不会出现两个线程同时尝试修改状态的问题。如果您的用例读起来像“获取当前状态,执行操作,修改它”,那么线程将必须获取状态,执行操作,然后执行原子获取检查和设置,您必须编写.
  • 或者您可以编写一个同步的 check-doaction-andset 方法,该方法将执行该操作的函子作为参数。
  • 我认为在读取状态时将其封装在线程安全包装器中并执行操作。对它的访问将被同步
【解决方案2】:

很大程度上取决于此处的实际用例,因此很难提出建议,但看起来您需要使用 java.util.concurrent.atomic.AtomicReference 为 GlobalStateCache 提供某种比较和设置语义.

public class GlobalStateCache {
    AtomicReference<MyImmutableState> atomic = new AtomicReference<MyImmutableState>(MyImmutableState.newInstance(null, null);

    public State getState()
    {
        return atomic.get();
    }

    public void updateState( State currentState, Args newArgs )
    {
        State s = currentState;
        while ( !atomic.compareAndSet( s, MyImmutableState.newInstance( s, newArgs ) ) )
        {
            s = atomic.get();
        }
    }
}

当然,这取决于可能创建一些额外的 MyImmutableState 对象的成本,以及如果状态已在下面更新,是否需要重新运行 myMethod(state),但这个概念应该是正确的。

【讨论】:

  • 我知道这种方法,它看起来很像“乐观锁定”,您乐观地认为状态现在会改变,如果是,您重新尝试操作。
  • 循环完全没用,如果你没有有意义的谓词/if条件CAS没有意义
【解决方案3】:

我认为关键是要区分对象和引用。

不可变对象可以安全地发布,因此任何线程都可以发布对象,并且如果任何其他线程读取对此类对象的引用 - 它可以安全地使用该对象。当然,reader 线程会看到在线程读取引用时发布的不可变对象状态,它不会看到任何更新,直到再次读取引用。

它在许多情况下都非常有用。例如。如果只有一个发布者,并且有很多读者——并且读者需要看到一致的状态。读者定期阅读参考,并处理获得的状态 - 它保证是一致的,并且不需要对读者线程进行任何锁定。此外,当可以放松一些更新时,例如你不在乎哪个线程更新状态。

【讨论】:

    【解决方案4】:

    回答你“主要”问题:没有 Thread2 不会看到变化。不可变对象不会改变:-)

    因此,如果 Thread1 读取状态 A,然后 Thread2 存储状态 B,则 Thread1 应再次读取变量以查看更改。

    变量的可见性受volatile 关键字的影响。如果变量声明为volatile,那么Java 保证如果一个线程更新变量,所有其他线程将立即看到更改(以速度为代价)。

    仍然不可变对象在多线程环境中非常有用。我会给你一个例子,我曾经如何使用它。假设您有一个由一个线程定期更改的对象(在我的情况下为life 字段),并且它以某种方式由其他线程处理(我的程序通过网络将其发送给客户端)。如果对象在处理过程中更改(它们发送不一致的生命字段状态),这些线程将失败。如果你使这个对象不可变并且每次它改变时都会创建一个新实例,你根本不需要编写任何同步。更新线程将定期发布对象的新版本,每次其他线程读取它时,它们都会拥有它的最新版本并且可以安全地处理它。这个特定示例节省了同步时间,但浪费了更多内存。这应该可以让您大致了解何时可以使用它们。

    我找到的另一个链接:http://download.oracle.com/javase/tutorial/essential/concurrency/immutable.html

    编辑(回答评论):

    我将解释我的任务。我必须编写一个网络服务器,它将向客户发送最新的生活领域并不断更新它。通过上面提到的设计,我有两种类型的线程:

    1. 更新表示生命字段的对象的线程。它是不可变的,因此实际上它每次都会创建一个新实例,并且只会更改引用。将引用声明为 volatile 这一事实至关重要。
    2. 每个客户端都有自己的线程。当客户端请求生命字段时,它会读取引用一次并开始发送。因为网络连接可能很慢,所以当这个线程发送数据时,生命字段可以被多次更新。对象是不可变的这一事实保证了服务器将发送一致的生命状态字段。在 cmets 中,您关心在此线程处理对象时所做的更改。是的,当客户收到数据时,它可能不是最新的,但您无能为力。这不是同步问题,而是连接速度慢的问题。

    不是说不可变对象可以解决你所有的并发问题。这显然不是真的,你指出了这一点。我试图向您解释它实际上可以解决问题的地方。我希望我的例子现在很清楚了。

    【讨论】:

    • volataile 是轻量级同步参考,java 规范不保证发布后的可见性,只有线程会获取最新值。在您的情况下,如果 2 个不可变对象 r 被发送但较新的对象首先获得然后第二个对象覆盖它会发生什么
    • 其实即使状态是同步的,如果你发布它也不会起作用
    • @dimitar 我知道你不相信我,这里是proof。特别是:一个字段可能被声明为 volatile,在这种情况下,Java 内存模型(第 17 节)确保所有线程都能看到变量的一致值。并且:对 volatile 字段(第 8.3.1.4 节)的写入发生在对该字段的每次后续读取之前。
    • thread1 读取 volatile A a thread1 doAction(a); -> 这使得复制引用,在 doAction 方法中,引用被限制在方法中,因此被限制在 thread1 中 thread2 在 thread1 doAction 方法的执行过程中更新 volatile A a
    • 案例1:thread1看到a的修改值(虽然如果A是不可变的,这是不可能的,因为更新到a意味着它指向新的内存对象)如果它看到新的值,这意味着首先doAction 的一半以旧状态执行,后半部分以新状态=> 不一致执行。 Case2:Thread1 看不到a 的新值,因为修改发生在a 发布之后。 Thread1 使用旧值。(如果 A a 是不可变的,并且在 thread1 完成后必须更新 a 的状态,那么它将覆盖 thread2 的更改,并且会丢失更新。
    【解决方案5】:

    Publication 似乎是一个有点棘手的概念,java concurrency in practice 中解释的方式对我来说效果不佳(与许多其他多线程概念相反在此wonderful book 中进行了解释。

    考虑到上述情况,让我们首先弄清楚您问题的一些简单部分。

    • 当你声明 让我们说 thread1 先完成 - 你怎么知道?或者,更准确地说,thread2 如何“知道”这一点?据我所知,这只能通过某种同步来实现,显式或不那么显式,如线程 join (参见 JLS - 17.4.5 Happens-before Order)。您到目前为止提供的代码没有提供足够的细节来判断是否是这种情况

    • 当您声明 thread1 将更新缓存状态时 - thread2 如何“知道”这一点?使用您提供的代码,thread2 完全有可能(但不能保证请注意)永远不会知道此更新

    • 当您声明 thread2... 时将覆盖该状态override 在这里是什么意思? GlobalStateCache 代码示例中没有任何内容可以以某种方式保证thread1 会注意到这个覆盖。更重要的是,提供的代码表明没有任何东西会以某种方式强加happen-before relation 来自不同线程的更新,因此人们甚至可以推测 override 可能会以相反的方式发生,你看到了吗?

      李>
    • 最后但并非最不重要的一点,不可变状态对我来说听起来相当模糊。考虑到这个棘手的主题,我会说危险的模糊。 state 字段是可变的,可以通过调用 updateState 方法来更改它,对吧?从您的代码中,我宁愿得出结论,MyImmutableState 类的实例被假定为不可变的 - 至少名称告诉我的是这样。

    综上所述,到目前为止,您提供的代码保证可见是什么?我不害怕……但也许总比没有好。我的看法是……

    对于 thread1,保证在调用 updateState 之前,它会看到从 thread2 更新的 null 或正确构造的(valid)对象.更新后,可以保证看到从 thread1 或 thread2 更新的正确构造(valid)对象中的任何一个。请注意,在此更新之后,线程 1 保证不会看到 null 根据我上面提到的 JLS 17.4.5("...x 和 y 是同一个线程的操作,x 在之前y 按程序顺序...")

    对于thread2,保证与上面的非常相似。

    基本上,您提供的代码可以保证两个线程都将看到 nullMyImmutableState 的正确构造(valid)实例之一 类。

    上面的保证乍一看似乎微不足道,但如果您浏览上面的一页,上面的引用让您感到困惑(“不可变对象可以安全地使用等...”),您会发现一个更值得深入的例子在 3.5.1 中钻取。不当发布:当好对象变坏时.

    是的,单独不可变的对象并不能保证其可见性,但它至少会保证该对象不会“从内部爆炸”,就像 3.5.1 中提供的示例一样:

    public class Holder {
      private int n;
    
      public Holder(int n) { this.n = n; }
    
      public void assertSanity() {
        if (n != n)
          throw new AssertionError("This statement is false.");
      }
    }
    

    上述代码的 Goetz cmets 从解释可变和不可变对象的问题开始,

    ...我们说持有人未正确发布。不正确发布的对象可能会出现两件事。其他线程可能会看到 holder 字段的陈旧值,因此即使在 holder 中放置了一个值,也会看到空引用或其他较旧的值...

    ...然后他深入研究如果对象是可变的会发生什么,

    ...但更糟糕的是,其他线程可能会看到持有者引用的最新值,但持有者状态的值过时。为了使事情更难以预测,线程可能会在第一次读取字段时看到一个陈旧的值,然后在下一次读取一个更新的值,这就是为什么 assertSanity 可以抛出 AssertionError.

    “AssertionHorror”以上可能听起来违反直觉,但如果您考虑如下场景(根据 Java 5 内存模型完全合法 - 顺便说一句,这是有充分理由的),那么所有的魔法都会消失:

    1. thread1 调用 sharedHolderReference = Holder(42);

    2. thread1 首先用默认值 (0) 填充 n 字段,然后将在构造函数中分配它,但是...

    3. ...但是调度程序切换到线程2,

    4. sharedHolderReference 来自 thread1 的 thread2 变得可见,因为,说因为为什么不呢?也许优化热点编译器决定现在是这样做的好时机

    5. thread2 读取最新的 sharedHolderReference 字段值仍为 0 btw

    6. thread2 调用 sharedHolderReference.assertSanity()

    7. thread2 读取 assertSanityif 语句的左侧值,即 0,然后它将读取右侧值但是.. .

    8. ...但是调度程序切换回线程1,

    9. thread1 通过将 n 字段值设置为 42

    10. 来完成上面第 2 步中暂停的构造函数分配
    11. thread1 的字段 n 中的值 42 对 thread2 可见,因为,说因为为什么不呢?也许优化热点编译器决定现在是这样做的好时机

    12. 然后,稍后,调度程序切换回线程2

    13. thread2 从上面第 6 步暂停的地方开始,即它读取 if 语句的右侧,现在是 42

      李>
    14. 哎呀,我们无辜的 if (n != n) 突然变成了 if (0 != 42)...

      李>
    15. ...自然抛出AssertionError

    据我了解,不可变对象的初始化安全只是保证不会发生上述情况 - 不多......也不少

    【讨论】:

    • 我不明白为什么 step4 会发生。为什么在对象的构造函数返回之前将引用分配给对象?
    • @AdrianLiu 因为它不被禁止。如果代码不同步,这是允许的。如果没有理由等待,为什么要等待
    • 我认为对于单线程程序,这种行为不能更快;对于多线程程序,编译器的这种行为会产生部分初始化的问题。我错了吗?
    • publication 在单线程上下文中几乎没有意义,至于多线程,您的理解对我来说是正确的@AdrianLiu
    猜你喜欢
    • 2012-03-11
    • 2021-02-27
    • 2010-12-26
    • 1970-01-01
    • 2016-03-29
    • 2016-12-23
    • 1970-01-01
    • 1970-01-01
    • 2019-02-23
    相关资源
    最近更新 更多