Java的WeakHashMap和缓存:为什么它引用键而不是值?

75

Java的WeakHashMap通常被认为是很有用的缓存工具。但奇怪的是,它的弱引用是按照映射的键而不是值来定义的。我的意思是,我想要缓存的是值,并且只有当除了缓存之外没有其他人强烈地引用它们时,我才想让它们被垃圾回收掉,对吧?

持有键的弱引用有什么帮助呢?如果你使用ExpensiveObject o = weakHashMap.get("some_key"),那么我希望缓存会保存'o'直到调用者不再持有强引用,而我完全不关心字符串对象"some_key"。

我错过了什么吗?


1
Java API充斥着奇怪的怪癖。您可以始终使用WeakReference重写WeakHashMap。 - Pacerier
5个回答

127

WeakHashMap通常不适用作缓存,至少大多数人所认为的那样。正如您所说,它使用弱键而不是弱值,因此它不是为大多数人想要使用它的方式设计的(事实上,我曾经看到有人错误地使用它)。

WeakHashMap主要用于保留关于您不控制生命周期的对象的元数据。例如,如果有一堆对象通过您的类,并且您想在不需要被通知它们作用域结束的情况下跟踪有关它们的额外数据,同时您对它们的引用不会使其继续存在。

一个简单的例子(我以前用过的例子)可能是:

WeakHashMap<Thread, SomeMetaData>

你可以使用WeakHashMap来跟踪系统中各个线程的操作;当线程终止时,条目将被静默地从你的地图中删除,并且如果你是最后一个引用它的对象,则不会阻止Thread被垃圾回收。然后,你可以迭代这个Map中的条目,了解有关系统中活动线程的元数据。

有关更多信息,请参见WeakHashMap不是缓存!

对于你所需要的缓存类型,可以使用专门的缓存系统 (例如 EHCache),或者查看 Guava MapMaker 类 。例如:

new MapMaker().weakValues().makeMap();

这将实现您想要的功能,或者如果您想变得更加高级,可以添加定时过期:

new MapMaker().weakValues().expiration(5, TimeUnit.MINUTES).makeMap();

4
仅针对2013年8月的更新:Google Collections现在更名为Guava,缓存创建逻辑现在是CacheBuilder类的一部分。 - Matthew Phillips
更精确的链接:http://docs.guava-libraries.googlecode.com/git-history/release/javadoc/com/google/common/cache/CacheBuilder.html - Matt R
1
注意,在MapMaker的示例中,我认为你应该说new MapMaker().softValues().makeMap(),因为调用weakValues()会给你与WeakHashMap相同的结果。这里有一个很好的例子,展示了如何使用MapMaker构建缓存 - https://dev59.com/X1DTa4cB1Zd3GeqPKIwP - jklp
1
jklp - 不,weakValues() 和 WeakHashMap 根本不一样。weakKeys() 才和 weak hash map 一样。WeakHashMap 对其值保持强引用!同意对于大多数情况而言,软引用可能比弱引用更有意义;然而,这是一个程度问题(软引用可能会被保留,只要没有内存压力;弱引用将在下一个 GC 周期中被丢弃),语义上并没有区别。 - Cowan
2
链接已经失效。 - Christopher
显示剩余3条评论

40
WeakHashMap 的主要用途是当你希望某些映射随着它们的键消失时也消失。而缓存则相反,当值消失时,你希望映射也会消失。

对于缓存来说,你需要一个 Map<K,SoftReference<V>>。一个SoftReference在内存紧张时会被垃圾回收掉(与WeakReference相反,后者可能在其引用物不再存在硬引用时立即被清除)。在缓存中,你希望你的引用是软引用(至少在 key-value 映射不会过期的情况下),这样如果以后要查找这些值,则这些值仍有可能存在于缓存中。如果使用的是弱引用,那么你的值将立即被垃圾回收掉,从而打败了缓存的目的。

为方便起见,你可能希望隐藏你的 Map 实现中的 SoftReference 值,使得你的缓存看起来像是类型为 <K,V> 而不是 <K,SoftReference<V>>。如果你想这样做,这个问题中有一些可以在网络上找到的实现建议。

还要注意的是,当你在 Map 中使用 SoftReference 值时,你必须手动删除已经清除了其 SoftReferences 的键值对,否则你的 Map 将永远增长,并泄漏内存。


2
使用这种解决方案随着时间的推移会留下许多哈希映射项,其值已被垃圾回收。是否有一种类似的方法可以替代它? - android developer
2
一种使用 Map<K, SoftRereference<?>> 的方法会在 GC 运行后在地图中留下包含 null 引用的 SoftReference 实例。我认为这个映射的内部实现必须定期清除所有值为软引用且持有 null 引用的映射,以进行良好的清理。 - Timmos
5
严格来说,如果一个不太熟悉的程序员使用HashMap<K, SoftReference<V>>这种类型的实现,那么这会导致内存泄漏。你可能需要在回答中提到这一点。看看WeakHashMap是如何处理它的,在Oracle JDK中有一个名为expungeStaleEntries的私有方法来负责清理工作。 - Timmos
@Timmos,你的意思是obj实例内部JVM引用到SoftReference<V>会阻止收集吗?那么这就是需要修复的JVM bug。 - Pacerier
2
不,仅仅是因为被SoftReference引用的GC值仍然存在于地图中而没有任何目的,如果您未采取措施将这些SoftReferences从地图中删除,则会导致内存泄漏。 SoftReferences中包含的值最终将被GC处理,但是SoftReferences本身不会。这是程序员的任务。 - Timmos

