0%

此为旧博客补档,原文上传于2022年3月23日。

前言

数据结构是每个程序员的必修课。无论是链表,堆,队列,栈,还是树,图,无疑都是伟大的发明。而基于这些数据结构之上的集合(collections),更是被广泛运用。在java开发中,我们经常使用诸如arrayList,hashmap这样的集合,对这些想必我们都已非常熟悉;然而,在android开发中,其实也有着一些特有的数据结构。使用这些数据结构,有时也许会带来出乎意料的优化效果,今天,我们就来分析一波稀疏数组(sparse array)。稀疏数组是android特有的集合,其作用类似于map,但是通过两个数组分别存储key和value。都用来存储键值对,但是短小精悍(其源码带上注释也不过500行),且拥有一套非常独特的机制。

sparseArray字段

sparseArray有如下一些字段,我们一一进行分析:

1
2
3
4
5
6
7
8
9
private static final Object DELETED = new Object();
private boolean mGarbage = false;

@UnsupportedAppUsage(maxTargetSdk = 28) // Use keyAt(int)
private int[] mKeys;
@UnsupportedAppUsage(maxTargetSdk = 28) // Use valueAt(int), setValueAt(int, E)
private Object[] mValues;
@UnsupportedAppUsage(maxTargetSdk = 28) // Use size()
private int mSize;

首先看mKeys数组,这是一个int类型数组,用来存储key,其值正是对应的value在mValues数组中的下标。可以看到sparsearray内部采用int类型表示key,而不像map,set一样可以自己通过泛型定义key的类型。这决定了sparsearray拥有一些独特的性质:(1)keys数组按照升序排序,通过二分查找定位key(而不是使用hash);(2)避免了一些装箱、拆箱的过程,提高了效率。

然后是mValues数组,从名字就可以看出这个数组存储value。sparsearray使用Object类型而不是泛型来存储value。

接着是一个int类型变量mSize,代表mKeys和mValues有效的长度(也是二分查找过程的右基准)。
然后是DELETED。这是sparseArray的延迟删除机制的体现,其是一个object类型对象,用于在删除键值对时替换原来的旧值。然后是mGarbage,这是一个bool类型变量。当数组中存在被标记为删除(但没有真正删除)的元素(即DELETED)时,这个标记位会变为true,表示数组中存在无效的废弃值(就像垃圾一样),用以触发sparseArray的gc(注意这里是sparseArray自己的一个方法,而不是系统的gc)。
初步了解这些字段后,我们就可以看看sparsearray的方法,并洞悉其原理了。

contains(int key)

这个方法用来查找某一key是否在数组中存在。其内部调用了另一方法indexOfKey:

1
2
3
4
5
6
7
public int indexOfKey(int key) {
if (mGarbage) {
gc();
}

return ContainerHelpers.binarySearch(mKeys, mSize, key);
}

首先检查mGarbage值,如果为true,说明数组中存在无效元素,应该先调用gc清除再查找。关于gc()方法下文再做分析。这里我们可以先看一看返回中的binarySearch方法,sparseArray中大量使用这个方法。其与JavaSE中的二分查找有什么不同:

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
37
38
39
40
41
class ContainerHelpers {

// This is Arrays.binarySearch(), but doesn't do any argument validation.
static int binarySearch(int[] array, int size, int value) {
int lo = 0;
int hi = size - 1;

while (lo <= hi) {
final int mid = (lo + hi) >>> 1;
final int midVal = array[mid];

if (midVal < value) {
lo = mid + 1;
} else if (midVal > value) {
hi = mid - 1;
} else {
return mid; // value found
}
}
return ~lo; // value not present
}

static int binarySearch(long[] array, int size, long value) {
int lo = 0;
int hi = size - 1;

while (lo <= hi) {
final int mid = (lo + hi) >>> 1;
final long midVal = array[mid];

if (midVal < value) {
lo = mid + 1;
} else if (midVal > value) {
hi = mid - 1;
} else {
return mid; // value found
}
}
return ~lo; // value not present
}
}

首先这个方法写在ContainerHelpers这个工具类中,这个类只有这一个方法的两种重载。基本代码是正宗的二分查找写法,而重要区别在于最后当查找失败时返回的值:~lo。一般二分查找会返回-1这样没有意义的负值,表示元素不存在,但这里返回的却是最后一次查找时得到的low值取反。这是非常优秀的设计。这也是一个负值,但为什么标新立异,要返回这样的值呢?这是因为,sparseArray通过二分查找寻找key,而二分查找的前提是数组有序。当找不到一个key时,证明我们需要在mKeys数组中插入这个key,且其插入的位置应该满足升序。又因为,当二分查找失败时,最后应该有low = high = mid,而这个low正是理想的插入位置,在这个位置上插入新的key,数组整体依然保持有序。所以我们希望知道这个位置的值,以方便下次插入。但又需要提醒调用者查找是失败的(即需要负值这样的特殊值),所以最终就对low取反并返回。这样,我们既能知道二分查找失败,当前key不存在,又能知道应该插入的理想位置,一举两得。

delete(int key)

我们接下来先讲sparseArray比较重要的删除操作。源码如下:

1
2
3
4
5
6
7
8
9
10
public void delete(int key) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

if (i >= 0) {
if (mValues[i] != DELETED) {
mValues[i] = DELETED;
mGarbage = true;
}
}
}

其思路十分清晰,也验证了我们之前所说:当要根据key删除一个value时,首先二分查找从mKeys中找到value的位置,如果i不为负,说明key确实存在,继续判断:如果当前元素不是DELETED(即为有效元素),就需要把它删除:设为DELETED,并将mGarbage设为true。这便是延迟删除:并没有真实地删除这一元素,其key值依然存在且有效。只有当调用gc()时,才会真正删除这些元素。除delete外还有一个removeReturnOld方法,效果同delete,只是多了返回旧值的功能;delete还有一个别名方法remove。

细心的读者应该已经注意到,我们反复提到了”延迟删除”这一机制。为什么sparseArray非要搞这么个机制?为什么不直接删掉元素呢?其实这正是为了减少数组迁移的次数,从而提高效率。我们通过接下来的put方法就能体会到这种设计的好处。

put(int key, E value)

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
public void put(int key, E value) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

if (i >= 0) {
mValues[i] = value;
} else {
i = ~i;

if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}

if (mGarbage && mSize >= mKeys.length) {
gc();

// Search again because indices may have changed.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}

mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
}
}

顺着源码,我们可以很容易地得出sparseArray插入键值对的思路:首先,还是从mKeys数组中二分查找当前key是否已经存在。如果已经有相同的key,则直接用当前新的value覆盖掉原来key对应的value。而如果没有当前key,就先对二分查找返回的i取反,即得到了当前key应该插入的位置。接下来就找到这个位置,看看其value是否为DELETED(延迟删除标记值),如果是,则证明当前值已经被废弃,可以直接覆盖,插入也就完成了;否则,就说明数组满了,无法插入。

重点来了:通常情况下,当遇到数组已满而需要插入新值时,需要对数组进行扩容。ArrayList就采取这样的思路。但对于sparseArray来说,这时会触发其延迟删除机制,从而规避了大部分可能需要扩容的情况。下面我们来看gc()方法:

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
private void gc() {
// Log.e("SparseArray", "gc start with " + mSize);

int n = mSize;
int o = 0;
int[] keys = mKeys;
Object[] values = mValues;

for (int i = 0; i < n; i++) {
Object val = values[i];

if (val != DELETED) {
if (i != o) {
keys[o] = keys[i];
values[o] = val;
values[i] = null;
}

o++;
}
}

mGarbage = false;
mSize = o;

// Log.e("SparseArray", "gc end with " + mSize);
}

gc()方法的实现思路很简单。首先,取数组旧的有效长度mSize(由于触发gc时数组内有DELETED元素,所以实际上新的有效长度应该减小)赋值给n,同时新建一个初值为0的int变量o。从下面的代码可以看出,这个o即代表新的有效长度。接下来对values数组进行遍历:如果当前值不为DELETED,且当前位置i != o,则说明在当前位置前存在DELETED元素,且其下标正是o。因此覆盖位置o处的keys和values,同时将当前位置的value置零。同时因为遇到了有效元素,o自加1。就这样迭代下去,最后values数组会被整理成前o个位置均为有效元素,后面则为null,所有的DELETED元素全部删除。也就是在此时,无效的keys和values才真正被清除。最后将象征有garbage的标志位置为false,同时更新有效长度mSize为o,一次gc就结束了。

回到之前的put()方法,由于进行了gc,此时的数组中可能出现了能够插入的空位,因此再做一次二分查找,并尝试插入。最后,我们就来看看执行插入的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int[] insert(int[] array, int currentSize, int index, int element) {
assert currentSize <= array.length;
if (currentSize + 1 <= array.length) {
System.arraycopy(array, index, array, index + 1, currentSize - index);
array[index] = element;
return array;
int[] newArray = ArrayUtils.newUnpaddedIntArray(growSize(currentSize));
System.arraycopy(array, 0, newArray, 0, index);
newArray[index] = element;
System.arraycopy(array, index, newArray, index + 1, array.length - index);
return newArray;
}
}

如果currentSize + 1 <= array.length,说明数组仍有插入空间,无需扩容,则直接调用arraycopy将需要插入的地方index后所有元素往后平移一位,再将待插入元素赋给index位置。否则,实在没有办法,需要扩容了。
总结一下put()的流程:检查key是否已经存在–>已经存在,直接覆盖值;不存在–>待插入位置为DELETED,直接覆盖;为有效值–>触发gc–>gc后有空位,插入;仍然没有空位–>扩容并插入。

get(int key)

最后简单地看一下get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public E get(int key) {
return get(key, null);
}

/**
* Gets the Object mapped from the specified key, or the specified Object
* if no such mapping has been made.
*/
@SuppressWarnings("unchecked")
public E get(int key, E valueIfKeyNotFound) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

if (i < 0 || mValues[i] == DELETED) {
return valueIfKeyNotFound;
} else {
return (E) mValues[i];
}
}

就是二分查找,没什么好说的。

总结

以上就是sparseArray基本的操作方法。同相同功能的hashmap相比,其采用简单的数组和int存储,成本更低,避免装箱,且在数据量较小时,随机访问的速度也很快。但是虽然采取了延迟删除,其插入操作仍可能需要复制数组,导致效率降低;当数据量增大到一定规模时,其他方法(如gc())等需要遍历数组,也相当费时,弊端越来越明显。因此,我们可以权衡sparseArray和hashmap不同的使用情景,合理选择,提高效率。

该篇内容为原博客博文,原上传于2022年2月27日。可能已过时,仅作补档用途,谨慎参考!

前言

上次,我们初步对FFmpeg进行了交叉编译及简单移植。然而,FFmpeg的强大之处在于使用简单的命令行完成艰巨的音视频处理。如果仅仅是集成众多动态库,我们只能使用库中提供的函数编写代码完成功能,相比于使用命令行,对于初学者来说是比较复杂和困难的方法。所以这一次,我们将尝试移植FFmpeg的命令行,直接通过命令调用FFmpeg。然而,笔者的初次移植血泪史揭示,现有的大量博客都是过时的,按照他们的步骤来做,会导致移植失败,白白浪费心力。需要在一般步骤的基础上,加上其他必要的操作。

修改configure文件

在之前的步骤中,我们并没有深入讨论源码目录下的configure文件本身的内容,也没有提到编译脚本为什么这么写,哪些参数是必须配置的。接下来,我们打开configure文件,首先对FFmpeg编译生成的动态库的格式进行设置:

1
2
vim configure
:/SLIBNAME_WITH_MAJOR

注意本文基于ffmpeg5.0,其他版本configure文件内容可能并不一致。

查看并找到下面的内容:

1
2
3
4
SLIBNAME_WITH_MAJOR='$(SLIBNAME).$(LIBMAJOR)' 
LIB_INSTALL_EXTRA_CMD='$$(RANLIB) "$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_VERSION)'
SLIB_INSTALL_LINKS='$(SLIBNAME_WITH_MAJOR) $(SLIBNAME)'

将其修改为:

1
2
3
4
SLIBNAME_WITH_MAJOR='$(SLIBPREF)$(FULLNAME)-$(LIBMAJOR)$(SLIBSUF)'
LIB_INSTALL_EXTRA_CMD='$$(RANLIB)"$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_MAJOR)'
SLIB_INSTALL_LINKS='$(SLIBNAME)'

这一步是许多博客都提到过的,其原因在于,FFmpeg默认编译生成的so库会在后缀后面带上版本号,如libavcodec.so.57.107.100。Android Studio不支持导入这样的库。实际上,FFmpeg也会生成一般命名的so库,只是经过上面的修改后,在安装目录下的lib目录中就只会生成一般命名的so库了,这会使得lib目录更加简洁美观。

接下来,我们找到if test "$target_os" = android处,注意到这里对应上了我们在编译脚本中配置的参数target-os=android,这里规定了一些默认配置,然而仔细观察,不难发现其中有些地方是有问题的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if test "$target_os" = android; then
cc_default="clang"
fi

ar_default="${cross_prefix}${ar_default}"
cc_default="${cross_prefix}${cc_default}"
cxx_default="${cross_prefix}${cxx_default}"
nm_default="${cross_prefix}${nm_default}"
pkg_config_default="${cross_prefix}${pkg_config_default}"
if ${cross_prefix}${ranlib_default} 2>&1 | grep -q "\-D "; then
ranlib_default="${cross_prefix}${ranlib_default} -D"
else
ranlib_default="${cross_prefix}${ranlib_default}"
fi
strip_default="${cross_prefix}${strip_default}"
windres_default="${cross_prefix}${windres_default}"

首先,上面的脚本没有设置当 target-os 为 android 时,c++ 的默认编译器;此外,其将ar,cc,cxx等等需要的参数均设置成以 cross_prefix 为前缀,然而找到我们工具链的bin目录下(如笔者这里是ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64/bin),可以发现相应的工具前缀是llvm-,而不是脚本里设置的cross_prefix,即aarch64-linux-android-。如果不修改,就会在最后移植时导致一系列问题。此外,其对pkg_config的配置也有问题。我们的工具链并没有内置pkg_config,所以这里也要改成我们自己安装的pkg_config。我们考虑以上列出的问题,将以上代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if test "$target_os" = android; then
cc_default="clang"
cxx_default="clang++"
fi

ar_default="llvm-${ar_default}"
cc_default="${cross_prefix}${cc_default}"
cxx_default="${cross_prefix}${cxx_default}"
nm_default="llvm-${nm_default}"
pkg_config_default="${pkg_config_default}"
if ${cross_prefix}${ranlib_default} 2>&1 | grep -q "\-D "; then
ranlib_default="llvm-${ranlib_default} -D"
else
ranlib_default="llvm-${ranlib_default}"
fi
strip_default="llvm-${strip_default}"
windres_default="${cross_prefix}${windres_default}"

注意到我们将涉及到前缀问题的参数均修改为了正确的前缀(有的参数暂时用不到,就没有修改),此外,我们将cxx_default重写为clang++。我们没有改写下面的cc_default和cxx_default,因为我们的脚本中已经设置了正确的cc和cxx,不会用到错误的默认值。到这里,configure就算改好了。这里我列出的代码仅以我自己的配置为准,务必要根据自己的工具链选择是否改写。
我们再回头看一眼编译脚本:

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
37
38
TOOLCHAIN=$HOME/Android/Sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64

