Java并发场景 - 我需要同步还是不需要?

16

说实话,我有一个哈希映射包含我称之为“程序代码”的数据,它位于一个对象中,就像这样:

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

我有很多读取线程,每个线程都会调用getValidProgramCodes()一次,并将其哈希映射表用作只读资源。

到目前为止还好。这里才变得有趣。

我想放一个定时器,每隔一段时间生成一个新的有效程序代码列表(不要在意如何生成),然后调用setValidProgramCodes。

我的理论是,我可以继续使用现有的代码,而不必显式同步。它是这样运行的: 在更新validProgramCodes的时间点上,validProgramCodes的值始终是好的 - 它是指向新哈希映射表或旧哈希映射表的指针。这是一切的关键假设。持有旧哈希映射表的读者没有问题;他们可以继续使用旧值,因为它不会被垃圾收集,直到释放它。每个读者都是短暂的;它很快就会死亡并被一个新的读者取代,后者会拾取新值。

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

10个回答

28

使用Volatile

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

大多数情况下,一个线程不关心另一个线程正在做什么。但是当它关心时,就需要使用同步机制。

对于那些认为原始代码已经安全的人,可以考虑以下情况: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))。这将创建一个快照,即使设置器的调用者继续更新地图"h",它也不会改变。例如,他们可能会清除地图并添加新条目。

依赖于接口

从风格上讲,通常最好使用抽象类型(如ListMap)声明API,而不是具体类型(如ArrayListHashMap)。如果需要更改具体类型(就像我在这里做的那样),这样可以提供灵活性。

缓存

将"h"分配给"validProgramCodes"的结果可能只是对处理器缓存的一次写入。即使启动了新线程,除非已将"h"刷新到共享内存,否则新线程将看不到它。一个好的运行时会避免刷新,除非必要,并且使用volatile是一种指示它必要的方法之一。

重新排序

假设以下代码:

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

如果setValidCodes仅仅是OP的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”。迭代器底层的地图已经发生了并发修改,并抛出运行时异常——一种魔鬼般的间歇性、似乎无法解释的运行时异常,在测试期间从未出现过。

并发编程

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

不要省略这个步骤。一个错误的程序多快都不能完成它的工作。这里展示的例子是简单而人为的,但请放心,它们说明了非常难以识别和解决的真实世界并发错误,因为它们的不可预测性和平台敏感性。

其他资源


4
不,代码示例并不安全,因为没有安全发布任何新的HashMap实例。在没有任何同步的情况下,有可能读取线程会看到一个部分初始化的HashMap。 请查看@erickson在他的答案中的“重新排序”下的解释。此外,我强烈推荐Brian Goetz的书Java并发实践! 无论您是否认为读取线程可能看到旧(陈旧)的HashMap引用或甚至永远看不到新引用,都无关紧要。最糟糕的情况是读取线程可能获得对未初始化且未准备好被访问的HashMap实例的引用并尝试访问它。

3
不,根据Java内存模型(JMM),这不是线程安全的。
在写入和读取HashMap实现对象之间没有“happens-before”关系。因此,尽管写入线程似乎首先写出对象然后是引用,但读取线程可能不会看到相同的顺序。
正如上面提到的,也没有保证读取线程将看到新值。在现有硬件上使用当前编译器时,实际上该值应该得到更新,除非循环体足够小,可以被充分内联。
因此,在新的JMM下,使引用变量volatile是足够的。这不太可能对系统性能产生实质性影响。
这个故事的寓意是:多线程编程很难。不要试图变聪明,因为有时候(也许不是在你的测试系统上)你不够聪明。

3
正如其他人已经指出的那样,这是不安全的,你不应该这样做。在此处,你需要使用volatile或synchronized来强制其他线程看到更改。
还没有提到的是,synchronized和特别是volatile可能比你想象的要快得多。如果它实际上是你的应用程序中的性能瓶颈,那么我会吃掉这个网页。
另一个选项(可能比volatile慢,但结果可能因情况而异)是使用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();
      }
    }
  }

2

我认为你的假设是正确的。唯一的事情是将validProgramCodes设置为易失性的。

