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檔案
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,胡適先生說的話。 怕什麼真理無窮,進一寸有一寸的歡喜 共勉!