夯實Java基礎系列18:深入理解Java內部類及其實現原理

a724888發表於2019-10-08

本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫裡檢視

https://github.com/h2pl/Java-Tutorial

喜歡的話麻煩點下Star哈

文章首發於我的個人部落格:

www.how2playlife.comww.how2playlife.com

內部類初探

什麼是內部類?

  內部類是指在一個外部類的內部再定義一個類。內部類作為外部類的一個成員,並且依附於外部類而存在的。內部類可為靜態,可用protected和private修飾(而外部類只能使用public和預設的包訪問許可權)。內部類主要有以下幾類:成員內部類、區域性內部類、靜態內部類、匿名內部類

內部類的共性

(1)內部類仍然是一個獨立的類,在編譯之後內部類會被編譯成獨立的.class檔案,但是前面冠以外部類的類名和$符號 。

(2)內部類不能用普通的方式訪問。

(3)內部類宣告成靜態的,就不能隨便的訪問外部類的成員變數了,此時內部類只能訪問外部類的靜態成員變數 。

(4)外部類不能直接訪問內部類的的成員,但可以通過內部類物件來訪問

  內部類是外部類的一個成員,因此內部類可以自由地訪問外部類的成員變數,無論是否是private的。

  因為當某個外圍類的物件建立內部類的物件時,此內部類會捕獲一個隱式引用,它引用了例項化該內部物件的外圍類物件。通過這個指標,可以訪問外圍類物件的全部狀態。

通過反編譯內部類的位元組碼,分析之後主要是通過以下幾步做到的:

  1 編譯器自動為內部類新增一個成員變數, 這個成員變數的型別和外部類的型別相同, 這個成員變數就是指向外部類物件的引用;

  2 編譯器自動為內部類的構造方法新增一個引數, 引數的型別是外部類的型別, 在構造方法內部使用這個引數為1中新增的成員變數賦值;

  3 在呼叫內部類的建構函式初始化內部類物件時, 會預設傳入外部類的引用。

使用內部類的好處:

靜態內部類的作用:

1 只是為了降低包的深度,方便類的使用,靜態內部類適用於包含類當中,但又不依賴與外在的類。

2 由於Java規定靜態內部類不能用使用外在類的非靜態屬性和方法,所以只是為了方便管理類結構而定義。於是我們在建立靜態內部類的時候,不需要外部類物件的引用。

非靜態內部類的作用:

1 內部類繼承自某個類或實現某個介面,內部類的程式碼操作建立其他外圍類的物件。所以你可以認為內部類提供了某種進入其外圍類的視窗。

2 使用內部類最吸引人的原因是:每個內部類都能獨立地繼承自一個(介面的)實現,所以無論外圍類是否已經繼承了某個(介面的)實現,對於內部類都沒有影響

3 如果沒有內部類提供的可以繼承多個具體的或抽象的類的能力,一些設計與程式設計問題就很難解決。
從這個角度看,內部類使得多重繼承的解決方案變得完整。介面解決了部分問題,而內部類有效地實現了”多重繼承”。

那靜態內部類與普通內部類有什麼區別呢?

問得好,區別如下:

(1)靜態內部類不持有外部類的引用
在普通內部類中,我們可以直接訪問外部類的屬性、方法,即使是private型別也可以訪問,這是因為內部類持有一個外部類的引用,可以自由訪問。而靜態內部類,則只可以訪問外部類的靜態方法和靜態屬性(如果是private許可權也能訪問,這是由其程式碼位置所決定的),其他則不能訪問。

(2)靜態內部類不依賴外部類
普通內部類與外部類之間是相互依賴的關係,內部類例項不能脫離外部類例項,也就是說它們會同生同死,一起宣告,一起被垃圾回收器回收。而靜態內部類是可以獨立存在的,即使外部類消亡了,靜態內部類還是可以存在的。

(3)普通內部類不能宣告static的方法和變數
普通內部類不能宣告static的方法和變數,注意這裡說的是變數,常量(也就是final static修飾的屬性)還是可以的,而靜態內部類形似外部類,沒有任何限制。

為什麼普通內部類不能有靜態變數呢?

1 成員內部類 之所以叫做成員 就是說他是類例項的一部分 而不是類的一部分

