死磕Java內部類(一篇就夠)

三好碼農發表於2019-06-17

Java內部類,相信大家都用過,但是多數同學可能對它瞭解的並不深入,只是靠記憶來完成日常工作,卻不能融會貫通,遇到奇葩問題更是難以有思路去解決。這篇文章帶大家一起死磕Java內部類的方方面面。 友情提示:這篇文章的討論基於JDK版本 1.8.0_191

開篇問題

我一直覺得技術是工具,是一定要落地的,要切實解決某些問題的,所以我們通過先丟擲問題,然後解決這些問題,在這個過程中來加深理解,最容易有收穫。 so,先丟擲幾個問題。(如果這些問題你早已思考過,答案也瞭然於胸,那恭喜你,這篇文章可以關掉了)。

  • 為什麼需要內部類?
  • 為什麼內部類(包括匿名內部類、區域性內部類),會持有外部類的引用?
  • 為什麼匿名內部類使用到外部類方法中的區域性變數時需要是final型別的?
  • 如何建立內部類例項,如何繼承內部類?
  • Lambda表示式是如何實現的?

為什麼需要內部類?

要回答這個問題,先要弄明白什麼是內部類?我們知道Java有三種型別的內部類

普通的內部類

public class Demo {

    // 普通內部類
    public class DemoRunnable implements Runnable {
        @Override
        public void run() {
        }
    }
}
複製程式碼

匿名內部類

public class Demo {

    // 匿名內部類
    private Runnable runnable = new Runnable() {
        @Override
        public void run() {

        }
    };
}
複製程式碼

方法內區域性內部類

public class Demo {

    // 區域性內部類
    public void work() {
        class InnerRunnable implements Runnable {
            @Override
            public void run() {

            }
        }
        InnerRunnable runnable = new InnerRunnable();
    }

}
複製程式碼

這三種形式的內部類,大家肯定都用過,但是技術在設計之初肯定也是要用來解決某個問題或者某個痛點,那可以想想內部類相對比外部定義類有什麼優勢呢? 我們通過一個小例子來做說明

public class Worker {
    private List<Job> mJobList = new ArrayList<>();

    public void addJob(Runnable task) {
        mJobList.add(new Job(task));
    }

    private class Job implements Runnable {
        Runnable task;
        public  Job(Runnable task) {
            this.task = task;
        }

        @Override
        public void run() {
            runnable.run();
            System.out.println("left job size : " + mJobList.size());
        }
    }
}
複製程式碼

定義了一個Worker類,暴露了一個addJob方法,一個引數task,型別是Runnable,然後定義 了一個內部類Job類對task進行了一層封裝,這裡Job是私有的,所以外界是感知不到Job的存在的,所以有了內部類第一個優勢。

  • 內部類能夠更好的封裝,內聚,遮蔽細節

我們在Job的run方法中,列印了外部Worker的mJobList列表中剩餘Job數量,程式碼這樣寫沒問題,但是細想,內部類是如何拿到外部類的成員變數的呢?這裡先賣個關子,但是已經可以先得出內部類的第二個優勢了。

  • 內部類天然有訪問外部類成員變數的能力

內部類主要就是上面的二個優勢。當然還有一些其他的小優點,比如可以用來實現多重繼承,可以將邏輯內聚在一個類方便維護等,這些見仁見智,先不去說它們。

我們接著看第二個問題!!!

為什麼內部類(包括匿名內部類、區域性內部類),會持有外部類的引用?

問這個問題,顯得我是個槓精,您先彆著急,其實我想問的是,內部類Java是怎麼實現的。 我們還是舉例說明,先以普通的內部類為例

普通內部類的實現

public class Demo {
    // 普通內部類
    public class DemoRunnable implements Runnable {
        @Override
        public void run() {
        }
    }
}
複製程式碼

切到Demo.java所在資料夾,命令列執行 javac Demo.java,在Demo類同目錄下可以看到生成了二個class檔案

普通內部類生成class.png

Demo.class很好理解,另一個 類

Demo$DemoRunnable.class
複製程式碼

就是我們的內部類編譯出來的,它的命名也是有規律的,外部類名Demo+$+內部類名DemoRunnable。 檢視反編譯後的程式碼(IntelliJ IDEA本身就支援,直接檢視class檔案即可)

