如何在Java中创建资源时正确地进行锁定

4

我有一个单例模式用来创建对象,目前它的代码如下:

public ApplicationManagerSingleton {
    ....
    private Map<String, Thing> map = new HashMap<String, Thing>();

    public Thing getThingById( String id ) {
       Thing t = null;
       if ( !map.contains(id) ) {
        t = longAndCostlyInitializationOfThing();
        map.put(id, t );
       }
       return map.get(id);
     }
 }

它显而易见的问题是,如果两个线程尝试访问同一件事物,它们可能会复制该事物。

因此我使用了锁:

 public ApplicationManagerSingleton {
      private Map<String, Thing> map = new HashMap<Sring, Thing>();
      public Thing getThingById(String id ) {
          synchronized( map ) {
             if (!map.contains(id)) {
                 t = initialize....
             }
             map.put(id, t);
           }
           returns map.get(id);
      }
 }

但现在情况更糟,因为每次创建新资源时,我都会将地图锁定一段时间,这会对想要不同内容的其他线程造成不利影响。

我相信使用Java 5并发包可以更好地解决这个问题。有人能指点我正确的方向吗?

我想避免为其他对不同内容感兴趣的线程锁定类或地图。

6个回答

3
如果您想要尽可能地避免创建多个项目,同时又不会阻塞太多操作,我建议使用两个映射表。其中一个映射表用于存储创建对象时使用的锁集合,另一个用于存储对象本身。
可以考虑如下代码实现:
private ConcurrentMap<String, Object> locks = new ConcurrentHashMap<String, Object>();
private ConcurrentMap<String, Thing> things = new ConcurrentHashMap<String, Thing>();

public void Thing getThingById(String id){
    if(!things.containsKey(id)){        
      locks.putIfAbsent(id, new Object());
      synchronized(locks.get(id)){
         if (!things.containsKey(id)){
             things.put(id, createThing());
         }
      }
    }

    return things.get(id);
}

这将仅阻止多个线程尝试获取相同键,同时防止为相同键创建两次Thing

更新

Guava Cache示例已移至新答案。


我正好想到了这样的东西... 这是我的: https://gist.github.com/4454732 - OscarRyz
伟大的思想。我认为使用putIfAbsent有助于减少对锁映射的调用。 - John B
我以为那是伪代码哈哈..看着它 :) - OscarRyz
1
不要忘记在锁内进行地图双重检查。 - John B
拥有多个答案是不被鼓励的。是否有充分的理由将它们分开? - John B
显示剩余7条评论

3
也许ConcurrentHashMap可以帮助你。顾名思义,它支持并发修改。
要仅创建一个新元素,您可以执行以下操作:
private Map<String,Thing> map = new ConcurrentHashMap<>();
private final Object lock = new Object();
public Thing getById(String id) {
  Thing t = map.get(id);
  if (t == null) {
    synchronized(lock) {
      if (!map.containsKey(id)) {
        t = //create t
        map.put(id, t);
      }
    }
  }
  return t;
}

一次只允许一个线程创建新的内容,但对于现有值没有任何锁定。

如果您想完全避免锁定,您需要使用两个映射表,但这会变得有些复杂,只有在您真正希望许多线程不断填充映射表时才值得这样做。对于这种情况,最好使用FutureTasks和线程池一起异步创建对象,最小化锁定时间(仍然需要锁定以便只有一个线程创建新元素)。

代码如下:

private Map<String,Future<Thing>> map = new ConcurrentHashMap<>();
private final Object lock = new Object();
ExecutorService threadPool = ...;
public Thing getById(String id) {
  Future<Thing> t = map.get(id);
  if (t == null) {
    synchronized(lock) {
      if (!map.containsKey(id)) {
        Callable<Thing> c = //create a Callable that creates the Thing
        t = threadPool.submit(c);
        map.put(id, t);
      }
    }
  }
  return t.get();
}

