• 作者:老汪软件技巧
  • 发表时间:2024-08-23 15:09
  • 浏览量:

一、概述

天下苦 ArrayMap 久矣。

这并非 哗众取宠,如果有幸翻阅了 Glide 的代码仓库,你会在 issues 中发现在一堆奇怪的 ClassCastException:

难以置信,一个 2018 年的问题,历经 6 年Glide 官方仍然未解决。最近笔者线上发现了类似的崩溃,经过排查,崩溃原因竟然和业务代码或 Glide本身 毫无关系——其根本原因,是源自 Google 官方的 ArrayMap 的设计缺陷,并且从 Android 8.0 保留至今仍未修复。

本文针对 ArrayMap 的设计理念和实现历程进行一个简单的回顾,最终回答以下几个问题:

二、设计与实现

ArrayMap 是官方提供的一种 键值对集合 ,我们知道,虽然 HashMap 已经提供了足够高的读写效率,但这是基于较高的 内存占用 和 垃圾回收频率 实现的。

因此 Google 设计了 ArrayMap 解决这两个问题。

2.1 减少pc成本

本文不会对 ArrayMap 进行源码级的讲解,读者仅需梳理思路即可。

首先,ArrayMap 的思路是使用两个并列的数组来存储键值对,可以极大地减少内存分配的次数,降低垃圾回收的压力:

public final class ArrayMap implements Map {
  int[] mHashes;
  Object[] mArray;
  int mSize;
}

简单介绍最重要的2个数组成员:

借用网上图描述下:

2.2 减少内存占用

这很好理解,ArrayMap 内部结构简单,提供了更紧凑的数据结构,而非类似 HashMap 复杂的数据结构(链表或红黑树),因此在存储少量数据时,比 HashMap 更加节省内存。

2.3 较高的读写效率

解决了 HashMap 内存效率的问题,ArrayMap 还需要保证 性能优势,众所周知,平均情况下,HashMap 的查找、插入和删除操作时间复杂度是 O(1)。

ArrayMap 使用 有序数组 和 二分查找 来定位键值对:

由于使用了二分查找,因此时间复杂度是 O(log n),由于其使用场景为 内存受限的小数据集操作,因此对数级别的时间复杂度是可以接受的。

2.4 进一步优化

ArrayMap 使用场景通常数据很少,而为了进一步 优化内存的分配和回收,其内部引入了 缓存池 概念

public final class ArrayMap implements Map {
  static Object[] mBaseCache;
  static Object[] mTwiceBaseCache;
}

简而言之,其内部使用一个 静态缓存池 存储已被回收但还可重用的内部存储结构。这包括一组预先定义的对象缓存队列,用来存储不同大小的数组。

当需要分配新的数组时,首先尝试从缓存中获取,而不是直接进行新的内存分配:

private void allocArrays(int size) {
  if (size == BASE_SIZE) {
      synchronized (sBaseCacheLock) {
          if (mBaseCache != null) {
              // 1.从静态缓存池中取出
              final Object[] array = mBaseCache;
              // 2.复用之
              mArray = array;
              mBaseCache = (Object[]) array[0];   // 【重要】3.3 会讲
              mHashes = (int[]) array[1];
              array[0] = array[1] = null;
              // ....
          }
      }
    }
    // 3. 无可用缓存,再new
    mKeys = new Object[size];
    mValues = new Object[size];
}

迄今为止,ArrayMap 为我们呈现了 Google 设计人员极致的优化追求,以进一步提升在 Android 内存受限环境下的性能。

三、缺陷3.1 线上的奇怪崩溃

使用ArrayMap 一段时间后,线上逐渐出现越来越多奇怪的 崩溃:

java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Object[]
    at androidx.collection.SimpleArrayMap.allocArrays(SimpleArrayMap.java:184)
    at androidx.collection.SimpleArrayMap.put(SimpleArrayMap.java:458)
    at com.bumptech.glide.util.CachedHashCodeArrayMap.put(CachedHashCodeArrayMap.java:34)
    at com.bumptech.glide.request.BaseRequestOptions.transform(BaseRequestOptions.java:1017)
    at com.bumptech.glide.request.BaseRequestOptions.transform(BaseRequestOptions.java:971)