9
你需要两个映射:一个将缓存键映射到弱引用值,另一个反过来将弱引用值映射到键。并且你需要一个引用队列和一个清理线程。
弱引用有能力在引用对象无法再次访问时将其移动到队列中。这个队列必须由清理线程清空。对于清理操作,有必要获取引用的键。这就是为什么需要第二个映射的原因。
下面的示例显示如何使用弱引用的哈希映射创建缓存。运行程序后,您会得到以下输出:
第一行显示了在删除对奇数列表的引用之前缓存的内容,第二行显示了在删除奇数之后的内容。
这是代码:
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class Cache<K,V>
{
    ReferenceQueue<V> queue = null;
    Map<K,WeakReference<V>> values = null;
    Map<WeakReference<V>,K> keys = null;
    Thread cleanup = null;

    Cache ()
    {
        queue  = new ReferenceQueue<V>();
        keys   = Collections.synchronizedMap (new HashMap<WeakReference<V>,K>());
        values = Collections.synchronizedMap (new HashMap<K,WeakReference<V>>());
        cleanup = new Thread() {
                public void run() {
                    try {
                        for (;;) {
                            @SuppressWarnings("unchecked")
                            WeakReference<V> ref = (WeakReference<V>)queue.remove();
                            K key = keys.get(ref);
                            keys.remove(ref);
                            values.remove(key);
                        }
                    }
                    catch (InterruptedException e) {}
                }
            };
        cleanup.setDaemon (true);
        cleanup.start();
    }

    void stop () {
        cleanup.interrupt();
    }

    V get (K key) {
        return values.get(key).get();
    }

    void put (K key, V value) {
        WeakReference<V> ref = new WeakReference<V>(value, queue);
        keys.put (ref, key);
        values.put (key, ref);
    }

    public String toString() {
        StringBuilder str = new StringBuilder();
        str.append ("{");
        boolean first = true;
        for (Map.Entry<K,WeakReference<V>> entry : values.entrySet()) {
            if (first)
                first = false;
            else
                str.append (", ");
            str.append (entry.getKey());
            str.append (": ");
            str.append (entry.getValue().get());
        }
        str.append ("}");
        return str.toString();
    }

    static void gc (int loop, int delay) throws Exception
    {
        for (int n = loop; n > 0; n--) {
            Thread.sleep(delay);
            System.gc(); // <- obstinate donkey
        }
    }

    public static void main (String[] args) throws Exception
    {
        // Create the cache
        Cache<String,List> c = new Cache<String,List>();

        // Create some values
        List odd = Arrays.asList(new Object[]{1,3,5});
        List even = Arrays.asList(new Object[]{2,4,6});

        // Save them in the cache
        c.put ("odd", odd);
        c.put ("even", even);

        // Display the cache contents
        System.out.println (c);

        // Erase one value;
        odd = null;

        // Force garbage collection
        gc (10, 10);

        // Display the cache again
        System.out.println (c);

        // Stop cleanup thread
        c.stop();
    }
}

3
很好的答案。值得注意的是,与许多其他类型的集合不同,ReferenceQueue会阻塞直到可以从queue.remove()返回一个值。这意味着清理线程不是一开始可能看起来像是无限循环而不等待的线程。 - Chris Hatton
@Gordon 如果您使用弱键和弱值,那么所有内容都是弱的,并且将在您将其添加到缓存后立即进行垃圾回收。 - ceving
这就是答案。因此,我们可以说,API的实现是为了优化清理阶段。 - Pacerier

8

另一个需要考虑的问题是,如果您采用Map<K, WeakReference<V>>方法,则值可能会消失,但映射不会。根据使用情况,您可能最终会得到一个包含许多已被GC'd的Weak References的条目的映射。


Map<K, V>,而不是Map<K, WeakReference<V>>。这个答案表面上看起来很有道理,但请注意,每次用户调用Map.get时,缺失的映射都可以被删除,而且由于这正是WeakHashMap删除键的方式,Java团队不可能没有意识到这一点。 - Pacerier
@Pacerier,您似乎在描述一种自定义的Map实现,当调用get方法并且发现WeakReference的引用为null时,会删除该条目。这仍然容易受到内存泄漏的影响,具体取决于使用方式,因为它依赖于对特定键的get调用。最好使用带有特定驱逐机制的缓存。 - Shannon

0

如果你需要弱引用,那么它其实非常容易:

public final class SimpleCache<K,V> {
    private final HashMap<K,Ref<K,V>> map = new HashMap<>();
    private final ReferenceQueue<V> queue = new ReferenceQueue<>();
    
    private static final class Ref<K,V> extends WeakReference<V> {
        final K key;
        
        Ref(K key, V value, ReferenceQueue<V> queue) {
            super(value, queue);
            this.key = key;
        }
    }
    
    private synchronized void gc() {
        for (Ref<?,?> ref; (ref = (Ref<?,?>)queue.poll()) != null;)
            map.remove(ref.key, ref);
    }
    
    public synchronized V getOrCreate(K key, Function<K,V> creator) {
        gc();
        Ref<K,V> ref = map.get(key);
        V v = ref == null ? null : ref.get();
        if (v == null) {
            v = Objects.requireNonNull(creator.apply(key));
            map.put(key, new Ref<>(key, v, queue));
        }
        return v;
    }
    
    public synchronized void remove(K key) {
        gc();
        map.remove(key);
    }
}

不需要多个线程;当调用其他方法时,通过轮询引用队列机会性地删除过期的映射条目。(这也是 WeakHashMap 工作的方式。)

示例:

static final SimpleCache<File,BigObject> cache = new SimpleCache<>();

...

// if there is already a BigObject generated for this file,
// and it is hasn't been garbage-collected yet, it is returned;
// otherwise, its constructor is called to create one
BigObject bo = cache.getOrCreate(fileName, BigObject::new)
// it will be gc'd after nothing in the program keeps a strong ref any more

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