安全发布和不可变性的优势 vs. 有效不可变性

26

我正在重新阅读《Java并发编程实战》,但我不确定自己是否完全理解了与不可变性和安全发布相关的章节。

书中所说的是:

不可变对象可以在没有额外同步的情况下被任何线程安全地使用,即使在发布时没有使用同步。

我不明白的是,为什么会有人(希望让自己的代码正确无误)会不安全地发布某个引用?

如果该对象是不可变的,并且它被不安全地发布了,那么我理解任何其他获得该对象引用的线程都会看到其正确的状态,因为通过适当的不可变性(使用final字段等)提供了保证。

但是,如果发布是不安全的,那么另一个线程可能仍然会在发布后看到null或以前的引用,而不是对不可变对象的引用,这似乎是没有人希望看到的。

如果使用安全发布确保所有线程都能看到新引用,那么即使对象只是有效不可变(没有final字段,但没有方法可以修改它们),一切也将再次变得安全。 正如书中所说:

安全地发布有效不可变对象可以在没有额外同步的情况下被任何线程安全地使用。

那么,为什么不可变性(与有效不可变性相比)非常重要? 在什么情况下会希望进行不安全发布?


2
我怀疑他们不会“故意”这样做。他们可能会无意中这样做,或者他们可能认为风险微乎其微而不在意。 - Dave Newton
3
我很难理解你的问题。“为什么会有人(想要让自己的代码正确)不安全地发布一些参考资料?”—— 没有人认为这是可取的,只是对于不可变对象来说,同步发布不是必需的 - aioobe
4
如果你希望对象的引用在所有其他线程中都可见,那么仍然需要这样做,不是吗?如果不希望出现不安全发布,那么总是需要进行安全发布,因此适当的不可变性就不再必要了。这就是我的问题所在。 - JB Nizet
2
不安全的发布意味着读取器可能无法正确初始化所有字段,那些字段不能从寄存器中获取,因为它们从一开始就没有被加载。在弱内存模型下,引用可能会在字段之前变得可见 - 这是不安全的发布。我的观点是,在您的测试用例中,无限循环发生是因为JIT只生成了一个加载,而不是该值被不安全地发布(由于该字段是最终的,这永远不会发生) - 这是两个不同的问题。 - bestsss
2
@bestsss:谢谢!你让我以新的眼光重新阅读了这一章节,我终于明白了:安全发布并不是关于使对象引用可见,而是确保如果它可见,那么它的状态也是可见的。因此,我的例子并不是一个不安全发布的例子,而是一个(潜在地)根本没有发布的例子,因为该字段不是易失性的。但是,如果内存稍后被刷新,线程最终将看到引用和对象的状态,因为它是不可变的。我理解了所有的机制,但术语还不太清楚。 - JB Nizet
显示剩余14条评论
4个回答

9
设计不需要同步的对象是可取的,原因如下:
1. 对象的用户可能会忘记同步。
2. 即使开销很小,同步也不是免费的,特别是如果您的对象不经常被许多不同的线程使用。
由于上述原因非常重要,最好学习有时候很难的规则并作为编写者来制作安全对象,而不是希望代码的所有用户都能正确使用它。还要记住作者并没有说对象未经安全发布,它是在没有同步的情况下安全发布的。
至于您的第二个问题,我刚刚检查过,这本书没有承诺另一个线程总是会看到对更新对象的引用,只是如果它确实看到了,它将看到一个完整的对象。但是我可以想象,如果它是通过另一个('Runnable'?)对象的构造函数发布的,那将是很好的。虽然这并不能解释所有情况。
编辑:
有效不可变和不可变之间的区别在于,在前一种情况下,您仍然需要以安全的方式发布对象。对于真正的不可变对象,这是不必要的。因此,真正的不可变对象更受欢迎,因为它们更容易按照上述原因进行发布。

