0%

android.util.ArraySet导致内存泄漏分析

ArraySet的实现原理

android.util.ArraySet 是 framework.jar 提供的一个容器类,它的功能和Java的HashSet一样,用来存放对象集合,但它的实现方式却和HashSet不一样。
ArraySet主要维护两个数组,一个是Object[]用来保存对象集合,一个是int[]用来保存对象对应的hashCode集合,对应关系如图:

tu1.png
对象和它的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> {

/**
* Maximum number of entries to have in array caches.
*/
private static final int CACHE_SIZE = 10;

/**
* Caches of small array objects to avoid spamming garbage. The cache
* Object[] variable is a pointer to a linked list of array objects.
* The first entry in the array is a pointer to the next array in the
* list; the second entry is a pointer to the int[] hash code array for it.
*/
static Object[] sBaseCache;
static int sBaseCacheSize;
static Object[] sTwiceBaseCache;
static int sTwiceBaseCacheSize;
}

缓存分为两个,一个是sBaseCache用来缓存长度为4的Object[]和int[],另一个是sTwiceBaseCache用来缓存长度为8的Object[]和int[],下图以sBaseCache为例子描述了缓存的数据结构:
tu2.png
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) {
//以下代码是取最后一个缓存作为当前实例的mArray,以及它的第二个位置上的int[]作为mHashes
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) {
}
// Whoops! Someone trampled the array (probably due to not protecting
// their access with a lock). Our cache is corrupt; report and give up.
Slog.wtf(TAG, "Found corrupt ArraySet cache: [0]=" + array[0]
+ " [1]=" + array[1]);
sTwiceBaseCache = null;
sTwiceBaseCacheSize = 0;
}
}
} else if (size == BASE_SIZE) {
//和上面长度为8的复用一样的逻辑
}

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;//保存int[]
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) {
//和上面长度为8的h回收一样的逻辑
}
}

内存泄漏的现象

在排查某个应用内存泄漏时发现放在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显示的引用链如下:
tu3.png
是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();
}
}
}

同样会导致内存泄漏:
tu4.png

解决方案

使用 androidx.collection.ArraySet,由于 androidx.collection.ArraySet 是应用进程自己加载创建的不会有上面讲的逻辑,自然也就不会导致内存泄漏。