0%

该篇内容为原博客博文,原上传于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。过度区分是没有必要的,不应为了比较而比较,谁优谁劣更是没有定数。

在 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 符号等

Kotlin 的 [] 到底是什么

刚写 Kotlin 时,我就很快爱上了 map["key"]list[i] 这种简洁的 [] 写法。它看起来就像在其他语言中早已熟悉的“下标访问”,但更准确的说法是:[] 是编译器提供的运算符约定(operator convention)语法糖。它既能访问 List/Array,也能用于 Map,甚至可以被自定义到任何类型上。


1) [] 的原理:编译期改写为 get/set

Kotlin 的 [] 并不是某种“运行时特性”。编译器会把它静态地改写成函数调用:

  • 读取:a[b]a.get(b)
  • 写入:a[b] = ca.set(b, c)

只要你的类型(成员函数或扩展函数)提供了相应签名,并用 operator 修饰,就可以使用 []。一个简单的自定义类实现例子如下:

1
2
3
4
5
6
7
8
9
10
class Grid<T>(private val data: Array<Array<T>>) {
operator fun get(x: Int, y: Int): T = data[x][y]
operator fun set(x: Int, y: Int, v: T) { data[x][y] = v }
}

fun main() {
val g = Grid(arrayOf(arrayOf(1, 2), arrayOf(3, 4)))
println(g[1, 0]) // 等价于 g.get(1, 0)
g[0, 1] = 9 // 等价于 g.set(0, 1, 9)
}

然而细心的小伙伴可能就已注意到了:当我们在 Kotlin 中使用 mutableMapOf<K, V>() 在底层创建java.util.LinkedHashMap<K, V>时, Java 类本身并没有定义set方法,我们却仍可以在 Kotlin 侧直接用索引语法。这是因为其借助了 Kotlin 另一个强大的功能:扩展方法

1
2
3
4
5
6
7
8
9
@kotlin.internal.InlineOnly  
public inline operator fun <@kotlin.internal.OnlyInputTypes K, V> Map<out K, V>.get(key: K): V? =
@Suppress("UNCHECKED_CAST") (this as Map<K, V>).get(key)

/**
* Allows to use the index operator for storing values in a mutable map. */@kotlin.internal.InlineOnly
public inline operator fun <K, V> MutableMap<K, V>.set(key: K, value: V): Unit {
put(key, value)
}

另外,在 Kotlin/Java 互操作里,只要 Java 类上确实存在符合签名的 get(...) / set(...) 实例方法,Kotlin 侧通常就可以直接用 []——不需要在 Java 里写什么 operator(Java 也没这个关键字)。Kotlin 编译器会把 a[i]/a[i]=v 按“运算符约定”解析到这些方法上。

关键点

  • 索引参数不必一定是 Int:Map 的 key 就是泛型 K
  • a[x, y] 这种“多参数索引”是合法的,本质是 get(x, y)
  • 这是编译期语法糖:是否能用 [],由静态类型上有没有匹配的 operator get/set 决定。

2) Kotlin 标准集合里 [] 的语义:Map ≠ List/Array

虽然都用 [],但 Map 与 List/Array 的失败策略完全不同。这是 Kotlin 设计中很重要的语义区分,写码时也需要注意。

2.1 Map:查询语义(缺失返回 null)

Map<K, V>

  • map[key] 会调用 get(key)
  • key 不存在 → 返回 null
  • 因此返回类型通常是 V?
1
2
3
val m = mapOf("a" to 1)
println(m["a"]) // 1
println(m["b"]) // null

注意:即使 key 存在,value 也可能是 null(当 V 允许为 null 时)。所以 null 可能表示两种情况:

  1. key 不存在
  2. key 存在但 value 本来就是 null

如果希望“缺 key 就直接失败(抛异常等)”,常见的处理方式是:

  • map.getValue(key):缺失抛 NoSuchElementException
  • requireNotNull(map[key])
  • map[key] ?: error("...")

2.2 List / Array / String:索引语义(越界抛异常)

ListArrayString

  • list[i] / arr[i] / s[i] 是严格索引访问
  • 越界会抛异常(如 IndexOutOfBoundsExceptionStringIndexOutOfBoundsException
1
2
3
val list = listOf(10, 20)
println(list[1]) // 20
println(list[2]) // 抛异常

Kotlin也提供了更安全的替代版本:

  • getOrNull(i):越界返回 null
  • getOrElse(i) { default }:越界返回默认值
1
2
println(list.getOrNull(2))         // null
println(list.getOrElse(2) { -1 }) // -1

2.3 Set:通常没有 []

Set 不支持“按位置”或“按 key”索引,因此没有 set[i] 这种形式。常用的是:

  • x in set / set.contains(x) 判断元素是否存在

3) 和 C / Java / Python 的 [] 对比

[] 是一个长得很像、但在不同语言里“底层机制与语义”差别极大的符号。如果你像我一样日常需要经常在不同语言的项目间来回切换时,可能会搞混,或者直接认为 Kotlin的 [] 与其他语言完全是一种东西。然而他们虽为同一个符号,实际却有着不同的契约。

3.1 Kotlin vs Java:Java 没有通用的 [] 重载

Java 的 [] 只用于数组访问(和数组类型语法),例如 arr[i]
Java 不能obj[key] 解释成方法调用(没有运算符重载),所以 Java 中 Map 访问只能写:

1
map.get(key)

而 Kotlin 的 map[key] 对 JVM 来说仍然只是一次 get(key) 方法调用,是 Kotlin 编译器提供的语法糖。

3.2 Kotlin vs C:C 的 [] 是指针算术(无边界检查)

C 里 a[i] 等价于 *(a + i)

  • 越界通常是未定义行为(可能崩溃,也可能悄悄读错)
  • 不存在“缺失返回 null”的统一约定

而Kotlin 的 List/Array 越界是受控失败(抛异常),错误更可见、更可定位。

3.3 Kotlin vs Python:都“像方法”,但 Kotlin 是静态的、Python 更动态

Python 的:

  • obj[key]obj.__getitem__(key)
  • obj[key] = vobj.__setitem__(key, v)

这和 Kotlin 的“映射到方法”很相似,但差异非常关键:

  • 动态 vs 静态:Python 在运行时决定调用谁;Kotlin 在编译期由静态类型解析 get/set
  • 切片语法:Python 有内建 a[1:5](slice)。
    Kotlin 没有内建切片 []list[1..5] 默认不表示切片(除非自己定义 get(IntRange))。
  • 负数索引:Python a[-1] 是最后一个元素;Kotlin list[-1] 会越界异常(没有特殊语义)。

3.4 Kotlin vs C++:都可重载,但容器默认行为不同

C++ 也能重载 operator[],但很多容器的 operator[] 语义可能是“缺 key 自动插入默认值”(如 std::map)。
Kotlin 的 Map [] 不会插入,它是纯查询;要写入必须显式调用 map[key] = value


