一次 WeakHashMap 引发的深入思考——对懒加载思想和 WeakReference 的重新理解

今天在读 Effective Java 中对内存泄露的讨论时,触发了对 WeakHashMap 这个类的好奇,大概翻了一下源码,核心的印象是:用拉链法处理哈希冲突,用一个 Entry 数组来作为 Hash 桶。这个 Entry 类型继承 WeakReference,是一个弱引用,表示一个 Entry 对象当没有其他强引用时,就会被垃圾收集。因此用 WeakHashMap 来作为缓存,可以有效避免内存泄露。这一部分对应的源码:

public class WeakHashMap<K,V>  
    extends AbstractMap<K,V>  
    implements Map<K,V> {  
  
    Entry<K,V>[] table;

    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {  
	V value;  
	final int hash;  
	Entry<K,V> next;  
    }
}

问题

在这里就想到了一个问题:如果在 Entry 链中间的一个 Entry 没有其他强引用,如果它被回收不是会导致拉链在它后面 Entry 也会被回收吗?

JDK 是如何解决这个问题的?

我们能想到的问题,JDKer 一定也考虑到了,如果回收一个中间的 Entry 导致哈希拉链断开一定是不合适和,解决的思路其实很简单 —— 在中间 Entry 被回收后,重新调整链表的指针,使得前一个 Entry 直接指向被移除 Entry 的下一个 Entry,就能保证链表的连续性。

思路没有问题,但是有一个很核心的问题:能不能介入到垃圾回收的流程中去做这个链表指针的重新调整呢?我所知道的介入垃圾回收的方式有一个 finalized(),可以给将要被回收的对象一次处理的机会,但是这个方法显然是不合适的,至少不能直接解决问题。

WeakHashMap 的源码中处理这个问题的核心思想是:将被垃圾回收的 Entry 保存到一个队列中,在合适的时机通过 expungeStaleEntries()  从引用队列获取被垃圾回收键对应的 Entry,找到其在链表中的位置,然后根据情况调整链表指针移除该 Entry。看到这里,大家是不是想到了“懒加载”的思想?在回收时候先不进行回收,而是在以后要访问的时候进行时机回收

核心方法 expungeStaleEntries 的源码,实际上就是做了一个链表中元素的移除。

    private void expungeStaleEntries() {
        for (Object x; (x = queue.poll()) != null; ) {
            synchronized (queue) {
                @SuppressWarnings("unchecked")
                    Entry<K,V> e = (Entry<K,V>) x;
                int i = indexFor(e.hash, table.length);

                Entry<K,V> prev = table[i];
                Entry<K,V> p = prev;
                while (p != null) {
                    Entry<K,V> next = p.next;
                    if (p == e) {
                        if (prev == e)
                            table[i] = next;
                        else
                            prev.next = next;
                        // Must not null out e.next;
                        // stale entries may be in use by a HashIterator
                        e.value = null; // Help GC
                        size--;
                        break;
                    }
                    prev = p;
                    p = next;
                }
            }
        }
    }

什么时候执行实际回收? —— 懒回收 思想

这个核心方法很简单,但是重要的问题是:什么时机调用这个方法呢?

在源码中,有三个地方直接调用了这个方法:

    // 1 getTable() 会在 get() getEntry() 等所有需要访问 Entry 数组的时候
    private Entry<K,V>[] getTable() {
        expungeStaleEntries();
        return table;
    }
    // 2 size()
    public int size() {
        if (size == 0)
            return 0;
        expungeStaleEntries();
        return size;
    }
    // 3 resize() 会在 put() 和 putAll() 中被调用
    void resize(int newCapacity) {
        Entry<K,V>[] oldTable = getTable();
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry<K,V>[] newTable = newTable(newCapacity);
        transfer(oldTable, newTable);
        table = newTable;

        /*
         * If ignoring null elements and processing ref queue caused massive
         * shrinkage, then restore old table.  This should be rare, but avoids
         * unbounded expansion of garbage-filled tables.
         */
        if (size >= threshold / 2) {
            threshold = (int)(newCapacity * loadFactor);
        } else {
            expungeStaleEntries();
            transfer(newTable, oldTable);
            table = oldTable;
        }
    }

值得注意的是,实际清理方法会分别在这三个场景下被间接调用:

  • put()/putAll()
  • get() getEntry() 等所有需要访问 Entry 数组的时候
  • size()

这么一看,就是在所有访问(读、写)Entry 数组的时候,就会调用拉链重整方法,对队列里保存的被回收的 Entry 做实际回收。确实是“懒回收”的思想!

什么时候将即将被垃圾回收器回收的 Entry 放入队列?

实际上,会有这个问题是因为对弱引用 WeakReference 的理解有错🙅‍♀️

Entry 是 WeakReference 的子类,并不意味着 Entry 会在没有其他强引用的时候会被整个回收。注意看 Entry 的构造函数:实际上只有 key 被构造成 WeakReference,并且将 queue 绑定到这个弱引用的 key。

private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
Entry(Object key, V value,
              ReferenceQueue<Object> queue,
              int hash, Entry<K,V> next) {
            super(key, queue);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
}

当垃圾回收器发现某个键对象只被 WeakReference 引用,并且要回收该键对象时,WeakReference 机制会自动将对应的 Entry 放入关联的引用队列。这是 Java 虚拟机的垃圾回收机制和 WeakReference 类的底层实现共同完成的,具体的触发点在垃圾回收器进行对象可达性分析之后,判定该对象可被回收时。也就是虽然确实中间节点的 key 被回收了,但是 Entry 的其他信息保留在这个关联的引用队列中,包括了 next 信息,可以用来进行 expungeStaleEntries

记住只有当所要的缓存项的生命周期是由该键的外部引用而不是由值决定时,WeakHashMap 才有用处 —— 《Effective Java》第 7 条

书中的这句话也印证了上面的结论,WeakHashMap 中真正弱引用的只有 Entry 的键,而不是值或者指针。

Reference 的 队列机制

了解的 WeakRerence 的这个 ReferenceQueue 的机制后,其实可以立马想到虚引用的唯一作用就是跟踪对象的垃圾回收过程,因此它必须绑定一个队列,不然没有意义。实际上,软引用、弱引用、虚引用都可以在创建时关联一个引用队列

从源码结构上来看,PhantomReference, SoftReference, WeakReference, FinalReference(一般不使用) 这些引用全部都是抽象类 Reference 的子类,而这个队列是抽象类中的字段。

public abstract sealed class Reference<T>
    permits PhantomReference, SoftReference, WeakReference, FinalReference {
    volatile ReferenceQueue<? super T> queue;
    ...
    Reference(T referent, ReferenceQueue<? super T> queue) {
        this.referent = referent;
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }
    .... 
}

针对这个队列机制,如果引用队列中的元素一直不被处理,不是还是会内存泄露吗

比如引用队列中的 Entry 实例记录了被回收键对象对应的键值对信息。虽然键对象已经被回收,但 Entry 中还保存着值以及其他相关信息(如哈希值、链表指针等)。后续 WeakHashMap 在进行 putgetsize 等操作时,会调用 expungeStaleEntries 方法从引用队列中取出这些 Entry 并清理,从而保证 WeakHashMap 中不包含无效的键值对。

小结

从对 WeakReference 的一个简单疑问出发,竟然能引发这么多的学习和思考,包括了“懒回收”、Reference 的队列机制等等。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