2 結構上來說 他和你宣告的成員變數是一樣的地位 一個特殊的成員變數 而靜態的變數是類的一部分和例項無關

3 你若宣告一個成員內部類 讓他成為主類的例項一部分 然後又想在內部類宣告和例項無關的靜態的東西 你讓JVM情何以堪啊

4 若想在內部類內宣告靜態欄位 就必須將其內部類本身宣告為靜態

非靜態內部類有一個很大的優點:可以自由使用外部類的所有變數和方法

下面的例子大概地介紹了

1 非靜態內部類和靜態內部類的區別。

2 不同訪問許可權的內部類的使用。

3 外部類和它的內部類之間的關係

//本節討論內部類以及不同訪問許可權的控制
//內部類只有在使用時才會被載入。
//外部類B
public class B{
    int i = 1;
    int j = 1;
    static int s = 1;
    static int ss = 1;
    A a;
    AA aa;
    AAA aaa;
    //內部類A
    public class A {
//        static void go () {
//
//        }
//        static {
//
//        }
//      static int b = 1;//非靜態內部類不能有靜態成員變數和靜態程式碼塊和靜態方法,
        // 因為內部類在外部類載入時並不會被載入和初始化。
        //所以不會進行靜態程式碼的呼叫
        int i = 2;//外部類無法讀取內部類的成員,而內部類可以直接訪問外部類成員
        public void test() {
            System.out.println(j);
            j = 2;
            System.out.println(j);
            System.out.println(s);//可以訪問類的靜態成員變數
        }
        public void test2() {
            AA aa = new AA();
            AAA aaa = new AAA();
        }
    }
    //靜態內部類S,可以被外部訪問
    public static class S {
        int i = 1;//訪問不到非靜態變數。
        static int s = 0;//可以有靜態變數
        public static void main(String[] args) {
            System.out.println(s);
        }
        @Test
        public void test () {
//            System.out.println(j);//報錯,靜態內部類不能讀取外部類的非靜態變數
            System.out.println(s);
            System.out.println(ss);
            s = 2;
            ss = 2;
            System.out.println(s);
            System.out.println(ss);
        }
    }
    //內部類AA,其實這裡加protected相當於default
    //因為外部類要呼叫內部類只能通過B。並且無法直接繼承AA,所以必須在同包
    //的類中才能呼叫到(這裡不考慮靜態內部類),那麼就和default一樣了。
    protected class AA{
        int i = 2;//內部類之間不共享變數
        public void test (){
            A a = new A();
            AAA aaa = new AAA();
            //內部類之間可以互相訪問。
        }
    }
    //包外部依然無法訪問,因為包沒有繼承關係,所以找不到這個類
    protected static class SS{
        int i = 2;//內部類之間不共享變數
        public void test (){
            //內部類之間可以互相訪問。
        }
    }
    //私有內部類A,對外不可見,但對內部類和父類可見
    private class AAA {
        int i = 2;//內部類之間不共享變數
        public void test() {
            A a = new A();
            AA aa = new AA();
            //內部類之間可以互相訪問。
        }
    }
    @Test
    public void test(){
        A a = new A();
        a.test();
        //內部類可以修改外部類的成員變數
        //列印出 1 2
        B b = new B();
    }
}
//另一個外部類
class C {
    @Test
    public void test() {
        //首先,其他類內部類只能通過外部類來獲取其例項。
        B.S s = new B.S();
        //靜態內部類可以直接通過B類直接獲取,不需要B的例項,和靜態成員變數類似。
        //B.A a = new B.A();
        //當A不是靜態類時這行程式碼會報錯。
        //需要使用B的例項來獲取A的例項
        B b = new B();
        B.A a = b.new A();
        B.AA aa = b.new AA();//B和C同包,所以可以訪問到AA
//      B.AAA aaa = b.new AAA();AAA為私有內部類,外部類不可見
        //當A使用private修飾時,使用B的例項也無法獲取A的例項,這一點和私有變數是一樣的。
        //所有普通的內部類與類中的一個變數是類似的。靜態內部類則與靜態成員類似。
    }
}

內部類的載入

可能剛才的例子中沒辦法直觀地看到內部類是如何載入的,接下來用例子展示一下內部類載入的過程。

1 內部類是延時載入的,也就是說只會在第一次使用時載入。不使用就不載入,所以可以很好的實現單例模式。

