结论先行
避免像这样,在 Java 中使用双括号初始化:
new HashMap<String, String>() {{
put("key", value);
}};
内存泄漏追踪
我最近正在 LeakCanary 看到了以下内存泄漏追踪信息:
┬───
│ GC Root: Global variable in native code
│
├─ com.bugsnag.android.AnrPlugin instance
│ Leaking: UNKNOWN
│ ↓ AnrPlugin.client
│ ~~~~~~
├─ com.bugsnag.android.Client instance
│ Leaking: UNKNOWN
│ ↓ Client.breadcrumbState
│ ~~~~~~~~~~~~~~~
├─ com.bugsnag.android.BreadcrumbState instance
│ Leaking: UNKNOWN
│ ↓ BreadcrumbState.store
│ ~~~~~
├─ com.bugsnag.android.Breadcrumb[] array
│ Leaking: UNKNOWN
│ ↓ Breadcrumb[494]
│ ~~~~~
├─ com.bugsnag.android.Breadcrumb instance
│ Leaking: UNKNOWN
│ ↓ Breadcrumb.impl
│ ~~~~
├─ com.bugsnag.android.BreadcrumbInternal instance
│ Leaking: UNKNOWN
│ ↓ BreadcrumbInternal.metadata
│ ~~~~~~~~
├─ com.example.MainActivity$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of java.util.HashMap
│ ↓ MainActivity$1.this$0
│ ~~~~~~
╰→ com.example.MainActivity instance
Leaking: YES (Activity#mDestroyed is true)
当打开一个内存泄漏追踪日志时,我首先会看底部的对象,了解它的生命周期,这将帮助我理解内存泄漏追踪中的其他对象是否应该有相同的生命周期。
在底部,我们看到:
╰→ com.example.MainActivity instance
Leaking: YES (Activity#mDestroyed is true)
Activity
已经被销毁,应该已被垃圾回收器给回收掉了,但它仍驻留在内存中。
此时,我开始在内存泄漏追踪日志中寻找已知类型,并尝试弄清楚它们是否属于同一个被销毁的范围(=> 正在泄漏)或更高的范围(=> 没有泄漏)。
在顶部,我们看到:
├─ com.bugsnag.android.Client instance
│ Leaking: UNKNOWN
我们的 BugSnag 客户端是一个用于分析崩溃报告单例,由于每个应用我们创建一个实例,所以它没有泄漏。
├─ com.bugsnag.android.Client instance
│ Leaking: NO
所以我们现在需要转变焦点,特别关注从最后一个 Leaking: NO
到第一个 Leaking: YES
的部分:
…
├─ com.bugsnag.android.Client instance
│ Leaking: NO
│ ↓ Client.breadcrumbState
│ ~~~~~~~~~~~~~~~
├─ com.bugsnag.android.BreadcrumbState instance
│ Leaking: UNKNOWN
│ ↓ BreadcrumbState.store
│ ~~~~~
├─ com.bugsnag.android.Breadcrumb[] array
│ Leaking: UNKNOWN
│ ↓ Breadcrumb[494]
│ ~~~~~
├─ com.bugsnag.android.Breadcrumb instance
│ Leaking: UNKNOWN
│ ↓ Breadcrumb.impl
│ ~~~~
├─ com.bugsnag.android.BreadcrumbInternal instance
│ Leaking: UNKNOWN
│ ↓ BreadcrumbInternal.metadata
│ ~~~~~~~~
├─ com.example.MainActivity$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of java.util.HashMap
│ ↓ MainActivity$1.this$0
│ ~~~~~~
╰→ com.example.MainActivity instance
Leaking: YES (Activity#mDestroyed is true)
BugSnag 客户端保持了一个面包屑的环形缓冲区。这些应该保留在内存中,它们也没有泄漏。
所以让我们跳过上述内容,从下面这里继续分析:
├─ com.bugsnag.android.BreadcrumbInternal instance
│ Leaking: NO
我们只需要关注从最后一个 Leaking: NO
到第一个Leaking: YES
的部分:
…
├─ com.bugsnag.android.BreadcrumbInternal instance
│ Leaking: NO
│ ↓ BreadcrumbInternal.metadata
│ ~~~~~~~~
├─ com.example.MainActivity$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of java.util.HashMap
│ ↓ MainActivity$1.this$0
│ ~~~~~~
╰→ com.example.MainActivity instance
Leaking: YES (Activity#mDestroyed is true)
BreadcrumbInternal.metadata
:内存泄漏追踪通过面包屑实现的元数据字段。MainActivity$1
实例是java.util.HashMap
的匿名子类:MainActivity$1
是在MainActivity
中定义的HashMap
的匿名子类。它是从MainActivity.java
中定义的第一个匿名类(因为是$1
)。this$0
:每个匿名类都有一个隐式字段引用到定义它的外部类,这个字段被命名为this$0
。
也就是说:记录到 BugSnag 的面包屑之一有一个元数据映射,这是一个 HashMap
的匿名子类 ,它保留对外部类的引用,这个外部类就是被销毁的 Activity
。
让我们看看我们在 MainActivity
中记录面包屑的地方:
void logSavingTicket(String ticketId) {
Map<String, Object> metadata = new HashMap<String, Object>() {{
put("ticketId", ticketId);
}};
bugsnagClient.leaveBreadcrumb("Saving Ticket", metadata, LOG);
}
这段代码利用了一个被称为“双括号初始化” 的有趣的 Java 代码块 。它允许你创建一个 HashMap
,并通过添加代码到 HashMap
的匿名子类的构造函数中同时初始化它。
new HashMap<String, Object>() {{
put("ticketId", ticketId);
}};
Java 的匿名类总是隐式地引用其外部类。
因此,这段代码:
void logSavingTicket(String ticketId) {
Map<String, Object> metadata = new HashMap<String, Object>() {{
put("ticketId", ticketId);
}};
bugsnagClient.leaveBreadcrumb("Saving Ticket", metadata, LOG);
}
实际上被编译为:
class MainActivity$1 extends HashMap<String, Object> {
private final MainActivity this$1;
MainActivity$1(MainActivity this$1, String ticketId) {
this.this$1 = this$1;
put("ticketId", ticketId);
}
}
void logSavingTicket(String ticketId) {
Map<String, Object> metadata = new MainActivity$1(this, ticketId);
bugsnagClient.leaveBreadcrumb("Saving Ticket", metadata, LOG);
}
结果,这个 breadcrumb 就一直持有对已销毁的 activity 实例的引用。
总结
尽管使用 Java 的双括号初始化看起来很"炫酷",但它会无故地额外创建类,可能会导致内存泄漏。因此避免在 Java 中使用双括号初始化。
你可以用下面这种更安全的方式来解决这个问题:
Map<String, Object> metadata = new HashMap<>();
metadata.put("ticketId", ticketId);
bugsnagClient.leaveBreadcrumb("Saving Ticket", metadata, LOG);
或者利用 Collections.singletonMap()
进一步简化代码:
Map<String, Object> metadata = singletonMap("ticketId", ticketId);
bugsnagClient.leaveBreadcrumb("Saving Ticket", metadata, LOG);
或者,直接将文件转换为 Kotlin。