ArraySet的实现原理 android.util.ArraySet 是 framework.jar 提供的一个容器类,它的功能和Java的HashSet一样,用来存放对象集合,但它的实现方式却和HashSet不一样。 ArraySet主要维护两个数组,一个是Object[]用来保存对象集合,一个是int[]用来保存对象对应的hashCode集合,对应关系如图:
对象和它的hashCode是通过数组的下标关联起来的,例如第i个位置:mHashes[i]保存的是mArray[i]这个对象的hashCode。而且mHashes里面的hashCode并不是随意排列的,而是升序排列,这样我们就可以在mHashes数组上使用二分查找hashCode的方式快速定位我们要操作的对象。
具体的增删改查接口的实现介绍可以参考其他文章,不再详细阐述,这里主要介绍下它的缓存的实现原理,ArraySet主要特点是使用更少的内存保存对象集合、适用于集合里对象数量少的场景,所以它对保存集合的容器Object[]已经保存hashCode的int[]做了一个缓存的设计,如代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public final class ArraySet <E > implements Collection <E >, Set <E > { private static final int CACHE_SIZE = 10 ; static Object[] sBaseCache; static int sBaseCacheSize; static Object[] sTwiceBaseCache; static int sTwiceBaseCacheSize; }
缓存分为两个,一个是sBaseCache用来缓存长度为4的Object[]和int[],另一个是sTwiceBaseCache用来缓存长度为8的Object[]和int[],下图以sBaseCache为例子描述了缓存的数据结构: sBaseCache指向最后一个缓存Object[],Object[]的第一个位置指向上一个缓存的Object[],第二个位置缓存自己这个Object[]对应的保存hashCode的int[],依次类推最多10组。sTwiceBaseCache的数据结构和这个是一样的。
复用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 private void allocArrays (final int size) { if (size == (BASE_SIZE * 2 )) { synchronized (sTwiceBaseCacheLock) { if (sTwiceBaseCache != null ) { final Object[] array = sTwiceBaseCache; try { mArray = array; sTwiceBaseCache = (Object[]) array[0 ]; mHashes = (int []) array[1 ]; if (mHashes != null ) { array[0 ] = array[1 ] = null ; sTwiceBaseCacheSize--; if (DEBUG) { Log.d(TAG, "Retrieving 2x cache " + mHashes + " now have " + sTwiceBaseCacheSize + " entries" ); } return ; } } catch (ClassCastException e) { } Slog.wtf(TAG, "Found corrupt ArraySet cache: [0]=" + array[0 ] + " [1]=" + array[1 ]); sTwiceBaseCache = null ; sTwiceBaseCacheSize = 0 ; } } } else if (size == BASE_SIZE) { } mHashes = new int [size]; mArray = new Object[size]; }
回收 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private static void freeArrays (final int [] hashes, final Object[] array, final int size) { if (hashes.length == (BASE_SIZE * 2 )) { synchronized (sTwiceBaseCacheLock) { if (sTwiceBaseCacheSize < CACHE_SIZE) { array[0 ] = sTwiceBaseCache; array[1 ] = hashes; for (int i = size - 1 ; i >= 2 ; i--) { array[i] = null ; } sTwiceBaseCache = array; sTwiceBaseCacheSize++; if (DEBUG) { Log.d(TAG, "Storing 2x cache " + array + " now have " + sTwiceBaseCacheSize + " entries" ); } } } } else if (hashes.length == BASE_SIZE) { } }
内存泄漏的现象 在排查某个应用内存泄漏时发现放在android.util.ArraySet里面的某个类的7个实例对象间接引用到了Activity,导致Activity即使执行了onDestory也没有被回收。简化后的代码逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import android.util.ArraySet;public class MainActivity extends AppCompatActivity { private static class Holder { public Activity activity; } ArraySet<Holder> holders = new ArraySet<>(); @Override protected void onCreate (Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.activity_main); for (int i = 0 ; i < 7 ; i++) { Holder holder = new Holder(); holder.activity = this ; holders.add(holder); } } }
ArraySet保存的是Holder的实例集合,Holder实例持有Activity引用,ArraySet里面有7个对象,乍一看没什么问题呀,但是却导致Activity内存泄漏了,起初我怀疑是Profiler的问题,于是我使用了LeakCanary,以及轮询调用WeakReference的get()方法,都显示Activity没有被回收。Profiler显示的引用链如下: 是ArraySet实例里面的mArray间接引用到Activity导致没有被回收,但是我们知道mArray只是ArraySet实例的一个成员变量而已,怎么会导致这个结果呢?
内存泄漏分析 我们看Profiler会发现mArray就是它检测到的最后一个引用Activity的对象了,所以它会不会是 被JNI的代码给引用到了,搜索mArray被引用的地方,发现只是被System.arraycopy引到了,怀疑过是不是这个native方法捣鬼,但是这是一个Java的方法,身经百战了,应该不会出现这个错误了,要不然早就被发现了。 之后想到的是mArray可能是复用以前别的ArraySet实例的mArray,是不是mArray里面的对象没清理干净导致泄漏,但这显然错了,只有被引用才导致泄漏,主动引用别的没被释放的实例对mArray的回收没有任何影响啊。 先不管这些,我们要确认我们这个ArraySet的mArray是不是复用的,上一节贴的复用代码在成功复用的时候会打印日志,这里我是重新编译了系统把这个类的日志开关DEBUG = true,可以发现的确打印了日志:
1 Retrieving 2x cache [I@9883301 now have 0 entries
所以我们可以确定在这个ArraySet需要从长度为4扩容到长度为8时,复用了一个长度为8的缓存。 那这个被复用的mArray是谁创建的呢?在什么时候放入缓存的呢? 刚刚我们已经打开了日志开关,在回收时会打印日志: Storing 2x cache,但是但是,奇怪的事情发生了,自这个进程启动开始,就没有打印过这个日志,所以可以猜测这个缓存不是不是应用进程自己创建的,而是它的父进程创建的,而应用进程的父进程就是Zygote进程。 先来看看这个长度为8的Object[]缓存里面都有些什么的对象,通过反射获取如下(需要系统签名的应用哦~):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 D/:sTwiceBaseCache:[Ljava.lang.Object;@460a8e9 D/:sTwiceBaseCache: val0 : null D/:sTwiceBaseCache: val1 : [I@9883309 D/:sTwiceBaseCache: val2 : null D/:sTwiceBaseCache: val3 : null D/:sTwiceBaseCache: val4 : null D/:sTwiceBaseCache: val5 : null D/:sTwiceBaseCache: val6 : null D/:sTwiceBaseCache: val7 : null D/:sTwiceBaseCache[1 ] is int [],val0 : 1 D/:sTwiceBaseCache[1 ] is int [],val1 : 2 D/:sTwiceBaseCache[1 ] is int [],val2 : 4 D/:sTwiceBaseCache[1 ] is int [],val3 : 8 D/:sTwiceBaseCache[1 ] is int [],val4 : 16 D/:sTwiceBaseCache[1 ] is int [],val5 : 32 D/:sTwiceBaseCache[1 ] is int [],val6 : 64 D/:sTwiceBaseCache[1 ] is int [],val7 : 128
可以发现sTwiceBaseCache的hashCode是@460a8e9。为了实锤sTwiceBaseCache是Zygote进程放入的,我们取出自开机启动时保存的日志,搜索Zygote进程的的日志信息,由于我们已经打开了ArraySet的日志开关,所以能够看到它的日志,看最后一次调用ArraySet的freeArray方法的日志如下:
1 266 266 D ArraySet: Storing 2x cache [Ljava.lang.Object;@460a8e9 now have 1 entries
266是Zygote的进程pid,放入Object[]的hashCode正是子进程获取到的Object[]的hashCode,所以我们可以猜测:父进程创建的static Object[],被子进程用来复用保存新的对象,会导致新的对象不能被回收。 为了验证这个结论,我们再做一个实验,在ArraySet中加入一个static Object[] sTestCache:
1 2 3 4 5 6 7 8 public final class ArraySet <E > implements Collection <E >, Set <E > { static Object[] sBaseCache; static int sBaseCacheSize; static Object[] sTwiceBaseCache; static int sTwiceBaseCacheSize; static Object[] mTestCache = new Object[]{new Object()}; }
然后在子进程反射复用这个mTestCache:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class MainActivity extends AppCompatActivity { @Override protected void onCreate (Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.activity_main); try { Field field = ArraySet.class.getDeclaredField("sTestCache" ); field.setAccessible(true ); Object[] objects = (Object[]) field.get(null ); field.set(null , null ); objects[0 ] = this ; } catch (NoSuchFieldException | IllegalAccessException e) { e.printStackTrace(); } } }
同样会导致内存泄漏:
解决方案 使用 androidx.collection.ArraySet,由于 androidx.collection.ArraySet 是应用进程自己加载创建的不会有上面讲的逻辑,自然也就不会导致内存泄漏。