Java 并发方案 -- 我是否需要同步?

2022-09-03 04:19:56

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

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

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

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

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

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

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


答案 1

使用易失性

这是一个线程关心另一个线程在做什么的情况吗?那么JMM常见问题解答就有了答案:

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

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

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

我建议将有效的程序代码成员声明为“易失性”。速度差异可以忽略不计,并且无论引入什么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));
    }

}

不变性

除了用 包装它之外,我还复制了地图()。这使得快照不会更改,即使 setter 的调用方继续更新映射“h”。例如,他们可能会清除地图并添加新条目。unmodifiableMapnew HashMap(h)

取决于接口

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

缓存

将“h”分配给“validProgramCodes”的结果可能只是写入处理器的缓存。即使新线程启动,“h”对新线程也不可见,除非它已被刷新到共享内存。除非有必要,否则良好的运行时将避免刷新,而使用是指示有必要的一种方法。volatile

重组

假定以下代码:

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

如果只是OP的,编译器可以自由地对代码进行如下重新排序:setValidCodesvalidProgramCodes = 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

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

其他资源


答案 2

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

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

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


推荐