【问题标题】:Synchronizing a Map of Sets/Lists同步集合/列表的映射
【发布时间】:2012-03-04 09:51:05
【问题描述】:

我想在“Map of Sets”集合上实现一个变体,该集合将被多个线程不断访问。我想知道我所做的同步是否足以保证不会出现任何问题。

所以给定以下代码,其中 Map、HashMap 和 Set 是 Java 实现,而 Key 和 Value 是一些任意对象:

public class MapOfSets {
    private Map<Key, Set<Value>> map;

    public MapOfLists() {
        map = Collections.synchronizedMap(new HashMap<Key, Set<Value>());
    }

    //adds value to the set mapped to key
    public void add(Key key, Value value) {
        Set<Value> old = map.get(key);

        //if no previous set exists on this key, create it and add value to it
        if(old == null) {
            old = new Set<Value>();
            old.add(value);
            map.put(old);
        }
        //otherwise simply insert the value to the existing set
        else {
            old.add(value);
        }
    }

    //similar to add
    public void remove(Key key, Value value) {...}

    //perform some operation on all elements in the set mapped to key
    public void foo(Key key) {
        Set<Value> set = map.get(key);

        for(Value v : set)
            v.bar();
    }
}

这里的想法是因为我已经同步了 Map 本身,所以 get() 和 put() 方法应该是原子的,对吧?所以应该不需要对 Map 或其中包含的 Sets 进行额外的同步。那么这会奏效吗?

或者,上述代码是否比另一种可能的同步解决方案更有优势:

public class MapOfSets {
    private Map<Key, Set<Value>> map;

    public MapOfLists() {
        map = new HashMap<Key, Set<Value>();
    }

    public synchronized void add(Key key, Value value) {
        Set<Value> old = map.get(key);

        //if no previous set exists on this key, create it and add value to it
        if(old == null) {
            old = new Set<Value>();
            old.add(value);
            map.put(old);
        }
        //otherwise simply insert the value to the existing set
        else {
            old.add(value);
        }
    }

    //similar to add
    public synchronized void remove(Key key, Value value) {...}

    //perform some operation on all elements in the set mapped to key
    public synchronized void foo(Key key) {
        Set<Value> set = map.get(key);

        for(Value v : set)
            v.bar();
    }
}

我不同步数据结构,而是同步所有可能的公共方法。那么哪些会起作用,哪个更好呢?

【问题讨论】:

  • old = new Set&lt;Value&gt;(); - Set 是一个接口,你用的是什么类?如果使用非并发类可能会出现问题
  • 这有关系吗?我正在考虑使用 CopyOnWriteArraySet,它保证线程安全,但映射或访问器的同步是否已经保证一次只有一个线程会查看 Set?
  • 为什么第一个实现保证一次只有一个线程会查看Set?我不认为这种说法是正确的,如果集合不是线程安全的,那么当 2 个线程尝试同时从集合中添加/删除元素时,您可能会遇到并发问题。
  • 我想我看到了第一个实现的问题。第二个呢?

标签: java multithreading collections concurrency synchronization


【解决方案1】:

您发布的第一个实现不是线程安全的。考虑当两个具有相同key 的并发线程访问add 方法时会发生什么:

  1. 线程 A 执行该方法的第 1 行,并获得 null 引用,因为不存在具有给定键的项目
  2. 线程 B 执行该方法的第 1 行,并获得一个 null 引用,因为不存在具有给定键的项目 - 这将在 A 从第一次调用,因为地图是同步的
  3. 线程 A 将 if 条件评估为 false
  4. 线程 B 将 if 条件评估为 false

从那时起,两个线程将继续执行 if 语句的 true 分支,您将丢失两个 value 对象之一。

您发布的方法的第二个变体看起来更安全。


但是,如果您可以使用第三方库,我建议您查看 Google Guava,因为它们提供并发多图 (docs)。