package inner;

public class Demo$DemoRunnable implements Runnable {
    public Demo$DemoRunnable(Demo var1) {
        this.this$0 = var1;
    }

    public void run() {
    }
}
複製程式碼

生成的類只有一個構造器,引數就是Demo型別,而且儲存到內部類本身的this$0欄位中。到這裡我們其實已經可以想到,內部類持有的外部類引用就是通過這個構造器傳遞進來的,它是一個強引用。

驗證我們的想法

怎麼驗證呢?我們需要在Demo.class類中加一個方法,來例項化這個DemoRunnable內部類物件

   // Demo.java
    public void run() {
        DemoRunnable demoRunnable = new DemoRunnable();
        demoRunnable.run();
    }
複製程式碼

再次執行 javac Demo.java,再執行javap -verbose Demo.class,檢視Demo類的位元組碼,前方高能,需要一些位元組碼知識,這裡我們重點關注run方法(插一句題外話,位元組碼簡單的要能看懂,-。-)

  public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class inner/Demo$DemoRunnable
         3: dup
         4: aload_0
         5: invokespecial #3                  // Method inner/Demo$DemoRunnable."<init>":(Linner/Demo;)V
         8: astore_1
         9: aload_1
        10: invokevirtual #4                  // Method inner/Demo$DemoRunnable.run:()V
        13: return

複製程式碼
  • 先通過new指令,新建了一個Demo$DemoRunnable物件
  • aload_0指令將外部類Demo物件自身載入到棧幀中
  • 呼叫Demo$DemoRunnable類的init方法,注意這裡將Demo物件作為了引數傳遞進來了

到這一步其實已經很清楚了,就是將外部類物件自身作為引數傳遞給了內部類構造器,與我們上面的猜想一致。

匿名內部類的實現

public class Demo {
    // 匿名內部類
    private Runnable runnable = new Runnable() {
        @Override
        public void run() {

        }
    };
}
複製程式碼

同樣執行javac Demo.java,這次多生成了一個Demo$1.class,反編譯檢視程式碼

package inner;

class Demo$1 implements Runnable {
    Demo$1(Demo var1) {
        this.this$0 = var1;
    }

    public void run() {
    }
}
複製程式碼

可以看到匿名內部類和普通內部類實現基本一致,只是編譯器自動給它拼了個名字,所以匿名內部類不能自定義構造器,因為名字編譯完成後才能確定。 方法區域性內部類,我這裡就不贅述了,原理都是一樣的,大家可以自行試驗。 這樣我們算是解答了第二個問題,來看第三個問題。

為什麼匿名內部類使用到外部類方法中的區域性變數時需要是final型別的?

這裡先申明一下,這個問題本身是有問題的,問題在哪呢?因為java8中並不一定需要宣告為final。我們來看個例子

   // Demo.java
    public void run() {
        int age = 10;
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                int myAge = age + 1;
                System.out.println(myAge);
            }
        };
    }
複製程式碼

匿名內部類物件runnable,使用了外部類方法中的age區域性變數。編譯執行完全沒問題,而age並沒有final修飾啊! 那我們再在run方法中,嘗試修改age試試

    public void run() {
        int age = 10;
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                int myAge = age + 1;
                System.out.println(myAge);
                age = 20;   // error
            }
        };
    }
複製程式碼

編譯器報錯了,提示資訊是”age is access from inner class, need to be final or effectively final“。很顯然編譯器很智慧,由於我們第一個例子並沒有修改age的值,所以編譯器認為這是effectively final,是安全的,可以編譯通過,而第二個例子嘗試修改age的值,編譯器立馬就報錯了。

外部類變數是怎麼傳遞給內部類的?

這裡對於變數的型別分三種情況分別來說明

非final區域性變數

我們去掉嘗試修改age的程式碼,然後執行javac Demo.java,檢視Demo$1.class的實現程式碼

package inner;

class Demo$1 implements Runnable {
    Demo$1(Demo var1, int var2) {
        this.this$0 = var1;
        this.val$age = var2;
    }

    public void run() {
        int var1 = this.val$age + 1;
        System.out.println(var1);
    }
}
複製程式碼

可以看到對於非final區域性變數,是通過構造器的方式傳遞進來的。