2 不論是靜態內部類還是非靜態內部類都是在第一次使用時才會被載入。

3 對於非靜態內部類是不能出現靜態模組(包含靜態塊,靜態屬性,靜態方法等)

4 非靜態類的使用需要依賴於外部類的物件,詳見上述物件innerClass 的初始化。

簡單來說,類的載入都是發生在類要被用到的時候。內部類也是一樣

1 普通內部類在第一次用到時載入,並且每次例項化時都會執行內部成員變數的初始化,以及程式碼塊和構造方法。

2 靜態內部類也是在第一次用到時被載入。但是當它載入完以後就會將靜態成員變數初始化,執行靜態程式碼塊,並且只執行一次。當然,非靜態成員和程式碼塊每次例項化時也會執行。

總結一下Java類程式碼載入的順序,萬變不離其宗。

規律一、初始化構造時,先父後子;只有在父類所有都構造完後子類才被初始化

規律二、類載入先是靜態、後非靜態、最後是建構函式。

靜態構造塊、靜態類屬性按出現在類定義裡面的先後順序初始化,同理非靜態的也是一樣的,只是靜態的只在載入位元組碼時執行一次,不管你new多少次,非靜態會在new多少次就執行多少次

規律三、java中的類只有在被用到的時候才會被載入

規律四、java類只有在類位元組碼被載入後才可以被構造成物件例項

成員內部類

在方法中定義的內部類稱為區域性內部類。與區域性變數類似,區域性內部類不能有訪問說明符,因為它不是外圍類的一部分,但是它可以訪問當前程式碼塊內的常量,和此外圍類所有的成員。

需要注意的是:
區域性內部類只能在定義該內部類的方法內例項化,不可以在此方法外對其例項化。

public class 區域性內部類 {
    class A {//區域性內部類就是寫在方法裡的類,只在方法執行時載入,一次性使用。
        public void test() {
            class B {
                public void test () {
                    class C {
                    }
                }
            }
        }
    }
    @Test
    public void test () {
        int i = 1;
        final int j = 2;
        class A {
            @Test
            public void test () {
                System.out.println(i);
                System.out.println(j);
            }
        }
        A a = new A();
        System.out.println(a);
    }
    static class B {
        public static void test () {
            //static class A報錯,方法裡不能定義靜態內部類。
            //因為只有在方法呼叫時才能進行類載入和初始化。
        }
    }
}

匿名內部類

簡單地說:匿名內部類就是沒有名字的內部類,並且,匿名內部類是區域性內部類的一種特殊形式。什麼情況下需要使用匿名內部類?如果滿足下面的一些條件,使用匿名內部類是比較合適的:
只用到類的一個例項。
類在定義後馬上用到。
類非常小(SUN推薦是在4行程式碼以下)
給類命名並不會導致你的程式碼更容易被理解。
在使用匿名內部類時,要記住以下幾個原則:

1  匿名內部類不能有構造方法。

2  匿名內部類不能定義任何靜態成員、方法和類。

3  匿名內部類不能是public,protected,private,static。

4  只能建立匿名內部類的一個例項。

5 一個匿名內部類一定是在new的後面,用其隱含實現一個介面或實現一個類。

6  因匿名內部類為區域性內部類,所以區域性內部類的所有限制都對其生效。

一個匿名內部類的例子:

    public class 匿名內部類 {
}
interface D{
    void run ();
}
abstract class E{
    E (){
    }
    abstract void work();
}
class A {
        @Test
        public void test (int k) {
            //利用介面寫出一個實現該介面的類的例項。
            //有且僅有一個例項,這個類無法重用。
            new Runnable() {
                @Override
                public void run() {
//                    k = 1;報錯,當外部方法中的區域性變數在內部類使用中必須改為final型別。
                    //因為方外部法中即使改變了這個變數也不會反映到內部類中。
                    //所以對於內部類來講這只是一個常量。
                    System.out.println(100);
                    System.out.println(k);
                }
            };
            new D(){
                //實現介面的匿名類
                int i =1;
                @Override
                public void run() {
                    System.out.println("run");
                    System.out.println(i);
                    System.out.println(k);
                }
            }.run();
            new E(){
                //繼承抽象類的匿名類
                int i = 1;
                void run (int j) {
                    j = 1;
                }
                @Override
                void work() {
                }
            };
        }
}

