问题 使用ConcurrentMap双重检查锁定


我有一段代码可以由多个线程执行,这些线程需要执行I / O绑定操作才能初始化存储在a中的共享资源 ConcurrentMap。我需要使这段代码线程安全,并避免不必要的调用来初始化共享资源。这是有缺陷的代码:

    private ConcurrentMap<String, Resource> map;

    // .....

    String key = "somekey";
    Resource resource;
    if (map.containsKey(key)) {
        resource = map.get(key);
    } else {
        resource = getResource(key); // I/O-bound, expensive operation
        map.put(key, resource);
    }

使用上面的代码,多个线程可以检查 ConcurrentMap 并看到资源不存在,并且所有人都试图打电话 getResource() 这很贵。为了确保共享资源只进行一次初始化,并在资源初始化后使代码高效,我想做这样的事情:

    String key = "somekey";
    Resource resource;
    if (!map.containsKey(key)) {
        synchronized (map) {
            if (!map.containsKey(key)) {
                resource = getResource(key);
                map.put(key, resource);
            }
        }
    }

这是双重检查锁定的安全版本吗?在我看来,因为检查被调用 ConcurrentMap,它的行为就像声明的共享资源一样 volatile 从而防止可能发生的任何“部分初始化”问题。


9320
2017-08-09 21:27


起源

如果您查看本页右侧的“相关”部分并稍微了解一下,您会看到很多有用的信息。特别是,在这个问题中接受的答案: stackoverflow.com/questions/157198/... - Jeremy
这整个论点值得怀疑。您是否实际计算了进入同步块所需的时间? (这是你要避免的)在我的测试中我得到0毫秒告诉我..没关系 - che javara
现在不是进入pmc255试图避免的同步块的时候,而是通过getResource(key)加载资源的时间,pmc255表示这是一个“I / O限制,昂贵的操作”。 - David Conrad


答案:


是的,它很安全。

如果 map.containsKey(key) 据医生说,这是真的, map.put(key, resource) 发生在它之前。因此 getResource(key) 发生在之前 resource = map.get(key)一切都安然无恙。


3
2017-08-09 21:49



你能链接到说那个的文档吗?我似乎无法找到那个位编辑:是 这个文件 你在说什么 - jonatzin


如果您可以使用外部库,请查看Guava的 MapMaker.makeComputingMap()。它是为您想要做的事情量身定制的。


4
2017-08-09 22:00



我调查了一下impl。这是不合时宜的。我不相信任何人都可以分析它的并发行为。 - irreputable
来自Guava(Google集合)的计算地图确保(昂贵的)计算仅执行一次。 Brian Goetz也在他的书中以“Memoizer”的名义出现了这种模式。 - sjlee
好吧,我正在使用MapMaker构建ConcurrentMap。我的问题是,使用ConcurrentMap(其接口定义的合同)是否是常见的双重检查锁定问题的充分解决方法。 - pmc255
此功能现已移出 MapMaker 至 CacheBuilder - MikeFHay


为什么不在ConcurrentMap上使用putIfAbsent()方法?

if(!map.containsKey(key)){
  map.putIfAbsent(key, getResource(key));
}

可以想象,你可以不止一次调用getResource(),但它不会发生很多次。更简单的代码不太可能咬你。


2
2017-08-09 21:32



+1比我快:) - Bohemian♦
重复的getResource()正是他想要避免的 - irreputable
不可信的是正确的 - getResource()调用是我想要只调用一次的。 - pmc255
你仍然应该使用putIfAbsent,但你可以将它放在同步块中。 - rfeak
@rfeak然后你有一个围绕一切的同步块,你必须在每次从地图上得到东西时同步。双重检查锁定的重点是避免额外的同步。 - Brian Gordon


一般来说,双重检查锁定  如果要同步的变量标记为volatile,则安全。但是你最好同步整个功能:


