实现LRU缓存的最佳方法

3

我想创建一个高效的LRU缓存实现。我发现最方便的方法是使用LinkedHashMap,但如果有许多线程正在使用缓存,它会变得相当慢。我的实现在这里:

/**
 * Class provides API for FixedSizeCache.
 * Its inheritors represent classes         
 * with concrete strategies     
 * for choosing elements to delete
 * in case of cache overflow. All inheritors
 * must implement {@link #getSize(K, V)}. 
 */
public abstract class FixedSizeCache <K, V> implements ICache <K, V> {
    /**
     * Current cache size.
     */
    private int currentSize;


    /**
     *  Maximum allowable cache size.
     */
    private int maxSize;


    /**
     * Number of {@link #get(K)} queries for which appropriate {@code value} was found.
     */
    private int keysFound;


    /**
     * Number of {@link #get(K)} queries for which appropriate {@code value} was not found.
     */
    private int keysMissed;


    /** 
     * Number {@code key-value} associations that were deleted from cache
     * because of cash overflow.
     */
    private int erasedCount; 


    /**
     * Basic data structure LinkedHashMap provides
     * convenient way for designing both types of cache:
     * LRU and FIFO. Depending on its constructor parameters
     * it can represent either of FIFO or LRU HashMap.
     */
    private LinkedHashMap <K, V> entries;


    /** 
     * If {@code type} variable equals {@code true}
     * then LinkedHashMap will represent LRU HashMap.
     * And it will represent FIFO HashMap otherwise.
     */ 
    public FixedSizeCache(int maxSize, boolean type) {

        if (maxSize <= 0) {
            throw new IllegalArgumentException("int maxSize parameter must be greater than 0");
        }

        this.maxSize = maxSize;
        this.entries = new LinkedHashMap<K, V> (0, 0.75f, type);
    }


    /** 
     * Method deletes {@code key-value} associations 
     * until current cache size {@link #currentSize} will become 
     * less than or equal to maximum allowable
     * cache size {@link #maxSize}
     */
    private void relaxSize()  {

        while (currentSize > maxSize) {

             // The strategy for choosing entry with the lowest precedence
             // depends on {@code type} variable that was used to create  {@link #entries} variable. 
             // If it was created with constructor LinkedHashMap(int size,double loadfactor, boolean type)
             // with {@code type} equaled to {@code true} then variable {@link #entries} represents
             // LRU LinkedHashMap and iterator of its entrySet will return elements in order
             // from least recently used to the most recently used.
             // Otherwise, if {@code type} equaled to {@code false} then {@link #entries} represents
             // FIFO LinkedHashMap and iterator will return its entrySet elements in FIFO order -
             // from oldest in the cache to the most recently added.

            Map.Entry <K, V> entryToDelete = entries.entrySet().iterator().next();

            if (entryToDelete == null) {
                throw new IllegalStateException(" Implemented method int getSize(K key, V value) " +
                        " returns different results for the same arguments.");  
            }

            entries.remove(entryToDelete.getKey());
            currentSize -= getAssociationSize(entryToDelete.getKey(), entryToDelete.getValue());
            erasedCount++;
        }

        if (currentSize < 0) {
            throw new IllegalStateException(" Implemented method int getSize(K key, V value) " +
                    " returns different results for the same arguments.");
        }
    }


    /** 
     * All inheritors must implement this method
     * which evaluates the weight of key-value association.
     * Sum of weights of all key-value association in the cache
     * equals to {@link #currentSize}.  
     * But developer must ensure that
     * implementation will satisfy two conditions:
     * <br>1) method always returns non negative integers;
     * <br>2) for every two pairs {@code key-value} and {@code key_1-value_1}
     * if {@code key.equals(key_1)} and {@code value.equals(value_1)} then 
     * {@code getSize(key, value)==getSize(key_1, value_1)};
     * <br> Otherwise cache can work incorrectly.
     */
    protected abstract int getSize(K key, V value);


    /** 
     * Helps to detect if the implementation of {@link #getSize(K, V)} method
     * can return negative values. 
     */
    private int getAssociationSize(K key, V value)  {

        int entrySize = getSize(key, value);

        if (entrySize < 0 ) {
            throw new IllegalStateException("int getSize(K key, V value) method implementation is invalid. It returned negative value.");
        }

        return entrySize;
    }