【讨论】:

    【解决方案2】:

    第二个是正确的,但第一个不是。

    想一想,假设两个线程并行调用add()。以下是可能发生的情况:

    • 线程 1 调用 add("foo", bar");
    • 线程 2 调用 add("foo", baz");
    • 线程 1 获取“foo”的集合:null
    • 线程 2 获取“foo”的集合:null
    • 线程 1 创建一个新集合并在其中添加“bar”
    • 线程 2 创建一个新集合并在其中添加“baz”
    • 线程 1 将其集合放入映射中
    • 线程 2 将其集合放入映射中

    在故事的最后,地图包含一个“foo”值而不是两个。

    同步地图可确保其内部状态是一致的,并且您在地图上调用的每个方法都是线程安全的。但它不会使 get-then-put 操作成为原子操作。

    考虑使用 Guava 的 SetMultiMap 实现之一,它可以为您完成所有工作。将其封装到对Multimaps.synchronizedSetMultimap(SetMultimap) 的调用中,使其成为线程安全的。

    【讨论】:

      【解决方案3】:

      您的第二个实现将起作用,但它持有锁的时间超过了它需要的时间(使用同步方法而不是同步块的一个不可避免的问题),这会降低并发性。如果你发现这里的并发限制是一个瓶颈,你可以把锁定的区域缩小一点。

      或者,您可以使用java.util.concurrent 提供的一些无锁集合。这是我的尝试;这未经测试,它要求 Key 具有可比性,但它不应该执行任何锁定:

      public class MapOfSets {
          private final ConcurrentMap<Key, Set<Value>> map;
      
          public MapOfSets() {
              map = new ConcurrentSkipListMap<Key, Set<Value>>();
          }
      
          private static ThreadLocal<Set<Value>> freshSets = new ThreadLocal<Set<Value>>() {
              @Override
              protected Set<Value> initialValue() {
                  return new ConcurrentSkipListSet<Value>();
              }
          };
      
          public void add(Key key, Value value) {
              Set<Value> freshSet = freshSets.get();
              Set<Value> set = map.putIfAbsent(key, freshSet);
              if (set == null) {
                  set = freshSet;
                  freshSets.remove();
              }
              set.add(value);
          }
      
          public void remove(Key key, Value value) {
              Set<Value> set = map.get(key);
              if (set != null) {
                  set.remove(value);
              }
          }
      
          //perform some operation on all elements in the set mapped to key
          public void foo(Key key) {
              Set<Value> set = map.get(key);
              if (set != null) {
                  for (Value v: set) {
                      v.bar();
                  }
              }
          }
      }
      

      【讨论】:

      • @OldCurmudgeon:你太客气了。这有点像 hack(以一种好的方式),可能需要一些解释。可以将freshSet 视为在运行之间缓存的局部变量。
      • 能解释一下Thread local的使用吗?我可以通过这种方式在线程之间共享 Set 吗?
      【解决方案4】:

      对于您的 Map 实现,您可以只使用 ConcurrentHashMap - 您不必担心确保访问的线程安全,无论是输入还是检索,因为实现会为您处理。

      如果你真的想使用 Set,你可以调用

      Collections.newSetFromMap(new ConcurrentHashMap<Object,Boolean>()) 
      

      在您的 ConcurrentHashMap 上。

      【讨论】:

      • -1,恐怕:问题是关于集合图,同时确保对它的访问是明智的。你的建议对此没有帮助。他们会给你一个安全的地图,或者一个安全的集合,但在询问者的原始实现中仍然存在并发问题。
      • 我的错。但是,肯定将 ConcurrentHashSet 放在 ConcurrentHashMap 中会提供解决方案吗?如果两者都是“安全”对象,那么肯定会为您提供集合图,这本质上是安全的吗?还是我在这里错过了什么?
      • abahgat 和 JB Nizet 比我能更好地解释这一点。所有并发类保证对象上的单个操作是线程安全的。仍然存在可能不安全的操作组合。考虑两个线程都在做if (!map.containsKey(a)) map.put(a, b); 的简单情况。两个线程都可以执行map.containsKey(a) 并获得false,然后继续尝试在映射中添加一个条目;一个会覆盖另一个的条目。
      • 理解一个非常重要的事情是仅仅使用线程安全类不会导致线程安全代码!您还必须确保将构建块安全地放在一起。
      • 我现在明白了——仅仅因为类是线程安全的,并不意味着操作本身就一定是线程安全的。为解释干杯,你说得非常清楚!
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-12-09
      • 2014-10-18
      • 1970-01-01
      相关资源
      最近更新 更多