匿名內部類裡的final

使用的形參為何要為final

參考檔案: http://android.blog.51cto.com/268543/384844

我們給匿名內部類傳遞引數的時候,若該形參在內部類中需要被使用,那麼該形參必須要為final。也就是說:當所在的方法的形參需要被內部類裡面使用時,該形參必須為final。

為什麼必須要為final呢?

首先我們知道在內部類編譯成功後,它會產生一個class檔案,該class檔案與外部類並不是同一class檔案,僅僅只保留對外部類的引用。當外部類傳入的引數需要被內部類呼叫時,從java程式的角度來看是直接被呼叫:

public class OuterClass {
    public void display(final String name,String age){
        class InnerClass{
            void display(){
                System.out.println(name);
            }
        }
    }
}

從上面程式碼中看好像name引數應該是被內部類直接呼叫?其實不然,在java編譯之後實際的操作如下:

public class OuterClass$InnerClass {
    public InnerClass(String name,String age){
        this.InnerClass$name = name;
        this.InnerClass$age = age;
    }
    public void display(){
        System.out.println(this.InnerClass$name + "----" + this.InnerClass$age );
    }
}

所以從上面程式碼來看,內部類並不是直接呼叫方法傳遞的引數,而是利用自身的構造器對傳入的引數進行備份,自己內部方法呼叫的實際上時自己的屬性而不是外部方法傳遞進來的引數。

直到這裡還沒有解釋為什麼是final

在內部類中的屬性和外部方法的引數兩者從外表上看是同一個東西,但實際上卻不是,所以他們兩者是可以任意變化的,也就是說在內部類中我對屬性的改變並不會影響到外部的形參,而然這從程式設計師的角度來看這是不可行的。

畢竟站在程式的角度來看這兩個根本就是同一個,如果內部類該變了,而外部方法的形參卻沒有改變這是難以理解和不可接受的,所以為了保持引數的一致性,就規定使用final來避免形參的不改變。

簡單理解就是,拷貝引用,為了避免引用值發生改變,例如被外部類的方法修改等,而導致內部類得到的值不一致,於是用final來讓該引用不可改變。

故如果定義了一個匿名內部類,並且希望它使用一個其外部定義的引數,那麼編譯器會要求該引數引用是final的。

內部類初始化

我們一般都是利用構造器來完成某個例項的初始化工作的,但是匿名內部類是沒有構造器的!那怎麼來初始化匿名內部類呢?使用構造程式碼塊!利用構造程式碼塊能夠達到為匿名內部類建立一個構造器的效果。

public class OutClass {
    public InnerClass getInnerClass(final int age,final String name){
        return new InnerClass() {
            int age_ ;
            String name_;
            //構造程式碼塊完成初始化工作
            {
                if(0 < age && age < 200){
                    age_ = age;
                    name_ = name;
                }
            }
            public String getName() {
                return name_;
            }
            public int getAge() {
                return age_;
            }
        };
    }

內部類的過載