private volatile HashMap validProgramCodes;

这样,当你更新validProgramCodes的“指针”时,你保证所有线程访问相同的最新HashMap“指针”,因为它们不依赖于本地线程缓存而直接访问内存。

1
只要您不关心读取过时的值,并且可以保证哈希映射在初始化时已正确填充,该分配将起作用。您应该至少使用Collections.unmodifiableMap在Hashmap上创建hashMap,以确保读者不会更改/删除映射中的对象,并避免多个线程互相干扰并在其他线程破坏迭代器时使其无效。(上面的作者关于volatile的说法是正确的,我应该看到这一点)

只是为了明确,你保证“哈希映射在初始化时被正确填充”的方式是使用同步 :) 可以使用synchronized、volatile或者java.util.concurrent中的某些东西。 - Scott Bale

1

虽然这不是这个特定问题的最佳解决方案(Erickson提出的新的unmodifiableMap的想法是),但我想花点时间提一下在Java 5中引入的java.util.concurrent.ConcurrentHashMap类,这是一个专门为并发而构建的HashMap版本。这个结构在读取时不会阻塞。


0

-1

我认为这很危险。使用线程会导致各种微妙的问题,非常麻烦难以调试。你可能需要看一下 FastHashMap,它适用于类似于此的只读线程情况。

至少,我也会将 validProgramCodes 声明为 volatile,这样引用就不会被优化成寄存器或其他东西。


1
FastHashMap是非常冒险的! - Tom Hawtin - tackline

-3

如果我正确地阅读了JLS(不保证!),那么对引用的访问总是原子性的,没有例外。请参见第17.7节double和long的非原子处理

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


编辑:在评论区和其他答案的讨论审查后,以下是来自Doug Lea的书籍(Java并发编程实战 第2版)第94页第2.2.7.2节可见性,第3项的引用:

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

在第94页上,Lea继续描述了与此方法相关的风险:

内存模型保证,在上述操作最终发生时,一个线程对特定字段所做的特定更新最终将对另一个线程可见。但是,最终可能需要任意长的时间。

因此,当它必须绝对、明确地可见给任何调用线程时,需要使用volatile或其他同步屏障,特别是在长时间运行的线程或循环访问该值的线程中(正如Lea所说)。

然而,如果像问题所暗示的那样存在短暂的线程,并且为新读者创建新线程,而且不会影响应用程序读取过时数据,则不需要同步。

@erickson的回答在这种情况下是最安全的,保证其他线程将会看到HashMap引用的更改。我建议遵循这个建议,仅仅是为了避免对要求和实现的混淆,这导致了这个答案的“反对票”和下面的讨论。

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


1
正如我在其他地方回答和评论的那样,这里还没有提到更大的潜在问题,即如果没有同步,新的HashMap就不会被安全地发布,因此可能会以无效状态(即其内容尚未对读取线程可见)的形式对读取线程可见。 - Scott Bale
1
将新的HashMap实例分配给“validProgramCodes”字段并不是问题。是的,赋值是原子的。在缺乏适当同步的情况下,该字段的赋值和该映射的填充可能会从读取器线程的POV中重新排序。请搜索“安全发布”。 - Scott Bale
1
@Ken,“validProgramCodes”类型是HashMap,而不是Map,因此它不能引用除HashMap(或HashMap子类)以外的任何内容。但是,除此之外,如果Collections$SynchronizedMap没有被安全地发布,它就只能是不安全的。它的构造函数没有任何同步,所以即使这个想法也行不通。 - Scott Bale
1
@Scott:在 Map/syncrhonizedMap/HashMap 上犯了 DOH!错误 - 但是,对初始化的假设仍然成立。至于我是否会批准该API,这取决于要求和整个类的实现。我假设这个示例是有意不完整的,并且不会作为提交内容。 - Ken Gentle
1
@Ken 在这个有限的示例中,做出假设是不可避免的。你说得对,同步可以在元数据内部或外部发生(只要它被一致地应用)。但是,在这个示例中,必须在某个地方(很可能是同步)进行一些操作,以确保安全发布。 - Scott Bale
显示剩余19条评论

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