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);
}
其中:
BundleInfo保存 RN 页面的埋点信息;rootTag是页面标识,通常从 1 开始,以 10 为步长递增,例如1、11、21……;- 前几个页面的埋点读取正常;
- 页面创建次数增加后,
getPageInfo(rootTag)偶尔返回null; - 问题更容易在发生 GC 后出现。
表面上看,数据像是没有成功写入,或者被其他业务逻辑删除了。实际上,数据已经写入 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 运行时通常会缓存 -128 到 127 范围内的 Integer 对象。缓存数组会长期强引用这些对象,因此命中缓存的 Key 不会因为只被 WeakHashMap 引用而回收。
一旦数值超出缓存范围,装箱通常会产生新的 Integer 对象。如果业务没有保存这个对象的强引用,put 调用结束后,它就可能只剩下 WeakHashMap 中的弱引用。
这就造成了一种很有迷惑性的现象:
rootTag 范围 | 装箱结果 | Map 条目的表现 |
|---|---|---|
通常为 -128~127 | 复用缓存中的 Integer | Key 还有缓存提供的强引用,数据看起来稳定 |
| 超出缓存范围 | 通常创建新的 Integer | Key 可能只剩弱引用,GC 后数据消失 |
小整数阶段的“正常”并不能证明 WeakHashMap 用法正确,它只是碰巧依赖了 Integer 缓存提供的强引用。
为什么常在第 14 次附近暴露
假设 rootTag 从 1 开始,每次增加 10:
1, 11, 21, 31, ..., 121, 131, 141, ...
121 仍处于常见的 Integer 缓存范围内,而下一个值 131 已超过默认上界 127。按这组分配规则计算,第 14 个生成的 rootTag 就可能开始暴露问题。
这里的“第 14 个”指的是标识的生成次数,并不一定等于当前同时打开的页面数。实际触发时机还会受到以下因素影响:
rootTag的起始值和递增规则;- 运行时采用的
Integer缓存范围; - 页面或框架是否持有同一个装箱对象;
- GC 的发生时机。
真正的分界线不是某个固定页码,而是:作为 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);
}
选择哪种容器不是最关键的。关键在于建立明确的注册和注销流程,让缓存生命周期与页面生命周期保持一致。
如果读写可能发生在不同线程,还需要在此基础上补充同步机制,或选择合适的并发容器。