我同意这个观点。但问题是:为什么“正确”的不可变性(使用final字段)比“有效”的不可变性(没有final字段,但无法被修改)更可取?实际上,安全发布“有效”不可变对象已经足够了。对我来说这有点像多此一举。不是说这不是好事,而是我想确保自己没有漏掉什么。 - JB Nizet
我更新了答案以帮助解决这个问题。当然,在许多情况下,两者都可以达到相同的效果。我认为你提到的许多影响是由于做出的选择而不是本身的选择。因此,给你两个选项并不一定是有意设计的。 - Thirler
谢谢你的回答。事实上,我认为我们在所有方面都达成了一致。我只是无法想象出一个可以接受非同步发布的用例。我们什么时候会选择这样的发布方式?是否有一些用例不会导致错误? - JB Nizet
1
好的一点是,当您替换旧值并且不介意某些对象仍然查看新值时,它至少是可用的。另一个情况是在其他不可变对象或线程构造之前使用对象时。我认为通常这些不安全的发布将依赖于另一个效果来确保其他线程看到它。 - Thirler

5
那么,为什么不可变性(vs. 有效的不可变性)如此重要呢?
我认为主要原因在于,真正的不可变对象更难在以后被破坏。如果你声明了一个字段为 final,那么它就是最终的,没有别的。你必须删除 final 才能更改该字段,这应该引起警觉。但是,如果一开始你没有把 final 加上,有人可以粗心大意地添加一些代码来更改该字段,然后就会出现问题——只是添加了一些(可能在子类中的)代码,而没有修改任何现有代码。
我还认为,显式的不可变性使得(JIT)编译器可以进行一些优化,否则这些优化将很难或不可能被证明是合理的。例如,当使用 volatile 字段时,运行时必须保证写入和读取线程之间存在 happens-before 关系。实际上,这可能需要内存屏障、禁用乱序执行优化等 - 也就是说,会带来性能损失。但是,如果对象是(深度)不可变的(仅包含对其他不可变对象的 final 引用),则可以放松要求而不会破坏任何东西:只需要对写入和读取单个 引用 保证 happens-before 关系,而不是整个对象图。
因此,显式的不可变性使程序更加简单,从而更易于人类推理和维护,并且更易于计算机执行优化。这些好处随着对象图的增长呈指数级增长,也就是说,对象包含对象,包含对象——如果所有东西都是不可变的,那么一切都很简单。当需要可变性时,将其局限于严格定义的位置,并保持其他所有内容不变,仍然会带来许多好处。

1
我认为这实际上就是答案。向易变字段发布是一种安全的发布方式,因为它与其他线程建立了 happens-before 关系。 - JB Nizet

2
“不安全的发布”通常适用于这样一种情况:在其他线程看到写入到字段的最新值是可取的,但是线程看到一个早期值相对无害。一个典型的例子是缓存的 String 哈希值。第一次调用一个 String 上的 hashCode(),它将计算一个值并将其缓存。如果另一个调用同一字符串上的 hashCode() 的线程可以看到第一个线程计算出的值,则它不必重新计算哈希值(因此节省时间),但是如果第二个线程没有看到哈希值,则不会发生任何问题。它只会执行一个多余但无害的计算,本可以避免。使 hashCode() 安全地发布哈希值是可能的,但是偶尔的重复哈希计算比保证安全发布所需的同步成本要便宜得多。实际上,除了相当长的字符串外,同步成本很可能会抵消缓存的任何好处。
不幸的是,我认为 Java 的创建者们没有想到代码将写入字段并且希望其他线程可以看到它,但如果不能看到也没关系,并且存储在字段中的引用将进一步标识具有类似字段的另一个对象的情况。这导致编写语义正确的代码比编写可能有效但其语义不会得到保证的代码更加繁琐和可能更慢。在某些情况下,我不知道任何真正好的解决方法,除了使用一些毫无意义的 final 字段来确保正确“发布”事物。

2

当我读完第1-3章时,我和原帖作者有同样的问题。我认为作者可以更好地阐述这个问题。

我认为区别在于,在未安全发布时,可以观察到有效不可变对象的内部状态处于不一致状态,而永久不可变对象的内部状态永远不会出现不一致状态。

然而,如果引用未得到安全发布,我认为可以观察到不可变对象的引用已经过期/陈旧。


我对引用的安全发布一无所知。我认为关键点在于引用本身是可变的,一个线程可能持有一个过时的不可变对象的引用,在许多情况下这可能是可以接受的。 - Adrian Liu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接