16

这是交易。我有一个包含我称之为“程序代码”的数据的哈希映射,它存在于一个对象中,如下所示:

Class Metadata
{
    private HashMap validProgramCodes;
    public HashMap getValidProgramCodes() { return validProgramCodes; }
    public void setValidProgramCodes(HashMap h) { validProgramCodes = h; }
}

我有很多很多阅读器线程,每个阅读器线程都会调用一次 getValidProgramCodes() ,然后将该哈希图用作只读资源。

到目前为止,一切都很好。这就是我们变得有趣的地方。

我想放入一个计时器,它每隔一段时间会生成一个新的有效程序代码列表(不管如何),并调用 setValidProgramCodes。

我的理论——我需要帮助来验证——是我可以继续按原样使用代码,而无需进行显式同步。它是这样的:在更新 validProgramCodes 时,validProgramCodes 的值总是好的——它是指向新哈希图或旧哈希图的指针。 这是一切都取决于的假设。 拥有旧 hashmap 的读者是可以的;他可以继续使用旧值,因为在他释放它之前不会被垃圾收集。每个读者都是短暂的;它很快就会死去,并被一个新的取而代之的人取代,后者将获得新的价值。

这个有水吗?我的主要目标是在绝大多数没有更新发生的情况下避免代价高昂的同步和阻塞。我们每小时只更新一次左右,读者不断地进进出出。

4

10 回答 10

28

使用易失性

这是一个线程关心另一个线程在做什么的情况吗?那么JMM FAQ有答案:

大多数时候,一个线程并不关心另一个线程在做什么。但是当它发生时,这就是同步的目的。

作为对那些说 OP 的代码是安全的人的回应,请考虑一下:Java 的内存模型中没有任何内容可以保证在启动新线程时该字段将被刷新到主内存。此外,只要在线程中无法检测到更改,JVM 就可以自由地重新排序操作。

从理论上讲,不能保证读取器线程看到“写入”到 validProgramCodes。在实践中,他们最终会,但你不能确定什么时候。

我建议将 validProgramCodes 成员声明为“volatile”。速度差异可以忽略不计,并且无论现在和将来可能引入何种 JVM 优化,都可以保证代码的安全性。

这是一个具体的建议:

import java.util.Collections;

class Metadata {

    private volatile Map validProgramCodes = Collections.emptyMap();

    public Map getValidProgramCodes() { 
      return validProgramCodes; 
    }

    public void setValidProgramCodes(Map h) { 
      if (h == null)
        throw new NullPointerException("validProgramCodes == null");
      validProgramCodes = Collections.unmodifiableMap(new HashMap(h));
    }

}

不变性

除了用 包裹它之外unmodifiableMap,我还复制了地图 ( new HashMap(h))。即使 setter 的调用者继续更新映射“h”,这也会生成一个不会更改的快照。例如,他们可能会清除地图并添加新条目。

依赖接口

List从风格上讲,通常最好使用像and这样的抽象类型来声明 API Map,而不是像ArrayListandHashMap.这样的具体类型。如果具体类型需要更改(就像我在这里所做的那样),这在未来提供了灵活性。

缓存

将“h”分配给“validProgramCodes”的结果可能只是对处理器缓存的写入。即使新线程启动,“h”对新线程也不会可见,除非它已被刷新到共享内存。一个好的运行时会避免刷新,除非它是必要的,而 usingvolatile是表明它是必要的一种方式。

重新排序

假设以下代码:

HashMap codes = new HashMap();
codes.putAll(source);
meta.setValidProgramCodes(codes);

如果setValidCodes只是 OP's validProgramCodes = h;,编译器可以自由地重新排序代码,如下所示:

 1: meta.validProgramCodes = codes = new HashMap();
 2: codes.putAll(source);

假设在编写器第 1 行执行后,读取器线程开始运行以下代码:

 1: Map codes = meta.getValidProgramCodes();
 2: Iterator i = codes.entrySet().iterator();
 3: while (i.hasNext()) {
 4:   Map.Entry e = (Map.Entry) i.next();
 5:   // Do something with e.
 6: }

现在假设编写器线程在读取器的第 2 行和第 3 行之间的映射上调用“putAll”。Iterator 底层的映射经历了并发修改,并抛出了运行时异常——一个极其间歇的、看似莫名其妙的运行时异常,从来没有测试过程中产生。

并发编程

任何时候你有一个线程关心另一个线程在做什么,你必须有某种内存屏障来确保一个线程的操作对另一个线程是可见的。如果一个线程中的事件必须在另一个线程中的事件之前发生,则必须明确指出。否则无法保证。在实践中,这意味着volatilesynchronized

不要吝啬。不正确的程序无法完成其工作的速度并不重要。此处显示的示例简单且做作,但请放心,它们说明了现实世界的并发错误,由于其不可预测性和平台敏感性,这些错误非常难以识别和解决。

其他资源

于 2008-11-18T22:14:56.850 回答
4

不,代码示例不安全,因为没有任何新 HashMap 实例的安全发布。如果没有任何同步,读取器线程可能会看到部分初始化的HashMap。

在他的回答中查看@erickson 在“重新排序”下的解释。此外,我不能推荐 Brian Goetz 的Java Concurrency in Practice一书!