public synchronized Resource getResource(String key) {
  Resource resource = map.get(key);
  if (resource == null) {
    resource = expensiveGetResourceOperation(key);    
    map.put(key, resource);
  }
  return resource;
}

性能打击很小,您将确定不会同步 问题。

编辑:

这实际上比替代方案更快,因为在大多数情况下,您不必对地图进行两次调用。唯一的额外操作是空检查,其成本接近于零。

第二次编辑:

此外,您不必使用ConcurrentMap。常规的HashMap会做到这一点。更快。


1
2017-08-09 22:03



这种方法很可能会被反复调用很多次;同步整个方法似乎很昂贵,这是我想要首先避免的。此外,对ConcurrentMap.containsKey的调用应该稍微更高效,因为ConcurrentMap的锁定比同步整个containsKey操作的对象级锁更精细。也就是说,可以同时调用containsKey,而同步的getResource()一次只能被一个线程访问,即使在资源初始化之后也是如此。 - pmc255
事实上,你的例子就像Java Concurrency In Practice 5.6节中列出的“memoization”表现不佳的例子。 - pmc255
我同意这个答案。当我计算进入同步块的性能命中和hashmap操作时,它仍然<1ms。您需要为亚毫秒优化提供理由。 - che javara
此操作的速度取决于争用的数量。如果没有什么争用,那就是最快的方法。如果存在很多争用,那么它可能会更慢。一如既往,不要推测,写一个基准。 - ccleve


没必要 - ConcurrentMap 支持这与其特殊的原子 的putIfAbsent 方法。

不要重新发明轮子:尽可能使用API​​。


0
2017-08-09 21:33



在我调用putIfAbsent之前,我需要获取我想要放在地图中的值;获取操作很昂贵。使用putIfAbsent并没有解决这个问题。 - pmc255
如果你输掉比赛,那么你将需要进行多余的昂贵初始化。 - bmargulies
@bmargulies这是真的,但它只在相同的钥匙的比赛条件下很少发生,所以它不应该伤害太多。你可以在它周围加上额外的防护,但是我会等到看看运行时会发生什么,只有“修复”它 需求 定影 - Bohemian♦
在我的情况下,昂贵的东西有副作用,绝对不能额外运行。 Guava ComputedMap看起来合适。 - bmargulies


判决结果是。我以纳秒准确度计算了3种不同的解决方案,因为毕竟最初的问题是关于性能的:

在常规HashMap上完全同步函数

synchronized (map) {

   Object result = map.get(key);
   if (result == null) {
      result = new Object();
      map.put(key, result);
   }                
   return result;
}

第一次调用:15,000纳秒,后续调用:700纳秒

使用带有ConcurrentHashMap的双重检查锁

if (!map.containsKey(key)) {
   synchronized (map) {
      if (!map.containsKey(key)) {
         map.put(key, new Object());
      }
   }
} 
return map.get(key);

第一次调用:15,000纳秒,后续调用:1500纳秒

不同风格的双重检查ConcurrentHashMap

Object result = map.get(key);
if (result == null) {
   synchronized (map) {
      if (!map.containsKey(key)) {
         result = new Object();
         map.put(key, result);
      } else {
         result = map.get(key);
      }
   }
} 

return result;

第一次调用:15,000纳秒,后续调用:1000纳秒

您可以看到最大的成本是在第一次调用时,但是对于所有3都是类似的。后续调用在常规HashMap上是最快的,方法同步如user237815建议但只有300个NANO seocnds。毕竟我们在这里讨论的是NANO秒,这意味着一秒钟的数十亿。


0
2017-08-17 15:37



现在替换 new Object() 从网络中读取和解析几兆字节的XML,你会得到像pmc255实际上要求的东西。你误解了这个问题。 - David Conrad
不,我没有误解这个问题。它是关于在构造地图之前检查地图是否包含对象并将其放入地图并确保其线程安全。无论新的Object()做什么,只有在对象不存在时才会调用它。 - che javara