export PATH=$PATH:$TOOLCHAIN/bin

CROSS_PREFIX=aarch64-linux-android
CROSS_PREFIX_CLANG=aarch64-linux-android21
PREFIX=$HOME/open/FFmpeg/src/ffmpeg-5.0-arm64
ANDROID_API=21
ARCH=aarch64
CPU=armv8-a

# configure默认的CC与CXX路径是错误的,需要修正
export CC=$CROSS_PREFIX_CLANG-clang
export CXX=$CROSS_PREFIX_CLANG-clang++

./configure \
--prefix=$PREFIX \
--enable-shared \
--enable-postproc \
--enable-gpl \
--enable-jni \
--disable-static \
--enable-cross-compile \
--disable-doc \
--cross-prefix=$CROSS_PREFIX- \
--cc=$CC \
--cxx=$CXX \
--sysroot=$TOOLCHAIN/sysroot \
--extra-cflags="-D__ANDROID_API__=$ANDROID_API -U_FILE_OFFSET_BITS" \
--arch=$ARCH \
--cpu=$CPU \
--target-os=android

make clean
make -j4
make install

echo "build android ffmpeg finished."

运行脚本,我们像上次一样,在安装目录得到了bin,include,lib,share几个目录,表示编译成功。

移植命令行

首先,我们像上次一样,拷贝lib目录下所有so库至我们的Android项目中。接下来,我们首先考虑如何实现命令行的移植。我们在使用ffmpeg命令的时候,实际上是在将命令行参数传给ffmpeg的main函数,并由main函数具体执行命令。那么我们要做的事情也就清晰了,即在android项目中使用ffmpeg的main函数,并向其传递参数。接下来的步骤如下:

我们拷贝整个include目录。这里我直接将libs和include目录放在和src同级(注意我们不建议在实际项目中这样组织目录层次。官方推荐的做法是将原生相关的文件统一放在cpp目录下管理)。可以看见,在include目录中,是我们所需要的众多头文件。

接下来,我们回到ffmpeg源码目录,这里有我们需要用到的一些文件。首先是fftools文件夹,这里是命令行调用ffmpeg的工具。我们复制其中的cmdutils.h和ffmpeg.h到android项目的include文件夹下(别忘了include是我们存放众多头文件的地方),再把cmdutils.c, ffmpeg.c, ffmpeg_filter.c, ffmpeg_hw.c, ffmpeg_opt.c五个文件复制到libs目录下。此外,我们还需要源码目录下编译后生成的config.h,将它也复制到include目录下。注意这里文件存放的位置不是绝对的,只要保证能合理地编写CMakeLists即可

到此,我们的目录结构如下:

结构1

结构2

接着,我们还需要修改其中的一部分代码,以使其适应Android APP下的应用情景。参考博客:https://blog.csdn.net/yhaolpz/article/details/77146156#commentBox

  1. 修改日志输出源
    我们希望能在调用ffmpeg时,将其日志输出导入至AS的logcat中,方便我们的开发与调试。为此,我们首先引入android的log库。在CMakeLists中新增如下代码:
1
2
3
4
5
6
find_library(log-lib log)
...
target_link_libraries(
...
${log-lib}
)

接下来,在include目录写一个androidlog.h文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifdef ANDROID
#include <android/log.h>
#ifndef LOG_TAG
#define MY_TAG "MYTAG"
#define AV_TAG "AVLOG"
#endif
#define LOGE(format, ...) __android_log_print(ANDROID_LOG_ERROR, MY_TAG, format, ##__VA_ARGS__)
#define LOGD(format, ...) __android_log_print(ANDROID_LOG_DEBUG, MY_TAG, format, ##__VA_ARGS__)
#define XLOGD(...) __android_log_print(ANDROID_LOG_INFO,AV_TAG,__VA_ARGS__)
#define XLOGE(...) __android_log_print(ANDROID_LOG_ERROR,AV_TAG,__VA_ARGS__)
#else
#define LOGE(format, ...) printf(MY_TAG format "\n", ##__VA_ARGS__)
#define LOGD(format, ...) printf(MY_TAG format "\n", ##__VA_ARGS__)
#define XLOGE(format, ...) fprintf(stdout, AV_TAG ": " format "\n", ##__VA_ARGS__)
#define XLOGI(format, ...) fprintf(stderr, AV_TAG ": " format "\n", ##__VA_ARGS__)
#endif

打开ffmpeg.c,引入头文件#include "androidlog.h",并实现原本为空的logg_callback_null函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void log_callback_null(void *ptr, int level, const char *fmt, va_list vl)
{
static int print_prefix = 1;
static int count;
static char prev[1024];
char line[1024];
static int is_atty;
av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix);
strcpy(prev, line);
if (level <= AV_LOG_WARNING){
XLOGE("%s", line);
}else{
XLOGD("%s", line);
}
}

在ffmpeg.c的main函数第一行加入如下代码:

1
av_log_set_callback(log_callback_null);

这样,以后在调用ffmpeg命令时,就可以像在shell里一样在logcat获得ffmpeg输出的日志信息。

  1. 执行命令后清除数据并保留进程
    在ffmpeg.c的ffmpeg_cleanup中末尾加上如下代码:
1
2
3
4
5
nb_filtergraphs = 0;
nb_output_files = 0;
nb_output_streams = 0;
nb_input_files = 0;
nb_input_streams = 0;

在ffmpeg.c的main函数的return语句调用前增加如下代码:

1
ffmpeg_cleanup(0);

再修改cmdutils.c中的exit_program函数如下:

1
2
3
4
int exit_program(int ret)
{
return ret;
}

注意由于我们修改了函数的返回值,故还需要到cmdutils.h中修改对应的函数声明:

1
int exit_program(int ret);

以上都完成后,不要忘了在ffmpeg.h中加上对main函数的声明,否则我们调用不到ffmpeg.c的main函数。大多数博客都喜欢将main函数的函数名改写为run, exec之类的其他名称,这个因人而异,修不修改没有问题。
到此,对ffmpeg源文件的修改就结束了,大多数博客也就到此结束了,他们可以愉快地在android中使用ffmpeg命令了。但此时如果我们尝试启动APP,会发现AS报出一长串的错误,都是找不到头文件。此时不要慌张,回到ffmpeg源码目录下寻找缺失的头文件并逐一复制即可。这里我丢失的头文件如下,可供参考:

  • compat目录下:va_copy.h
  • libavcodec目录下:matops.h
  • libavformat目录下:os_support.h
  • libavutil目录下:aarch64等架构目录(主要需要的是其中的timer.h),internal.h,libm.h,thread.h,timer.h

再次启动APP,应该编译通过,APP正常启动。如果你的AS依然报错,且不是丢失头文件,而是各种语法问题(如内联汇编限制符),你可以先尝试修正,如果始终不行,请检查ffmpeg编译脚本是否正确编写,目标架构等参数是否配置正确。

我们可以编写一个测试用例,这里我之前有一个将wav格式音频转码为aac格式的业务需求,是使用android的MediaCodec API实现的,其代码逻辑复杂冗长,我们尝试使用简单的ffmpeg命令实现这个需求。

编写如下工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
object FFmpegUtils {
init {
System.loadLibrary("jnitest")
}

fun WAV2AAC(pcmPath: String, aacPath: String) {
val command = arrayOf(
"ffmpeg",
"-i",
pcmPath,
aacPath
)
exec(command)
}

private external fun exec(args: Array<String>)
}

我们先是加载了native库,并且声明了一个native方法,用于接收命令参数。exec函数的原形如下:

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
JNIEXPORT void JNICALL
Java_com_eynnzerr_medialearning_utils_FFmpegUtils_exec(JNIEnv *env, jobject thiz,
jobjectArray args) {
int i = 0;
int argc = 0;
char **argv = NULL;
jstring *strr = NULL;

if (args != NULL) {
argc = (*env)->GetArrayLength(env, args);
argv = (char **) malloc(sizeof(char *) * argc);
strr = (jstring *) malloc(sizeof(jstring) * argc);

av_log(NULL, AV_LOG_INFO, "argc is %d", argc);

for (i = 0; i < argc; i ++) {
strr[i] = (jstring) (*env)->GetObjectArrayElement(env, args, i); // strr将传入的kotlin string转为jstring
argv[i] = (char *) (*env)->GetStringUTFChars(env, strr[i], 0); // argv将jstring转化为UTF Chars
av_log(NULL, AV_LOG_INFO, "receive argv: %s", argv[i]);
}
}

main(argc, argv);

for(i = 0; i < argc; i ++) {
(*env)->ReleaseStringUTFChars(env, strr[i], argv[i]);
}
free(argv);
free(strr);
}

实际测试,完美运行,成功移植FFmpeg命令行至Android平台。

该篇内容为原博客博文,原上传于2022年2月12日。可能已过时,仅作补档用途,谨慎参考!

前言

FFmpeg是一款开源音视频处理工具,简单易用,功能强大。将FFmpeg移植到Android平台上,可以使得我们在从事 Android 音视频开发时如虎添翼。上回我们已经在 ubuntu 上初步配置和安装了 FFmpeg,但这样还是远远不够的。接下来就探讨一下如何在 Android Studio 中集成FFmpeg。

适用版本

  • FFmpeg 5.0 “Lorentz”
  • ubuntu 20.04
  • NDK 21.4
  • CMake 3.18
  • Android Studio Bumblebee 2021.1.1

其中NDK和CMake为方便演示使用的是AS自带的版本,如果使用自己安装的版本可以参考官方教程进行配置。

Step1. 重新编译FFmpeg

FFmpeg本身是C语言编写的,所以我们需要使用NDK将其编译成适用于Android平台的so库,并通过jni进行调用。所以仅由我们在上一篇中简单的配置而产生的FFmpeg库是完全不能胜任的,需要设置必要的配置参数,重新编译。

首先运行

1
make uninstall & make distclean

以卸载和清除上次编译产生的文件。由于需要设置的参数较多,这一次我们选择编写Shell脚本来对FFmpeg进行配置和编译。这里我的脚本参考了同组学长 CarriSun 的写法,具体如下:

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
TOOLCHAIN=$HOME/Android/Sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64

export PATH=$PATH:$TOOLCHAIN/bin

CROSS_PREFIX=aarch64-linux-android
CROSS_PREFIX_CLANG=aarch64-linux-android21
PREFIX=$HOME/open/ffmpeg
ANDROID_API=21
ARCH=arm64
CPU=armv8-a

export CC=$CROSS_PREFIX_CLANG-clang
export CXX=$CROSS_PREFIX_CLANG-clang++

./configure \

--prefix=$PREFIX \
--enable-shared \
--disable-static \
--enable-cross-compile \
--disable-doc \
--cross-prefix=$CROSS_PREFIX- \
--cc=$CC \
--cxx=$CXX \
--sysroot=$TOOLCHAIN/sysroot \
--extra-cflags="-isysroot $TOOLCHAIN/sysroot -isystem $TOOLCHAIN/sysroot/usr/include/aarch64-linux-android -D__ANDROID_API__=$ANDROID_API -U_FILE_OFFSET_BITS" \
--arch=$ARCH \
--cpu=$CPU \
--target-os=android

make clean
make -j4
make install

echo "Build FFmpeg on arm64 finished."

TOOLCHAIN 指定了编译时的工具链,ARCH 和 CPU 指定了目标平台支持的架构和CPU(重要)。

对该脚本 chmod 和执行后,我们就重新编译出了其中一种适用于 Android 平台的 FFmpeg 了。转到安装目录下,可以看到include目录下的头文件和lib目录下的动态库。

step2. 配置Android Studio,导入动态库

打开Android Studio选择已有的项目(或者新建Native C++项目)。在模块级 build.gradle 中加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
android {
...

externalNativeBuild {
cmake {
path = "CMakeLists.txt"
version = "3.18.1"
}
}

defaultConfig {
...

ndk{ abiFilters 'arm64-v8a' }

...
}
}

其中cmake项是配置CMakeLists的路径和CMake的版本。
默认情况下,Gradle(无论是通过 Android Studio 使用,还是从命令行使用)会针对所有非弃用 ABI 进行构建。要限制应用支持的 ABI 集,请使用 abiFilters。如这里我填入了arm64-v8a,正是对应了编译FFmpeg时指定的目标CPU和架构。如果这里不设置abiFilter的话,之后构建app会出现问题。

关于abi,强烈建议读一下官网的说明:Android ABI

接下来,我们将编译生成的FFmpeg目录下include目录复制到app目录下,并创建cpp/arm64-v8a目录,且将FFmpeg/lib目录下所有so库复制过来。然后,我们就可以准备着手写CMakeList了:

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
37
38
39
40
41
42
43
cmake_minimum_required(VERSION 3.18)

project(jnitest C)

set(CMAKE_C_STANDARD 99)

aux_source_directory(/home/eynnzerr/open/AndroidProjectX/MediaLearning/app/cpp DIR_LIB)

add_library(jnitest SHARED ${DIR_LIB})

include_directories(include)

add_library(avcodec SHARED IMPORTED)
set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavcodec.so)

add_library(avdevice SHARED IMPORTED)
set_target_properties(avdevice PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavdevice.so)

add_library(avfilter SHARED IMPORTED)
set_target_properties(avfilter PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavfilter.so)

add_library(avformat SHARED IMPORTED)
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavformat.so)

add_library(avutil SHARED IMPORTED)
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavutil.so)

add_library(swresample SHARED IMPORTED)
set_target_properties(swresample PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libswresample.so)

add_library(swscale SHARED IMPORTED)
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libswscale.so)

target_link_libraries(
jnitest
avcodec
avdevice
avfilter
avformat
avutil
swresample
swscale
)

以上就是添加FFmpeg的共享库并将它们全部链接进来。至此,准备工作全部完成,sync一下项目即可。

Step.3 测试

cpp目录下引入jni头文件后,编写测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "jni.h"

#ifndef _Included_JNITest
#define _Included_JNITest

#ifdef __cplusplus
extern "C" {
#endif
#include <libavutil/avutil.h> //库导入放在extern C内!FFmpeg是C编写的。

JNIEXPORT jstring JNICALL Java_com_eynnzerr_medialearning_MainActivity_getString(JNIEnv *env, jobject thiz) {
return (*env)->NewStringUTF(env, avutil_configuration());
}

#ifdef __cplusplus
}
#endif
#endif

这里简单返回一下FFmpeg的配置信息。对应原生方法:

1
2
3
4
5
6
companion object {
init {
System.loadLibrary("jnitest")
}
}
private external fun getString(): String

我们在Log中打印出来看看:

1
Log.d(TAG, "onCreate: msg from JNI: ${getString()}")

开始构建app。注意由于这里我们选择的架构是arm64-v8a,因此app只能在支持该架构的手机上(虚拟机或实机)运行,Android Studio会给出这方面的警告。想要增加支持的架构,就可以指定不同的CPU多次编译FFmpeg,并在abiFilter中添加。

运行app,可以看到日志中确实输出了相关信息,证明FFmpeg已经成功移植到了Android平台。

报错记录

总体上来说,第一次尝试AS集成FFmpeg,整个过程还算比较顺利,只有在最后报了一些错误,也都已经解决,但秉持会有和我一样的小白也可能遇到同样的问题,特此记录,相互学习。

动态库路径问题

信息:ffmpeg: error while loading shared libraries: libavdevice.so.59: cannot open shared object file: No such file or directory

原因:通过源码安装软件未进行库搜索路径配置,系统找不到ffmpeg动态库路径。