   /**
    * Returns the {@code value} corresponding to {@code key} or
    * {@code null} if  {@code key} is not present in the cache. 
    * Increases {@link #keysFound} if finds a corresponding {@code value}
    * or increases {@link #keysMissed} otherwise. 
    */
    public synchronized final V get(K key)  {

        if (key == null) {
            throw new NullPointerException("K key is null");
        }

        V value = entries.get(key);
        if (value != null) {
            keysFound++;
            return value;
        }

        keysMissed++;
        return value;
    }


    /** 
     * Removes the {@code key-value} association, if any, with the
    *  given {@code key}; returns the {@code value} with which it
    *  was associated, or {@code null}.
    */
    public synchronized final V remove(K key)  {

        if (key == null) {
            throw new NullPointerException("K key is null");
        }

        V value = entries.remove(key);

        // if appropriate value was present in the cache than decrease
        // current size of cache

        if (value != null) {
            currentSize -= getAssociationSize(key, value);
        }

        return value;
    }


   /**
    * Adds or replaces a {@code key-value} association.
    * Returns the old {@code value} if the
    * {@code key} was present; otherwise returns {@code null}.
    * If after insertion of a {@code key-value} association 
    * to cache its size becomes greater than
    * maximum allowable cache size then it calls {@link #relaxSize()} method which
    * releases needed free space. 
    */
    public synchronized final V put(K key, V value)  {

        if (key == null || value == null) {
            throw new NullPointerException("K key is null or V value is null");
        }

        currentSize += getAssociationSize(key, value);      
        value = entries.put(key, value);

        // if key was not present then decrease cache size

        if (value != null) {
            currentSize -= getAssociationSize(key, value);
        }

        // if cache size with new entry is greater
        // than maximum allowable cache size
        // then get some free space

        if (currentSize > maxSize) {
            relaxSize();
        }

        return value;
    }


    /**
     * Returns current size of cache. 
     */
    public synchronized int currentSize() {
        return currentSize;
    }


    /** 
     * Returns maximum allowable cache size. 
     */ 
    public synchronized int maxSize() {
        return maxSize;
    }


    /** 
     * Returns number of {@code key-value} associations that were deleted
     * because of cache overflow.   
     */
    public synchronized int erasedCount() {
        return erasedCount;
    }


    /** 
     * Number of {@link #get(K)} queries for which appropriate {@code value} was found.
     */
    public synchronized int keysFoundCount() {
        return keysFound;
    }


    /** 
     * Number of {@link #get(K)} queries for which appropriate {@code value} was not found.
     */
    public synchronized int keysMissedCount() {
        return keysMissed;
    }


    /**
     * Removes all {@code key-value} associations
     * from the cache. And turns {@link #currentSize},
     * {@link #keysFound}, {@link #keysMissed} to {@code zero}.  
     */
    public synchronized void clear() {
        entries.clear();
        currentSize = 0;
        keysMissed = 0;
        keysFound = 0;
        erasedCount = 0;
    }


    /**
     * Returns a copy of {@link #entries}
     * that has the same content.
     */
    public synchronized LinkedHashMap<K, V> getCopy() {
        return new LinkedHashMap<K, V> (entries);
    }
}

如果我们有许多线程试图调用get()方法,由于同步的原因,这种实现速度会非常慢。有更好的方法吗?


1
你可以使用 ConcurrentHashMap 代替吗? - Rob I
1
如果你没有进行测量,那么你只是在猜测。你能提供一些数字来证明它太慢了吗?你需要它跑得多快? - Peter Lawrey
1
你确定 LHM 是问题所在吗?看起来你增加的代码导致整个程序变得更慢了。 - Peter Lawrey
1
@AndreyYaskulsky 如果你没有针对特定程序提供某些具体证据,仅凭数组和映射在快速搜索方面的性能差异来解释程序未能达到其性能目标,我会认为这是猜测。 - Patricia Shanahan
您可以在以下链接中找到可行的解决方案: https://dev59.com/XGw15IYBdhLWcg3wy-vO - vaquar khan
显示剩余3条评论
5个回答

