Java中的多线程对象→对象缓存映射?

4
我想要一个Java集合,具有以下特点:
  • 将任意的Object映射到Object(不仅限于String或其他受限制的键)
  • 将用作缓存;如果键不在缓存中,则会计算值(这不必内置到集合中)
  • 将从多个线程同时访问
  • 将永远不会从中删除项目
  • 必须非常高效地读取(缓存命中);写入时不一定高效(缓存未命中)

如果多个线程同时导致缓存未命中,则会产生冗余计算,这是可以接受的。典型情况是缓存最初由一个线程填充。

在线程不安全的哈希表周围使用synchronized块无法满足高效读取的标准。线程本地缓存很简单,但意味着新线程很昂贵,因为它们具有缓存的完整副本。

Java 1.5内置函数或我们可以复制到我们的MIT许可项目中的一个或少量类文件优先考虑,而不是大型外部库。

4个回答

8

使用Java并发哈希表

ConcurrentHashMap<object, object> table;

public object getFromCache(object key)
{
    value = table.get(key);

    if (value == null)
    {
        //key isn't a key into this table, ie. it's not in the cache
        value = calculateValueForKey(key)
        object fromCache = table.putIfAbsent(key, value);
    }

    return value;
}

/**
* This calculates a new value to put into the cache
*/
public abstract object calculateValueForKey(object key);

注意:这不再是一个多线程缓存的通用解决方案,因为它依赖于对象是不可变的这一事实,因此对象等价性并不重要。


编辑了三次,终于正确了;) 根据您的CalculateValueForKey方法的实现方式,即使在写入时也应该非常快(并且永远不会阻塞)。 - Martin
修复了一个小问题,使用putIfAbsent,并现在改用递归。如果您有堆栈溢出的恐惧,可以将value == null部分轻松地转换为循环 :) - Martin
是的,这使得它看起来稍微复杂了一些。我只是基于这篇文章提出建议 http://dmy999.com/article/34/correct-use-of-concurrenthashmap - John Vint
这看起来不错,但是:(1)我认为我根本不需要fromCache,因为我不在乎缓存是否提供答案的不同副本(缓存值是不可变的,并且对象身份无关紧要)。 (2)你为什么在(value == null)周围使用while循环?我认为如果值最初为null,则假定toCache为非null,则赋值的值必须为非null,因此循环最多只执行一次,并且可以是一个if。我错过了什么吗? - Kevin Reid
不是一个很大的问题;在我的实际应用中,键是Java类,所以除了使用类加载器可以做的事情之外,没有太多问题。但是,是的,弱键会有帮助,就像 http://google-collections.googlecode.com/svn/trunk/javadoc/com/google/common/collect/MapMaker.html 提供的那样。 - Kevin Reid
显示剩余6条评论

2
这个问题可以参考我其中一个项目中的SingletonCache类,可能会有所帮助。
public abstract class SingletonCache<K, V> {

    private final ConcurrentMap<K, V> cache = new ConcurrentHashMap<K, V>();

    public V get(K key) {
        V value = cache.get(key);
        if (value == null) {
            cache.putIfAbsent(key, newInstance(key));
            value = cache.get(key);
        }
        return value;
    }

    protected abstract V newInstance(K key);
}

要使用它,您需要扩展它并实现newInstance方法,该方法在缓存未命中时创建新值。然后调用get方法并提供一个键来获取与该键对应的实例。这里是一个使用示例的链接
该类保证为每个键只返回一个实例,但可以多次调用newInstance方法,此时将使用第一次计算的实例,并且其余实例将被丢弃。还要注意,此缓存不会删除旧实例,而是无限期存储所有值(在我的用例中,必须缓存有限数量的实例)。从ConcurrentHashMap中读取不使用锁定,因此应满足您的效率要求。

2
这是我的解决方案,但我不是一个熟练的线程编程专家,所以请根据您的判断进行评论/投票/比较其他答案。
使用线程局部变量(java.lang.ThreadLocal),其中包含每个线程使用的哈希表作为第一级缓存。如果在该表中未找到键,则对第二级缓存进行同步访问,这是所有线程共享的 synchronized -access哈希表。通过这种方式,仅计算缓存值一次,并且它在所有线程之间共享,但每个线程都有键到值映射的本地副本,因此存在一些内存成本(但比具有独立的每个线程缓存的成本要低),但读取效率高。

1
这是一个可以的解决方案,但使用Java并发哈希映射实现(请参见我的答案)同样简单易行,并且性能更好(并且消耗的内存明显更少)。 - Martin

1

关于Java并发实战(作者:Brian Goetz)第5.6节中提到的缓存,您有什么想法吗?这里有详细描述

它只使用了java.util.concurrent包中的类。

该文章构建了一个缓存,并描述了每个版本的弱点,最终版本是一个高效的缓存,在其中只有一个并发线程将计算一个缺失的条目。

我已经复制并粘贴了最终代码,但值得阅读全文并思考所概述的问题。甚至更好的做法是购买该书——它非常优秀。

import java.util.concurrent.*;

public class Memoizer<A, V> implements Computable<A, V> {
  private final ConcurrentMap<A, Future<V>> cache
      = new ConcurrentHashMap<A, Future<V>>();
  private final Computable<A, V> c;
  public Memoizer(Computable<A, V> c) { this.c = c; }
  public V compute(final A arg) throws InterruptedException {
      while (true) {
          Future<V> f = cache.get(arg);
          if (f == null) {
              Callable<V> eval = new Callable<V>() {
                  public V call() throws InterruptedException {
                      return c.compute(arg);
                  } 
              };
              FutureTask<V> ft = new FutureTask<V>(eval);
              f = cache.putIfAbsent(arg, ft);
              if (f == null) {
                  f = ft;
                  ft.run();
              }
          }
          try {
              return f.get();
          } catch (CancellationException e) {
              cache.remove(arg, f);
          } catch (ExecutionException e) {
          // Kabutz: this is my addition to the code...
          try {
             throw e.getCause();
          } catch (RuntimeException ex) {
              throw ex;
          } catch (Error ex) {
              throw ex;
          } catch (Throwable t) {
              throw new IllegalStateException("Not unchecked", t);
          }
        }
     }
  }
}

我们不需要确保值仅计算一次,因此听起来我们可以只使用ConcurrentHashMap,而不必担心同时缓存未命中的冗余计算。有任何理由不这样做吗? - Kevin Reid
我上面的解决方案是基于这样一个假设:您不需要担心计算两次相同的值 :) - Martin
1
好的 - 明白了。链接的文章也讲解了那种情况。此外,问题没有说明缓存必须明确允许并发计算。因此,我假设停止它们的解决方案也是可接受的。我想我不应该发布代码。也许应该考虑谁将使用缓存。并发代码非常难以理解,一个可能会计算两次条目的缓存可能会被一个经验不足的程序员在绝对不希望计算条目两次的情况下使用。 - A_M

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