本文首發(fā)于我的個人博客 —— Bridge for You烈评,轉載請標明出處肮塞。
前言
我們先來看一道很簡單的小題:
public class AnonymousDemo1
{
public static void main(String args[])
{
new AnonymousDemo1().play();
}
private void play()
{
Dog dog = new Dog();
Runnable runnable = new Runnable()
{
public void run()
{
while(dog.getAge()<100)
{
// 過生日,年齡加一
dog.happyBirthday();
// 打印年齡
System.out.println(dog.getAge());
}
}
};
new Thread(runnable).start();
// do other thing below when dog's age is increasing
// ....
}
}
其中Dog類是這樣的:
public class Dog
{
private int age;
public int getAge()
{
return age;
}
public void setAge(int age)
{
this.age = age;
}
public void happyBirthday()
{
this.age++;
}
}
這段程序的功能非常簡單,就是啟動一個線程千康,來模擬一只小狗不斷過生日的一個過程。
不過铲掐,這段代碼并不能通過編譯拾弃,為什么,仔細看一下摆霉!
.
.
.
.
.
.
看出來了嗎砸彬?是的,play()方法中斯入,dog變量要加上final修飾符,否則會提示:
Cannot refer to a non-final variable dog inside an inner class defined in a different method
加上final后蛀蜜,編譯通過刻两,程序正常運行。
但是滴某,這里為什么一定要加final呢磅摹?
學Java的時候,我們都聽過這句話(或者類似的話):
匿名內部類來自外部閉包環(huán)境的自由變量必須是final的
那時候一聽就懵逼了霎奢,什么是閉包户誓?什么叫自由變量?最后不求甚解幕侠,反正以后遇到這種情況就加個final就好了帝美。
顯然,這種對待知識的態(tài)度是不好的晤硕,必須“知其然并知其所以然”悼潭,最近就這個問題做了一番研究,希望通過比較通俗易懂的言語分享給大家舞箍。
我們學框架舰褪、看源碼、學設計模式疏橄、學并發(fā)編程占拍、學緩存,甚至了解大型網(wǎng)站架構設計捎迫,可回過頭來看看一些非常簡單的Java代碼晃酒,卻發(fā)現(xiàn)還有那么幾個旮旯,是自己沒完全看透的立砸。
匿名內部類的真相
既然不加final無法通過編譯掖疮,那么就加上final,成功編譯后颗祝,查看class文件反編譯出來的結果浊闪。
在class目錄下面恼布,我們會看到有兩個class文件:AnonymousDemo1.class和AnonymousDemo1$1.class,其中搁宾,帶美元符號$的那個class折汞,就是我們代碼里面的那個匿名內部類。接下來盖腿,使用 jd-gui 反編譯一下爽待,查看這個匿名內部類:
class AnonymousDemo1$1
implements Runnable
{
AnonymousDemo1$1(AnonymousDemo1 paramAnonymousDemo1, Dog paramDog) {}
public void run()
{
while (this.val$dog.getAge() < 100)
{
this.val$dog.happyBirthday();
System.out.println(this.val$dog.getAge());
}
}
}
這代碼看著不合常理:
- 首先,構造函數(shù)里傳入了兩個變量翩腐,一個是AnonymousDemo1類型的鸟款,另一個是Dog類型,但是方法體卻是空的茂卦,看來是反編譯時遺漏了何什;
- 再者,run方法里this.val$dog這個成員變量并沒有在類中定義等龙,看樣子也是在反編譯的過程中遺漏掉了处渣。
既然 jd-gui 的反編譯無法完整的展示編譯后的代碼,那就只能使用 javap 命令來反匯編了蛛砰,在命令行中執(zhí)行:
javap -c AnonymousDemo1$1.class
執(zhí)行完命令后罐栈,可以在控制臺看到一些匯編指令,這里主要看下內部類的構造函數(shù):
com.bridgeforyou.anonymous.AnonymousDemo1$1(com.bridgeforyou.anonymous.Anonymo
usDemo1, com.bridgeforyou.anonymous.Dog);
Code:
0: aload_0
1: aload_1
2: putfield #14 // Field this$0:Lcom/bridgeforyou/an
onymous/AnonymousDemo1;
5: aload_0
6: aload_2
7: putfield #16 // Field val$dog:Lcom/bridgeforyou/a
nonymous/Dog;
10: aload_0
11: invokespecial #18 // Method java/lang/Object."<init>":
()V
14: return
這段指令的重點在于第二個putfield指令泥畅,結合注釋荠诬,我們可以知道,構造器函數(shù)將傳入的dog變量賦值給了另一個變量位仁,現(xiàn)在浅妆,我們可以手動填補一下上面那段信息遺漏掉的反編譯后的代碼:
class AnonymousDemo1$1
implements Runnable
{
private Dog val$dog;
private AnonymousDemo1 myAnonymousDemo1;
AnonymousDemo1$1(AnonymousDemo1 paramAnonymousDemo1, Dog paramDog) {
this.myAnonymousDemo1 = paramAnonymousDemo1;
this.val$dog = paramDog;
}
public void run()
{
while (this.val$dog.getAge() < 100)
{
this.val$dog.happyBirthday();
System.out.println(this.val$dog.getAge());
}
}
}
至于外部類AnonymousDemo1,則是把dog變量傳遞給AnonymousDemo1$1的構造器障癌,然后創(chuàng)建一個內部類的實例罷了凌外,就像這樣:
public class AnonymousDemo1
{
public static void main(String[] args)
{
new AnonymousDemo1().play();
}
private void play()
{
final Dog dog = new Dog();
Runnable runnable = new AnonymousDemo1$1(this, dog);
new Thread(runnable).start();
}
}
關于Java匯編指令,可以參考 Java bytecode instruction listings
到這里我們已經(jīng)看清匿名內部類的全貌了涛浙,其實Java就是把外部類的一個變量拷貝給了內部類里面的另一個變量康辑。
我之前在 用畫小狗的方法來解釋Java值傳遞 這篇文章里提到過,Java里面的變量都不是對象轿亮,這個例子中疮薇,無論是內部類的val$dog變量,還是外部類的dog變量,他們都只是一個存儲著對象實例地址的變量而已我注,而由于做了拷貝按咒,這兩個變量指向的其實是同一只狗(對象)。
那么為什么Java會要求外部類的dog一定要加上final呢但骨?
一個被final修飾的變量:
- 如果這個變量是基本數(shù)據(jù)類型励七,那么它的值不能改變智袭;
- 如果這個變量是個指向對象的引用,那么它所指向的地址不能改變掠抬。
關于final吼野,維基百科說的非常清楚 final (Java) - Wikipedia
因此,這個例子中两波,假如我們不加上final瞳步,那么我可以在代碼后面加上這么一句dog = new Dog(); 就像下面這樣:
// ...
new Thread(runnable).start();
// do other thing below when dog's age is increasing
dog = new Dog();
這樣,外面的dog變量就指向另一只狗了腰奋,而內部類里的val$dog单起,還是指向原先那一只,就像這樣:
這樣做導致的結果就是內部類里的變量和外部環(huán)境的變量不同步劣坊,指向了不同的對象馏臭。
因此,編譯器才會要求我們給dog變量加上final讼稚,防止這種不同步情況的發(fā)生。
為什么要拷貝
現(xiàn)在我們知道了绕沈,是由于一個拷貝的動作锐想,使得內外兩個變量無法實時同步,其中一方修改乍狐,另外一方都無法同步修改赠摇,因此要加上final限制變量不能修改。
那么為什么要拷貝呢浅蚪,不拷貝不就沒那么多事了嗎藕帜?
這時候就得考慮一下Java虛擬機的運行時數(shù)據(jù)區(qū)域了,dog變量是位于方法內部的惜傲,因此dog是在虛擬機棧上洽故,也就意味著這個變量無法進行共享,匿名內部類也就無法直接訪問盗誊,因此只能通過值傳遞的方式时甚,傳遞到匿名內部類中。
那么有沒有不需要拷貝的情形呢哈踱?有的荒适,請繼續(xù)看。
一定要加final嗎
我們已經(jīng)理解了要加final背后的原因开镣,現(xiàn)在我把原來在函數(shù)內部的dog變量刀诬,往外提,“提拔”為類的成員變量邪财,就像這樣:
public class AnonymousDemo2
{
private Dog dog = new Dog();
public static void main(String args[])
{
new AnonymousDemo2().play();
}
private void play()
{
Runnable runnable = new Runnable()
{
public void run()
{
while (dog.getAge() < 100)
{
// 過生日陕壹,年齡加一
dog.happyBirthday();
// 打印年齡
System.out.println(dog.getAge());
}
}
};
new Thread(runnable).start();
// do other thing below when dog's age is increasing
// ....
}
}
這里的dog成了成員變量质欲,對應的在虛擬機里是在堆的位置,而且無論在這個類的哪個地方帐要,我們只需要通過 this.dog把敞,就可以獲得這個變量。因此榨惠,在創(chuàng)建內部類時奋早,無需進行拷貝,甚至都無需將這個dog傳遞給內部類赠橙。
通過反編譯耽装,可以看到這一次,內部類的構造函數(shù)只有一個參數(shù):
class AnonymousDemo2$1
implements Runnable
{
AnonymousDemo2$1(AnonymousDemo2 paramAnonymousDemo2) {}
public void run()
{
while (AnonymousDemo2.access$0(this.this$0).getAge() < 100)
{
AnonymousDemo2.access$0(this.this$0).happyBirthday();
System.out.println(AnonymousDemo2.access$0(this.this$0).getAge());
}
}
}
在run方法里期揪,是直接通過AnonymousDemo2類來獲取到dog這個對象的掉奄,結合javap反匯編出來的指令,我們同樣可以還原出代碼:
class AnonymousDemo2$1
implements Runnable
{
private AnonymousDemo2 myAnonymousDemo2;
AnonymousDemo2$1(AnonymousDemo2 paramAnonymousDemo2) {
this.myAnonymousDemo2 = paramAnonymousDemo2;
}
public void run()
{
while (this.myAnonymousDemo2.getAge() < 100)
{
this.myAnonymousDemo2.happyBirthday();
System.out.println(this.myAnonymousDemo2.getAge());
}
}
}
相比于demo1凤薛,demo2的dog變量具有"天然同步"的優(yōu)勢姓建,因此就無需拷貝,因而編譯器也就不要求加上final了缤苫。
回看那句經(jīng)典的話
上文提到了這句話 —— “匿名內部類來自外部閉包環(huán)境的自由變量必須是final的”速兔,一開始我不理解,所以看著很蒙圈活玲,現(xiàn)在再來回看一下:
首先涣狗,自由變量是什么?
一個函數(shù)的“自由變量”就是既不是函數(shù)參數(shù)也不是函數(shù)內部局部變量的變量舒憾,這種變量一般處于函數(shù)運行時的上下文镀钓,就像demo中的dog,有可能第一次運行時镀迂,這個dog指向的是age是10的狗丁溅,但是到了第二次運行時,就是age是11的狗了探遵。
然后唧瘾,外部閉包環(huán)境是什么?
外部環(huán)境如果持有內部函數(shù)所使用的自由變量别凤,就會對內部函數(shù)形成“閉包”饰序,demo1中,外部play方法中规哪,持有了內部類中的dog變量求豫,因此形成了閉包。
當然,demo2中蝠嘉,也可以理解為是一種閉包最疆,如果這樣理解,那么這句經(jīng)典的話就應該改為這樣更為準確:
匿名內部類來自外部閉包環(huán)境的自由變量必須是final的蚤告,除非自由變量來自類的成員變量努酸。
對比JavaScript的閉包
從上面我們也知道了,如果說Java匿名內部類時一種閉包的話杜恰,那么這是一種有點“殘缺”的閉包获诈,因為他要求外部環(huán)境持有的自由變量必須是final的。
而對于其他語言心褐,比如C#和JavaScript舔涎,是沒有這種要求的,而且內外部的變量可以自動同步逗爹,比如下面這段JavaScript代碼(運行時直接按F12亡嫌,在打開的瀏覽器調試窗口里,把代碼粘貼到Console頁簽掘而,回車就可以了):
function fn() {
var myVar = 42;
var lambdaFun = () => myVar;
console.log(lambdaFun()); // print 42
myVar++;
console.log(lambdaFun()); // print 43
}
fn();
這段代碼使用了lambda表達式(Java8也提供了挟冠,后面會介紹)創(chuàng)建了一個函數(shù),函數(shù)直接返回了myVar這個外部變量袍睡,在創(chuàng)建了這個函數(shù)之后知染,對myVar進行修改,可以看到函數(shù)內部的變量也同步修改了女蜈。
應該說,這種閉包色瘩,才是比較“正澄苯眩“和“完整”的閉包。
Java8之后的變動
在JDK1.8中居兆,也提供了lambda表達式覆山,使得我們可以對匿名內部類進行簡化,比如這段代碼:
int answer = 42;
Thread t = new Thread(new Runnable() {
public void run() {
System.out.println("The answer is: " + answer);
}
});
使用lambda表達式進行改造之后泥栖,就是這樣:
int answer = 42;
Thread t = new Thread(
() -> System.out.println("The answer is: " + answer)
);
值得注意的是簇宽,從JDK1.8開始,編譯器不要求自由變量一定要聲明為final吧享,如果這個變量在后面的使用中沒有發(fā)生變化魏割,就可以通過編譯,Java稱這種情況為“effectively final”钢颂。
上面那個例子就是“effectively final”钞它,因為answer變量在定義之后沒有變化,而下面這個例子,則無法通過編譯:
int answer = 42;
answer ++; // don't do this !
Thread t = new Thread(
() -> System.out.println("The answer is: " + answer)
);
花絮
在研究這個問題時遭垛,我在StackOverflow參考了這個問題:Cannot refer to a non-final variable inside an inner class defined in a different method
其中一個獲得最高點贊尼桶、同時也是被采納的回答,是這樣解釋的:
When the main() method returns, local variables (such as lastPrice and price) will be cleaned up from the stack, so they won't exist anymore after main() returns.
But the anonymous class object references these variables. Things would go horribly wrong if the anonymous class object tries to access the variables after they have been cleaned up.
By making lastPrice and price final, they are not really variables anymore, but constants. The compiler can then just replace the use of lastPrice and price in the anonymous class with the values of the constants (at compile time, of course), and you won't have the problem with accessing non-existent variables anymore.
大致的意思是:由于外部的變量會在方法結束后被銷毀锯仪,因此要將他們聲明為final常量泵督,這樣即使外部類的變量銷毀了,內部類還是可以使用庶喜。
這么淺顯小腊、無根無據(jù)的解釋居然也獲得了那么多贊,后來評論區(qū)有人指出了錯誤溃卡,回答者才在他的回答里加了一句:
edit - See the comments below - the following is not a correct explanation, as KeeperOfTheSoul points out.
可見溢豆,看待一個問題時,不能只從表面去解釋瘸羡,要解釋一個問題漩仙,必須弄清背后的原理。
參考內容
- Cannot refer to a non-final variable inside an inner class defined in a different method
- Why a non-final "local" variable cannot be used inside an inner class, and instead a non-final field of the enclosing class can?
- Captured variable in a loop in C#
- Java 8 Lambda Limitations: Closures - DZone Java
- Difference between final and effectively final
- final (Java) - Wikipedia
- java為什么匿名內部類的參數(shù)引用時final?
- Java bytecode instruction listings
- What are Free and Bound variables?