锁定将仅在创建Callable、将其提交到线程池以获取Future并将该Future放入映射表所需的时间内生效。Callable将在线程池中创建元素,当它返回元素时,Future的get()方法将解锁并返回其值(对于任何正在等待的线程;后续调用不会再次进行锁定)。


那么,Future.get() 会阻塞线程直到该特定的 future 准备好? - OscarRyz
没错。Future.get() 会阻塞直到资源准备就绪,然后立即返回其值。 - Chochos
1
这在生产环境中对我造成了3-1毫秒的失败。更改为concurrentMap.putIfAbsent()调用可行。https://dev59.com/wGzXa4cB1Zd3GeqPX9Bt#14346757 - OscarRyz

2
经过调查,我认为GuavaLoadingCache可能是这个问题的一个非常好的解决方案。默认情况下,CacheBuilder将创建一个不执行任何逐出操作(因此它只是一个映射),并且在加载已经构建的键时具有线程阻塞功能。

LoadingCache

CacheBuilder

 private Cache<String, Thing> myCache;

 MyConstructor(){
    myCache = CacheBuilder.newBuilder().build(
       new CacheLoader<String, Thing>() {
         public Thing load(String key) throws AnyException {
           return createExpensiveGraph(key);
         }
        });
 }

  public void Thing getThingById(String id){
    return myCache.get(id);
  }

1

您可以立即在地图中插入一个轻量级代理。代理将在初始化后委托给真实对象,但在此之前会阻塞。一旦真正的Thing被初始化,它就可以替换地图中的代理。

private Map<String, Thing> map = new ConcurrentHashMap<>();

public Thing getThingById(String id) {
    ThingProxy proxy = null;
    synchronized (map) {
        if (!map.containsKey(id)) {
            proxy = new ThingProxy();
            map.put(id, proxy);
        }
    }
    if (proxy != null) {
        proxy.initialize();
        map.put(id, proxy.getRealThing());
    }
    return map.get(id);
}

private class ThingProxy implements Thing {

    private Thing realThing;
    private CountDownLatch countDownLatch = new CountDownLatch(1);

    @Override
    public void someMethodOfThing() {
        try {
            countDownLatch.await();
            realThing.someMethodOfThing();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public void initialize() {
        realThing = longAndCostlyInitializationOfThing();
        countDownLatch.countDown();
    }

    public Thing getRealThing() {
        return realThing;
    }
}

这会在地图上创建一个锁定,但仅短暂地创建代理并在需要时放置。

如果代理的代码变得冗长,那么您最好使用反射来创建代理(参见 java.lang.reflect.Proxy)。


你能否发布一些代码来演示你的想法?我不太明白。 - radai
我会的,请给我一些时间。 - bowmore
我在考虑沿着这条线路进行,但是将锁放在地图上,这样以下的线程只锁定那一部分... - OscarRyz
1
我的最终建议与此类似,但使用Futures而不是自定义代理。 - Chochos
这也是一个很好的想法。但你确实需要保留一个线程(或从池中获取一个)来初始化条目。我的方法只是让本来会等待的线程完成工作。 :) - bowmore

1
您可以使用 ConcurrentHashMap。这个类可以被多个线程同时访问而不会出现问题。

1
ConcurrentHashMap没有机制来确保您不会重复创建项目的工作。 - John B
你好!:) 但它并不能防止创建重复的资源,请参考:https://gist.github.com/4454674 - OscarRyz
嗨,Theo,你比我最初想的要接近得多。 - OscarRyz

0

我尝试了这些解决方案,但在我的实现过程中它们在某些时候失败了,不是说它们在其他情况下不起作用。

最终我采用了ConcurrentMap来检查资源是否已经被请求过。如果没有,就会创建并存储在其他地方。

... 
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ConcurrentHashMap;

... 
private ConcurrentMap<String, Boolean> created = new ConcurrentMap<>();
....
if ( created.putIfAbsent( id, Boolean.TRUE ) == null ) {
    somewhereElse.put( id, createThing() );
}

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