注意

  1. List/Array/String 的 [] 越界就抛异常,不像 C 那样“可能悄悄错”。
  2. Kotlin 没有 Python 那种内建切片list[1..3] 不是默认用法。
  3. Kotlin 的 [] 解析看静态类型:能不能用、用哪个 get,编译期就决定了。
  4. Kotlin Map 的 [] 不会像某些 C++ 容器那样访问即插入

4) 总结

  • Kotlin 的 []编译器把语法改写成 operator get/set 调用
  • Map[] 是“查询”,缺失返回 nullList/Array/String[] 是“索引”,越界抛异常
  • 虽然符号相同,但和 C(指针算术)、Java(无重载)、Python(动态 + 切片/负索引)相比,契约差异很大,跨语言迁移时要特别留意。

附:自用速查

场景 Kotlin 写法 失败行为
Map 查值 map[key] key 不存在 → null
Map 强制必须存在 map.getValue(key) 不存在 → 抛异常
List/Array 索引 list[i] / arr[i] 越界 → 抛异常
安全索引 list.getOrNull(i) 越界 → null
Set 判断存在 x in set 返回 true/false

该篇内容为原博客博文,原上传于2023年1月18日。

Sealed ClassSealed Interface是 kotlin 引入的全新特性。在初学 kotlin 时,我就一直没有掌握其用法,甚至到现在也不能在该用的时候立刻想到。

本篇文章主要总结Sealed Class的基本概念和常见用法。

什么是 Sealed

jetbrains 对 Sealed Class/Interface 的介绍如下:

Sealed classes and interfaces represent restricted class hierarchies that provide more control over inheritance. All direct subclasses of a sealed class are known at compile time. No other subclasses may appear outside a module within which the sealed class is defined. For example, third-party clients can’t extend your sealed class in their code. Thus, each instance of a sealed class has a type from a limited set that is known when this class is compiled.

The same works for sealed interfaces and their implementations: once a module with a sealed interface is compiled, no new implementations can appear.

In some sense, sealed classes are similar to enum classes: the set of values for an enum type is also restricted, but each enum constant exists only as a single instance, whereas a subclass of a sealed class can have multiple instances, each with its own state.

从以上介绍可以大致总结出 Sealed Class/Interface 有如下特性:

  • 表示受限的类层次结构,并可以对继承提供更多的控制

  • 密封类的子类在编译期即被确定为一个有限的集合内,不可扩展

  • 密封类的子类只能位于定义了该密封类的

  • 密封类的子类可以有多个实例

介绍中还提到,sealed class 和 enum class 在某种意义上是类似的,实际上,sealed class 诞生的重要原因之一正是为了克服 enum class 在某些场合下的局限性,也即上述特性的第一点和第四点。我们知道,枚举类有两个特性,在某些场合下是优点,但在另外一些场合下却可能成为缺点:

  • 每个枚举类型只能有一个实例(称为枚举常量)

  • 各枚举常量只能使用相同类型的属性

1
2
3
4
5
enum class Drink(val id: Int) {
Milk(1),
Coffee(2),
Water(3)
}

在如上我们创建的枚举类中,各子类(Milk,Coffee,Water)无论有多少对象,都只能有一个实例(即单例的枚举常量),且其属性均只能为枚举类中定义的 Int 类型。

而密封类则取消了以上限制,允许密封类的子类具有多个实例,且各子类可以定义自己的属性。

1
2
3
4
5
6
sealed class Drink {
// 这里直接在定义块内定义子类
class Milk(val id: Int): Drink()
class Coffee(val id: Double): Drink()
class Water(val id: String): Drink()
}

如上,密封类允许各子类具有不同类型的属性,只需要在子类的主构造函数中声明即可。需要注意的是,密封类在继承上的写法与抽象类区别较大,反而更加类似于一般的抽象类。实际上,密封类正是抽象的,不能直接生成实例,且可以具有抽象成员。因此,密封类可以像上述写法一样定义继承自密封类的子类,也可以用object直接定义子类对象,也可以再用sealed class,即密封类继承密封类,实现更加细粒度的类层次结构。

各子类允许有多个不同状态的实例:

1
2
3
4
val milk1 = Drink.Milk(1)
val milk2 = Drink.Milk(2)
println(milk1.id)
println(milk2.id)

上述特性的第二点意义在于,密封类对运行时的扩展是封闭的。程序在编译时,即可通过继承关系确认密封类的全部子类,由此产生了密封类一个非常实用的用处,在写 when 语句时不需要添加 else 分支,因为全部可能的分支在编译时即可确定,没有其他情况:

1
2
3
4
5
fun test(drink: Drink) = when (drink) {
is Drink.Milk -> println("milk")
is Drink.Coffee -> println("coffee")
is Drink.Water -> println("water")
}

上述特性的第三点意义在于,限制了密封类的作用空间,并且随着 kotlin 版本的更迭越来越宽松:从只能在 sealed class 内,到 1.1 支持同一文件内,到 1.5 支持同一包下。

怎么用 Sealed

kotlin 初学者往往会把sealed class当做enum class用,但enum class具有很明显的适用场景,这么做是不合适的。比如,你仅需要一系列相同类型的单例,且不需要任何额外描述,也不需要特殊的函数,那么用枚举就足够了,比如在我的《设计模式之状态模式》中,各状态有统一的处理方法,用枚举来表示。

在 Android 开发中, sealed class有如下一些使用场景:

  • 列表有不同类型的子项(文字、图片),用密封类表示列表的 item

  • 封装网络请求中成功(含有任意类型的请求数据)和失败(含有失败信息,如异常)返回的数据

  • 使用object达到与枚举类似的效果(虽然在 google 的官方示例都出现了这种用法,但还是不推荐。为什么不直接用枚举呢?)

  • 其他一切不满足枚举的应用场景,但需要与枚举类似效果,可以考虑用密封类

关于 Sealed Interface

以上都在讨论sealed class,而sealed interface作为 kotlin1.5 中登场的新特性,也值得说道说道。

密封接口进一步补足了密封类和枚举类的一些不足之处,如枚举类编译后继承自Enum,由于单继承不能再继承其他类,此时可以用密封接口,对枚举类做进一步划分。当然直接用嵌套密封类也未尝不可。

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

前言

kotlin是一款十分灵活而又强大的语言。合理地运用kotlin的一些特性,可以极大地提高代码可读性和质量,提高效率。下面我们就来说一说kotlin中的scope function

域函数

我们主要介绍let, also, with, run, apply这五种常用的域函数。

(1)let

先上let函数的源码:

1
2
3
4
5
6
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}

从源码我们可以看出,首先,let是对调用者的扩展函数。其次,let函数接收一个lambda表达式block作为唯一参数,且将调用者本身作为参数传给了lambda。而在函数的内部,首先使用contract进行约束,向编译器表明这个lambda只会执行一次(EXACTLY_ONCE),接着就是调用lambda并返回其返回值。
进而,我们可以得出如下结论:

  1. 由于let函数将调用者作为lambda的参数,因而在let闭包内,可以用it指代调用者;
  2. let函数是具有返回值的,且其返回值应为lambda表达式中的最后一行/return语句。

