Java同步块 vs. Collections.synchronizedMap

88

以下代码是否已正确设置同步调用 synchronizedMap

public class MyClass {
  private static Map<String, List<String>> synchronizedMap = Collections.synchronizedMap(new HashMap<String, List<String>>());

  public void doWork(String key) {
    List<String> values = null;
    while ((values = synchronizedMap.remove(key)) != null) {
      //do something with values
    }
  }

  public static void addToMap(String key, String value) {
    synchronized (synchronizedMap) {
      if (synchronizedMap.containsKey(key)) {
        synchronizedMap.get(key).add(value);
      }
      else {
        List<String> valuesList = new ArrayList<String>();
        valuesList.add(value);
        synchronizedMap.put(key, valuesList);
      }
    }
  }
}

据我的理解,在 addToMap() 方法中,我需要同步块来防止另一个线程在我调用 put() 之前调用 remove()containsKey() 方法,但是在 doWork() 方法中,我不需要同步块,因为另一个线程不能在 remove() 方法返回之前进入 addToMap() 中的同步块,因为我最初使用 Collections.synchronizedMap() 创建了 Map。这是正确的吗?还有更好的方法吗?

7个回答

91

Collections.synchronizedMap() 保证你想在map上执行的每个原子操作都将被同步。

然而,在map上运行两个或更多操作必须在一个block中同步。

因此,是的-您正在正确地进行同步。


26
我认为提到这个是有好处的,因为Java文档明确指出synchronizedMap同步的是map本身而不是某个内部锁,所以这个方法能够正常工作。如果不是这样的话,使用synchronized(synchronizedMap)就不正确了。 - extraneon
2
@Yuval,你能详细解释一下你的答案吗?你说synchronizedMap可以原子地执行操作,那么如果syncMap使所有操作都是原子性的,为什么还需要自己的同步块呢?你的第一段似乎排除了对第二段的担忧。 - almel
@almel请查看我的答案 - Sergey
3
由于Map已经使用了Collections.synchronizedMap(),为什么还需要同步块?我不理解第二个观点。 - Bimal Sharma
@BimalSharma 因为在同步块中对映射执行了两个原子操作。同步集合保证,在调用contains时没有其他线程会改变其状态,get也是一样。但是,在containsget之间,另一个线程可能会替换或删除通过contains检查的元素,因此您的get结果可能不是您期望的。这就是为什么使用同步块的原因,以保证这两个原子操作之间没有更改。 - Koenigsberg

15

如果您正在使用JDK 6,则可能希望查看ConcurrentHashMap

请注意该类中的putIfAbsent方法。


13

你的代码潜在地存在一个微妙的错误。

[更新:因为他使用了map.remove(),所以下面的描述不完全准确。我第一次浏览时错过了这个事实。:( 感谢问题的作者指出了这一点。我将其余部分保留不变,但修改了引言中的措辞,表示有可能存在错误。]

doWork()中,你以线程安全的方式从Map中获取List值。然而,在此之后,你正在以不安全的方式访问该列表。例如,一个线程可能在 doWork() 中使用列表,而另一个线程在 addToMap() 中调用 synchronizedMap.get(key).add(value)。这两个访问没有同步。经验法则是,一个集合的线程安全保证不会扩展到它们存储的键或值。

你可以通过将同步列表插入到Map中来解决此问题,例如

List<String> valuesList = new ArrayList<String>();
valuesList.add(value);
synchronizedMap.put(key, Collections.synchronizedList(valuesList)); // sync'd list

或者你可以在访问 doWork() 中的列表时对映射进行同步:

  public void doWork(String key) {
    List<String> values = null;
    while ((values = synchronizedMap.remove(key)) != null) {
      synchronized (synchronizedMap) {
          //do something with values
      }
    }
  }

我认为最后一种选择会稍微限制并发性能,但在我看来更加清晰易懂。

另外,关于ConcurrentHashMap的一点提示。这是一个非常有用的类,但并不总是适合替换同步的HashMap。引用自其Javadoc:

在依赖于线程安全而不是同步细节的程序中,此类与Hashtable完全互操作。

换句话说,putIfAbsent()对于原子插入非常好,但它不能保证map的其他部分在该调用期间不会更改;它只保证原子性。在您的示例程序中,您正在依赖于同步的HashMap的同步细节,以进行除put()之外的其他操作。

最后一件事。:)这个伟大的引用来自《Java Concurrency in Practice》,它总是帮助我设计和调试多线程程序。

对于每个可能被多个线程访问的可变状态变量,所有对该变量的访问都必须使用相同的锁执行。