解决sudo vim /etc/ld.so.conf 加上ffmpeg的lib文件夹路径,然后sudo ldconfig

架构匹配问题

信息

  1. clang: error: linker command failed with exit code 1 (use -v to see invocation)
  2. C/C++: /…/Android/Sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64/lib/gcc/i686-linux-android/4.9.x/../../../../i686-linux-android/bin/ld: error: ../../../../libs/libswscale.so: incompatible target
  3. C/C++: ../../../../cpp/main.c:16: error: undefined reference to ‘avutil_configuration’

原因:(在上面已经提到过)第一次移植ffmpeg时,app总是不能通过编译。对代码反复修改后仍未解决,并报出以上错误。其实从第二条错误信息中的incompatible target就可以看出,这有可能是因为目标架构不匹配的原因。下面是stack overflow上热心码友给出的解释:

The error you’re getting means that ndk-build is trying to use your .so file while compiling your module for an incompatible target.
Android supports several cpu architectures (armeabi, armeabi-v7a, arm64-v8a, x86, x86_64, mips, mips64). You can choose which one you want to support using the APP_ABI variable from Application.mk. If you set it to all, ndk-build will try to use this .so file you’re referencing for each of these architectures, but this cannot work.
Your .so file must have been compiled for Android platforms, and you need to have a different version of it for each architecture you’re supporting. You can give a dynamic reference to the right .so file, such as LOCAL_SRC_FILES := $(TARGET_ARCH_ABI)/libslabhidtouart.so so it looks for your .so file under armeabi-v7a folder when compiling for armeabi-v7a, under x86 for x86, etc.
Of course you need to provide these .so files. If you can’t get .so files for all the supported architectures, you’ll have to restrict your APP_ABI to the architectures of the .so file you have. You can determine the architecture your .so file has been compiled for using readelf.

然而,现如今互联网上大部分博客都没有强调ffmpeg移植的架构匹配性问题,有几篇中有所涉及,但也没有解释原因 (这里再次感叹音视频学习资料匮乏)

解决:首先在编译ffmpeg时,通过–cpu参数和–arch指定目标CPU与架构,再在android studio中配置ndk的abiFilter。

Android Studio 兼容性问题

信息:2 files found with path ‘lib/armeabi-v7a/libbufferhub.so‘ from inputs

原因:早期版本的Android Gradle插件要求使用以下命令显式打包CMake外部本机内部版本使用的所有预构建库 jniLibs:

1
2
3
4
5
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}

使用Android Gradle Plugin 4.0时,不再需要上述配置,并且会导致构建失败。现在,外部本机构建会自动打包这些库,因此将jniLibs结果与库明确打包在一起。为避免生成错误,只需jniLibs从build.gradle文件中删除配置即可。绝大多数过时的博客都因此极具误导性。

解决:在app:build.gradle中去掉sourceSets的设置

该篇内容为原博客博文,原上传于2022年10月29日。

前置知识

自动内存管理

说到程序的内存管理,C/C++开发人员具有绝对的权利。通过malloc()new,他们可以极其奔放地分配内存,自由掌控对象的“所有权”。然而能力越大责任也越大,伴随着操控内存的快感之后而来的则是维护内存的无尽痛苦。编译器不能发现潜在的内存问题,必须由开发者提前避免。喜闻乐见的问题包括但不限于:

  • 判断分配内存有效
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
sws = sws_getContext(
in_width,
in_height,
AV_PIX_FMT_YUV420P,
out_width,
out_height,
AV_PIX_FMT_YUV420P,
SWS_FAST_BILINEAR,
nullptr,
nullptr,
nullptr
);

if (!sws) {
av_log(nullptr, AV_LOG_INFO, "Cannot create sws context.\n");
}

// open writing stream of output file
outputFile = fopen(outputPath, "wb");
if (!outputFile) {
av_log(nullptr, AV_LOG_ERROR, "Failed to open output file!\n");
}

...

if (!x264Param) delete x264Param;
x264Param = new x264_param_t;
int ret = x264_param_default_preset(x264Param, "fast", "zerolatency");
if (ret < 0) {
av_log(nullptr, AV_LOG_ERROR, "Failed to set preset parameter!\n");
}

...

ret = x264_param_apply_profile(x264Param, x264_profile_names[1]);
if (ret < 0) {
av_log(nullptr, AV_LOG_ERROR, "Failed to apply main profile!\n");
}

encoder = x264_encoder_open(x264Param);
if(!encoder) {
av_log(nullptr, AV_LOG_ERROR, "Failed to open x264 encoder!\n");
}

// Write headers to file
int header_size = x264_encoder_headers(encoder, &nals, &nalCount);
if(header_size < 0) {
av_log(nullptr, AV_LOG_ERROR, "Error when calling x264_encoder_headers()!\n");
}
// outputStream << nals[0].p_payload;
if (!fwrite(nals[0].p_payload, sizeof(uint8_t), header_size, outputFile)) {
av_log(nullptr, AV_LOG_ERROR, "Failed to write header!\n");
}
  • 必要的内存释放代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
X264Encoder::~X264Encoder() {
if (encoder) {
x264_picture_clean(&inFrame);
x264_encoder_close(encoder);
encoder = nullptr;
}
if (outputFile) {
fclose(outputFile);
outputFile = nullptr;
}
if (sws) {
sws_freeContext(sws);
sws = nullptr;
}
delete mbQp;
}
  • 其它:野指针、越界、…(虽然这些多半是程序员自己粗心大意)

于是有人想到,如果能够自动管理对象的生命周期,写代码的时候可能就可以少考虑一些麻烦事,少写一些模版化的代码了。这就是自动内存管理的诞生。jvm 正具有自动内存管理的能力,对于 jvm 系语言的程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要操心为每一个new操作写配对的delete/free,也因此大大减少了内存泄漏和内存溢出的可能。虽然将内存控制的权利交给 jvm ,可以为开发者省去不少麻烦,但也正因如此,一旦真的出现了内存相关问题,如果不了解 jvm 的内存结构和管理策略,排查错误也将无从下手。

jvm内存结构

对象的自动清理——GC

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1。回收计数器值为0的对象内存。
优点 实现简单,判定效率高。
缺点 不能解决循环引用对象的回收。

可达性分析算法

以一系列被称为 GC Roots 的对象为根,从这些根节点开始向下搜索,搜索途径的路径称为引用链。当一个对象到 GC Roots 没有任何引用链(图论中的不可达),则证明该对象不可用,可以被回收。

可作为 GC Roots 的对象:

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

.hprof文件

.hprof是 jvm 的堆内存快照文件,可用于分析内存泄漏等异常问题。对其文件结构感兴趣的同学可以阅读协议文档

四种引用

强引用

强引用就是在代码中普遍存在的,类似val obj = Any()(new in java )这类的引用。只要强引用仍存在,GC 就永远不会回收被引用的对象。

软引用

用以描述尚有用但非必需的对象。对于软引用关联的对象,在系统即将发生 OOM 之前,会被 GC 列入回收范围之中进行第二次回收,如果这次回收后依然没有足够内存,则抛出 OOM 异常。

1
val obj = SoftReference(Any())

弱引用

也用以描述非必需对象,但强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次 GC 发生之前。当 GC 工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(即 LeakCanary 中所指的弱可达)。

1
val obj = WeakReference(Any())

虚引用

也称为幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用获取对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被 GC 回收时收到一个系统通知。

1
val obj = PhantomReference(Any())

引用队列

当注册的引用型对象(软引用、弱引用、虚引用)在 GC 检测到所引用的对象可达性发生改变时,会将这个引用型的对象添加到引用队列中。引用队列实际上只是持有着已经不再引用堆中的要被清除的对象的引用型对象,并不能使对象再次存活下去,其用处只是为了提醒程序员非强引用型变量所引用的对象已经具有不可达性,即这个对象已经从堆中拿不到了。

LeakCanary

LeakCanary 是 Android 开源社区巨头 Square 公司出品的 Android app 内存泄漏检测工具。

2.x版本Logo: 一只寄了的金丝雀

2.0-alpha-1版本以后,LeakCanary 经由纯 kotlin 重写,并且更新了许多 API 沿用至今,故此处只简要介绍 LeakCanary2 的使用,以及基于 LeakCanary2 源码分析其原理。

基本使用

  1. 引入该库依赖。
1
2
3
4
5
6
7
val leakCanaryVersion = "2.9.1"

dependencies {
...
debugImplementation("com.squareup.leakcanary:leakcanary-android:$leakCanaryVersion")
...
}
  1. 模拟一个典型的内存泄漏场景
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@DelicateCoroutinesApi
class MainActivity : ComponentActivity() {

// 匿名内部类隐式持有activity引用
private val handler: Handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
Log.d(TAG, "handleMessage: handler msg: ${msg.what}")
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
val context = LocalContext.current

AVPlayerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = {
GlobalScope.launch(Dispatchers.IO) {
while (true) {
handler.sendEmptyMessage(1)
delay(1000)
}
}
Toast.makeText(context, "start job.", Toast.LENGTH_SHORT).show()
},
) {
Text(text = "start job")
}
Button(
onClick = {
startActivity(Intent(context, SecondActivity::class.java))
finish()
}
) {
Text(text = "start activity")
}
}
}
}
}
}

override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy: called")
}

companion object {
private const val TAG = "MainActivity"
}
}

这里我们以匿名内部类的方式在MainActivity中创建了一个Handler,将主线程的looper传给该handler,并创建了一个协程让handler不断向主线程发送空消息。点击第一个按钮将启动该协程,第二个按钮将跳转至另一个activity并立即销毁当前activity

分析不难得知,虽然销毁了activity,但协程中的线程依然活跃并作为GC Root 对象。而该线程又持有handler实例,handler作为内部类又隐式持有外部mainActivity实例,故存在以下引用链使得mainActivity可达(随便找了个在线画图网页,请无视掉水印):

  1. 启动应用,不一会儿便可以看到LeakCanary弹出一则通知,表示探测到了内存泄漏:

点击该通知,将开始导出并分析app运行时jvm堆的信息。当提示完成时,再次点击通知,将进入LeakCanary提供的客户端:

点击这个刚感知到的内存泄漏条目,堆的信息将以可视化的形式呈现:

或者我们也可以从日志中获取引用链的信息,示例输出的日志如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
====================================
HEAP ANALYSIS RESULT
====================================
1 APPLICATION LEAKS

References underlined with "~~~" are likely causes.
Learn more at https://squ.re/leaks.

108144 bytes retained by leaking objects
Signature: 2dbd12c5ad0a3810a8158c3cd35a69dcb07f496d
┬───
│ GC Root: Thread object

├─ java.lang.Thread instance
│ Leaking: UNKNOWN
│ Retaining 254 B in 7 objects
│ Thread name: 'kotlinx.coroutines.DefaultExecutor'
│ ↓ Thread.parkBlocker
│ ~~~~~~~~~~~
├─ kotlinx.coroutines.DefaultExecutor instance
│ Leaking: UNKNOWN
│ Retaining 108.5 kB in 2579 objects
│ ↓ EventLoopImplBase._delayed
│ ~~~~~~~~
├─ kotlinx.coroutines.EventLoopImplBase$DelayedTaskQueue instance
│ Leaking: UNKNOWN
│ Retaining 108.4 kB in 2578 objects
│ ↓ ThreadSafeHeap.a
│ ~
├─ kotlinx.coroutines.internal.ThreadSafeHeapNode[] array
│ Leaking: UNKNOWN
│ Retaining 108.4 kB in 2577 objects
│ ↓ ThreadSafeHeapNode[0]
│ ~~~
├─ kotlinx.coroutines.EventLoopImplBase$DelayedResumeTask instance
│ Leaking: UNKNOWN
│ Retaining 108.4 kB in 2576 objects
│ ↓ EventLoopImplBase$DelayedResumeTask.cont
│ ~~~~
├─ kotlinx.coroutines.CancellableContinuationImpl instance
│ Leaking: UNKNOWN
│ Retaining 108.3 kB in 2575 objects
│ ↓ CancellableContinuationImpl.delegate
│ ~~~~~~~~
├─ kotlinx.coroutines.internal.DispatchedContinuation instance
│ Leaking: UNKNOWN
│ Retaining 108.2 kB in 2570 objects
│ ↓ DispatchedContinuation.continuation
│ ~~~~~~~~~~~~
├─ com.eynnzerr.avplayer.MainActivity$onCreate$1$1$1$1$1$1 instance
│ Leaking: UNKNOWN
│ Retaining 108.2 kB in 2569 objects
│ Anonymous subclass of kotlin.coroutines.jvm.internal.SuspendLambda
│ this$0 instance of com.eynnzerr.avplayer.MainActivity with mDestroyed = true
│ ↓ MainActivity$onCreate$1$1$1$1$1$1.this$0
│ ~~~~~~
╰→ com.eynnzerr.avplayer.MainActivity instance
​ Leaking: YES (ObjectWatcher was watching this because com.eynnzerr.avplayer.MainActivity received
​ Activity#onDestroy() callback and Activity#mDestroyed is true)
​ Retaining 108.1 kB in 2568 objects
​ key = 427e7b26-a712-4eee-81f2-3040f34111a8
​ watchDurationMillis = 125452
​ retainedDurationMillis = 120450
​ mApplication instance of android.app.Application
​ mBase instance of android.app.ContextImpl
====================================
0 LIBRARY LEAKS

A Library Leak is a leak caused by a known bug in 3rd party code that you do not have control over.
See https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/#4-categorizing-leaks
====================================
0 UNREACHABLE OBJECTS

An unreachable object is still in memory but LeakCanary could not find a strong reference path
from GC roots.
====================================
METADATA

Please include this in bug reports and Stack Overflow questions.

Build.VERSION.SDK_INT: 30
Build.MANUFACTURER: unknown
LeakCanary version: 2.9.1
App process name: com.eynnzerr.avplayer
Class count: 19677
Instance count: 104943
Primitive array count: 86281
Object array count: 17593
Thread count: 20
Heap total bytes: 16092598
Bitmap count: 0
Bitmap total bytes: 0
Large bitmap count: 0
Large bitmap total bytes: 0
Stats: LruCache[maxSize=3000,hits=35774,misses=78728,hitRate=31%]
RandomAccess[bytes=3855083,reads=78728,travel=23243159115,range=18698859,size=24675970]
Heap dump reason: user request
Analysis duration: 3073 ms
Heap dump file path: /storage/emulated/0/Download/leakcanary-com.eynnzerr.avplayer/2022-10-27_22-39-44_274.hprof
Heap dump timestamp: 1666881589949
Heap dump duration: 1291 ms

根据以上日志输出分析,以线程对象Thread object作为 GC Root,中间途径一系列协程内部的引用链,到达continuation对象,由continuation又能达到一个限定名为com.eynnzerr.avplayer.MainActivity$onCreate$1$1$1$1$1$1的奇怪对象,根据日志信息可以判定这个对象实际上是该协程的SuspendLambda匿名内部类对象,它封装了函数体内的代码块,也即间接代表着handler实例,因此便能最终到达MainActivity的实例,与理论相符。

原理分析

下面我们结合 LeakCanary2 的源码,对其运行原理进行分析。

非侵入式注册

首先,如果我们在项目中用过 LeakCanary1,不难会发现 LeakCanary2 相对于 1.x 版本一大变化在于,曾经我们需要在项目中重写Application并在其onCreate()方法中手动装载LeakCanary的监视器RefWatcher,为其传入context对象以完成其初始化:

1
2
3
...
refWatcher = LeakCanary.install(this);
...

但在 LeakCanary2 中,我们不再需要向项目中新增任何代码,只需要引入依赖即可。这是怎么实现的呢?实际上,这里用到了四大组件中ContentProvider的特性:在ContentProvider的生命周期中,它是在Application.attach()之后和Application.create()之前调用ContentProvider.onCreate()初始化,也即不需要我们手动进行显式初始化,并且此时也能获取到context,故可以通过一个ContentProvider实现非侵入式的注册,不会向现有项目引入任何注册相关的代码。所以在阅读源码时,也就不难理解为什么这里会继承ContentProvider。并且因为我们只是利用了contentProvider不仅能自动创建,还能在创建时拿到context的特性实现非侵入式注册,故对contentProvider的增删改查函数自然也没有实现(= null)

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
internal class MainProcessAppWatcherInstaller : ContentProvider() {

override fun onCreate(): Boolean {
val application = context!!.applicationContext as Application
AppWatcher.manualInstall(application)
return true
}

// 增删改查的零实现
override fun query(
uri: Uri,
projectionArg: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? = null

override fun getType(uri: Uri): String? = null

override fun insert(uri: Uri, contentValues: ContentValues?): Uri? = null

override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0

override fun update(
uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?
): Int = 0
}

MainProcessAppWatcherInstaller将在项目构建后在Manifest中声明。

1
2
3
4
5
6
7
<application>
<provider
android:name="leakcanary.internal.MainProcessAppWatcherInstaller"
android:authorities="${applicationId}.leakcanary-installer"
android:enabled="@bool/leak_canary_watcher_auto_install"
android:exported="false" />
</application>

注意到这里将获取的context传入了AppWatcher.manualInstall()这个函数:

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
37
38
@JvmOverloads
fun manualInstall(
application: Application,
retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
) {
// 检查是否在主线程中
checkMainThread()

// 如果已经安装过了则抛异常
if (isInstalled) {
throw IllegalStateException(
"AppWatcher already installed, see exception cause for prior install call", installCause
)
}

// 检查输入参数 retainedDelayMillis 的合法性
check(retainedDelayMillis >= 0) {
"retainedDelayMillis $retainedDelayMillis must be at least 0 ms"
}
this.retainedDelayMillis = retainedDelayMillis

// debug时开启日志
if (application.isDebuggableBuild) {
LogcatSharkLog.install()
}

// Requires AppWatcher.objectWatcher to be set
LeakCanaryDelegate.loadLeakCanary(application)

// 为传入待注册的 InstallableWatcher 一一调用注册
watchersToInstall.forEach {
it.install()
}

// Only install after we're fully done with init.
installCause = RuntimeException("manualInstall() first called here")
}

注意到此处watchersToInstall有默认值appDefaultWatchers(application),实际上就是 LeakCanary 默认注册的一些预先定义的监视器:

1
2
3
4
5
6
7
8
9
10
11
fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
FragmentAndViewModelWatcher(application, reachabilityWatcher),
RootViewWatcher(reachabilityWatcher),
ServiceWatcher(reachabilityWatcher)
)
}