随之,我们可以得到let函数的作用:

  1. 在明确某一对象实例在一定范围内需要使用时,可以对其调用let,再用it指代;
  2. (常用)对某一可能为空的对象进行统一判空的处理。

这里对作用二作一些解释。对于一个可能为空的对象object,如果不使用let,每次调用其方法或属性时,都要加上?或者!!,以及类似if not null的判空逻辑。但如果使用let,代码就将简化为如下的形式:

1
2
3
4
object?.let {
it.doSomething()
it.id = 0
}

以上代码表示,只有当object不为空时,才会执行let中的逻辑,且由于判空交给了object后的?符来处理,let内部不再需要任何多余的判空。如此以来,判空就变得优雅了许多。

(2)also

先上also函数的源码:

1
2
3
4
5
6
7
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}

从源码可见,also函数的实现思路与let类似,都是对调用者的扩展,都是将调用者作为参数传给lambda,都使用contract进行调用约束,都会在内部执行lambda。但有如下不同:lambda没有返回值,而从return语句看,also返回的是调用者本身。
进而,我们可以得出如下结论:

  1. also函数的应用场景与let相同;
  2. also函数返回值为调用者本身。

(3)with

先上with函数的源码:

1
2
3
4
5
6
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}

虽然源码的整体风格和之前两者类似,但其使用方法却截然不同。首先,with是一个通用的扩展函数,其有两个参数:receiver,和一个lambda。注意这里lambda的参数是T.(),它表明将这个lambda函数作为同为类型T的receiver的一个扩展函数,从而能被receiver所调用。进而,我们可以得出如下结论:

  1. 由于with内部是通过扩展函数使得receiver去调用lambda,因而可以在lambda的闭包内部直接访问receiver的public方法/属性。(即没有it等间接指代值)
  2. with函数是具有返回值的。从源码的return语句可以看出,其返回的是lambda表达式的最后一行/return语句。

随之,我们可以得到with函数的作用:
在需要多次调用某一对象的方法或属性时,可以将该对象传给with,再在with内部处理相应逻辑。这样写可以避免多次重复地写该对象。
举个栗子:当我们想写一个alertDialog时,需要先创建builder,对builder进行一系列配置,再调用builder的create()方法返回一条alertDialog。有了with后,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private lateinit var alertDialog: AlertDialog

...

val builder = AlertDialog.Builder(activity)
alertDialog = with (builder) {
val alertView = layoutInflater.inflate(R.layout.dialog_confirm, null, false)
setView(alertView)
setNegativeButton("取消") { dialog, which ->
Toast.makeText(activity, "已取消", Toast.LENGTH_SHORT).show()
}
setPositiveButton("确定") { dialog, which ->
run {
bill?.let { it1 -> viewModel.deleteBill(it1) }
activity?.onBackPressed()
}
}
create()
}

是不是简洁了许多?

(4)run

还是先上源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}

public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}

run有点类似let和with的结合体,继承了两者各自的优点,因而使用场景十分广泛:首先,它像let一样是对调用者的扩展,因而可以统一判空处理;其次,它又像with一样,传入lambda的是T.(),表明它可以像with一样在闭包内直接访问方法/属性。最后,它返回的是lambda的返回值。

(5)apply

上源码。。。

1
2
3
4
5
6
7
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}

不难看出,apply和run类似于also和let的关系,使用场景类似,只是返回值有差异。
而由于apply返回的是调用者本身,因而它十分适合用来做变量的初始化。

总结

函数名 特点 作用
let 1.在指定域内定义变量it 2.返回最后一行 统一判空处理
also 1.在指定域内定义变量it 2.返回传入对象本身 统一判空处理
with 1.直接访问方法和属性 2.返回最后一行 多次调用时省去变量名
run 1.let和with的结合 2.返回最后一行 统一判空的同时省去it,省去变量名的同时判空
apply 1.同run 2.返回传入对象本身 同run,此外适用于初始化变量

作者纯kotlin小白,如有疏漏与错误,敬请大佬指正!

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

话说从寒假开始半摸半读《CS:APP》,但是一直没有写Lab。都说9个Lab是全书的精华,因此深感有必要做一下,所以前阵子抽空先把DataLab做了。虽说是第一个(据说也是最简单的)Lab,却也做的磕磕绊绊,深感自己码力不足。总之最后是完成了,还是写篇博客记录一下,也方便自己日后review。

1分题

1分题基本都是签到题,难度不大。

bitXor
1
2
3
4
5
6
7
8
9
10
/* 
* bitXor - x^y using only ~ and &
* Example: bitXor(4, 5) = 1
* Legal ops: ~ &
* Max ops: 14
* Rating: 1
*/
int bitXor(int x, int y) {
return ~(~(~x&y)&~(x&~y));
}

只使用位与和位非实现位或,运用De Morgan’s laws就行了。

tmin
1
2
3
4
5
6
7
8
9
/* 
* tmin - return minimum two's complement integer
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 4
* Rating: 1
*/
int tmin(void) {
return 0x1 << 31;
}

返回补码表示的最小数,即0x8000_8000,也即int类型能表示的最小数,也没有什么好说的。

2分题

开始有些技巧性的操作出现了。

isTmax
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* isTmax - returns 1 if x is the maximum, two's complement number,
* and 0 otherwise
* Legal ops: ! ~ & ^ | +
* Max ops: 10
* Rating: 1
*/
int isTmax(int x) {
int i = x + 1; // Tmin,1000...
x = x + i; // -1,1111...
x = ~x; // 0,0000...
i = !i; // exclude x=0xffff...
x = x + i; // exclude x=0xffff...
return !x;
}

要求如果x是补码表示的最大数就返回1,否则返回0。
那么我们首先要能识别出补码最大数,并且能将其转化为0x1作为结果。显然0x7fff_ffff就是补码最大数,如何利用允许使用的运算符将其转化为0x1呢?这里就比较tricky了。我们可以取x + 1 + x,如果x是0x7fff_ffff时,将得到0xffff_ffff,即全1。对全1取位反即可得到0,再对0取逻辑非就能得到0x1了。
但这里有一个容易忽略的情形,即当x为0xffff_ffff时,由于溢出,x + 1 + x也能得到同样的结果(我是在btest时才发现这个特例),所以需要排除这个情形,使得只有当x=0x7fff_ffff时才能返回0x1。为此,我们可以不着急对0取逻辑非,而是先再对原来的x+1取逻辑非,再加给取位反后的x + 1 + x。当x为0x7fff_ffff时,这一步多出的操作不会对结果造成任何影响,因为此时x + 1取非后得到的是0,加个0显然不会有影响;但对于x为0xffff_ffff时,x + 1取非后得到的是1,加回去后得到1,再取逻辑非时就变成了0,从而排除了这个例外。