  如果你建立了一個內部類,然後繼承其外圍類並重新定義此內部類時,會發生什麼呢?也就是說,內部類可以被過載嗎?這看起來似乎是個很有用的點子,但是“過載”內部類就好像它是外圍類的一個方法,其實並不起什麼作用:

class Egg {
       private Yolk y;
       protected class Yolk {
              public Yolk() {
                     System.out.println("Egg.Yolk()");
              }
       }
       public Egg() {
              System.out.println("New Egg()");
              y = new Yolk();
       }
}
public class BigEgg extends Egg {
       public class Yolk {
              public Yolk() {
                     System.out.println("BigEgg.Yolk()");
              }
       }
       public static void main(String[] args) {
              new BigEgg();
       }
}
複製程式碼
輸出結果為:
New Egg()
Egg.Yolk()

預設的構造器是編譯器自動生成的,這裡是呼叫基類的預設構造器。你可能認為既然建立了BigEgg 的物件,那麼所使用的應該是被“過載”過的Yolk,但你可以從輸出中看到實際情況並不是這樣的。
這個例子說明,當你繼承了某個外圍類的時候,內部類並沒有發生什麼特別神奇的變化。這兩個內部類是完全獨立的兩個實體,各自在自己的名稱空間內。

內部類的繼承

因為內部類的構造器要用到其外圍類物件的引用,所以在你繼承一個內部類的時候,事情變得有點複雜。問題在於,那個“祕密的”外圍類物件的引用必須被初始化,而在被繼承的類中並不存在要聯接的預設物件。要解決這個問題,需使用專門的語法來明確說清它們之間的關聯:

class WithInner {
        class Inner {
                Inner(){
                        System.out.println("this is a constructor in WithInner.Inner");
                };
        }
}
public class InheritInner extends WithInner.Inner {
        // ! InheritInner() {} // Won't compile
        InheritInner(WithInner wi) {
                wi.super();
                System.out.println("this is a constructor in InheritInner");
        }
        public static void main(String[] args) {
                WithInner wi = new WithInner();
                InheritInner ii = new InheritInner(wi);
        }
}

複製程式碼
輸出結果為:
this is a constructor in WithInner.Inner
this is a constructor in InheritInner

可以看到,InheritInner 只繼承自內部類,而不是外圍類。但是當要生成一個構造器時,預設的構造器並不算好,而且你不能只是傳遞一個指向外圍類物件的引用。此外,你必須在構造器內使用如下語法:
enclosingClassReference.super();
這樣才提供了必要的引用,然後程式才能編譯通過。

有關匿名內部類實現回撥,事件驅動,委託等機制的文章將在下一節講述。

Java內部類的實現原理

內部類為什麼能夠訪問外部類的成員?

定義內部類如下:

Java 內部類

使用javap命令進行反編譯。

編譯後得到Main.class Main$Inner.class兩個檔案,反編譯Main$Inner.class檔案如下:

Java 內部類

可以看到,內部類其實擁有外部類的一個引用,在建構函式中將外部類的引用傳遞進來。

匿名內部類為什麼只能訪問區域性的final變數?

其實可以這樣想,當方法執行完畢後,區域性變數的生命週期就結束了,而區域性內部類物件的生命週期可能還沒有結束,那麼在區域性內部類中訪問區域性變數就不可能了,所以將區域性變數改為final,改變其生命週期。

編寫程式碼如下:

Java 內部類

這段程式碼編譯為Main.class Main$1.class兩個檔案,反編譯Main$1.class檔案如下:

Java 內部類

可以看到,java將編譯時已經確定的值直接複製,進行替換,將無法確定的值放到了內部類的常量池中,並在建構函式中將其從常量池取出到欄位中。

可以看出,java將區域性變數m直接進行復制,所以其並不是原來的值,若在內部類中將m更改,區域性變數的m值不會變,就會出現資料不一致,所以java就將其限制為final,使其不能進行更改,這樣資料不一致的問題就解決了。

參考文章

https://www.cnblogs.com/hujingnb/p/10181621.html

https://blog.csdn.net/codingtu/article/details/79336026

https://www.cnblogs.com/woshimrf/p/java-inner-class.html

https://www.cnblogs.com/dengchengchao/p/9713979.html

微信公眾號

Java技術江湖

如果大家想要實時關注我更新的文章以及分享的乾貨的話,可以關注我的公眾號【Java技術江湖】一位阿里 Java 工程師的技術小站,作者黃小斜,專注 Java 相關技術:SSM、SpringBoot、MySQL、分散式、中介軟體、叢集、Linux、網路、多執行緒,偶爾講點Docker、ELK,同時也分享技術乾貨和學習經驗,致力於Java全棧開發!

Java工程師必備學習資源: 一些Java工程師常用學習資源,關注公眾號後,後臺回覆關鍵字 “Java” 即可免費無套路獲取。

我的公眾號

個人公眾號:黃小斜

作者是 985 碩士,螞蟻金服 JAVA 工程師,專注於 JAVA 後端技術棧:SpringBoot、MySQL、分散式、中介軟體、微服務,同時也懂點投資理財,偶爾講點演算法和計算機理論基礎,堅持學習和寫作,相信終身學習的力量!

程式設計師3T技術學習資源: 一些程式設計師學習技術的資源大禮包,關注公眾號後,後臺回覆關鍵字 “資料” 即可免費無套路獲取。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69906029/viewspace-2659074/,如需轉載,請註明出處,否則將追究法律責任。

相關文章