这里也就解释了为什么 LeakCanary2 宣称可以自动检测ActivityFragmentAndViewModelRootViewService的内存泄漏。

捕捉内存泄漏

接下来我们继续看具体实现原理,以ActivityWatcher为例:

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
/**
* Expects activities to become weakly reachable soon after they receive the [Activity.onDestroy]
* callback.
*/
class ActivityWatcher(
private val application: Application,
private val reachabilityWatcher: ReachabilityWatcher
) : InstallableWatcher {

private val lifecycleCallbacks =
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy() callback"
)
}
}

override fun install() {
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}

override fun uninstall() {
application.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
}
}

这段代码有趣的地方有很多,我们首先把注意力聚焦在lifecycleCallbacks字段上。注意到这是一个实现了Application.ActivityLifecycleCallbacks接口的对象,并且委托给了noOpDelegate。乍看这个接口要求实现Activity生命周期中各个阶段的回调,但是这里只重写了onActivityDestroyed()方法。之所以能这么做是因为我们把其它回调函数的实现委托给了noOpDelegate,而为什么要这么做在于后续我们只会用到onActivityDestroyed()这一个回调,以此防止重写其它回调方法,实现更安全的封装。

接着我们注意到,在回调的内部调用了reachabilityWatcher.expectWeaklyReachable()函数。这个函数名很有意思,我们首先解释一下何谓weaklyReachable,即弱可达,其意为当前实例在引用链中仅可通过弱引用到达。也就是说, leakCanary 在其内存泄漏检测中使用到了弱引用。实际上,这里就运用到了我们之前提到的特性:当一个实例弱可达时,其将会在下次 gc 中被回收;且如果在构建WeakReference时,传入一个ReferenceQueue,则当弱引用持有的对象被回收时, jvm 会将这个弱引用放入构造时关联的引用队列中。不用强引用,显然是因为强引用始终不会被回收;不用软引用,是因为软引用只会在OOM前被回收,不便于控制;不用虚引用,是因为虚引用与 gc 无关,不能起作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.lang.ref.ReferenceQueue
import java.lang.ref.WeakReference

fun main() {
val rq = ReferenceQueue<Pair<String, Int>?>()
// var pair: Pair<String, Int>? = "kotlin" to 233 // 仿 LeakCanary 使用键值对 此处为强引用
val weakReference = WeakReference(pair, rq) // 创建弱引用并关联引用队列

println(rq.poll())

pair = null // 清除对键值对的强引用
System.gc() // 手动gc一把
Thread.sleep(1000) // 开始gc后阻塞retainedTimeMills时间,等待pair被回收

println(rq.poll())
}

以上测试代码输出如下:

1
2
null
java.lang.ref.WeakReference@2a84aee7

利用这个特性,我们便有可能感知到一个对象是否内存泄漏了,大致思路如下:以 Activity 为例, 监听 Activity 的回调,当 Activity 调用onDestroy()时,将其通过弱引用保存到一个ReferenceQueue中,在每次 gc 后等待一段时间,检查ReferenceQueue是否有值。正常情况下, Activity 在 destroy 后,生命周期走到尽头,理应被 gc 回收。此时除了我们添加的弱引用外不会再被引用,即此后 Activity 将处于弱可达状态。那么按照弱引用的特性,其将会在 gc 后被放入引用队列,即referenceQueue.poll()将返回弱引用值。但如果发生了内存泄露,即此时除了我们添加的弱引用外,Activity 还有别的强引用,那么 gc 时就不会回收该 Activity,即referenceQueue.poll()将一直返回null。据此,就能得知 Activity 是否发生了内存泄漏!

回到源码,我们说到在ActivityWatcher中设置了onActivityDestroyed的回调函数,并在其中调用了reachabilityWatcher.expectWeaklyReachable()函数。首先,这里的reachabilityWatcher是通过默认值传入的objectWatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// AppWatcher.kt
val objectWatcher = ObjectWatcher(
clock = { SystemClock.uptimeMillis() },
checkRetainedExecutor = {
check(isInstalled) {
"AppWatcher not installed"
}
mainHandler.postDelayed(it, retainedDelayMillis)
}, // 通过 Executor 实现延迟探测
isEnabled = { true }
)
...

fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
...
)
}
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ObjectWatcher.kt
class ObjectWatcher constructor(
private val clock: Clock,
private val checkRetainedExecutor: Executor,
private val isEnabled: () -> Boolean = { true }
) : ReachabilityWatcher {

private val watchedObjects = mutableMapOf<String, KeyedWeakReference>() // 以键值对形式保存传入的观察对象弱引用。当对象弱可达时会被移出map
private val queue = ReferenceQueue<Any>() // 维护了一个上文提到的引用队列
...
@Synchronized override fun expectWeaklyReachable(
watchedObject: Any, // 调用方法时传入观察对象,如 Activity
description: String
) {
if (!isEnabled()) {
return
}
// 由于我们判别内存泄漏的依据是看引用队列是否返回null,故先在引用队列中将弱可达的引用清除。
removeWeaklyReachableObjects()
val key = UUID.randomUUID()
.toString()
val watchUptimeMillis = clock.uptimeMillis()
val reference =
KeyedWeakReference(
watchedObject,
key,
description,
watchUptimeMillis,
queue
) // 构造弱引用,关联引用队列

watchedObjects[key] = reference // 每个传入的观察对象都会存入这个map
checkRetainedExecutor.execute {
moveToRetained(key) // 工作线程中延迟retainedDelayMillis再执行。
}
}
...
// 这个函数被反复调用
private fun removeWeaklyReachableObjects() {
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
// 返回值不为null,说明当前这个被观察的对象未发生内存泄漏,故将其从map中移除
watchedObjects.remove(ref.key)
}
} while (ref != null) // 清空队列
}
...
@Synchronized private fun moveToRetained(key: String) {
removeWeaklyReachableObjects()
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
// 由于弱可达(未内存泄漏)的观察对象都已经过removeWeaklyReachableObjects()从watchedObjects
// 移除,故对于当前观察对象,若其在watchedObjects中仍存在,则证明其发生了内存泄漏。
retainedRef.retainedUptimeMillis = clock.uptimeMillis() // 标记该对象内存泄漏了
onObjectRetainedListeners.forEach { it.onObjectRetained() } // 调用发生内存泄漏时的回调
}
}
}

moveToRetained()中最后调用的回调,将调用HeapDumpTrigger.checkRetainedInstances()函数,进行 dump hprof 前的最后检查:

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
37
38
39
// HeapDumpTrigger.kt 对应实例InternalLeakCanary.heapDumpTrigger
private fun checkRetainedObjects() {
...

// 统计可能发生了内存泄漏的被观察对象的个数。返回的正是 watchedObjects.count
var retainedReferenceCount = objectWatcher.retainedObjectCount

// 由于没有被回收的原因可能是延迟等待时间不够长, GC 尚未来得及回收,故这里再进行一次 GC
if (retainedReferenceCount > 0) {
gcTrigger.runGc()
retainedReferenceCount = objectWatcher.retainedObjectCount
}

if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return

val now = SystemClock.uptimeMillis()
val elapsedSinceLastDumpMillis = now - lastHeapDumpUptimeMillis
// 为避免频繁dump操作,节省资源,两次 dump 之间时间间隔少于阈值则直接返回
if (elapsedSinceLastDumpMillis < WAIT_BETWEEN_HEAP_DUMPS_MILLIS) {
onRetainInstanceListener.onEvent(DumpHappenedRecently)
showRetainedCountNotification(
objectCount = retainedReferenceCount,
contentText = application.getString(R.string.leak_canary_notification_retained_dump_wait)
)
scheduleRetainedObjectCheck(
delayMillis = WAIT_BETWEEN_HEAP_DUMPS_MILLIS - elapsedSinceLastDumpMillis
)
return
}

dismissRetainedCountNotification()
val visibility = if (applicationVisible) "visible" else "not visible"
// 执行 dump 操作
dumpHeap(
retainedReferenceCount = retainedReferenceCount,
retry = true,
reason = "$retainedReferenceCount retained objects, app is $visibility"
)
}

调用dumpHeap导出并分析 hprof 文件:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private fun dumpHeap(
retainedReferenceCount: Int,
retry: Boolean,
reason: String
) {
// 指定 dump 路径 新建文件
val directoryProvider =
InternalLeakCanary.createLeakDirectoryProvider(InternalLeakCanary.application)
val heapDumpFile = directoryProvider.newHeapDumpFile()

val durationMillis: Long
if (currentEventUniqueId == null) {
currentEventUniqueId = UUID.randomUUID().toString()
}
try {
InternalLeakCanary.sendEvent(DumpingHeap(currentEventUniqueId!!))

if (heapDumpFile == null) {
throw RuntimeException("Could not create heap dump file")
}
saveResourceIdNamesToMemory()
val heapDumpUptimeMillis = SystemClock.uptimeMillis()
KeyedWeakReference.heapDumpUptimeMillis = heapDumpUptimeMillis
durationMillis = measureDurationMillis {
// 关键:执行dump hprof到指定文件
configProvider().heapDumper.dumpHeap(heapDumpFile)

}
if (heapDumpFile.length() == 0L) {
throw RuntimeException("Dumped heap file is 0 byte length")
}
lastDisplayedRetainedObjectCount = 0
lastHeapDumpUptimeMillis = SystemClock.uptimeMillis()
objectWatcher.clearObjectsWatchedBefore(heapDumpUptimeMillis)
currentEventUniqueId = UUID.randomUUID().toString()

// 关键:开启 workManager 进行堆分析
InternalLeakCanary.sendEvent(HeapDump(currentEventUniqueId!!, heapDumpFile, durationMillis, reason))

} catch (throwable: Throwable) {
// dump 失败了,重试,再不行就显示通知提示失败
InternalLeakCanary.sendEvent(HeapDumpFailed(currentEventUniqueId!!, throwable, retry))

if (retry) {
scheduleRetainedObjectCheck(
delayMillis = WAIT_AFTER_DUMP_FAILED_MILLIS
)
}
showRetainedCountNotification(
objectCount = retainedReferenceCount,
contentText = application.getString(
R.string.leak_canary_notification_retained_dump_failed
)
)
return
}
}

以上代码执行了两个最为关键的逻辑:导出 hprof 和分析 hprof。

首先看 hprof 的导出操作:

1
2
3
4
// HeapDumpTrigger.kt
durationMillis = measureDurationMillis {
configProvider().heapDumper.dumpHeap(heapDumpFile)
}

这里通过configProvider()拿到了同一个包下的LeakCanary.Config内部类实例,并调用其heapDumper.dumpHeap()函数:

1
2
// LeakCanary.kt
val heapDumper: HeapDumper = AndroidDebugHeapDumper,
1
2
3
4
5
6
// AndroidDebugHeapDumper.kt
object AndroidDebugHeapDumper : HeapDumper {
override fun dumpHeap(heapDumpFile: File) {
Debug.dumpHprofData(heapDumpFile.absolutePath)
}
}

到这里就明确了: leakCanary 导出 hprof 的方式实际上就是调用 Android 系统给我们提供的接口:Debug.dumpHprofData(path : String)

再看 hprof 的分析操作:

1
2
3
4
5
6
7
8
9
// HeapDumpTrigger.kt
InternalLeakCanary.sendEvent(
HeapDump(
currentEventUniqueId!!,
heapDumpFile,
durationMillis,
reason
)
)

LeakCanary 实现了一套自己的简要的消息机制,Event是其预先定义的一些事件,EventListener是其监听到Event时的回调接口:

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
// EventListener.kt
fun interface EventListener {

// 同属于Event,但需要各自不同的参数,故使用sealed class
sealed class Event(
val uniqueId: String
) : Serializable {

class DumpingHeap(uniqueId: String) : Event(uniqueId)

// 这里可见以上代码中的HeapDump就是一种 Event
class HeapDump(
uniqueId: String,
val file: File,
val durationMillis: Long,
val reason: String
) : Event(uniqueId)

class HeapDumpFailed(
uniqueId: String,
val exception: Throwable,
val willRetryLater: Boolean
) : Event(uniqueId)

class HeapAnalysisProgress(
uniqueId: String,
val step: Step,
val progressPercent: Double
) : Event(uniqueId)

...
}

fun onEvent(event: Event)
}

再看InteralLeakCanary.sendEvent()这个函数:

1
2
3
4
5
6
// InternalLeakCanary.kt
fun sendEvent(event: Event) {
for(listener in LeakCanary.config.eventListeners) {
listener.onEvent(event)
}
}

其实就是逐个调用每个EventListeneronEvent()函数罢了。再看LeakCanary.config.eventListeners中都注册了哪些监听器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// LeakCanary.kt
val eventListeners: List<EventListener> = listOf(
LogcatEventListener, // 负责在接收到对应事件时打印日志
ToastEventListener, // 负责在接收到对应事件时弹出Toast
LazyForwardingEventListener { // 负责在接收到对应事件时弹出通知
if (InternalLeakCanary.formFactor == TV) TvEventListener else NotificationEventListener
},
// 关键: 创建工作线程,对堆快照文件进行分析。之前版本中使用的是 Service
when {
RemoteWorkManagerHeapAnalyzer.remoteLeakCanaryServiceInClasspath ->
RemoteWorkManagerHeapAnalyzer
WorkManagerHeapAnalyzer.validWorkManagerInClasspath -> WorkManagerHeapAnalyzer
else -> BackgroundThreadHeapAnalyzer
}
),

与堆分析有关的是最后一个listener,这里根据情况分为了更细化的三种 Analyzer ,但它们都实现了EventListener接口,分别对应堆分析的三种运行场所,可由开发者自主选择(此处略过):

  • 位于另一进程的workManager
  • 位于本进程的workManager
  • 位于本进程的一个后台线程中
    虽然执行场所不同,但它们都完成同一件工作:即在工作线程中完成对堆快照文件的分析。那么我们就以WorkManagerHeapAnalyzer为例,分析接下来的流程。
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
// WorkManagerHeapAnalyzer.kt
object WorkManagerHeapAnalyzer : EventListener {

// 只有当我们的app导入了相关依赖后才能确定有workManager可以用,并且对其版本也有要求,
// 故这里只能通过反射动态加载workManager类,供后续使用
internal val validWorkManagerInClasspath by lazy {
try {
Class.forName("androidx.work.WorkManager")
val dataBuilderClass = Class.forName("androidx.work.Data\$Builder")
dataBuilderClass.declaredMethods.any { it.name == "putByteArray" }.apply {
if (!this) {
SharkLog.d { "Could not find androidx.work.Data\$Builder.putByteArray, WorkManager should be at least 2.1.0." }
}
}
} catch (ignored: Throwable) {
false
}
}

...

override fun onEvent(event: Event) {
if (event is HeapDump) {
// HeapAnalyzerWorker继承自Worker
val heapAnalysisRequest = OneTimeWorkRequest.Builder(HeapAnalyzerWorker::class.java).apply {
setInputData(event.asWorkerInputData())
addExpeditedFlag()
}.build()
SharkLog.d { "Enqueuing heap analysis for ${event.file} on WorkManager remote worker" }
val application = InternalLeakCanary.application
// 获取workManager实例并执行worker
WorkManager.getInstance(application).enqueue(heapAnalysisRequest)
}
}
}

可以看到我们通过反射加载WorkManager类。之后在onEvent()中就是常规的workManager的用法了:构建Worker,传入workManager执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HeapAnalyzerWorker.kt
internal class HeapAnalyzerWorker(appContext: Context, workerParams: WorkerParameters) :
Worker(appContext, workerParams) {
override fun doWork(): Result {
// 关键:调用runAnalysisBlocking进行分析
val doneEvent =
AndroidDebugHeapAnalyzer.runAnalysisBlocking(inputData.asEvent()) { event ->
InternalLeakCanary.sendEvent(event)
}
InternalLeakCanary.sendEvent(doneEvent)
return Result.success()
}

...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// AndroidDebugHeapAnalyzer.kt
fun runAnalysisBlocking(
heapDumped: HeapDump,
isCanceled: () -> Boolean = { false },
progressEventListener: (HeapAnalysisProgress) -> Unit
): HeapAnalysisDone<*> {
val progressListener = OnAnalysisProgressListener { step ->
val percent = (step.ordinal * 1.0) / OnAnalysisProgressListener.Step.values().size
progressEventListener(HeapAnalysisProgress(heapDumped.uniqueId, step, percent))
}

val heapDumpFile = heapDumped.file
val heapDumpDurationMillis = heapDumped.durationMillis
val heapDumpReason = heapDumped.reason

val heapAnalysis = if (heapDumpFile.exists()) {
// 使用Shark API 进行堆分析
analyzeHeap(heapDumpFile, progressListener, isCanceled)
} else {
missingFileFailure(heapDumpFile)
}

...省略一大段代码
}

其实到这一步,代码的执行重点就转到调用Shark提供的堆分析工具了。Shark同样出自 Square 之手,在 LeakCanary2 中用以代替之前版本中使用的堆分析工具HAHA。结合注释看Shark的用法:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
private fun analyzeHeap(
heapDumpFile: File,
progressListener: OnAnalysisProgressListener,
isCanceled: () -> Boolean
): HeapAnalysis {
val config = LeakCanary.config
val heapAnalyzer = HeapAnalyzer(progressListener)
val proguardMappingReader = try {
ProguardMappingReader(application.assets.open(PROGUARD_MAPPING_FILE_NAME))
} catch (e: IOException) {
null
}

progressListener.onAnalysisProgress(PARSING_HEAP_DUMP)

val sourceProvider =
ConstantMemoryMetricsDualSourceProvider(ThrowingCancelableFileSourceProvider(heapDumpFile) {
if (isCanceled()) {
throw RuntimeException("Analysis canceled")
}
})

// Shark API openHeapGraph将解析指定hprof文件并返回一个描述其结构的图(graph)
val closeableGraph = try {
sourceProvider.openHeapGraph(proguardMapping = proguardMappingReader?.readProguardMapping())
} catch (throwable: Throwable) {
return HeapAnalysisFailure(
heapDumpFile = heapDumpFile,
createdAtTimeMillis = System.currentTimeMillis(),
analysisDurationMillis = 0,
exception = HeapAnalysisException(throwable)
)
}
return closeableGraph
.use { graph ->
// 向heapAnalyzer.analyze函数传入graph进行分析,结果包装为result
val result = heapAnalyzer.analyze(
heapDumpFile = heapDumpFile,
graph = graph,
leakingObjectFinder = config.leakingObjectFinder,
referenceMatchers = config.referenceMatchers,
computeRetainedHeapSize = config.computeRetainedHeapSize,
objectInspectors = config.objectInspectors,
metadataExtractor = config.metadataExtractor
)
if (result is HeapAnalysisSuccess) {
val lruCacheStats = (graph as HprofHeapGraph).lruCacheStats()
val randomAccessStats =
"RandomAccess[" +
"bytes=${sourceProvider.randomAccessByteReads}," +
"reads=${sourceProvider.randomAccessReadCount}," +
"travel=${sourceProvider.randomAccessByteTravel}," +
"range=${sourceProvider.byteTravelRange}," +
"size=${heapDumpFile.length()}" +
"]"
val stats = "$lruCacheStats $randomAccessStats"
result.copy(metadata = result.metadata + ("Stats" to stats))
} else result
}
}
定位泄漏对象

ClosableHeapGraph就是实现了closable接口的HeapGraph子接口,故我们分析HeapGraph的结构:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
interface HeapGraph {
val identifierByteSize: Int

val context: GraphContext

val objectCount: Int

val classCount: Int

val instanceCount: Int

val objectArrayCount: Int

val primitiveArrayCount: Int

val gcRoots: List<GcRoot>

//所有对象的序列,包括类对象、实例对象、对象数组、原始类型数组
val objects: Sequence<HeapObject>

//类对象序列
val classes: Sequence<HeapClass>

//实例对象数组
val instances: Sequence<HeapInstance>

//对象数组序列
val objectArrays: Sequence<HeapObjectArray>

//原始类型数组序列
val primitiveArrays: Sequence<HeapPrimitiveArray>

@Throws(IllegalArgumentException::class)
fun findObjectById(objectId: Long): HeapObject

@Throws(IllegalArgumentException::class)
fun findObjectByIndex(objectIndex: Int): HeapObject

fun findObjectByIdOrNull(objectId: Long): HeapObject?

// 关键
fun findClassByName(className: String): HeapClass?

fun objectExists(objectId: Long): Boolean

fun findHeapDumpIndex(objectId: Long): Int

fun findObjectByHeapDumpIndex(heapDumpIndex: Int): HeapObject
}

可以看到HeapGraph可以解析出该堆快照的信息,包括各种对象,并提供索引方法访问。那么,我们便有方法探测堆中发生内存泄漏的对象的位置了:

  1. 首先根据上文分析,可知此时目标对象被com.squareup.leakcanary.KeyedWeakReference所持有,故可以用findClassByName()方法传入全限定名找到这个类;
  2. 解析这个类的实例域,找到字段名和引用对象的ID,再用findObjectById()方法就能定位到目标对象了。

Shark在解析得到heapGraph的背后封装了大量逻辑,使得堆分析十分简便易用。是个好库,感恩 Square!

确定泄漏引用链

在之前 LeakCanary2 的简单使用示例中,我们可以看到最终在 app 里显示了从 GC Roots 对象到内存泄漏对象的引用链,这一步便是确定这条引用链。由于到内存泄漏对象可能存在多条引用链,故Shark选择 BFS 找出最短引用链。

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
37
38
39
40
41
private fun State.findPathsFromGcRoots(): PathFindingResults {
// 首先让 GCRoots 对象全部入队
enqueueGcRoots()

val shortestPathsToLeakingObjects = mutableListOf<ReferencePathNode>()
visitingQueue@ while (queuesNotEmpty) {
val node = poll() // 队列中对象依次出队

// 如果是内存泄漏的对象,则记录下来,否则继续
if (leakingObjectIds.contains(node.objectId)) {
shortestPathsToLeakingObjects.add(node) // 存储发生了内存泄漏的对象,用于反推引用链

// 判断已经找到的内存泄漏的对象个数,如果已经找完了则提前结束搜索
if (shortestPathsToLeakingObjects.size == leakingObjectIds.size()) {
if (computeRetainedHeapSize) {
listener.onAnalysisProgress(FINDING_DOMINATORS)
} else {
break@visitingQueue
}
}
}

// 解析对象
val heapObject = graph.findObjectById(node.objectId)
objectReferenceReader.read(heapObject).forEach { reference ->
// 解析邻居 邻居入队
val newNode = ChildNode(
objectId = reference.valueObjectId,
parent = node,
lazyDetailsResolver = reference.lazyDetailsResolver
)
enqueue(newNode, isLowPriority = reference.isLowPriority)
}
}

// 路径解析通过反推链表完成,这里不再赘述
return PathFindingResults(
shortestPathsToLeakingObjects,
if (visitTracker is Dominated) visitTracker.dominatorTree else null
)
}

至此, LeakCanary 检测内存泄漏的主要流程便走完了。我们了解了从 app 启动开始,contentProvider注册监视器,到检测到内存泄漏,触发回调,产生 hprof 文件,解析堆结构,到计算引用链为止 leakCanary 的工作原理,再后续的工作只剩下 UI 层的显示,以及和 LeakCanary app 的 IPC 通信,不再属于我们今天讲解的重点,便不再赘述,有兴趣的同学可以自己下载 LeakCanary2 源码学习。

总结

  • 利用弱引用和引用队列的特性捕获内存泄漏;
  • 利用WorkManager(动态获取)执行后台任务;
  • 利用Shark解析堆快照文件;
  • 利用广度优先搜索确定引用链。

经常能在网上看到开发者争吵对于Android开发,究竟什么样的架构才是最好的。MVC 早已过时,MVP 在现代compose框架下很别扭,所以争论的焦点往往是 MVVM VS MVI

明确概念区分是有必要的,但我觉得实在是没必要把它当成非黑即白的大事。看过许多开源项目,在 Android + Compose 语境里,所谓 MVVM 和 MVI,实际落地后确实只差一层皮:

现在大家对于状态管理的普遍写法是:

  • ViewModel 持有 StateFlow
  • Compose collect
  • UI 调 viewModel.xxx()

这时如果有人嘴上说自己是 MVI,但代码只是:

  • 多定义了个 Intent
  • 多定义了个 Effect
  • onClick 改成 sendEvent(intent)
  • 内部拿when识别接着走不同逻辑

那在我看来,这只是MVVM 套了 MVI 命名风格,不是什么本质区别,这样的MVI实际上根本不足以称之为MVI。MVI 与 MVVM 的真正区别,不在于有没有 Intent / Effect 类型,而在于是否真的在代码中采用了两种不同的状态管理哲学。对于MVI来说:

  • 是否有 reducer 思维?
  • 是否有严格的单向状态演化约束?
  • 是否真正分离了状态和副作用?

在 MVVM 中,ViewModel 是页面逻辑中心,对外暴露状态,并直接提供若干命令式方法给 UI 调用,状态更新逻辑也散落在各方式中,相对自由奔放。例如:

1
2
3
4
fun onRefresh() { ... }
fun onRetry() { ... }
fun onKeywordChange(value: String) { ... }
fun onItemClick(id: String) { ... }

这使得业务代码写起来很灵活,直接新增方法就好了,逻辑都写方法里,状态有变化直接在这里update就好了,UI按需调用。代价则是基本没有对状态的任何范式约束。

而在 MVI 中,所有输入先被建模成 Intent;所有页面可见信息收敛成单一 State;状态变化应只通过 reducer 统一规则演化,并且明确隔离副作用,派生出Effect。也就是说,MVI 的重点不是“多定义几个 sealed class”,而是:

  • 状态转移是否被统一建模
  • 所有用户动作和系统动作是否都走统一入口
  • 副作用是否被明确区分,不混进 state

所以说,很多人把区别理解成:MVVM就是UI调用 onClick(),MVI就是UI调用 sendEvent(Intent.Click),这太浅了,也根本不是核心。如果还纠结于类似方法名怎么起、sealed class 要不要写、 一次性事件叫 effect 还是 event 这类价值不高的争论上,实属可惜。真正的区别更接近于:MVVM把ViewModel用来当页面管理者,功能自己设计;而MVI则是把ViewModel当成一个出入口,统一输入输出,内部维护一个状态机,所有输入都驱动状态机的转移,输出新状态。

也因此在我看来,虽然这两种架构经常被放在一起对比,但实际上他们所反映的思路并非同一维度:

  • MVVM 是一种更强调对架构的严格分层与View/Model解耦
  • MVI 则是在此基础之上,强调状态流转的建模思路

严格讲,很多 Android 项目其实就是MVVM 外壳 + MVI 风格状态管理

  • MVVM解决架构分层:View层对应Composable,ViewModel层就是ViewModel,Model层对应依赖的Repository及其下的数据源;
  • MVI解决页面状态流转与事件消费:用 Intent / State / Effect / Reducer 的方式组织。

所以在我看来它们并不完全互斥,很多成熟写法其实就是架构层面是 MVVM 而状态管理层面借鉴 MVI。过度区分是没有必要的,不应为了比较而比较,谁优谁劣更是没有定数。

此为旧博客补档,原文上传于2022年7月5日。

背景

自从AlexNet以来,人们一直致力于探索更深的神经网络,以更好地解决计算机视觉等领域的问题。从2012年到2014年,ILSVRC大赛的优胜者一直在增加网络的深度。更多的卷积层可以使得提取的特征更加丰富,但同时也会带来梯度消失/爆炸和过拟合的问题,导致模型不收敛。虽然当时大牛们已经提出了诸如Batch Normalization的方法解决这些问题,但人们依然发现一个奇怪的现象:随着网络深度的不断增加,网络预测准确度先逐渐增大趋于饱和,最后反而将重新开始下降!并且这还并不是过拟合导致的结果,因为误差依然在增大。这似乎是相当反常识的一个现象,因为只要我们让更深的网络层采取恒等映射,理论上就能保证网络性能至少不会比浅层神经网络下降,而对于能模拟几乎任何映射的神经网络来说,恒等映射简直是小菜一碟。由此可见,是神经网络背后隐藏的学习思路出现了问题,比如更深处的多个网络层的堆叠因为非线性变化的大量使用,连恒等映射都无法学习了。

退化现象

何恺明大佬将这个现象称为“退化”(degradation),并提出了一种全新的网络结构以解决退化问题,使得神经网络能继续从提高深度获得准确度的收益。这就是“残差网络”(ResNet)的诞生。

残差单元的原理

ResNet的设计核心思想正是受到了退化现象的启发。与其学习目标映射$H(x)$,转而学习映射的残差$F(x)=H(x)- x$,进而原先的映射可以表示为$F(x)+x$。残差的获取是通过短路连接实现。

短路连接网络

为什么这样以来就能避免退化了呢?我尝试从数学上进行了推导:

首先,对于一个图2所示的残差单元,假设其输入为$x$,输出为$y$,$F(x)$是学习得到的残差,并且$F(x)$和$x$具有相同的维度,则有

$$
y = F(x) + x
$$

若省略bias,并令$\sigma$表示ReLU,W1, W2分别表示两个weight layer(可以是卷积或全连接)的权值,则$F(x)$又可写成:

$$
F(x) = W_2\sigma(W_1x)
$$

进而,y可表示为:

$$
y = W_2\sigma(W_1x) + x
$$

如果我们堆叠多个这样的残差单元,并令$x_i$表示为第i个残差单元的输入,同时也是第(i-1)个残差单元的输出,那么上式可以改写为如下形式:

$$
x_{i+1} = W_2\sigma(W_1x_i) + x_i
$$

则从第a个残差单元开始,到第b个残差单元结束,这样一组残差单元整体端到端的输入输出关系可由累加得到:

$$
x_{b+1} = \sum_{i=a}^b W_{2i}\sigma(W_{1i}x_i) + x_a
$$

更明确地写,将$x_{b+1}$换为$y_b$,则最终得到第b个残差单元的输出和第a个残差单元的输入间的关系为:

$$
y_b = \sum_{i=a}^b W_{2i}\sigma(W_{1i}x_i) + x_a
$$

接下类就可以利用链式法则,求得反向传播到第a层的梯度:

$$
\frac{\partial loss}{\partial x_a} = \frac{\partial loss}{\partial y_b} \frac{\partial y_b}{\partial x_a} = \frac{\partial loss}{\partial y_b}(1 + \frac{\partial}{\partial x_i}\sum_{i=a}^b W_{2i}\sigma(W_{1i}x_i))
$$

上式说明,在进行反向传播时,首先括号中的第二项十分容易计算,此外由于括号中”1”这一项的存在,除非第二项为-1,否则是不会发生梯度消失的,而第二项求和为-1附近值的概率是极小的,即能实现梯度的无损传播,完美规避了传统DNN的弊端。

网络结构与PyTorch源码阅读

笔者在此先偷了个懒,因为光看论文没有太搞明白,于是先大致阅读了ResNet在torchvision.models中的实现,自己手撸的轮子版本就下次一定了!下面以resnet34为例,开始分析其源码。

首先从论文中可以找到resnet34的结构示意图:

resnet1

resnet2

可以看到通过引入残差单元,可以把网络做的很深。

resnet34中,网络可以分为开头的一层卷积+池化,中间4组不同深度的残差单元,和最终的均值池化与分类器。

当调用resnet34时,实际上是返回了一个内部的_resnet

1
2
3
4
5
6
7
8
9
10
def resnet34(pretrained: bool = False, progress: bool = True, **kwargs: Any) -> ResNet:
r"""ResNet-34 model from
`"Deep Residual Learning for Image Recognition" <https://arxiv.org/pdf/1512.03385.pdf>`_.

Args:
pretrained (bool): If True, returns a model pre-trained on ImageNet
progress (bool): If True, displays a progress bar of the download to stderr
"""
return _resnet('resnet34', BasicBlock, [3, 4, 6, 3], pretrained, progress,
**kwargs)

注意这里传入的BasicBlock,实际上就是上文中的残差单元:

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
# 网络定义
self.conv1 = conv3x3(inplanes, planes, stride)
self.bn1 = norm_layer(planes)
self.relu = nn.ReLU(inplace=True)
self.conv2 = conv3x3(planes, planes)
self.bn2 = norm_layer(planes)
self.downsample = downsample
self.stride = stride

# 前向传播
def forward(self, x: Tensor) -> Tensor:
identity = x

out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)