allOddBits
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 
* allOddBits - return 1 if all odd-numbered bits in word set to 1
* where bits are numbered from 0 (least significant) to 31 (most significant)
* Examples allOddBits(0xFFFFFFFD) = 0, allOddBits(0xAAAAAAAA) = 1
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 12
* Rating: 2
*/
int allOddBits(int x) {
int mask = 0xAA + (0xAA << 8);
mask = mask + (mask << 16); // mask = 0xAAAAAAAA
x = mask & x;
return !(~x & mask);
}

从0开始计位,若x所有奇数位都为1则返回1,否则返回0。
如何提取x的奇数位呢?显然用一个奇数位为1,偶数位为0的mask就行了,但我们不能直接用int mask = 0xAAAAAAAA构造出这个掩码,因为Lab要求整数实验的代码最多使用8位常数,如0xAA,所以我们首先要用0xAA产生0xAAAAAAAA,方法有很多,这里就不再赘述。再用这个mask与x位与,即可在结果的奇数位上对应得到x的奇数位。如何判定x的奇数位是否全为1呢?我们很容易想到如果如此,那么x位反后再与mask位与,应当得到全0,因为一个是奇数位全0,偶数位全1,另一个是奇数位全1,偶数位全0,没有一位是相等的。再对结果取逻辑非就能得到1。而对于不满足的x,检验可知经同样的运算,最终都会得到0。

negate
1
2
3
4
5
6
7
8
9
10
/* 
* negate - return -x
* Example: negate(1) = -1.
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 5
* Rating: 2
*/
int negate(int x) {
return ~x + 0x1;
}

返回x的相反数。只要对补码表示有基本了解,这题应该都是秒过,不再赘述。

3分题

难度更大了。。。

isAsciiDigit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 
* isAsciiDigit - return 1 if 0x30 <= x <= 0x39 (ASCII codes for characters '0' to '9')
* Example: isAsciiDigit(0x35) = 1.
* isAsciiDigit(0x3a) = 0.
* isAsciiDigit(0x05) = 0.
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 15
* Rating: 3
*/
int isAsciiDigit(int x) {
// 考察如何使用位运算比较大小->不能直接用算术运算符,但思路依然是可以比较符号位->用位运算凑出符号位改变的临界数
int sign = 0x1 << 31;
int compH = ~(sign | 0x39); // 当与小于等于0x39的数相加后,符号位为0;当与大于0x39的数相加后,符号位变为1
int compL = ~0x30; // 当与大于0x30的数相加后,符号位为0;当与小于等于0x30的数相加后,符号位变为1
int sign_1 = ((compH + x) & sign) >> 31; // 当符号位为0时,得到sign_1为全0;当符号位为1时,得到sign_1为全1
int sign_2 = ((compL + x + 0x1) & sign) >> 31; // 这里需要额外加个1,因为当x=0x30时,符号位仍为1,需要加1进位使其变为0.且加1不会对0x30之后的数的结果产生影响
return !(sign_1 | sign_2); // 这里要用逻辑非来实现或非,因为最后要求的是返回0/1,而sign_1/2位或后仍是32位全0/1整数
}

若x是0~9数字的ASCII码表示,则返回1,否则返回0。
只要我们能比较x同0x30和0x39的大小就行了。如何用位运算比较两个数之间的大小呢?(我不道啊?看了别人的写法才懂)有一种比较tricky的处理方式,虽然这题不给用减法,但我们可以借鉴类似的想法,即自己构造出会使得符号位改变的临界数,再看符号位就行了。具体思路可以看代码注释。

conditional
1
2
3
4
5
6
7
8
9
10
11
12
/* 
* conditional - same as x ? y : z
* Example: conditional(2,4,5) = 4
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 16
* Rating: 3
*/
int conditional(int x, int y, int z) {
int flag = !!x;
int mask = ~flag + 1;
return (y & mask) + (z & ~mask);
}

这里的难点在于如何从x的位级表示获取其一位布尔值,使用逻辑非即可。第一次逻辑非会把0转化为1,非0转化为0,第二次逻辑非会还原为原逻辑值,所以需要2次逻辑非。但我们不能直接用这个一位布尔值,因为要实现选择操作,用一位只能保留原输入的一位,显然是不对的,故还需经一步得到全1和全0,再做与操作。

isLessOrEqual
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 
* isLessOrEqual - if x <= y then return 1, else return 0
* Example: isLessOrEqual(4,5) = 1.
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 24
* Rating: 3
*/
int isLessOrEqual(int x, int y) {
int sign = 0x1 << 31;
int sign_x = x & sign;
int sign_y = y & sign;
int sign_inequal = ((sign_x ^ sign_y) >> 31) & 0x1; // sign_inequal为0x1,表示符号不同;为0则表示符号相同
int sign_diff = (y + ~x + 0x1) & sign; // y - x 的差值符号,当为0x1<<32时表示y<x,当为全0时表示y>=x。 有错误:当x为负,y为正时有可能溢出,因而不可靠。只有当符号相同时才可靠
int same_res = !sign_diff;// 符号相同时的结果
int diff_res = !sign_y;//符号不同时的结果,当y为正数时返回值就为1,否则返回0
return (sign_inequal & diff_res) + (!sign_inequal & same_res);//符号不同时,即sign_equal为1时,返回diff_res;否则返回same_res
}

比较x,y大小,返回比较结果。
第一眼看,我们也许会想用isAsciiDigit的思路做这题,但这是不行的。因为上一题我们只考虑0x30~0x39这一正数小区间,而现在我们需要考虑整个实数域。用相同的方法处理y为负数时会出现问题。
这时就要回退到一般运算思路,即符号不同正数为大,符号相同看差值符号即可。获取差值时,这里不能直接用’-‘,故用补码方式。
x,y和y-x的符号不难获得,但难点在于如何通过位运算在符号不同时正确选择正的那一个数,思路可参见代码注释。

4分题

这几位更是重量级。

logicalNeg
1
2
3
4
5
6
7
8
9
10
11
/* 
* logicalNeg - implement the ! operator, using all of
* the legal operators except !
* Examples: logicalNeg(3) = 0, logicalNeg(0) = 1
* Legal ops: ~ & ^ | + << >>
* Max ops: 12
* Rating: 4
*/
int logicalNeg(int x) {
return ((x | (~x + 1)) >> 31) + 1;
}

实现逻辑非运算符。要做出这题,首先需要清楚C语言中的右移对于有符号数是算术右移,以及整数中的1000…0000表示最小数而不是表示负零。其次,还是要想办法将位级表示的x的信息用一个比特表示出来,之前我们都是用!!,但这题就是需要我们实现!,故得采取新的思路。想到对于0来说,其原码和补码都是0x0000,符号位相或仍为0;而对于非0数,其源码和补码的符号位必然互补,从而相或时为1,进而就可以区分0和非0数了。最终要求是求反,故再加1即可。
真正实现起来,一行代码即可搞定,但背后的思考过程不是那么容易,你得想到0和非0数各自相反数符号位的差异,才能做出这题。