5
我不知道这是否有益,但如果您可以用ConcurrentHashMap替换您的LinkedHashMap,那么您将提高吞吐量——ConcurrentHashMap使用分片来允许多个同时读取和写入。它也是线程安全的,因此您不需要同步您的读者和写者。
除此之外,请用ReadWriteLock替换您对synchronized关键字的使用。这将允许多个同时的读者。

Java中的标准R/W锁非常糟糕,因为它会写入元数据并展示共享,所以性能不佳。您可以使用带有accessorder的LHM和R/W锁,因为读取操作会改变映射。 - bestsss

4

尽量不要重新实现已经存在的功能:Guava Caches。它几乎拥有你所需要的所有功能:基于大小的清除、并发、权重设置。如果符合你的需求,请使用它。如果不符合,那么尝试自己实现,但始终要先进行评估(在我看来)。这只是一条建议。


LinkedHashMap已经构建完成,但Guava尚未构建。使用LHM进行重新实现的方式是什么? - Peter Lawrey
我同意,但他添加的代码,无论如何都必须添加,因为Guava也不会考虑对象的大小。 - Peter Lawrey
对象的大小是什么意思? - Nándor Krácser
他正在为每个条目调用 getSize(K key, V value); 方法。 - Peter Lawrey
1
我明白了,但这个也在Guava中实现了:http://docs.guava-libraries.googlecode.com/git/javadoc/com/google/common/cache/Weigher.html - Nándor Krácser
显示剩余2条评论

1
你需要运行这样的性能测试。
Map<Object, Object> map = Collections.synchronizedMap(new LinkedHashMap<Object, Object>(16, 0.7f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        return size() > 1000;
    }
});
Integer[] values = new Integer[10000];
for (int i = 0; i < values.length; i++)
    values[i] = i;

long start = System.nanoTime();
for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < values.length; j++) {
        map.get(values[j]);
        map.get(values[j / 2]);
        map.get(values[j / 3]);
        map.get(values[j / 4]);
        map.put(values[j], values[j]);
    }
}
long time = System.nanoTime() - start;
long rate = (5 * values.length * 1000) * 1000000000L / time;
System.out.printf("Performed get/put operations at a rate of %,d per second%n", rate);

在我的2.5 GHz i5笔记本电脑上打印

Performed get/put operations at a rate of 27,170,035 per second

你需要多少百万次操作每秒?

你没有测试争用(这是最糟糕的地方),而且你使用了最便宜的hashCode和equals。锁将被偏向(或在测试中完全删除)。不得不说:这是一个糟糕的微基准测试。读写锁也不好,不能用于访问顺序。 - bestsss
不需要测试很多东西,但如果您整天只访问同一张地图,则应该只使用一个线程。多个线程只会更慢。只有 OP 知道实际使用情况是什么样子的。我的观点是,您必须测试您的场景,才能对性能有任何想法。 - Peter Lawrey

1
如前所述,问题的主要原因是在LRU算法中更新共享数据结构。为了克服这个问题,您可以使用分区或者使用另一种LRU算法。现代算法比LRU表现更好。请参阅cache2k基准测试页面上关于该主题的比较。
cache2k的驱逐实现CLOCK和CLOCK-Pro具有完全的读并发性而无需锁定。

0

LRU算法本身涉及对共享结构的独占修改。因此,争用是必然的,你无法做太多事情。

如果你不需要严格的LRU算法,并且可以容忍一些驱逐策略的不一致性,那么这个东西看起来更加明亮。你的条目(值包装器)需要一些使用统计信息,并且需要基于所述使用统计信息的过期策略。

然后,你可以基于ConcurrentSkipListMap(即你可以将其视为数据库索引)拥有类似LRU的结构,当缓存即将过期时,使用该索引并根据它来过期元素。你需要进行双重检查等操作,但这是可行的。更新索引并不是免费的,但是可以很好地扩展。请记住,ConcurrentSkipListMap.size()是一个昂贵的O(n)操作,因此你不应该依赖它。实现并不难,但也不是微不足道的,除非你有足够的争用(核心),否则同步(LHM)可能是最简单的方法。


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