为什么缓存元素不会立即过期?

Why don't cache elements expire promptly?

我有我认为简单的使用Guava缓存。但是,这种行为对我来说并不直观。我有一个 POJO,Foo,属性为 Id (Integer)。在检索 Foo 的实例时,我使用 Integer 作为缓存的键。如果我将三个项目放入缓存中,并休眠足够长的时间以使所有内容都过期,那么无论键值如何,我都希望有相同的行为。问题是我根据使用的密钥看到了不同的行为。我将三个对象放入缓存:1000、2000 和 3000。

[main] INFO CacheTestCase - 3000 creating foo, 1000
[main] INFO CacheTestCase - 3000 creating foo, 2000
[main] INFO CacheTestCase - 3000 creating foo, 3000
[main] INFO CacheTestCase - 3000 Sleeping to let some cache expire . . .
[main] INFO CacheTestCase - 3000 Continuing . . .
[main] INFO CacheTestCase - 3000 Removed, 1000
[main] INFO CacheTestCase - 3000 Removed, 2000
[main] INFO CacheTestCase - 3000 creating foo, 1000
[main] INFO CacheTestCase - 

请注意,在上面的 运行 中,键为 3000 的 Foo 实例并未从缓存中删除。下面是相同代码的输出,但我使用的不是密钥 3000,而是 4000。

[main] INFO CacheTestCase - 4000 creating foo, 1000
[main] INFO CacheTestCase - 4000 creating foo, 2000
[main] INFO CacheTestCase - 4000 creating foo, 4000
[main] INFO CacheTestCase - 4000 Sleeping to let some cache expire . . .
[main] INFO CacheTestCase - 4000 Continuing . . .
[main] INFO CacheTestCase - 4000 Removed, 1000
[main] INFO CacheTestCase - 4000 Removed, 2000
[main] INFO CacheTestCase - 4000 Removed, 4000
[main] INFO CacheTestCase - 4000 creating foo, 1000

当然,我做了一些非常愚蠢的事情。这是我的 MCVE:

package org.dlm.guava;

import com.google.common.cache.*;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.TimeUnit;

/**
 * Created by dmcreynolds on 8/17/2015.
 */
public class CacheTestCase {
    static final Logger log = LoggerFactory.getLogger("CacheTestCase");
    String p = ""; // just to make the log messages different
    int DELAY = 10000; // ms
    @Test
    public void testCache123() throws Exception {
        p = "3000";
        LoadingCache<Integer, Foo> fooCache = CacheBuilder.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(100, TimeUnit.MILLISECONDS)
                .removalListener(new FooRemovalListener())
                .build(
                        new CacheLoader<Integer, Foo>() {
                            public Foo load(Integer key) throws Exception {
                                return createExpensiveFoo(key);
                            }
                        });

        fooCache.get(1000);
        fooCache.get(2000);
        fooCache.get(3000);
        log.info(p + " Sleeping to let some cache expire . . .");
        Thread.sleep(DELAY);
        log.info(p + " Continuing . . .");
        fooCache.get(1000);
    }


    private Foo createExpensiveFoo(Integer key) {
        log.info(p+" creating foo, " + key);
        return new Foo(key);
    }


    public class FooRemovalListener
        implements RemovalListener<Integer, Foo> {
        public void onRemoval(RemovalNotification<Integer, Foo> removal) {
            removal.getCause();
            log.info(p+" Removed, " + removal.getKey().hashCode());
        }
    }

    /**
     * POJO Foo
     */
    public class Foo {
        private Integer id;

        public Foo(Integer newVal) {
            this.id = newVal;
        }

        public Integer getId() {
            return id;
        }
        public void setId(Integer newVal) {
            this.id = newVal;
        }
    }
}

来自 CacheBuilder 的 Javadoc:

If expireAfterWrite or expireAfterAccess is requested entries may be evicted on each cache modification, on occasional cache accesses, or on calls to Cache.cleanUp(). Expired entries may be counted by Cache.size(), but will never be visible to read or write operations.

要说的一件事是,一旦过期,如果您尝试阅读任何过期的条目,您会发现它们不再存在。因此,例如,尽管您在 RemovalListener 中没有看到 3000 的条目被删除,但如果您调用 fooCache.get(3000),它必须首先加载该值(并且那时你会看到旧值被删除了)。所以从缓存用户的角度来看API,旧的缓存值已经消失了。

您在示例中看到特定行为的原因非常简单:出于并发原因,缓存 分段 。条目根据它们的哈希码分配一个段,每个段就像一个小的独立缓存。所以大多数操作(例如fooCache.get(1000))只会对单个段进行操作。在您的示例中,10002000 显然分配给了同一段,而 3000 则在另一个段中。 4000,在您的第二个版本中,被分配到与 10002000 相同的段,因此在为 1000 发生了。

在大多数实际使用中,段通常应该足够频繁地被命中,以便定期清理过期的条目,这不会成为问题。但是,除非您在缓存上调用 cleanUp(),否则无法保证 何时 会发生。

超时不会立即进行维护。

来自 documentation(强调我的):

When Does Cleanup Happen?

Caches built with CacheBuilder do not perform cleanup and evict values "automatically," or instantly after a value expires, or anything of the sort. Instead, it performs small amounts of maintenance during write operations, or during occasional read operations if writes are rare.

The reason for this is as follows: if we wanted to perform Cache maintenance continuously, we would need to create a thread, and its operations would be competing with user operations for shared locks. Additionally, some environments restrict the creation of threads, which would make CacheBuilder unusable in that environment.

Instead, we put the choice in your hands. If your cache is high-throughput, then you don't have to worry about performing cache maintenance to clean up expired entries and the like. If your cache does writes only rarely and you don't want cleanup to block cache reads, you may wish to create your own maintenance thread that calls Cache.cleanUp() at regular intervals.

If you want to schedule regular cache maintenance for a cache which only rarely has writes, just schedule the maintenance using ScheduledExecutorService.

如果在您的系统中及时进行清理很重要,那么这些解决方案中的任何一个都应该适合您。


无关,您可能已经知道这一点,但我希望您没有使用原始类型声明所有缓存类型。最好用完全参数化的 <Integer, Foo> 类型来指定它们,以防止 heap pollution.

的风险