howManyBits
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
/* howManyBits - return the minimum number of bits required to represent x in
* two's complement
* Examples: howManyBits(12) = 5
* howManyBits(298) = 10
* howManyBits(-5) = 4
* howManyBits(0) = 1
* howManyBits(-1) = 1
* howManyBits(0x80000000) = 32
* Legal ops: ! ~ & ^ | + << >>
* Max ops: 90
* Rating: 4
*/
int howManyBits(int x) {
int b16, b8, b4, b2, b1, b0; // 必须提前声明,否则dlc检查时会报错
int sign = x >> 31; // 提取符号位,sign为全1或全0
x = (sign & ~x) | (~sign & x); // 这一步是为了方便处理负数。对负数取反,将找0转化为找1,这样对于正负数就可以统一处理了
//经上一行操作后,x符号位总为0,故算术右移不会造成影响
b16 = !!(x >> 16) << 4; //考察高16位是否有1,若有,b16得16,否则得0,至少需要16位。
x = x >> b16; //若高16位有1,则将x右移16位。对于b16=16,此为向左缩小搜索范围;对于b16=0,此为向右增大搜索范围
b8 = !!(x >> 8) << 3; //考察高8位是否有1,若有,b16得8,否则得0,至少需要位数再加8位
x = x >> b8; //继续缩小范围,考察高4位。下面以此类推。
b4 = !!(x >> 4) << 2;
x = x >> b4;
b2 = !!(x >> 2) << 1;
x = x >> b2;
b1 = !!(x >> 1);
b0 = x >> b1; // 最高两位是11时,所需位数还得加1
return b16 + b8 + b4 + b2 + b1 + b0 + 1; // 符号位必定占一位
}

求用补码表示x至少需要几位。思路很好想,符号位必定占一位,剩下的从左往右找第一个有效位(正数是1,负数是0),其与之后剩下的位都是需要的位。但是代码实在写不出来,就copy别人的代码了。
实际上代码的思路也很简单,就是在这串32位比特上做二分搜索还能这样,艹),找到第一个有效位,同时统计比特数量。
这里还有一个小插曲,即使用的临时变量都必须在函数的一开始提前声明,不能在使用时才声明并初始化,否则dlc检查时会报错,说你没有声明变量。不太清楚原因。

floatScale2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 
* floatScale2 - Return bit-level equivalent of expression 2*f for
* floating point argument f.
* Both the argument and result are passed as unsigned int's, but
* they are to be interpreted as the bit-level representation of
* single-precision floating point values.
* When argument is NaN, return argument
* Legal ops: Any integer/unsigned operations incl. ||, &&. also if, while
* Max ops: 30
* Rating: 4
*/
unsigned floatScale2(unsigned uf) {
int exp = (uf & 0x7f800000) >> 23; //exp低23位表示原数的阶码
int sign = uf & 0x80000000; // sign最高位为原数符号位,其余位全为0
if (exp == 255) return uf; // 无穷或NaN
if (exp == 0) return sign | (uf << 1); // 非规格数
if (++exp == 255) return sign | 0x7f800000; // 溢出,返回无穷大
return (exp << 23) | (uf & 0x807fffff); // 规格数且无溢出
}

实现2*f。与前面的题相比,这题其实不难,只要认真读了书,知道浮点数的表示方法就能做出来。
若为规格数,直接阶码加1即可。但若阶码加1后变为全1,则返回的应是对应符号的无穷大。
若为非规格数,则直接左移1位并补上符号位即可。这是因为当非规格数的小数字段最高位为0时,直接左移1位仍表示为非规格数,且小数字段因左移而乘以2,自然得到了结果
当小数字段最高位为1时,左移1位会导致阶码变为0x1而成为规格数。但对规格数而言,0x1阶码与非规格数表示的指数相同,都为1-Bias。且虽然小数最高位看似丢失了,但规格数表示的尾数本身就是1+f,与非规格数的尾数f相比,相当于把最高位的1表示成十进制的0.5乘以2得到的1单独提取了出来,其余位左移自然乘以2。得益于IEEE浮点标准的这种非规格数平滑向规格数过渡的设计,我们这里处理非规格数也简单很多,不需要担心溢出。
若为无穷大和NaN,直接返回原数即可

floatFloat2Int
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
/* 
* floatFloat2Int - Return bit-level equivalent of expression (int) f
* for floating point argument f.
* Argument is passed as unsigned int, but
* it is to be interpreted as the bit-level representation of a
* single-precision floating point value.
* Anything out of range (including NaN and infinity) should return
* 0x80000000u.
* Legal ops: Any integer/unsigned operations incl. ||, &&. also if, while
* Max ops: 30
* Rating: 4
*/
int floatFloat2Int(unsigned uf) {
int exp, E, tail, sign;
if (!(uf & 0x7fffffff)) return 0; // 当uf为0,-0时,直接返回0即可。注意这个-0容易遗漏

exp = (uf & 0x7f800000) >> 23;
sign = uf & 0x80000000;
tail = (uf & 0x007fffff) | 0x00800000; // 由于之后的代码会排除非规格数,故对规格数而言,真实的小数表示为尾数+1,这里与0x00800000相或就是为了加上小数点前的1

E = exp - 127; // E表示由阶码复原得到的真实指数,注意E > 31时,必定在左移时覆盖掉符号位,所以会溢出。此时也包括了NaN和INF(E=255-127必然大于31)
if (E > 31) return 0x80000000;
if (E < 0) return 0; // 直观来理解,对于始终小于2的尾数,当E < 0时,相当于至少除以2,所得一定是个小于1的小数,故取0。非规格数也在这里被排除

// 0 <= E <= 31时,依然有可能发生溢出,故需要做甄别并排除
// 开始左移/右移。当E > 23时,说明乘法后所有小数点后的原尾数位都移到了小数点前,此时结果为一个整数,故直接左移即可。 当E < 23时, 说明乘法后仍含有小数部分,
// 则转为Int时直接丢弃这些小数部分即可,对应的操作就是右移
tail = (E > 23) ? tail << (E - 23) : tail >> (23 - E);
// 原来是按位表示的1+小数,故对E>23来说,一开始乘2时会先移动小数点,故不会改变作为Int的各位数值。只有当超过23部分的E会导致数值开始乘2,即左移
// 而对于E<23来说,无论如何都只会改变小数点而不改变数值,因而不会左移。但因为此时含有小数,故权值不是从0而是从某个负数开始,因而各位上的权值对应都会衰减,本应是右移23位来对上小数
// 的权值,但因为有乘E次2的操作,每次乘2都会使权值对应升高1位,故会抵消E次右移操作,最终效果就是右移23-E位。同时在右移的过程中,还舍去了小数的信息。
// 统一的解释:因为小数从左向右的权值是从-1开始的,而不是从0开始的,故对于原小数的位级表示,应首先右移23位来对上正确的权值(<<-23 or >>23)。
// 但因为有乘E次2的操作,每次乘2都会使权值对应升高1位,故会抵消E次右移操作,最终效果就是右移23-E位(<<E-23 or >> 23-E)。同时注意在右移的过程中,还会舍去小数的信息。
return sign ? -tail : tail; // 保留原符号
}