out = self.conv2(out)
out = self.bn2(out)

if self.downsample is not None:
identity = self.downsample(x)

out += identity
out = self.relu(out)

return out

而传入_resnet的第三个参数,一个int列表,稍后可以看到是指定了各组残差单元中包括的残差单元个数,可以由结构图验证。

再看_resnet内部,实际上就是调用了真正的Resnet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _resnet(
arch: str,
block: Type[Union[BasicBlock, Bottleneck]],
layers: List[int],
pretrained: bool,
progress: bool,
**kwargs: Any
) -> ResNet:
model = ResNet(block, layers, **kwargs)
if pretrained:
state_dict = load_state_dict_from_url(model_urls[arch],
progress=progress)
model.load_state_dict(state_dict)
return model

进入ResNet,我们就能真正看到它的结构了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3,
bias=False)
self.bn1 = norm_layer(self.inplanes)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(block, 64, layers[0])
self.layer2 = self._make_layer(block, 128, layers[1], stride=2,
dilate=replace_stride_with_dilation[0])
self.layer3 = self._make_layer(block, 256, layers[2], stride=2,
dilate=replace_stride_with_dilation[1])
self.layer4 = self._make_layer(block, 512, layers[3], stride=2,
dilate=replace_stride_with_dilation[2])
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)

可以看到,以上代码与结构图遥相呼应,其中layer1~4对应4个残差单元组。那么可想而知_makelayer的作用正是根据传入的模块结构,通道数和模块数构建网络层了:

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
def _make_layer(self, block: Type[Union[BasicBlock, Bottleneck]], planes: int, blocks: int,
stride: int = 1, dilate: bool = False) -> nn.Sequential:
norm_layer = self._norm_layer
downsample = None
previous_dilation = self.dilation
if dilate:
self.dilation *= stride
stride = 1
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(
conv1x1(self.inplanes, planes * block.expansion, stride),
norm_layer(planes * block.expansion),
)
# 通道数不一致(即虚线)的处理

layers = []
layers.append(block(self.inplanes, planes, stride, downsample, self.groups,
self.base_width, previous_dilation, norm_layer))
self.inplanes = planes * block.expansion
for _ in range(1, blocks):
layers.append(block(self.inplanes, planes, groups=self.groups,
base_width=self.base_width, dilation=self.dilation,
norm_layer=norm_layer))

return nn.Sequential(*layers)

小结

AlexNet的横空出世,开创了DNN在CV中应用的热潮;VGG在AlexNet的基础上,做到了更深的深度;GoogleNet创新性地提出Inception模块和1x1卷积在数据降维中的应用;ResNet解决了前面网络都无从对策的退化问题,开辟了DNN的深度新的上限……现在,各种网络层出不穷,但这些经典网络可以说是奠定了它们共同的坚实基础,因此很有学习和掌握的必要。

至此,这四种网络的基础知识就学习完毕了。接下来,我希望复习几种目标检测中常用的算法(RCNN, Fast-RCNN, Faster-RCNN, YOLO, SSD),以巩固自己的知识,并为项目积累经验。

此为旧博客补档,原文上传于2022年7月4日。

前言

用英文写了前两篇笔记,主要是为了契合周报,但相对于读者可能不太友好,于是从这一篇开始还是用中文做笔记了。这次带来的是对GoogleNet V1的主要结构复现。

背景

上一篇笔记中我们复现的经典网络是VGGNet,它是2014年ImageNet大赛的第二名,而当年的第一名正是GoogleNet。如果说VGG只是对AlexNet的结构作改进,那么GoogleNet则是针对当时DNN设计的痛点做出了许多创新,提出了行之有效的解决方案。随着网络长宽的不断增大和深度的不断加深,其准确度会相应不断提升,但同时参数也在急剧增多,过拟合发生的可能性也越来越大。 当时,一般的DNN的构建思路如下:堆叠卷积层,间以池化层,并通过LRN,Dropout等技巧防止过拟合。然而对于稍深的DNN,对硬件的要求依然很高,问题没有很好地解决。比如VGG相对于AlexNet,换用了更小的卷积核,参数数量固然减少了,但基数依然很大。即便放到现在,笔者笔记本搭载的 RTX2060 也只能保证训练 VGG11(batch_size为32) 而不显存溢出,并且针对一个具有以万为单位的中型数据集能保证一、两个小时完成训练。

对此,GoogleNet主要提出了两点新的加快训练速度和减少参数数量的方案:

  • 利用 1x1卷积核(Network in Network)对数据降维,同时增加非线性,并做到减少参数和防止过拟合。
  • 提出Inception模块,可以理解为对相似尺度的特征提取的卷积核分组,将大的不利计算的稀疏矩阵转化为多个小的便于计算的密集矩阵,并且结合了1x1卷积核降维处理。Inception的灵感来自Hebbian principle,即如果两个神经元常常同时产生动作电位,这两个神经元之间的连接就会变强,反之则变弱。

网络结构

GoogleNet中主要涉及到3个网络结构:Inception Module,和Auxiliary Classifier,以及最终的GoogleNet主干网络。

Inception Module

直观来看,Inception其实就是将多个卷积和池化的操作放在一起组装为一个小型网络模块,使得神经网络的设计模块化。下图是Inception模块的结构:

Inception

从图中可见,Inception包含了一组不同卷积核大小的卷积核和一个必要的均值池化层。不同尺度的特征,往往需要不同大小的感受野来捕获。传统的网络结构中,在一层卷积层只能有一种大小卷积核,其能获得的特征不一定是最佳的,而可能需要别的大小的卷积核。Inception所做的正是将这一过程交给神经网络判断,网络通过调节参数,自主选择合适大小的卷积核。原始的Inception结构如图(a)所示,这样的结构仍会带来较多参数,不能直接用于网络。解决方案是加入先前所述的1x1卷积核,做到数据降维,减少参数数量,于是最终形成了图(b)所示的结构。
用Pytorch实现如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Inception(nn.Module):

def __init__(self, ch_in, ch1x1, ch3x3_reduce, ch3x3, ch5x5_reduce, ch5x5, pool_proj):
super().__init__()
self.parallel1 = ReluConv(ch_in=ch_in, ch_out=ch1x1, kernel_size=1, stride=1)
self.parallel2 = nn.Sequential(
ReluConv(
ch_in=ch_in,
ch_out=ch3x3_reduce,
kernel_size=1,
stride=1
),
ReluConv(
ch_in=ch3x3_reduce,
ch_out=ch3x3,
kernel_size=3,
stride=1,
padding=1
)
)
self.parallel3 = nn.Sequential(
ReluConv(
ch_in=ch_in,
ch_out=ch5x5_reduce,
kernel_size=1,
stride=1
),
ReluConv(
ch_in=ch5x5_reduce,
ch_out=ch5x5,
kernel_size=5,
stride=1,
padding=2
)
)
self.parallel4 = nn.Sequential(
nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
ReluConv(
ch_in=ch_in,
ch_out=pool_proj,
kernel_size=1,
stride=1
)
)

def forward(self, x):
# Do DepthConcat
parallel1 = self.parallel1(x)
parallel2 = self.parallel2(x)
parallel3 = self.parallel3(x)
parallel4 = self.parallel4(x)
return torch.cat((parallel1, parallel2, parallel3, parallel4), 1)

Auxiliary Classifier

Auxiliary Classifier,即辅助分类器,是为了增强梯度(防止出现梯度消失),以及增加正则化而设计的一种子模块网络。它只在训练过程加入,其运算结果在乘以一个权重系数(0.3)后与最终输出结果一起作用于反向传播。它由一个均值池化层,一个1x1卷积+ReLU激活层,一个全连接层,一个Dropout层,和一个接了softmax的全连接层构成。

用pytorch实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AuxClassifier(nn.Module):

def __init__(self, ch_in, num_classes=2):
super().__init__()
self.pool = nn.AvgPool2d(kernel_size=5, stride=3)
self.conv = ReluConv(ch_in=ch_in, ch_out=128, kernel_size=1, stride=1)
self.fc1 = nn.Linear(in_features=2048, out_features=1024) # input is N x 128 x 4 x 4
self.fc2 = nn.Linear(in_features=1024, out_features=num_classes)

def forward(self, x):
x = self.pool(x)
x = self.conv(x)
x = torch.flatten(x, 1) # as input of fc layer
x = self.fc1(x)
x = F.relu(x)
x = F.dropout(x, 0.7, training=self.training)
x = self.fc2(x)
return x

GoogleNet

GoogleNet的结构如下:

Screenshot from 2022-07-04 20-41-50.png

可见,在较浅层,主要组成部分依然是传统的卷积+池化。随后便是Inception模块的不断堆叠,间以最大池化,最后是dropout+全连接层输出分类结果。需要注意的是这里的全连接层实际上是一个均值池化层,通过7x7的滤波器大小,将前一层输入的7x7大小的特征直接转化为1x1。论文提到这里用池化层代替卷积层,实现了0.6%的准确度提升。

用pytorch实现如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
class GoogleNet(nn.Module):

def __init__(self, num_classes=2, aux_enabled=True):
super().__init__()
'''
input: 227 x 227 x 3
output: 56 x 56 x 64
'''
self.conv1 = ReluConv(ch_in=3, ch_out=64, kernel_size=7, stride=2, padding=3)
self.pool1 = nn.MaxPool2d(kernel_size=3, stride=2, ceil_mode=True)
# LRN?

'''
input: 56 x 56 x 64
output: 28 x 28 x 192
'''
self.conv2 = ReluConv(ch_in=64, ch_out=64, kernel_size=1, stride=1)
self.conv3 = ReluConv(ch_in=64, ch_out=192, kernel_size=3, stride=1, padding=1)
self.pool2 = nn.MaxPool2d(kernel_size=3, stride=2, ceil_mode=True)
# LRN?

'''
input: 28 x 28 x 192
output: 28 x 28 x 256
'''
self.inception3a = Inception(
ch_in=192,
ch1x1=64,
ch3x3_reduce=96,
ch3x3=128,
ch5x5_reduce=16,
ch5x5=32,
pool_proj=32
)

'''
input: 28 x 28 x 256
output: 14 x 14 x 480
'''
self.inception3b = Inception(
ch_in=256,
ch1x1=128,
ch3x3_reduce=128,
ch3x3=192,
ch5x5_reduce=32,
ch5x5=96,
pool_proj=64
)
self.pool3 = nn.MaxPool2d(kernel_size=3, stride=2, ceil_mode=True)

'''
input: 14 x 14 x 480
output: 14 x 14 x 512
'''
self.inception4a = Inception(
ch_in=480,
ch1x1=192,
ch3x3_reduce=96,
ch3x3=208,
ch5x5_reduce=16,
ch5x5=48,
pool_proj=64
)