令人摸不到头脑的日志,来看崩溃的业务代码:

GlideApp.with(context).load(path).transform(xxx).into(imageView);

有点更蒙圈了,自测几乎无法复现,这到底什么情况?

于是 Glide 官方仓库的 issues 中出现了若干类似的反馈。

3.2 超级不安全

崩溃的根本原因在于 ArrayMap 本身的 线程不安全。

请注意,这里还有点特殊,我们知道 HashMap 本身也是线程不安全的,但通常只是指 对象本身的线程不安全:并发场景下,只影响到单个对象,对其它场景的 HashMap 实例及内部数据是没有影响的。

ArrayMap 就厉害了,并发场景下,一个数据异常,会影响到其它场景的其它 ArrayMap 实例。

笔者词穷,确需一个词描述以区分ArrayMap,那就是 线程超级不安全 。

具体流程可靠参考 2.4 小节的源码,当并发场景下,缓存池内的数据有可能会收到污染,代码执行顺序如下:

// SimpleArrayMap.java
private void allocArrays(int size) {
  // 省略其它
  
  // 1.从静态缓存池中取出
  final Object[] array = mBaseCache;
  // 2.复用之
  mArray = array;
  // *************************
  // 3.此时,其它线程进行了写操作,如 mArray.put(XXX),即 mBaseCache 缓存池受到了污染.
  // *************************
  mBaseCache = (Object[]) array[0];   // 4.此时读取缓存池数据,脏数据导致类型异常,app崩溃
  
 // 省略其它
}

需要强调的是,当 mBaseCache 缓存池受到污染后,可能并不会引起崩溃,而是把隐患埋了下来,当下一次,在其它业务场景下,通过 new ArrayMap() 创建对象并申请内存时,缓存池复用才会抛出异常。

这也就解释了,为何 Glide 会在极度偶现的场景下崩溃,其本质可能是由于其它线程使用 ArrayMap 引起,只不过后续 Glide 加载图片时引爆了炸弹而已。

3.3 使用建议

由于 frameWork、Glide等框架内部依然大量使用了 ArrayMap, 作为开发者,我们仍需尽量避免类似问题的发生。

最好的方式是保证使用时的线程安全,即和 Glide 等源码保持一致,只在 主线程使用 ArrayMap 及其子类。

3.4 躲都躲不掉

即使小心翼翼,正确线程的使用、甚至不使用ArrayMap,开发者仍然会遇到 ArrayMap 带来的崩溃问题:

如图,在 androidx.palette 官方的提色板组件中,Palette 实例的构建默认是通过 AsyncTask 创建在子线程中的,而内部的 ArrayMap 也自然在子线程中创建,因此也会导致上文中的崩溃问题。

读到这里,即使开发者业务中从未使用 ArrayMap,仍很难避免 三方库 甚至 官方库 内部ArrayMap的错误使用导致的崩溃。

笔者不禁疑惑,至此,ArrayMap 中的缓存池设计真的合理吗,如果不合理,为何其所在的 androidx.collection 库一直对该问题视而不见呢?

感谢评论区 @Goooler 的提醒(评论误删了..),1.4.3 版本 SimpleArrayMap 使用 kotlin 进行了重写,源码解决了静态缓存池所带来的线程不安全的问题,大家可以通过手动指定 collcetion 库的版本的方式规避。

小结

ArrayMap 设计的初衷是为了提高在 内存受限环境下 的 小数据集操作 的内存效率和性能,大量的官方、三方源码中都有应用。

遗憾的是,ArrayMap 线程超级不安全,尽量在主线程使用,避免子线程使用引起崩溃。

进一步的,考虑到这个类并不是那么好用,且该类崩溃问题不易定位问题原因。普通业务场景下,笔者还是倾向HashMap。

关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。