实现floatint强制类型转换。这题也没写出来,copy了网上代码,代码解释看注释就行。

floatPower2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 
* floatPower2 - Return bit-level equivalent of the expression 2.0^x
* (2.0 raised to the power x) for any 32-bit integer x.
*
* The unsigned value that is returned should have the identical bit
* representation as the single-precision floating-point number 2.0^x.
* If the result is too small to be represented as a denorm, return
* 0. If too large, return +INF.
*
* Legal ops: Any integer/unsigned operations incl. ||, &&. Also if, while
* Max ops: 30
* Rating: 4
*/
unsigned floatPower2(int x) {
int exp = x + 127; // 加上bias得到位级表示的阶码
// 阶码的范围是00000000~11111111,超出这个表示范围时,对应0(全0)和INF(0111_1111_1000_0...0)
if (exp <= 0) return 0; // 极小数,当做0
if (exp > 0xff) return 0x7f800000; // INF
return exp << 23;
}

实现2的幂次方运算。这题简单一点,首先由x加上bias得到对应浮点数的阶码。由于2的幂符号位为0,尾数也为全0,故再对阶码移位到浮点数对应的位置即可。注意要排除极小数和无穷,即超过阶码表示的数。

结果

这里有个小插曲,编译btest时提示不兼容的gcc。这是因为我的机器是x86-64,但Lab的makefile中添加了-m32参数,而我缺少在64位机器编译32位程序的库。这里可以选择修改makefile,把这个参数删去或者改为-m64,或者安装需要的库,有gcc-multilibmodule-assistant
测试结果如图。
lab_1_result.png

碎碎念

从这个学期开始,我一直处于一种浑浑噩噩的状态,课内没学好,加权卷不上去,课外的知识也进度缓慢(包括但不限于CSAPP只读到了第四章,计算机视觉半熟不熟,几个月没写安卓)。最近看了许多大牛学长的博客和他们的经历分享,收获了很多,还是希望能向强者靠拢。开始做CSAPP的lab,也算是自己挣脱摆烂状态的一次尝试。希望自己能继续努力,保持学习吧!

该篇内容为原博客博文,原上传于2021年9月11日。

前言

提到Android中的消息机制,想必大家对其都不陌生。Android消息机制,一般指handler和其附加的mq及looper的运行机制与工作过程。handler常用于解决在子线程不能访问UI的问题,举一个常见的例子:我们在子线程中执行了耗时工作后,需要对UI进行更新,但又不能直接在子线程更新UI(这是因为控件在重绘前会调用checkThread()检查当前是否为主线程,以防ANR)。此时我们就可以创建handler,并将要执行的代码逻辑写入一个runnable任务中,并调用handler的post(本质上仍是调用send)或send将任务提交给主线程的消息队列中,再由主线程的looper取出这个任务,在主线程执行,如此便自然地从子线程转入主线程,实现了UI的更新。当然我们也可以直接调用Activity类的runOnUiThread()方法,道理是一样的。下面就让我们一起从源码的角度探究handler消息处理机制的原理,以及handler使用存在的一些问题。

handler的构造问题

handler有多个构造方法,我们常常用到的有以下几个:

  1. 无参构造
1
2
3
4
@Deprecated
public Handler() {
this(null, false);
}

这个构造方法已经弃用。其内部调用了另一个有参构造方法,但是传的值为null和false,让我们接下去看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Handler(@Nullable Callback callback, boolean async) {
if (FIND_POTENTIAL_LEAKS) {
final Class<? extends Handler> klass = getClass();
if ((klass.isAnonymousClass() || klass.isMemberClass() || klass.isLocalClass()) &&
(klass.getModifiers() & Modifier.STATIC) == 0) {
Log.w(TAG, "The following Handler class should be static or leaks might occur: " +
klass.getCanonicalName());
}
}

mLooper = Looper.myLooper();
if (mLooper == null) {
throw new RuntimeException(
"Can't create handler inside thread " + Thread.currentThread()
+ " that has not called Looper.prepare()");
}
mQueue = mLooper.mQueue;
mCallback = callback;
mAsynchronous = async;
}

这里我们先解释一下这个构造方法的参数:callback是handler中定义的一个接口,用于简化构建handler的过程(可以用实现这个接口来避免自定义handler子类),下文再对这个接口做详细解释。这里传入值为null,说明我们不采用这种方式。async标志这个handler是否采用异步消息,下文再对handler的异步、普通和屏障消息作区别以及介绍同步屏障机制。这里传入false,说明我们默认采用的都是普通消息机制。
这个构造方法首先会通过FIND_POTENTIAL_LEAKS判断是否由可能存在的内存泄漏情况,如果有,则会输出日志警告我们。(不过我没有找到设置这个bool值为true的代码部分。。。)关于handler潜在的内存泄漏风险,下文会重点讨论。接着调用Looper.myLooper()尝试获取当前线程的looper。我们进入这个方法看看:

1
2
3
public static @Nullable Looper myLooper() {
return sThreadLocal.get();
}

调用了sThreadLocal的get方法并返回。那么sThreadLocal是什么呢?找到定义如下:

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();

发现这是一个ThreadLocal变量。关于ThreadLocal这里不做过多介绍(计划单独写一篇博客研究一下ThreadLocal,毕竟也是一个十分重要的知识点)。这样以来就明确了:每个线程只能有最多一个looper,并且存储在threadLocal里。调用get便可以得到当前线程的looper。但是除了主线程以外(主线程的消息机制下文会重点讨论),线程默认是没有looper的,需要自己通过prepare创建。如此,get方法就有可能返回null。当返回null时,就会抛出异常。以上我们可以得出一个结论:使用无参构造方法,必须确保当前线程持有looper,否则就会导致异常抛出。因此,这个方法被废弃也就不足为奇了。相应地,google建议我们使用另一个构造方法代替:
2.

1
2
3
public Handler(@NonNull Looper looper) {
this(looper, null, false);
}

这个构造方法同样在内部调用了另一个构造方法:

1
2
3
4
5
6
public Handler(@NonNull Looper looper, @Nullable Callback callback, boolean async) {
mLooper = looper;
mQueue = looper.mQueue;
mCallback = callback;
mAsynchronous = async;
}

可见这里直接使用了参数指定的looper,并获得了其对应的消息队列。

MQ的工作原理