final區域性變數

age修改為final

    public void run() {
        final int age = 10;
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                int myAge = age + 1;
                System.out.println(myAge);
            }
        };
    }
複製程式碼

同樣執行javac Demo.java,檢視Demo$1.class的實現程式碼

class Demo$1 implements Runnable {
    Demo$1(Demo var1) {
        this.this$0 = var1;
    }

    public void run() {
        byte var1 = 11;
        System.out.println(var1);
    }
}
複製程式碼

可以看到編譯器很聰明的做了優化,age是final的,所以在編譯期間是確定的,直接將+1優化為11。 為了測試編譯器的智商,我們把age的賦值修改一下,改為執行時才能確定的,看編譯器如何應對

    public void run() {
        final int age = (int) System.currentTimeMillis();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                int myAge = age + 1;
                System.out.println(myAge);
            }
        };
    }
複製程式碼

再看Demo$1 位元組碼實現

class Demo$1 implements Runnable {
    Demo$1(Demo var1, int var2) {
        this.this$0 = var1;
        this.val$age = var2;
    }

    public void run() {
        int var1 = this.val$age + 1;
        System.out.println(var1);
    }
}
複製程式碼

編譯器意識到編譯期age的值不能確定,所以還是採用構造器傳參的形式實現。現代編譯器還是很機智的。

外部類成員變數

將age改為Demo的成員變數,注意沒有加任何修飾符,是包級訪問級別。

public class Demo {
    int age = 10;
    public void run() {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                int myAge = age + 1;
                System.out.println(myAge);
                age = 20;
            }
        };
    }
}
複製程式碼

javac Demo.java,檢視匿名內部內的實現

class Demo$1 implements Runnable {
    Demo$1(Demo var1) {
        this.this$0 = var1;
    }

    public void run() {
        int var1 = this.this$0.age + 1;
        System.out.println(var1);
        this.this$0.age = 20;
    }
}
複製程式碼

這一次編譯器直接通過外部類的引用操作age,沒毛病,由於age是包訪問級別,所以這樣是最高效的。 如果將age改為private,編譯器會在Demo類中生成二個方法,分別用於讀取age和設定age,篇幅關係,這種情況留給大家自行測試。

解答為何區域性變數傳遞給匿名內部類需要是final?

通過上面的例子可以看到,不是一定需要區域性變數是final的,但是你不能在匿名內部類中修改外部區域性變數,因為Java對於匿名內部類傳遞變數的實現是基於構造器傳參的,也就是說如果允許你在匿名內部類中修改值,你修改的是匿名內部類中的外部區域性變數副本,最終並不會對外部類產生效果,因為已經是二個變數了。 這樣就會讓程式設計師產生困擾,原以為修改會生效,事實上卻並不會,所以Java就禁止在匿名內部類中修改外部區域性變數。

如何建立內部類例項,如何繼承內部類?

由於內部類物件需要持有外部類物件的引用,所以必須得先有外部類物件

Demo.DemoRunnable demoRunnable = new Demo().new DemoRunnable();
複製程式碼

那如何繼承一個內部類呢,先給出示例

    public class Demo2 extends Demo.DemoRunnable {
        public Demo2(Demo demo) {
            demo.super();
        }

        @Override
        public void run() {
            super.run();
        }
    }
複製程式碼

必須在構造器中傳入一個Demo物件,並且還需要呼叫demo.super(); 看個例子

public class DemoKata {
    public static void main(String[] args) {
        Demo2 demo2 = new DemoKata().new Demo2(new Demo());
    }

    public class Demo2 extends Demo.DemoRunnable {
        public Demo2(Demo demo) {
            demo.super();
        }

        @Override
        public void run() {
            super.run();
        }
    }
}
複製程式碼

由於Demo2也是一個內部類,所以需要先new一個DemoKata物件。 這一個問題描述的場景可能用的並不多,一般也不這麼去用,這裡提一下,大家知道有這麼回事就行。

Lambda表示式是如何實現的?

Java8引入了Lambda表示式,一定程度上可以簡化我們的程式碼,使程式碼結構看起來更優雅。做技術的還是要有刨根問底的那股勁,問問自己有沒有想過Java中Lambda到底是如何實現的呢?