如果我使用synchronizedMap.get()访问列表,我能理解你关于bug的观点。但是由于我正在使用remove(),下一个带有该键的添加操作不应该创建一个新的ArrayList并且不会干扰我在doWork中使用的那个。 - Ryan Ahearn
正确!我完全忽略了你的删除。 - JLR
1
对于每个可被多个线程访问的可变状态变量,所有对该变量的访问都必须在同一个锁定状态下进行。我通常会添加一个私有属性,只是一个新的Object(),并将其用于我的同步块。这样我就知道它完全是通过该上下文的原始方式进行的。synchronized (objectInVar){} - AnthonyJClink

11

是的,你正在正确同步。我会详细解释一下。 只有在你必须依赖于前面方法调用的结果来进行后续方法调用时,才需要在synchronizedMap对象上同步两个或多个方法调用的执行。 让我们看一下这段代码:

synchronized (synchronizedMap) {
    if (synchronizedMap.containsKey(key)) {
        synchronizedMap.get(key).add(value);
    }
    else {
        List<String> valuesList = new ArrayList<String>();
        valuesList.add(value);
        synchronizedMap.put(key, valuesList);
    }
}
在这段代码中。
synchronizedMap.get(key).add(value);

synchronizedMap.put(key, valuesList);

方法调用依赖于先前结果。

synchronizedMap.containsKey(key)

方法调用。

如果方法调用的顺序没有同步,结果可能会出错。 例如,线程1正在执行方法addToMap(),而线程2正在执行方法doWork(), 在synchronizedMap对象上的方法调用顺序可能如下所示: 线程1已经执行了该方法

synchronizedMap.containsKey(key)

结果为 "true"。 此后,操作系统已将执行控制权切换到线程2并执行了

synchronizedMap.remove(key)

执行控制权已经切换回 线程1,例如它已经执行了

synchronizedMap.get(key).add(value);
相信synchronizedMap对象包含key,并且synchronizedMap.get(key)将返回null,因此会抛出NullPointerException异常。如果对synchronizedMap对象的方法调用顺序不依赖于彼此的结果,则无需同步该顺序。例如,您无需同步此顺序:
synchronizedMap.put(key1, valuesList1);
synchronizedMap.put(key2, valuesList2);

这里

synchronizedMap.put(key2, valuesList2);

方法调用不依赖于先前结果

synchronizedMap.put(key1, valuesList1);

方法调用(它不关心两个方法调用之间是否有其他线程干扰,例如删除了key1)。


4

我认为那看起来是正确的。如果我需要做出任何改变,我会停止使用Collections.synchronizedMap()并采用同样的方式同步所有内容,以使它更清晰。

此外,我会替换

  if (synchronizedMap.containsKey(key)) {
    synchronizedMap.get(key).add(value);
  }
  else {
    List<String> valuesList = new ArrayList<String>();
    valuesList.add(value);
    synchronizedMap.put(key, valuesList);
  }

使用

List<String> valuesList = synchronziedMap.get(key);
if (valuesList == null)
{
  valuesList = new ArrayList<String>();
  synchronziedMap.put(key, valuesList);
}
valuesList.add(value);

3
要做的事情。我不明白为什么我们在日常应用程序逻辑中仍然需要在某些对象上进行同步(在大多数情况下将只是集合本身),而我们仍然需要使用Collections.synchronizedXXX() API。 - kellogs

3
你同步的方式是正确的。但是有一个陷阱。
集合框架提供的同步包装器确保方法调用,即add/get/contains将互斥运行。
然而,在现实世界中,通常会在放入值之前查询地图。因此,您需要执行两个操作,因此需要一个同步块。所以你使用的方式是正确的。不过,
你可以使用集合框架中可用的Map的并发实现'ConcurrentHashMap',它的好处是
1.它有一个API 'putIfAbsent',它会以更有效的方式做同样的事情。
2.它很高效:CocurrentMap只锁定键,因此它不会阻塞整个地图的世界。而你已经阻止了键和值。
3.您可能已经在代码库的其他位置传递了map对象的引用,您或您团队中的其他开发人员可能会使用不正确。也就是说,他可能只会添加()或获取()而不锁定地图的对象。因此,他的调用不会与您的同步块互斥运行。但是使用并发实现可以让您放心,它永远不会被错误地使用/实现。

2

请查看Google Collections中的Multimap,例如这个演示文稿的第28页。

如果由于某些原因无法使用该库,请考虑使用ConcurrentHashMap替代SynchronizedHashMap;它有一个巧妙的putIfAbsent(K,V)方法,您可以使用它来原子性地添加元素列表(如果尚未存在)。此外,如果您的使用模式需要,可以考虑使用CopyOnWriteArrayList作为映射值。


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