现在我们有了handler对象,并且这个handler内部持有某个线程的looper和messageQueue的对象。接着,就可以调用handler的send/post方法发送消息了。由于查看源码可以知道post方法本质上还是将runnable包装成message后调用send系列方法,所以这里就直接看send系列方法。譬如调用sendMessage方法:

1
2
3
public final boolean sendMessage(@NonNull Message msg) {
return sendMessageDelayed(msg, 0);
}

内部调用了另一个方法sendMessageDelayed。由此可见,我们也可以直接调用sendMessageDelayed并指定一个延迟时间,实现定时任务的效果。走进这个方法:

1
2
3
4
5
6
public final boolean sendMessageDelayed(@NonNull Message msg, long delayMillis) {
if (delayMillis < 0) {
delayMillis = 0;
}
return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}

又调用了另一个方法sendMessageAtTime。走进这个方法:

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
    public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) {
MessageQueue queue = mQueue;
if (queue == null) {
RuntimeException e = new RuntimeException(
this + " sendMessageAtTime() called with no mQueue");
Log.w("Looper", e.getMessage(), e);
return false;
}
return enqueueMessage(queue, msg, uptimeMillis);
}
```
可以看见,首先方法试图获取一个消息队列,如果获取不到则会发出警告并返回false,那么消息则发送失败,不了了之。否则,接下来会调用enqueueMessage方法,走进这个方法:
```java
private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
long uptimeMillis) {
msg.target = this;
msg.workSourceUid = ThreadLocalWorkSource.getUid();

if (mAsynchronous) {
msg.setAsynchronous(true);
}
return queue.enqueueMessage(msg, uptimeMillis);
}
```
直接看最后一行,发现经过一系列方法,最终调用的是消息队列的enqueueMessage方法,也是消息队列工作较为重要的一个方法。走进这个方法:
```java
boolean enqueueMessage(Message msg, long when) {
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}

synchronized (this) {
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}

if (mQuitting) {
IllegalStateException e = new IllegalStateException(
msg.target + " sending message to a Handler on a dead thread");
Log.w(TAG, e.getMessage(), e);
msg.recycle();
return false;
}

msg.markInUse();
msg.when = when;
Message p = mMessages;
boolean needWake;
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
// Inserted within the middle of the queue. Usually we don't have to wake
// up the event queue unless there is a barrier at the head of the queue
// and the message is the earliest asynchronous message in the queue.
needWake = mBlocked && p.target == null && msg.isAsynchronous();
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}

// We can assume mPtr != 0 because mQuitting is false.
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}
```
虽然上面的代码比较长,但其实只做了一件很简单的事情:向消息队列插入当前消息。
首先,我们要了解消息队列底层采用的数据结构。虽然称作队列,其本质上只是一个普通的单链表。这点可从message类的源码印证:
```java
...
// sometimes we store linked lists of these things
@UnsupportedAppUsage
/*package*/ Message next;
...

每个message对象都有一个next指针域。
enqueueMessage首先会对一些特殊情况进行处理,接着上了synchronize保证对链表的访问是线程安全的。接着分两种情况:第一次插入,唤醒阻塞的队列并创建新头,插入第一条消息,否则就尾插到链表尾部。
那么,looper是如何取出mq中的消息的呢?这里就要调用另一个方法next()了:

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
Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.
final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {
// Try to retrieve the next message. Return if found.
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}

// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}

// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

这段代码中涉及到许多值得注意的地方,比如同步屏障机制的实现等。但目前我们只关心next()方法的主要作用:取出消息队列中下一个消息。核心代码如下:

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
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronousmessage in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}

// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}

首先获取了当前系统时间now。接着开始遍历消息队列。如果遇到屏障,则跳过普通消息寻找下一个异步消息(暂且不管)。而如果遍历到的消息的when字段大于当前时间,说明这个消息还未准备好,于是不先取出而是设置了一个唤醒时间,等到时间时再取出。否则,取出当前消息,并返回给调用者(looper)。而如果迟迟没有返回,消息队列就会设置nextPollTimeoutMillis为-1,并无限循环地阻塞下去,直到取出并返回可用的消息。最后是消息队列退出时的一些善后操作。
如此以来,消息队列的工作原理我们就大致清晰了。

Looper的工作原理

handler负责发送和接收消息,message queue负责按FIFO方式存储消息,而looper则负责无限期地从mq中取出消息并分发给handler。looper的核心方法是loop()。
我们先了解一些looper类的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@UnsupportedAppUsage
static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
@UnsupportedAppUsage
private static Looper sMainLooper; // guarded by Looper.class
private static Observer sObserver;

@UnsupportedAppUsage
final MessageQueue mQueue;
final Thread mThread;
private boolean mInLoop;

@UnsupportedAppUsage
private Printer mLogging;
private long mTraceTag;

其中sThreadLocal我们已经不陌生了,用来保存不同线程的Looper对象;sMainLooper是主线程的looper,这个比较特殊。sObserver是用来处理事务的观察者(暂且不管)。mQueue用来保存消息队列,mThread是当前工作线程。mInLoop标记looper正在工作当中。mLogging是日志工具。

前面已经提到,每一个线程必须先构造looper,消息机制才能正常工作。那么我们就来看一看Looper的构造方法:

1
2
3
4
private Looper(boolean quitAllowed) {
mQueue = new MessageQueue(quitAllowed);
mThread = Thread.currentThread();
}

可见这里就是简单地构造了looper对应的消息队列,并保存了当前的线程对象。值得注意的是,这个唯一的构造方法是私有的,说明我们不能直接通过new获取一个looper。相反,我们需要调用prepare()方法:

1
2
3
4
5
6
7
8
9
10
public static void prepare() {
prepare(true);
}

private static void prepare(boolean quitAllowed) {
if (sThreadLocal.get() != null) {
throw new RuntimeException("Only one Looper may be created per thread");
}
sThreadLocal.set(new Looper(quitAllowed));
}

在prepare方法中,我们会先尝试从threadLocal中获取looper,看看当前线程是否已经有looper了。如果有,将会抛出异常,以此保证每个线程只能持有最多一个looper对象。如果没有,就会构造looper并放入threadLocal中。

前面我们反复提到了主线程looper的特殊性。实际上,Looper类还提供了一个prepareMainLooper()方法:

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
    @Deprecated
public static void prepareMainLooper() {
prepare(false);
synchronized (Looper.class) {
if (sMainLooper != null) {
throw new IllegalStateException("The main Looper has already been prepared.");
}
sMainLooper = myLooper();
}
}
```
这个方法原本用于让ActivityThread创建主线程looper,但现在安卓环境会自己帮你创建,于是这个方法就被废弃了。现在,我们只需要知道主线程looper是系统自身已经创建好的,不需要自己再调用prepare方法创建。
下面我们开始介绍loop()方法。只有调用了loop(),消息循环才能真正起作用。
```java
public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
if (me.mInLoop) {
Slog.w(TAG, "Loop again would have the queued messages be executed"
+ " before this one completed.");
}

me.mInLoop = true;
final MessageQueue queue = me.mQueue;