來看一個最簡單的例子

public class Animal {
    public void run(Runnable runnable) {
    }
}
複製程式碼

Animal類中定義了一個run方法,引數是一個Runnable物件,Java8以前,我們可以傳入一個匿名內部類物件

run(new Runnable() {
            @Override
            public void run() {
            }
});
複製程式碼

Java 8 之後編譯器已經很智慧的提示我們可以用Lambda表示式來替換。既然可以替換,那匿名內部類和Lambda表示式是不是底層實現是一樣的呢,或者說Lambda表示式只是匿名內部類的語法糖呢? 要解答這個問題,我們還是要去位元組碼中找線索。通過前面的知識,我們知道javac Animal.java命令將類編譯成class,匿名內部類的方式會產生一個額外的類。那用Lambda表示式會不會也會編譯新類呢?我們試一下便知。

    public void run(Runnable runnable) {
    }

    public void test() {
        run(() -> {});
    }
複製程式碼

javac Animal.java,發現並沒有生成額外的類!!! 我們繼續使用javap -verbose Animal.class來檢視Animal.class的位元組碼實現,重點關注test方法

  public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         6: invokevirtual #3                  // Method run:(Ljava/lang/Runnable;)V
         9: return

SourceFile: "Demo.java"
InnerClasses:
     public static final #34= #33 of #37; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
  0: #18 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #19 ()V
      #20 invokestatic com/company/inner/Demo.lambda$test$0:()V
      #19 ()V

複製程式碼

發現test方法位元組碼中多了一個invokedynamic #2 0指令,這是java7引入的新指令,其中#2 指向

#2 = InvokeDynamic      #0:#21         // #0:run:()Ljava/lang/Runnable;
複製程式碼

而0代表BootstrapMethods方法表中的第一個,java/lang/invoke/LambdaMetafactory.metafactory方法被呼叫。

BootstrapMethods:
  0: #18 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #19 ()V
      #20 invokestatic com/company/inner/Demo.lambda$test$0:()V
      #19 ()V
複製程式碼

這裡面我們看到了com/company/inner/Demo.lambda$test$0這麼個東西,看起來跟我們的匿名內部類的名稱有些類似,而且中間還有lambda,有可能就是我們要找的生成的類。 我們不妨驗證下我們的想法,可以通過下面的程式碼列印出Lambda物件的真實類名。

    public void run(Runnable runnable) {
        System.out.println(runnable.getClass().getCanonicalName());
    }

    public void test() {
        run(() -> {});
    }
複製程式碼

列印出runnable的類名,結果如下

com.company.inner.Demo$$Lambda$1/764977973
複製程式碼

跟我們上面的猜測並不完全一致,我們繼續找別的線索,既然我們有看到LambdaMetafactory.metafactory這個類被呼叫,不妨繼續跟進看下它的實現

    public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                             invokedName, samMethodType,
                                             implMethod, instantiatedMethodType,
                                             false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
    }
複製程式碼

內部new了一個InnerClassLambdaMetafactory物件。看名字很可疑,繼續跟進

public InnerClassLambdaMetafactory(...)
            throws LambdaConversionException {
        //....
        lambdaClassName = targetClass.getName().replace('.', '/') + "$$Lambda$" + counter.incrementAndGet();
        cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
       //....
    }
複製程式碼

省略了很多程式碼,我們重點看lambdaClassName這個字串(通過名字就知道是幹啥的),可以看到它的拼接結果跟我們上面列印的Lambda類名基本一致。而下面的ClassWriter也暴露了,其實Lambda運用的是Asm位元組碼技術,在執行時生成類檔案。我感覺到這裡就差不多了,再往下可能就有點太過細節了。-。-

Lambda實現總結

所以Lambda表示式並不是匿名內部類的語法糖,它是基於invokedynamic指令,在執行時使用ASM生成類檔案來實現的。

寫在最後

這可能是我迄今寫的最長的一篇技術文章了,寫的過程中也在不斷的加深自己對知識點的理解,顛覆了很多以往的錯誤認知。寫技術文章這條路我會一直堅持下去。 非常喜歡得到裡面的一句slogan,胡適先生說的話。 怕什麼真理無窮,進一寸有一寸的歡喜 共勉!

相關文章