在一个整数值上进行同步

37
假设我想根据一个整数ID值进行锁定。在这种情况下,有一个函数从缓存中获取一个值,并且如果该值不存在,则进行相当昂贵的检索/存储操作。
现有的代码没有同步,可能会触发多个检索/存储操作。
//psuedocode
public Page getPage (Integer id){
   Page p = cache.get(id);
   if (p==null)
   {
      p=getFromDataBase(id);
      cache.store(p);
   }
}

我想要做的是根据id进行同步检索,例如。
   if (p==null)
   {
       synchronized (id)
       {
        ..retrieve, store
       }
   }

很遗憾,这不会起作用,因为两个独立的调用可以具有相同的整数id值,但是不同的整数对象,所以它们不会共享锁,并且不会发生同步。
有没有一种简单的方法来确保您拥有相同的整数实例?例如,这样会起作用吗?
 syncrhonized (Integer.valueOf(id.intValue())){

Integer.valueOf()的javadoc似乎暗示你可能会得到相同的实例,但这并不是一个保证:
返回一个表示指定int值的Integer实例。如果不需要新的Integer实例,通常应该优先使用这个方法而不是构造函数Integer(int),因为这个方法通过缓存经常请求的值,很可能在空间和时间性能上得到显著的改善。
所以,除了像保持一个以int为键的WeakHashMap的Lock对象的更复杂的解决方案之外,你有没有关于如何获得一个保证相同的Integer实例的建议?(这种解决方案没有问题,只是似乎有一个明显的一行代码的解决方案我没有注意到)。

4
这个“重复”的问题和下面 Eddie 的回答比“原始”的问题更好! - arun
请参见https://dev59.com/5lnUa4cB1Zd3GeqPWgOJ,特别是https://dev59.com/5lnUa4cB1Zd3GeqPWgOJ#27806218。 - rogerdpack
使用 AtomicInteger 怎么样? - GregT
FYI,Brian Goetz在他的Devoxx 2018演讲中提到,永远不要锁定Integer,具体时间为54:49 - Basil Bourque
9个回答

57

不建议使用 Integer 进行同步,因为您无法控制哪些实例是相同的,哪些实例是不同的。Java没有提供这样的功能(除非您在小范围内使用整数),并且在不同的JVM上也不能保证可靠性。如果您必须同步一个 Integer,则需要维护一个 MapSet,以确保您获得所需的确切实例。

更好的方式是创建一个新对象,并将其存储在以 Integer 为键的 HashMap 中进行同步。类似以下代码:

public Page getPage(Integer id) {
  Page p = cache.get(id);
  if (p == null) {
    synchronized (getCacheSyncObject(id)) {
      p = getFromDataBase(id);
      cache.store(p);
    }
  }
}

private ConcurrentMap<Integer, Integer> locks = new ConcurrentHashMap<Integer, Integer>();

private Object getCacheSyncObject(final Integer id) {
  locks.putIfAbsent(id, id);
  return locks.get(id);
}

为了解释这段代码,它使用了ConcurrentMap,允许使用putIfAbsent。您可以这样做:

  locks.putIfAbsent(id, new Object());
但是这样做会增加每次访问时创建一个对象的(小)成本。为了避免这种情况,我只是在Map中保存整数本身。这样做有什么好处?与直接使用整数有什么不同?
当您从Map中执行get()操作时,键将使用equals()进行比较(或者至少使用等效于使用equals()的方法)。具有相同值的两个不同整数实例将相互相等。因此,您可以传递任意数量的不同整数实例“new Integer(5)”作为参数传递给getCacheSyncObject,并且始终仅返回包含该值的首个传递实例。
存在不希望在Integer上同步的原因...如果多个线程在Integer对象上同步,并且因此在想要使用不同锁时无意中使用相同的锁,则可能陷入死锁。您可以通过使用
  locks.putIfAbsent(id, new Object());

通过这种方式,您可以确保此类将在其他任何类不会同步的对象上进行同步,从而每次访问缓存时产生(非常)小的成本。始终是一个好的事情。


@johnny:是的,我更新了我的答案以考虑到这一点。 - Eddie
1
这个映射会不断增长吗?在同步块的结尾处,是否有意义从映射中删除锁对象条目,并将其放置在finally块内? - emmby
如果您在同步块中删除锁对象,则会失去跨多个线程安全锁定的能力。只有当您不管理进入缓存的内容时,此映射才会无限增长。但这是一个单独的问题。 - Eddie
有一个地方看起来不太可靠。也就是在这一行 if (p == null) { 之后,另一个线程可能会先运行并将 Page 持久化到数据库中,因此再调用 cache.store(p); 可能没有意义,甚至会导致错误。 - Sergei Ledvanov
1
@SergeiLedvanov:是的,这是OP代码中存在的竞争条件。要使此代码线程安全,需要更改getPage()。一种选择是将Page p = cache.get(id);if移动到synchronized(getCacheSyncObject(id))块内部。您总是要付出同步的代价...但您将是线程安全的。 - Eddie
显示剩余7条评论

5

使用线程安全的映射,例如ConcurrentHashMap。这将允许您安全地操作映射,但使用不同的锁来进行真正的计算。通过这种方式,您可以拥有多个计算同时运行一个映射。

使用ConcurrentMap.putIfAbsent,但是不要放置实际值,而是使用计算轻量级的Future。可能是FutureTask实现。运行计算,然后获取结果,它将线程安全地阻塞直到完成。


4
Integer.valueOf() 只返回有限范围内的缓存实例。您没有指定您的范围,但一般来说,这种方法是不可行的。
然而,即使您的值在正确的范围内,我强烈建议您不要采用这种方法。由于这些缓存的 Integer 实例可供任何代码使用,您无法完全控制同步,这可能会导致死锁。这与试图在 String.intern() 的结果上进行锁定的问题相同。
最好的锁是私有变量。由于只有您的代码可以引用它,您可以保证不会发生死锁。
顺便说一句,使用 WeakHashMap 也不起作用。如果作为键的实例未被引用,它将被垃圾回收。如果它被强引用,您可以直接使用它。

3

在Integer上使用synchronized在设计上听起来非常错误。

如果你需要在检索/存储期间单独同步每个项目,你可以创建一个Set,并将当前锁定的项目存储在其中。换句话说,

// this contains only those IDs that are currently locked, that is, this
// will contain only very few IDs most of the time
Set<Integer> activeIds = ...

Object retrieve(Integer id) {
    // acquire "lock" on item #id
    synchronized(activeIds) {
        while(activeIds.contains(id)) {
            try { 
                activeIds.wait();   
            } catch(InterruptedExcption e){...}
        }
        activeIds.add(id);
    }
    try {

        // do the retrieve here...

        return value;

    } finally {
        // release lock on item #id
        synchronized(activeIds) { 
            activeIds.remove(id); 
            activeIds.notifyAll(); 
        }   
    }   
}

同样适用于商店。
重点是:没有一行代码可以完全按照您的需求解决此问题。

+1 我基本上喜欢这种方法。只有一个潜在的问题,就是我在下面的帖子中提到的,在高争用情况下,等待所有ID的线程会在任何一个ID可用时被唤醒。 - Neil Coffey
如果您没有太多的线程,这可能不是一个真正的问题。但如果是这种情况,您可以使用Object.notify()并使用Object.wait(maxTime)来确保没有调用者会被卡住。 - Antonio
1
同意,在低争用情况下可能不是太大的问题。不幸的是,我认为你提到的解决方案实际上可能会更糟糕...! - Neil Coffey
请记住,“真正的while(){wait...}争用”仅在获取锁时发生,而不是在加载/存储期间发生。这意味着,如果您有100个线程同时尝试获取锁,则无论如何都会存在争用。 - Antonio
此外,maxTime值只是为了避免潜在问题。但我看到了另一个问题:获取锁的公平性不足。在我看来,这可能真的是个问题,而不是你提到的那个。要解决所有情况的问题需要进行全面重写,我承认这一点。 - Antonio

1

Steve,

你提出的代码在同步方面存在一堆问题(Antonio 的代码也有相同的问题)。

总结如下:

  1. 你需要缓存一个昂贵的对象。
  2. 确保当一个线程正在检索一个对象时,另一个线程不会尝试检索同样的对象。
  3. n 线程都尝试获取对象,只有一个对象被检索和返回。
  4. 对于请求不同对象的线程,它们不会互相竞争。

伪代码实现此目标(使用 ConcurrentHashMap 作为缓存):

ConcurrentMap<Integer, java.util.concurrent.Future<Page>> cache = new ConcurrentHashMap<Integer, java.util.concurrent.Future<Page>>;

public Page getPage(Integer id) {
    Future<Page> myFuture = new Future<Page>();
    cache.putIfAbsent(id, myFuture);
    Future<Page> actualFuture = cache.get(id);
    if ( actualFuture == myFuture ) {
        // I am the first w00t!
        Page page = getFromDataBase(id);
        myFuture.set(page);
    }
    return actualFuture.get();
}

注意:

  1. java.util.concurrent.Future是一个接口
  2. java.util.concurrent.Future实际上没有set()方法,但可以查看实现Future接口的现有类来了解如何实现自己的Future(或使用FutureTask)
  3. 将实际检索推送到工作线程几乎肯定是个好主意。

1

使用Integer对象作为键的ConcurrentHashMap怎么样?


是的,就像我提到的那样,我可以使用某种映射<Integer,Object>,其中对象将是私有锁。 我更感兴趣的是是否有一种在Java中保证特定整数实例的方法。 - Steve B.

1
你可以查看这个代码来创建一个基于 ID 的互斥量。该代码是针对字符串 ID 编写的,但很容易进行 Integer 对象的编辑。

1
请参阅Java并发实践中的第5.6节:“构建高效、可扩展的结果缓存”。它涉及到您正在尝试解决的确切问题。特别是,请查看记忆化模式。 alt text (来源:umd.edu

1

从各种回答中可以看出,有多种方法来解决这个问题:

  • Goetz et al的方法在像这样的情况下,保留FutureTasks缓存的方法非常有效,在这种情况下,“无论如何都要缓存某些内容”,因此不介意建立FutureTask对象的映射(如果您介意映射增长,至少很容易使之并行化)。
  • 对于“如何锁定ID”的一般性答案,Antonio概述的方法具有一个优势,即当将锁的映射添加到/从其中删除时,这是显而易见的。

您可能需要注意Antonio的实现中可能存在的一个潜在问题,即notifyAll()将唤醒等待所有ID的线程,当其中一个可用时,这在高并发下可能无法很好地扩展。原则上,我认为您可以为每个当前被锁定的ID拥有一个Condition对象,然后等待/信号就由它控制。当然,如果实际上很少有多个ID同时被等待,那么这不是问题。


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