內部類在 Java 里面算是非常常見的一個功能了,在日常開發中我們肯定多多少少都用過,這里總結一下關於 Java 中內部類的相關知識點和一些使用內部類時需要注意的點。
從種類上說,內部類可以分為四類:普通內部類、靜態內部類、匿名內部類、局部內部類。我們來一個個看:
普通內部類
這個是最常見的內部類之一了,其定義也很簡單,在一個類里面作為類的一個字段直接定義就可以了,例:
public class InnerClassTest {
public class InnerClassA {
}
}
在這里 InnerClassA 類為 InnerClassTest 類的普通內部類,在這種定義方式下,普通內部類對象依賴外部類對象而存在,即在創建一個普通內部類對象時首先需要創建其外部類對象,我們在創建上面代碼中的 InnerClassA 對象時先要創建 InnerClassTest 對象,例:
public class InnerClassTest {
public int field1 = 1;
protected int field2 = 2;
int field3 = 3;
private int field4 = 4;
public InnerClassTest() {
// 在外部類對象內部,直接通過 new InnerClass(); 創建內部類對象
InnerClassA innerObj = new InnerClassA();
System.out.println("創建 " + this.getClass().getSimpleName() + " 對象");
System.out.println("其內部類的 field1 字段的值為: " + innerObj.field1);
System.out.println("其內部類的 field2 字段的值為: " + innerObj.field2);
System.out.println("其內部類的 field3 字段的值為: " + innerObj.field3);
System.out.println("其內部類的 field4 字段的值為: " + innerObj.field4);
}
public class InnerClassA {
public int field1 = 1;
protected int field2 = 2;
int field3 = 3;
private int field4 = 4;
// static int field5 = 5; // 編譯錯誤!普通內部類中不能定義 static 屬性
public InnerClassA() {
System.out.println("創建 " + this.getClass().getSimpleName() + " 對象");
System.out.println("其外部類的 field1 字段的值為: " + field1);
System.out.println("其外部類的 field2 字段的值為: " + field2);
System.out.println("其外部類的 field3 字段的值為: " + field3);
System.out.println("其外部類的 field4 字段的值為: " + field4);
}
}
public static void main(String[] args) {
InnerClassTest outerObj = new InnerClassTest();
// 不在外部類內部,使用:外部類對象. new 內部類構造器(); 的方式創建內部類對象
// InnerClassA innerObj = outerObj.new InnerClassA();
}
}
這里的內部類就像外部類聲明的一個屬性字段一樣,因此其的對象時依附於外部類對象而存在的,我們來看一下結果:
我們注意到,內部類對象可以訪問外部類對象中所有訪問權限的字段,同時,外部類對象也可以通過內部類的對象引用來訪問內部類中定義的所有訪問權限的字段,后面我們將從源碼里面分析具體的原因。
我們下面來看一下靜態內部類。
靜態內部類
我們知道,一個類的靜態成員獨立於這個類的任何一個對象存在,只要在具有訪問權限的地方,我們就可以通過 類名.靜態成員名 的形式來訪問這個靜態成員,同樣的,靜態內部類也是作為一個外部類的靜態成員而存在,創建一個類的靜態內部類對象不需要依賴其外部類對象。例:
public class InnerClassTest {
public int field1 = 1;
public InnerClassTest() {
System.out.println("創建 " + this.getClass().getSimpleName() + " 對象");
// 創建靜態內部類對象
StaticClass innerObj = new StaticClass();
System.out.println("其內部類的 field1 字段的值為: " + innerObj.field1);
System.out.println("其內部類的 field2 字段的值為: " + innerObj.field2);
System.out.println("其內部類的 field3 字段的值為: " + innerObj.field3);
System.out.println("其內部類的 field4 字段的值為: " + innerObj.field4);
}
static class StaticClass {
public int field1 = 1;
protected int field2 = 2;
int field3 = 3;
private int field4 = 4;
// 靜態內部類中可以定義 static 屬性
static int field5 = 5;
public StaticClass() {
System.out.println("創建 " + StaticClass.class.getSimpleName() + " 對象");
// System.out.println("其外部類的 field1 字段的值為: " + field1); // 編譯錯誤!!
}
}
public static void main(String[] args) {
// 無需依賴外部類對象,直接創建內部類對象
// InnerClassTest.StaticClass staticClassObj = new InnerClassTest.StaticClass();
InnerClassTest outerObj = new InnerClassTest();
}
}
結果:
可以看到,靜態內部類就像外部類的一個靜態成員一樣,創建其對象無需依賴外部類對象(訪問一個類的靜態成員也無需依賴這個類的對象,因為它是獨立於所有類的對象的)。但是於此同時,靜態內部類中也無法訪問外部類的非靜態成員,因為外部類的非靜態成員是屬於每一個外部類對象的,而本身靜態內部類就是獨立外部類對象存在的,所以靜態內部類不能訪問外部類的非靜態成員,而外部類依然可以訪問靜態內部類對象的所有訪問權限的成員,這一點和普通內部類無異。
匿名內部類
匿名內部類有多種形式,其中最常見的一種形式莫過於在方法參數中新建一個接口對象 / 類對象,並且實現這個接口聲明 / 類中原有的方法了:
public class InnerClassTest {
public int field1 = 1;
protected int field2 = 2;
int field3 = 3;
private int field4 = 4;
public InnerClassTest() {
System.out.println("創建 " + this.getClass().getSimpleName() + " 對象");
}
// 自定義接口
interface OnClickListener {
void onClick(Object obj);
}
private void anonymousClassTest() {
// 在這個過程中會新建一個匿名內部類對象,
// 這個匿名內部類實現了 OnClickListener 接口並重寫 onClick 方法
OnClickListener clickListener = new OnClickListener() {
// 可以在內部類中定義屬性,但是只能在當前內部類中使用,
// 無法在外部類中使用,因為外部類無法獲取當前匿名內部類的類名,
// 也就無法創建匿名內部類的對象
int field = 1;
@Override
public void onClick(Object obj) {
System.out.println("對象 " + obj + " 被點擊");
System.out.println("其外部類的 field1 字段的值為: " + field1);
System.out.println("其外部類的 field2 字段的值為: " + field2);
System.out.println("其外部類的 field3 字段的值為: " + field3);
System.out.println("其外部類的 field4 字段的值為: " + field4);
}
};
// new Object() 過程會新建一個匿名內部類,繼承於 Object 類,
// 並重寫了 toString() 方法
clickListener.onClick(new Object() {
@Override
public String toString() {
return "obj1";
}
});
}
public static void main(String[] args) {
InnerClassTest outObj = new InnerClassTest();
outObj.anonymousClassTest();
}
}
來看看結果:
上面的代碼中展示了常見的兩種使用匿名內部類的情況:
-
直接 new 一個接口,並實現這個接口聲明的方法,在這個過程其實會創建一個匿名內部類實現這個接口,並重寫接口聲明的方法,然后再創建一個這個匿名內部類的對象並賦值給前面的 OnClickListener 類型的引用;
-
new 一個已經存在的類 / 抽象類,並且選擇性的實現這個類中的一個或者多個非 final 的方法,這個過程會創建一個匿名內部類對象繼承對應的類 / 抽象類,並且重寫對應的方法。
同樣的,在匿名內部類中可以使用外部類的屬性,但是外部類卻不能使用匿名內部類中定義的屬性,因為是匿名內部類,因此在外部類中無法獲取這個類的類名,也就無法得到屬性信息。
局部內部類
局部內部類使用的比較少,其聲明在一個方法體 / 一段代碼塊的內部,而且不在定義類的定義域之內便無法使用,其提供的功能使用匿名內部類都可以實現,而本身匿名內部類可以寫得比它更簡潔,因此局部內部類用的比較少。來看一個局部內部類的小例子:
public class InnerClassTest {
public int field1 = 1;
protected int field2 = 2;
int field3 = 3;
private int field4 = 4;
public InnerClassTest() {
System.out.println("創建 " + this.getClass().getSimpleName() + " 對象");
}
private void localInnerClassTest() {
// 局部內部類 A,只能在當前方法中使用
class A {
// static int field = 1; // 編譯錯誤!局部內部類中不能定義 static 字段
public A() {
System.out.println("創建 " + A.class.getSimpleName() + " 對象");
System.out.println("其外部類的 field1 字段的值為: " + field1);
System.out.println("其外部類的 field2 字段的值為: " + field2);
System.out.println("其外部類的 field3 字段的值為: " + field3);
System.out.println("其外部類的 field4 字段的值為: " + field4);
}
}
A a = new A();
if (true) {
// 局部內部類 B,只能在當前代碼塊中使用
class B {
public B() {
System.out.println("創建 " + B.class.getSimpleName() + " 對象");
System.out.println("其外部類的 field1 字段的值為: " + field1);
System.out.println("其外部類的 field2 字段的值為: " + field2);
System.out.println("其外部類的 field3 字段的值為: " + field3);
System.out.println("其外部類的 field4 字段的值為: " + field4);
}
}
B b = new B();
}
// B b1 = new B(); // 編譯錯誤!不在類 B 的定義域內,找不到類 B,
}
public static void main(String[] args) {
InnerClassTest outObj = new InnerClassTest();
outObj.localInnerClassTest();
}
}
同樣的,在局部內部類里面可以訪問外部類對象的所有訪問權限的字段,而外部類卻不能訪問局部內部類中定義的字段,因為局部內部類的定義只在其特定的方法體 / 代碼塊中有效,一旦出了這個定義域,那么其定義就失效了,就像代碼注釋中描述的那樣,即外部類不能獲取局部內部類的對象,因而無法訪問局部內部類的字段。最后看看運行結果:
內部類的嵌套
-
內部類的嵌套,即為內部類中再定義內部類,這個問題從內部類的分類角度去考慮比較合適:
-
普通內部類:在這里我們可以把它看成一個外部類的普通成員方法,在其內部可以定義普通內部類(嵌套的普通內部類),但是無法定義 static 修飾的內部類,就像你無法在成員方法中定義 static 類型的變量一樣,當然也可以定義匿名內部類和局部內部類;
-
靜態內部類:因為這個類獨立於外部類對象而存在,我們完全可以將其拿出來,去掉修飾它的 static 關鍵字,他就是一個完整的類,因此在靜態內部類內部可以定義普通內部類,也可以定義靜態內部類,同時也可以定義 static 成員;
-
匿名內部類:和普通內部類一樣,定義的普通內部類只能在這個匿名內部類中使用,定義的局部內部類只能在對應定義域內使用;
-
局部內部類:和匿名內部類一樣,但是嵌套定義的內部類只能在對應定義域內使用。
深入理解內部類
不知道小伙伴們對上面的代碼有沒有產生疑惑:非靜態內部類可以訪問外部類所有訪問權限修飾的字段(即包括了 private 權限的),同時,外部類也可以訪問內部類的所有訪問權限修飾的字段。而我們知道,private 權限的字段只能被當前類本身訪問。然而在上面我們確實在代碼中直接訪問了對應外部類 / 內部類的 private 權限的字段,要解除這個疑惑,只能從編譯出來的類下手了,為了簡便,這里采用下面的代碼進行測試:
public class InnerClassTest {
int field1 = 1;
private int field2 = 2;
public InnerClassTest() {
InnerClassA inner = new InnerClassA();
int v = inner.x2;
}
public class InnerClassA {
int x1 = field1;
private int x2 = field2;
}
}
我在外部類中定義了一個默認訪問權限(同一個包內的類可以訪問)的字段 field1, 和一個 private 權限的字段 field2 ,並且定義了一個內部類 InnerClassA ,並且在這個內部類中也同樣定義了兩個和外部類中定義的相同修飾權限的字段,並且訪問了外部類對應的字段。最后在外部類的構造方法中我定義了一個方法內變量賦值為內部類中 private 權限的字段。我們用 javac 命令(javac InnerClassTest.java)編譯這個 .java 文件,會得到兩個 .classs 文件。InnerClassTest.class 和 InnerClassTest$InnerClassA.class,我們再用 javap -c 命令(javap -c InnerClassTest 和 javap -c InnerClassTest$InnerClassA)分別反編譯這兩個 .class 文件,InnerClassTest.class 的字節碼如下:
我們注意到字節碼中多了一個默認修飾權限並且名為 access$100 的靜態方法,其接受一個 InnerClassTest 類型的參數,即其接受一個外部類對象作為參數,方法內部用三條指令取到參數對象的 field2 字段的值並返回。由此,我們現在大概能猜到內部類對象是怎么取到外部類的 private 權限的字段了:就是通過這個外部類提供的靜態方法。
類似的,我們注意到 24 行字節碼指令 invokestatic ,這里代表執行了一個靜態方法,而后面的注釋也寫的很清楚,調用的是 InnerClassTest$InnerClassA.access$000 方法,即調用了內部類中名為 access$000 的靜態方法,根據我們上面的外部類字節碼規律,我們也能猜到這個方法就是內部類編譯過程中編譯器自動生成的,那么我們趕緊來看一下 InnerClassTest$InnerClassA 類的字節碼吧:
果然,我們在這里發現了名為 access$000 的靜態方法,並且這個靜態方法接受一個 InnerClassTest$InnerClassA 類型的參數,方法的作用也很簡單:返回參數代表的內部類對象的 x2 字段值。
我們還注意到編譯器給內部類提供了一個接受 InnerClassTest 類型對象(即外部類對象)的構造方法,內部類本身還定義了一個名為 this$0 的 InnerClassTest 類型的引用,這個引用在構造方法中指向了參數所對應的外部類對象。
最后,我們在 25 行字節碼指令發現:內部類的構造方法通過 invokestatic 指令執行外部類的 access$100 靜態方法(在 InnerClassTest 的字節碼中已經介紹了)得到外部類對象的 field2 字段的值,並且在后面賦值給 x2 字段。這樣的話內部類就成功的通過外部類提供的靜態方法得到了對應外部類對象的 field2 。
上面我們只是對普通內部類進行了分析,但其實匿名內部類和局部內部類的原理和普通內部類是類似的,只是在訪問上有些不同:外部類無法訪問匿名內部類和局部內部類對象的字段,因為外部類根本就不知道匿名內部類 / 局部內部類的類型信息(匿名內部類的類名被隱匿,局部內部類只能在定義域內使用)。但是匿名內部類和局部內部類卻可以訪問外部類的私有成員,原理也是通過外部類提供的靜態方法來得到對應外部類對象的私有成員的值。而對於靜態內部類來說,因為其實獨立於外部類對象而存在,因此編譯器不會為靜態內部類對象提供外部類對象的引用,因為靜態內部類對象的創建根本不需要外部類對象支持。但是外部類對象還是可以訪問靜態內部類對象的私有成員,因為外部類可以知道靜態內部類的類型信息,即可以得到靜態內部類的對象,那么就可以通過靜態內部類提供的靜態方法來獲得對應的私有成員值。來看一個簡單的代碼證明:
public class InnerClassTest {
int field1 = 1;
private int field2 = 2;
public InnerClassTest() {
InnerClassA inner = new InnerClassA();
int v = inner.x2;
}
// 這里改成了靜態內部類,因而不能訪問外部類的非靜態成員
public static class InnerClassA {
private int x2 = 0;
}
}
同樣的編譯步驟,得到了兩個 .class 文件,這里看一下內部類的 .class 文件反編譯的字節碼 InnerClassTest$InnerClassA:
仔細看一下,確實沒有找到指向外部類對象的引用,編譯器只為這個靜態內部類提供了一個無參構造方法。
而且因為外部類對象需要訪問當前類的私有成員,編譯器給這個靜態內部類生成了一個名為 access$000 的靜態方法,作用已不用我多說了。如果我們不看類名,這個類完全可以作為一個普通的外部類來看,這正是靜態內部類和其余的內部類的區別所在:靜態內部類對象不依賴其外部類對象存在,而其余的內部類對象必須依賴其外部類對象而存在。
OK,到這里問題都得到了解釋:在非靜態內部類訪問外部類私有成員 / 外部類訪問內部類私有成員 的時候,對應的外部類 / 外部類會生成一個靜態方法,用來返回對應私有成員的值,而對應外部類對象 / 內部類對象通過調用其內部類 / 外部類提供的靜態方法來獲取對應的私有成員的值。
內部類和多重繼承
我們已經知道,Java 中的類不允許多重繼承,也就是說 Java 中的類只能有一個直接父類,而 Java 本身提供了內部類的機制,這是否可以在一定程度上彌補 Java 不允許多重繼承的缺陷呢?我們這樣來思考這個問題:假設我們有三個基類分別為 A、B、C,我們希望有一個類 D 達成這樣的功能:通過這個 D 類的對象,可以同時產生 A 、B 、C 類的對象,通過剛剛的內部類的介紹,我們也應該想到了怎么完成這個需求了,創建一個類 D.java:
class A {}
class B {}
class C {}
public class D extends A {
// 內部類,繼承 B 類
class InnerClassB extends B {
}
// 內部類,繼承 C 類
class InnerClassC extends C {
}
// 生成一個 B 類對象
public B makeB() {
return new InnerClassB();
}
// 生成一個 C 類對象
public C makeC() {
return new InnerClassC();
}
public static void testA(A a) {
// ...
}
public static void testB(B b) {
// ...
}
public static void testC(C c) {
// ...
}
public static void main(String[] args) {
D d = new D();
testA(d);
testB(d.makeB());
testC(d.makeC());
}
}
程序正確運行。而且因為普通內部類可以訪問外部類的所有成員並且外部類也可以訪問普通內部類的所有成員,因此這種方式在某種程度上可以說是 Java 多重繼承的一種實現機制。但是這種方法也是有一定代價的,首先這種結構在一定程度上破壞了類結構,一般來說,建議一個 .java 文件只包含一個類,除非兩個類之間有非常明確的依賴關系(比如說某種汽車和其專用型號的輪子),或者說一個類本來就是為了輔助另一個類而存在的(比如說上篇文章介紹的 HashMap 類和其內部用於遍歷其元素的 HashIterator 類),那么這個時候使用內部類會有較好代碼結構和實現效果。而在其他情況,將類分開寫會有較好的代碼可讀性和代碼維護性。
內部類和內存泄露
在這一小節開始前介紹一下什么是內存泄露:即指在內存中存在一些其內存空間可以被回收的對象因為某些原因又沒有被回收,因此產生了內存泄露,如果應用程序頻繁發生內存泄露可能會產生很嚴重的后果(內存中可用的空間不足導致程序崩潰,甚至導致整個系統卡死)。
聽起來怪嚇人的,這個問題在一些需要開發者手動申請和釋放內存的編程語言(C/C++)中會比較容易產生,因為開發者申請的內存需要手動釋放,如果忘記了就會導致內存泄露,舉個簡單的例子(C++):
#include <iostream>
int main() {
// 申請一段內存,空間為 100 個 int 元素所占的字節數
int *p = new int[100];
// C++ 11
p = nullptr;
return 0;
}
在這段代碼里我有意而為之:在為指針 p 申請完內存之后將其直接賦值為 nullptr ,這是 C++ 11 中一個表示空指針的關鍵字,我們平時常用的 NULL 只是一個值為 0 的常量值,在進行方法重載傳參的時候可能會引起混淆。之后我直接返回了,雖然在程序結束之后操作系統會回收我們程序中申請的內存,但是不可否認的是上面的代碼確實產生了內存泄露(申請的 100 個 int 元素所占的內存無法被回收)。這只是一個最簡單不過的例子。我們在寫這類程序的時候當動態申請的內存不再使用時,應該要主動釋放申請的內存:
#include <iostream>
int main() {
// 申請一段內存,空間為 100 個 int 元素所占的字節數
int *p = new int[100];
// 釋放 p 指針所指向的內存空間
delete[] p;
// C++ 11
p = nullptr;
return 0;
}
而在 Java 中,因為 JVM 有垃圾回收功能,對於我們自己創建的對象無需手動回收這些對象的內存空間,這種機制確實在一定程度上減輕了開發者的負擔,但是也增加了開發者對 JVM 垃圾回收機制的依賴性,從某個方面來說,也是弱化了開發者防止內存泄露的意識。當然,JVM 的垃圾回收機制的利是遠遠大於弊的,只是我們在開發過程中不應該喪失了這種對象和內存的意識。
回到正題,內部類和內存泄露又有什么關系呢?在繼續閱讀之前,請確保你對 JVM 的在進行垃圾回收時如何找出內存中不再需要的對象有一定的了解,如果你對這個過程不太了解,你可以參考一下 這篇文章 中對這個過程的簡單介紹。我們在上面已經知道了,創建非靜態內部類的對象時,新建的非靜態內部類對象會持有對外部類對象的引用,這個我們在上面的源碼反編譯中已經介紹過了,正是因為非靜態內部類對象會持有外部類對象的引用,因此如果說這個非靜態內部類對象因為某些原因無法被回收,就會導致這個外部類對象也無法被回收,這個聽起來是有道理的,因為我們在上文也已經介紹了:非靜態內部類對象依賴於外部類對象而存在,所以內部類對象沒被回收,其外部類對象自然也不能被回收。但是可能存在這種情況:非靜態內部類對象在某個時刻已經不在被使用,或者說這個內部類對象可以在不影響程序正確運行的情況下被回收,而因為我們對這個內部類的使用不當而使得其無法被 JVM 回收,同時會導致其外部類對象無法被回收,即為發生內存泄露。那么這個 “使用不當” 具體指的是哪個方面呢?看一個簡單的例子,新建一個 MemoryLeakTest 的類:
public class MemoryLeakTest {
// 抽象類,模擬一些組件的基類
abstract static class Component {
final void create() {
onCreate();
}
final void destroy() {
onDestroy();
}
// 子類實現,模擬組件創建的過程
abstract void onCreate();
// 子類實現,模擬組件摧毀的過程
abstract void onDestroy();
}
// 具體某個組件
static class MyComponent extends Component {
// 組件中窗口的單擊事件監聽器
static OnClickListener clickListener;
// 模擬組件中的窗口
MyWindow myWindow;
@Override
void onCreate() {
// 執行組件內一些資源初始化的代碼
clickListener = new OnClickListener() {
@Override
public void onClick(Object obj) {
System.out.println("對象 " + obj + " 被單擊");
}
};
// 新建我的窗口對象,並設置其單擊事件監聽器
myWindow = new MyWindow();
myWindow.setClickListener(clickListener);
}
@Override
void onDestroy() {
// 執行組件內一些資源回收的代碼
myWindow.removeClickListener();
}
}
// 我的窗口類,模擬一個可視化控件
static class MyWindow {
OnClickListener clickListener;
// 設置當前控件的單擊事件監聽器
void setClickListener(OnClickListener clickListener) {
this.clickListener = clickListener;
}
// 移除當前控件的單擊事件監聽器
void removeClickListener() {
this.clickListener = null;
}
}
// 對象的單擊事件的監聽接口
public interface OnClickListener {
void onClick(Object obj);
}
public static void main(String[] args) {
MyComponent myComponent = new MyComponent();
myComponent.create();
myComponent.destroy();
// myComponent 引用置為 null,排除它的干擾
myComponent = null;
// 調用 JVM 的垃圾回收動作,回收無用對象
System.gc();
System.out.println("");
}
}
我們在代碼中添加一些斷點,然后采用 debug 模式查看:
程序執行到 72 行代碼,此時 72 行代碼還未執行,因此 myComponent 引用和其對象還未創建,繼續執行:
這里成功創建了一個 MyComponent 對象,但是其 create 方法還未執行,所以 myWindow 字段為 null,這里可能有小伙伴會問了,myComponent 對象的 clickListener 字段呢?怎么不見了?其實這和我們在代碼中定義 clickListener 字段的形式有關,我們定義的是 static OnClickListener clickListener; ,因此 clickListener 是一個靜態字段,其在類加載的完成的時候儲存在 JVM 中內存區域的 方法區 中,而創建的 Java 對象儲存在 JVM 的堆內存中,兩者不在同一塊內存區域。關於這些細節,想深入了解的小伙伴建議閱讀《深入理解JVM虛擬機》。好了,我們繼續執行代碼:
myComponent.create 方法執行完成之后創建了 OnClickListener 內部類對象,並且為 myWindow 對象設置 OnCLickListener 單擊事件監聽。我們繼續:
myComponent.destroy 方法執行完成之后,myWindow.removeClickListener 方法也執行完成,此時 myWindow 對象中的 clickListener字段為 null。我們繼續:
代碼執行到了 80 行,在此之前,所有的代碼和解釋都沒有什么難度,跟着運行圖走,一切都那么順利成章,其實這張圖的運行結果也很好理解,只不過圖中的文字需要思考一下:myComponent 引用指向的對象真的被回收了嗎?要解答這個問題,我們需要借助 Java 中提供的內存分析工具 jvisualvm (以前它還不叫這個名字…),它一般在你安裝 JDK 的目錄下的 bin 子目錄下:
我們運行這個程序:
在程序左邊可以找到我們當前正在執行的 Java 進程,雙擊進入:
單擊 tab 中的 監視 選項卡,可以看到當前正在執行的 Java 進程的一些資源占用信息,當然我們現在的主要目的是分析內存,那么們單擊右上角的 堆 Dump :
在這個界面,單擊 類 選項卡,會出現當前 Java 進程中用到的所有的類,我們已經知道我們要查找的類的對象只創建了一個,因此我們根據右上角的 實例數 來進行排除:我們成功的找到了我們創建的對象!而這樣也意味着當我們在上面代碼中調用 JVM 的垃圾回收動作沒有回收這三個對象,這其實就是一個真真切切的內存泄露!因為我們將 main 方法中的 myComponent 引用賦值為 null,就意味着我們已經不再使用這個組件和里面的一些子組件(MyWindow 對象),即這個組件和其內部的一些組件應該被回收。但是調用 JVM 的垃圾回收卻並沒有將其對應的對象回收。造成這個問題的原因在哪呢?
其實就在於我們剛剛在 MyComponent 類中定義的 clickListener 字段,我們在代碼中將其定義成了 static 類型的,同時這個字段又指向了一個匿名內部類對象(在 create 方法中 創建了一個 OnClickListener 接口對象,即通過一個匿名內部類實現這個接口並創建其對象),根據 JVM 尋找和標記無用對象的規則(可達性分析算法),其會將 clickListener 字段作為一個 “root” ,並通過它來尋找還有用的對象,在這個例子中,clickListener 字段指向一個匿名內部類對象,這個匿名內部類對象有一個外部類對象(MyComponent 類型的對象)的引用,而外部類對象中又有一個 MyWindow 類型的對象引用。因此 JVM 會將這三個對象都視為有用的對象不會回收。用圖來解釋吧:
Ok,通過這個過程,相信你已經理解了造成此次內存泄露的原因了,那么我們該如何解決呢?對於當前這個例子,我們只需要改一些代碼:
-
把 MyComponent 類中的 clickListener 字段前面的 static 修飾符去掉就可以了(static OnClickListener clickListener; -> OnClickListener clickListener;),這樣的話 clickListener 指向的對象,就作為 MyComponent 類的對象的一部分了,在 MyComponent 對象被回收時里面的子組件也會被回收。同時它們之間也只是互相引用(MyComponent 外部類對象中有一個指向 OnClickListener 內部類對象的引用,OnClickListener 內部類對象有一個指向 MyComponent 外部類對象的引用),根據 JVM 的 “可達性分析” 算法,在兩個對象都不再被外部使用時,JVM 的垃圾回收機制是可以標記並回收這兩個對象的。 雖然不強制要求你在 MyComponent 類中的 onDestroy 方法中將其 clickListener 引用賦值為 null,但是我還是建議你這樣做,因為這樣更能確保你的程序的安全性(減少發生內存泄露的機率,畢竟匿名內部類對象會持有外部類對象的引用),在某個組件被銷毀時將其內部的一些子組件進行合理的處理是一個很好的習慣。
-
你也可以自定義一個靜態內部類或者是另外自定義一個類文件,並實現 OnClickListener 接口,之后通過這個類創建對象,這樣就可以避免通過非靜態內部類的形式創建 OnClickListener 對象增加內存泄露的可能性。
避免內存泄漏
那么我們在日常開發中怎么合理的使用內部類來避免產生內存泄露呢?這里給出一點我個人的理解:
-
能用靜態內部類就盡量使用靜態內部類,從上文中我們也知道了,靜態內部類的對象創建不依賴外部類對象,即靜態內部對象不會持有外部類對象的引用,自然不會因為靜態內部類對象而導致內存泄露,所以如果你的內部類中不需要訪問外部類中的一些非 static 成員,那么請把這個內部類改造成靜態內部類;
-
對於一些自定義類的對象,慎用 static 關鍵字修飾(除非這個類的對象的聲明周期確實應該很長),我們已經知道,JVM 在進行垃圾回收時會將 static 關鍵字修飾的一些靜態字段作為 “root” 來進行存活對象的查找,所以程序中 static 修飾的對象越多,對應的 “root” 也就越多,每一次 JVM 能回收的對象就越少。 當然這並不是建議你不使用 static 關鍵字,只是在使用這個關鍵字之前可以考慮一下這個對象使用 static 關鍵字修飾對程序的執行確實更有利嗎?
-
為某些組件(大型)提供一個當這個大型組件需要被回收的時候用於合理處理其中的一些小組件的方法(例如上面代碼中 MyComponent 的 onDestroy 方法),在這個方法中,確保正確的處理一些需要處理的對象(將某些引用置為 null、釋放一些其他(CPU…)資源)