读者线程可能会看到旧的(陈旧的)HashMap 引用,或者甚至可能永远不会看到新的引用,这对您来说是无关紧要的。可能发生的最糟糕的事情是读取器线程可能会获取对尚未初始化且尚未准备好访问的 HashMap 实例的引用并尝试访问该实例。

于 2008-11-20T07:23:56.023 回答
3

正如其他人已经指出的那样,这是不安全的,您不应该这样做。您需要在此处使用 volatile 或 synchronized 来强制其他线程看到更改。

没有提到的是 synchronized 尤其是 volatile 可能比您想象的要快得多。如果它实际上是您的应用程序的性能瓶颈,那么我会吃掉这个网页。

另一种选择(可能比 volatile 慢,但 YMMV)是使用 ReentrantReadWriteLock 来保护访问,以便多个并发读者可以读取它。如果这仍然是性能瓶颈,我会吃掉整个网站。

  public class Metadata
  {
    private HashMap validProgramCodes;
    private ReadWriteLock lock = new ReentrantReadWriteLock();

    public HashMap getValidProgramCodes() { 
      lock.readLock().lock();
      try {
        return validProgramCodes; 
      } finally {
        lock.readLock().unlock();
      }
    }

    public void setValidProgramCodes(HashMap h) { 
      lock.writeLock().lock();
      try {
        validProgramCodes = h; 
      } finally {
        lock.writeLock().unlock();
      }
    }
  }
于 2008-11-20T15:44:48.260 回答
3

不,根据 Java 内存模型 (JMM),这不是线程安全的。

写入和读取实现对象之间没有发生之前的关系。HashMap因此,虽然写线程似乎先写出对象,然后再写出引用,但读线程可能不会看到相同的顺序。

如前所述,不能保证真正的线程会看到新值。在现有硬件上的当前编译器的实践中,值应该得到更新,除非循环体足够小以至于可以充分内联。

因此,volatile在新的 JMM 下进行参考就足够了。它不太可能对系统性能产生重大影响。

这个故事的寓意:穿线很困难。不要试图变得聪明,因为有时(可能不在你的测试系统上)你不够聪明。

于 2008-11-19T15:09:30.530 回答
2

我认为你的假设是正确的。我唯一要做的就是设置validProgramCodes易失性。

private volatile HashMap validProgramCodes;

这样,当您更新“指针”时,validProgramCodes您可以保证所有线程都访问相同的最新HasMap“指针”,因为它们不依赖本地线程缓存并直接进入内存。

于 2008-11-18T22:23:10.717 回答
1

只要您不担心读取过时的值,并且只要您可以保证在初始化时正确填充哈希图,该分配就可以工作。您至少应该在 Hashmap 上使用 Collections.unmodifiableMap 创建 hashMap,以确保您的读者不会从映射中更改/删除对象,并避免多个线程相互踩踏并在其他线程销毁时使迭代器无效。

(上面的作者对易失性的看法是正确的,应该已经看到了)

于 2008-11-18T22:18:15.313 回答
1

虽然这不是这个特定问题的最佳解决方案(埃里克森的新 unmodifiableMap 想法是),但我想花点时间提一下Java 5 中引入的java.util.concurrent.ConcurrentHashMap类,特别是 HashMap 的一个版本在考虑并发的情况下构建。此构造不会阻塞读取。

于 2008-11-21T13:45:39.360 回答
0

查看这篇关于并发基础的帖子。它应该能够令人满意地回答您的问题。

http://walivi.wordpress.com/2013/08/24/concurrency-in-java-a-beginners-introduction/

于 2013-08-28T11:44:32.313 回答
-1

我认为这是有风险的。线程化会导致各种微妙的问题,调试起来非常痛苦。您可能想查看FastHashMap,它适用于像这样的只读线程情况。

至少,我还要声明validProgramCodes这样volatile引用不会被优化到寄存器或其他东西中。

于 2008-11-18T22:13:12.860 回答
-3

如果我正确阅读了JLS(没有保证!),对引用的访问总是原子的,句号。请参阅第 17.7 节 double 和 long 的非原子处理

因此,如果对引用的访问始终是原子的,并且线程看到的返回的实例无关紧要Hashmap,那么您应该没问题。您永远不会看到对引用的部分写入。


编辑:在查看下面评论和其他答案中的讨论后,这里是来自的参考/引用

Doug Lea的书(Java 中的并发编程,第 2 版),第 94 页,第 2.2.7.2 节可见性,第 3 项:“

线程第一次访问对象的字段时,它会看到该字段的初始值或其他线程写入后的值。”

在页。94,Lea 继续描述与这种方法相关的风险:

内存模型保证,给定上述操作的最终发生,一个线程对特定字段进行的特定更新最终将对另一个线程可见。但最终可以是任意长的时间。

因此,当它绝对,肯定地,必须对任何调用线程可见,volatile或者需要一些其他同步屏障时,尤其是在长时间运行的线程或在循环中访问值的线程中(如 Lea 所说)。

但是,在存在短命线程的情况下,正如问题所暗示的那样,为新读者提供新线程并且不会影响应用程序读取陈旧数据, 不需要同步


@ erickson的答案在这种情况下是最安全的,保证其他线程会在发生HashMap引用更改时看到它们。我建议遵循该建议,只是为了避免对要求和实施的混淆,从而导致对该答案和下面的讨论“投反对票”。

我不会删除答案,希望它会有用。我不是在寻找“同伴压力”徽章...... ;-)

于 2008-11-18T22:35:51.630 回答