搞懂 JAVA 內部類
前些天寫了一篇關於 2018 年奮鬥計劃的文章,其實做 Android 開發也有一段時間了,文章中所寫的內容,也都是在日常開發中遇到各種問題後總結下來需要鞏固的基礎或者進階知識。那麼本文就從內部類開刀。
本文將會從以下幾部分來總結:
- 為什麼要存在內部類
- 內部類與外部類的關係
- 內部的分類及幾種分類的詳細使用注意事項
- 實際開發中會遇到內部類的問題
內部類為什麼存在
內部類 ( inner class ) : 定義在另一個類中的類
我們為什麼需要內部類?或者說內部類為啥要存在?其主要原因有如下幾點:
- 內部類方法可以訪問該類定義所在作用域中的資料,包括被 private 修飾的私有資料
- 內部類可以對同一包中的其他類隱藏起來
- 內部類可以實現 java 單繼承的缺陷
- 當我們想要定義一個回撥函式卻不想寫大量程式碼的時候我們可以選擇使用匿名內部類來實現
內部類方法可以訪問該類定義所在作用域中的資料
做 Android 的我們有時候會將各種 Adapter 直接寫在 Activity 中,如:
class MainActivity extends AppCompatActivity{
....
private List<Fragment> fragments = new ArrayList();
private class BottomPagerAdapter extends FragmentPagerAdapter{
....
@Override
public Fragment getItem(int position) {
return fragments.get(position);
}
...
}
...
}
複製程式碼
上文中 BottomPagerAdapter 便是 MainActivity 的一個內部類。也可以看出 BottomPagerAdapter 可以直接訪問 MainActivity 中定義的 fragments 私有變數。如果將 BottomPagerAdapter 不定義為內部類訪問 fragments 私有變數 沒有 getXXX 方法是做不到的。 這就是內部類的第一點好處。
可是為什麼內部類就可以隨意訪問外部類的成員呢?是如何做到的呢?
當外部類的物件建立了一個內部類的物件時,內部類物件必定會祕密捕獲一個指向外部類物件的引用,然後訪問外部類的成員時,就是用那個引用來選擇外圍類的成員的。當然這些編輯器已經幫我們處理了。
另外注意內部類只是一種編譯器現象,與虛擬機器無關。編譯器會將內部類編譯成 外部類名$內部類名 的常規檔案,虛擬機器對此一無所知。
內部類可以對同一包中的其他類隱藏起來
關於內部類的第二個好處其實很顯而易見,我們都知道外部類即普通的類不能使用 private protected 訪問許可權符來修飾的,而內部類則可以使用 private 和 protected 來修飾。當我們使用 private 來修飾內部類的時候這個類就對外隱藏了。這看起來沒什麼作用,但是當內部類實現某個介面的時候,在進行向上轉型,對外部來說,就完全隱藏了介面的實現了。 如:
public interface Incrementable{
void increment();
}
//具體類
public class Example {
private class InsideClass implements InterfaceTest{
public void test(){
System.out.println("這是一個測試");
}
}
public InterfaceTest getIn(){
return new InsideClass();
}
}
public class TestExample {
public static void main(String args[]){
Example a=new Example();
InterfaceTest a1=a.getIn();
a1.test();
}
}
複製程式碼
從這段程式碼裡面我只知道Example的getIn()方法能返回一個InterfaceTest例項但我並不知道這個例項是這麼實現的。而且由於InsideClass是private的,所以我們如果不看程式碼的話根本看不到這個具體類的名字,所以說它可以很好的實現隱藏。
內部類可以實現 java 單繼承的缺陷
我們知道 java 是不允許使用 extends 去繼承多個類的。內部類的引入可以很好的解決這個事情。 以下引用 《Thinking In Java》中的一段話:
每個內部類都可以隊裡的繼承自一個(介面的)實現,所以無論外圍類是否已經繼承了某個(介面的)實現,對於內部類沒有影響 如果沒有內部類提供的、可以繼承多個具體的或抽象的類的能力,一些設計與程式設計問題就難以解決。 介面解決了部分問題,一個類可以實現多個介面,內部類允許繼承多個非介面型別(類或抽象類)。
我的理解 Java只能繼承一個類這個學過基本語法的人都知道,而在有內部類之前它的多重繼承方式是用介面來實現的。但使用介面有時候有很多不方便的地方。比如我們實現一個介面就必須實現它裡面的所有方法。而有了內部類就不一樣了。它可以使我們的類繼承多個具體類或抽象類。如下面這個例子:
//類一
public class ClassA {
public String name(){
return "liutao";
}
public String doSomeThing(){
// doSomeThing
}
}
//類二
public class ClassB {
public int age(){
return 25;
}
}
//類三
public class MainExample{
private class Test1 extends ClassA{
public String name(){
return super.name();
}
}
private class Test2 extends ClassB{
public int age(){
return super.age();
}
}
public String name(){
return new Test1().name();
}
public int age(){
return new Test2().age();
}
public static void main(String args[]){
MainExample mi=new MainExample();
System.out.println("姓名:"+mi.name());
System.out.println("年齡:"+mi.age());
}
}
複製程式碼
上邊這個例子可以看出來,MainExample 類通過內部類擁有了 ClassA 和 ClassB 的兩個類的繼承關係。 而無需關注 ClassA 中的 doSomeThing 方法的實現。這就是比介面實現更有戲的地方。
通過匿名內部類來"優化"簡單的介面實現
關於匿名內部類相信大家都不陌生,我們常見的點選事件的寫法就是這樣的:
...
view.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(){
// ... do XXX...
}
})
...
複製程式碼
為什麼標題優化帶了"",其實在 Java8 引入 lambda 表示式之前我個人是比較討厭這種寫法的,因為 onClick 方法中的內容可能很複雜,可能會有很多判斷邏輯的存在,這就導致程式碼顯得很累贅,所以個人更喜歡使用匿名內部類來完成一些簡便的操作,配合lambda 表示式,程式碼會更便於閱讀 如
view.setOnClickListener(v -> gotoVipOpenWeb());
複製程式碼
內部類與外部類的關係
- 對於非靜態內部類,內部類的建立依賴外部類的例項物件,在沒有外部類例項之前是無法建立內部類的
- 內部類是一個相對獨立的實體,與外部類不是is-a關係
- 建立內部類的時刻並不依賴於外部類的建立
建立內部類的時刻並不依賴於外部類的建立
這句話是《Thinking In Java》中的一句話,大部分人看到這裡會斷章取義的認為 內部類的建立不依賴於外部類的建立,這種理解是錯誤的,去掉時刻二字這句話就會變了一個味道。
事實上靜態內部類「巢狀類」的確不依賴與外部類的建立,因為 static 並不依賴於例項,而依賴與類 Class 本身。
但是對於普通的內部類,其必須依賴於外部類例項建立正如第一條關係所說:對於非靜態內部類,內部類的建立依賴外部類的例項物件,在沒有外部類例項之前是無法建立內部類的。
對於普通內部類建立方法有兩種:
public class ClassOuter {
public void fun(){
System.out.println("外部類方法");
}
public class InnerClass{
}
}
public class TestInnerClass {
public static void main(String[] args) {
//建立方式1
ClassOuter.InnerClass innerClass = new ClassOuter().new InnerClass();
//建立方式2
ClassOuter outer = new ClassOuter();
ClassOuter.InnerClass inner = outer.new InnerClass();
}
}
複製程式碼
值得注意的是:正式由於這種依賴關係,所以普通內部類中不允許有 static 成員,包括巢狀類(內部類的靜態內部類) ,道理顯然而知:static 本身是針對類本身來說的。又由於非static內部類總是由一個外部的物件生成,既然與物件相關,就沒有靜態的欄位和方法。當然靜態內部類不依賴於外部類,所以其內允許有 static 成員。
現在返回頭來看標題,其實英文版中這句話是這樣描述的:
The point of creation of the inner-class objects not tied to the creation of the outer-class object.
個人認為這句話理解為:建立一個外部類的時候不一定要建立這個內部類。
拿文章開頭的 Adapter 的例子來說,我們不能說建立了 Activity 就一定會建立 Adapter (假設 Adapter 建立依賴於某個條件的成立)。只有當滿足條件的時候才會被建立。
內部類是一個相對獨立的實體,與外部類不是is-a關係
首先理解什麼是「is-a關係」: is-a關係是指繼承關係。知道什麼是is-a關係後相信,內部類個外部類不是is-a關係就很容易理解了。
而對於內部類是一個相對獨立的實體,我們可以從兩個方面來理解這句話:
- 一個外部類可以擁有多個內部類物件,而他們之間沒有任何關係,是獨立的個體。
- 從編譯結果來看,內部類被表現為 「外部類$內部類.class 」,所以對於虛擬機器來說他個一個單獨的類來說沒什麼區別。但是我們知道他們是有關係的,因為內部類預設持有一個外部類的引用。
內部類的分類
內部類可以分為:靜態內部類(巢狀類)和非靜態內部類。非靜態內部類又可以分為:成員內部類、方法內部類、匿名內部類。對於這幾種類的書寫相信大家早已熟練,所以本節主要說明的是這幾種類之間的區別:
靜態內部類和非靜態內部類的區別
- 靜態內部類可以有靜態成員,而非靜態內部類則不能有靜態成員。
- 靜態內部類可以訪問外部類的靜態變數,而不可訪問外部類的非靜態變數;
- 非靜態內部類的非靜態成員可以訪問外部類的非靜態變數。
- 靜態內部類的建立不依賴於外部類,而非靜態內部類必須依賴於外部類的建立而建立。
我們通過一個例子就可以很好的理解這幾點區別:
public class ClassOuter {
private int noStaticInt = 1;
private static int STATIC_INT = 2;
public void fun() {
System.out.println("外部類方法");
}
public class InnerClass {
//static int num = 1; 此時編輯器會報錯 非靜態內部類則不能有靜態成員
public void fun(){
//非靜態內部類的非靜態成員可以訪問外部類的非靜態變數。
System.out.println(STATIC_INT);
System.out.println(noStaticInt);
}
}
public static class StaticInnerClass {
static int NUM = 1;//靜態內部類可以有靜態成員
public void fun(){
System.out.println(STATIC_INT);
//System.out.println(noStaticInt); 此時編輯器會報 不可訪問外部類的非靜態變數錯
}
}
}
public class TestInnerClass {
public static void main(String[] args) {
//非靜態內部類 建立方式1
ClassOuter.InnerClass innerClass = new ClassOuter().new InnerClass();
//非靜態內部類 建立方式2
ClassOuter outer = new ClassOuter();
ClassOuter.InnerClass inner = outer.new InnerClass();
//靜態內部類的建立方式
ClassOuter.StaticInnerClass staticInnerClass = new ClassOuter.StaticInnerClass();
}
}
複製程式碼
區域性內部類
如果一個內部類只在一個方法中使用到了,那麼我們可以將這個類定義在方法內部,這種內部類被稱為區域性內部類。其作用域僅限於該方法。
區域性內部類有兩點值得我們注意的地方:
- 區域性內類不允許使用訪問許可權修飾符 public private protected 均不允許
- 區域性內部類對外完全隱藏,除了建立這個類的方法可以訪問它其他的地方是不允許訪問的。
- 區域性內部類與成員內部類不同之處是他可以引用成員變數,但該成員必須宣告為 final,並內部不允許修改該變數的值。(這句話並不準確,因為如果不是基本資料型別的時候,只是不允許修改引用指向的物件,而物件本身是可以被就修改的)
public class ClassOuter {
private int noStaticInt = 1;
private static int STATIC_INT = 2;
public void fun() {
System.out.println("外部類方法");
}
public void testFunctionClass(){
class FunctionClass{
private void fun(){
System.out.println("區域性內部類的輸出");
System.out.println(STATIC_INT);
System.out.println(noStaticInt);
System.out.println(params);
//params ++ ; // params 不可變所以這句話編譯錯誤
}
}
FunctionClass functionClass = new FunctionClass();
functionClass.fun();
}
}
複製程式碼
匿名內部類
- 匿名內部類是沒有訪問修飾符的。
- 匿名內部類必須繼承一個抽象類或者實現一個介面
- 匿名內部類中不能存在任何靜態成員或方法
- 匿名內部類是沒有構造方法的,因為它沒有類名。
- 與區域性內部相同匿名內部類也可以引用區域性變數。此變數也必須宣告為 final
public class Button {
public void click(final int params){
//匿名內部類,實現的是ActionListener介面
new ActionListener(){
public void onAction(){
System.out.println("click action..." + params);
}
}.onAction();
}
//匿名內部類必須繼承或實現一個已有的介面
public interface ActionListener{
public void onAction();
}
public static void main(String[] args) {
Button button=new Button();
button.click();
}
}
複製程式碼
為什麼區域性變數需要final修飾呢
原因是:因為區域性變數和匿名內部類的生命週期不同。
匿名內部類是建立後是儲存在堆中的,而方法中的區域性變數是儲存在Java棧中,當方法執行完畢後,就進行退棧,同時區域性變數也會消失。那麼此時匿名內部類還有可能在堆中儲存著,那麼匿名內部類要到哪裡去找這個區域性變數呢?
為了解決這個問題編譯器為自動地幫我們在匿名內部類中建立了一個區域性變數的備份,也就是說即使方法執結束,匿名內部類中還有一個備份,自然就不怕找不到了。
但是問題又來了。如果區域性變數中的a不停的在變化。那麼豈不是也要讓備份的a變數無時無刻的變化。為了保持區域性變數與匿名內部類中備份域保持一致。編譯器不得不規定死這些區域性域必須是常量,一旦賦值不能再發生變化了。所以為什麼匿名內部類應用外部方法的域必須是常量域的原因所在了。
特別注意:在Java8中已經去掉要對final的修飾限制,但其實只要在匿名內部類使用了,該變數還是會自動變為final型別(只能使用,不能賦值)。
實際開發中內部類有可能會引起的問題
內部類會造成程式的記憶體洩漏
相信做 Android 的朋友看到這個例子一定不會陌生,我們經常使用的 Handler 就無時無刻不給我們提示著這樣的警告。我們先來看下內部類為什麼會造成記憶體洩漏。
要想了解為啥內部類為什麼會造成記憶體洩漏我們就必須瞭解 java 虛擬機器的回收機制,但是我們這裡不會詳盡的介紹 java 的記憶體回收機制,我們只需要瞭解 java 的記憶體回收機制通過「可達性分析」來實現的。即 java 虛擬機器會通過記憶體回收機制來判定引用是否可達,如果不可達就會在某些時刻去回收這些引用。
那麼內部類在什麼情況下會造成記憶體洩漏的可能呢?
如果一個匿名內部類沒有被任何引用持有,那麼匿名內部類物件用完就有機會被回收。
如果內部類僅僅只是在外部類中被引用,當外部類的不再被引用時,外部類和內部類就可以都被GC回收。
如果當內部類的引用被外部類以外的其他類引用時,就會造成內部類和外部類無法被GC回收的情況,即使外部類沒有被引用,因為內部類持有指向外部類的引用)。
public class ClassOuter {
Object object = new Object() {
public void finalize() {
System.out.println("inner Free the occupied memory...");
}
};
public void finalize() {
System.out.println("Outer Free the occupied memory...");
}
}
public class TestInnerClass {
public static void main(String[] args) {
try {
Test();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void Test() throws InterruptedException {
System.out.println("Start of program.");
ClassOuter outer = new ClassOuter();
Object object = outer.object;
outer = null;
System.out.println("Execute GC");
System.gc();
Thread.sleep(3000);
System.out.println("End of program.");
}
}
複製程式碼
執行程式發現 執行記憶體回收並沒回收 object 物件,這是因為即使外部類沒有被任何變數引用,只要其內部類被外部類以外的變數持有,外部類就不會被GC回收。我們要尤其注意內部類被外面其他類引用的情況,這點導致外部類無法被釋放,極容易導致記憶體洩漏。
在Android 中 Hanlder 作為內部類使用的時候其物件被系統主執行緒的 Looper 持有(當然這裡也可是子執行緒手動建立的 Looper)掌管的訊息佇列 MessageQueue 中的 Hanlder 傳送的 Message 持有,當訊息佇列中有大量訊息處理的需要處理,或者延遲訊息需要執行的時候,建立該 Handler 的 Activity 已經退出了,Activity 物件也無法被釋放,這就造成了記憶體洩漏。
那麼 Hanlder 何時會被釋放,當訊息佇列處理完 Hanlder 攜帶的 message 的時候就會呼叫 msg.recycleUnchecked()釋放Message所持有的Handler引用。
在 Android 中要想處理 Hanlder 記憶體洩漏可以從兩個方面著手:
- 在關閉Activity/Fragment 的 onDestry,取消還在排隊的Message:
mHandler.removeCallbacksAndMessages(null);
複製程式碼
- 將 Hanlder 建立為靜態內部類並採用軟引用方式
private static class MyHandler extends Handler {
private final WeakReference<MainActivity> mActivity;
public MyHandler(MainActivity activity) {
mActivity = new WeakReference<MainActivity>(activity);
}
@Override
public void handleMessage(Message msg) {
MainActivity activity = mActivity.get();
if (activity == null || activity.isFinishing()) {
return;
}
// ...
}
}
複製程式碼
總結
本文從內部類的存在理由,內部類與外部類的關係,內部類分類以及開發中內部類可能造成的記憶體洩漏的問題上,總結了與內部類相關的問題,原諒本人才疏學淺,本文之前想要使用「徹底搞懂 java 內部類」但是當我寫完整片文章,我才發現,通過 java 內部類可能會延伸出各種各樣的知識,所以最終去掉了徹底二字,總結可能有很多不到位的地方。還望能夠及時幫我指出。
其中內部類分類,靜態內部類和非靜態內部類,以及區域性內部類和匿名內部的共同點和區別點很可能被面試問到,如果能因此延伸到內部類造成的記憶體洩漏問題上,想必也是個加分項。
本文參考 《Thinking in java》,《Java 核心技術 卷1》 http://blog.csdn.net/mcryeasy/article/details/54848452 http://blog.csdn.net/mcryeasy/article/details/53149594 https://www.zhihu.com/question/21373020 http://daiguahub.com/2016/09/08/java%E5%86%85%E9%83%A8%E7%B1%BB%E7%9A%84%E6%84%8F%E4%B9%89%E5%92%8C%E4%BD%9C%E7%94%A8/ https://www.zhihu.com/question/20969764