// Make sure the identity of this thread is that of the local process,
// and keep track of what that identity token actually is.
Binder.clearCallingIdentity();
final long ident = Binder.clearCallingIdentity();

// Allow overriding a threshold with a system prop. e.g.
// adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
final int thresholdOverride =
SystemProperties.getInt("log.looper."
+ Process.myUid() + "."
+ Thread.currentThread().getName()
+ ".slow", 0);

boolean slowDeliveryDetected = false;

for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}

// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// Make sure the observer won't change while processing a transaction.
final Observer observer = sObserver;

final long traceTag = me.mTraceTag;
long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
if (thresholdOverride > 0) {
slowDispatchThresholdMs = thresholdOverride;
slowDeliveryThresholdMs = thresholdOverride;
}
final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);

final boolean needStartTime = logSlowDelivery || logSlowDispatch;
final boolean needEndTime = logSlowDispatch;

if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
}

final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
final long dispatchEnd;
Object token = null;
if (observer != null) {
token = observer.messageDispatchStarting();
}
long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
try {
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
if (logSlowDelivery) {
if (slowDeliveryDetected) {
if ((dispatchStart - msg.when) <= 10) {
Slog.w(TAG, "Drained");
slowDeliveryDetected = false;
}
} else {
if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
msg)) {
// Once we write a slow delivery log, suppress until the queue drains.
slowDeliveryDetected = true;
}
}
}
if (logSlowDispatch) {
showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
}

if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

// Make sure that during the course of dispatching the
// identity of the thread wasn't corrupted.
final long newIdent = Binder.clearCallingIdentity();
if (ident != newIdent) {
Log.wtf(TAG, "Thread identity changed from 0x"
+ Long.toHexString(ident) + " to 0x"
+ Long.toHexString(newIdent) + " while dispatching to "
+ msg.target.getClass().getName() + " "
+ msg.callback + " what=" + msg.what);
}

msg.recycleUnchecked();
}
}
```java
提取出核心代码,其实loop的工作非常简单:
```java
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
}

就是无限循环地调用mq的next()方法,直到mq退出返回Null。什么时候mq返回null呢?当looper调用quit方法:

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
    public void quit() {
mQueue.quit(false);
}
```
会接着调用mq的quit:
```java
void quit(boolean safe) {
if (!mQuitAllowed) {
throw new IllegalStateException("Main thread not allowed to quit.");
}

synchronized (this) {
if (mQuitting) {
return;
}
mQuitting = true;

if (safe) {
removeAllFutureMessagesLocked();
} else {
removeAllMessagesLocked();
}

// We can assume mPtr != 0 because mQuitting was previously false.
nativeWake(mPtr);
}
}
```
这里将消息队列的mQuitting设置为了true。再看next()中的一段代码:
```java
// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}

由此可见,looper调用quit时,mq就会退出。消息循环终止。
现在,我们有了looper,当Looper从mq中取出了一条消息时,就会对其进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    msg.target.dispatchMessage(msg);
```
其中,msg.target就是发送这条消息的对象。也就是说,当我们使用handler发送一条消息后,这条消息经过放入mq,再被looper取出,又被looper分发给了同一个handler。只不过handler对象虽然是同一个,发送和接收消息时所处的线程却不一定相同。下面我们一探handler的dispatchMessage()方法:

# Handler的工作原理
如何使用handler发送消息,已经在介绍MQ时说过了。我们主要看一看handler是如何处理Looper取出的消息的:
```java
public void dispatchMessage(@NonNull Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}

首先会检查消息中的callback是否为空,这里callback就是通过post方式提交的runnable对象。如果不为空,进一步调用handleCallback()方法,这个方法也很简单:

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
    private static void handleCallback(Message message) {
message.callback.run();
}
```
就是执行runnable的run方法,完成其中的任务。
如果callback为空,会接着检查mCallback是否为空。前面我们已经介绍过,mCallback是handler内部定义的一个接口,提供了另一种使用Handler的方式:Handler handler = new Handler(Callback),这时就会按照callback内部的处理逻辑(自己在创建时实现)来处理方法。最后,如果mCallback为空,说明处理的是普通的message。调用handleMessage(必须在创建handler时重写,默认是一个空的方法)来处理即可。
这样,我们就大致搞清楚了handler消息机制的主要工作原理,即handler,mq,looper三大件各自的作用及使用方法。handler使用简单,快捷,但也存在一个一直为人诟病的潜在问题:**内存泄露**。下面我们就来探讨handler引发内存泄露的问题和常用的解决方案。
# Handler内存泄漏
众所周知在java中,成员对象会默认隐式地持有外部类的对象。假设现在我们在MainActivity中创建了一个handler对象,那么这个handler也就持有了MainActivity的引用。现在考虑这样一种情况:如果向handler提交了一组任务,当handler还在处理任务的时候退出MainActivity会怎么样?
正常来说,MainActivity应该被回收并释放。但由于此时handler仍在处理正在进行的任务而存在,其持有MainActivity的引用,导致gc无法及时对MainActivity进行回收。如此一来,便发生了内存泄露。
通常情况下,handler的内存泄露都是暂时的,当其中的任务全部处理完毕时,内存还是会得到释放。但有问题就应该解决,以防止更大的问题产生。如何规避这种情况呢?
首先我们可以想到,将handler加上static修饰,**变成静态内部类**,这样handler就不会再隐式持有activity的引用了。但是这样,就又产生了一个问题:静态的handler无法访问activity实例,如果要用到activity的资源怎么办?
方法很简单,让handler持有外部activity的**弱引用**不就好了?弱引用会在遇到gc时被回收,这样就基本上解决了问题。
最后,我们上一个演示实例。假设现在有这样一个需求:进入app时展示欢迎界面,一段时间后跳转至另一界面。一个简单的实现是准备两个activity,用handler实现定时intent跳转。
我们采用自定义handler的方式:
```java
static class MyHandler extends Handler {
WeakReference<WelcomeActivity> mactivity;

public MyHandler(@NonNull Looper looper, WelcomeActivity activity){
super(looper);//调用父类的显式指明的构造函数
mactivity = new WeakReference<WelcomeActivity>(activity);
}

@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);

WelcomeActivity nactivity = mactivity.get();
if(nactivity == null)
return ;

switch (msg.what) {
case 0:
Intent intent = new Intent(mactivity.get(), LoginActivity.class);
mactivity.get().startActivity(intent);
mactivity.get().finish();
break;
default:
break;
}
}
}

可以看到我这里就采用了static和weakReference来避免内存泄露和引用activity。创建handler实例后,在activity的onCreate()中调用handler.sendEmptyMessageDelayed(0,3000);即可实现功能。

结语

handler消息机制是android开发重要的功能,需要我们清楚其原理和实现。
这是我第一次尝试自主分析源码,因此文章中可能存在错误,以及一些理解不够深入。欢迎大佬提出问题并指正!