'''
input: 14 x 14 x 512
output: 14 x 14 x 512
'''
self.inception4b = Inception(
ch_in=512,
ch1x1=160,
ch3x3_reduce=112,
ch3x3=224,
ch5x5_reduce=24,
ch5x5=64,
pool_proj=64
)

'''
input: 14 x 14 x 512
output: 14 x 14 x 512
'''
self.inception4c = Inception(
ch_in=512,
ch1x1=128,
ch3x3_reduce=128,
ch3x3=256,
ch5x5_reduce=24,
ch5x5=64,
pool_proj=64
)

'''
input: 14 x 14 x 512
output: 14 x 14 x 528
'''
self.inception4d = Inception(
ch_in=512,
ch1x1=112,
ch3x3_reduce=144,
ch3x3=288,
ch5x5_reduce=32,
ch5x5=64,
pool_proj=64
)

'''
input: 14 x 14 x 528
output: 14 x 14 x 832
'''
self.inception4e = Inception(
ch_in=528,
ch1x1=256,
ch3x3_reduce=160,
ch3x3=320,
ch5x5_reduce=32,
ch5x5=128,
pool_proj=128
)

self.pool4 = nn.MaxPool2d(kernel_size=3, stride=2, ceil_mode=True)

'''
input: 7 x 7 x 832
output: 7 x 7 x 832
'''
self.inception5a = Inception(
ch_in=832,
ch1x1=256,
ch3x3_reduce=160,
ch3x3=320,
ch5x5_reduce=32,
ch5x5=128,
pool_proj=128
)

'''
input: 7 x 7 x 832
output: 7 x 7 x 1024
'''
self.inception5b = Inception(
ch_in=832,
ch1x1=384,
ch3x3_reduce=192,
ch3x3=384,
ch5x5_reduce=48,
ch5x5=128,
pool_proj=128
)

'''
input: 7 x 7 x 1024
output: 1 x 1 x 1024
'''
self.pool5 = nn.AvgPool2d(kernel_size=7, stride=1, ceil_mode=True)
self.dropout = nn.Dropout(0.4)

'''
input: 1024
output: 2
'''
self.fc = nn.Linear(in_features=1024, out_features=num_classes)

if aux_enabled:
self.aux_enabled = True
self.aux0 = AuxClassifier(ch_in=512, num_classes=2)
self.aux1 = AuxClassifier(ch_in=528, num_classes=2)

def forward(self, x):
x = self.conv1(x)
x = self.pool1(x)

x = self.conv2(x)
x = self.conv3(x)
x = self.pool2(x)

x = self.inception3a(x)
x = self.inception3b(x)
x = self.pool3(x)

x = self.inception4a(x)
if self.training and self.aux_enabled:
aux0 = self.aux0(x)

x = self.inception4b(x)
x = self.inception4c(x)
x = self.inception4d(x)
if self.training and self.aux_enabled:
aux1 = self.aux1(x)

x = self.inception4e(x)
x = self.pool4(x)
x = self.inception5a(x)
x = self.inception5b(x)

x = self.pool5(x)
x = torch.flatten(x, 1)
x = self.dropout(x)
x = self.fc(x)

if self.training and self.aux_enabled:
return x, aux0, aux1
return x

注意这里我们和之前一样,用较小的数据集代替ImageNet,并对网络中输出层做相应调整。

训练

设置batch_size为32,经过20个epoch,得到训练结果如下:
loss图像:

loss.png

accuracy图像:

accuracy.png

这里依然出现了之前一样的问题:loss曲线剧烈抖动,同时accuracy每轮epoch开始有毛刺?新手上路,实在没弄清楚原因。虽然图像不甚完美,但可看到loss还是随训练总体上是降低的,而accuracy是升高的。并且在验证集中也能达到准确率目标。

结论

GoogleNet作为一种经典DNN,还有许多值得学习的设计思想,并且其本身也经过了多次迭代升级,这里仅仅是对初代GoogleNet(V1)进行了Pytorch的代码复现。

下一个学习目标定为ResNet的论文阅读和pytorch复现。

在 Java / JVM Kotlin 并发开发中,ThreadLocal 经常被用来保存“与一次请求/一次任务绑定的上下文信息”,比如 traceId、用户信息、租户信息、事务/会话上下文等。它看起来像“全局变量”,但又能做到线程隔离:同一个 ThreadLocal 在不同线程里互不干扰


1. ThreadLocal 是什么

ThreadLocal 说到底也就是 Java 语言提供的一个泛型类,它的主要作用是为变量包上一层壳:正常来说,当多线程访问同一变量时,他们将持有相同的变量,访问同一个地址,从而也引入线程不安全的并发问题;当如果将这个变量包裹在 ThreadLocal 中,每个线程则将只会访问到各线程独立的、针对该变量的一份拷贝,实现变量的线程隔离。

核心特点:

  • 变量是“线程作用域”(thread-scoped),不是“对象作用域”
  • set/get/remove 都是对“当前线程”的数据操作
  • 很适合承载上下文类信息,减少层层传参

2. 用法示例

1
2
3
4
5
6
7
8
9
10
11
12
13
import kotlin.concurrent.thread  

val name = ThreadLocal<String>()

thread {
name.set("hello")
println("thread-1 name: ${name.get()}")
}.join()

thread {
name.set("world")
println("thread-2-name: ${name.get()}")
}.join()

以上代码创建了一个ThreadLocal<String>,并在两个线程中为其设置了不同的值然后读取。输出结果为:

1
2
thread-1 name: hello
thread-2-name: world

3. 原理

很多人初看会以为 ThreadLocal 自己保存了“每个线程的值”,所有逻辑都在这个类中,包括网上很多过时的博客都会如此误导,说TheadLocal里放了张 Map,key 是 Thread 对象,value 是线程给变量赋的值。 实际上并非如此,ThradLocal 的源码轻量且直观,自己读一遍就能完全掌握其原理。

实际上,值不在 ThreadLocal 里,而在 Thread 里,存放在另一个数据结构:一个字段名为threadLocalsThreadLocalMap中;ThreadLocal 只是作为 ThreadLocalMap 这张表的 key。

  • 每个 Thread 对象内部维护两张ThreadLocalMap表:
    • threadLocals:普通 ThreadLocal 的存储
    • inheritableThreadLocalsInheritableThreadLocal 的存储(稍后介绍)
  • ThreadLocal 更像是一把“钥匙”(key),用于在当前线程的表里定位 value。
1
2
3
// java.lang.Thread 中的成员变量
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

3.1. ThreadLocalMap 与弱引用 Entry

ThreadLocalMapThreadLocal 的内部静态类,底层不是 HashMap,而是一个数组:

  • Entry[] table 存放键值对
  • 使用开放定址 + 线性探测 解决冲突
1
2
3
4
5
6
7
8
9
// ThreadLocalMap lazy 构造方法
// Entry 为其底层存储数据的成员数组
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

最关键的是 Entry

  • key 是 ThreadLocal 的弱引用(WeakReference)
  • value 用一个强引用另外存储
