WeakHashMap + Integer 导致数据丢失

在 RN 页面埋点中,我们曾使用 WeakHashMap<Integer, BundleInfo> 保存页面元数据,并以 rootTag 作为 Key。代码刚运行时一切正常,但随着页面创建次数增加,部分埋点数据开始“随机”消失。

问题最终定位到一个容易被忽略的组合:WeakHashMap 使用弱引用保存 Key,而 int 类型的 rootTag 在放入 Map 时会自动装箱为 Integer。小整数缓存暂时掩盖了生命周期问题;当 rootTag 超出缓存范围后,Key 可能只剩弱引用,并在 GC 时被回收。

问题现象

业务代码可以简化为:

private final Map<Integer, BundleInfo> pageInfoMap = new WeakHashMap<>();

void registerPage(int rootTag, BundleInfo pageInfo) {
    pageInfoMap.put(rootTag, pageInfo);
}

BundleInfo getPageInfo(int rootTag) {
    return pageInfoMap.get(rootTag);
}

其中:

表面上看,数据像是没有成功写入,或者被其他业务逻辑删除了。实际上,数据已经写入 Map,只是对应的 Key 被 GC 回收,Map 条目也随之被清理。

WeakHashMap 为什么会丢数据

普通 HashMap 会强引用 Key。只要 Map 仍然存活,它保存的 Key 和 Value 通常就不会被 GC 回收。

WeakHashMap 不同:它通过弱引用持有 Key。当一个 Key 在 Map 之外不再有强引用时,GC 可以回收这个 Key,随后 WeakHashMap 会移除对应条目。

因此,WeakHashMap 适合关联“本身具有明确对象生命周期”的 Key,例如某个只要仍被页面持有就应继续存在的对象。它并不适合保存临时装箱得到的数值 ID,因为装箱对象的生命周期与页面生命周期没有任何关系。

Integer 缓存如何掩盖问题

int 传给 Map<Integer, ...> 时,Java 会自动调用 Integer.valueOf 完成装箱。其行为可以简化为:

public static Integer valueOf(int value) {
    if (value >= CACHE_LOW && value <= CACHE_HIGH) {
        return INTEGER_CACHE[value - CACHE_LOW];
    }
    return new Integer(value); // 用于说明语义:实际实现可能不同
}

Java/Android 运行时通常会缓存 -128127 范围内的 Integer 对象。缓存数组会长期强引用这些对象,因此命中缓存的 Key 不会因为只被 WeakHashMap 引用而回收。

一旦数值超出缓存范围,装箱通常会产生新的 Integer 对象。如果业务没有保存这个对象的强引用,put 调用结束后,它就可能只剩下 WeakHashMap 中的弱引用。

这就造成了一种很有迷惑性的现象:

rootTag 范围装箱结果Map 条目的表现
通常为 -128127复用缓存中的 IntegerKey 还有缓存提供的强引用,数据看起来稳定
超出缓存范围通常创建新的 IntegerKey 可能只剩弱引用,GC 后数据消失

小整数阶段的“正常”并不能证明 WeakHashMap 用法正确,它只是碰巧依赖了 Integer 缓存提供的强引用。

为什么常在第 14 次附近暴露

假设 rootTag 从 1 开始,每次增加 10:

1, 11, 21, 31, ..., 121, 131, 141, ...

121 仍处于常见的 Integer 缓存范围内,而下一个值 131 已超过默认上界 127。按这组分配规则计算,第 14 个生成的 rootTag 就可能开始暴露问题。

这里的“第 14 个”指的是标识的生成次数,并不一定等于当前同时打开的页面数。实际触发时机还会受到以下因素影响:

真正的分界线不是某个固定页码,而是:作为 Key 的 Integer 是否还存在 Map 之外的强引用。

正确的修复方式

方案一:使用 HashMap,按生命周期主动清理

如果 rootTag 只是数值 ID,应使用强引用容器,并在页面销毁时明确删除数据:

private final Map<Integer, BundleInfo> pageInfoMap = new HashMap<>();

void registerPage(int rootTag, BundleInfo pageInfo) {
    pageInfoMap.put(rootTag, pageInfo);
}

void unregisterPage(int rootTag) {
    pageInfoMap.remove(rootTag);
}

void clearAllPages() {
    pageInfoMap.clear();
}

这样,数据的存活时间由页面或 RN 引擎的生命周期决定,不再依赖不可预测的 GC 时机。

方案二:Android 场景使用 SparseArray

如果 Key 始终是 int,也可以使用 SparseArray,避免额外的装箱对象:

private final SparseArray<BundleInfo> pageInfoMap = new SparseArray<>();

void registerPage(int rootTag, BundleInfo pageInfo) {
    pageInfoMap.put(rootTag, pageInfo);
}

void unregisterPage(int rootTag) {
    pageInfoMap.remove(rootTag);
}

选择哪种容器不是最关键的。关键在于建立明确的注册和注销流程,让缓存生命周期与页面生命周期保持一致。

如果读写可能发生在不同线程,还需要在此基础上补充同步机制,或选择合适的并发容器。