1
2
3
4
5
6
7
8
9
static class Entry extends WeakReference<ThreadLocal<?>> {  
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

⚠️注意!
这带来一个非常重要的内存泄漏隐患,在使用时需要注意: 如果 ThreadLocal 本身不再被外部强引用,GC 可能回收它使得 key 变成 null (弱引用特性),但 value 仍可能被强引用挂在表里,直到表在后续操作中被清理或线程结束。


3.2. get() 做了什么:从当前线程取表再查 key

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {  
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

方法总结:

  1. 获取当前所在线程对象:Thread t = Thread.currentThread()
  2. 获取当前线程的 ThreadLocalMap 对象:ThreadLocalMap m = t.threadLocals
  3. 若表还未初始化: m != null,在 map 中按当前 ThreadLocal 查找 entry,命中则返回 value
  4. 否则(或未命中):走初始化逻辑 setInitialValue()

初始化值来源:

  • 默认 initialValue() 返回 null
  • 可通过覆写 initialValue()ThreadLocal.withInitial(supplier) 提供初始值

3.3. set() 做了什么:在当前线程的 ThreadLocalMap 里放值

1
2
3
4
5
6
7
8
9
10
11
12
13
public void set(T value) {  
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

方法总结:

  1. 拿当前线程 t
  2. 取线程的ThreadLocalMap,没有就创建
  3. 调用表的set方法,在 ThreadLocalMap 中插入或覆盖
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
private void set(ThreadLocal<?> key, Object value) {  
// 计算哈希
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

// 线性探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {

// 1. 同key,直接覆盖
if (e.refersTo(key)) {
e.value = value;
return;
}

// 2. 旧entry,已被GC回收,直接插入/替换
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}

// 3. 空槽,直接插入
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

方法总结:

  • 计算 key 的哈希定位到数组下标i
  • 如遇冲突,线性探测向后找
  • 遇到三种情况:
    1. 空槽:直接插入
    2. 同 key:覆盖 value
    3. 陈旧 entry(stale entry,key 已被 GC 成 null):触发替换/清理逻辑(例如 replaceStaleEntry),尽量把 stale 清掉并维持探测链正确

3.4. remove() 做了什么:将其从当前线程表中删除 + 触发重排

ThreadLocal.remove() 不是“把 ThreadLocal 清空”,而是从 Thread.currentThread().threadLocals 中移除该 key 对应的 entry。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
private void remove(ThreadLocal<?> key) {  
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

...

private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale. while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

方法总结:

  • 在开放定址结构里删除元素会“断链”,所以实现通常会:
    • 清空当前槽位
    • 调用 expungeStaleEntry(...) 的逻辑做重排(rehash/expunge),确保后续 key 仍可被正确查到

4. 线程池“串号”与“疑似内存泄漏”:问题解释

4.1 为什么会串号

线程池会复用线程,而若上一个任务 set() 了 ThreadLocal 但没 remove(),下一个任务复用同一线程时 get() 会读到上次残留的值,这就是常见的“上下文串号”问题。

4.2 为什么会出现“疑似内存泄漏”

根因与引用类型和JVM回收机制有关:

  • Entry.key 是弱引用,ThreadLocal 可被 GC 回收,导致key 变 null(过期)
  • Entry.value 是强引用,仍挂在 ThreadLocalMap.table
  • 当线程池线程生命周期很长,value 可能长期释放不了

所以严格说这更像是value 的滞留,而不是语言层面的永久泄漏;但在服务型线程池里表现就像泄漏一样严重。

4.3 为什么有时又会“自己恢复”

ThreadLocalMap 并非完全不管 stale entry。它会在 get/set/remove 等路径中顺便清理,这一点在上面的源码中也已经能观察到。常见的清理方法有:

  • expungeStaleEntry(i):从某位置开始清理并重排探测链
  • cleanSomeSlots(...):启发式清理一段,控制均摊开销
  • expungeStaleEntries():更彻底的全表清理(更重)

这种清理是“被动触发”的。若线程后续很少触达 map,滞留可能持续很久。

4.4. 最佳实践

因此,对于线程池/容器线程(Web 线程、RPC 线程、异步执行器线程),一定要确保对ThreadLocal在用后调用 try/finally remove,解决上下文串号(逻辑错误)和 value 滞留(内存风险):

1
2
3
4
5
6
try {
TL.set(ctx);
// do work
} finally {
TL.remove();
}

5. 关于 InheritableThreadLocal

因为ThreadLocal值是存在Thread专属的表中的,这就导致在一些异步场景中,ThreadLocal值无法随着线程切换进行自动传递。对于跨线程传递ThreadLocal值的问题,JDK 原生提供的方案就是InheritableThreadLocal,直译正是“可继承的 ThreadLocal”。

InheritableThreadLocal 的表对应于 Thread.inheritableThreadLocals。子线程在创建时会把父线程相关 map 做拷贝/派生。

1
2
3
4
// Thread 构造方法中的相关逻辑摘取
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

这种方案的缺陷在于,它仅是在线程创建时进行复制,是一次性的;而在线程池中,线程通常不是“新建”,而是复用,因此它经常不符合对“父子线程传递”的直觉预期,个人用的也比较少。


总结

  • ThreadLocal 的隔离不是“ThreadLocal 内部隔离”,而是 “每个线程各自持有一张 ThreadLocalMap”
  • ThreadLocalMap 通过数组 + 线性探测实现,key 为弱引用、value 为强引用
  • 线程池场景中,不 remove() 会导致:
    • 业务层面“串号”
    • 内存层面 value 滞留(看起来像泄漏)
  • 源码通过在 get/set/remove 中顺带清理 stale entry 来做均摊控制,但不可靠替代显式清理
  • 最佳实践:ThreadLocal 用完必 remove,且放 finally

本文从 Kotlin 的基本类型出发,深入探讨数值字面量的实现原理,并全面剖析 Kotlin 与 Java 中的装箱/拆箱机制,力求做到知其然知其所以然。


一、Kotlin 基本类型概览

Kotlin 作为一门现代 JVM 语言,对基本类型的处理既保留了 Java 的高效性,又增添了更优雅的语法设计。与 Java 区分原始类型和包装类的理念不同,Kotlin 中一切皆对象——没有原始类型(primitive types)的概念,但编译器会在底层自动优化为 JVM 原始类型以保证性能。

1.1 数值类型

Kotlin 提供了多种数值类型,覆盖不同精度需求:

类型 位宽 取值范围
Byte 8 位 -128 ~ 127
Short 16 位 -32768 ~ 32767
Int 32 位 $-2^{31}$ ~ 2^{31}-1
Long 64 位 $-2^{63}$ ~ $2^{63}-1$
Float 32 位 单精度浮点
Double 64 位 双精度浮点
注:Byte, Short, Int, Long 还具有对应无符号类型:UByte, UShort, UInt, ULong

字面量写法:

1
2
3
4
5
6
7
val intNum = 100          // 默认推断为 Int
val longNum = 100L // L 后缀表示 Long
val floatNum = 3.14f // f 后缀表示 Float
val doubleNum = 3.14 // 默认推断为 Double
val hexNum = 0xFF // 十六进制
val binaryNum = 0b1010 // 二进制
val readable = 1_000_000 // 支持下划线分隔,提升可读性

显式类型转换: ⚠️Kotlin 不支持隐式类型转换,必须显式调用转换函数:

1
2
3
val i: Int = 100
val l: Long = i.toLong() // ✅ 正确
// val l: Long = i // ❌ 编译错误

1.1.1 Number 类

以上数值类型都有一个共同的父类:Number,这是一个抽象类,并定义了一系列显示类型转换函数:

1
2
3
4
5
6
7
8
9
public abstract class Number {  
public abstract fun toDouble(): Double
public abstract fun toFloat(): Float
public abstract fun toLong(): Long
public abstract fun toInt(): Int
public abstract fun toChar(): Char // Deprecated
public abstract fun toShort(): Short
public abstract fun toByte(): Byte
}

通过继承 Number类并实现 Comparable接口,派生出以上数值类型。

1.2 字符与字符串

Char 表示单个字符(16-bit Unicode),用单引号包裹;字符串用双引号或三引号表示:

1
2
3
4
5
6
val letter: Char = 'A'
val str = "Hello, Kotlin"
val multiLine = """
这是多行字符串
保留原始格式
""".trimIndent()

注意 Char 与数值类型互转的方法,直接调用 toChar()toLong()等已经弃用,需要以 Int类型作为媒介:

1
2
3
4
5
val char: Char = 'A'  
val i: Int = char.code // Code of a Char is the value it was constructed with, and the UTF-16 code unit corresponding to this Char.
val l: Long = char.code.toLong()
val c: Char = i.toChar() // significant 16 bits of this Int value.
val d: Char = i.digitToChar() // decimal digit

Kotlin 支持字符串模板,这在构造特定字符串以及日志打印等场景都非常好用:

1
2
3
val name = "World"
println("Hello, $name!") // 简单变量
println("长度是 ${name.length}") // 表达式

Kotlin String 还有更多值得说道的知识点,在此先略过,之后另写一篇博客来详细探讨。

1.3 布尔类型与数组

1
2
3
4
5
6
7
val isKotlinFun: Boolean = true

// 泛型数组
val arr = arrayOf(1, 2, 3)

// 原始类型数组(避免装箱开销)
val intArr = intArrayOf(1, 2, 3)

二、数值字面量的实现机制

既然 Kotlin 中 Int 是一个类,为什么 val x = 1 不需要写成 val x = Int(1) 这样的构造函数形式?

2.1 字面量是语言级别的语法糖

数值字面量是 Kotlin 语言规范直接支持的特殊语法,编译器看到 1 时,直接将其识别为 Int 类型的字面量常量,不需要经过任何构造函数调用。

1
2
val x = 1        // 字面量语法,编译器直接处理
val y = Int(1) // ❌ 编译错误!Int 没有公开构造函数

2.2 为什么 Int 没有构造函数?

Kotlin 的数值类型是特殊的内置类型,构造函数是私有的:

设计原因 说明
性能优化 编译器可以直接映射到 JVM 原始类型,无需真正的对象分配
语义清晰 1 就是 1,不需要 new Integer(1) 的冗余
防止滥用 避免 Int(someString) 这种容易出错的用法

2.3 编译过程示例

1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────┐
│ Kotlin 源码 val x = 1 │
├─────────────────────────────────────────────────────┤
│ 编译器理解 x 是 Int 类型,值为字面量 1 │
├─────────────────────────────────────────────────────┤
│ 字节码输出 ICONST_1 (JVM 原始 int 指令) │
│ ISTORE x │
└─────────────────────────────────────────────────────┘

反编译为 Java 代码,就是简单的 int x = 1;——直接是原始类型,零开销。


三、Kotlin 中的装箱机制

虽然 Kotlin 在语法层面”隐藏”了装箱的概念,但装箱并不是就消失了——只是编译器帮我们自动处理了。

3.1 什么时候会发生装箱?

场景 是否装箱 底层类型
val x: Int = 1 ❌ 不装箱 JVM int
val x: Int? = 1 ✅ 装箱 JVM Integer
val list: List<Int> ✅ 装箱 List<Integer>
fun foo(x: Any) 传入 Int ✅ 装箱 Object
val arr: IntArray ❌ 不装箱 JVM int[]
val arr: Array<Int> ✅ 装箱 JVM Integer[]

3.2 可空类型必须装箱

JVM 原始类型无法表示 null,所以可空数值必须用包装类:

1
2
val a: Int = 1      // 底层: int
val b: Int? = 1 // 底层: Integer(才能存 null)

3.3 泛型强制装箱

JVM 的泛型使用类型擦除,不支持原始类型作为类型参数:

1
2
val list = listOf(1, 2, 3)  // List<Int> → List<Integer>
// 每个元素都被装箱了!

3.4 Array<Int> vs IntArray

1
2
3
4
5
6
7
// 装箱数组
val boxed: Array<Int> = arrayOf(1, 2, 3)
// 底层: Integer[] {Integer.valueOf(1), Integer.valueOf(2), ...}

// 原始类型数组
val primitive: IntArray = intArrayOf(1, 2, 3)
// 底层: int[] {1, 2, 3}

内存布局差异:

1
2
3
4
5
6
7
Array<Int> (装箱)                    IntArray (原始)
┌─────────────────┐ ┌─────────────────┐
│ Integer 引用 ──────→ [Integer 对象] │ int: 1 │
│ Integer 引用 ──────→ [Integer 对象] │ int: 2 │
│ Integer 引用 ──────→ [Integer 对象] │ int: 3 │
└─────────────────┘ └─────────────────┘
额外的对象开销 + 指针跳转 连续内存,零开销

所以多使用诸如 IntArray的原始数据类型数组可以避免装箱开销,算是 Kotlin 使用的基本技巧。


四、Java 中的装箱机制

Java 的装箱机制比 Kotlin 更”显眼”——因为 Java 明确区分原始类型和包装类

4.1 原始类型 vs 包装类

原始类型 包装类
int Integer
long Long
double Double
boolean Boolean

4.2 自动装箱与拆箱

Java 5 引入了自动装箱,让两种类型可以”无缝”转换:

1
2
3
4
5
// 自动装箱:int → Integer
Integer x = 100; // 编译器转换为: Integer.valueOf(100)

// 自动拆箱:Integer → int
int y = x; // 编译器转换为: x.intValue()

4.3 装箱的经典陷阱

陷阱一:== 比较的诡异行为(面试常考)

1
2
3
4
5
6
7
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true ✅

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false ❌

原因是 Integer.valueOf() 对 -128 ~ 127 范围内的值使用了缓存池,超出范围则创建新对象。比较值永远用 equals()

1
2
3
4
5
6
public static Integer valueOf(int i) {
if (i >= -128 && i <= 127) {
return IntegerCache.cache[i + 128]; // 返回缓存对象
}
return new Integer(i); // 超出范围,创建新对象
}

陷阱二:NullPointerException

1
2
3
Integer x = null;
int y = x; // 💥 NullPointerException!
// 因为实际执行的是: x.intValue()

陷阱三:循环中的性能杀手

1
2
3
4
5
6
7
8
9
10
11
// 糟糕的写法:每次迭代都装箱
Long sum = 0L;
for (long i = 0; i < 1000000; i++) {
sum += i; // 拆箱 → 相加 → 装箱,创建大量临时对象
}

// ✅ 正确的写法
long sum = 0L;
for (long i = 0; i < 1000000; i++) {
sum += i; // 纯原始类型操作
}

4.4 内存开销对比

  • 原始类型 int: 4 bytes
  • 包装类 Integer: 对象头 (12 bytes) + value (4 bytes) + 对齐 ≈ 16 bytes + 引用指针 4-8 bytes

可见一个 Integer 比 int 多占用约 4-5 倍内存!


五、Kotlin vs Java 装箱对比

特性 Java Kotlin
类型区分 显式区分 int / Integer 统一为 Int,编译器决定
装箱语法 可见 (Integer x = 1) 隐藏 (val x: Int? = 1)
null 安全 运行时抛异常 编译期检查
原始数组 int[] IntArray
包装数组 Integer[] Array<Int>
1
2
3
// Java:你必须自己选择
int a = 1; // 原始类型
Integer b = 1; // 包装类(自动装箱)
1
2
3
// Kotlin:编译器帮你选择
val a: Int = 1 // 底层是 int
val b: Int? = 1 // 底层是 Integer(因为可空)

六、最佳实践

Kotlin 最佳实践

1
2
3
4
5
6
7
8
// 1. 尽量用非空类型
val x: Int = 1 // ✅ 底层是 int

// 2. 数值数组用原始类型版本
val arr = IntArray(1000) // ✅ int[]
val arr = Array<Int>(1000){0} // ❌ Integer[]

// 3. 性能敏感场景注意泛型集合的装箱开销

Java 最佳实践

1
2
3
4
5
6
7
8
9
10
// 1. 优先使用原始类型
int count = 0;

// 2. 比较包装类用 equals()
if (integer1.equals(integer2)) { ... }

// 3. 拆箱前检查 null
if (value != null && value > 0) { ... }

// 4. 避免在循环中使用包装类做累加

七、总结

理解类型系统和装箱机制,是写出高质量 JVM 代码的基础:

  • Kotlin 通过统一的类型语法和空安全设计,将装箱的复杂性隐藏在编译器背后,让开发者专注于业务逻辑
  • Java 的显式双轨制要求开发者时刻意识到原始类型和包装类的区别,避免性能陷阱和 NPE

无论使用哪种语言,记住一个原则即可:在性能敏感的场景下,优先使用原始类型;在需要对象语义的场景下,注意装箱的开销和空值处理

案例一

2025年12月18日,发现v1.0.3版本桌面端安装包体积爆炸增大,从原本的150MB左右暴涨到1GB,如下图所示。
image.png

由于三端均产生该问题,以 macOS 端(.dmg)为例展开排查。解包产物,切入 contents 目录查看文件夹大小,执行 du -sh ./*,结果如下:

1
2
3
4
5
6
7
116K ./Contents/_CodeSignature 
999M ./Contents/app
4.0K ./Contents/Info.plist
196K ./Contents/MacOS
4.0K ./Contents/PkgInfo
16K ./Contents/Resources
158M ./Contents/runtime

显然 Contents/app(999MB) 炸了。这也就是说:这次膨胀原因主要来自 应用层打包进去的 jar/依赖/资源 异常地多,初步怀疑是某个库发生了问题,将三份native包全都打包到一个native平台上了。

切入 Contents/app 执行 du -sh ./* ,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
22M ./opencv-4.6.0-1.5.8-android-x86-b38c986c415ad7d34111fa1ff684a19a.jar
26M ./opencv-4.6.0-1.5.8-ios-arm64-5a95a9fd33b458cf8dc8538fff16.jar
28M ./opencv-4.6.0-1.5.8-ios-x86_64-d3cdf6cb93ba33f2240a97fd5193.jar
24M ./opencv-4.6.0-1.5.8-linux-arm64-91b0d28db0dcbe847ead233f99a2831.jar
22M ./opencv-4.6.0-1.5.8-linux-armhf-4d45d47f293943abde4163761b744fc.jar
27M ./opencv-4.6.0-1.5.8-linux-ppc64le-511dfd3698bebebd724d4052e6a34b90.jar
27M ./opencv-4.6.0-1.5.8-linux-x86_64-91c04431ec13e27014c25b8f77ece6.jar
28M ./opencv-4.6.0-1.5.8-linux-x86-941136bbfad2956a86b10f9242fad34.jar
20M ./opencv-4.6.0-1.5.8-macosx-arm64-699b23fa8d64ced8f96a65d012ff48.jar
25M ./opencv-4.6.0-1.5.8-macosx-x86_64-855f22d26a3e7eb65122adacf1e5cee4.jar
29M ./opencv-4.6.0-1.5.8-windows-x86_64-149da917d80e13db6cac72fd53170cc.jar
27M ./opencv-4.6.0-1.5.8-windows-x86-e136927b1d9446f968ea2bd45dfa13e.jar
...

这就很显然了,验证了猜想是对的。明明是macOS平台的包,却连 windows 及 linux 的jar都被打包进来,所以导致了包体积暴涨。下一步是排查最终的罪魁祸首,是谁干了这件蠢事。回到项目中运行 ./gradlew :composeApp:dependencyInsight --configuration desktopRuntimeClasspath --dependency javacv-platform,输出如下:

image-1.png

水落石出,结果显示是 cmp-image-pick-n-crop-jvm:1.1.2 这个库依赖 org.bytedeco:javacv-platform:1.5.8 又依赖 org.bytedeco:opencv-platform:4.6.0-1.5.8cmp-image-pick-n-crop 是我在Github上找的一个用来裁剪图片的第三方库,它的JVM平台包依赖了 *-platform 全家桶,导致暴增。我在本次更新的改动中将其版本从 1.1.1 升级到 1.1.2 才出现这个问题,看来是作者在更新他的库依赖时犯了个低级错误。

修复很简单,回退到 1.1.1 即可解决,但是治标不治本。本着开源精神至上的原则我把该库fork并拉了下来准备直接修并提PR,结果这才发现这是个假开源项目。源代码是空的,只有个示例app。对印度人的刻板印象再次加深。无奈只好提了个issue作罢,但估计也不会被理睬,看commit日期这个库已经被放弃维护了,令人感叹。

案例二

2026年1月28日,发现v1.0.4版本桌面端安装包体积再次出现异常增大,但这次涨幅不如上次,且只有 Win / Linux 平台受影响。

image-2.png

这次以 Linux 平台 (.deb) 为例进行排查,还是老方法,解包然后看谁变大了。分别对新老版本安装包运行 du -h -d 4 old | sort -h | tail -n 30 查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# old
4.0K old/opt/bandoristationm/share
4.0K old/opt/bandoristationm/share/doc
20K old/opt/bandoristationm/bin
129M old/opt/bandoristationm/lib/app
168M old/opt/bandoristationm/lib/runtime
298M old
298M old/opt
298M old/opt/bandoristationm
298M old/opt/bandoristationm/lib

# new
4.0K new/opt/bandoristationm/share
4.0K new/opt/bandoristationm/share/doc
24K new/opt/bandoristationm/bin
129M new/opt/bandoristationm/lib/app
480M new/opt/bandoristationm/lib/runtime
610M new
610M new/opt
610M new/opt/bandoristationm
610M new/opt/bandoristationm/lib

lib/runtime 炸了,显然这次不是依赖包的问题,而是JRE出了差错,就像没被裁剪一样。在新版本包中运行 find new/opt/bandoristationm/lib/runtime/lib -type f -print0 | xargs -0 ls -lnS | head -n 30,结果如下:

1
2
3
4
5
6
-rw-r--r--  1 501  20  209861688 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/libcef.so
-rw-r--r-- 1 501 20 147634191 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/modules
-rw-r--r-- 1 501 20 26726528 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/server/libjvm.so
-rw-r--r-- 1 501 20 10717392 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/icudtl.dat
-rw-r--r-- 1 501 20 10715134 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/ct.sym
...

一上来就看懂了,怎么混进来个庞然大物 libcef.so,我这是不知道被谁塞了整套 Chromium 啊。翻了翻过去的 commit 记录,定位了罪魁祸首:在 Android Studio 建议迁移 gradle 后,gradle 在升级到9.0.0的同时,顺手给我塞了个 gradle-daemon-jvm.properties。我没有在意,但是这个配置文件里面设置了 toolchainVendor=JETBRAINS,即指定 JBR 为 Gradle Daemon 的运行时,而上面的URL里面又指定了 JBR 的版本是带 JCEF 的版本:

  • Linux: jbrsdk_jcef-21.0.9-linux-x64-b895.149.tar.gz
  • Windows: jbrsdk_jcef-21.0.9-windows-x64-b895.149.zip
  • macOS: jbrsdk_jcef-21.0.9-osx-aarch64-b895.149.tar.gz

这也导致当workflow在跑时,Gradle构建时,不会再用我指定的 temurin,而是现场下带JCEF 的 JBR 用,自然打包时把 libcef.so 等一系列不需要的东西也拐进来了。

解决:直接rollback即可。

经验总结

遇到包体积暴涨不用慌,凡事皆有迹可循。桌面端打包(Compose Desktop/jpackage)有个特点:最终体积主要由两部分决定

  • 应用层lib/app(jar、资源、代码)-> 通常增长是渐进的
  • 运行时层lib/runtime(JRE/JBR、native、资源包)-> 一旦策略变了就是断崖式增长

不要用“总大小”当指标,而是要抓“结构”,把体积拆成可解释的结构指标,迅速归因:

  • app 变大 → 依赖/资源/重复打包
  • runtime 变大 → JDK/JBR 变更、jlink 失效、引入 JCEF、debug 符号等